developer是什么意思

更新时间:2022-11-26 06:26:37 阅读: 评论:0


2022年11月26日发(作者:body language 课件)

C#基础之IL,轻松读懂中间代码IL转载

先说说学IL有什么⽤,有⼈可能觉得这玩意平常写代码⼜⽤不上,学了有个卵⽤。到底有没有卵⽤呢,暂且也不说什么学了可以看看⼀些语法糖的实现,或对理解更深⼀点这

些虚头巴脑的东西。其实IL本⾝逻辑很清楚,主要是把指令的意思搞明⽩就好办了。记指令只要记住⼏个规律就好,我把它们分为三类。

第⼀类:直观型

这⼀类的特点是⼀看名字就知道是⼲嘛的,不需要多讲,如下:

名称说明

Add将两个值相加并将结果推送到计算堆栈上。

Sub从其他值中减去⼀个值并将结果推送到计算堆栈上。

Div将两个值相除并将结果作为浮点(F类型)或商(int32类型)推送到计算堆栈上。

Mul将两个值相乘并将结果推送到计算堆栈上。

Rem将两个值相除并将余数推送到计算堆栈上。

Xor计算位于计算堆栈顶部的两个值的按位异或,并且将结果推送到计算堆栈上。

And计算两个值的按位"与"并将结果推送到计算堆栈上。

Or计算位于堆栈顶部的两个整数值的按位求补并将结果推送到计算堆栈上。

Not计算堆栈顶部整数值的按位求补并将结果作为相同的类型推送到计算堆栈上。

Dup复制计算堆栈上当前最顶端的值,然后将副本推送到计算堆栈上。

Neg对⼀个值执⾏求反并将结果推送到计算堆栈上。

Ret从当前⽅法返回,并将返回值(如果存在)从调⽤⽅的计算堆栈推送到被调⽤⽅的计算堆栈上。

Jmp退出当前⽅法并跳⾄指定⽅法。

NewobjNewObject创建⼀个值类型的新对象或新实例,并将对象引⽤推送到计算堆栈上。

NewarrNewArray将对新的从零开始的⼀维数组(其元素属于特定类型)的对象引⽤推送到计算堆栈上。

Nop如果修补操作码,则填充空间。尽管可能消耗处理周期,但未执⾏任何有意义的操作。Debug下的

Pop移除当前位于计算堆栈顶部的值。

InitobjInitObject将位于指定地址的值类型的每个字段初始化为空引⽤或适当的基元类型的0。

IsinstIsInstance测试对象引⽤是否为特定类的实例。

Sizeof将提供的值类型的⼤⼩(以字节为单位)推送到计算堆栈上。

Box将值类转换为对象引⽤。

Unbox将值类型的已装箱的表⽰形式转换为其未装箱的形式。

Castclass尝试将引⽤传递的对象转换为指定的类。

Switch实现跳转表。

Throw引发当前位于计算堆栈上的异常对象。

Call调⽤由传递的⽅法说明符指⽰的⽅法。

Calli通过调⽤约定描述的参数调⽤在计算堆栈上指⽰的⽅法(作为指向⼊⼝点的指针)。

Callvirt对对象调⽤后期绑定⽅法,并且将返回值推送到计算堆栈上。

强调⼀下,有三种call,⽤的场景不太⼀样:

Call:常⽤于调⽤编译时就确定的⽅法,可以直接去元数据⾥找⽅法,如静态函数,实例⽅法,也可以call虚⽅法,不过只是call这个类型本⾝的虚⽅法,和实例的⽅法

性质⼀样。另外,call不做null检测。

Calli:MSDN上讲是间接调⽤指针指向的函数,具体场景没见过,有知道的朋友望不吝赐教。

Callvirt:可以调⽤实例⽅法和虚⽅法,调⽤虚⽅法时以多态⽅式调⽤,不能调⽤静态⽅法。Callvirt调⽤时会做null检测,如果实例是null,会抛出

NullReferenceException,所以速度上⽐call慢点。

第⼆类:加载(ld)和存储(st)

我们知道,C#程序运⾏时会有线程栈把参数,局部变量放上来,另外还有个计算栈⽤来做函数⾥的计算。所以把值加载到计算栈上,算完后再把计算栈上的值存到线程栈上去,

这类指令专门⼲这些活。

