《嵌入式C语言自我修养》读书笔记:第四章 程序的编译、链接、安装和运行
我们写下的 C 代码为什么能够变成可执行文件?可执行文件又是怎样进入内存并被 CPU 执行的?这一章尝试把整条路径串起来。
C 源文件(.c) → 预处理文件(.i) → 汇编文件(.s) → 目标文件(.o)
→ 链接生成 ELF → 安装或烧写 → 装载与初始化 → CPU 执行
阅读范围说明:本文同时讨论 Linux 等操作系统环境和嵌入式裸机环境。两者前半段的编译、汇编和链接过程相似,但“安装”和“运行”的方式差别很大。
一、先从 Hello World 说起
学习 C 语言时,几乎都会遇到下面这段代码:
#include <stdio.h>
int main(void)
{
printf("hello, world\n");
return 0;
}
刚开始学习时,我们只知道:保存为 C 文件,编译后运行,屏幕上就会出现 hello, world。现在要继续追问:它到底怎样一步步变成可执行文件?

图 1:从多个 C 源文件到可执行文件的完整流程
假设程序由 main.c 和 sum.c 组成,它们通常会被分别预处理、编译和汇编,得到各自的目标文件,最后再与需要的库一起交给链接器。
二、预处理:处理以 # 开头的指令
预处理器主要处理 #include、#define、#if 等预处理指令,并完成头文件展开、宏替换、条件编译和注释处理等工作。它并不是简单地“去掉 #”,而是先根据这些指令生成新的源代码文本。
gcc -E main.c -o main.i
打开 main.i,会发现文件通常比原来的 main.c 大很多,因为 stdio.h 及其间接包含的头文件已经展开。这里出现的是 printf 的函数声明,而不是它在 C 库中的函数实现。

图 2:main.i 中可以看到来源标记、展开的头文件、函数声明和自己的源代码
我的理解:预处理主要服务于源代码组织。它让程序员可以使用头文件、宏和条件编译,但处理后的结果仍然是 C 语言文本,还不是机器指令。
三、编译:把 C 语言翻译成汇编语言
狭义的“编译”是把经过预处理的 C 程序翻译为目标架构的汇编代码:
gcc -S main.i -o main.s
为了便于理解,可以把编译器内部的工作概括为前端、中间端和后端:
| 阶段 | 主要工作 | 常见问题或结果 |
|---|---|---|
| 词法分析 | 把字符流分解成一个个 Token(记号、词元) | 识别关键字、标识符、运算符和常量等 |
| 语法分析 | 根据 C 语言语法构造语法树 | 发现缺少分号、括号不匹配等语法错误 |
| 语义分析 | 检查类型、声明、作用域和表达式含义 | 发现类型不兼容、使用未声明变量等问题 |
| 中间代码生成 | 生成便于分析和优化的中间表示(IR) | 把源语言与具体 CPU 暂时解耦 |
| 优化 | 在不改变程序语义的前提下改进代码 | 常量折叠、删除无用代码、循环优化等 |
| 目标代码生成 | 选择指令、分配寄存器,输出目标架构汇编 | 得到 main.s |
用一个表达式理解前三个阶段
int c = a + b * 2;
词法分析可以把它拆成 9 个 Token:
int c = a + b * 2 ;
- 词法分析识别每个 Token;无法识别的字符可能在这一阶段触发诊断。
- 语法分析按运算优先级构造树形结构,因此先计算
b * 2,再与a相加。 - 语义分析还会检查
a、b是否已经声明,以及参与运算的类型是否合法。
实际编译器的各阶段并非完全割裂,一条错误信息也不一定能机械地归到唯一阶段,但这个划分有助于建立整体认识。
四、汇编:从 .s 生成可重定位目标文件
汇编器把汇编助记符和伪操作转换为机器码与目标文件信息,生成 .o 文件:
gcc -c main.s -o main.o
# 或直接调用汇编器
as main.s -o main.o
目标文件通常是 ELF 格式,但它还是可重定位目标文件,不是最终可直接运行的程序。除了机器码,它还包含 Section、符号表和重定位信息等。

图 3:可重定位目标文件中的 Section Header 示例
为什么很多 .o 文件中的 Section 地址看起来都是 0
在可重定位目标文件中,代码和数据通常还没有获得最终运行地址,因此多个 .o 文件的 Section 地址都可能显示为 0,这并不冲突。它们只是各自独立的输入文件,链接器稍后会重新布局并分配地址。
需要纠正:重定位并不是简单地用“初始地址 + offset”得到真实地址。重定位表中的 offset 表示某个 Section 内需要修补的位置;链接器还要结合符号最终地址、重定位类型、附加值以及当前位置等信息计算结果。
重定位表
readelf -r sum.o

