我们写下的 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 相加。
  • 语义分析还会检查 ab 是否已经声明,以及参与运算的类型是否合法。

实际编译器的各阶段并非完全割裂,一条错误信息也不一定能机械地归到唯一阶段,但这个划分有助于建立整体认识。

四、汇编:从 .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。可以把核心过程概括为:

  1. 读取各个目标文件和库;
  2. 把性质相同或规则指定的输入 Section 合并到输出 Section;
  3. 解析符号引用,判断每个外部名称对应哪个定义;
  4. 根据链接脚本和布局规则分配地址;
  5. 处理重定位,把正确的地址或偏移写入代码和数据;
  6. 生成可执行文件或共享库。

图 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 完整符号表,可能在发布时被剥离 记录 mainsum 等符号
.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() 替换自身映像。

  1. 内核检查 ELF Header 和 Program Headers;
  2. 创建或替换进程的虚拟地址空间;
  3. 映射代码、只读数据、可写数据等 Segment,并建立栈;
  4. 若为动态链接程序,装载动态链接器和所需共享库,完成必要的重定位;
  5. 设置初始寄存器与入口地址,转入用户态执行;
  6. C 运行库启动代码完成初始化后调用 main()

图 9:操作系统环境下,从 ELF 装载到进程执行的主要路径

2. 裸机环境

裸机没有进程、虚拟地址空间以及用户态/内核态切换。固件被写入 Flash 后,CPU 在上电或复位时从架构规定的复位入口开始运行:

  1. 取得复位向量或复位处理函数地址;
  2. 设置栈指针,执行底层硬件初始化;
  3. 把 .data 的初始值从 Flash 复制到 RAM;
  4. 清零 RAM 中的 .bss
  5. 可选地初始化时钟、FPU、C/C++ 运行库等;
  6. 调用 main(),程序通常进入主循环,并由中断处理异步事件。

图 10:裸机固件从烧写、复位、启动初始化到 main 函数的执行路径

九、静态链接与动态链接

对比项 静态链接 动态链接
常见库文件 .a .so
链接方式 链接器从静态库中选取需要的目标模块,并复制到最终可执行文件 生成程序时记录共享库依赖;装载或运行时由动态链接器完成映射和符号绑定
最终文件 通常较大,已包含所需库代码 通常较小,主要保留共享库依赖和动态链接信息
运行依赖 不依赖对应的外部共享库文件,但仍依赖操作系统及系统调用接口 目标系统必须存在 ABI 兼容的共享库和动态链接器
内存共享 不同程序通常各自携带库代码 多个进程可共享同一份只读代码页,可能节省内存
启动与调用开销 不需要动态装载和运行时符号解析 初次装载或延迟绑定可能产生额外开销,但不能简单断言整体执行一定更慢
升级维护 库变化后通常要重新链接并发布程序 可独立更新共享库,但必须保持 ABI 兼容,否则程序仍可能无法运行
常见场景 裸机固件、恢复工具、部署环境固定的程序 Linux 桌面与服务器应用、多个程序共享公共库

我的理解:静态链接强调“把需要的库代码装进自己的可执行文件”,动态链接强调“运行环境提供共享库”。两者没有绝对优劣,要根据存储空间、部署方式、ABI、升级需求和运行环境选择。

十、本章小结

  1. 预处理负责展开头文件、替换宏和处理条件编译,输出仍然是 C 文本。
  2. 编译器经过词法、语法、语义分析及中间表示、优化和代码生成,输出汇编代码。
  3. 汇编器把汇编代码变成带有 Section、符号和重定位信息的 .o 文件。
  4. 链接器合并 Section、解析符号、分配地址并完成重定位,生成最终 ELF。
  5. Section 更偏向编译链接视角,Segment 更偏向装载运行视角。
  6. 操作系统通过装载器建立进程映像;裸机通过启动代码初始化 RAM 后进入 main()
  7. 程序的“安装”在 Linux 中通常是部署文件,在 MCU 中通常是烧写固件。

理解这条完整链路以后,再看编译报错、链接错误、链接脚本、启动文件和反汇编,就不再是互不相关的知识点,而是同一条程序生命周期上的不同环节。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