链接器的核心工作就是符号表解析和重定位,链接命令文件则使得编程者可以给链接器提供必要的指导和辅助信息。多数时候,由于集成开发环境的存在,开发者无需了解链接命令文件的编写,使用默认配置即可。但若需要对计算机系统存储空间实行更精细化的管理,读懂链接命令文件并能稍作修改则显得很有必要。
编译器生成可重分配地址的代码块和数据块,这些块被叫做“段”。通过段名对代码块和数据块的标识,连接器就能在链接的时候根据链接规则(默认规则或链接命令文件制定)将代码块和数据块分配到指定的存储空间中。
图1 将段组合到可执行文件中
汇编器有5个伪指令支持标识汇编语言程序各个部分应归属的段:.text .data .sect .bss .usect。编程者也可以创建任一种段的子段,得以更精细地控制存储器区域。
初始化段:.text .data .sect创建初始化段。
.text:代码区间。
.data:已初始化的全局和静态变量。
.sect:创建类似于.text .data 的命名段,同时用于创建子段。
未初始化段:.bss .usect指令创建未初始化段。
.bss:未初始化的全局和静态变量,以及被初始化为0的全局和静态变量。
.usect:创建类似于.bss的命名段,同时用于创建子段。
含有原始数据的段,归类为初始化的,意味着目标文件含有该段的存储器实际内容映像;默认情况下,.bss段和.usect伪指令定义的段没有原始数据,它们在存储器映射图里占据空间,但没有实际内容。每次使用bss和usect伪指指令时,汇编器就在.bss或该命名段内预留增补的空间。在目标文件里,一个未初始化段有正常的段头,也可以含有定义在其内的符号,但没有存于段内的存储器映像。
命名段是用户创建的,可以像.text .data .bss段一样使用它们。
子段是较大段内一些比较小的段,子段使用户更细致地控制存储器空间,一个子段可以单独分配地址,也可以与同一基段的其它子段分配在一起。子段用基段名加冒号加子段名来标识,对子段命名的语法如下:
symbol .usect "section name:subsection name",size in bytes [,alignment [,bank offset]]
.sect "section name:subsection name"
-mo选项使得编译器把一个文件中的每一个函数放入它自己的子段中。这样,只有被应用程序调用的函数,才被连接到最后的可执行函数中,这可以导致整个代码的尺寸减小。但是,如果一个文件中几乎所有的函数都被引用,使用-mo编译器选项,也能导致整个代码尺寸的增加。
“加载时”和“运行时”是两个容易让人产生迷惑的概念,理解它们需要了解代码在硬件层面的详细被执行过程。
在掉电时,包含运行代码的可执行文件一般都存放于非易失的ROM或磁盘中,但由于这些存储器的访问速率受到限制,在实际上电运行时,还需要专门的一段加载代码(或称加载器),将需要的代码和数据段复制到主存中,然后通过跳转到程序的第一条指令或入口点,来运行该代码。这个将程序复制到内存,并开始运行的过程叫做加载。
加载地址确定了加载器把段的原始数据放置的位置,任何对这个段的引用涉及的是它的运行地址,运行时必须把这个段从加载地址复制到运行地址。这也就能理解为什么计算机术语上把诸如stdio、string等库文件称作运行时库(run-time lib),因为它们在运行时才被加载到和调用代码一起运行。
连接器命令文件是ASCII码文件,包括一个或多个下述信息:
输入文件。如目标文件、文档库、其它命令文件(如果一个命令文件调用另一个命令文件作为输入,这个语句必须是调用文件最后的语句,连接器不从被调用的命令文件返回)。
连接器选项。它能以与命令行同样的方式应用于命令文件。
MEMORY和SECTION连接器伪指令(只能在命令文件里使用这些伪指令,不能在命令行上使用它们)。
赋值语句。它定义全局符号并给它们赋值。
下列名字作为连接器伪指令的关键字被保留,在命令文件里不要使用它们作为符号或段名:
一个完整的简单链接命令文件示例如下:
a.obj b.obj c.obj /* Input filenames */
--output_file=prog.out /* Options */
--map_file=prog.map
MEMORY /* MEMORY directive */
{
FAST_MEM: origin = 0x0100 length = 0x0100
SLOW_MEM: origin = 0x7000 length = 0x1000
}
SECTIONS /* SECTIONS directive */
{
.text: > SLOW_MEM
.data: > SLOW_MEM
.bss: > FAST_MEM
}
3.1 MEMORY伪指令
MEMORY定义一个目标系统的存储器映像图。用户给存储器各部分命名,制定他们的起始地址和长度。它的通用语法如下:
MEMORY
{
name 1 [( attr )] : origin = expression , length = expression [, fill = constant]
..
name n [( attr )] : origin = expression , length = expression [, fill = constant]
}
其中name为一段存储区的名字;attr定义该存储区的属性,如可读、可写、可执行、可初始化;origin为该存储区的起始地址;length为存储区长度,以字节为单位;fill选项指定该存储区的空闲区域用什么constant来填充。一个使用例子如下:
MEMORY
{
FAST_MEM (RW) : o = 0x00000020, l = 0x00001000, f = 0xFFFFFFFF
}
由MEMORY伪指令定义的存储器是已配置的,没有用MEMORY伪指令显式地计入的存储器是未配置的。连接器不把程序任何一段放到未配置的存储器里。
如果不使用MEMORY伪指令,连接器则使用一个默认的基于处理器结构的存储器模型。这个模型假定系统内全部地址空间存在且可使用。
3.2 SECTIONS伪指令
SECTIONS告诉连接器怎样把输入段组合成输出段,以及把输出段放在存储器的什么位置。
SECTIONS
{
name : [property [, property] [, property] . . . ]
name : [property [, property] [, property] . . . ]
name : [property [, property] [, property] . . . ]
}
其中name为输出段名,SECTIONS伪指令的作用就是将输入段(基段或子段)重新组合到一个输出段(基段或子段),并指定该输出段的加载地址、运行地址、填充值等属性。
property则是可选的命令选项,这些命令选项有:
链接器给每个输出段在目标存储器内分配两个地址:加载时地址和运行时地址。一般情况下它们是同一个,可以认为每个段仅有单一的地址。如果加载和运行的地址是分离的,跟随关键字load后的所有参数,应用于加载定位;跟随关键字run后的所有参数,应用于运行定位。
未初始化段不加载,因此有意义的仅仅是运行地址。如果对未初始化段的加载地址和运行地址二者都指定,连接器发出警告并忽略加载地址。如果只指定一个地址,连接器把它作为运行地址对待,而不管称它是加载或是运行。
用户可以为输出段提供一个指定的起始地址,但这种地址绑定与边界对齐(alignment)和指定存储器(named memory)不兼容,如果使用了边界对齐(alignment)和指定存储器(named memory),将不能绑定段地址。如果试图这样做,连接器将发出错误信息。
输出段能以两种方法组成:
作为SECTIONS伪指令定义的结果;
把SECTIONS伪指令未定义的同名输入段组合到一个输出段。如果对子段没有显式地指定,子段将被组合到具有同一基段名的段内。
连接器允许在SECTIONS伪指令内任意嵌套GROUP和UNION语句。
3.3 SECTIONS伪指令内的UNION语句
UNION语句嵌套在SECTIONS指令内使用,它提供一种方法,把几个段定位到同一运行地址。UNION占据与它最大成员一样大的空间。UNION的成员保持为独立段,它们只是简单地作为一个单位定位在一起。
未初始化段不加载,不需要加载地址。
UNION: run = FAST_MEM
{
.bss:part1: { file1.obj(.bss) }
.bss:part2: { file2.obj(.bss) }
}
但如果初始化段是UNION的成员,它的加载定位必须分别指定,也即UNION共享地址只是对于运行地址而言,加载地址不能共享。
UNION run = FAST_MEM
{
.text:part1: load = SLOW_MEM, { file1.obj(.text) }
.text:part2: load = SLOW_MEM, { file2.obj(.text) }
}
3.4 SECTIONS伪指令内的GROUP 语句
GROUP语句嵌套在SECTIONS指令内使用,用于强制几个输出段连续定位。例如下面的语句,使用GROUP强制连接器将.data段和term_rec段相邻定位,其中.data定位到地址0x1000,term_rec紧随其后:
SECTIONS
{
.text /* Normal output section */
.bss /* Normal output section */
GROUP 0x00001000 : /* Specify a group of sections */
{
.data /* First section in the group */
term_rec /* Allocated immediately after .data */
}
}
3.5 原点“.”符号
一个用原点“.”标记的特殊符号,代表在地址分配期间的段程序计数器(SPC)的当前值,SPC保持跟踪段内当前地址。符号“.”指的是段的当前运行地址,而不是当前加载地址。
3.6 一个SECTIONS伪指令分配存储的例子
/**************************************************/
/* Sample command file with SECTIONS directive */
/**************************************************/
file1.obj file2.obj /* Input files */
--output_file=prog.out /* Options */
SECTIONS
{
.text: load = EXT_MEM, run = 0x00000800
.const: load = FAST_MEM
.bss: load = SLOW_MEM
.vectors: load = 0x00000000
{
t1.obj(.intvec1)
t2.obj(.intvec2)
endvec = .;
}
.data:alpha: align = 16
.data:beta: align = 16
}
链接器并不是一定需要连接器伪指令,如果没有使用它们,连接器将使用目标处理器默认的分配代码方案。
在链接过程中经常会出现由于存储区空间不足导致段分配失败的提示,同时连接器会给出未使用空间的大小和需要空间的大小。这一现象可表现出两种情况:一是未使用 空间>需要空间;二是需要空间>未使用 空间。
第二种情况其实很好理解,第一种情况是怎么回事呢?实际上,段的空间的分配是并不是我们想象中的连续的一个紧挨一个,由于数据对齐的需要以及内存页的适配,都会在内存中产生一些空隙(hole),使得实际所需要的内存空间超过了根据变量大小计算出来的理论值。这样做的目的是为了优化数据页(DP)寄存器的加载,达到减小代码尺寸和优化程序性能的目的。
那么,一旦出现存储区空间不足的提示,我们该如何重新调整段的分配来解决这个问题呢?于一个单一的段而言,有三个办法可以尝试:
1. 查看编译后生成的.map文件,其中显示了每一个存储区的空间使用情况,另寻找一个空间大小足够,且内存属性相似的存储区,将该段分配到该区;
2. 标注多个备选存储区。操作符“| ”用来为段指定多个存储器区域,如果输出段不能成功地分配到任一个所指定的存储器区域,连接器发出一个错误信息。
.text : > FLASHA | FLASHC | FLASHD
这个例子中连接器将首先尝试将.text段分配给FLASHA,如果不成功,则依次尝试FLASHC和FLASHD,直到分配成功,否则报错误提示。
3. 将段分割分配到多个存储区。操作符“>> ”标明输出段能被分裂装到指定的存储区域内,前提是几个内存区域的总长度要满足要求。
.text : >> FLASHA | FLASHC | FLASHD
这个例子中,如果.text段不能完整地分配到FLASHA,则连接器会将剩余的部分继续分配到FLASHC,甚至分配到FLASHD中。
【1】田黎育,何佩琨,朱梦宇. TMS320C6000系列DSP编程工具与指南[M].北京:清华大学出版社 2006.
【2】TMS320C6000 Assembly Language Tools v7.4--SPRU186W,2012.
【3】Beginner's Guide to Linkers.
【4】Linkers and Loaders,John Levine.
想进一步跟踪本博客动态,欢迎关注我的个人微信订阅号:信号君
郑重·专业·有料