C 语言如何变成汇编语言?
从预处理与链接,到 x86-64 调用约定和完整 stdio.h 流程
C 语言如何变成汇编语言?
本博客文章以教学为目的。编译器版本、操作系统、ABI 和优化选项都可能改变输出,重要内容请在自己的环境中再次确认。
执行 gcc -S file.c 很简单,阅读生成的 .s 文件却不一定容易。#include <stdio.h> 消失了,printf 的庞大实现只剩一行 call,Linux 与 Windows 示例使用的参数寄存器也不相同。
与其背诵单条指令,不如先理解 C 源代码成为可执行文件的完整过程。本文依次介绍预处理、编译、汇编、链接、x86-64 调用约定、优化,以及 stdio.h 的 45 个核心函数。
除非另有说明,示例以 x86-64 Linux、System V ABI 和 Intel 语法为准。C 标准本身不指定 CPU 或汇编语法。
从源代码到运行的五个阶段
- 预处理器处理
#include、#define和#if等指令。 - 编译器分析处理后的 C 程序并生成目标 CPU 的汇编代码。
- 汇编器把指令和元数据编码成目标文件。
- 链接器解析符号,将目标文件与库组合成可执行文件。
- 操作系统加载器把可执行文件和共享库映射到内存。
通常所说的“把 C 转成汇编”,是指停在第二阶段并保留 .s 或 .asm 文件。
最小观察示例
#include <stdio.h>
static int add(int left, int right)
{
return left + right;
}
int main(void)
{
int value = add(20, 22);
printf("value = %d\n", value);
return 0;
}
这段程序同时包含函数调用、局部变量、字符串字面量和可变参数函数,但仍足够小,适合逐行观察。
让 GCC 停在各个阶段
gcc -std=c17 -E stdio_trace.c -o stdio_trace.i
gcc -std=c17 -O0 -fno-omit-frame-pointer -S -masm=intel -fverbose-asm stdio_trace.c -o stdio_trace.s
gcc -std=c17 -c stdio_trace.c -o stdio_trace.o
gcc stdio_trace.o -o stdio_trace
-E 保留预处理结果,-S 保留汇编,-c 在生成目标文件后停止。-masm=intel 选择 Intel 语法。学习时可以比较 -O0 与 -O2,前者更容易和源代码对应,后者能显示实际优化后的程序。
gcc -std=c17 -O2 -save-temps=obj stdio_trace.c -o stdio_trace
-save-temps=obj 可以一次保留多个中间文件。
Clang 与 MSVC
clang -std=c17 -O0 -S -masm=intel stdio_trace.c -o stdio_trace.s
clang -std=c17 -O2 -S -emit-llvm stdio_trace.c -o stdio_trace.ll
LLVM IR 不是 CPU 汇编语言,而是最终指令选择之前的中间表示。
MSVC 可以在 Developer Command Prompt 中生成汇编列表。
cl /nologo /std:c17 /Od /FAs /Fa:stdio_trace.asm stdio_trace.c
cl /nologo /std:c17 /O2 /FAs /Fa:stdio_trace_opt.asm stdio_trace.c
编译器列表与反汇编并不相同
编译器输出的汇编仍可能包含标签、段、伪指令和源代码注释。反汇编则从已经编码的目标文件或可执行文件中读取指令。
objdump -d -Mintel stdio_trace.o
objdump -d -Mintel stdio_trace
链接后的程序可能出现动态链接跳板和重定位后的地址。调试符号可以保留更多名称,但不能恢复完整的 C 源代码。
Intel 语法的最低阅读方式
Intel 语法通常先写目标操作数。
mov eax, 42
add eax, 8
cmp eax, 50
je .equal
mov 搬运数据,lea 计算地址表达式,cmp 设置标志,条件跳转再读取这些标志。call 保存返回地址并转移控制,ret 返回调用方。Intel 语法中的方括号表示内存访问。
读取未优化的 add
System V 的调试型构建可能生成如下代码。
add:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
pop rbp
ret
前两个整数参数位于 edi 和 esi,返回值放在 eax。优化后,栈上的临时存储可能消失,函数可以缩成一个 lea,甚至被完全内联。
System V x86-64 通常用 rdi、rsi、rdx、rcx、r8、r9 传递前六个整数或指针参数,以 rax 返回整数结果,浮点值使用 XMM 寄存器。
Windows x64 通常使用 rcx、rdx、r8、r9 传递前四个参数,调用方还要保留 32 字节 shadow space。解释寄存器前必须先确认目标 ABI。
printf 调用如何出现
mov esi, 42
lea rdi, .LC0[rip]
xor eax, eax
call printf@PLT
rdi 获得格式字符串地址,esi 获得整数 42。System V 的可变参数规则还利用 al 表示使用了多少个浮点向量寄存器,因此这里把 eax 清零。printf@PLT 是动态链接入口,不是 printf 的完整实现。
优化会让 C 的外形消失
编译器可以内联 add、在编译期算出 20 + 22、不为 value 分配栈空间、删除不可达代码,也可能把固定的 printf("hello\n") 改为 puts。只要符合语言规则下的可观察行为,这些转换都是允许的。
<stdio.h> 真正提供什么
包含头文件不会把完整 I/O 库粘贴到源代码中。预处理结果主要包含类型、宏和函数声明,实现主体通常位于 C 运行库。
核心抽象是由 FILE * 表示的流。FILE 可以保存缓冲区、文件位置、EOF、错误和同步状态,但内部成员属于实现细节。程序启动时会提供 stdin、stdout 和 stderr。
重要类型包括 FILE、fpos_t、size_t。常见宏包括 EOF、NULL、BUFSIZ、FOPEN_MAX、FILENAME_MAX、L_tmpnam、TMP_MAX、_IOFBF、_IOLBF、_IONBF、SEEK_SET、SEEK_CUR、SEEK_END。
45 个核心函数
文件操作,4 个
| 函数 | 用途 |
|---|---|
remove | 按名称删除文件 |
rename | 重命名文件 |
tmpfile | 创建临时二进制流 |
tmpnam | 生成临时名称,但难以安全使用 |
实际程序应优先使用能够原子地创建并打开唯一文件的平台安全 API,以避免 tmpnam 的竞争条件。
打开、关闭与缓冲,6 个
fclose、fflush、fopen、freopen、setbuf、setvbuf。
常用模式是 r、w、a、r+、w+、a+,需要时加 b。w 会截断已有内容,所有打开结果都要检查。可移植 C 不使用 fflush(stdin) 丢弃输入。
格式化输出,8 个
fprintf、printf、sprintf、snprintf、vfprintf、vprintf、vsprintf、vsnprintf。
转换格式大致为 %[flags][width][.precision][length]specifier。格式与实际参数类型必须一致,不能把不可信文本直接当作格式字符串。可变参数中的 float 会提升为 double,较窄的整数类型通常提升为 int。
格式化输入,6 个
fscanf、scanf、sscanf、vfscanf、vscanf、vsscanf。
转换目标通常必须是可写且类型正确的指针。%s 要限制字段宽度,并检查表示成功赋值数量的返回值。交互输入经常适合先用 fgets 读取整行,再单独解析。
字符与字符串 I/O,10 个
fgetc、fgets、fputc、fputs、getc、getchar、putc、putchar、puts、ungetc。
读取字符时应存入 int,这样才能区分所有 unsigned char 值与 EOF。
块 I/O,2 个
fread 和 fwrite 以对象数组为单位传输。返回值是完整传输的元素数,不一定是字节数。直接写入结构体会包含 padding、字节序和类型大小差异,不是可移植的序列化格式。
文件位置,5 个
fgetpos、fseek、fsetpos、ftell、rewind。文本流位置有额外的可移植性限制,大文件也常需要平台提供的 64 位扩展 API。
状态与诊断,4 个
clearerr、feof、ferror、perror。只有在读取尝试越过文件末尾后才能知道 EOF,因此不要用 while (!feof(file)) 控制循环,应以读取函数的结果为准。
Annex K 与平台扩展
一些实现提供 fopen_s、fprintf_s、scanf_s 等函数,但它们不是所有 C 环境都支持的核心替代品。Microsoft 的 _s 函数也有自己的参数规则。说明 API 时应区分 ISO C、POSIX 与 Windows 扩展。
标准 I/O 不等于每次一个系统调用
printf 通常先在用户空间格式化数据并更新 FILE 缓冲区,之后 flush 才可能调用 POSIX write 等 OS API。缓冲可以减少昂贵的内核切换。
getc 与 putc 允许实现为宏,快速路径可能直接展开。没有看到 call getc 并不意味着 C 操作消失了。
二进制文件复制的基本循环
unsigned char buffer[64 * 1024];
size_t read_count;
while ((read_count = fread(buffer, 1, sizeof buffer, source)) > 0)
{
size_t written = 0;
while (written < read_count)
{
size_t count = fwrite(buffer + written, 1,
read_count - written, destination);
if (count == 0)
{
break;
}
written += count;
}
}
if (ferror(source))
{
perror("fread");
}
完整程序还要检查两次 fopen、部分写入、ferror,以及 fclose 时才出现的输出错误。
合适的学习顺序
先观察只有整数参数和返回值的函数,再比较 -O0 与 -O2。接着一次只增加一项:局部数组、普通函数调用、printf。比较目标文件与链接后的可执行文件,最后在 Windows x64 等另一种 ABI 上重做。
常见错误
- 认为一段 C 程序只有唯一的汇编结果。
- 把
-O0当成逐行机械翻译。 - 格式说明符与参数类型不一致。
- 把用户输入当作
printf格式字符串。 scanf("%s", buffer)没有限制宽度。- 使用
while (!feof(file))。 - 在可移植程序中依赖
fflush(stdin)。 - 忽略输出流的
fclose失败。 - 把结构体原始字节当作可移植格式。
- 从未定义行为推导一般汇编规则。
一行 printf 到实际输出
<stdio.h> 提供声明,编译器按 ABI 安排参数,汇编器编码含符号引用的指令,链接器或加载器把符号连接到 C 运行库。运行时库格式化数据、更新流缓冲区,最后在需要时请求 OS 写出字节。
参考资料
- ISO C 工作草案 N1570
- GCC code generation options
- Clang command-line reference
- MSVC 0
- System V AMD64 ABI
- Microsoft x64 calling convention
- GNU objdump
总结
C 程序经过预处理、编译、汇编、链接和加载才运行。汇编结果取决于目标 ABI 与优化。<stdio.h> 提供声明和宏,C 运行库提供真正的流实现。把这些层次分开后,指令、调用、缓冲与 OS API 就能连成一条可以理解的路径。