基于RISC-V的编译链接和装载06之编译
基于RISC-V的编译链接和装载06之编译
这篇文档主要讲述编译以及交叉编译。
1 程序为什么要编译?
编译的过程其实就是将源程序翻译成CPU能够识别的二进制机器指令的过程。

源代码(方便人类阅读):
例如,如下helloworld.c代码,这是给人类看的代码,CPU并不理解什么意思,因为CPU只认识二进制机器码。
/* helloworld.c */
#include <stdio.h>
int main(void)
{
printf("hello, world\n");
return 0;
}
机器码(方便CPU阅读):
CPU只认识二进制机器码,如下是helloworld ELF的反汇编(局部),三列内容分别是地址,二进制机器码,对应的汇编指令。实际放到内存中的只有机器码,也即第二列的内容。
# 第一列:地址 二列:机器码 三列:反汇编指令
10450 <main>:
10450: 6541 lui a0,0x10
10452: 1141 addi sp,sp,-16
10454: 50850513 addi a0,a0,1288 # 10508 <_IO_stdin_used+0x8>
10458: e406 sd ra,8(sp)
1045a: fe7ff0ef jal 10440 <puts@plt>
1045e: 60a2 ld ra,8(sp)
10460: 4501 li a0,0
10462: 0141 addi sp,sp,16
10464: 8082 ret
如果某天,哪个大神直接使用01来编程,那么CPU直接可以理解,就不需要编译汇编等过程了。
2 编译的过程
一些底层软件面试总爱问一个问题:编译过程分为哪几步?
这个问题比较基础,主要通过这个问题来了解面试者对编译链接过程是否有一些概念。
对于C语言类似的编译性语言:
-
编译如果从广义来说,表示源码从预处理(Prepressing)->编译(Compilation)->汇编(Assembly)->链接(Linking)的整个流程;
-
狭义上的编译就是其中编译(从预处理
.i文件到汇编代码.s文件)这一步;

