[读书笔记]程序的机器级表示
汇编指令
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(如下图中的%eax,并且对应的指令的suffix为l,比如movl、xorl)就把高位的四个bytes置为零。寄存器也可以存放包括浮点数的任何数据类型。
汇编指令的操作数有几种格式:
- immediate(立即数),代表了一个常量值,ATT格式中以
$
开头,比如$-577
,$0x1F
。 - register(寄存器),代表一个寄存器中存放的内容,下图中用R[ra]表示(将所有寄存器看作一个以寄存器标识为索引的数组R)。
- memory(内存),有几种不同的addressing mode(寻址模式),其中最为一般化的就是
M[Imm + R[rb]+ R[ri]*s]
这个形式(下表中最下面一行),其他的都是这种形式的特殊情况。M[Addr]表示内存中地址从Addr开始的跨越一个或多个bytes长度的一个值(将内存看作一个很大的元素为byte的数组M,以地址为索引)。在x86-64中,即使操作数是1个、2个或4个bytes,memory reference总是以quad word register(就是%rax什么的)表示,比如movw %dx, (%rax)
。
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。
算数和逻辑指令例如:INC D
、ADD S,D
、AND S,D
、SAL k,D
(左移)等等。
另外还有一个特殊的指令:leaq S,D
,其中leaq的lea是load effective address(但压根不会去引用内存,常常用来做一些算术运算),它的第一个操作数就像MOV指令中的“memory reference”,但并不会进行“dereference”(解引用),而是就是指这个寄存器中的内容,比如leaq (%rdx), %rax
只不过是把%rdx中的内容复制到%rax罢了,所以经常被用来做一些算术运算(no memory access occurs),比如leaq 7(%rdx,%rdx,4), %rax
就是把%rax设为5倍的%rdx + 7
。移位运算的shift ammount(第一个操作数)要么是立即数,要么只能是%cl这个单字节的寄存器。
x86-64还提供特殊的指令,就是能“不溢出”地计算两个数的乘积(结果以两个寄存器表示,%rdx中存放高位64位,%rax中存放低位64位),以及除法,如imulq S
、divq
等等,这些指令都只有一个操作数,另一个参数必须事先存放在%rax中。这里的乘法虽然也叫imulq(还有一个算术指令IMUL S,D
),但只有一个operand,所以不会造成混淆。
除了常规寄存器,还有一些寄存器,它们保存的都是些1位的condition code(条件码),这些condition code代表了最近的算术或逻辑运算造成的某些结果。最常用的condition code包括:
- CF: Carry Flag(进位标志)。 最近的操作使高位产生了进位,可用来检查无符号数操作的溢出。
- ZF: Zero Flag(零标志)。最近的操作得出的结果为0。
- SF: Sign Flag(符号标志)。最近的操作得到的结果为负数。
- OF: Overflow Flag(溢出标志)。最近的操作造成了有符号数的溢出。
所有的算术和逻辑指令都会影响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的组合来决定是否跳转,比如je Label
就是如果ZF等于1就跳转到Label,否则继续顺序执行。jmp是无条件跳转,除了jmp到某个label,还可以用jmp *%rax
表示以%rax中的内容为jump target(跳转目标,即目标指令的地址),也可以jpm *(%rax)
,表示jump target为这个寄存器中的地址的内存位置中的内容(星号后面跟的是一个内存位置)。
在汇编代码中,跳转目标都是用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格式的不同在于:
- 省略了size designation suffix(指令名中用于指定数据长度的后缀),比如用mov代替movq。
- 省略了寄存器名字前面的%符号,比如用rbx代替%rbx。
- 用不同的格式来描述内存中的位置,比如用QWORD PTR [rbx],而不是(%rbx)。
- 和ATT format有着相反的顺序,即:move a b表示”把b move 到a“。
Procedures(过程)
call指令类似jmp指令,可以是:call Label
,也可以是:call *Operand
,这条指令会把下一条指令的地址push到栈中,然后把PC设为call指令的operand代表的地址。ret指令从栈中pop出一个地址然后将PC设为此地址。call和ret指令完成了过程的调用和返回。
下图是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和寄存器约定的过程调用机制,也能很自然地支持函数的递归调用,和调用其他函数并没有什么区别。
数组的分配和访问
数组可以理解为内存中的一块L*N
字节的连续区域,这里的L为数组元素类型的大小,N为数组长度。而数组A(假设数组叫A)就代表指向数组第一个元素的指针,所以,数组中索引为i的个元素(在C中用A[i]表示)就存放在地址为A + L*i
的地方。在C中,为了方便,p+i
这个表达式的实际代表的值是xp + L*i
,p是一个T类型的指针,i是一个整数,xp是p的值,L是T类型的大小。
假设有一个多维数组int A[5][3];
,这里的A是一个长度为5的数组,数组的元素类型为“长度为3的整型数组”,它在内存中的存储顺序是这样的:
另外,关于多维数组中元素的访问,编译器可以做出一些优化,以简化对数组元素的地址的计算。
Structure和Union
假设有一个struct声明:
struct rec {
int i;
int j;
int a[2];
int *p;
};
那么这样的一个struct对象在内存中就是这样的:
类似数组,如果想访问struct对象中的某个字段,只要给这个“struct指针”(指向此struct开头)加上对应的offset(偏移量)即可,比如:假设变量r
的类型为struct rec *
,它的值为pr ,那么r->j
的地址就为pr + 4
,如果写成汇编就是:movl 4(%rdi), %eax
(r在%rdi中,指令将r->j
放在%eax中),所以说,这个偏移量完全是在编译时就已经决定的,然后“写死”在汇编代码中,而机器代码对于字段声明或是它们的类型一无所知。
union是C的另一个特性,主要用于某个对象有一些“互斥”的字段,然后可以节省空间,所以一个union的总的占用空间是其所有字段中所占空间最大的字段所占的空间。
数据对齐
为了简化硬件上的设计,通常有这样一个限制:CPU每一次的操作总是从内存中的一个地址为k的倍数的位置取得k个bytes。所以,如果我们能保证所有的原始类型(比如char,int这些)的数据的地址都是k的倍数,那么每一次只需要一次CPU操作就能得到这个数据的全部。所以,Intel推荐我们对数据进行对齐,从而提高性能(虽然在大多数情况下,即使不做对齐也能正常工作)。这个对齐的规定是:任何k个bytes的原始类型的数据的地址都必须是k的倍数,比如int类型的数据的地址就应是4的倍数(另外,某些Intel和AMD处理器也规定大多数的函数的stack frame上的数据的地址需要是16的倍数 )。为了达到这个规定,通常会有一些空间上的浪费,比如这样一个struct:
struct S1 {
int i;
char c;
int j;
};
为了达到上述规定,可以在第二个字段c的后面补齐3个bytes(从而使j的地址满足要求),看起来就像这样:
指令.align 8
就表示:接下来的数据的保证会从一个8的倍数的地址开始。
Buffer Overflow攻击
由于C不会对数组的索引做检查,所以完全可以使用超过数组长度的索引值来访问那些不属于数组的内存空间,比如修改saved registers,或修改函数的返回地址等等。Buffer overflow攻击简单来说就是:某个函数接受一个用户输入的字符串,放进一个预先分配好的char数组中,但没有对用户输入的长度做任何检查,所以一旦超过了预先分配的长度,就会造成stack状态被破坏(比如覆盖返回地址,让程序跳转到一段恶意代码)。
通过编译器防御buffer overflow攻击的手段包括:
- 即使是同一个程序,每次运行它的时候,都先在stack上随机分配一定的空间(不能太小,否则很容易被暴力破解),这段空间没什么用,只是为了让每次的stack pointer都不那么一样,所以攻击者没那么容易猜到恶意代码实际所在的地址。
- 在local buffer分配之前,先插入一段“guard value”(一个随机值),在恢复saved registers和跳转到返回地址之前,先比较这个guard value是否被改变了,如果被改变了就报错。
- 可以指定内存中的哪些部分是可执行的代码,从而禁止程序运行某些非编译器生成的代码。
但最好的习惯还是应该在代码中对任何用户输入进行校验。
浮点数代码
到现在为止所介绍的指令其实都是用于整数的,有一套专门用于浮点数的操作和运算的指令和寄存器,类似已经介绍过的那些用于整数的指令,包括传送指令(类似MOV)、用于类型转换的指令、用于算术运算或位运算(通常可以用于实现“绝对值”、“相反数”这些)的指令、用于比较的指令。由于立即数只能是整数,所以代码中的“浮点数常量”在汇编代码中都会被转化为内存中的值。