C 语言:编译 → 汇编 → 链接 → 加载运行
整体一条完整链路:
C 源码 (.c) → 预处理 → 编译 → 汇编 → 链接 → ELF 可执行文件 → OS 加载器加载到内存 → CPU 执行
一、C程序的完整链路
C 属于编译型高级语言,不是解释型
整个过程分为4 大阶段(预处理、编译、汇编、链接),最后还有操作系统的加载运行阶段。
以 ARM 交叉编译举例:arm-linux-gnueabihf-gcc test.c -o test
阶段 1:预处理(Preprocess)
工具:cpp(C 预处理器,gcc 内部调用)
输入:test.c
输出:预处理后的文本文件 test.i
(C 语言,去掉注释、展开宏、头文件) 做的事情:
- 删除注释
#include <xxx.h>把头文件内容直接原地展开#define宏替换,条件编译#ifdef / #endif裁剪代码- 处理
#error、#line等预处理指令
这一步不做语法检查,只是文本替换。 命令单独执行:
gcc -E test.c -o test.i
阶段 2:编译(Compile)
工具:gcc 内置的 cc1
输入:test.i
输出:汇编代码 test.s(汇编文本,ARM 汇编指令)
这一步才是高级语言转汇编:
- 词法分析:拆成 token(关键字、变量、运算符)
- 语法分析:构建语法树 AST
- 语义分析:类型检查、报错(变量未定义、类型不匹配就在这里报)
- 优化(O0/O1/O2)
- 生成对应架构汇编(ARMv8 A55 汇编,x86 就是 x86 汇编)
重点区分:很多人口语说的 “编译” 是全套;编译器理论里狭义编译 = C → 汇编
gcc -S test.i -o test.s
得到的 .s 可以直接打开看汇编指令:ldr、str、add、bl 这些。

阶段 3:汇编(Assemble)
工具:as(汇编器)
输入:test.s 汇编文本
输出:可重定位目标文件 test.o(ELF 格式)
汇编器做的事: 把一条条汇编指令,翻译成 CPU 能识别的二进制机器码,生成 ELF .o
- 生成段:
.text代码段、.data已初始化全局变量、.bss未初始化全局变量 - 生成符号表:函数名、全局变量名(符号)
- 生成重定位表:记录哪些符号地址现在还不知道,后面链接的时候填地址
重点:
test.o里面所有外部函数(比如 printf)只有符号占位,没有真实地址!
gcc -c test.s -o test.o
可重定位目标程序,就是这个 .o!
阶段 4:链接(Link)
工具:ld 链接器,gcc 会自动调用 ld
输入:一个或多个 .o + 静态库 .a / 动态库 .so
输出:ELF 可执行文件 test
链接分两大块:符号解析 + 重定位
- 符号解析:找到每个未定义符号对应的实现 比如代码里调用
printf(),去 libc 库里面找到 printf 符号 - 重定位:根据符号真实地址,修改
.o机器码里占位的地址 把之前重定位表里面待填的地址全部补全
链接两种方式:
- 静态链接:
libc.a里面用到的代码,直接拷贝合并进最终程序;程序独立,不依赖外部库,体积大。 - 动态链接(Linux 默认):不把 printf 代码拷进去,只记录依赖
libc.so.6;程序运行的时候,操作系统再去加载 so、绑定地址。体积小,多个程序共享库。
# 手动链接示例
ld test.o -o test -lc
链接完得到的 ELF 还不能直接跑!
它只是磁盘上的文件,地址只是虚拟地址规划,还没有进内存。
阶段 5:加载 & 运行(Linux OS 完成)
在 ARM Linux 下,执行 ./test,内核做这些:
1、shell 调用 execve() 系统调用,把 ELF 交给内核加载器
2、内核解析 ELF 头部:读取各个段(.text/.data/.bss)
3、在进程的虚拟地址空间分配内存:
- .text:代码段,只读,放机器指令
- .data:存放已经初始化的全局变量
- .bss:零初始化,不占磁盘空间,加载时内核清 0
4、如果是动态链接程序:内核先启动 动态链接器 ld-linux.so,由它加载所有依赖 .so,完成运行时符号绑定
5、设置栈、堆、环境变量、argv
6、设置 CPU 的 PC 寄存器(程序计数器)指向入口地址(不是 main!是 _start)
7、CPU 开始一条条取指、译码、执行机器码
C 程序入口不是
main()!_start是 libc 提供的入口函数:先初始化 libc 运行环境、构造栈,最后才调用main;main return 之后回到_start,调用exit()退出进程。

二、C程序完整链路流程图
test.c
↓ 预处理 cpp
test.i
↓ 编译 cc1(C→汇编)
test.s
↓ 汇编 as
test.o (可重定位ELF)
↓ 链接 ld
test (可执行ELF)
↓ execve 内核加载
映射到进程虚拟内存,ld.so动态链接
↓ PC指向_start,CPU开始跑机器码
三、嵌入式 ARM 开发的几个关键点
1、PC 上 gcc 是 x86 编译器
给 A55 ARM64用的是 aarch64-linux-gnu-gcc 交叉编译器,生成 ARM64 机器码,x86 PC 不能直接跑。
2、U-Boot 里裸机程序
很多时候只编译 + 汇编,不依赖 libc、不经过动态链接;没有 OS 加载器,自己在物理地址运行。
3、内核模块 .ko
也是 ELF 可重定位文件,由内核在 insmod 的时候完成内核态链接,不是用户态程序。
4、重定位
① .o 阶段是可重定位
② 链接之后可执行文件是已经完成重定位
③ 动态库 so 是位置无关代码 PIC,支持加载到任意虚拟地址。
四、小结和补充
PIC 位置无关代码(-fPIC)
1、动态库 .so 编译必须加 -fPIC,代码里面不写死绝对地址,靠相对地址寻址
2、so 可以被加载到任意虚拟地址,不用改代码段。
3、静态程序不需要 PIC。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)