跳转到内容
新建笔记

从 C/C++ 到真实汇编:编译阶段、ABI 与优化

从 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 相加必须避免超出可表示范围,不能把源语言的未定义溢出行为解释成固定的机器整数回绕规则。

add.c
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.s
add:
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]
ret

lea 在这里计算地址表达式对应的加法结果,并不读取该地址处的内存。不能根据源码有两个参数,就要求反汇编中也存在两个栈槽。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.s
arm-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 lr

Cortex-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:

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* 删除派生对象,基类析构函数必须满足相应的多态删除要求;这里显式使用虚析构函数。

features.cpp
#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 class
Result of add: 5
1 2 3 4 5
Caught 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、优化选项和目标运行库阅读。

  1. 记录编译器版本、目标三元组、ABI、架构与优化选项。
  2. 先确认源码能正确编译、运行并得到预期结果,再导出汇编。
  3. 区分真实指令、标签、注释、重定位与汇编器指令;逐项跟踪参数和返回值。
  4. 把完整汇编重新汇编成目标文件,必要时再用对应工具反汇编;摘录不能代替完整构建输入。
  5. 对硬件相关代码继续验证链接地址、启动、运行库与板级行为。主机运行通过不等于目标板验证完成。

本页宿主示例已在 GNU/Linux x86-64 与 Windows x64 的所述 GCC 环境运行;列出的 Arm 输出只做编译、汇编与反汇编检查,未声明在开发板运行。Clang 命令作为等效入口说明,未用于生成本页输出。