
C语言生成可执行程序一共有4个步骤:预处理 → 编译 → 汇编 → 链接,每一步都能单独执行。咱们用下面的简单例子,讲解一下整个编译过程。
测试代码(test.c)
#include <stdio.h>
#define MSG "Hello, C Process!"
int main() {
printf("%s\n", MSG);
return 0;
}
第一步:预处理(Preprocessing)
命令
gcc -E test.c -o test.i
输入
test.c(我们写的C语言源码,文本格式)
输出
test.i(展开后的纯C代码,文本格式,可直接用vim/gedit打开),其体积会大幅增大,通常从几十行变成几万行,核心原因是插入了头文件内容。
核心工作
1. 展开 #include 头文件:把
2. 展开 #define 宏定义:纯文本替换,把代码中所有的 MSG,全部替换成 “Hello, C Process!”,替换后宏名 MSG 会消失。
3. 删除所有注释:// 单行注释、/* */ 多行注释,全部删除,不保留任何注释内容,预处理只保留有效代码。
4. 处理条件编译:如果代码中有 #if、#ifdef、#else、#endif 等,会根据条件保留对应代码、删除无用代码(比如调试用的代码,可通过条件编译屏蔽)。
5. 添加行号和文件名标记:在代码中插入隐藏的行号、文件名信息(比如 # 1 “test.c”),方便后续编译报错时,快速定位到源码中的错误位置。
预处理阶段不检查任何C语言语法错误,哪怕你把 printf 写成 printff,这一步也不会报错,因为它只做“文本替换/删除”,不识别C语言语法。而且 test.i 仍然是纯C语言代码,不是汇编、不是二进制,打开后能看懂,只是行数极多,大部分是展开的头文件内容。实操中,用 head -20 test.i 可以快速查看 test.i 的前20行,能直观看到头文件展开和宏替换的效果,不用打开整个大文件。
第二步:编译(Compilation)
命令
gcc -S test.i -o test.s
输入
test.i(预处理后的纯C代码)
输出
test.s(汇编语言代码,文本格式,可直接打开查看),其内容与CPU架构强相关,同样的 test.i 文件,在 x86 电脑(比如普通笔记本)和 ARM 电脑(比如树莓派)上,生成的 test.s 内容完全不同,因为两种CPU的指令集不一样。
核心工作
1. 检查C语言语法错误:这是第一个真正检查语法的阶段,也是整个流程中首次进行语法校验的环节。如果代码有少分号、括号不匹配、变量未定义、函数调用错误等,都会在这一步报错,终止流程(比如把 main 写成 mian,会报“未定义的引用 to main”)。若此处报错,只需要回到 test.c 中修改语法错误,重新执行预处理和编译即可,不用重新执行后续步骤。
2. 语义分析与优化:编译器会分析代码的逻辑(比如变量的作用域、函数的调用关系),并做基础优化(默认无优化,加 -O2 参数可开启中级优化,让代码运行更快、体积更小)。
3. 翻译C代码→汇编代码:把C语言的语句(比如 printf、return 0),翻译成对应CPU架构的汇编指令(比如 x86 架构的 mov、call 指令)。这一步才是真正的“编译”,预处理只是“文本处理”,而编译是“语言转换”,把高级C语言转换成低级汇编语言。
第三步:汇编(Assembly)
命令
gcc -c test.s -o test.o
输入
test.s(汇编语言代码)
输出
test.o(二进制目标文件,不可直接阅读,需用 objdump 工具查看),需要注意的是,test.o 并不能直接运行,运行会报错“Permission denied”或“无法执行二进制文件”。
核心工作
1. 汇编指令→机器码:把 test.s 中的汇编指令,一一翻译成CPU能直接识别的二进制代码(0和1的组合),这是代码从“人类可看懂”到“机器可识别”的关键一步。
2. 生成符号表:记录代码中的函数名、变量名(比如 main、printf),以及它们在目标文件中的临时位置(此时还不是最终内存地址)。
3. 生成重定位信息:标记出“需要后续修补地址”的位置(比如 printf 函数,此时只知道要调用它,但不知道它在内存中的具体地址,需要链接阶段修补)。
test.o 无法直接运行的原因有3个:一是函数地址未确定,printf 等库函数的真实地址还没分配,程序不知道去哪里找这个函数;二是没有程序入口信息,系统不知道从哪里开始执行(虽然有 main 函数,但还没和系统的启动代码关联);三是未符合 Linux 可执行文件格式(ELF),缺少程序头、段信息等,系统无法识别它是可执行程序。实操中,用 objdump -d test.o 可以查看 test.o 中的机器码和汇编指令,能看到 main 函数对应的二进制代码。如果有多个源码文件,比如 test1.c、test2.c,分别汇编后会生成 test1.o、test2.o,后续链接时会合并这两个目标文件。
第四步:链接(Linking)
命令
gcc test.o -o test
(底层实际调用 ld 链接器,gcc 只是封装了这个过程,直接用 ld test.o -o test 也能链接,但需要手动指定库路径,不推荐,用 gcc 链接更便捷,它会自动处理库路径和启动代码,不用手动配置)
输入
test.o(目标文件) + 系统共享库(主要是 libc.so,C标准库,包含 printf 等函数的实现) + 系统启动代码(crt0.o 等,负责初始化程序、调用 main 函数)
输出
test(最终可执行文件,Linux 下默认是 ELF 格式,绿色文件,可直接运行)。Linux 下的可执行文件、目标文件、共享库,都是 ELF 格式,用 file test 可以查看文件格式(会显示“ELF 64-bit LSB executable”)。
核心工作
1. 合并目标文件:如果有多个 .o 文件(比如 test1.o、test2.o),会把它们合并成一个文件,统一分配内存地址。
2. 符号解析:找到代码中引用的外部符号(比如 printf),在系统库(libc.so)中找到对应的实现,建立关联。
3. 重定位:根据符号的真实地址,修补目标文件中“未确定的地址”(比如把 printf 的调用地址,替换成 libc.so 中 printf 的实际内存地址)。
4. 封装 ELF 格式:把合并后的机器码、符号表、重定位信息等,打包成 Linux 可识别的 ELF 可执行文件格式,添加程序头(告诉系统如何加载程序)、段信息(.text 代码段、.data 数据段、.bss 未初始化数据段)。
5. 关联启动代码:把系统启动代码(crt0.o)和我们的 main 函数关联,程序运行时,先执行启动代码(初始化栈、堆、环境变量),再调用 main 函数,main 函数结束后,由启动代码处理返回值。
链接分为动态链接和静态链接两种,需重点区分,实操中经常用到:
– 动态链接(默认):程序运行时,才去加载 libc.so 共享库,如果系统中没有 libc.so,程序会报错“找不到共享库”;优点是程序体积小,多个程序可以共用一个 libc.so,节省内存。实操命令(显式指定动态链接):gcc test.o -o test -ldl
– 静态链接:把 libc.so 中的相关代码,直接打包进可执行文件中,程序运行时不需要依赖系统中的 libc.so,可独立运行(比如拷贝到没有安装C标准库的Linux系统中也能运行);优点是可移植性强,缺点是程序体积大,这是正常现象,静态链接会打包整个库,比如 test 可能从几KB变成几MB。实操命令(静态链接):gcc test.o -o test -static(需要系统安装静态库,比如 libc.a,否则会报错)
链接阶段若报错“未定义的引用 to xxx”,大概率是两个原因:① 代码中调用的函数没有实现(比如自己写了一个函数声明,没写实现);② 没有链接对应的库(比如用了 math 库的 sqrt 函数,需要加 -lm 参数链接 math 库)。
最终运行与验证
./test
输出结果:Hello, C Process!,说明整个流程成功。
最终总结
test.c(源码,文本) ↓(预处理 gcc -E) test.i(展开后C代码,文本) ↓(编译 gcc -S) test.s(汇编代码,文本) ↓(汇编 gcc -c) test.o(目标文件,二进制,不可运行) ↓(链接 gcc/ld) test(可执行文件,ELF格式,可运行)
Linux 下 C 源码到可执行文件,核心就是“4步走”,每一步都有明确的目标和输出,没有神秘操作:
1. 预处理:处理文本,把“不完整”的源码补全;
2. 编译:检查语法,把高级语言转成低级汇编;
3. 汇编:翻译指令,把汇编转成机器能识别的二进制;
4. 链接:整合资源,把半成品变成能直接运行的程序。
大家在日常工作中,有遇到哪些编译相关的问题呢?欢迎留言讨论