2.1 预处理(Prepressing)
对于C语言,预处理一般之使用#号标记的语句,提供预处理命令是为了方便编程:
常见的预处理命令如下表:
| 类别 | 命令 | 主要用途 |
|---|---|---|
| 文件包含 | #include |
插入头文件内容,可以实现模块化编程 |
| 宏定义 | #define、#undef |
定义/取消符号常量或宏函数,提高程序可读性 |
| 条件编译 | #if、#ifdef、#ifndef、#else、#elif、#endif |
选择性编译代码,方便让代码兼容不同的处理器平台 |
| 错误/警告 | #error、#warning |
编译时输出消息 |
| 调试优化相关 | #line、#pragma |
修改行号、编译器指令 |
| 字符串化 | #、## |
宏参数转换(宏内使用) |
预处理过程主要包括以下操作:
- 头文件展开,将
#include包含的头文件展开 - 宏展开,将
#define定义的常量展开 - 条件编译,处理
#if、#ifdef、#endif等包括的内容,丢弃掉其余分支 - 删除注释,注释是给人类阅读的,机器完全不需要
- 添加行号与文件标识
2.2 编译(Compilation)
一个完整的编译(狭义)过程通常分为以下几个主要阶段:
| 阶段 | 说明 |
|---|---|
| 1. 词法分析(Lexical Analysis) | 将源代码分解成“词法单元”(Tokens),如关键字、标识符、运算符等 |
| 2. 语法分析(Syntax Analysis / Parsing) | 根据语言的语法规则,将 Token 流构建成抽象语法树(AST) |
| 3. 语义分析(Semantic Analysis) | 检查 AST 是否符合语言的语义规则(如类型匹配、变量声明等),并生成带注解的 AST |
| 4. 中间代码生成(Intermediate Code Generation) | 将 AST 转换为一种与目标机器无关的中间表示(IR),如 GIMPLE、RTL、LLVM IR 等 |
| 5. 优化(Optimization) | 对中间代码进行各种优化(如常量折叠、死代码消除、循环优化等) |
| 6. 目标代码生成(Code Generation) | 将优化后的中间代码转换为目标架构的汇编代码 |
2.3 汇编(Assembly)
汇编过程是将汇编代码(.s) 翻译成目标文件(.o),同时构建 ELF 结构的过程。
换句话说:汇编过程是将汇编代码翻译成对应的二进制指令,同时生成一些必要信息,以节(section)的形式组装到目标文件中。
2.4 链接(Linking)
链接器将多个目标文件"粘合"成一个完整的程序,解决跨文件符号引用,为代码和数据分配最终的运行时地址,并生成操作系统可直接加载执行的 ELF 文件。
后面几篇会重点介绍静态链接与动态链接。
3 交叉编译
在一个平台上使用工具生成另一个平台(目标平台)可运行的程序,这个过程就是交叉编译。
比如我们用RISC-V交叉编译工具链riscv64-unknown-linux-gnu-gcc 在x86 机器上编译出能在RISC-V平台运行的ELF,而这个ELF不能在x86 intel机器上运行,原因是显而易见的,交叉编译出的指令是RISC-V指令,在x86 intel上运行会失败(报非法指令)。
GCC编译中-I -L -l 等参数的使用:
交叉编译要熟练掌握riscv64-unknown-linux-gnu-gcc 与 -I, -L, -l 选项的搭配使用,这三个选项分别控制编译和链接过程中对头文件、库文件路径和库名称的搜索,熟练掌握这三者的使用,是进行复杂项目交叉编译的基础。
| 选项 | 全称/含义 | 作用阶段 | 作用 |
|---|---|---|---|
-I |
Include | 预处理阶段 (Preprocessing) | 指定编译器搜索 头文件**(.h, .hpp)的额外目录 |
-L |
Library Path | 链接阶段 (Linking) | 指定链接器搜索库文件(.a, .so)的额外目录 |
-l |
link library | 链接阶段 (Linking) | 指示链接器链接指定名称的库文件。它会自动查找 lib<name>.a 或 lib<name>.so |
交叉编译举例:
$ riscv64-unknown-linux-gnu-gcc \
-I/path/to/libA/include \ # -I告诉编译器去哪找头文件, -I 可以有多个
main.c \
-L/path/to/libA/lib \ # 告诉链接器去哪找 libmylib.a, -L 可以有多个
-lmylib \ # 告诉链接器要链接 libmylib.a (自动补全为 libmylib.a/.so)
-lpthread \ # 告诉链接器要链接 libpthread (系统库)
-o main
链接库有三种写法:
链接库有如下三种写法,其区别如下:
| 写法 | 示例 | 特点 |
|---|---|---|
-l + -L |
-L.../lib -lmylib |
简洁,但 .so/.a 选择不可控,优先找 .so(动态),找不到才用 .a(静态),除非链接参数指定-static |
| 全路径 | .../lib/libmylib.a |
最明确,但路径写死,不便移植,常用于只想要某个特定库文件、避免歧义 |
-l: |
-L.../lib -l:libmylib.a |
可以精确选择 .a 还是 .so,同时仍享受 -L 的搜索路径 |
一定要注意链接顺序:
链接顺序在 C/C++ 编译中很重要,这涉及到符号解析的算法。
牢记:源文件在前,库在后
记住如下一句话:
在大型 C/C++ 项目中,调整链接库的顺序往往是解决
undefined reference(找不到符号)或隐藏 Bug 的终极手段。
更细致的讨论参考:链接顺序的影响
4 编译问题探讨
4.1 为什么编译要分节?
源代码编译后,代码部分会放到.text节,数据部分会放到.data节,只读数据放到.rodata节等等,之前一直有个疑问,为什么要分节,分节有什么好处,不分节行不行?以下对这些情况进行分析:
有如下原因:
原因1:安全原因(最致命)
现代操作系统和 CPU 遵循 W^X(Write or eXecute, but not both)安全原则,也即:
.text节(代码):标记为 可执行(X)、可读(R),但 不可写(W).data节(数据):标记为 可读(R)、可写(W),但 不可执行(X)
如果代码和数据混合在一起,攻击者可能向数据区写入恶意机器码(如缓冲区溢出),然后跳转到该数据区执行,实现代码注入攻击。通过分离 .text 和 .data,操作系统可以避免这种攻击。
原因2:性能原因:
现代 CPU 通常采用 哈佛架构(Harvard Architecture)的缓存设计,CPU 可以同时从 I-Cache 取指令,从 D-Cache 读写数据,如果代码和数据混合,缓存效率会显著下降。
原因3:内存共享:
多个进程运行同一个程序时,.text 节可以共享物理内存页(只读 + 可执行),每个进程的 .data 节必须有独立副本(可写),如果代码和数据混合,就无法实现这种高效的内存共享。
原因4:链接与重定位的便利性:
在链接阶段,链接器需要对代码中的符号引用进行重定位(Relocation)。如果代码和数据混合,重定位表会变得极其复杂,难以管理。
分离后:.text 节的重定位集中在代码段,.data 节的重定位集中在数据段,链接器可以按节处理,逻辑清晰。
总结:
分离 .text 和 .data 是现代操作系统和硬件协同设计的结果,是安全性、性能和可维护性的必然要求。
4.2 从源码到目标文件
从编码经过预处理(Prepressing)->编译(Compilation)->汇编(Assembly) 得到目标文件,那具体是哪个步骤产生节呢?
答案是汇编这一步,从汇编文件汇编后得到节
具体来说:
- 编译(狭义)生成的是汇编代码(.s 文件),它只“标记”了节的归属;在
.s文件中,编译器会插入各种汇编伪指令(Directives),其中最核心的就是.section,用来告诉汇编器:“接下来的内容属于哪个节”。 - 汇编器真正把代码和数据“打包”成目标文件中各个节(Section)
下图来自《程序员的自我修养—链接、装载与库》的3.2节:

由图可见,其元素的空间分配符合下表:
| 程序中的元素 | 分配的节 |
|---|---|
| 代码 | .text节 |
| 字符串常量以及const常量 | .rodata节 |
| 已初始化的全局变量 | .data节 |
| 已初始化的局部静态变量 | .data节 |
| 初始化为0的全局变量 | .bss节 |
| 未初始化全局变量 | .bss节 |
| 未初始化的局部静态变量 | .bss节 |
| 局部变量 | 栈空间 |
参考:
- Makefile的使用
- 程序编译(1)-目标文件的编译步骤 - jesonwoo - 博客园
- 《程序员的自我修养—链接、装载与库》
- RISC-V Binutils工具链04之ld工具的使用

浙公网安备 33010602011771号