挂一个汇编编写时的(疑似)常见问题 awa

· · 科技·工程

序言

这是一个常见(bushi)的汇编程序编写问题,本蒟蒻在此留下一篇文章。
这是关于函数编写的,尤其在使用 GCC 链接时 main 会导致严重的内存问题。

适用范围

在阅读之前,请检查以下条件(如果不符合的话这篇文章也帮不到你哦):

  1. 正在用汇编语言Assembly, ASM)编写程序
  2. 面向 GNU/Linux 系统编程(貌似 MS-Windows 上也是一样的,但是我没试过)
  3. 面向 x86 架构的CPU编程(同样地 ARM 可能也是这个道理)
  4. 使用 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
    ::::

作者的环境

这里顺带披露一下作者的环境,但是无关大碍,可以跳过:

  1. 主系统: Microsoft Windows 10 版本 22H2,操作系统内部版本 19045.6466
  2. WSL(Windows Subsystem for Linux,适用于 GNU/Linux 的 Windows 子系统) 内核版本 6.18.33.2-2
  3. 子系统 Ubuntu 24.04.4 LTS
  4. NASM version 2.16.01
  5. GCC (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
  6. GNU ld (GNU Binutils for Ubuntu) 2.42
  7. 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 !  
(好吧就一遍,懒得复制粘贴了)