⽐⽅说ldloc.0:

这个可以拆开来看,Ld打头可以理解为Load,也就是加载;loc可以理解为localvariable,也就是局部变量,后⾯的.0表⽰索引。连起来的意思就是把索引为0的局部变量加载到

计算栈上。对应的ldloc.1就是把索引为1的局部变量加载到计算栈上,以此类推。

知道了Ld的意思,下⾯这些指令也就很容易理解了。

ldstr=loadstring,

ldnull=loadnull,

ldobj=loadobject,

ldfld=loadfield,

ldflda=loadfieldaddress,

ldsfld=loadstaticfield,

ldsflda=loadstaticfieldaddress,

ldelem=loadelementinarray,

ldarg=loadargument,

ldc则表⽰加载数值,如ldc.i4.0,

关于后缀

.i[n]:[n]表⽰字节数,1个字节是8位,所以是8*n的int,⽐如i1,i2,i4,i8,i1就是int8(byte),i2是int16(short),i4是int32(int),i8是int64(long)。

相似的还有.u1.u2.u4.u8分别表⽰unsignedint8(byte),unsignedint16(short),unsignedint32(int),unsignedint64(long);

.R4,.R8表⽰的是float和double。

.ovf(overflow)则表⽰会进⾏溢出检查,溢出时会抛出异常;

.un(unsigned)表⽰⽆符号数;

.ref(reference)表⽰引⽤;

.s(short)表⽰短格式,⽐如说正常的是⽤int32,加了.s的话就是⽤int8;

.[n]⽐如.1,.2等,如果跟在i[n]后⾯则表⽰数值,其他都表⽰索引。如ldc.i4.1就是加载数值1到计算栈上,再如ldarg.0就是加载第⼀个参数到计算栈上。

ldarg要特别注意⼀个问题:如果是实例⽅法的话ldarg.0加载的是本⾝,也就是this,ldarg.1加载的才是函数的第⼀个参数;如果是静态函数,ldarg.0就是第⼀个参数。

与ld对应的就是st,可以理解为store,意思是把值从计算栈上存到变量中去,ld相关的指令很多都有st对应的,⽐如stloc,starg,stelem等,就不多说了。

第三类:⽐较指令,⽐较⼤⼩或判断bool值

有⼀部分是⽐较之后跳转的,代码⾥的if就会产⽣这些指令,符合条件则跳转执⾏另⼀些代码:

以b开头:beq,bge,bgt,ble,blt,bne

先把b去掉看看:

eq:equivalentwith,==

ge:greaterthanorequivalentwith,>=

gt:greaterthan,>

le:lessthanorequivalentwith,<=

lt:lessthan,<

ne:notequivalentwith,!=

这样是不是很好理解了,beqIL_0005就是计算栈上两个值相等的话就跳转到IL_0005,bleIL_0023是第⼀个值⼩于或等于第⼆个值就跳转到IL_0023。

以br(break)开头:br,brfal,brtrue,

br是⽆条件跳转;

brfal表⽰计算栈上的值为fal/null/0时发⽣跳转;

brtrue表⽰计算栈上的值为true/⾮空/⾮0时发⽣跳转

还有⼀部分是c开头,算bool值的,和前⾯b开头的有点像:

ceq⽐较两个值,相等则将1(true)推到栈上,否则就把0(fal)推到栈上

cgt⽐较两个值,第⼀个⼤于第⼆个则将1(true)推到栈上,否则就把0(fal)推到栈上

clt⽐较两个值,第⼀个⼩于第⼆个则将1(true)推到栈上,否则就把0(fal)推到栈上

以上就是三类常⽤的,把这些搞明⽩了,IL指令也就理解得七七⼋⼋了。就像看⽂章⼀样,认识⼤部分字后基本就不影响阅读了,不认识的猜下再查下,下次再看到也就认得

了。

例⼦

下⾯看个例⼦,随⼿写段简单的代码,是否合乎逻辑暂不考虑,主要是看IL:

源代码:

1usingSystem;

2

3namespaceILLearn

