本文主要介绍了链接器和链接脚本的基本内容。主要偏向于入门级以及常见容易混淆的知识点。
1. 链接器介绍
在现在软件工程中,程序一般都比较复杂,通常由多个源文件组成。在编译的过程中会对这些源文件进行汇编或者编译然后生成目标文件。这些目标文件一般都包含代码段、数据段、符号表等等内容。
链接器(linker) 是一个程序,这个程序主要的作用就是将目标文件(包括用到的标准库函数目标文件)的代码段、数据段以及符号表等内容搜集起来并按照 ELF或者EXE 等格式组合成一个可执行的二进制文件的过程。
总结一下:
编译器或者汇编器生成目标文件 链接器将目标文件组合成可执行文件
另外,在操作系统发展早期,还没有形成链接器的概念,操作系统的加载器(Loader,LD) 完成了类似工作,后面由于系统越来越复杂,也就引入了链接器(Linker),但是由于习惯问题,LD 还是链接器的代名词。
我们常见的链接文件也是以 ld 为后缀的。
1.1 GNU Linker
GUN Linker 采用 AT&T 链接脚本语言。 链接脚本会将经过编译或者汇编的二进制文件(.o文件) 生成可执行二进制文件。这个可执行二进制文件,有一个总的代码段和数据段。 本文是基于官方 v2.34 版本。
一般的,GNU工具链提供一个 ld的命令,常见的用法是
ld -o testld test_1.o test_2.o -lc
上述命令是把目标文件 test_1.o 和test_2.o 以及C语言库文件 libc.a 链接成名为testld 的可执行文件。
“-lc” 选项 表示将C语言可文件也链接到 testld 可执行文件中。 其次,因为上述命令没有使用 “-T” 选项来制定一个链接脚本,则链接器会默认使用内置的链接脚本。在实际项目中,一般都是需要自行编写一个链接文件来描述最终可执行文件的代码段/数据段等布局。
针对ARM64, 使用的 GNU的工具链是 aarch64-linux-gnu , 对应的链接器命令则是 aarch64-linux-gnu-ld。
举个例子
aarch64-linux-gnu-ld -T config/linker.ld -Map build/test.map -o build/test.elf build/test_1.o build/test_2.o
上述命令是把目标文件 test_1.o 和test_2.o 链接并生成 test.elf 可执行文件和 test.map 符号表文件。
常用的 ld 命令的选项如下:
选项 | 说明 |
---|---|
-T | 指定链接脚本 |
-Map | 输出一个符号表文件 |
-o | 输出最终可执行二进制文件 |
-b | 指定目标代码输入文件的格式 |
-e | 使用指定的符号作为程序的初始执行点 |
-l | 把指定的库文件添加到要链接的文件清单中 |
-L | 把指定的路径添加到搜索库的目标清单中 |
-S | 忽略来自输出文件的调试器符号信息 |
-s | 忽略来自输出文件的所有符号信息 |
-t | 在处理输入文件时显示它们的名称 |
-Ttext | 使用指定的地址作为代码段的起点 |
-Tdata | 使用指定的地址作为数据段的起点 |
-Tbss | 使用指定的地址作为未初始化的数据段的起点 |
-Bstatic | 只使用静态库 |
-Bdynamic | 只使用动态库 |
-defsym | 在输出文件中定义指定的全局符号 |
2. 链接脚本
链接器在链接过程中需要使用链接脚本。如果没有通过 “-T” 参数指定链接脚本时,链接器会使用内置的链接脚本。
链接脚本的作用: 将输入文件的段按照指定的地址空间布局合并到输出文件的段中。
section 中文翻译一般是“段”, 或者“节” 。 本文翻译为“段”。也就是输入段,输出段、代码段、数据段等等。
输入文件和输出文件指的是汇编或者编译之后的目标文件。它们都按照一定格式组成,比如ELF 格式。两者之间的区别只是输出文件具有可执行性。
这些目标文件都是由一系列的段组成。段是目标文件中具有相同特性的最小可处理信息单元。不同的段描述目标文件不同类型的信息以及特征。
输入文件的段称之为输入段(input section) , 输出文件的段称之为输出段(output section)。 输入段描述的是链接器应该如何将输入文件映射到内存布局中。输出段描述的是链接器应该如何在内存中布局最终的可执行文件。
输出段和输入段包括段的名字、大小、可加载(loadable)属性以及可分配属性(allocatable)等。 – 可加载属性用于在运行时加载这些段的内容到内存去。 – 可分配属性用于在内存中预留一个区域,并且不会加载这个区域的内容。
在链接脚本中,还有两个有关段的地址概念。 – 加载地址(load address) : 加载时段所在的地址 – 虚拟地址(virtual address) :运行时段所在的地址。也称为运行地址。
通常情况下,这两个地址是相同的。但是在有些嵌入式的IC 设计中,它们也有可能不相同。比如STM32 。例如, 一个代码段被加载到ROM 中,在程序启动时被复制到RAM 中。 在这种情形下,ROM 地址就是加载地址,RAM地址是虚拟地址。
本节主要介绍如何编写一个链接脚本。
2.1 一个简单的链接脚本
下面是一个简单的链接脚本。
SECTIONS
{. = 0x10000;.text :{ *(.text)}. = 0x8000000;.data = { *(.text)}.bss : {*(.bss)}
}
可执行程序都是由代码(.text)段、数据(.data)段、未初始化的数据段(.bss)等组成。
第1行:SECTION 是链接脚本语法中的最关键和最基础命令,它用来描述输出文件的内存布局。sections-command 的作用是将输入文件的段映射到输出文件的各个段 ,然后将输入段整合为输出段,最后将输出段放入程序地址空间和进程地址空间。
SECTION 命令格式如下:
SECTIONS
{sections-commandsections-command...
}
sections-command 有三种: – ENTRY 命令:用来设置程序的入口。 – 符号赋值语句:用来给符号赋值 – 输出段的描述语句。这一种用到的最多。
第3行:“.” 代表的是当前计数器(Location Counter, LC), 整个第三行代码的意思是将后面的代码(.text)段的链接地址设置为0x10000。
第4行: “*” 表示所有的输入文件(.o文件,即二进制文件),整行代码的意思是输出文件 的代码(.text)段由所有输入文件 的代码(.text)段组成。
第5行: 和第3行语法一样,意思是将后面的数据(.data)段的链接地址设置为 0x8000000。
第6行: 和第4行类似, 意思是输出文件 的数据段(.data)段由所有的输入文件 的数据(.data)段组成。
第7行: 和第4,6行类似,意思是输出文件 的未初始化的数据(.bss)段由所有的输入文件 的未初始化的数据(.bss)段组成。
2.2 设置程序入口处
程序执行的第一条指令称之为入口点(entry point)。
在链接脚本中,使用ENTRY 命令设置程序的入口点。例如,设置符号symbol 为程序的入口点。
ENTRY(symbol)]
另外,还有以下几种方式来设置入口点。链接器会依次尝试下列方法来设置入口点,直到成功为止。
- 使用 GCC 工具链的 LD 命令 并 增加 “-e” 选项参数指定入口点。
- 在链接脚本中通过 ENTRY 命令设置入口点。
- 通过特定符号(例如:“start” 符号) 设置入口点
- 使用代码段的起始地址
- 使用地址0
2.3 符号赋值与引用
在链接脚本中,符号可以像高级语言比如C语言一样进行赋值和操作,允许的操作包括赋值、加减乘除、左移、右移、与、或等。
symbol = expression ;
synbol += expression ;
symbol -= expression ;
symbol *= expression ;
symbol /= expression ;
symbol <<= expression ;
symbol >>= expression ;
symbol &= expression ;
symbol |= expression ;
高级语言(比如C语言)常常需要引用链接脚本定义的符号。在 C语言中定义全局变量 foo 并且赋值为100。
int foo = 100;
C 语言声明一个符号时,编译器在程序内存中保留足够的空间来保存符号的值。另外,编译器在程序的符号表中创建一个保存该符号地址的条目。即符号表包含保存符号值的内存块的地址。
因此,编译器会在符号表中存储 foo 这个符号,这个符号保存在某个内存地址里,这个内存地址用来存储初始值 100 。
当程序再一次访问 foo 变量时,如果设置 foo 为1, 程序就在符号表中查找符号 “foo” ,获取与该符号关联的内存地址,然后把1 写入该内存地址。
在链接脚本定义的符号仅仅是在符号表中创建了一个符号。并没有分配内存来存储这个符号。也就是说,它只有地址,但是没有存储内容。所以链接脚本中定义的符号只代表一个地址,而不能存储其内容。
比如在链接脚本中定义一个foo 符号并赋值。
foo = 0x100
链接器会在符号表中创建 一个名为 “foo” 的符号,0x100 表示的是内存地址的位置。而且地址0x100 没有存储任何特别的东西。 换句话说, "foo" 符号仅仅用来记录某个内存地址。
我们来看个例子。
start_of_ROM = .ROM;
end_of_ROM = .ROM + sizeof(.ROM);
start_of_FLASH = .FLASH;
上面的链接脚本中,ROM 和 FLASH 分别表示存储在ROM与闪存中的段。
在C语言中,我们可以通过如下代码片段把ROM的内容搬移到FLASH中。
第一种: 通过“&” 获取符号的地址
extern char start_of_ROM, end_of_ROM, start_of_FLASH;
memcpy(& start_of_FLASH, & start_of_ROM, & end_of_ROM - & start_of_ROM)
第二种:将符号理解为数组
extern char start_of_ROM[], end_of_ROM[], start_of_FLASH[];
memcpy(start_of_FLASH, start_of_ROM, end_of_ROM - start_of_ROM)
一个常用的编程技巧是在链接脚本里,为每个段都配置一些符号,以方便C语言访问每个段的起始地址和结束地址。
我们再来看个例子。
SECTIONS
{start_of_text = . ;.text: {*(.text)}end_of_text = . ;start_of_data = . ;.data: {*(.data)}end_of_data = . ;
}
在C语言中,使用以下代码可以很方便地访问这些段的起始地址和结束地址。
extern char start_of_text[];
extern char end_of_text[];
extern char start_of_data[];
extern char end_of_data[];
2.4 当前位置计数器
当前位置计数器(location counter) 使用特殊符号 “.” 表示。
SECTIONS
{.text :{*(.text)_etext = .;}_bdata = (. + 3 ) & ~ 3;.data:( *(.data) )
}
上述链接脚本中,第 6 行和第 8 行 使用了当前位置计数器。 – 第6行: _etext 设置为当前位置,即代码段结束的地方。 – 第8行: 设置_bdata 的起始地址为当前位置后下一个与4字节对齐的地方。
2.5 SECTIONS 命令
2.5.1 输出段
输出段的描述格式如下:
section [address] [(type)]:[AT(lma)][ALIGN(section_align)][constraint]{output-section-commandoutput-section-command...} [>region] [AT>lma_region] [:phdr :phdr ...] [=fillexp]
常用的部分内容含义: – section : 段的名字,例如代码(.data)段、数据(.data)段等 – address : 虚拟地址 – type: 输出段的属性 – lma: 加载地址 – ALIGN:对齐要求 – output-section-command: 描述输入段如何映射到输出段 – region: 特定的内存区域 – phdr: 特定的程序段(program segment)
一个输出段有两个地址 – 虚拟地址(Virtual Memory Address,VMA):运行时段所在的地址,即运行地址 – 加载地址(Load Memory Address,LMA): 加载时段所在的地址
有个地方需要注意的是,如果没有通过 “AT” 来指定LMA,那么 LMA=VMA,即加载地址等于运行地址。
一般嵌入式系统中,经常遇到加载地址和运行地址不一致的情况。比如image 存放在Flash中,运行时复制到RAM中。
2.5.2 输入段
输入段用来告诉链接器如何将输入文件映射到内存布局中。输入段一般包括输入文件以及对应的段。
使用通配符“*”来包含某些特定的段。比如代码(.text)段 :
*(.text)
通配符“ * ” 可以匹配任何文件名的代码段。
与之对应的,如果需要剔除某些指定的文件,可以使用 “EXCLUDE_FILE”。 比如下面的例子中,除了 crtend.o 和 otherfile.o 文件之外,其余剩余文件的.ctors段会加入输入段。
EXCULDE_FILE (*crtend.o *otherfile.o) *(.ctors)
下面两条语句是由区别的。
*(.text .rdata)
*(.text) *(.rdata)
第一条语句按照加入输入文件的顺序把相应的代码段和只读数据段加入;第二条语句先加入所有输入文件的代码段,再加入所有输入文件的只读数据段。
有个容易出错的地方,来看下面的例子
* (EXCLUDE_FILE) (*somefile.o) .text .rdata)
上面这条语句的意思是除了somefile.o 文件的代码段之外,把其他文件的代码段都加入输入段。另外所有文件的只读数据段都加入输入段。
与之类似的, 如果想要剔除 somefile.o 文件的代码段和只读数据段,可以这样实现
* (EXCLUDE_FILE) (*somefile.o) .text (EXCLUDE_FILE) (*somefile.o) .rdata)
或者
EXCLUDE_FILE (*somefile.o) *(.text .rdata)
如果需要指定文件名中的特定段,例如,把data.o 文件中的数据段可以加入输入段里,使用以下代码。
data.o (.data)
来看个具体例子:
SECTIONS {outputa 0x10000 :{all.ofoo.o (.input1)}outputb :{foo.o (.input2)foo1.o (.input1)}outputc :{*(.input1)*(.input2)}
}
这个链接脚本一共有3个输出段 — outputa 、outputb 和 outputc 。 – outputa 输出段的起始地址为0x10000。首先在这个起始文件里存储 all.o 文件中所有的段,然后存储 foo.o 文件的input1段。 – outputb 输出段的地址紧跟着outputa 段后面。outputb 输出段存储包括 foo.o的 input2 段,以及 foo1.o 文件的input1 段。 – outputc 输出段的地址紧跟着outputb后面。outputc输出段包括了所有文件的input1段和所有文件的 input2段。
2.6 典型例子
在嵌入式系统中,虚拟地址和加载地址不一致的情况很常见。一般性的情形是 image 存放在ROM中,运行时复制到RAM 中。ROM 中的地址也就是加载地址,而RAM中的地址就是运行地址,即虚拟地址。
SECTIONS{.text 0x1000 : { *(.text) _etext = . ; }.mdata 0x2000 :AT (ADDR (.text) + SIZEOF (.text) ){ _data = .; *(.data); _edata = . ; }.bss 0x3000 :{ _bstart = . ; *(.bss) *(COMMON) ; _bend = . ;}}
上面的例子中,首先链接文件会创建3个段。 – 代码.text 段:代码段, 虚拟地址和加载地址都是 0x1000 – .mdata 段:用户自定义的数据段,虚拟地址为 0x2000 ,但是加载地址通过 “AT” 符号指定为代码段的结束地址 – .data 和 .edata 都只是符号,用来记录 .mdata段的VMA地址。 – .bss 段:未初始化的数据段的虚拟地址是 0x3000 虚拟地址和加载地址都是 0x3000
程序初始化的时候,需要将 .mdata段的从ROM中的加载地址复制到RAM中的虚拟地址。
extern char _etext, _data, _edata, _bstart, _bend;
char *src = &_etext;
char *dst = &_data;while( dst < &_edata)*dst++ = *src++;for(dst = &_bstart; dst < &_bend; dst++)*dst = 0;
.mdata 段的加载地址在 _etext 起始的地址,数据段的虚拟地址在 _data 其实的地方。那么由此可以得到数据段的大小为 _edata – _data 。
2.6 常用的内建函数
1. SIZE SIZEOF(section) : 返回一个段的大小。
下面的例中 symbol_1 和symbol_2 都是用来返回.output段的大小。
SECTIONS{....output {.start = .;....end = .;}symbol_1 = .end -.start;symbol_2 = SIZEOF(.output);...
}
2. ABSOLUTE
ABSOLUTE(exp) 返回表达式(exp) 的绝对值。主要用于在段定义中给符号赋绝对值。
SECTIONS
{. = 0xA0000,.my_symbol :{my_symbol_1 = ABSOLUTE(0x100);my_symbol_2 = (0x100);}
}
在 .my_offset 的段中,符号 my_symbol_1 使用了 ABSOLUTE()内建函数, 然后将数值0x1000赋值给符号my_symbol_1, 符号my_symbol_2 没有使用内建函数,因此符号 my_symbol_2属于 .my_symbol 段里的符号,于是符号 my_symbol_2的地址为 0xA0000+ 0x100。
3. ADDR
ADDR(section) 返回段的虚拟地址。
SECTIONS{....output1 {start_of_output_1 = ABSOLUTE(.);...}.output:{symbol_1 = ADDR(.output);symbol_2 = start_of_output_1;}...
}
symbol_1 和 symbol_2 两个都获取到 .output1 的VMA地址。
4. ALIGN
ALIGN(align) 返回下一个与 align 字节对齐的地址,它是基于当前的位置来计算对齐地址的。
SECTIONS
{....data ALIGN(0x2000):{*(.data)variable = ALIGN(0x8000);}...
}
上述链接脚本的 .data 段 会设置在下一个与 0x200 字节对齐的地址上。 另外,定义一个 variable 变量,这个变量的地址是下一个与 0x8000 字节对齐的地址。
5. 其他
还有一些比较简单的内建函数: – LOADADDR(section): 返回段的虚拟地址。 – MAX(exp1,exp2): 返回两个表达式中的最大值。 – MIN(exp1,exp2): 返回两个表达式中的最小值。
3. 小练习
1. 分析下面linker.ld 链接脚本中每条语句的含义。
SECTIONS
{. = 0x80000,.text.boot :{*(.text.boot)}.text : {*(.text)}.rodata : {*(.rodata)}.data : {*(.data)}. = ALIGN(0x8);bss_begin = .;.bss :{*(.bss*)}bss_end = .;. = ALIGN(4096);init_pg_dir = .;+= 4096;
}
- 第1行: SECTION 是链接脚本语法中最关键和最基础的命令,它用来描述输出文件的内存布局。
- 第3行:指定了当前的加载入口地址是0x80000,即链接地址为0x80000
- “.” 表示location counter, 当前位置
- 第4行:将输入文件中的所有 .text.boot 段组合到输出文件的.text.boot 段
- 虚拟地址(VMA)也是0x80000
- .text.boot 段是第一个段
- 第5行:将输入文件中的所有 .text 段组合到到输出文件的 .text段
- 第6行:将输入文件中的所有 .rodata 段组合到输出文件的 . rodata段
- 第7行:将输入文件中的 .data段组合到输出文件的 .data段
- 第9行:将 .bss 段设置在下一个与 0x8字节对齐的地址上
- 第10行:符号bss_begin 记录当前位置计数
- 第11行: 将输入文件中的所有 .bss 段组合到输出文件的 .bss 段
- 第12行:符号 bss_end 记录当前位置计数器
- 第14行:符号 init_pg_dir 设置在下一个与 4096字节对齐的地址上
- 分配一个page的空间,用于存放页表
4. 总结
需要区分加载地址、虚拟地址(运行地址)、链接地址之间的区别。