图 4:重定位项记录需要在哪里、按什么类型修正地址
例如,编译 main.c 时可能还不知道 sum 函数最后会被放到哪里。汇编器先在相关位置留下待修正项,链接器得到全部输入文件后再完成地址计算和填充。
符号表
readelf -s sum.o

图 5:符号表记录符号的名称、值、大小、类型、绑定方式和所在 Section 等信息
从 ELF 文件结构上看,符号表可以理解为由许多 Elf32_Sym 或 Elf64_Sym 条目组成的数组;每个条目描述一个符号,而符号名称通常通过索引引用字符串表。
五、链接:合并 Section、解析符号并完成重定位
每个源文件单独生成目标文件后,链接器负责把它们和需要的库组织成最终 ELF。可以把核心过程概括为:
- 读取各个目标文件和库;
- 把性质相同或规则指定的输入 Section 合并到输出 Section;
- 解析符号引用,判断每个外部名称对应哪个定义;
- 根据链接脚本和布局规则分配地址;
- 处理重定位,把正确的地址或偏移写入代码和数据;
- 生成可执行文件或共享库。

图 6:链接器把各输入文件的 Section 按规则合并,形成最终 ELF
强符号与弱符号
如果多个目标文件中出现同名定义,链接器需要决定采用哪一个。可以先记住常见规则:
- 多个同名强定义通常会导致“重复定义”链接错误;
- 一个强定义和若干弱定义同时存在时,通常选择强定义;
- 只有多个弱定义时,链接器可以选择其中一个,具体选择不应作为程序逻辑依赖;
- 没有定义的普通强引用通常会导致“未定义符号”错误;未解析的弱引用则允许保留为 0 或按平台规则处理。
int default_handler(void) __attribute__((weak));
int default_handler(void)
{
return 0;
}
__attribute__((weak)) 是 GCC/Clang 常见扩展,可把该定义标记为弱符号。嵌入式启动文件常使用弱定义提供默认中断处理函数,再允许用户用同名强定义覆盖它。
补充:过去未初始化的文件作用域变量可能以 COMMON 符号形式参与链接。现代 GCC 默认使用 -fno-common,多个同名定义更容易直接报错,因此不要把“未初始化全局变量一定是弱符号”当成规则。
六、认识 ELF 文件
ELF(Executable and Linkable Format)既可用于可重定位目标文件、可执行文件,也可用于共享库和 Core Dump。常用的查看命令有:
readelf -h a.out # 查看 ELF Header
readelf -S a.out # 查看 Section Headers
readelf -l a.out # 查看 Program Headers
readelf -s a.out # 查看符号表
readelf -r main.o # 查看重定位表

图 7:ELF Header 中包含文件类型、目标架构、入口地址以及各类表的位置

图 8:ELF 中常见的 Section Headers
常见 Section 与 C 语言内容的对应关系
| Section | 主要内容 | C 语言示例或说明 |
|---|---|---|
.text |
机器指令,通常可读、可执行 | void func(void) { } |
.rodata |
只读数据和字符串常量 | "hello";具体放置方式由编译器和链接脚本决定 |
.data |
具有非零初值的全局变量和静态变量 | int a = 10; |
.bss |
零初始化或未显式初始化的全局/静态对象 | static int count; |
.symtab |
完整符号表,可能在发布时被剥离 | 记录 main、sum 等符号 |
.strtab |
与符号表等结构配合使用的字符串表 | 保存符号名称字符串 |
.rel.* / .rela.* |
重定位信息 | 具体使用 REL 还是 RELA 与架构和目标格式有关 |
.plt |
过程链接表 | 常为动态函数调用提供跳转入口 |
.got |
全局偏移表 | 动态链接或位置无关代码通过它间接取得地址 |
.init_array |
初始化函数指针数组 | 在 main 前调用,C++ 构造也可使用 |
.fini_array |
终止函数指针数组 | 正常退出阶段调用,C++ 析构也可使用 |
.ARM.attributes |
ARM 目标文件属性 | 记录架构、ABI、浮点约定等属性 |
.ARM.exidx |
ARM 异常展开索引 | 用于异常展开和栈回溯等 |
为什么 .bss 能减小文件体积
.bss 中的对象在运行时需要占用内存,也具有大小和地址,但 ELF 文件通常不保存与其大小相等的一大片零字节,只记录“需要多少空间”。
- 在操作系统环境中,装载器建立相应的零初始化内存区域;
- 在裸机环境中,启动代码通常根据链接器提供的边界符号把
.bss清零。
所以准确地说,.bss 节省的是可执行文件或固件镜像中的存储空间,而不是运行时 RAM。
Section 与 Segment 不要混淆:Section 主要服务于编译和链接,Program Header 描述的 Segment 主要服务于装载和运行。Linux 内核装载可执行 ELF 时,重点读取的是 Program Header,并把需要装载的 PT_LOAD Segment 映射到进程地址空间。
七、程序的安装
“安装”通常不是新的编译阶段,而是把已经构建好的程序、库和配置文件放到目标环境能够找到的位置。
| 环境 | 安装的含义 | 常见形式 |
|---|---|---|
| Linux 应用 | 复制可执行文件、共享库、配置和资源,并设置权限或服务 | make install、包管理器、复制到根文件系统 |
| 交叉编译系统 | 把目标程序部署到开发板,而不是在编译主机上直接运行 | SCP、NFS、制作根文件系统或系统镜像 |
| 裸机/MCU | 把由 ELF 转换得到的 BIN/HEX 固件写入非易失存储器 | 调试器、下载器、串口 Bootloader、OTA |
八、程序如何运行
1. 操作系统环境
以 Linux 为例,在 Shell 中输入程序名时,Shell 通常会创建子进程并调用 execve() 装入新程序。但 fork() 不是 ELF 自身运行的必经步骤,其他进程也可以直接调用 execve() 替换自身映像。
- 内核检查 ELF Header 和 Program Headers;
- 创建或替换进程的虚拟地址空间;
- 映射代码、只读数据、可写数据等 Segment,并建立栈;
- 若为动态链接程序,装载动态链接器和所需共享库,完成必要的重定位;
- 设置初始寄存器与入口地址,转入用户态执行;
- C 运行库启动代码完成初始化后调用
main()。

