x86-64 CPU有16个寄存器,每个寄存器都能存储一个64位(即8个bytes)的值,每个寄存器的名字都以%r开头,并且不同的寄存器有约定上的不同的用途。下图以%rax为例,这个寄存器专门用于存放返回值,其中的“子部分”%rax、%ax、%al也能作为指令的operand(操作数),但是他们分别能表示的数据长度各不相同(比如%al代表%rax中低位的8个bits),而且必须和mov指令的最后一个字母匹配,当指令的目标操作数的bytes不满8个bytes的时候,约定是:若目标操作数为1个byte或者2个bytes(如下图中的%ax和%al)则就保留剩余bytes不变;而如果是4个byte(如下图中的�x,并且对应的指令的suffix为l,比如movl、xorl)就把高位的四个bytes置为零。寄存器也可以存放包括浮点数的任何数据类型。汇编指令的操作数有几种格式:
MOV类指令就是把一个src(源)里的数值复制到一个指定的dst(目标位置),寄存器或者某个内存地址(根据寄存器的名称或者mov指令的最后一个字母来判断dst(目标)中的多少个bytes会被覆盖)。x86-64有一些规定,比如:src和dst不能同时是内存地址;movq的src只能是一个能代表32bit two‘s complement的立即数,然后被sign extend(就是用符号位(最高位)填充满所有高位)到64位后再复制到dst;而movabsq的src可以是任意的64位立即数,而dst只能是寄存器。 MOVZ和MOVS都是把“小的src”复制到“大的dst”,MOVZ是zero-extend(高位补零),而MOVS是sign extend,其中也有一些特殊规定。这两个指令可以很好地实现高级语言中的各种基本类型的cast(强制转换)。 【旁注】由于历史原因,Intel使用“word”来指16位的数据类型。像moveb,movew,movel和moveq最后一个字母表示operand(操作数)的大小,b就是一个字节,w是一个word,l是两个word(l是long的缩写,表示“long word”),q是四个word(quad word)。在浮点数的情况下,single precision是moves,double precision是movel(虽然和整型的movel名字相同,但是不会产生歧义,因为浮点数代码的上下文完全不一样)。 根据约定,我们把stack向下画,x86-64中最低的地址算stack的顶部。%rsp寄存器中存放的是stack指针,指向栈顶元素。push和pop指令如下,push需要指定src,pop需要指定dst。 算数和逻辑指令例如: 另外还有一个特殊的指令: x86-64还提供特殊的指令,就是能“不溢出”地计算两个数的乘积(结果以两个寄存器表示,%rdx中存放高位64位,%rax中存放低位64位),以及除法,如 除了常规寄存器,还有一些寄存器,它们保存的都是些1位的condition code(条件码),这些condition code代表了最近的算术或逻辑运算造成的某些结果。最常用的condition code包括:
所有的算术和逻辑指令都会影响condition code。另外,CMP指令和SUB类似,但是不会改变dst中的值,只会改变上面这些condition code。同理,TEST和AND类似,而且经常用在“和自己与”,因为和自己与还是自己,所以可以知道某个数是负数还是零。 SET指令是根据condition code的某种组合,然后把某个byte置为0或1(必定存在一种组合可以确定两个数的大小关系,所以SET指令可以理解为高级语言中的比较符号(大于、小于号什么的)),比如sete(e代表equal,也可以叫setz)指令。SET指令经常跟在CMP后面使用,从而确定两个数的大小关系。 这里重要的启示是,机器代码并不知道某个值是个signed还是unsigned,还得靠人来通过不同的指令来“告诉”计算机,比如setl是“signed <”(告诉计算机这里是个有符号数),而setb是“unsigned <”(告诉计算机这里是个无符号数)。 跳转指令和SET指令类似,也是根据某些condition code的组合来决定是否跳转,比如 在汇编代码中,跳转目标都是用L1或者L2这样的标签来表示。 assembler和linker都会以某种编码生成合适的代码来替换这些标签。有几种不同的编码,但最常用的叫做PC relative,也就是算出当前指令(跳转指令的下一条指令)的地址和跳转目标的差,作为跳转指令的操作数,然后等到CPU要执行的时候就会根据当前地址(program counter)和这个差算出jump target,这样就相当于是指令间的地址都是相对的(有正有负),不管动态加载到内存时候的具体地址是多少,都不需要改变跳转指令的jump target。 conditional move指令就是根据某个条件判断要不要“move”的mov指令,有时候可以用它来优化if分支,因为CPU会对指令进行pipelining(流水线处理),即使有jump,CPU也会猜一下然后继续不断装载指令,但万一猜错了if分支,那么已经装载的指令都要全部扔掉然后去装载另一个分支的指令,而用了conditional move指令优化过的代码则不会有这种风险,因为它会事先把两种可能的结果都算出来,然后根据条件,只取其中一个结果就是了。很明显,这种优化是有风险的,比如当算出任何一种结果有副作用或是很昂贵的时候。编译器必须自己权衡并作出决定。 while、for和switch都是用条件跳转指令来实现的。值得注意的是,switch的汇编实现可以利用一种叫jump table的数据结构来优化,这个jump table就是一个数组,里面的元素对应每个“case代码块”的起始地址,这种优化方法有点类似于计数排序,条件是“各个case的数”分布必须在一定范围内。所以只要根据“switch数”算出在jump table中对应的索引,然后就能直接得到jump target了(所以也就不必一次一次比较了)。 【旁注】汇编代码有两种格式: ATT格式(以AT&T公司命名)和Intel格式。以上的内容都是ATT格式。相比ATT格式,Intel格式的不同在于:
Procedures(过程)call指令类似jmp指令,可以是: 下图是P过程调用Q过程的示意图: 每一个过程在栈中都有一块属于自己的区域,叫stack frame(栈帧),注意栈是“向下”画的。图中每个stack frame的各个区域不是必须的,而是只有当需要时才会分配,当一个过程中的所有的局部变量用寄存器保存就足够了,并且不会再调用其他函数时,那么其实它压根就不需要stack frame。x86-64中,传递参数一般通过寄存器就足够,但如果参数大于6个,就只能依赖于栈,上图中P中的argument 7至argument n(以及Q中的Argument build area)就是用于分配第7至第n个参数的地方,Q可以通过stack栈顶指针加上一定的偏移量来访问这些参数。Local variables的分配也同理,但是Q只能访问P的argument build area和自己的local variables区域。在一个过程开始的时候,先让栈顶指针向栈顶移动一定长度,即分配第7至n个参数以及local variables,但是在return之前,为了回收这些分配的空间,还必须让栈顶指针向相反的方向移动同样的长度,这样以后再执行ret就可以保证pop出来的是正确的返回地址。所以,过程调用的汇编代码常常是将这些局部变量需要在stack上分配和回收的长度“写死”在代码中。 另外,关于寄存器还有一些约定。某些寄存器不能被callee(被调用者)改变,这些寄存器叫作callee-saved registers(由被调用者“保证”它们的值不变)。也就是说,当P调用Q时,可以放心地把某些变量存到callee-saved registers中,而不用担心Q会改变这些寄存器中的内容。当然,Q可以先把这些寄存器中的内容push到stack上,然后随便用这些寄存器,只要在返回之前,从stack上把原来的值pop回相应的callee-saved register中就行。P自己本身就很可能也是一个callee,所以在使用callee-saved registers存放变量前,也会先在stack上保存其中原来的值。另一类寄存器叫作caller-saved registers,它们可以被任何函数改变,所以当P调用Q之前,必须把用到的caller-saved registers中的内容先保存到stack(图中的saved registers区域)或callee-saved registers中,然后才能放心地去调用Q。 这样的利用stack和寄存器约定的过程调用机制,也能很自然地支持函数的递归调用,和调用其他函数并没有什么区别。 数组的分配和访问数组可以理解为内存中的一块 假设有一个多维数组 另外,关于多维数组中元素的访问,编译器可以做出一些优化,以简化对数组元素的地址的计算。 Structure和Union假设有一个struct声明:
那么这样的一个struct对象在内存中就是这样的: 类似数组,如果想访问struct对象中的某个字段,只要给这个“struct指针”(指向此struct开头)加上对应的offset(偏移量)即可,比如:假设变量 union是C的另一个特性,主要用于某个对象有一些“互斥”的字段,然后可以节省空间,所以一个union的总的占用空间是其所有字段中所占空间最大的字段所占的空间。 数据对齐为了简化硬件上的设计,通常有这样一个限制:CPU每一次的操作总是从内存中的一个地址为k的倍数的位置取得k个bytes。所以,如果我们能保证所有的原始类型(比如char,int这些)的数据的地址都是k的倍数,那么每一次只需要一次CPU操作就能得到这个数据的全部。所以,Intel推荐我们对数据进行对齐,从而提高性能(虽然在大多数情况下,即使不做对齐也能正常工作)。这个对齐的规定是:任何k个bytes的原始类型的数据的地址都必须是k的倍数,比如int类型的数据的地址就应是4的倍数(另外,某些Intel和AMD处理器也规定大多数的函数的stack frame上的数据的地址需要是16的倍数 )。为了达到这个规定,通常会有一些空间上的浪费,比如这样一个struct:
为了达到上述规定,可以在第二个字段c的后面补齐3个bytes(从而使j的地址满足要求),看起来就像这样: 指令 Buffer Overflow攻击由于C不会对数组的索引做检查,所以完全可以使用超过数组长度的索引值来访问那些不属于数组的内存空间,比如修改saved registers,或修改函数的返回地址等等。Buffer overflow攻击简单来说就是:某个函数接受一个用户输入的字符串,放进一个预先分配好的char数组中,但没有对用户输入的长度做任何检查,所以一旦超过了预先分配的长度,就会造成stack状态被破坏(比如覆盖返回地址,让程序跳转到一段恶意代码)。 通过编译器防御buffer overflow攻击的手段包括:
但最好的习惯还是应该在代码中对任何用户输入进行校验。 浮点数代码到现在为止所介绍的指令其实都是用于整数的,有一套专门用于浮点数的操作和运算的指令和寄存器,类似已经介绍过的那些用于整数的指令,包括传送指令(类似MOV)、用于类型转换的指令、用于算术运算或位运算(通常可以用于实现“绝对值”、“相反数”这些)的指令、用于比较的指令。由于立即数只能是整数,所以代码中的“浮点数常量”在汇编代码中都会被转化为内存中的值。 |
|