从 C/C++ 理解汇编,先固定目标架构、ABI、编译器和优化选项。同一段源码可以生成不同指令;变量也不一定各占一个栈槽,函数调用可能被内联或在编译期求值。
编译、汇编和链接各负责什么
跳转到“编译、汇编和链接各负责什么”编译器前端会经过词法分析、语法分析、语义检查,再生成中间表示;后续优化和代码生成把程序转换为目标平台的指令。这些是理解实现的常见阶段,不要求每个编译器都把它们做成独立程序或单独运行一遍。
| 命令形式 | 主要产物 | 还没有完成的工作 |
|---|---|---|
gcc -E example.c | 预处理后的源码 | 语义检查、代码生成等 |
gcc -S example.c | 汇编文本,通常为 example.s | 汇编成目标文件与链接 |
gcc -c example.c | 目标文件,GNU/Linux 常为 example.o | 解析其他文件与库中的符号等链接工作 |
gcc example.o -o example | 按当前平台工具链链接的可执行文件 | 仍需适合的运行环境 |
Clang 也支持 clang -S example.c -o example.s 这类接口,但具体输出不能据此保证与 GCC 相同。Windows、GNU/Linux 和裸机环境的文件格式、运行库与启动代码不同,不能把一个平台的汇编片段直接当作另一平台的完整程序。
同一个 add,先看实际生成结果
跳转到“同一个 add,先看实际生成结果”将下面保存为 add.c。示例只传入 2 和 3;一般情况下,C 的有符号 int 相加必须避免超出可表示范围,不能把源语言的未定义溢出行为解释成固定的机器整数回绕规则。
int add(int a, int b) { return a + b;}
int main(void) { int result = add(2, 3); return result == 5 ? 0 : 1;}本页输出来自 GCC 13.3.0 的 GNU/Linux x86-64、MinGW-w64 GCC 13.1.0 的 Windows x64,以及 Arm GNU Toolchain GCC 13.3.1。以下汇编框摘录函数区域,完整文件还有段、符号、属性等汇编器指令;摘录不是可单独链接的工程。
GNU/Linux x86-64:低优化时的局部栈槽
跳转到“GNU/Linux x86-64:低优化时的局部栈槽”这里使用 System V AMD64 ABI,并让 GCC 保留帧指针。生成命令如下:
gcc -std=c11 -Wall -Wextra -Werror -O0 -fno-omit-frame-pointer -fno-asynchronous-unwind-tables -fno-stack-protector -S -masm=intel add.c -o add-linux-O0.sadd: endbr64 push rbp mov rbp, rsp mov DWORD PTR -4[rbp], edi mov DWORD PTR -8[rbp], esi mov edx, DWORD PTR -4[rbp] mov eax, DWORD PTR -8[rbp] add eax, edx pop rbp ret| 指令或位置 | 在这个函数中的意义 |
|---|---|
endbr64 | 当前工具链配置生成的控制流保护入口标记,不执行加法 |
push rbp、mov rbp, rsp | 保存原 RBP,并建立当前帧指针 |
| EDI、ESI | 此 ABI 中两个 int 参数的传入位置 |
[rbp-4]、[rbp-8] | 这次生成结果为两个参数使用的局部槽位,不是 C 语言要求的固定位置 |
add eax, edx | 计算并留下整数返回值 EAX |
pop rbp、ret | 恢复 RBP,并用栈中的返回地址返回 |
这里没有 sub rsp, ...,仍然可以使用 RSP 以下的少量存储:System V AMD64 用户态约定提供128字节 red zone,叶子函数可以把无需跨调用保留的临时数据放在这里。该规则不能直接套到 Windows x64 或内核代码。较大的栈帧或需要其他保存动作的函数可能显式调整 RSP;某些生成结果也用 leave 恢复栈帧,它不是每个函数都必须出现的指令。System V AMD64 ABI,栈帧章节
提高优化级别,不一定还有栈帧
跳转到“提高优化级别,不一定还有栈帧”把上面命令的 -O0 换成 -O2,本次得到:
add: endbr64 lea eax, [rdi+rsi] retlea 在这里计算地址表达式对应的加法结果,并不读取该地址处的内存。不能根据源码有两个参数,就要求反汇编中也存在两个栈槽。main 中的常量调用还可能直接被优化成结果已知的返回过程;阅读完整输出时应区分独立保留的 add 符号和某个调用点实际是否仍调用它。
Windows x64:同样是 x86-64,参数寄存器不同
跳转到“Windows x64:同样是 x86-64,参数寄存器不同”在 MinGW-w64 的 x64 环境,用 gcc -std=c11 -Wall -Wextra -Werror -O2 -S -masm=intel add.c -o add-windows-O2.s 得到:
add: .seh_endprologue lea eax, [rcx+rdx] ret这里两个整数参数使用 ECX、EDX,与 GNU/Linux 的 EDI、ESI 不同。.seh_endprologue 是 Windows 展开信息相关的汇编器指令,不是 CPU 加法指令。调用方的栈空间要求、寄存器保存义务等也要遵循对应 ABI,详见 Microsoft x64 调用约定。
Cortex-M3 与 A32:先看完整文件的目标属性
跳转到“Cortex-M3 与 A32:先看完整文件的目标属性”arm-none-eabi-gcc -std=c11 -Wall -Wextra -Werror -O2 -mcpu=cortex-m3 -mthumb -S add.c -o add-m3-O2.sarm-none-eabi-gcc -std=c11 -Wall -Wextra -Werror -O2 -mcpu=cortex-a9 -marm -S add.c -o add-a32-O2.s本次 Cortex-M3 的函数区域为:
add: @ args = 0, pretend = 0, frame = 0 @ frame_needed = 0, uses_anonymous_args = 0 @ link register save eliminated. add r0, r0, r1 bx lrCortex-A9 的 A32 函数区域为:
add: @ args = 0, pretend = 0, frame = 0 @ frame_needed = 0, uses_anonymous_args = 0 @ link register save eliminated. add r0, r0, r1 bx lr两个片段的助记符恰好相同,不代表二进制编码相同。完整文件分别声明 Thumb 或 Arm 状态。根据 AAPCS32,本例参数在 R0/R1,结果在 R0;函数没有继续调用其他函数,可以使用未被覆盖的 LR 返回。若它需要嵌套调用,必须正确保存仍有用的返回信息与寄存器,不能只照抄结尾的 bx lr。
printf 示例:常量折叠与运行库调用
跳转到“printf 示例:常量折叠与运行库调用”把下面保存为 sum.c:
#include <stdio.h>
int main(void) { int a = 5; int b = 10; int sum = a + b; printf("Sum: %d\n", sum); return 0;}在宿主环境编译并运行,会得到 Sum: 15。对 Cortex-M3 使用 -O2 -mcpu=cortex-m3 -mthumb -S 编译后,main 的摘录为:
main: @ args = 0, pretend = 0, frame = 0 @ frame_needed = 0, uses_anonymous_args = 0 push {r3, lr} movs r1, #15 ldr r0, .L4 bl printf movs r0, #0 pop {r3, pc}.L5: .align 2.L4: .word .LC0| 观察点 | 解释 |
|---|---|
movs r1, #15 | 优化器已算出 5+10,无需保留两个局部变量和一次运行时加法 |
ldr r0, .L4 与 .word .LC0 | 从文字池取格式字符串地址;完整文件的其他位置还有 .LC0 对应字符串 |
bl printf | 把控制转移到运行库函数,并写入 LR |
push {r3, lr} | 保存返回信息,同时满足这个调用点的栈对齐要求;这里的 R3 槽位可用于对齐 |
movs r0, #0、pop {r3, pc} | 设置 main 的返回值并恢复返回位置 |
这份摘录不能独立汇编链接:它引用完整输出中的字符串符号以及外部 printf。交叉编译成功也不等于串口终端已经能显示文本;裸机还需启动代码、链接脚本、C 运行库和输出重定向。AAPCS32 的栈对齐与调用规则见 Arm ABI 文档。
C++:保留语义,再观察编译器怎样实现
跳转到“C++:保留语义,再观察编译器怎样实现”下面把原来的继承、多态、模板、标准容器与异常示例整理成完整的 features.cpp。因为经由 Base* 删除派生对象,基类析构函数必须满足相应的多态删除要求;这里显式使用虚析构函数。
#include <exception>#include <iostream>#include <stdexcept>#include <vector>
class Base {public: virtual ~Base() = default; virtual void show() const { std::cout << "Base class\n"; }};
class Derived : public Base {public: void show() const override { std::cout << "Derived class\n"; }};
template <typename T>T add(T a, T b) { return a + b;}
void testException() { throw std::runtime_error("Test Exception");}
int main() { Base* b = new Derived; b->show(); delete b;
std::cout << "Result of add: " << add<int>(2, 3) << '\n'; const std::vector<int> values = {1, 2, 3, 4, 5}; for (int value : values) { std::cout << value << ' '; } std::cout << '\n';
try { testException(); } catch (const std::exception& error) { std::cout << "Caught exception: " << error.what() << '\n'; } return 0;}宿主编译运行命令:
g++ -std=c++17 -Wall -Wextra -Werror -O0 features.cpp -o features./features输出如下,展示时省略了数字行末尾的一个空格:
Derived classResult of add: 51 2 3 4 5Caught exception: Test Exception| C++ 语义 | 阅读汇编时要检查什么 |
|---|---|
| 构造、析构与动态内存 | new、构造函数、析构函数与 delete 各自的语义;还可能出现异常清理路径 |
| 虚函数调用 | 常见 ABI 会通过虚函数表等机制分派;优化器在能够证明对象类型时可能去虚化,不能只看源码就断言一定有间接调用 |
| 模板 | add<int> 的实例化与普通函数的生成、内联或常量折叠;模板本身不是一条机器指令 |
std::vector<int> | 容器对象、元素存储、长度/容量管理、迭代与释放;一串打印数字的字符不能替代容器语义 |
| 异常 | 抛出对象、匹配处理器、栈展开与清理所需的代码和元数据;一个标签不能独立实现 try/catch |
| 标准输出 | 与 C++ 运行库的调用关系,不能假定等同一次简单的 printf |
把 new_Derived、delete、throw_runtime_error 等写成空函数,再配上直接跳往 Derived_show 的流程,只能作为不完整示意,不能宣称实现了上面的 C++ 程序。需要研究汇编时,应对这份完整源码用 g++ -S 生成实际文件,并结合对应 C++ ABI、优化选项和目标运行库阅读。
怎样核对自己的输出
跳转到“怎样核对自己的输出”- 记录编译器版本、目标三元组、ABI、架构与优化选项。
- 先确认源码能正确编译、运行并得到预期结果,再导出汇编。
- 区分真实指令、标签、注释、重定位与汇编器指令;逐项跟踪参数和返回值。
- 把完整汇编重新汇编成目标文件,必要时再用对应工具反汇编;摘录不能代替完整构建输入。
- 对硬件相关代码继续验证链接地址、启动、运行库与板级行为。主机运行通过不等于目标板验证完成。
本页宿主示例已在 GNU/Linux x86-64 与 Windows x64 的所述 GCC 环境运行;列出的 Arm 输出只做编译、汇编与反汇编检查,未声明在开发板运行。Clang 命令作为等效入口说明,未用于生成本页输出。