基于RISC-V的编译链接和装载07之静态链接
基于RISC-V的编译链接和装载07之静态链接
静态链接是将多个目标文件和库文件合并为一个可执行文件的过程,它主要包括如下三步:
- 节合并:将相同类型的节(如
.text、.data)合并成更大的、连续的节,确定了基本内存布局; - 符号解析:建立符号引用与定义的关联,解决跨文件的函数和变量引用;
- 重定位:根据前两步的结果,修正所有地址引用,为代码和全局变量分配最终地址。
1 节合并
1.1 相似节合并
上一章讲述了每个源文件经过编译后分节存放,链接就是将这些节合并起来,对于多个输入目标文件,链接器怎么合并它们呢?最终目标文件的节是怎么摆放呢?有如下两种合并方式:
- 方式1: 顺序叠加
- 方式2:相似节合并

方式1:顺序叠加
将目标文件按照次序依次叠加起来,比较简单,但是这么做会造成一些问题:
- 内存碎片,每个节可能都有对齐需求(如4KB对齐等),成千上万个节就会造成较大的空间浪费;
- 安全原因,因为代码段要放到只读区,数据段要放到可读可写区,如果区间的权限交替,不好控制。
方式2:相似节合并(标准)
将多个目标文件中相同类型的节(section)合并成一个连续的节,这种是标准做法。
控制节组装顺序:
既然已经确定了使用相似节合并方式,接下来,如何指定程序各个节的组装顺序呢?
答案是:链接脚本,第12章将讲述链接脚本的一些知识,下面举一个例子来说明。
1.2 用链接脚本控制节的组装顺序
这里用一个简单例子来说明使用链接脚本控制节的组装顺序,以及精确控制不同源文件的节合并顺序。
假设我们要链接如下两个文件(用例见 02_static_link):
第一个文件a.c定义了一个全局变量 shared以及main函数,在main函数中引用了foo函数
第二个文件b.c引用了全局变量 shared,以及定义了foo函数
// a.c
int shared = 5; // 定义全局变量shared
int main() { // 定义main函数
int a = foo(); // 引用foo函数
return 0;
}
// b.c
extern int shared;
int foo(void) { // 定义foo函数
return shared * 2; // 引用全局变量shared
}
编译:
$ cd riscv-elf-link-load/test/02_static_link
$ make
提供了3个链接脚本:
脚本1:控制最终输出节(Output Sections)的先后顺序,.text节放到.data节前
/*
* linker_1.lds
* 第一个脚本,起始地址放在0x10000,.text节放到.data节前
*/
SECTIONS {
. = 0x10000;
.text : { *(.text) }
.data : { *(.data) }
}
在Makefile中修改LDSCRIPT := linker_1.lds, 编译后,可以查看反汇编或者直接查看符号位置:
$ riscv64-unknown-linux-gnu-nm static_link.elf
0000000000010022 T foo
0000000000010000 T main
00000000000100b0 D shared
其中shared是定义全局变量,放在.data节,foo与main函数属于.text节,可见:.text节放到.data节前
脚本2:控制最终输出节(Output Sections)的先后顺序,.data节放到.text节前
/*
* linker_2.lds
* 第二个脚本,调整节之间的顺序,.data节放到.text节前
*/
SECTIONS {
. = 0x10000;
.data : { *(.data) }
.text : { *(.text) }
}
在Makefile中修改LDSCRIPT := linker_2.lds, 编译后,可以查看反汇编或者直接查看符号位置:
riscv64-unknown-linux-gnu-nm static_link.elf
0000000000010026 T foo
0000000000010004 T main
0000000000010000 D shared
可见:使用这个链接脚本,.data节放到.text节前
脚本3:控制不同源文件(目标文件)的节合并顺序
实现方式:在链接脚本中,不使用 *(.text) 这种全局通配符,而是显式指定具体的目标文件。如:
- 在链接脚本中,在输出节的大括号
{ }内,可以通过指定文件名来控制合并顺序,如将a.o(.text)放在b.o(.text)前面 - 或通过调整命令行调整目标文件的输入顺序
/*
* linker_3.lds
* 第三个脚本,精确控制不同源文件的节合并顺序,b.o 的.text节放到a.o 的.text前
*/
SECTIONS {
. = 0x10000;
.text : {
b.o(.text)
a.o(.text)
}
.data : { *(.data) }
}
在Makefile中修改LDSCRIPT := linker_3.lds, 编译后,可以查看反汇编或者直接查看符号位置:
$ riscv64-unknown-linux-gnu-nm static_link.elf
0000000000010000 T foo
0000000000010020 T main
00000000000100b0 D shared
可见:使用这个链接脚本,b.o 的.text节放到a.o 的.text前
脚本4:控制同一源文件内部符号/节的先后顺序
上述3个脚本演示了控制节之前的前后顺序,以及不同源文件的符号顺序,更进一步的还能更细致的控制某个源文件内部符号的先后顺序吗?比如 a.c 有多个函数foo() 以及 main() 等,能控制这些函数的顺序吗?
回答:可以进一步调节,但这已经超出了普通链接脚本(Linker Script)的能力,需要编译器(Compiler)和链接脚本配合来实现。
在默认情况下,编译器会将同一个源文件中的所有代码打包到一个 .text 段中,链接器无法“拆开”它们。要实现源文件内部符号(如不同的函数)的排序,需要让编译器为每个符号生成独立的段,然后由链接脚本对这些独立段进行排序。编译器与链接器分别要做如下工作:
-
在编译源文件时,需要加上
-ffunction-sections(针对函数)或-fdata-sections(针对全局变量)选项。 -
链接脚本配合:拆分后,链接脚本就可以像对待不同文件一样,使用
a.o(.text.foo)和a.o(.text.main)来精确控制同一文件内部符号的先后顺序。
如果同一文件内有大量的函数被拆分成了独立的节,我们不需要在链接脚本中一个个手写。可以直接利用链接脚本内置的 SORT() 关键字(例如 *(SORT(.text.*))),让链接器自动按照字母顺序或其他规则对这些内部节进行排序,这在大型项目中非常实用。
因为当前的例子比较简单,源码中并没有提供脚本4。
总的来说:
链接脚本可以控制程序各部分的布局,链接脚本的粒度是“节”具体包括:
- 输出节的顺序与地址(如
.text、.data、.bss谁在前谁在后),见脚本1与2; - 同一输出节内,各输入节/各目标文件的排列顺序(决定哪个
.o的代码在前),见脚本3; - 间接控制函数和全局变量的顺序——前提是它们被分配到可区分的输入节中,再通过链接脚本排列这些节,见脚本4。
2 符号解析
多个目标中的节(section)并不是独立的,这个目标文件可能需要调用另外一个目标文件的全局变量或者函数,符号解析(symbol resolution)将符号引用与符号定义关联起来,解决目标文件之间的符号依赖关系。
查看目标文件符号表:
查看 a.o 的符号
# 查看 a.o 的符号
$ riscv64-unknown-linux-gnu-nm a.o
U foo
0000000000000000 T main
0000000000000000 D shared
查看 b.o 的符号
# 查看 b.o 的符号
$ riscv64-unknown-linux-gnu-nm b.o
0000000000000000 T foo
U shared
也即:
| 符号 | 定义位置 | 类型 | 地址 |
|---|---|---|---|
shared |
a.o (.data) |
数据 | 待确定 |
main |
a.o (.text) |
函数 | 待确定 |
foo |
b.o (.text) |
函数 | 待确定 |
符号解析:
其符号解析过程如下:

