挂一个汇编编写时的(疑似)常见问题 awa
序言
这是一个常见(bushi)的汇编程序编写问题,本蒟蒻在此留下一篇文章。
这是关于函数编写的,尤其在使用 GCC 链接时 main 会导致严重的内存问题。
适用范围
在阅读之前,请检查以下条件(如果不符合的话这篇文章也帮不到你哦):
- 正在用汇编语言(Assembly, ASM)编写程序
- 面向 GNU/Linux 系统编程(貌似 MS-Windows 上也是一样的,但是我没试过)
- 面向 x86 架构的CPU编程(同样地 ARM 可能也是这个道理)
- 使用 GCC 处理链接过程
*注:可以直接使用 LD 进行链接,正常情况下依然适用 ::::info[如何配置环境] 由于本蒟蒻平常使用 Ubuntu系统,这里放的就是 Debian 系 - APT 的安装命令,如果你使用的是 Arch 系统就忍着-> 如果你使用的是 Arch 系统,也不要放弃解决问题, Pacman 等工具的操作逻辑也是差不多的。\$ sudo dpkg --add-architecture i386
\$ sudo apt update
\$ sudo apt install build-essential gdb nasm
\$ sudo apt install gcc-multilib
\$ sudo apt install libc6-dev:i386
::::
作者的环境
这里顺带披露一下作者的环境,但是无关大碍,可以跳过:
- 主系统: Microsoft Windows 10 版本 22H2,操作系统内部版本 19045.6466
- WSL(Windows Subsystem for Linux,适用于 GNU/Linux 的 Windows 子系统) 内核版本 6.18.33.2-2
- 子系统 Ubuntu 24.04.4 LTS
- NASM version 2.16.01
- GCC (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
- GNU ld (GNU Binutils for Ubuntu) 2.42
- CLion 2026.2.1 (远程连接 WSL 用的,因为我实在用不好 vim...TvT)
引出
要是单纯写只调用系统 API 的 ASM 程序确实太费时费力了,好在 GNU 提供了标准的、符合C ABI的依赖—— glibc (C 语言的依赖是很容易被ASM调用的)
可能就会有很多人去用 GCC 或者 LD 链接。
C 语言中有 main ,而用 GCC 链接,通常情况下也会有 main 标签,以供链接。
C 语言中的 main,是一个函数。大多数情况下应当把 ASM 中的 main 也写成这种函数的形式:
main:
push ebp ; 被调用者负责保存调用者的栈帧基址
mov ebp, esp ; 更新局域栈帧基址
; 主体部分...
xor eax, eax ; 将 eax 置 0,代表返回 0
mov esp, ebp ; 更新栈顶
pop ebp ; 恢复调用者的栈帧基址
ret ; 更新 eip,返回调用者处
按道理来讲,main确实应该是函数。(废话)
但是,末尾的销毁栈帧,有一个需要注意的地方!
问题
这是销毁栈帧的代码:
mov esp, ebp ; 可选,但不写的话需要保证前面是严格栈平衡的。某些情况下(如最后一次调用函数后没有负责清理栈上的参数等随性的情况下),这条指令很重要
pop ebp ; 将栈顶保存的调用者栈帧基址弹出恢复
ret ; 从栈顶获取返回地址,并更新 EIP,回到调用者处
但有人可能会写成这样(我试探了一下某 AI,然后它给我的就是这种代码。引以为戒吧,AI 在 ASM 这方面不太聪明的样子 hh):
; 错误示范
; 刚才我们说了严格栈平衡的程序在 pop ebp 前没有 mov esp,ebp 也不是问题。所以问题并不在这里
pop ebp
mov esp, ebp ; 问题出在这里
ret ; 程序崩溃在这里
这会导致段错误的!!!
听君一席话,如听一席话
敲黑板)所以千万不要在 pop 恢复了 ebp 后再度用 ebp 给 esp 赋值哦!
为何?Why?
既然你已经读到了这里,那么恭喜你,你要脑洞大开了(无任何隐喻)
在这里,我们需要铺垫一个基础知识:过程(函数)的原理:
过程
过程 是一段可以被多次调用的代码块,类似于高级语言中的函数(Function)或子程序(Subroutine)。
在汇编中,过程通过 CALL 指令调用,通过 RET 指令返回。CALL 会将返回地址压入栈中,RET 从栈中弹出返回地址并跳转回去。
在过程内部,应当保存和恢复所有被修改的非易失寄存器,这是汇编编程的基本礼仪。不这样做会导致调用方的寄存器值被意外修改。CALL 和 RET 原理
指令 实际执行的操作 CALL label push eip(保存下一条指令地址)+ jmp label RET pop eip(从栈中弹出地址并跳转)
*注:EIP 寄存器用于记录下一条将要执行的指令地址
栈帧结构
高地址区域
+------------------
| 参数2 <-- [ebp + 12]
+------------------
| 参数1 <-- [ebp + 8]
+------------------
| 返回地址 <-- [ebp + 4]
+------------------
| 旧EBP <-- [ebp] (当前 EBP 指向这里)
+------------------
| 局部变量 <-- [ebp - 4], [ebp - 8], ...
+------------------ <-- ESP 指向这里
低地址区域*注:在 GNU/Linux 中,栈自高地址向低地址发展
实践出真知
我们将会用这道题目进行调试(使用 GDB)演示:
B2003-输出第二个整数
请不要试图问我为何选这么简单的题目,因为汇编写稍微大一点的程序就很反人类(雀食正确代码:
section .bss bin resd 1 ans resd 1
section .data fmt_in db "%u", 0 fmt_out db "%u", 0xA, 0
section .text global main extern scanf extern printf
main: push ebp mov ebp, esp
push bin
push fmt_in
call scanf
add esp, 8
push ans
push fmt_in
call scanf
add esp, 8
push bin
push fmt_in
call scanf
add esp, 8
push dword [ans]
push fmt_out
call printf
add esp, 8
xor eax, eax
mov esp, ebp
pop ebp
ret
#### 错误代码:
section .bss bin resd 1 ans resd 1
section .data fmt_in db "%u", 0 fmt_out db "%u", 0xA, 0
section .text global main extern scanf extern printf
main: push ebp mov ebp, esp
push bin
push fmt_in
call scanf
add esp, 8
push ans
push fmt_in
call scanf
add esp, 8
push bin
push fmt_in
call scanf
add esp, 8
push dword [ans]
push fmt_out
call printf
add esp, 8
xor eax, eax
pop ebp
mov esp, ebp ; 错误:这一行到了 pop ebp 后面
ret
#### 构建错误示例
> \$ nasm -f elf32 -g B2003.asm
> \$ gcc -m32 B2003.o -o B2003
#### 执行 & 问题的暴露
> \$ ./B2003
> 1 2 3
> 2
> Segmentation fault (core dumped)
> \$ echo \$?
> 319
>
我们可以看到,程序输出了正确的结构,但后续发生了段错误,返回值为319.
我们可以知道的是,崩溃一定发生在 call printf 和 ret 之间。
此时我们修改一下代码,添加一个标签以供调试:
section .bss bin resd 1 ans resd 1
section .data fmt_in db "%u", 0 fmt_out db "%u", 0xA, 0
section .text global main extern scanf extern printf
main: push ebp mov ebp, esp
push bin
push fmt_in
call scanf
add esp, 8
push ans
push fmt_in
call scanf
add esp, 8
push bin
push fmt_in
call scanf
add esp, 8
push dword [ans]
push fmt_out
call printf
error: ; 此处我们添加一个标签,在调试时会将其设定为断点处,方便定位问题。
add esp, 8
xor eax, eax
pop ebp
mov esp, ebp ; 错误:这一行到了 pop ebp 后面
ret
#### 查找原因
在这里我们使用 GDB 进行调试。
***注:GDB 中显示的指令为“即将执行的下一条指令”**
> \$ gdb ./B2003
> ...
> Reading symbols from ./B2003...
> \(gdb\) break main
> Breakpoint 1 at 0x11a0: file B2003.asm, line 15.
> \(gdb\) break error
> Breakpoint 2 at 0x11ec: file B2003.asm, line 37.
> \(gdb\) run
> ...
> Breakpoint 1, main \(\) at B2003.asm:15
> 15 push ebp
这里我们暂停在了 main 开始的地方,而“push ebp”的指令还没有执行,现在看看 ebp 的值和内存吧:
> \(gdb\) p/x \$ebp
> \$1 = 0x0
> \(gdb\) x/4wx \$esp
> 0xffffd33c: 0xf7d9fcb9 0x00000001 0xffffd3f4 0xffffd3fc
根据栈帧的结构,我们可以得知,main 的返回地址为 0xf7d9fcb9,而 ebp 初始值为 **0 ——注意这很重要,关系到最后的程序崩溃是如何发生的。**
> \(gdb\) si
> 16 mov ebp, esp
> \(gdb\) x/4wx \$esp
> 0xffffd338: 0x00000000 0xf7d9fcb9 0x00000001 0xffffd3f4
我们执行了“push ebp”,ebp 被压入栈顶,现在栈顶向高地址(栈底)的第一个 dword (双字,32位)为 ebp 的内容,即 **0x0** ;栈顶向高地址的第二个 dword 为该函数的返回地址。
> \(gdb\) continue
> Continuing.
> 1 2 3
> 2
>
> Breakpoint 2.1, error () at B2003.asm:37
> 37 xor eax, eax
我们快进到下一个断点(即为我们定位到的崩溃位置区间起始部分),途中给出了输入,程序也正常为我们提供了输出
> \(gdb\) si
> 38 pop ebp
> \(gdb\) x/4wx \$esp
> 0xffffd078: 0x00000000 0xf7d9fcb9 0x00000001 0xffffd134
> \(gdb\) si
> error \(\) at B2003.asm:39
> 39 mov esp, ebp
> \(gdb\) p/x \$ebp
> $2 = 0x0
> \(gdb\) si
> error () at B2003.asm:40
> 40 ret
> \(gdb\) si
> Program received signal SIGSEGV, Segmentation fault.
看到了吗?程序崩溃了,发生了段错误。
我们复盘一下:
执行 xor eax, eax
此后我们查看了内存,栈顶为 0x0,真实返回地址为 0xf7d9fcb9(前面我们提到过)
执行 pop ebp -> 此时栈顶的0x0(也就是一开始ebp的值)弹出,给到了 ebp,ebp 为 0x0.
执行 mov esp, ebp -> esp 被赋给了ebp,**此时 esp 为 0x0**
**执行 ret -> 此时,计算机应取出栈顶下的返回地址,但 esp 栈顶地址寄存器为 0!计算机试图取此处的地址,但这里是非法内存!**
所以,就这样段错误了
### 总结
千万不要把函数尾声的防御性代码 mov esp, ebp 放在恢复调用者 ebp 代码 pop ebp 之后!程序会崩溃的!!!
### More
编写不易(~~骗你的,其实就写了一会~~),可以点个赞吗 awa
### 最后
~~不是你到底什么时候真正讲完啊?!~~ <--站一边去,我们班不差你这一个
!!!重要的事情说三遍:
Linux 是操作系统内核,操作系统应该是 GNU/Linux !
(好吧就一遍,懒得复制粘贴了)