链接器
链接是指将源文件通过汇编生成的目标文件(包括标准库函数的目标文件)按照某一种格式(eg:ELF)组合成一个可执行二进制文件的过程。在操作系统发展的早期并没有链接器的概念,操作系统的加载器(loader LD)做了所有的工作,后来操作系统越来越复杂,才出现了链接器。
链接器采用AT&T链接脚本语言,在操作系统实现中通常需要编写一个链接脚本来描述最终可执行文件的代码段/数据段等布局。
ld命令的常用选项如下:
- -T 指定链接脚本
- -Map 输出一个符号表文件
- -o 输出最终可执行二进制文件
- -b 制定目标代码输入文件的格式
- -e 使用指定的符号作为程序的初始执行点
- -I 把指定库文件添加到要链接的文件清单中
- -L 把指定路径添加到搜索库的目录清单中
链接脚本
基本概念
输入文件和输出文件指的是汇编或编译后的目标文件,它们按照一定的格式组成,只不过输出文件具有可执行属性。这些目标文件都由一系列的段组成。段时目标文件中具有相同特征的最小可处理信息单元,不同的段用来描述目标文件中不同类型的信息以及特征。
输出段和输入段包括段的名字、大小、可加载属性以及可分配属性等。可加载属性用于在运行时加载这些段的内容到内存中。可分配属性用于在内存中预留一个区域,并且不会加载这个区域的内容。
链接脚本中还有两个关于段的地址,分别是加载地址和虚拟地址。加载地址是加载时段所在的地址,虚拟地址是运行时段所在的地址,也称为运行地址。
设置入口点
程序执行的第一条指令称为入口点。在链接脚本中,使用ENTRY命令设置程序的入口点。
ENTRY (symbol)
除此之外还有别的方式设置入口点,gcc会依次检索,直到成功为止:
- 使用gcc工具链的LD命令和-e选项指定入口
- 在链接脚本中通过ENTRY命令设置入口点
- 通过特定符号(例如start)设置入口点
- 使用代码段的起始地址
- 使用0地址
符号赋值与引用
在链接脚本中,符号可以像c语言一样进行赋值和操作,允许的操作包括赋值、加法、减法、乘法、除法、左移、右移、与或等。
符号仅仅代表一个地址,与高级程序中变量的概念是不同的,变量代表某地址上所存储的值。
当前位置计数器
特殊符号“.”,它表示当前位置计数器。将其赋值给符号,那么该符号就表示当前的地址。
SECTIONS命令
SECTIONS命令告诉链接器如何把输入段映射到输出段,以及如何在内存中存放这些输出段。
section [address] [(type)] :
[AT(lma)]
[ALIGN(section_align)]
[constraint]
{
output-section-command
output-section-command
....
}[>region] [AT>lma_region] [:phdr :phdr ...] [=fillexp]
- section:段的名字,例如.text .data等
- address:虚拟地址
- type:输出段的属性
- lma:加载地址
- ALIGN:对齐要求
- output- section-command:描述输入段如何映射到输出段
- region:特定的内存区域
- phdr:特定的程序段
如果没有通过AT指定lma,那么lma(load memory address)=vma(virtual memory address),即加载地址等于虚拟地址。但在嵌入式系统中,存在加载地址不等于虚拟地址的情况。例如将镜像文件加载到开发板的闪存中(由lma指定),而bootloader将闪存中的镜像文件赋值到sdram中(由vma指定)
内置函数
-
ABSOLUTE(exp)
SECTIONS { . = 0xb0000, .my_offset : { myoffset1 = ABSOLUTE(0x100); myoffset2 = (0x100); } }
myoffset1 = 0x100
myoffset2 = 0xb0000 + 0x100
-
ADDR(section) 返回段的虚拟地址
-
ALIGN(align) 返回下一个与align对齐的地址,是基于当前位置来计算的
-
SIZEOF(section) 返回一个段的大小
-
PROVIDE 从链接脚本中导出一个符号,当且仅当这个符号在其它地方没有定义并且没有链接时才会使用
MEMORY关键字
MEMORY关键字用于描述一个MCU ROM和RAM的内存地址分布(Memory Map),MEMORY中所做的内存描述主要用于SECTIONS中LMA和VMA的定义。
加载重定位
- 加载地址:存储代码的物理地址,称为LMA
- 运行地址:程序运行时的地址,称为VMA
- 链接地址:在编译、链接时指定的地址,编程人员设想将来程序要运行的地址。反汇编dump查看的就是链接地址。
链接地址和运行地址可以相同,也可以不同。假设芯片内部sram的起始地址是0x0,ddr的起始地址是0x80000000。SBI固件运行在M模式,起始地址为0x80000000。uboot和Linux内核运行在S模式,起始地址为0x80200000。正常的芯片启动流程如下:
- 芯片上电,运行boot rom的程序。
- boot rom程序会初始化nor flash等外部存储介质,把SBI固件加载到DDR内存中,并跳转到DDR内存中。
- SBI固件切换到S模式,并且运行S模式下的uboot
- uboot初始化硬件和启动环境,并跳转到Linux内核运行
在第二步中由于SBI固件的镜像太大了,sram放不下,所以需要把镜像放在DDR内存中,通常SBI固件在编译的时候就把链接地址设置在了DDR内存中。而bootrom在复位上电阶段只是把nor flash前4KB的代码加载到了sram中给,CPU从0x0取指,运行前4KB的代码。那么此时运行地址在0x0,而链接地址在0x80000000,运行地址与链接地址不相同。在这种情况下,代码还能正常运行的原因在于两个重要的概念:位置无关的代码和位置有关的代码。
-
位置无关的代码:顾名思义,是指无论运行地址和链接地址是否相等,该代码指令都能正常运行。在汇编中,如J、JAL、MV等指令都属于位置无关的代码,它们都是基于PC进行相对寻址的
-
位置有关的代码:指令的执行时与内存地址有关的。在RISC-V汇编中,通过修改ra寄存器的值实现相对跳转
li a1, PAGE_OFFSET add ra, ra, a1
因此,通过修改返回地址为链接地址,当函数返回时,就会跳到指定的链接地址处。这个过程叫做加载重定位。
在重定位之前,程序只能执行一些位置无关的代码
链接重定位
在编译阶段,编译器是无法确定每个符号的最终链接地址的,因此编译阶段生成的可重定位目标文件中的所有符号都暂时设置为0x0。链接器在链接最终的可执行二进制文件时,才会具有全局内存地址分布,这些符号的最终地址由链接器来分配和确定,这个过程叫做链接重定位。
链接器完成地址和空间分配之后,就确定了符号的最终地址。链接器需要根据符号的最终地址对指令进行修正。在ELF文件规范中定义了一个重定位表relocation table。,它存储在重定位段中。重定位表指明了需要重定位的符号、重定位的类型以及需要重定位指令在段中的相对地址。
在链接阶段有一种优化技术——链接器松弛优化,它旨在减少不必要的指令。通常精简指令处理器体系结构需要两条指令来实现对一个符号地址的访问。一条指令处理符号地址高位部分,一条指令处理符号地址低位部分。但是在链接阶段,可以使用一条指令完成上述操作,这就是链接器松弛优化的作用,它主要涉及两个方面:
- 函数跳转优化
- 符号地址访问优化
函数跳转优化
在RISC-V中通常使用call指令实现函数跳转。call是一条伪指令,由两条指令组成,可以实现长跳转模式:
auipc ra, 0
jalr ra, ra, 0
call指令跳转范围是32位有符号数的地址区间,即当前pc前后2GB的区间。而jal短跳转指令,只能在21位有符号数的地址区间内跳转,即当前pc前后1MB的区间。
在编译阶段,无法确定需要跳转的函数的最终地址,因此编译器会默认将call指令解析成auipc和jalr的指令组合。而在链接阶段,链接器确定了函数的地址,从而可以根据偏移量确定是否选择使用短跳转指令,从而达到优化的效果。
符号地址优化访问
RISC-V在访问32位pc相对地址或符号(全局变量)时,常常会使用两条指令的组合:
auipc a0, %pcrel_hi(sym)
addi a0, a0, %pcrel_lo(sym)
RISC-V指令集提供了另一种非常巧妙的优化手段,即使用全局指针(global pointer GP)寄存器,它指向数据段(.sdata)中一个地址。如果全局变量存储在这个GP寄存器的值为基地址前后2KB的范围内,那么链接器就能够优化对全局变量的访问。
addi rd, gp, offset