CSAPP5——程序机器级表示
从C到机器代码
机器代码就是处理器能够直接执行的字节层面上的程序,但是对于人类来说基本上是不可读的,所以把字节按照具体含义进行『翻译』,就成了人类可读的汇编代码。注意这里的用词是『翻译』而不是『编译』,可以认为汇编代码就是机器代码的可读形式。
C->可执行程序:
- C 语言代码(a.c)经过编译器的处理(
gcc -0g -S
)成为汇编代码(a.s) - 汇编代码(a.s)经过汇编器的处理(
gcc
或as
)成为对象程序(a.o) - 对象程序(a.o)以及所需静态库(lib.a)经过链接器的处理(
gcc
或ld
)最终成为计算机可执行的程序
先来看一段C代码及其经过汇编产生的代码
1 | // C 代码 |
C 代码的意思很简单,就是把值 t
存储到指针 dest
指向的内存中。对应到汇编代码,就是把 8字节(也就是四个字, Quad words)移动到内存中(这也就是为什叫做 movq
)。t
的值保存在寄存器 %rax 中,dest
指向的地址保存在 %rbx 中,而 *dest
是取地址操作,对应于在内存中找到对应的值,也就是 M[%rbx]
,在汇编代码中用小括号表示取地址,即 (%rbx)
。最后转换成 3 个字节的指令,并保存在 0x40059e
这个地址中。
CPU 指令
一个实例
了解寄存器和内存模型以后,就可以来看汇编语言到底是什么了。下面是一个简单的程序example.c
。
1 | int add_a_and_b(int a, int b) { |
gcc 将这个程序转成汇编语言。
1 | $ gcc -S example.c |
上面的命令执行以后,会生成一个文本文件example.s
,里面就是汇编语言,包含了几十行指令。这么说吧,一个高级语言的简单操作,底层可能由几个,甚至几十个 CPU 指令构成。CPU 依次执行这些指令,完成这一步操作。
example.s
经过简化以后,大概是下面的样子。
1 | _add_a_and_b: |
可以看到,原程序的两个函数add_a_and_b
和main
,对应两个标签_add_a_and_b
和_main
。每个标签里面是该函数所转成的 CPU 运行流程。
1 | push %ebx |
这一行里面,push
是 CPU 指令,%ebx
是该指令要用到的运算子。一个 CPU 指令可以有零个到多个运算子。
下面我就一行一行讲解这个汇编程序,建议读者最好把这个程序,在另一个窗口拷贝一份,省得阅读的时候再把页面滚动上来。
push 指令
根据约定,程序从_main
标签开始执行,这时会在 Stack 上为main
建立一个帧,并将 Stack 所指向的地址,写入 ESP 寄存器。后面如果有数据要写入main
这个帧,就会写在 ESP 寄存器所保存的地址。
然后,开始执行第一行代码。
1 push 3
push
指令用于将运算子放入 Stack,这里就是将3
写入main
这个帧。
虽然看上去很简单,push
指令其实有一个前置操作。它会先取出 ESP 寄存器里面的地址,将其减去4个字节,然后将新地址写入 ESP 寄存器。使用减法是因为 Stack 从高位向低位发展,4个字节则是因为3
的类型是int
,占用4个字节。得到新地址以后, 3 就会写入这个地址开始的四个字节。
1 push 2
第二行也是一样,push
指令将2
写入main
这个帧,位置紧贴着前面写入的3
。这时,ESP 寄存器会再减去 4个字节(累计减去8)。
call 指令
第三行的call
指令用来调用函数。
1 call _add_a_and_b
上面的代码表示调用add_a_and_b
函数。这时,程序就会去找_add_a_and_b
标签,并为该函数建立一个新的帧。
下面就开始执行_add_a_and_b
的代码。
1 push %ebx
这一行表示将 EBX 寄存器里面的值,写入_add_a_and_b
这个帧。这是因为后面要用到这个寄存器,就先把里面的值取出来,用完后再写回去。
这时,push
指令会再将 ESP 寄存器里面的地址减去4个字节(累计减去12)。
mov 指令
mov
指令用于将一个值写入某个寄存器。
1 mov %eax, [%esp+8]
这一行代码表示,先将 ESP 寄存器里面的地址加上8个字节,得到一个新的地址,然后按照这个地址在 Stack 取出数据。根据前面的步骤,可以推算出这里取出的是2
,再将2
写入 EAX 寄存器。
下一行代码也是干同样的事情。
1 mov %ebx, [%esp+12]
上面的代码将 ESP 寄存器的值加12个字节,再按照这个地址在 Stack 取出数据,这次取出的是3
,将其写入 EBX 寄存器。
add 指令
add
指令用于将两个运算子相加,并将结果写入第一个运算子。
1 add %eax, %ebx
上面的代码将 EAX 寄存器的值(即2)加上 EBX 寄存器的值(即3),得到结果5,再将这个结果写入第一个运算子 EAX 寄存器。
pop 指令
pop
指令用于取出 Stack 最近一个写入的值(即最低位地址的值),并将这个值写入运算子指定的位置。
1 pop %ebx
上面的代码表示,取出 Stack 最近写入的值(即 EBX 寄存器的原始值),再将这个值写回 EBX 寄存器(因为加法已经做完了,EBX 寄存器用不到了)。
注意,pop
指令还会将 ESP 寄存器里面的地址加4,即回收4个字节。ret 指令
ret
指令用于终止当前函数的执行,将运行权交还给上层函数。也就是,当前函数的帧将被回收。
1 ret
可以看到,该指令没有运算子。
随着add_a_and_b
函数终止执行,系统就回到刚才main
函数中断的地方,继续往下执行。
1 add %esp, 8
上面的代码表示,将 ESP 寄存器里面的地址,手动加上8个字节,再写回 ESP 寄存器。这是因为 ESP 寄存器的是 Stack 的写入开始地址,前面的pop
操作已经回收了4个字节,这里再回收8个字节,等于全部回收。
1 ret
最后,main
函数运行结束,ret
指令退出程序执行。