4{

5classProgram

6{

7constintWEIGHT=60;

8

9staticvoidMain(string[]args)

10{

11varheight=170;

12

13Peoplepeople=newDeveloper("brook");

14

15varvocation=ation();

16

17varhealthStatus=thyWeight(height,WEIGHT)?"healthy":"nothealthy";

18

ine($"{vocation}is{healthStatus}");

20

ne();

22}

23}

24

25abstractclassPeople

26{

27publicstringName{get;t;}

28

29publicabstractstringGetVocation();

30

31publicstaticboolIsHealthyWeight(intheight,intweight)

32{

33varhealthyWeight=(height-80)*0.7;

34returnweight<=healthyWeight*1.1&&weight>=healthyWeight*0.9;//标准体重是(⾝⾼-80)*0.7,区间在10%内都是正常范围

35}

36}

37

38classDeveloper:People

39{

40publicDeveloper(stringname)

41{

42Name=name;

43}

44

45publicoverridestringGetVocation()

46{

47return"Developer";

48}

49}

50}

在命令⾏⾥输⼊:csc/debug-/optimize+/out:

打开IL查看⼯具:C:ProgramFiles(x86),不同版本可能⽬录不太⼀样。打开刚编译的⽂件,如

下:

双击节点就可以查看IL,如:

Developer的构造函数:

publichidebysigspecialnamertspecialname

(stringname)cilmanaged

3{

4//代码⼤⼩14(0xe)

ck8

6IL_0000:ldarg.0//加载第1个参数,因为是实例,⽽实例的第1个参数始终是this

7IL_0001:::.ctor()//调⽤基类People的构造函数,⽽People也会调⽤Object的构造函数

8IL_0006:ldarg.0//加载this

9IL_0007:ldarg.1//加载第⼆个参数也就是name

10IL_0008:::t_Name(string)//调⽤this的t_Name,t_Name这个函数是编译时为属性⽣成的

11IL_000d:ret//return

12}//endofmethodDeveloper::.ctor

Developer的GetVocation:

publichidebysigvirtualinstancestring//虚函数

2GetVocation()cilmanaged

3{

4//代码⼤⼩6(0x6)

ck8//最⼤计算栈,默认是8

6IL_0000:ldstr"Developer"//加载string"Developer"

7IL_0005:ret//return

8}//endofmethodDeveloper::GetVocation

People的IsHealthyWeight:

publichidebysigstaticboolIsHealthyWeight(int32height,//静态函数

2int32weight)cilmanaged

3{

4//代码⼤⼩52(0x34)

ck3//最⼤计算栈⼤⼩

init([0]float64healthyWeight)//局部变量

7IL_0000:ldarg.0//加载第1个参数,因为是静态函数,所以第1个参数就是height

8IL_0001:ldc.i4.s80//ldc加载数值,加载80

9IL_0003:sub//做减法,也就是height-80,把结果放到计算栈上,前⾯两个已经移除了

10IL_0004:conv.r8//转换成double,因为下⾯计算⽤到了double,所以要先转换

11IL_0005:ldc.r80.69999999999999996//加载double数值0.7,为什么是0.69999999999999996呢,⼆进制存不了0.7,只能找个最相近的数

12IL_000e:mul//计算栈上的两个相乘,也就是(height-80)*0.7

13IL_000f:stloc.0//存到索引为0的局部变量(healthyWeight)

14IL_0010:ldarg.1//加载第1个参数weight

15IL_0011:conv.r8//转换成double

16IL_0012:ldloc.0//加载索引为0的局部变量(healthyWeight)

17IL_0013:ldc.r81.1001//加载double数值1.1,看IL_0010到IL_0013,加载了3次,这个函数最多也是加载3次,所以maxstack为3

18IL_001c:mul//计算栈上的两个相乘,也就是healthyWeight*1.1,这时计算栈上还有两个,第⼀个是weight,第⼆个就是这个计算结果

19IL_001d:_0032//⽐较这两个值,第⼀个⼤于第⼆个就跳转到IL_0032,因为第⼀个⼤于第⼆个表⽰第⼀个条件weight<=healthyWeight*1.1就是fal,也操作符是&&,后⾯没必要再算,直接return0

20IL_001f:ldarg.1//加载第1个参数weight

21IL_0020:conv.r8//转换成double

22IL_0021:ldloc.0//加载索引为0的局部变量(healthyWeight)

23IL_0022:ldc.r80.90002//加载double数值0.9

24IL_002b:mul//计算栈上的两个相乘,也就是healthyWeight*0.9,这时计算栈上还有两个,第⼀个是weight,第⼆个就是这个计算结果

25IL_002c://⽐较⼤⼩,第⼀个⼩于第⼆个则把1放上去,否则放0上去

26IL_002e:ldc.i4.0//加载数值0

27IL_002f:ceq//⽐较⼤⼩,相等则把1放上去,否则放0上去

28IL_0031:ret//return栈顶的数,为什么没⽤.s,因为IL_0033返回的是fal

29IL_0032:ldc.i4.0//加载数值0

30IL_0033:ret//return栈顶的数

31}//endofmethodPeople::IsHealthyWeight