图 9:操作系统环境下,从 ELF 装载到进程执行的主要路径
2. 裸机环境
裸机没有进程、虚拟地址空间以及用户态/内核态切换。固件被写入 Flash 后,CPU 在上电或复位时从架构规定的复位入口开始运行:
- 取得复位向量或复位处理函数地址;
- 设置栈指针,执行底层硬件初始化;
- 把
.data的初始值从 Flash 复制到 RAM; - 清零 RAM 中的
.bss; - 可选地初始化时钟、FPU、C/C++ 运行库等;
- 调用
main(),程序通常进入主循环,并由中断处理异步事件。

图 10:裸机固件从烧写、复位、启动初始化到 main 函数的执行路径
九、静态链接与动态链接
| 对比项 | 静态链接 | 动态链接 |
|---|---|---|
| 常见库文件 | .a |
.so |
| 链接方式 | 链接器从静态库中选取需要的目标模块,并复制到最终可执行文件 | 生成程序时记录共享库依赖;装载或运行时由动态链接器完成映射和符号绑定 |
| 最终文件 | 通常较大,已包含所需库代码 | 通常较小,主要保留共享库依赖和动态链接信息 |
| 运行依赖 | 不依赖对应的外部共享库文件,但仍依赖操作系统及系统调用接口 | 目标系统必须存在 ABI 兼容的共享库和动态链接器 |
| 内存共享 | 不同程序通常各自携带库代码 | 多个进程可共享同一份只读代码页,可能节省内存 |
| 启动与调用开销 | 不需要动态装载和运行时符号解析 | 初次装载或延迟绑定可能产生额外开销,但不能简单断言整体执行一定更慢 |
| 升级维护 | 库变化后通常要重新链接并发布程序 | 可独立更新共享库,但必须保持 ABI 兼容,否则程序仍可能无法运行 |
| 常见场景 | 裸机固件、恢复工具、部署环境固定的程序 | Linux 桌面与服务器应用、多个程序共享公共库 |
我的理解:静态链接强调“把需要的库代码装进自己的可执行文件”,动态链接强调“运行环境提供共享库”。两者没有绝对优劣,要根据存储空间、部署方式、ABI、升级需求和运行环境选择。
十、本章小结
- 预处理负责展开头文件、替换宏和处理条件编译,输出仍然是 C 文本。
- 编译器经过词法、语法、语义分析及中间表示、优化和代码生成,输出汇编代码。
- 汇编器把汇编代码变成带有 Section、符号和重定位信息的
.o文件。 - 链接器合并 Section、解析符号、分配地址并完成重定位,生成最终 ELF。
- Section 更偏向编译链接视角,Segment 更偏向装载运行视角。
- 操作系统通过装载器建立进程映像;裸机通过启动代码初始化 RAM 后进入
main()。 - 程序的“安装”在 Linux 中通常是部署文件,在 MCU 中通常是烧写固件。
理解这条完整链路以后,再看编译报错、链接错误、链接脚本、启动文件和反汇编,就不再是互不相关的知识点,而是同一条程序生命周期上的不同环节。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)