整体一条完整链路:

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 语言,去掉注释、展开宏、头文件) 做的事情:

  1. 删除注释
  2. #include <xxx.h> 把头文件内容直接原地展开
  3. #define 宏替换,条件编译 #ifdef / #endif 裁剪代码
  4. 处理 #error、#line 等预处理指令

这一步不做语法检查,只是文本替换。 命令单独执行:

gcc -E test.c -o test.i

阶段 2:编译(Compile)

工具:gcc 内置的 cc1

输入:test.i

输出:汇编代码 test.s(汇编文本,ARM 汇编指令)

这一步才是高级语言转汇编:

  1. 词法分析:拆成 token(关键字、变量、运算符)
  2. 语法分析:构建语法树 AST
  3. 语义分析:类型检查、报错(变量未定义、类型不匹配就在这里报)
  4. 优化(O0/O1/O2)
  5. 生成对应架构汇编(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 

链接分两大块:符号解析 + 重定位

  1. 符号解析:找到每个未定义符号对应的实现 比如代码里调用 printf(),去 libc 库里面找到 printf 符号
  2. 重定位:根据符号真实地址,修改 .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。

Logo

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

更多推荐