CSAPP 第三章 程序的机器级表示 总结

C语言指令对应的机器表示

历史观点

Inter 处理器系列俗称 x86,它经历了一个长期的、不断进化的过程。几十年来,实现了从 16 位到 32 位 i386,最后到现在 64 位的 x86-64 处理器,其中每个后继处理器都是向后兼容的——较早版本的处理器上编译的代码总是可以在较新的处理器上运行。

摩尔定律

1965年,Gordon Mone (Intel 公司的创始人)推断,在未来的十年,芯片上的晶体管数量每年都会翻一番,这个预测就是摩尔定律。事实证明这个预测是正确的。

还以为是摩尔庄园那个摩尔(x

程序编码

假设C程序 Hello.c 文件,我们在 linux 系统中用下面的命令编译代码:

1
linux> gcc -g Hello.c -o Hello

gcc(GCC)是 Linux 的默认编译器

用户看来是执行了一条命令,但是实际上机器执行了一系列的程序,才将源代码转换成可执行代码。有以下几个流程,我们也可以用不同的命令控制 GCC 逐步执行:

  • 预处理

    1
    > gcc -E Hello.c -o Hello.i

    C预处理器拓展源代码,插入所有 #include 命令指定的文件,并拓展所有以 #define 声明指定的宏。

  • 编译

    1
    > gcc -S Hello.i -o Hello.s

    编译器会产生汇编代码文件 Hello.s

  • 汇编

    1
    > gcc -c test.s -o test.o

    汇编器会将汇编代码转化成二进制目标代码文件 Hello.o 。其中,目标代码是汇编代码的一种形式,它包含所有指令的二进制表示,但是没有填入全局值的地址。

  • 链接

    1
    > gcc -g test.o -o test [--static]

    连接器将目标代码文件与实现库函数(例如 printf )的代码合并,并产生最终的可执行代码文件。其中,我们可以在队后一步编译的时候添加 static 参数进行静态链接,不参加参数就是默认动态链接。

对于机器级编程来说,有两种抽象尤为重要:

  • 由指令集体系结构或指令集架构(ISA)定义的机器及程序的格式和行为。
  • 内存地址实际上是虚拟地址

在C语言中,我们声明和分配各种数据类型的对象,但是机器代码不区分这些数据,只是将内存看作一个很大的、按字节寻址的数组。不区分各种类型的数据,数组和结构也只使用一组连续的字节来表示。

X86-64 的机器代码和原始的C语言代码差别很大,对于程序员来说是有很多的隐藏状态,例如:

  • 程序计数器(通常称为 PC ,在 x86-64 中用 %rip 表示)用于指示程序要执行的下一条指令在内存中的地址
  • 整数寄存器文件,用于存储地址(对应C语言的指针)或整数数据。
  • 条件码寄存器,保存着最近执行的算术逻辑或逻辑指令的状态信息
  • 向量寄存器,存放一个或多个整数或浮点数值

我们经常接触到的汇编语言,其实并不是机器语言。但是汇编代码表示非常接近机器代码,由于汇编代码对于程序员来讲更具有可读性,所以我们经常用汇编代码来学习和解释机器代码行为

程序内存这些都是由虚拟内存来寻址的,操作系统负责管理虚拟内存空间,把虚拟地址转换为实际物理地址

前面有提到,程序的汇编指令转化为计算机可读的文件二进制目标代码后就变成了一个01的字节序列,机器也只是执行这个字节序列,机器对指令的源代码一无所知。

当然我们可以利用下面的命令反编译可重定向文件或者是可执行 ELF 文件:

1
2
$objdump -d filename.o
$objdump -d filename

我们也可以使用 hexdump -x filename.o 命令输出二进制文件的十六进制表示形式(虽然没有什么可读性上的帮助 xsbb

objdump 、hexdump 命令还有不同参数对应不同功能

机器代码的特性:

  • 指令可以从任意内存地址开始,CPU不会强制进行代码对齐

  • 指令长度从1字节到15字节不等。常用指令的字节数少,越不常用字节数越多

  • 从给定的某一位置开始,每一个机器字节都对应唯一的指令

  • 反汇编器只是根据字节序列确定汇编代码,它不需要访问编译出该程序的源文件

如果我们直接 cat filename.s,输出的汇编代码如下图所示:

其中以 . 开头的都是知道汇编器和编译器的伪指令,我们可以忽略。我们在表示汇编代码时会省略这些伪指令,同时,汇编代码的格式也有不同,分为 AT&T 和 Inter 两种风格:

gcc 默认输出的是 AT&T ,微软系列都是 Inter

Inter AT&T
指令后缀 无后缀,例如:mov、push 有指示大小的后缀,例如:pushq、movq
寄存器 寄存器无 % 符号,例如:rbx 寄存器有 % 符号,例如:%rbx
立即数 有任何前缀,直接用一个数字表示 用 $ 前缀表示一个立即数
操作数顺序 操作符 目的操作数 , 源操作数 例如:mov eax,1 操作符 源操作数 , 目的操作数 例如:mov $1,%eax
内存取址 section:[base + index*scale + disp] section:disp(base, index, scale)

其中base和index必须是寄存器,disp和scale可以是常数

计算方法 : disp + base + index * scale

最终地址 = 地址或偏移 + %基址或偏移量寄存器 + %索引寄存器 * 比例因子

数据格式

由于 Intel 是从 6 位体系拓展成 64 位的,所以用术语 “字(word)” 表示 16 位的数据类型,因此 32 位数被称为 “双字(double words)”,同理 64 位数被称为 “四字(quad words)”。

指针在不同位数系统中的大小不同,在多少的系统里面就是多少,64位系统中就对应存储为 8 字节的四字。

下面是常见的数据类型的大小:

C声明 Intel数据类型 汇编代码后缀 大小(字节)
char 字节 b 1
short w 2
int 双字 l 4
long 四字 q 8
char * 四字 q 8
float 单精度 s 4
double 双精度 l 8

需要注意的是,虽然4字节的整数和双精度浮点数都是用 l 来表示,但是不会产生歧义,因为两种数据类型使用的是完全不同的两组指令和寄存器。

访问信息

寄存器用来存储数据和指针,最初的16位系统中有八个16位的寄存器,分别是 ax , bx , cx , dx , d i, si , sp , bp 。后来发展到 32 位,寄存器也拓展成 32 位,在原本寄存器名字的前面加上 e 来表示,如 eax 。现在的 x86-64 中,寄存器对应拓展为 64 位,把所有的 e 替换成了 r,并且新增了 8 个寄存器 r8~r15。

操作数指示符

大多数的指令都有一个或多个操作数,指示出执行一个操作中要使用的源数据值以及放置结果的目的位置。操作数可以是立即数、寄存器或者内存引用。

其中,内存引用会根据计算出的有效地址去访问内存位置。同时,有许多种寻址模式,允许不同形式的内存引用。书上也给出了很多寻址模式的解析:

我们最常用的寻址方式是图上的最后一个,是比例变址寻址的一种,表示为 Imm(x,y,z),表示了地址为 x+yz+Imm 的内存空间。

数据传送指令

数据传送指令,就是字面意思,把数据从一个位置复制到另一个位置的指令。

我们把对应执行操作相同,但是执行的操作数大小不同的不同指令称之为同一个指令类

MOV 类指令是最简单的数据传送指令,在实际应用中也最为频繁。这类指令把数据从原位置复制到目的位置,不对数据做任何的处理和改变。

这类指令有两个操作数,源操作数存储在寄存器或内存中的立即数 ,而目的操作数指定一个位置,它是寄存器或者一个内存地址。需要注意的是,传送指令的两个操作数不能同时指向内存位置

MOV 类由 movbmovwmovlmovq 四个指令组成,它们之间的差异是操作数据大小不同,分别对应 1 字节、2 字节、4 字节和 8 字节,操作数中的寄存器部分必须和指令最后一个字符对应。这其中传输双字的指令 movl ,如果目的操作数是一个寄存器,那么它会把寄存器的高位 4 字节全部置零

有的时候目的寄存器和源寄存器的字长并不是对应的,可能会出现目的寄存器的字长大于源寄存器的情况,这个时候就会对源操作数进行拓展。这样又对应有两种拓展方式:零拓展(MOVZ 类)和符号拓展(MOVS类)。

指令名称中的后两个字符都是大小指示符,第一个字符指定源的大小(字节b,字w,双字l),第二个就是目的大小(字w,双字l,四字q)。具体如下表:

指令 描述
movzbw 将做了零拓展的字节传送到字
movzbl 将做了零拓展的字节传送到双字
movzwl 将做了零拓展的字传送到双字
movzbq 将做了零拓展的字节传送到四字
movzwq 将做了零拓展的字传送到四字
movsbw 将做了符号拓展的字节传送到字
movsbl 将做了符号拓展的字节传送到双字
movswl 将做了符号拓展的字传送到双字
movsbq 将做了符号拓展的字节传送到四字
movswq 将做了符号拓展的字传送到四字
movslq 将做了符号拓展的双字传送到四字
cltq 把 %eax 符号拓展到 %rax ,等效于 movslq %eax, %rax

零拓展是用 0 填充目的中的剩余字节,符号拓展就是用最高位符号位填充剩余字节。

压入和弹出栈数据

压栈和入栈实际上也是数据传送的操作,只不过操作的对象是栈。pushq(push)的功能是把数据压入栈,popq(pop) 的功能是弹出数据。这两个指令都只有一个操作数——压入的数据源和弹出的数据目的。

push 的作用是将栈指针减 8 也就是抬高栈顶,再将操作数写入开辟出来的栈空间中;pop 正好相反,先将栈顶的8 字节数据传送到操作数当中,再将栈指针加 8。

需要注意的是,因为不能同时交换两个内存地址,所以 push 的源操作数只能是立即数或者是寄存器, pop 的目的操作数只能是寄存器

算数和逻辑操作

之前在第二章中学到过计算机的运算,主要分为算数运算和逻辑运算。

这些运算操作又被分成四组:加载有效地址、一元操作、二元操作和移位。一元操作就是只有一个操作数,同样的,二元操作就是有两个操作数。在这之下,每一个运算的指令类都有对应的不同大小操作数的变种。

具体如下表:

一元操作指令

指令 描述
INC D 自增(D++)
DEC D 自减(D–)
NEG D 取负(-D)
NOT D 取反(~D)

二元操作指令

指令 描述
ADD S, D D+ = S
SUB S, D D- = S
IMUL S, D D* = S(有符号)
MUL S, D D* = S(无符号)
IDIV S, D D/ = S(有符号)
DIV S, D D/ = S(无符号)
XOR S, D D^ = S
OR S, D D| = S
AND S, D D& = S
SAL S, D D<< = S(有符号)
SAR S, D 算术右移 D>> = S(有符号)
SHL S, D D<< = S(无符号)
SHR S, D 逻辑右移 D>> = S(无符号)

加载有效地址

leaq(lea) 指令是加载有效地址指令,它实际上就是 movq 的变形。

lea 指令形式是从内存读取数据到寄存器,但实际上它根本既没有引用内存。它的第一个操作数看上去是个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入目的操作数。此外,lea 的目的操作数必须是寄存器。

lea 指令有很多灵活的用法,经常用于计算数据,可以实现加法和有限形式的乘法,如下面的程序:

1
2
3
4
5
6
long example(long x,long y)
{
//x in %rdi,y in %rsi
long t=x+4*y;
return t;
}

如果没有 lea 指令,这个函数对应的计算部分的汇编代码可以被写成这样:

1
2
3
movq    %rsi, %rax      # 将y(%rsi)移动到%rax寄存器
salq $2, %rax # 乘以4(左移2位,相当于乘以4)
addq %rdi, %rax # 将%rdi添加到%rax

但我们引用 lea ,汇编代码就被简化了:

1
leaq (%rdi,4,%rsi),%rax

没错就这一句🤔

控制

程序中不止会有逐步指令执行的直线代码行为,C语言中的某些结构会要求有条件的执行,比如循环语句,在循环中我们会重复执行多次统一部分的指令。对此,机器提供相应的指令来控制修改机器代码的指令顺序。

执行方式分为条件执行和非条件执行

条件码

除了整数寄存器,CPU还维护着一组单位个的条件码寄存器,用来描述最近的算术或逻辑操作的属性,用来判断并执行条件分支指令。常见的条件码如下:

指令 作用 描述
CF 进位标志 判断是否产生进位
ZF 零标志 判断最近的结果是否为0
SF 符号标志 最近操作的结果是否为负数
OF 溢出标志 最近的操作是否导致补码溢出

leaq 指令不会改变任何条件码,因为它是用地址进行计算的。除此之外,所有的指令都会设置条件码。同时需要注意,INC 和 DEC 指令不会改变进位标志,CMP 指令和 TEST 指令只改变条件码。

除了对寄存器的操作,CMP指令和SUB指令的行为相同,TEST指令和ADD指令的行为相同

访问条件码

机器并不是直接去读取条件吗,常用的方法有以下三种:

  • 根据条件码的某种组合,将一个字节设置为0或1。我们将这类指令称为 SET 指令
  • 条件跳转到程序的某个其他的部分
  • 有条件地传送数据

每一条 SET 指令的后缀都指明了它们所考虑的条件码的组合,需要注意的是后缀表示的是不同的条件而不是不同的操作数。具体的 SET 指令如下表:

指令 同义后缀 条件码 设置条件
sete z ZF==1 相等(零)
setne nz ZF==0 不相等(不为零)
sets / SF==1 负数
setns / SF==0 非负数
setg nle (SF^OF)==0&ZF==0 (有符号)大于
setge nl (SF^OF)==0 (有符号)大于等于
setl nge (SF^OF)==1 (有符号)小于
setle ng (SF^OF)==1|ZF (有符号)小于等于
seta nbe CF==0&ZF==0 (无符号)大于
setae nb CF==0 (无符号)大于等于
setb nae CF==1 (无符号)小于
setbe na CF|ZF==1 (无符号)小于等于

跳转指令

前面提到机器需要指令来实现指令执行的切换,跳转(jump)指令就会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label)指明。

jmp 指令是无条件跳转,它可以直接跳转,即跳转目标是作为指令的一部分编码,比如 jmp *%rax ;也可以间接跳转,即跳转目标需要从寄存器或内存位置读出,比如 jmp *(%rax)

jmp 指令的条件指令就是 j+后缀 ,具体后缀和上面 set 后缀部分相同,对应条件也相同。

跳转指令的目标值是一个地址,它主要有两种编码方式:

  • 绝对地址:给出绝对地址,用四字节直接指定要跳转到的内存地址。
  • 相对地址:用偏移量编码,将目标指令的地址与紧跟在跳转指令后面的指令地址之间的差值作为编码,编译器或汇编器会完成这些工作。

条件分支的实现

条件分支是编程中常用的一种控制结构,它允许根据条件的成立与否来执行不同的代码路径。条件分支通常可以通过两种主要方法来实现:条件控制和条件传送语句。

条件控制来实现条件分支

在条件控制中,程序使用条件语句(如 ifelse ifelse )来检查一个或多个条件表达式,并基于这些条件的真假来选择执行不同的代码块。

条件控制通常使用分支指令(如条件跳转指令)来实现,例如,在汇编语言中,jz(跳转如果零)、jnz(跳转如果不为零)等指令可用于根据条件跳转到不同的代码段。

条件控制的一个典型示例是使用 if 语句来根据条件执行不同的代码块。比如:

1
2
3
4
5
if (x>y) 
{
return 1;
}
return 0;

在汇编代码中我们就可以写成:

1
2
3
4
5
6
7
8
9
    cmp rdi, rsi     ; 比较 x 和 y
jg L1 ; 如果 x > y,则跳转到 greater
mov rax, 0 ; 否则,将 0 存储在 %rax 中
jmp done
L1:
mov rax, 1 ; 如果 x > y,则将 1 存储在 %rax 中
done:
; 返回结果并退出
ret
条件传送语句实现条件分支

条件传送语句是一种通过条件来选择是否将一个值传送到目标寄存器或内存位置的机制,使用数据实现条件转移。它通常用于执行非常简单的条件分支,其中只有两个可能的结果。

1
2
3
4
5
6
7
8
if (x>y) 
{
result=x-y;
}
else
{
result=y-x;
}

在汇编代码中我们就可以写成:

1
2
3
4
5
6
7
   movq %rdi,%rax
subq %rsi,%rax ;let %rax=x-y
movq %rsi,%rbx
subq %rdi,%rbx ;let %rbx=y-x
cmp %rsi,%rdi
cmovle %rbx,%rax ;if x<=y movq %rbx,%rax
ret

注意观察上面的汇编代码,它会先把两种结果都算出来,最后再根据判断结果赋值。虽然这种方法看上去执行的步骤更多,但是实际上它的运行速度更快。

条件控制和条件传送的比较

处理器通过流水线来获得高性能,在流水线中,机器会重叠连续指令,比如在执行某一条指令的同时会同时执行它前面一条的算术运算。

像前面的条件传送语句中,他的计算步骤是同时进行的;但是由于条件控制语句在执行完毕之前,不能确定程序是否要跳转,也就无法知道它接下来要执行的指令,所以我们只能等待条件跳转指令执行完毕再次填充指令流水作业,速度自然就慢了。

循环

C语言中的很多循环例如 do-while 、while 和 for 。汇编中并没有相应的代码来直接实现这些操作,但是可以用条件测试和跳转组合来实现循环的效果。

do-while
1
2
3
4
5
6
int i=10;
do{
//bodyment
i--;
}
while(i>=0);

它对应的汇编代码就是:

1
2
3
4
5
6
movq $10,%rcx     
do:
;do bodyment
subq $1,%rcx
test %rcx,%rcx
jns do

同样,其他的循环也有对应的实现方法

while
1
2
3
4
5
while(i>=0)
{
//bodyment
i--;
}
1
2
3
4
5
6
7
8
do:
subq $1,%rcx
test %rcx,%rcx
js end
;do bodyment
jmp do
end:
ret
for
1
2
3
4
for(int i=0;i<10;i++)
{
//bodyment
}
1
2
3
4
5
6
movq %rcx,0
do:
;something
addq %rcx,$1
cmpq $10,%rcx
jl do

综上,上方的三个循环都可以用条件分支代码的思路去实现,由控制来构成机器代码。

switch

switch 语句可以根据一个整数索引值进行多重分支,当开关的情况数量比较多,并且值的跨度范围比较小时,会通过使用跳转表来实现程序的高效性。

跳转表是一个数组或类似数据结构,其中每个 case 标签对应一个表项。每个表项包含两部分信息:条件值和跳转目标。在跳转的判断部分,程序会在跳转表中直接查找 switch 表达式的值对应的条件值,只进行一次判断就跳转到指定位置运行。

比如下面这段C语言代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void switch_eg(n)
{
int val=1;
switch(n)
{
case 1:
val+=2;
break;
case 2:
val-=2;
break;
case 3:
val*=2;
break;
case 4:
val/=2;
break;
default:
val--;
}
return val;
}

对应的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
.L1:
.quad .L2
.quad .L3
.quad .L4
.quad .L5
.quad .L6

switch_eg:
movq $1,%rax
subq $1,%rdi ;rdi 也就是 n ,其值要匹配跳转表的索引
cmpq $3,%rdi
ja L6
jmp *.L1(,%rdi,8) ;跳转表
L2:
addq $2,%rax
jmp end
L3:
subq $2,%rax
jmp end
L4:
mul $2,%rax
jmp end
L5:
div $2,%rax
jmp end
L6:
addq $1,%rax
end:
ret

过程

过程是软件中很重要的抽象,它提供了一种封装代码的方式,用一组参数和可选的返回值实现了某种功能。我们可以在程序中不同的地方调用这个函数。

我们假设过程 P 调用过程 Q ,Q 执行后返回到 P。

运行时栈

C语言过程调用机制的一个关键特性就是使用了栈数据结构提供的后进先出的内存管理原则。

当某个函数运行时所需要的局部变量超过了寄存器的数量,就会在栈上开辟空间,这个在栈上分配的空间就称为这个函数的栈帧。

当 P 调用 Q 时,会把 P 的状态保存起来,并存储 Q 调用结束后的返回地址,调用完以后再恢复到调用前的状态。需要注意这个返回地址属于 P 的帧的一部分

转移控制

当控制从函数 P 转移到函数 Q 只需要把程序计数器(PC)设置为 Q 的起始地址,但当 Q 调用结束返回 P时,处理器必须记住 P 应该继续执行的代码位置,这个信息就是由 call 指令来记录的。

call Q 指令用来调用 Q ,同时它会将 P 当前的地址 A 压入栈中,并将 PC 设置为 Q 的起始地址。地址 A 就是函数 Q 的返回地址,也是 ret 指令的地址。ret 指令紧跟在 call 指令之后,会从栈中弹出返回地址 A,并把 PC 重新设置为 A ,继续函数 P 的执行。

call 指令和 ret 指令的一般形式如下:

指令 描述 作用
call Label 过程调用 控制程序跳转到被调用过程的指令地址
call *Operand 过程调用 控制程序跳转到被调用过程的指令地址
ret 从过程调用中返回 控制程序返回到调用它的地方

这两个指令对应的寄存器层面的变化如下:

call 指令执行前

call 指令执行完毕后,rip 寄存器指向调用的函数 foo() 的地址 0x4004e7,此时 rsp 指向返回地址 0x400514

ret 执行后 rip 寄存器指向原本 foo() 函数对应的返回地址 0x400514

数据传送

在函数调用的过程中,不仅要把控制在过程中传递,还需要传递数据参数。

在 x86-64 中,大部分的过程间的数据传递是通过寄存器实现的。寄存器最多传递六个整数,顺序是: %rdi,%rsi,%rdx,%rcx,%r8,%r9 ,其余参数自右向左依次入栈。

栈上的局部存储

有一些局部数据必须放在内存中,具体情况如下:

  • 寄存器不足以存放所有的本地数据
  • 对局部变量使用地址运算符 & 进行取地址操作
  • 变量是数组或结构,必须通过引用来访问

寄存器中的局部存储空间

我们知道,寄存器是在整个过程中共享的资源,我们需要保证 在一个过程调用另一个过程时,被调用者不会覆盖调用者将要使用的寄存器的值,以保证调用结束后程序可以正常运行。对此 x86-64 有一组统一的寄存器使用惯例。

惯例中:%rbx,%rbp,%r12~%r15 被划分为被调用者保存寄存器,就是被调用函数不回去待变这些寄存器的值。而其它的寄存器,除了 %rsp 都分类为调用者保存寄存器,也就是任何函数都可以改变它们。

我们可以这样理解“调用者保存”这个名字,Q 被调用时可以随意改变这些寄存器,那么保存好这些数据就是调用者 P 的责任。

数组的分配和访问

C语言中的数组是一种将标量数据据继承更大数据类型的方式。

基本原则

对于数据类型 T 和整型常数 N,数组声明为 T A[N]; ,它的起始位置我们表示为 xA ,数组占用的字节数是 sizeof(T)* N ,我们访问 A[i] ,实际上就是访问 xA + sizeof(T)* i

指针运算

C语言允许且对指针进行运算,计算出来的值会根据引用该指针的数据类型的大小进行伸缩。也就是说表达式 p+i 相当于 *xp+sizeof(type)i 的地址(xp 表示数组 p 的基地址)。

嵌套的数组

我们使用的二维数组,例如 int A[5][3];,等价于 typedef int row3_t[3]; row3_t A[5],这种就是嵌套声明。

我们要访问多维数组 T D[R][C] 的元素 D[i][j] ,对应的内存地址是 xD+sizeof(T) *i+sizeof(T) *j

异质的数据结构

C语言提供了两种将不同类型对象结合到一起创建数据类型的机制:结构和联合

结构(struct)

struct 也叫结构体,将可能不同类型的基本数据聚和到一个对象当中,用名字来引用结构的各个部分。编译器维护关于每个结构类型的信息,指示每个字段的字节偏移,作为内存引用指令中的位移,用来实现各个元素的引用。

也就是说,我们想要访问结构体中的某个对象,只需要将结构体的地址再加上这个字段的偏移即可。

联合(union)

联合的声明方式和结构体相同,但不同的是联合内的所有成员变量共享同一块内存,只能同时访问一个数据成员,而结构体中的数据成员各自独立的占用内存,所以可以被同时访问。

例如:

1
2
3
4
5
6
union U
{
char c;
int i[2];
double v;
}

对于 U的指针 pp->cp->i[0]都是指向 U 的起始位置。

数据对齐

许多计算系统都对数据类型的合法地址做出了一些限制,要求某些数据对象的地址必须是某个值的倍数。例如处理器总是从内存中取 8 字节,那么有效地址就必须是 8 字节的倍数,这样我们可以保证用一个内存操作来读、写值了。

例如下面的结构体,如果没有对齐要求,我们认为他们的内存占用情况如下:

1
2
3
4
5
struct S{
int i; //+4
char c; //4+=1
int j; //5+=4
}

但实际上是:

1
2
3
4
5
struct S{
int i; //+4
char c; //4+=1
int j; //8+=4
}

画出图就是下面的样子,灰色部分是为了对内对齐导致的两个数据成员之间的空的间隙

因为不同的数据类型都必须对其相应的值K,具体如下表:

K 类型
1 char
2 short
4 int , float
8 long , double , char*

在机器级程序中将控制与数据结合起来

指针

指针我一生之敌!

指针是C语言的特色,它们允许程序直接访问计算机内存中的数据。每个指针都对应一个类型,表示了它指向的内存为什么数据类型。

以下是指针的特点:

  • * 操作符是间接引用指针,结果数据是该指针的类型
  • 每个指针都有一个值,这个值就是某个指定类型对象的地址,NULL(0) 也是一个值,表示指针并没有指向任何地方
  • 对指针进行强制类型转换值,改变它的类型,不改变它的值
  • 指针可以指向函数

我们这样来声明一个指针:

1
2
type *name ;   
type (*name) (arglist) ; //指向函数,括号必须将*和函数名括在一起

GDB调试器

GNU 调试器 GDB 提供了许多有用的特性,支持机器级程序运行时的分析。我们可以用下面的指令启动GDB并调试程序

1
linux> gdb filename

输入 help(h) 就可以查看具体的命令,常用的命令如下:

调试命令 (缩写) 作用
break (b) xxx 在源代码指定的某一行设置断点,其中 xxx 用于指定具体打断点位置
run (r) 执行被调试的程序,其会自动在第一个断点处暂停执行
continue (c) 当程序在某一断点处停止后,用该指令可以继续执行,直至遇到断点或者程序结束
next (n) 令程序一行代码一行代码的执行
step(s) 如果有调用函数,进入调用的函数内部;否则,和 next 命令的功能一样
until (u) 当你厌倦了在一个循环体内单步跟踪时,单纯使用 until 命令,可以运行程序直到退出循环体
until n 命令中,n 为某一行代码的行号,该命令会使程序运行至第 n 行代码处停止
print (p)xxx 打印指定变量的值,其中 xxx 指的就是某一变量名
list (l) 显示源程序代码的内容,包括各行代码所在的行号
finish(fi) 结束当前正在执行的函数,并在跳出函数后暂停程序的执行
return(return) 结束当前调用函数并返回指定值,到上一层函数调用处停止程序执行
jump(j) 使程序从当前要执行的代码处,直接跳转到指定位置处继续执行后续的代码
quit (q) 终止调试,退出 GDB shell
kill (k) 杀死程序,强制终止正在被调试的异常程序

内存越界引用和缓冲区溢出

C在数组引用的时不会进行任何边界检查,而且局部变量和状态信息都存放在栈中,两者结合起来就会导致严重的程序错误,破坏栈中存储的信息,如果保持这个被破坏的信息并试图重新加载寄存器或者尝试执行 ret ,就会导致程序发生错误。

一种常见的状态破坏被称为缓冲区溢出,通常发生子啊在我们在栈中分配字符数组的时候,输入的字符串的长度超出了为它分配的空间大小。例如:

1
2
3
4
5
6
int echo()
{
char buf[8];
gets(buf); //gets不会对输入数据的长度进行判断,只有遇到"\n"才会停止读取
puts(buf);
}

对应的汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
pushq    %rbp
movq %rsp, %rbp
subq $16, %rsp
leaq -8(%rbp), %rax
movq %rax, %rdi
movl $0, %eax
call gets@PLT
leaq -8(%rbp), %rax
movq %rax, %rdi
call puts@PLT
nop
leave
ret

其中有指令 leaq -8(%rbp), %rax 的意思就是将相对于基指针 rbp 的地址偏移 -8加载到寄存器 rax 中,也就是说一旦输入超过了 8 字节,就会覆盖 rbp 所在的位置,再长 8 字节就会覆盖住返回地址。

缓冲区溢出的致命使用就是让程序执行本来不该执行的函数,这也是一种最常见的网络攻击的方法。我们称编写的攻击代码为 exploit code ,直接输入的字节被称为 payload。一般情况下,攻击代码可能会尝试启动一个 shell 程序,这样攻击者就可以获取对受害计算机直接操控的权限。

对抗缓冲区溢出攻击

GCC 提供了一些机制来防止攻击者利用缓冲区溢出来获取系统控制权限,如下:

  • 栈随机化

    一般情况下,程序每次运行的虚拟地址都是固定不变的,这样我们就可以直接将想要执行的函数地址覆盖在返回地址上。

    栈随机的思想使得每次程序运行时栈的位置都有变化,这类技术我们成为 地址空间布局随机化(Address-Space Layout Randomization,ASLR)。原理是每次运行程序时,程序的不同部分包括程序代码、库代码和全局变量等数据都会被加载到内存的不同区域。

    当然攻击者也有对抗保护的方法,就是在实际的的攻击代码前面插入很多 nop 指令,那么我们只要任意命中一个 nop 都可以导致我们恶意代码的顺利执行。这个序列常用的术语 “nop sled” 就是”滑“过序列的意思。

  • 栈破坏检测

    栈破坏检测是在栈已经被破坏后进行的防御措施,GCC 中对应的机制叫做栈保护者(stack-protector)。其思想是在缓冲区和返回地址之前插入一段随机数,在函数返回之前检查这个随机数是否被更改,如果被更改就终止程序。这个随机数叫做金丝雀(canary)值。

  • 栈不可执行

    这个保护措施直接限制了攻击者像系统中插入可执行代码的行为,也就是 NX(No-Execute),通过这个特性我们可以将栈设置为不可执行,也就是向栈上插入的代码是不会运行的。

浮点数

书上还有一小节浮点数的部分

当前使用的 AVX 指令集中,浮点数的机器代码风格和整数数据的各种操作类似,不同就在于寄存器和指令的表示,浮点数通常有专用的浮点寄存器和浮点指令。

AVX 指令集是英特尔(Intel)和AMD(Advanced Micro Devices)处理器架构中的一种 SIMD(Single Instruction, Multiple Data,单指令多数据)扩展指令集


我要开始快乐国庆节噜!!

身上颓废的上学味已经消散,现在我浑身都散发着浓郁的爱国气息🥳


CSAPP 第三章 程序的机器级表示 总结
https://shmodifier.github.io/2023/09/30/CSAPP-第三章-程序的机器级表示/
作者
Modifier
发布于
2023年9月30日
许可协议