主函数Main:

privatehidebysigstaticvoidMain(string[]args)cilmanaged

2{

oint//这是⼊⼝

4//代码⼤⼩67(0x43)

ck3//⼤⼩为3的计算栈

init(stringV_0,

7stringV_1)//两个string类型的局部变量,本来还有个people的局部变量,被relea⽅式优化掉了,因为只是调⽤了people的GetVocation,后⾯没⽤,所以可以不存

8IL_0000:ldc.i40xaa//加载int型170

9IL_0005:ldstr"brook"//加载string"brook"

10IL_000a:per::.ctor(string)//new⼀个Developer并把栈上的brook给构造函数

11IL_000f:::GetVocation()//调⽤GetVocation

12IL_0014:stloc.0//把上⾯计算的结果存到第1个局部变量中,也就是V_0

13IL_0015:ldc.i4.s60//加载int型60

14IL_0017:::IsHealthyWeight(int32,//调⽤IsHealthyWeight,因为是静态函数,所以⽤call

15int32)

16IL_001c:_0025//如果上⾯返回true的话就跳转到IL_0025

17IL_001e:ldstr"nothealthy"//加载string"nothealthy"

18IL_0023:_002a//跳转到IL_002a

19IL_0025:ldstr"healthy"//加载string"healthy"

20IL_002a:stloc.1//把结果存到第2个局部变量中,也就是V_1,IL_0017到IL_002a这⼏个指令加在⼀起⽤来计算三元表达式

21IL_002b:ldstr"{0}is{1}"//加载string"{0}is{1}"

22IL_0030:ldloc.0//加载第1个局部变量

23IL_0031:ldloc.1//加载第2个局部变量

24IL_0032:callstring[mscorlib]::Format(string,//调⽤,这⾥也可以看到C#6.0的语法糖$"{vocation}is{healthStatus}",编译后的结果和以前的⽤法⼀样

25object,

26object)

27IL_0037:callvoid[mscorlib]e::WriteLine(string)//调⽤WriteLine

28IL_003c:callstring[mscorlib]e::ReadLine()//调⽤ReadLine

29IL_0041:pop

30IL_0042:ret

31}//endofmethodProgram::Main

很简单吧,当然,这个例⼦也很简单,没有事件,没有委托,也没有async/await之类,这些有兴趣的可以写代码跟⼀下,这⼏种都会在编译时插⼊也许你不知道的代码。

就这么简单学⼀下,应该差不多有底⽓和⾯试官吹吹⽜逼了。

1.实例解析IL

作为C#程序员,IL的作⽤不⾔⽽喻,⾸先来看⼀个⾮常简单的程序和它的IL解释图,通过这个程序的IL指令来简单的了解常见的IL指令是什么意思。

classProgram

{

staticvoidMain(string[]args)

{

inti=2;

stringstr="C#";

ine("hello"+str);

}

}

classProgram

{

staticvoidMain(string[]args)

{

inti=2;

stringstr="C#";

ine("hello"+str);

}

}

接下来要明确⼀个概念,运⾏时任何有意义的操作都是在堆栈上完成的,⽽不是直接操作寄存器。这就为跨平台打下了基础,通过设计不同的编译器编译相同的IL代码

来实现跨平台。对于堆栈我们的操作⽆⾮就是压栈和出栈,在IL中压栈通常以ld开头,出栈则以st开头。知道这个后再看上⾯的指令感觉⼀下⼦就豁然开朗了,接下来继续学习的

步伐,下⾯的表格是对于⼀些常见ld指令。st指令则是将ld指令换成st,功能有压栈变为出栈,有时候会看到在st或ld后加.s这表⽰只取⼀个字节。再来看看流程控制,知道压出栈