上述的例子只是最简单的情况,当遇到多个同名的符号,链接器最终决定链接哪个呢?有如下原则:
同名的符号链接原则(强弱符号规则):
- 强符号不允许多次定义,否则就会发生重定义错误
- 一个强符号可以与弱符号共存
- 允许多个弱符号存在,如果有多个弱符号且没有强符号,那么取体积大者弱符号
注意:符号解析时有不同规则,比如:“强弱符号规则”与“首次出现原则”,详细情况可以查看链接过程中的符号处理
3 重定位
重定位就是为函数和全局变量分配最终地址。
静态链接过程包括地址粗调与细调:
- 地址粗调:节合并时,初步确定地址(第一节提到使用
linker_1.lds等链接脚本控制顺序) - 地址细调:重定位修正,为函数和全局变量分配最终地址,修正指令
3.1 查看重定位信息
链接器怎么知道哪些符号需要调整地址呢?这就需要重定位表,重定位表记录了需要重定位的符号。
查看 a.o 的重定位表:
# 查看 a.o 的重定位表
$ riscv64-unknown-linux-gnu-objdump -r a.o
a.o: file format elf64-littleriscv
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000008 R_RISCV_CALL_PLT foo
0000000000000008 R_RISCV_RELAX *ABS*
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
000000000000001c R_RISCV_32_PCREL .L0
0000000000000020 R_RISCV_ADD32 .L0
0000000000000020 R_RISCV_SUB32 .L0
0000000000000031 R_RISCV_SET6 .L0
0000000000000031 R_RISCV_SUB6 .L0
从上述log可以看出:foo函数的重定位类型为R_RISCV_CALL_PLT,这个标签同时打上R_RISCV_RELAX标签,表示可以被链接松弛优化。 后部分(.eh_frame)表示异常帧的重定位信息,与DWARF调试信息相关,不再详述。
查看 b.o 的重定位表:
# 查看 b.o 的重定位表
$ riscv64-unknown-linux-gnu-objdump -r b.o
b.o: file format elf64-littleriscv
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000008 R_RISCV_HI20 shared
0000000000000008 R_RISCV_RELAX *ABS*
000000000000000c R_RISCV_LO12_I shared
000000000000000c R_RISCV_RELAX *ABS*
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
000000000000001c R_RISCV_32_PCREL .L0
0000000000000020 R_RISCV_ADD32 .L0
0000000000000020 R_RISCV_SUB32 .L0
0000000000000031 R_RISCV_SET6 .L0
0000000000000031 R_RISCV_SUB6 .L0
从上述log可以看出:全局变量shared的重定位类型为R_RISCV_HI20与R_RISCV_LO12_I
3.2 查看反汇编
查看链接前的反汇编。
查看a.o的反汇编:
$ riscv64-unknown-linux-gnu-objdump -d -r a.o
a.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <main>:
0: 1101 addi sp,sp,-32
2: ec06 sd ra,24(sp)
4: e822 sd s0,16(sp)
6: 1000 addi s0,sp,32
8: 00000097 auipc ra,0x0
8: R_RISCV_CALL_PLT foo
8: R_RISCV_RELAX *ABS*
c: 000080e7 jalr ra # 8 <main+0x8>
10: 87aa mv a5,a0
12: fef42623 sw a5,-20(s0)
16: 4781 li a5,0
18: 853e mv a0,a5
1a: 60e2 ld ra,24(sp)
1c: 6442 ld s0,16(sp)
1e: 6105 addi sp,sp,32
20: 8082 ret
main调用foo的指令如下:
auipc ra,0x0
jalr ra # 8 <main+0x8>
在链接前,不知道foo函数的最终地址,所以暂时使用0x0。链接完成后,确定符号的最终地址,链接器需要更具最终的地址来修正上述指令。
查看b.o的反汇编:
$ riscv64-unknown-linux-gnu-objdump -d -r b.o
b.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <foo>:
0: 1141 addi sp,sp,-16
2: e406 sd ra,8(sp)
4: e022 sd s0,0(sp)
6: 0800 addi s0,sp,16
8: 000007b7 lui a5,0x0
8: R_RISCV_HI20 shared
8: R_RISCV_RELAX *ABS*
c: 0007a783 lw a5,0(a5)
c: R_RISCV_LO12_I shared
c: R_RISCV_RELAX *ABS*
10: 0017979b slliw a5,a5,0x1
14: 2781 sext.w a5,a5
16: 853e mv a0,a5
18: 60a2 ld ra,8(sp)
1a: 6402 ld s0,0(sp)
1c: 0141 addi sp,sp,16
1e: 8082 ret
foo函数加载shared变量的指令如下:
lui a5,0x0
lw a5,0(a5)
同样的,链接时候不知道shared变量的具体位置,所以暂时使用0x0。
3.3 重定位详细计算
RISC-V支持多种不同的重定位类型,不同的重定位类型其指令修正方式是不同的,对于本例:
-
foo函数的重定位类型R_RISCV_CALL_PLT
-
全局变量
shared的重定位类型R_RISCV_HI20/R_RISCV_LO12_I
这里只以这个用例来简单说明函数以及全局变量的修正过程。
a.o重定位计算:
根据链接脚本,foo 函数的最终地址被确定为 0x10022,auipc 指令的地址是 0x10004。
- 目标地址 (S):
0x10022(foo的地址) - 当前位置 (P):
0x10008(auipc指令的地址) - 地址差 (S - P):
0x10022 - 0x10008 = 0x1A
RISC-V 使用 PC 相对寻址。auipc 和 jalr 组合起来,其跳转目标是 P + (imm20 << 12) + sext(imm12),需要将这个地址差 0x1E 拆分填入两条指令。
- 计算
auipc的立即数 (高20位):
为了正确进行四舍五入,计算高20位时需要加上一个偏移量0x800(即 2048)
imm20 = (0x1A + 0x800) >> 12 = 0x81A >> 12 = 0
所以,auipc指令的立即数被修正为0 - 计算
jalr的立即数 (低12位):
imm12 = 0x1A & 0xFFF = 0x1A
所以,jalr指令的立即数被修正为0x1A(十进制 26)
重定位后 (在 static_link.elf 中):
0x10004: 00000097 auipc ra, 0x0 # 高20位是 0
0x10008: 01e080e7 jalr ra, 26(ra) # 低12位是 0x1A (26)
b.o 中的重定位处理:
在 b.o 的 foo 函数中,访问全局变量 shared 同样需要两条指令 lui 和 lw 协同完成。
- 重定位前 (在
b.o中):
# 假设 foo 函数的起始地址是 0x10022
...
0x1002a: 000007b7 lui a5, 0x0 # 占位符,等待链接器填入高20位
0x1002e: 0007a783 lw a5, 0(a5) # 占位符,等待链接器填入低12位
- 链接器计算:
根据链接脚本,全局变量shared的最终地址被确定为0x100b0
- 目标地址 (S):
0x100b0(shared的地址)
RISC-V 访问全局数据时,lui 负责加载高20位,lw 负责加载低12位作为偏移
- 计算
lui的立即数 (高20位):
同样,为了四舍五入,计算高20位时需要加上0x800。
imm20 = (0x100b0 + 0x800) >> 12 = 0x108b0 >> 12 = 0x10
所以,lui指令的立即数被修正为0x10 - 计算
lw的立即数 (低12位):
imm12 = 0x100b0 & 0xFFF = 0x0b0
所以,lw指令的立即数被修正为0x0b0(十进制 176)
重定位后 (在 static_link.elf 中):
0x1002a: 000107b7 lui a5, 0x10 # 高20位是 0x10
0x1002e: 0b07a783 lw a5, 176(a5) # 低12位是 0x0b0 (176)
最终得到可执行文件:
$ riscv64-unknown-linux-gnu-nm static_link.elf
0000000000010022 T foo
0000000000010000 T main
00000000000100b0 D shared
以上便是整个静态链接的几个步骤。
参考:
- 《程序员的自我修养—链接、装载与库》
- RISC-V Binutils工具链04之ld工具的使用

浙公网安备 33010602011771号