面试官:你会不会汇编?啊?我会不会编?

大家好,我是坤虫🐛。今天我们一起来分析一个简单的汇编程序,这将帮助我们深入了解 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)中已涉及

参考链接

相关推荐
我是菜鸡1638411 小时前
Arm64 中 B跳转汇编的使用是如何实现的
汇编语言
Terasic友晶科技13 天前
第22篇 基于ARM A9处理器用汇编语言实现中断<四>
fpga开发·汇编语言·de1-soc开发板·按键和定时器中断
Terasic友晶科技15 天前
第23篇 基于ARM A9处理器用汇编语言实现中断<五>
fpga开发·汇编语言·de1-soc开发板·定时器中断周期
Terasic友晶科技18 天前
第20篇 基于ARM A9处理器用汇编语言实现中断<二>
fpga开发·汇编语言·中断·de1-soc开发板
hummhumm20 天前
第30章 汇编语言--- 性能优化技巧
开发语言·性能优化·程序设计·优化·汇编语言·高级语言·低级语言
Terasic友晶科技22 天前
第21篇 基于ARM A9处理器用汇编语言实现中断<三>
fpga开发·汇编语言·中断·de1-soc开发板
Terasic友晶科技25 天前
第19篇 基于ARM A9处理器用汇编语言实现中断<一>
汇编语言·中断·de1-soc开发板
hummhumm1 个月前
第8章 汇编语言--- 循环结构
java·运维·开发语言·汇编·数据结构·算法·汇编语言
Kent_J_Truman1 个月前
微机接口课设——基于Proteus和8086的打地鼠设计(8255、8253、8259)Proteus中Unknown 1-byte opcode / Unknown 2-byte opcode错误
proteus·汇编语言