3.1 程序编码
1.计算机系统使用了多种不同形式的抽象,对于机器级编程来说,两种抽象尤为重要:
- 指令集体系结构(ISA):定义了处理器状态、指令的格式,以及每条指令对状态的影响
- 机器级程序使用的存储器地址是虚拟地址:提供的存储器模型看上去是一个非常大的字节数组
2.反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有区别。反汇编省略了指令结尾的q,给call和ret指令添加了q后缀。
3.可执行程序反汇编和对.c反汇编产生的代码有差别。对于可执行文件的反汇编,链接器将代码的地址移到了一段不同的地址范围,链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。
3.2 数据格式
GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小、。后缀l可以表示4字节整数和8字节双精度浮点数,但是并没有歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
3.3 访问信息
1.x86-64的CPU包含一组16个存储64位值的通用目的寄存器,用来存储整数数据和指针。
2.不同操作数可能被分为三种类型,分别为立即数(表示常数)、寄存器(表示某个寄存器的内容)、内存引用(根据计算出来的地址访问某个内存位置)。
3.传送指令两个操作数不能都指向内存位置。MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是movl指令以寄存器作为目的时,会把寄存器的高位4字节设置为0。movq指令只能以表示为32位补码数字的立即数作为源操作数,然后把这个值符号扩展得到64位的值,放到目的位置。
4.MOVZ类中的指令把目的中剩余的字节填充为0,MOVS类中的指令通过符号扩展来填充,把源操作的最高位进行复制。它们均以寄存器或内存地址作为源,以寄存器作为目的。
把4字节源值零扩展到8字节逻辑上应该是movzlq,但并没有这样的指令。可以使用movl来实现(movl指令会把寄存器的高位4字节设置为0)。
3.4 算术和逻辑操作:
如果寄存器%eax的值为x,那么指令leal 3(%edx, %edx, 2),%eax
将设置%eax的值为2x+3。
移位量可以是一个立即数,或者放在单字节寄存器%cl中。左移指令有SAL和SHL,两者效果一样,都是将右边填上0,而右移指令不同,SAR执行算术移位(填上符号位),而SHR执行逻辑移位(填上0)。
无符号数乘法(mulq)和补码乘法(imulq)要求一个参数必须在%rax中,另一个作为指令的源操作数给出。乘积存放在%rdx(高64位)和%rax(低64位);有符号除法idivl 将寄存器 %rdx(高32位)和 %rax(低32位)中的64位数作为被除数,而除数作为指令的操作数给出。指令将商存储在%rax中,将余数存储于%rdx中。
3.5 控制
1.条件码寄存器描述了最近算术或逻辑运算的属性,可以检测这些寄存器来执行条件分支指令:
-
CF:进位标志。可用来检查无符号操作的溢出。如:(unsigned)t < (unsigned)
-
ZF:零标志。如:(t == 0)
-
SF:符号标志。如:(t < 0)
-
OF:溢出标志,最近的操作导致了补码溢出。如:(a<0==b<0)&&(t<0!=a<0)
2.leaq 指令不会设置条件码,除过前面提到的指令外,CMP(和SUB行为一样)和TEST(和ADD行为一样)指令会设置条件码,但不改变任何其他寄存器。testq %rax %rax
用来检查 %rax 是零、正数还是负数。
3.条件码通常不会直接读取,通常使用的方法有三种:
- 可以根据条件码的某种组合,将一个字节设置为0或者1。
- 可以条件跳转到程序的某个其他部分。
- 可以有条件的传送数据
SET指令时条件码的组合,执行比较指令,根据计算t=a-b设置条件码。有符号比较测试基于SF、OF和ZF的组合,无符号比较测试基于CF和ZF。
4.jump 指令有三种跳转方式:直接跳转、间接跳转(‘*’后跟一个操作数指示符)、其他条件跳转(根据条件码的某个组合,或者跳转,或者继续执行代码序列中的下一条指令)。
常用的PC相对的对于跳转指令的编码会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。
5.汇编中没有do-while、while和for相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。大多数汇编器中都要先将其他形式的循环转换成do-while格式。
do-while的通用形式可以翻译成如下所示的条件和goto语句:
loop:
body-statement
t=test-expr;
if(t)
goto loop;
while循环第一种翻译方式跳转到中间:
goto test;
loop:
body-statement
test:
t=test-expr;
if(t)
goto loop;
第二种翻译方式为首先用条件分支,如果初始条件不成立就跳过循环,转化为do-while循环:
t=test-expr;
if(!t)
goto done;
loop:
body-statement
t=test-expr;
if(t)
goto loop;
done:
for循环可以很容易转换成while循环,进而转换成do-while形式:
init-expr;
t=test-expr;
if(!t)
goto done;
loop:
body-statement
update-expr;
t=test-expr;
if(t)
goto loop;
done:
switch语句的跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。
3.6 过程
1.过程提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。过程机制的构建需要实现传递控制、传递数据、分配和释放内存。
2.过程P可以传递最多6个整数值,如果Q需要更多参数,P可以在调用Q前在自己的栈帧里存储好这些参数。寄存器最多传输6个小于等于64位的数据,并通过%rax返回数据。如果一个函数有大于6个整型参数,超出6个的部分就通过保存在调用者的栈帧来传递。
3.%rbx、%rbp和%r12~%15被调用者保存,在使用前被调用者要把这里面的值保存好,保证其值在返回时和调用时是一样的(这里就像有我有一辆豪车,可以把车子借给朋友使用,但是一定要把钥匙保存好,用完了之后还回来),这让我想到了之前看过的汇编代码在被调用函数的第一步都是 push %ebp.
4.所有其他寄存器,除了%rsp为调用者保存,意味着任何函数都能修改它们,则在调用前首先保存好这个数据是调用者的责任。(这里的调用者就像很有票子的王健林一样,儿子王思聪可以无偿的使用王健林的票子)
参考《深入理解计算机系统》| 程序的机器级表示。
5.递归的调用其实与其他函数的调用是一样的,因为每个过程调用在栈中都有私有的空间,多个未完成调用的局部变量不会相互影响。
3.7 数据分配和访问
1.设 xA 表示起始位置,则访问数组元素 A[i] 的位置在 xA+ L*i,L为数据类型的大小(单位为字节)。数组元素的访问一般借助存储器引用指令。如计算 int 型的 E[i]: E 的地址存放在 %rdx 中,而 i 存放在 %rcx 中。movl (%rdx,%rcx,4),%eax
表示计算地址 xE+4i,并读取这个存储器位置的值,将结果放到 %eax 中。
2.如果 P 是一个执行类型 T 的数据的指针,P 的值为 xP,那么表达式 P+i 的值为 xP + L*i,L 是数据类型T的大小。假设整型数组 E 的起始地址和整数索引 i 分别存放在 %rdx 和 %rcx 中,下面是一些与 E 有关的表达式,可以明显看出 leal 和 movl 的区别(前者产生地址,后者引用内存):
3.数组的嵌套,也就是数组的数组。对于数组 int A[5][3],可以将 A 看成是一个有 5 个元素的数组,而每个元素都是 3 个 int 类型的数组。计算D[R][C](int 型)的地址:
&D[i][j] = xD + L(C * i + j)
由于每组有 C 个数据,所以跳过一组就要乘以C,跳过I组就 C*i 个,再加上偏移的 j 就是所求地址。
3.8 异质的数据结构
1.结构:所有的组成部分在存储器中连续存放,指向结构的指针指向结构的第一个字节。
2.联合:允许以多种类型来引用一个对象,总大小等于它最大字段的大小,而指向一个联合的指针,引用的是数据结构的起始位置。
3.x86-64系统对齐要求为:对于任何需要K字节的标量数据类型的起始地址必须是K的倍数。汇编中.align 8
要求后面的数据起始位置是8的倍数。结构体的对齐除了要满足每个字段的对齐要求,还需要考虑整体的结构满足怎样的对齐要求。
如:
struct test {
int i;
int j;
char c;
};
我们能保证起始地址4字节对齐要求,但struct s2 d[4]
就不能满足 d 的每个元素的对齐要求,因为这些元素的地址分别为xd,xd+9,xd+18和xd+27,所以为s2分配12个字节。
3.9 在机器级程序中将控制与数据结合起来
1.void * 表示通用指针,malloc函数返回一个通用指针,然后转换成一个有类型的指针。
2.指针从一个类型转为另外一个类型,只是伸缩因子变化,不改变它的值。如 p 是一个 char * 类型的指针,值为p,(int * )p + 7
计算为 p+28 ,而(int * )(p + 7)
计算为 p+7。
3.C对数组引用不做边界检查,同时局部变量和状态信息(寄存器值和返回指针等)都存放在栈中,这使得越界的数组写操作会破坏存储在栈中的状态信息。常见的状态破坏称为缓冲区溢出。
栈是向低地址增长的,数组缓冲区是向高地址增长的。故上图所示 buf[8] 在输入超过 8 个时就会覆盖栈上存储的某些信息。如果破坏了存储的返回地址,那么ret指令会使程序跳转到完全意想不到的地方(如跳转到攻击代码)。使用gets或strcpy、strcat、sprintf等能导致存储溢出的函数(不需要告诉它们目标缓冲区的大小就产生一个字节序列),都不是好的编程习惯。
4 对抗缓冲区溢出攻击的方法:
- 栈随机化:使得栈的位置在程序每次运行时都有变化。实现的方式是程序开始时,在栈上分配一段0--n字节之间的随机大小空间
- 栈破坏检测:在栈中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值。这个金丝雀值是在程序每次运行时随机产生的,因此,攻击者没有简单的办法知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者函数调用的某个操作改变了。如果是,那么程序异常终止
- 限制可执行代码区域:限制那些能够存放可执行代码的存储器区域
3.10 浮点代码
浮点数操作和整数操作很类似,指令命名上有区别,故此部分简述。
1.AVX浮点体系结构允许数据存储在16个YMM寄存器中,每个YMM寄存器是256位。对标量数据(单个数据)操作时,寄存器只保存浮点数,而且只使用低32位(float)或64位(double)。
2.浮点传送指令:
3.浮点转换指令:
4.%xmm0~%xmm7最多可以传递8个浮点参数,额外参数通过栈传递。%xmm0返回浮点数。XMM寄存器都是调用者保存,被吊用着不用保存就覆盖这些寄存器中的任意一个。当函数包含指针、整数和浮点数混合的参数时,指针和整数通过通用寄存器传递,浮点值通过XMM寄存器传递。
5.浮点运算操作(第一个源操作数S1可以是XMM寄存器或内存位置,第二个源操作数和目的操作数必须是XMM寄存器):
6.AVX浮点操作不能以立即数值作为操作数,编译器需要为所有常量分配和初始化存储空间(从标号为 .LC2 的内存位置读出 1.8,从标号为 .LC3 的内存位置读出 32.0):
7.位级操作:
8.浮点比较操作(S2必须在XMM寄存器中):
浮点比较指令会设置ZF、CF、奇偶标志位PF(当浮点操作数中任一个时NaN会设置该位):
3.11 问题及解决
B中最大为long,所以以8字节对齐,我想当然地将 i 后填充4,c和d后填充7,总共为32字节。看了答案是16字节后,我意识到 i、c、d “拼”一起依然小于8字节,所以应该是在它们后填充2字节,总共就为16字节。如果像我那样做的话就太浪费存储空间了。
E中8字节对齐,P3结构体数组中第二、三个元素c[2]、c[3] 2字节还能和P2结构体的i、c、d “拼”,为什么答案 t 的起始位置为24了,像是没把它们拼一起,直接在c[3]后扩充6字节?最后想了想结构体填充的规则,如果拼一起 t 的起始位置为 18,不是8的倍数。
另外有一问题未解决,习题3.9中在一片movq、salq、sarq中出现了movl,感觉有点奇怪,虽然只有最低位的字节指示着移位量的解释能接受,那在这里使用 movq 和 movl 有什么区别?是效率上的区别吗?书中还有很多地方出现 q、l、b、w “混用”的例子,什么时候该用什么时候不该用呢?