← SEARCHABLE ARCHIVE
Assembly · 2026-08-01

C 语言如何变成汇编语言?

从预处理与链接,到 x86-64 调用约定和完整 stdio.h 流程

INTERACTIVE READING MODE →

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 或汇编语法。

从源代码到运行的五个阶段

  1. 预处理器处理 #include、#define 和 #if 等指令。
  2. 编译器分析处理后的 C 程序并生成目标 CPU 的汇编代码。
  3. 汇编器把指令和元数据编码成目标文件。
  4. 链接器解析符号,将目标文件与库组合成可执行文件。
  5. 操作系统加载器把可执行文件和共享库映射到内存。

通常所说的“把 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 写出字节。

参考资料

总结

C 程序经过预处理、编译、汇编、链接和加载才运行。汇编结果取决于目标 ABI 与优化。<stdio.h> 提供声明和宏,C 运行库提供真正的流实现。把这些层次分开后,指令、调用、缓冲与 OS API 就能连成一条可以理解的路径。