大家好,我是坤虫🐛。今天我们一起来分析一个简单的汇编程序,这将帮助我们深入了解 CPU、寄存器、栈是如何协调进行计算的。曾经的我对 CPU 的计算过程、寄存器、函数调用的原理感到很困惑,但通过学习和实践,终于弄明白了。希望这篇文章能帮助你更好地理解这些原理!
我的开发环境
GCC 的版本是 7.5.0,CPU 硬件架构是 64 位。
shell
[root@kundebug /tmp]$ gcc -v
使用内建 specs。
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/local/gcc-7.5.0/libexec/gcc/x86_64-redhat-linux/7.5.0/lto-wrapper
目标:x86_64-redhat-linux
配置为:../configure --prefix=/usr/local/gcc-7.5.0 --mandir=/usr/local/gcc-7.5.0/share/man --infodir=/usr/local/gcc-7.5.0/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-bootstrap --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-linker-build-id --with-linker-hash-style=gnu --enable-languages=c,c++,go --enable-plugin --enable-initfini-array --disable-libgcj --enable-graphite --enable-gnu-indirect-function --with-tune=generic --build=x86_64-redhat-linux --disable-multilib
线程模型:posix
gcc 版本 7.5.0 (GCC)
[root@kundebug /tmp]$ uname -m
x86_64
[root@kundebug /tmp]$
C语言示例代码
保存在 test_asm.c 文件中
c
int add_a_and_b(int a, int b) {
return a + b;
}
int main() {
int x = 3;
int y = 4;
return add_a_and_b(x, y);
}
编译生成汇编代码
执行如下编译命令,就可以得到汇编代码,保存在 test_asm.s 文件中:
shell
[root@kundebug /tmp]$ gcc -S test_asm.c
[root@kundebug /tmp]$ ll
总用量 8.0K
-rw-r--r-- 1 root root 128 2025/01/23 13:15:46 test_asm.c
-rw-r--r-- 1 root root 851 2025/01/23 13:17:09 test_asm.s
[root@kundebug /tmp]$ cat test_asm.s
.file "test_asm.c"
.text
.globl add_a_and_b
.type add_a_and_b, @function
add_a_and_b:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size add_a_and_b, .-add_a_and_b
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $3, -4(%rbp)
movl $4, -8(%rbp)
movl -8(%rbp), %edx
movl -4(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call add_a_and_b
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (GNU) 7.5.0"
.section .note.GNU-stack,"",@progbits
[root@kundebug /tmp]$
直接使用 gcc -S 编译生成的汇编代码中会有一些辅助调试的信息。
像上面的.LFB0
、.LFE0
、.LFB1
、.LFE1
符号是由编译器自动生成的标签,主要用于标识函数的开始和结束。
.LFB0
(Local Function Begin 0) .LFE0
(Local Function End 0)
另外还有一些编译器生成的以 .cfi_
为开始的标签,比如:
.cfi_startproc
.cfi_def_cfa_offset
.cfi_offset
.cfi_def_cfa_register
.cfi_def_cfa
.cfi_endproc
这些都是编译器生成的伪指令,不影响程序的执行,主要是用于向调试器(比如 gdb)提供函数栈和寄存器相关的信息,支撑调试器的工作的。
我们希望得到一个比较干净的汇编代码来分析, 所以可以在编译时增加选项 -fno-asynchronous-unwind-tables
, 这个选项是禁用异步展开表(Unwind Tables)的意思。
Unwind Tables,展开表。它是一组数据结构,用来支持程序在运行时进行堆栈展开(stack unwinding),堆栈展开是错误处理和调试的重要机制。
这样一来就不会再有这些标签了。
shell
[root@kundebug /tmp]$ gcc -fno-asynchronous-unwind-tables -S test_asm.c && cat test_asm.s
.file "test_asm.c"
.text
.globl add_a_and_b
.type add_a_and_b, @function
add_a_and_b:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
.size add_a_and_b, .-add_a_and_b
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $3, -4(%rbp)
movl $4, -8(%rbp)
movl -8(%rbp), %edx
movl -4(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call add_a_and_b
leave
ret
.size main, .-main
.ident "GCC: (GNU) 7.5.0"
.section .note.GNU-stack,"",@progbits
[root@kundebug /tmp]$
你可能观察到了,这份汇编代码中使用到的指令有:pushq movq subq movl。 这是 ATT 格式的汇编代码。ATT 格式也是 GCC、OBJDUMP 等工具的默认格式。
而 Microsoft 的工具和 Intel 的文档,汇编代码都是 Intel 格式的。 这两种格式不太相同:
- Intel 格式:mov
- ATT 格式:movq
当然,GCC 也可以产生 Intel 格式的汇编代码,只需要带上参数 -masm=intel。
一份干净的汇编代码
接下来,我给这份汇编代码,增加注释和序号,方便我们按照序号来分析。 不同的汇编器,注释符号不一样,有使用英文分号;
做为注释符号的,有使用井号#
作为注释符号。我这里使用的是 GCC 的工具链,汇编代码中的注释,是以英文分号;
做为注释符号。
asm
add_a_and_b:
pushq %rbp ; (11)
movq %rsp, %rbp ; (12)
movl %edi, -4(%rbp) ; (13)
movl %esi, -8(%rbp) ; (14)
movl -4(%rbp), %edx ; (15)
movl -8(%rbp), %eax ; (16)
addl %edx, %eax ; (17)
popq %rbp ; (18)
ret ; (19)
main:
pushq %rbp ; (1)
movq %rsp, %rbp ; (2)
subq $16, %rsp ; (3)
movl $3, -4(%rbp) ; (4)
movl $4, -8(%rbp) ; (5)
movl -8(%rbp), %edx ; (6)
movl -4(%rbp), %eax ; (7)
movl %edx, %esi ; (8)
movl %eax, %edi ; (9)
call add_a_and_b ; (10)
leave ; (20)
ret ; (21)
逐行分析
(1) pushq %rbp
push 和 pop 指令,是用来在寄存器和栈(主存,或者说内存条)之间进行操作的。
push 指令是将寄存器的值,保存到主存中。
pop 指令是将栈(主存)中保存的值,恢复到寄存器里。
%rbp 寄存器,是栈帧基址寄存器,存储栈中最高位数据的内存地址。 我们知道,栈是向下生长,堆是向上生长的。 栈中最高位数据的内存地址,就是栈的起始地址。
在进入 main 函数之前,我们无法确定 rbp 寄存器的值是什么。 但是由于 main 函数内部也会使用 rbp 寄存器, 所以就需要暂时把 %rbp 寄存器的值先存到栈(主存)里面, 等 main 函数处理完成之后,再从栈(主存)中将值恢复到 %rbp 寄存器。
在函数的入口处,将 %rbp 的值入栈保存,在函数的出口处出栈,这是C语言编译器的规定。
这样做是为了确保函数在调用前后,%rbp 寄存器的值不会改变,不影响 main 函数的调用者。
push 和 pop 指令只有一个操作数, 我们不需要指定将寄存器的值 push 到栈的哪个地址,以及将栈的哪个地址的值 pop 到寄存器。
是因为,对栈进行读写的内存地址,是由 %rsp 栈指针寄存器自动管理的。
push 入栈和 pop 出栈指令执行之后,%rsp 寄存器存储的栈指针的值会自动更新。
因为栈是从高地址位向低地址位生长。
push 指令是增加栈元素的操作,所以执行 push 后,%rsp 寄存器的值会 -4(64 位机器就是 -8)。
pop 指令是减少栈元素的操作,所以执行 pop 后,%rsp 寄存器的值会 +4(64 位机器就是 +8)。
将 %rbp 寄存器的值,放入栈中,这样就构造出了 main 函数的栈帧。 %rsp 寄存器存储的栈指针的值,会自动更新,指向新的栈顶。
(2) movq %rsp, %rbp
mov 指令有这几种:movb(8位)、movw(16位)、movl(32位)、movq(64位)
mov 指令的基本格式是:movx source, destination
上面提到 rsp 栈指针寄存器是自动管理的,而当前 %rsp 中保存的是新的栈顶。 所以这条指令的意思就是,将 %rsp 寄存器的值,传递到 %rbp 中。 也就是设置当前函数栈帧的基址,可以很方便地根据偏移访问局部变量。
(3) subq $16, %rsp
subq 指令表示:从寄存器或内存地址中减去一个值。 $16 是一个立即数(即字面量数值),就表示数字 16。 %rsp 是栈指针寄存器(Stack Pointer Register),用于指向当前栈的顶部。
subq $16, %rsp 表示将栈指针 rsp 减少 16 个单位,这表明要留出16个单位的空间来分配局部变量了。 因为每个局部变量都需要占据一定的空间,我们这里有两个局部变量 x 和 y, 所以让栈会向下增长 16 个单位,来为这 2 个局部变量提供存储空间。
(4) movl $3, -4(%rbp)
将数字3,存储到栈上的某个位置。
这个位置是从 rbp
寄存器指向的栈帧的顶部,向下偏移 4 字节的位置。
-4(%rbp) 表示当前函数栈帧中第一个局部变量的位置。
它通常用于存储简单的变量,如 int
类型的局部变量。
(5) movl $4, -8(%rbp)
将数字4,存储到栈上的某个位置。
-8(%rbp) 表示栈帧中第二个局部变量的位置。
(6) movl -8(%rbp), %edx
读取栈中变量 y 的值,加载到寄存器 %edx 中。
(7) movl -4(%rbp), %eax
读取栈中变量 x 的值,加载到寄存器 %eax 中。
(8) movl %edx, %esi
%edx 存储了变量 y 的值,现在复制到 %esi 寄存器中,作为第二个参数。
(9) movl %eax, %edi
%eax 存储了变量 x 的值,现在复制到 %edi 寄存器中,作为第一个参数。
在 x86-64 架构中,有一组特定的寄存器专门用来传递函数的参数。
参数位置 | 寄存器 | 用途 |
---|---|---|
第一个参数 | %rdi |
传递第一个参数 |
第二个参数 | %rsi |
传递第二个参数 |
第三个参数 | %rdx |
传递第三个参数 |
第四个参数 | %rcx |
传递第四个参数 |
第五个参数 | %r8 |
传递第五个参数 |
第六个参数 | %r9 |
传递第六个参数 |
如果函数有超过 6 个参数,从第 7 个参数开始,就会通过栈传递。
你可能有疑问,上面的指令使用的是 %esi %edi 寄存器。 表格里面给出来的是 %rdi %rsi 寄存器。 那 %edi 与 %rdi 有什么区别呢?
其实,%rdi 和 %edi 指向同一个物理寄存器。只不过,
- %rdi 是用于操作 64 位长度,也就是 8 字节,寄存器的低 8 位部分。
- %edi 是用于操作 32 位长度,也就是 4 字节,寄存器的低 8 位部分。
- %di 是用于操作 16 位长度,也就是 2 字节,寄存器的低 8 位部分。
- %dil 是用于操作 8 位长度,也就是 1 字节,寄存器的低 8 位部分。
当我们使用 %edi 时,相当于修改了 %rdi 的低 32 位,高 32 位会自动清零。
根据代码中变量的类型,编译器生成汇编代码时,会使用对应的寄存器名称。
(10) call add_a_and_b
这行指令的意思是调用 add_a_and_b 函数。
call 指令不仅仅是跳转到指定的函数,还需要处理栈和控制流程,保证函数调用和返回之后也能正常运行。
我们知道,程序计数器,是用来存储下一条指令所在内存的地址。
CPU 的控制器,会参照程序计数器的数值,从内存中读取指令,并执行。
call 指令在将 add_a_and_b 函数的入口地址,设定到程序计数器之前,
call 指令还需要把函数调用结束后,要执行的那一条指令的地址,存储在栈中。
当函数执行完毕后,执行 ret 指令,就会把刚刚说的保存到栈中的地址,再设定到程序计数器中。
这样一来,执行流程就接续上了。
所以,call 指令背后的操作:
-
call 指令会将下一条指令的地址 (也就是
call
指令执行后的那一条指令的地址)压入栈中,为了在函数执行完后能够返回到正确的地方继续执行主程序。 -
call 指令会将目标函数 add_a_and_b 的地址加载到程序计数器(%rip)中,从而跳转到目标函数开始执行。
(11) pushq %rbp
作用同(1),形成了新的函数 add_a_and_b 的帧。
(12) movq %rsp, %rbp
作用同(2),更新 %rbp 寄存器指针,使其指向最新的栈顶。
也就是设置当前函数栈帧的基址,可以很方便地根据偏移访问局部变量。
(13) movl %edi, -4(%rbp)
上一步,已经将 %rbp 寄存器指针指向了栈顶。
当前步骤,则是将专用传参寄存器中保存的第一个参数,恢复到栈中,栈要增长了。
(14) movl %esi, -8(%rbp)
当前步骤,也是将专用传参寄存器中保存的第二个参数,恢复到栈中,栈要增长了。
(15) movl -4(%rbp), %edx
将 -4(%rbp) 的值,也就是3,复制到 %eax 寄存器。
注意,-4(%rbp)的值,本质上是在栈中,CPU 必须要先把栈中的值,复制到寄存器中,才能做计算。CPU 只能对寄存器中的数据进行直接操作。
(16) movl -8(%rbp), %eax
将栈中位置 -8(%rbp) 的值,也就是8,复制到 %eax 寄存器。
%eax 是一个通用寄存器,作为 32 位操作的累加寄存器,主要用来做加法、乘法等算术运算。
(17) addl %edx, %eax
加法指令格式:ADD A,B 将 A 与 B 相加,结果存在 A 中;
将 %edx 与 %eax 中的数值相加,结果存在 %edx 中。
(18) popq %rbp
在函数结束时,需要将 %rbp 寄存器恢复为调用者的值, 以回到调用者(也就是 main 函数)的栈帧。
popq 指令执行了如下动作:
- 从栈中取出调用者的 %rbp 值,也就是 (11) 被保存的 %rbp 的值;
- 将这个值存储到 %rbp 中,这样就恢复成 调用者(main 函数)的 %rbp 的值;
- 更新 %rsp,让它指向新的栈顶位置,回收栈帧。
(19) ret
ret 是汇编中的返回指令。 它的作用是从当前函数,返回到调用者函数的执行位置。
ret 指令从栈顶弹出返回地址(也就是 main 函数 call 指令的下一条指令的地址), 并将这个返回地址,加载到指令指针寄存器 %rip 中。
这样一来,CPU 取到的下一条要执行的指令,就接续到 main 函数中的 leave 指令了。
(20) leave
leave 指令,用于恢复栈帧。
等价于 movq %rbp, %rsp 和 popq %rbp。
- 将 %rbp 恢复为其调用者的 %rbp。
- 通过更新 %rsp,让它指向新的栈顶位置,回收栈帧。
(21) ret
ret指令的作用,在步骤(19)中已涉及
参考链接
- 《程序是怎样跑起来的》, by 矢泽久雄
- 《汇编语言》(第4版),清华大学出版社
- 《汇编语言入门教程》,阮一峰