和流程控制后,基本上看出IL的⼤概意思那就冒闷踢啦。流程控制主要就是循环和分⽀,下⾯我写了个有循环和分⽀的⼩程序。其中我们⽤到了加法和⽐较运算,为此得在这⾥

介绍最基本的三种运算:算术运算(add、sub、mul乘法、div、rem求余);⽐较运算(cgt⼤于、clt⼩于、ceq等于);位运算(not、and、or、xor异或、左移shl、右移shr)。要注意

在⽐较运算中,当执⾏完指令后会直接将结果1或0压栈,这个过程是⾃动完成的。对于流程控制,主要是br、brture和brfal这3条指令,其中br是直接进⾏跳转,brture和brture

则是进⾏判断再进⾏跳转。

ldarg加载成员的参数,如上⾯的ldarg.0

ldarga装载参数的地址,注意⼀般加个a表⽰取地址

ldc将数字常量压栈,如上⾯的ldc.i4.2

ldstr将字符串的引⽤压栈

ldloc/ldlocaldloc将⼀个局部变量压栈,加a表⽰将这个局部变量的地址压栈

Ldelem表⽰将数组元素压栈

ldlen将数组长度压栈

ldind将地址压栈,以地址来访问或操作数据内

classProgram

{

staticvoidMain(string[]args)

{

intcount=2;

stringstrName="C#";

if(strName=="C#")

{

for(inti=0;i

ine("helloC#");

}

el

ine("haha");

}

}

classProgram

{

staticvoidMain(string[]args)

{

intcount=2;

stringstrName="C#";

if(strName=="C#")

{

for(inti=0;i

ine("helloC#");

}

el

ine("haha");

}

}

2.⾯向对象的IL

有了前⾯的基础后,基本上看⼀般的IL代码不会那么⽅了。如果我们在程序中声明⼀个类并创建对象,则在IL中可以看到newobj、class、instance、static等关键字。看IL指

令会发现外部是类,类⾥⾯有⽅法,虽然⽅法⾥⾯是指令不过这和C#代码的结构是很相似的。从上⾯的这些现象可以很明显的感受到IL并不是简单的指令,它是⾯向对象的。当

我们在C#中使⽤new创建⼀个对象时则在IL中对应的是newobj,另外还有值类型也是可以通过new来创建的,不过在IL中它对应的则是initobj。newobj⽤来创建⼀个对象,⾸先会

分配这个对象所需的内存,接着初始化对象附加成员同步索引块和类型对象指针然后再执⾏构造函数进⾏初始化并返回对象引⽤。initobj则是完成栈上已经分配好的内存的初始

化⼯作,将值类型置0引⽤类型置null即可。另外string是引⽤类型,从上⾯的例⼦可以看到⼀般是使⽤ldstr来将元数据中的字符串引⽤加载到栈中⽽不是newobj。但是如果在代

码中创建string变量不是直接赋值⽽是使⽤new关键字来得到string对象,那么在IL中将会看到newobj指令。当创建⼀维零基数组时还会看到newarr指令,它会创建数组并将⾸地

址压栈。不过如果数组不是⼀维零基数组的话仍将还是会看到我们熟悉的newobj。

既然是⾯向对象的,那么继承中的虚⽅法或抽象⽅法在IL中肯定会有相应的指令去完成⽅法的调⽤。调⽤⽅法主要是call、callvirt、calli,call主要⽤来调⽤静态⽅法,callvirt

则⽤来调⽤普通⽅法和需要运⾏时绑定的⽅法(也就是⽤instance标记的实例⽅法),calli是通过函数指针来进⾏调⽤的。不过也存在特殊情况,那就是call去调⽤虚⽅法,⽐如在

密封类中的虚⽅法因为⼀定不可能会被重写因此使⽤call可提⾼性能。为什么会提⾼性能呢?不知道你是否还记得创建⼀个对象去调⽤这个对象的⽅法时,我们经常会判断这个

对象是否为null,如果这个对象为null时去调⽤⽅法则会报错。之所以出现这种情况是因为callvirt在调⽤⽅法时会进⾏类型检测,此外判断是否有⼦类⽅法覆盖的情况从⽽动态绑

定⽅法,⽽采⽤call则直接去调⽤了。另外当调⽤基类的虚⽅法时,⽐如调⽤ng⽅法就是采⽤call⽅法,如果采⽤callvirt的话因为有可能要查看⼦类(⼀直查看到最后

⼀个继承⽗类的⼦类)是否有重写⽅法,从⽽降低了性能。不过说到底call⽤来调⽤静态⽅法,⽽callvirt调⽤与对象关联的动态⽅法的核⼼思想是可以肯定的,那些采⽤call的特殊

情况都是因为在这种情况下根本不需要动态绑定⽅法⽽是可以直接使⽤的。calli的意思就是拿到⼀个指向函数的引⽤,通过这个引⽤去调⽤函数,不过在我的学习中没有使⽤到

这个,这个具体是如何拿到引⽤的我也不清楚,感兴趣者请⾃⾏百度。

的⾓⾊

⼤家都知道C#代码编译后就会⽣成元数据和IL,可是我们常见的exe这样的程序集是如何⽣成的呢,它与IL是什么关系呢?⾸先有⼀点是可以肯定的,那就是程序集中肯定

会包含元数据和IL,因为这2样东西是程序集中的核⼼。下⾯是⼀个描述程序集和内部组成图,从图中可以看出⼀个程序集是有多个托管模块组成的,⼀个模块可以理解为⼀个类

或者多个类⼀起编译后⽣成的程序集。程序集清单指的是描述程序集的相关信息,PE⽂件头描述PE⽂件的⽂件类型、创建时间等。CLR头描述CLR版本、CPU信息等,它告诉

系统这是⼀个程序集。然后最主要的就是每个托管模块中的元数据和IL了。元数据⽤来描述类、⽅法、参数、属性等数据,中每个模块包含44个元数据表,主要包括

定义表、引⽤表、指针表和堆。定义表包括类定义表、⽅法表等,引⽤表描述引⽤到类型或⽅法之间的映射记录,指针表⾥存放着⽅法指针、参数指针等。可以看到元数据表就

相当于⼀个数据库,多张表之间有类似于主外键之间的关系。

由前⾯的知识可以总结出IL是独⽴于CPU且⾯向对象的指令集。平台将其之上的语⾔全都编译成符合CLS(公共语⾔规范)的IL指令集,接着再由不同的编译器翻译成本地代

码,⽐如我们常见的JIT编译器,如果在Mac上运⾏C#可通过Mac上的特定编译器来将IL翻译成Mac系统能够执⾏的机器码。也就是说IL正如它的名字⼀样是作为⼀种中间语⾔来

执⾏动态程序,⽐如我们调⽤⼀个⽅法表中的⽅法,这个⽅法会指向⼀个触发JIT编译器地址和⽅法对应的IL地址,于是JIT编译器便将这个⽅法指向的IL编译成本地代码。⽣成本

地代码后这个⽅法将会有⼀条引⽤指向本地代码⾸地址,这样下次调⽤这个⽅法的时候将直接执⾏指向的本地代码。

结束

IL其实不难,有没有⽤则仁者见仁,智者见智,有兴趣就学⼀下,也花不了多少时间,确实也没必要学多深,是吧。

当然,也是要有耐⼼的,复杂的IL看起来还真是挺头痛。好在有⼯具ILSpy,可以在option⾥选择部分不反编译来看会⽐较简单些。

最后介绍两个⼯具:

.NetReflector可以把⽤户⾃⼰编写的IL指令转化为正常代码,⼤家可以⾃⼰下载安装;

IL查看⼯具可以把正常代码转化为IL指令,vs2010中路径为C:ProgramFiles(x86),不同版本⽬录可能不太⼀

样。

有了这两个⼯具当我们想⽤IL指令实现某⼀功能但不会写时,可以先⽤正常代码把功能写出来,在IL查看⼯具中查看IL代码是什么样的,然后⾃⼰再根据转化的IL代码逻辑使⽤IL

指令实现想要的功能。

本文发布于:2022-11-26 06:26:37,感谢您对本站的认可!

本文链接:http://www.wtabcd.cn/fanwen/fan/90/23412.html

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。

相关文章
留言与评论(共有 0 条评论)
   
验证码:
Copyright ©2019-2022 Comsenz Inc.Powered by © 专利检索| 网站地图