C语言程序编译过程详解
源程序: hello.c
#include
#define GREETING "Hello, World!"
int main() {
printf(GREETING "\n"); // Print the greeting and a newline
return 0;
}
目标: 将这个人类可读的源代码 (hello.c) 转换成计算机可以直接执行的机器指令 (最终的可执行文件,如 hello.exe 或 hello.out)。
编译过程主要分为四个阶段:
预处理编译 ( 狭义)汇编链接
我们将使用 gcc 编译器 (Linux/macOS 常用) 作为例子,并展示如何分步执行以及每个步骤的输入/输出。
阶段 1:预处理
输入: 源代码文件 (hello.c)工具: 预处理器 (cpp - C Preprocessor, 通常由编译器驱动如 gcc -E)输出: 预处理后的源代码文件 (通常为 .i 文件, 如 hello.i)命令: gcc -E hello.c -o hello.i (-E: 只进行预处理, -o: 指定输出文件名)做了什么:
处理 #include 指令: 找到 #include
名词解释 - 头文件 ( .h): 包含函数声明、宏定义、类型定义等信息的文本文件。它告诉编译器某个函数长什么样(返回值类型、参数类型),但不包含函数的具体实现(机器代码)。#include 是一种文本替换指令。
处理 #define 指令: 找到 #define GREETING "Hello, World!",在源文件中所有出现 GREETING 的地方,用字符串 "Hello, World!" 替换它。
名词解释 - 宏定义 : 使用 #define 指令定义的标识符(如 GREETING)及其代表的替换文本(如 "Hello, World!")。预处理器会在编译前进行简单的文本替换。
删除注释: 将源代码中所有的注释 (// ... 和 /* ... */) 删除。处理条件编译指令: 如 #if, #ifdef, #ifndef, #else, #elif, #endif。根据这些指令中定义的条件(通常是宏是否已定义或宏的值),决定是否包含某段代码。本例中没有条件编译指令。
名词解释 - 条件编译: 根据预定义的宏或其他条件,让预处理器决定在编译时包含或排除特定的代码块。常用于跨平台或不同编译配置。
添加行标记: 为了方便后续阶段(尤其是编译器报错时)定位原始代码位置,预处理器会在输出文件中插入特殊的 #line 指令。
hello.i 文件内容预览 (简化版):
# 1 "hello.c"
# 1 "
# 1 "
# 31 "
...
/* 大量从 stdio.h 复制过来的内容 */
...
typedef struct {...} FILE;
extern int printf (const char *__restrict __format, ...);
...
# 3 "hello.c" 2
int main() {
printf("Hello, World!" "\n");
return 0;
}
可以看到 GREETING 被替换了,注释被删除了,#include
阶段 2:编译 (狭义)
输入: 预处理后的源代码 (hello.i)工具: 编译器 (cc1 - GNU C 编译器的核心)输出: 汇编语言源代码文件 (通常为 .s 文件, 如 hello.s)命令: gcc -S hello.i -o hello.s (-S: 只进行预处理和编译,生成汇编代码)
也可以直接从 .c 开始: gcc -S hello.c -o hello.s (编译器会自动调用预处理器)
做了什么: 这是编译过程的核心阶段,将高级语言 © 翻译成低级语言 (特定 CPU 架构的汇编语言)。主要子步骤:
词法分析 :
名词解释: 读取预处理后的字符流 (hello.i 的内容)。作用: 将其分解成一系列有意义的词素 (Lexeme) 或 标记 (Token)。过程: 识别关键字 (int, return)、标识符 (main, printf)、常量 (0, "Hello, World!")、运算符 (=, ;) 等。忽略空格、换行符等空白字符。例子: int main() { ... } 会被分解成 Token 序列: [关键字 int], [标识符 main], [左括号 (], [右括号 )], [左花括号 {], ...
语法分析 (Syntax Analysis / Parsing):
名词解释: 使用词法分析产生的 Token 序列。作用: 根据 C 语言的语法规则 检查程序结构是否正确,并构建 抽象语法树 (Abstract Syntax Tree - AST)。过程: 检查 Token 序列是否符合语法(如括号是否匹配,语句是否以分号结束,表达式结构是否正确)。如果语法错误,编译器报错。成功则构建 AST。名词解释 - 抽象语法树 (AST): 一种树形数据结构,它以更抽象的形式表示程序的语法结构,忽略掉一些不重要的细节(如分号、括号的位置)。树的节点代表语法结构(如函数声明、函数调用、表达式、赋值语句等),叶子节点代表变量名、常量值等。简化 AST 示例 (对应 main 函数):FunctionDefinition
|-- Type: int
|-- Name: main
|-- Parameters: (空)
|-- Body: CompoundStatement
|-- ExpressionStatement
| |-- FunctionCall
| |-- FunctionName: printf
| |-- Arguments
| |-- StringLiteral: "Hello, World!"\n
|-- ReturnStatement
|-- Expression: IntegerLiteral(0)
语义分析:
名词解释: 使用语法分析生成的 AST。作用: 检查程序是否符合语言的语义规则。语法正确不代表含义正确。过程: 进行类型检查(变量类型是否匹配,函数调用参数类型和数量是否匹配函数声明),检查变量是否已声明(作用域 检查),检查 return 语句是否返回了正确类型的值等。如果发现语义错误(如给 int 变量赋字符串值,调用未声明的函数),编译器报错。例子: 检查 printf(GREETING "\n") 中的 printf 是否已声明(在预处理阶段引入的 stdio.h 中已声明),检查参数类型(GREETING 被替换成字符串字面量,类型是 const char*,符合 printf 第一个参数的要求)。
中间代码生成 (可选但常见):
名词解释: 通常在语义分析之后。作用: 将 AST 转换成一种与具体机器架构无关的、更简单的低级表示形式,称为 中间表示 (Intermediate Representation - IR)。常见的 IR 如三地址码 (Three-Address Code - TAC) 或 LLVM IR。优点: 便于后续的优化,使编译器的前端(与语言相关)和后端(与机器相关)分离。三地址码示例 (伪代码):t1 = address_of_string("Hello, World!\n")
call printf, t1
return 0
优化 (在 IR 或汇编级别):
名词解释: 对生成的中间代码或汇编代码进行变换。作用: 改进代码的性能(运行速度)和/或大小,同时保持程序功能不变(等价变换)。常见优化技术:
常量折叠: 计算编译时就能确定的常量表达式,如 int x = 5 * 10; -> int x = 50;常量传播: 将已知的常量值传播到使用该常量的地方。死代码消除: 删除永远不会被执行到的代码。公共子表达式消除: 避免重复计算相同的表达式。循环优化: 如循环展开、强度削弱等。
例子: 在本例中,优化可能比较简单。GREETING "\n" 在预处理后已经是 "Hello, World!\n",编译器可能直接把这个完整的字符串常量传给 printf,而不是在运行时拼接。
目标代码生成:
名词解释: 使用优化后的 IR (或 AST)。作用: 将中间表示转换成特定目标 CPU 架构的汇编语言 代码。过程: 为每个 IR 指令选择合适的机器指令或指令序列。处理 CPU 寄存器分配、指令选择、指令调度、函数调用约定(参数如何传递,返回值如何存放,栈帧如何管理)等底层细节。名词解释 - 汇编语言: 一种低级编程语言,使用助记符(如 mov, add, call, push, pop)代表机器指令,使用符号(标签)代表内存地址。它与机器指令几乎一一对应,但人类可读性比二进制机器码稍好。不同 CPU 架构(如 x86, ARM)有不同的汇编语言。
hello.s 文件内容预览 (x86-64 Linux, gcc 生成, 简化):
.file "hello.c"
.text
.section .rodata
.LC0:
.string "Hello, World!" # 字符串常量存储在只读数据段
.text
.globl main
.type main, @function
main:
.LFB0:
pushq %rbp # 保存旧的栈帧基址
movq %rsp, %rbp # 设置新的栈帧基址
leaq .LC0(%rip), %rdi # 将字符串地址加载到寄存器 rdi (第一个参数)
call puts@PLT # 调用 puts 函数 (编译器优化: 用 puts 代替 printf 打印字符串+换行)
movl $0, %eax # 将返回值 0 放入 eax 寄存器
popq %rbp # 恢复旧的栈帧基址
ret # 从函数返回
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 11.4.0) 11.4.0"
.section .note.GNU-stack,"",@progbits
注意:编译器进行了优化,它发现我们只是打印一个以换行符结尾的字符串,直接用更简单的 puts 函数替代了 printf。.section .rodata: 指示后续数据在只读数据段。.string "Hello, World!" 定义字符串常量 "Hello, World!" 并给它一个标签 .LC0。.text: 指示后续是代码段),存放可执行指令。.globl main: 声明 main 是一个全局符号,可以被其他模块(如链接器)看到。main:: main 函数的标签 (Label),代表其在内存中的地址。pushq, movq, leaq, call, movl, popq, ret: 这些都是 x86-64 汇编指令。%rbp, %rsp, %rdi, %eax: 这些是 CPU 寄存器 的名称。rdi 通常用于传递第一个参数,eax 通常用于存放整数返回值。call puts@PLT: 调用 puts 函数。@PLT 表示这个调用需要通过过程链接表 (Procedure Linkage Table - PLT) 进行,这是动态链接的关键机制(后面链接阶段会讲)。.size main, .-main: 记录 main 函数的大小。
阶段 3:汇编
输入: 汇编语言源代码 (hello.s)工具: 汇编器 (as)输出: 可重定位目标文件 (通常为 .o 文件, 如 hello.o)命令: gcc -c hello.s -o hello.o (-c: 只进行预处理、编译和汇编,生成目标文件)
也可以直接从 .c 或 .i 开始: gcc -c hello.c -o hello.o
做了什么:
将汇编语言代码 (hello.s) 逐行翻译成对应的机器指令,即 CPU 可以直接执行的二进制代码 (0 和 1)。解析汇编代码中的符号(如 main, .LC0, puts),并将它们记录在目标文件的符号表 中。
名词解释 - 符号: 程序中定义的函数名、变量名(全局变量、静态变量)或引用的外部函数/变量名。符号表记录符号的名称、类型(函数、数据对象等)、大小、在文件中的位置(段内偏移)以及绑定信息(全局/局部)。
处理汇编器指令 (.file, .text, .section, .globl, .string, .size 等),它们指示汇编器如何组织生成的目标文件。生成可重定位目标文件格式 (如 Linux 上的 ELF - Executable and Linkable Format, Windows 上的 COFF/PE)。这个目标文件包含:
代码段 (.text section): 存放 main 函数编译后的机器指令。数据段 (.data section / .rodata section): 存放已初始化的全局变量和静态变量。.rodata 存放只读数据(如我们的字符串常量 "Hello, World!")。未初始化数据段 (.bss section - Block Started by Symbol): 存放未初始化或初始化为 0 的全局变量和静态变量(通常不占磁盘空间,只记录大小)。符号表 (.symtab section): 记录文件中定义和引用的符号信息。
定义的符号: main (类型:函数,在 .text 段,全局可见), .LC0 (类型:对象/数据,在 .rodata 段,局部可见)。引用的符号: puts (类型:未定义函数,全局可见 - UND)。因为 puts 的实现不在 hello.c 中,它在标准 C 库 (libc.so) 里。
重定位表 (.rela.text / .rel.text section): 记录代码段 (.text) 中哪些位置的指令在链接时需要修改(重定位)。例如,call puts@PLT 指令中调用 puts 函数的地址在汇编阶段是未知的(因为 puts 在外部库),汇编器会在这里生成一个占位符(通常是 0),并在重定位表中添加一条记录:“在偏移量 X 处,有一个对符号 puts 的调用需要重定位,类型是 R_X86_64_PLT32(表示 PLT 相关的重定位)”。 .rela.data 记录数据段中需要重定位的地址。其他信息: 文件头、段头表、调试信息(如果有 -g 选项)等。
hello.o 文件: 此时它是一个可重定位的 目标文件。
名词解释 - 可重定位: 文件中的机器指令使用的内存地址(特别是函数调用地址、全局变量地址)还不是最终运行时的绝对地址。代码段和数据段都是从地址 0 开始计算的偏移量。链接器在链接阶段会将这些地址修改为程序加载到内存后的真实地址。它还不能直接执行,因为它引用了外部符号 (puts),并且它的地址还没有最终确定。
阶段 4:链接 (Linking)
输入:
一个或多个可重定位目标文件 (hello.o)库文件 : 包含预编译好的代码和数据的目标文件集合。
静态库 ( .a in Linux, .lib in Windows): 在链接时,链接器从库中提取需要的目标文件,完整地复制到最终的可执行文件中。程序运行时不再需要该库文件。优点是独立性强;缺点是增大可执行文件体积,库更新需要重新编译链接。动态库 / 共享库 ( .so in Linux, .dll in Windows): 在链接时,链接器只在可执行文件中记录它依赖哪个共享库以及库中的哪些符号。程序运行时,操作系统的动态链接器/加载器 (e.g., ld-linux.so on Linux) 负责将所需的共享库加载到内存(如果还没加载),并将程序中对共享库符号的引用绑定 到库在内存中的实际地址。优点是节省磁盘和内存空间(多个程序可共享一个库),库更新方便(替换 .so/.dll 文件即可,只要接口兼容);缺点是运行时依赖库文件存在且兼容。标准 C 库 (libc) 通常以共享库 (libc.so) 形式提供。我们的 puts 函数就在 libc.so 中。
工具: 链接器 (ld - Linker, 通常由编译器驱动如 gcc)输出: 可执行目标文件 (如 a.out, hello.exe, 或自定义的 hello)命令: gcc hello.o -o hello (链接 hello.o 并生成可执行文件 hello)做了什么: 链接器是编译过程的最后一步,负责将多个独立的模块(.o 文件、库文件)“缝合”成一个完整的、可以加载到内存运行的程序。主要任务:
符号解析 :
作用: 将每个模块(目标文件)中对符号的引用(如 hello.o 中的 puts)与该符号在某个模块中的定义(如 libc.so 中的 puts 函数)关联起来。过程: 链接器扫描所有输入的目标文件和库文件,构建一个全局符号表。对于每个未解析的引用(如 hello.o 引用的 puts),在全局符号表中查找其定义。如果找不到定义,链接器报错 (undefined reference to ...)。如果同一个符号被多个模块定义(且不是弱符号),链接器报错 (multiple definition of ...)。
重定位: 这是链接的核心魔法。
作用: 将多个输入模块的代码段和数据段分别合并成最终输出文件中的一个代码段和一个数据段。然后,修改代码段和数据段中对符号的引用(地址),使它们指向正确的运行时内存地址。过程:
合并段: 将所有输入模块的 .text 段合并到输出文件的 .text 段。合并所有 .data 段到输出 .data 段,合并所有 .rodata 到 .rodata,合并 .bss 到 .bss。确定运行时内存地址: 链接器为输出文件定义内存布局(如代码段从地址 0x400000 开始,数据段从 0x600000 开始)。它为每个段和段中定义的每个符号(函数、全局变量)分配一个运行时内存地址。例如,main 函数可能被分配到 0x400520,字符串 "Hello, World!" 被分配到 0x4005f0。修改引用: 链接器遍历每个输入模块的重定位表。对于表中的每一条重定位记录(如“在 .text 段偏移量 X 处,有一个对 puts 的调用需要重定位”),链接器:
找到引用所在的位置(.text 段起始地址 + 偏移量 X)。计算目标符号 (puts) 的最终运行时地址。根据重定位记录指定的重定位类型(如 R_X86_64_PLT32),使用计算出的目标地址修改该位置的指令或数据。例子: 修改 call puts@PLT 指令中的地址占位符,使其最终指向动态链接过程链接表 (PLT) 中 puts 对应的条目地址。动态链接器在程序加载时,会再通过 PLT 和 GOT 机制,将 puts 的 PLT 条目绑定到 libc.so 中 puts 函数在内存中的实际地址(延迟绑定)。
处理动态链接:
如果使用了共享库(如 libc.so),链接器不会将库代码复制到可执行文件中。链接器会在可执行文件中添加一个 .interp 段,指定运行时需要的动态链接器路径(如 /lib64/ld-linux-x86-64.so.2)。链接器会添加 .dynamic 段,包含动态链接信息:依赖哪些共享库 (libc.so.6)、符号哈希表、重定位表(.rela.plt, .rela.dyn)等。链接器会为动态链接符号(如 puts)创建 过程链接表 (Procedure Linkage Table - PLT) 和 全局偏移量表 (Global Offset Table - GOT) 的条目。PLT 在代码段,包含一小段跳转代码。GOT 在数据段,存储函数/变量的实际地址(由动态链接器在运行时填充)。
最终输出:hello (可执行文件)
它是一个可执行目标文件(如 ELF 格式的可执行文件)。包含了合并后的代码段、数据段、符号表(可能被剥离)、重定位信息(用于动态链接)、程序头表(告诉操作系统如何加载程序)、段头表等。包含了如何加载程序、从哪里开始执行(入口点通常是 _start,它初始化环境后调用 main)、以及运行时需要哪些共享库的信息。可以被操作系统加载到内存并执行。
总结流程图:
hello.c (源程序)
|
| [预处理] (cpp) -> 处理 #include, #define, 删除注释, 条件编译
|
hello.i (预处理后的源代码)
|
| [编译] (cc1) -> 词法分析 -> 语法分析 (AST) -> 语义分析 -> (中间代码生成 -> 优化) -> 目标代码生成
|
hello.s (汇编语言源代码)
|
| [汇编] (as) -> 翻译为机器指令, 生成符号表, 处理重定位信息
|
hello.o (可重定位目标文件) + libc.so (共享库) + ... (其他 .o 或库)
|_________________________________________
|
| [链接] (ld) -> 符号解析 -> 合并段 -> 重定位 (修改地址引用) -> 处理动态链接信息
|
hello (可执行文件)
|
| [运行时加载执行] (操作系统加载器 + 动态链接器 ld-linux.so)
|
"Hello, World!" (输出到屏幕)