CSAPP-Chap3 程序的机器级表示
主要学习内容:x86汇编
程序编码
编译代码:
linux> gcc -Og -o p p1.c p2.c
-Og 指使用机器代码
实际编译流程:首先, 预处理器扩展源代码,插入所有用巨nelude 命令指定的文件,并扩展所有用 #define 声明指定的宏。
其次,编译器产生两个源文件的汇编代码,名字分别为 pl. p2.s 。
接下来,汇器会将汇编代码转化成二进制目标代码文件 pl.a p2.o 目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。
最后,链接器将两个目标代码文件与实现库函数(例如 printf) 的代码合并,并产生最终的可执行代码文件(由命令行指示符 -op 指定的)。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式
机器级代码
x86-64 的机器代码和原始的 代码差别非常大。一些通常对 语言程序员隐藏的处理器状态都是可见的:
- 程序计数器(通常称为 “PC”, x86-64 中用%豆 表示)给出将要执行的下一条指令在内存中的地址。
- 整数寄存器(register)文件包含 16 个命名的位置,分别存储 64 位的值。这些寄存器可以存储地址(对应于 语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
- 条件码寄存器(codition codes)保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现 if while 语句。
- 一组向量寄存器可以存放一个或多个整数或浮点数值。
e.g.
1 | long mult2(long, long); |
使用linux> gcc -Og -S mstore.c会产生汇编文件mstore.s,包括以下几行:
1 | sumstore: |
我们可以用objdump充当反汇编器汇编代码:linux> objdump -d mstore.o
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个 main 函数。假设在文件 main.c 中有下面这样的函数:
1 |
|
然后,我们用如下方法生成可执行文件 prog:linux> gcc -Og -o prog main.c mstore.c
再进行反编译:linux> objdump -d prog
格式注解
所有.开头的行都是指导汇编器和器和链接器工作的伪指令,暂时可以忽略。
我们的表述是 ATT格式的汇编代码,这是 GCC OBJDUMP 和其他一些我们使用的工具的默认格式。在其他intel 和微软的文档中可能有intel格式的代码。
数据格式
Intel 用术语”字 (word)” 表示 16 位数据类型。因此,称 32 位数为“双字 (double words)”, 64 位数为“四字 (quad words)”。下图是所有对应的汇编代码数字类型。
![[Pasted image 20240630141628.png]]
大多数 GCC 生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种: movb( 传送字节)、 movw( 传送字)、 movl( 传送双字)和 movq( 传送四字)
访问信息
一个 x86-64 的中央处理单元 (CPU) 包含一组 16 个存储 64 位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。
![[Pasted image 20240630142700.png]]
在常见的程序里不同的寄存器扮演不同的角色。其中最特别的是栈指针 %rsp, 用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。
操作数(operand)指示符
- 立即数 (immediate)表示常数值
- 寄存器(register) 表示寄存器内容的变量
- 内存引用M_b[Addr] 对存储在内存中从地址Addr开始的b个字节数的引用
有多种寻址方式表示对不同形式的内存引用。
Imm(rb, ri, s) 表示的是最常用的形式 这样的引用有四个组成部分:一个立即数偏移Imm, 一个基址寄存器 rb’ 个变址寄存器 r, 和一个比例因子 s, s这里 必须是1,2,4或者8。
![[Pasted image 20240630143715.png]]
数据传输命令
MOV
最简单形式的数据传送指令-MOV 类。这些指令把数据从源位置复制到目的位置,不做任何变化。 MOV 类由四条指令组成: movb movw movl,movq,操作数据大小分别为1,2,4,8字节。
对于 movq 指令来说,需要源操作数和目标操作数,源操作数可以是立即数、寄存器值或内存值的任意一种,但目标操作数只能是寄存器值或内存值。指令的具体格式可以这样写 movq [Imm|Reg|Mem], [Reg|Mem],第一个是源操作数,第二个是目标操作数,例如:
movq Imm, Reg->mov $0x5, %rax->temp = 0x5;movq Imm, Mem->mov $0x5, (%rax)->*p = 0x5;movq Reg, Reg->mov %rax, %rdx->temp2 = temp1;movq Reg, Mem->mov %rax, (%rdx)->*p = temp;movq Mem, Reg->mov (%rax), %rdx->temp = *p;
这里有一种情况是不存在的,没有movq Mem, Mem这个方式,也就是说,我们没有办法用一条指令完成内存间的数据交换。
括号的意思就是寻址,这也分两种情况:- 普通模式,(R),相当于
Mem[Reg[R]],也就是说寄存器 R 指定内存地址,类似于 C 语言中的指针,语法为:movq (%rcx), %rax也就是说以 %rcx 寄存器中存储的地址去内存里找对应的数据,存到寄存器 %rax 中 - 移位模式,D(R),相当于
Mem[Reg[R]+D],寄存器 R 给出起始的内存地址,然后 D 是偏移量,语法为:movq 8(%rbp),%rdx也就是说以 %rbp 寄存器中存储的地址再加上 8 个偏移量去内存里找对应的数据,存到寄存器 %rdx 中
对于寻址来说,比较通用的格式是D(Rb, Ri, S)->Mem[Reg[Rb]+S*Reg[Ri]+D],其中: D- 常数偏移量Rb- 基寄存器Ri- 索引寄存器,不能是 %rspS- 系数
除此之外,还有如下三种特殊情况(Rb, Ri)->Mem[Reg[Rb]+Reg[Ri]]D(Rb, Ri)->Mem[Reg[Rb]+Reg[Ri]+D](Rb, Ri, S)->Mem[Reg[Rb]+S*Reg[Ri]]
MOVZ, MOVS
还有两类数据移动指令,在将较小的源值复制到较大的目的时使用。所有这些指令都把数据从源(在寄存器或内存中)复制到目的寄存器 MOVZ 类中的指令把目的中剩余的字节填充为 o, MOVS 类中的指令通过符号扩展来填充,把源操作的最高位进行复制。
![[Pasted image 20240630151512.png]]
压入与弹出栈数据
栈是一种数据结构,可以添加或者删除值,不过要遵循”后进先出"的原则。通过 push 操作把数据压入栈中,通过 pop 操作删除数据;
![[Pasted image 20240630201837.png]]
将一个四字值压入栈中,首先要将栈指针减 8, 然后将值写到新的栈顶地址。所以,pushq %rbp就等价于两条指令
算数和逻辑操作
大多数操作都分成了指令类,这些指令类有各种带不同大小操作数的变种(只有 leaq 没有其他大小的变种)。例如,指令类ADD 由四条加法指令组成: addb addw addl addq, 分别是字节加法、字加法、双字加法和四字加法。事实上,给出的每个指令类都有对这四种不同大小数据的指令。
![[Pasted image 20240630202200.png]]
LEAQ
leaq是movq的变形:从内存读数据到寄存器,它还可以简洁地描述普通的算术操作。例如,如果寄存%rdx 的值为 x, 那么指令 leaq 7 (%rdx, %rdx , 4), %rax 将设置寄存器 %rax 的值为 5x+7。
一元和二元操作
第二组中的操作是一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置(类似C中的++,–计算符)
第三组是二元操作,其中,第二个操作数既是源又是目的。这种语法让人想起C语言中的赋值运算符,例如 x-=y 。不过,要注意,源操作数是第一个,目的操作数是第二个。
移位操作
先给出移位量,然后第二项给出的是要移位的数。
discussion
我们看到图 3-10 所示的大多数指令,既可以用千无符号运算,也可以用千补码运算。只有右移操作要求区分有符号和无符号数。这个特性使得补码运算成为实现有符号整数运算的一种比较好的方法的原因之一.
特殊算数操作
x86-64指令集对 128 (16字节) 数的操作提供有限的支持。
![[Pasted image 20240630204649.png]]
此外, x86-64 指令集还提供 两条不同的“单操作数”乘法指令,以计算两个 64值的全 128 位乘积一个是无符号数乘法 (mulq), 而另一个是补码乘法 (imulq) 。这两条指令都要求一个参数必须在寄存器 rax 中,而另一个作为指令的源操作数给出。然后乘积存放在寄存器 %rdx( 64 位)和 rax( 64 位)中。虽然 imulq 这个名字可以用于两个不同的乘法操作,但是汇编器能够通过计算操作数的数目,分辨出想用哪条指令。
控制
C语言中的某些结构,比如条件语句、循环语句和分支语句,要求有条件的执行,根据数据测试的结果来决定操作执行的顺序。
条件码
除了整数寄存器, CPU 还维护着一组单个位的条件码 (condition code) 寄存器,它们描述了最近的算术或逻辑操作的属性。
先来回顾一下 x86-64 处理器中不同的寄存器:
![[Pasted image 20240630210958.png]]
寄存器中存储着当前正在执行的程序的相关信息:
- 临时数据存放在 (%rax, …)
- 运行时栈的地址存储在 (%rsp) 中
- 目前的代码控制点存储在 (%rip, …) 中
- 目前测试的状态放在 CF, ZF, SF, OF 中
条件代码与跳转
最后的四个标识位(CF, ZF, SF, OF)就是用来辅助程序的流程控制的,意思是:
- CF: Carry Flag (针对无符号数) 进位标志
- ZF: Zero Flag 零标志
- SF: Sign Flag (针对有符号数) 符号标志,最近操作得到负数
- OF: Overflow Flag (针对有符号数)溢出标志:补码溢出
访问条件码
常用的使用方法有三种: 1) 可以根据条件码的某种组合,将一个字节设置为 或者 1, 2) 可以条件跳转到程序的某个其他的部分, 3) 可以有条件地传送数据。
SET 指令;它们之间的区别就在于它们考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。这些指令的后缀表示不同的条件而不是操作数大小。
![[Pasted image 20240701094436.png]]
跳转指令
跳转 (jump) 指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label) 指明。
![[Pasted image 20240701110933.png]]
用条件控制来实现条件分支
类似C语言goto代码
控制流:
1 | t = test-expr; |
用条件传送来实现条件分支
处理器中更加高效
使用数据的条件转移。这种方法计算1个条件操作的两种结果,然后再根据条件是否满足从中选取一个。
原始C代码和基于条件传送的实现:
1 | long absdiff(long x, long y) |
1 | long cmovdiff(long x, long y) |
汇编代码实现:
1 | absdiff: |
原因:处理器通过使用流水线 (pipelining) 来获得高性能,在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向 内存写数据,以及更新程序计数器) 这种方法通过重叠连续指令的步骤来获得高性能,例如,在取一条指令的同时,执行它前面一条指令的算术运算。
为了提高效率,采用分支预测的逻辑。错误的预测会带来严重的性能下降。
![[Pasted image 20240701112437.png]]
更加详细的理解:
考虑以下表达式:v = test-expr ? then-expr : else-expr;
1 | if (! test-expr) |
循环
用条件测试和跳转组合起来实现循环的效果
do-while 循环
1 | loop: |
while循环
1 | goto test; |
如果优化等级更高:首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为 do-while 循环。
1 | t = test-expr; |
for和while类似
switch
使用跳转表
1 | long switch_eg (long x, long y, long z){ |
伪C代码如下,数组 jt 包含7个表项,每个都是 个代码块的地址。这些位置由代码的标号定义,在 jt 的表项中由代码指针指明,由标号加上&&前缀组成。(回想运算符&创建一个指向数据值的指针。在做这 扩展时, GCC 的作者 们创 造了一个新的运算符&&,这个运算 符创 个指向代码位置 的指 针。
1 | void switch_eg_impl(long x, long n, |
可能会类似如下汇编代码,这里 %rdi 是参数 x,%rsi 是参数 y,%rdx 是参数 z, %rax 是返回值:
1 | switch_eg: |
通过上面的例子,我们可以大概了解处理 switch 语句的方式:大的 switch 语句会用跳转表,具体跳转时可能会用到决策树(if-elseif-elseif-else)
过程
一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值,过程会对程序状态产生什么样的影响。
运行时栈
在 x86-64 中,所谓的栈,实际上一块内存区域,这个区域的数据进出满足先进后出的原则。越新入栈的数据,地址越低,所以栈顶的地址是最小的。
![[Pasted image 20240702152615.png]]
可以用 pushq popq 指令将数据存入栈中或是从栈中取出。将栈指针减小一个适当的量可以为没有指定初始值的数据在栈上分配空间。类似地,可以通过增加栈指针来释放空间。
转移控制
函数 转移到函数 要简单地把程序计数器 (P 设置为 的代码的起始位。不过, 稍后从 返回的时候,处理器必须记录好它 要继续 的执行的代码位置。x86 -64 机器中,这个信息是用指 all 调用过程 来记录的。该指 会把地址 压入栈中,并将 PC 设置为 的起始地址, 压入的地址 被称为返回地址,是紧跟在 call 指令后面的那条指令的地址。对应的指令 ret 从栈中弹出地址 A, 并把 PC 设置为A。
![[Pasted image 20240702185426.png]]
example:
1 | Beginning of function multstore |
执行过程:
![[Pasted image 20240702185728.png]]
数据传送
x86-64 中,大部分过程间的数据传送是通过寄存器实现的 例如,我们已经看到无数的函数示例,参数在寄存器 rdi rsi 和其他寄存器中传递 。
x86-64 中,可以通过寄存楛最多传递 个整型(例如整数和指针)参数 。寄 存器的使用是有特殊顺序的,寄存器使用的名字取决千要传递的数据类型的大小。
![[Pasted image 20240702185917.png]]超出6个的部分就要通过栈来传递 。
![[Pasted image 20240702190257.png]]
栈上的局部存储
到目前为止我们看到的大多数过程示例都不需要 超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变蜇使用地址运算符'&',因此必须能够为它产生一个地址
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到 。在 描述数组和结构分配时,我们会讨论这个问题。
运行时栈提供了一种简单的、在需要时分配、函数完成时释放局部存储的机制。
既然是利用栈来进行函数调用,自然而然就可以推广到递归的情况,而对于每个过程调用来说,都会在栈中分配一个帧 Frames。每一帧里需要包含: - 返回信息
- 本地存储(如果需要)
- 临时空间(如果需要)
整一帧会在过程调用的时候进行空间分配,然后在返回时进行回收,在 x86-64/Linux 中,栈帧的结构是固定的,当前的要执行的栈中包括: - Argument Build: 需要使用的参数
- 如果不能保存在寄存器中,会把一些本地变量放在这里
- 已保存的寄存器上下文
- 老的栈帧的指针(可选)
而调用者的栈帧则包括: - 返回地址(因为
call指令被压入栈的) - 调用所需的参数
具体如下图所示:
![[Pasted image 20240702221145.png]]
递归过程
递归就是一个栈的实现过程!
例子
1 | long pcount_r(unsigned long x) { |
对应的汇编代码为:
1 | pcount_r: |
实际执行的过程中,会不停进行压栈,直到最后返回,所以递归本身就是一个隐式的栈实现,但是系统一般对于栈的深度有限制(每次一都需要保存当前栈帧的各种数据),所以一般来说会把递归转换成显式栈来进行处理以防溢出。
数据分配与访问
回顾一下数据类型所占字节的大小
![[Pasted image 20240702221633.png]]
对于数组声明T A[N];会产生两个效果:
- 内存中会分配一个L * N字节的连续区域,L为数据类型T的大小
- 引入标识符A,A可用于作为指向数组第一个元素的指针
对于数组访问A[i],若A的地址存放于%rdx,i存放于%rcx则指令movl (%rdx, %rcx, 4), %eax能访问int类型数组第i个元素。
指针运算
C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩. 比如p+i就会自动成为x_p + L * i,L是p的数据类型。
单操作数操作符’&’和'*'可以产生指针和间接引用指针.
例子如下图:
![[Pasted image 20240705170059.png]]
返回数组值的操作类型为 int, 因此涉及 字节操作(例如movl) 和寄存器(例如 %eax) 。那些返回指针的操作类型为 int * , 因此涉及 字节操作(例如 leaq) 和寄存器(例如 %rax) 。
嵌套数组
对于多维的数组,基本形式是 T A[R][C],R 是行,C 是列,如果类型 T 占 K 个字节的话,那么数组所需要的内存是 R*C*K 字节。具体在内存里的排列方式如下:
![[Pasted image 20240705172143.png]]
数组元素在内存中按照行优先的顺序排列。
定长数组,变长数组
看不太懂
一般的C语言数组都是定长数组(最好先define一个长度减少复用debug耗时),C99允许方括号内有表达式
应该的意思是GCC 能够识别出程序访问多维数组的元素的步长,然后通过指针或者数组步长的加法避免更多时钟周期的乘法的优化。
异质的数据结构
结构体 struct
结构体是 C 语言中非常常用的一种机制,具体在内存中是如何存放的呢?
ex1:
1 | struct rec |
![[Pasted image 20240705173101.png]]
ex2: (非对齐)
1 | struct S1 |
![[Pasted image 20240705173147.png]]
文中讲“具体对齐的原则是,如果数据类型需要 K 个字节,那么地址都必须是 K 的倍数”——这只是windows的原则,
Linux中的对齐策略是“2字节数据类型的地址必须为2的倍数,较大的数据类型(int,double,float)的地址必须是4的倍数”
原因:内存访问是以4.8字节为单位,非对齐效率低下
对于一个结构体来说,所占据的内存空间必须是最大的类型所需字节的倍数,所以可能需要占据更多的空间,所以在创建结构体的时候先把打的数据类型放在前面可能是一种优化
union 联合
所有成员共享同一块内存空间。即使它们是不同类型,union的大小也是其最大成员的大小。
在任何时候只能存储一个成员的值。如果存储了新的值,旧的值将被覆盖。
Therefore, 对于类型 union U3 *的指针 p, p-> p-> i[O] p->v引用 的都是数据结构的起始位置.
数据对齐
许多计算机系统对基本数据类型的合法地址做出了 些限制,要求某种类型对象的地址必须是某个值 K( 通常是1,2,4,8) 的倍数。
在机器级程序中将控制与数据结合起来
指针
as said in [[61C-P1-C]]
GDB
![[Pasted image 20240705204515.png]]
![[Pasted image 20240705204534.png]]
内存越界引用和缓冲区溢出
C对于数组引用不进行任何边界检查,局部变量和状态信息都存储在栈中–对越界的数组元素的写操作会破坏存储在栈中的状态信息!
一种特别常见的状态破坏称为缓冲区溢出 (buffer overflow) 通常,在栈中分配某个字符数组来保存字符串,但是字符串的长度超出了为数组分配的空间
最好使用fgets函数,限制待读入的最大字节数
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。输入一个程序字符串,包含一些可执行代码的字节编码,称为攻击代码:
对抗缓存区溢出攻击
- 栈随机化(stack randomization):使得栈的位置在程序每次运行时都有变化。Linux 系统中,栈随机化已经变成了标准行为 它是更大的一类技术中的一种,这类技术称为地址空间布局随机化 (Addr ess-Space Layout Randomization), 或者简称 ASLR。每次运行时程序的不同部分 ,包括程 序代码、库代码、栈、 全局变量和堆数据,都会被加载到内存的不同区域。
但是攻击者仍然可以反复地用不同的地址进行攻击,在实际攻击代码之前加一段很长的nop指令。猜中一个地址就会到达攻击代码(空操作雪橇) - 栈破坏检测(stack corruption detection):在栈帧中任何局部缓冲区和栈状态之间存一个随机产生的“金丝雀(canary)”值,规定为只读,检测其是否被修改
- 限制可执行的区域(limiting executable code regions):栈可以被标记为不可执行区域
