一、目的
很多小伙伴并不是计算机专业出身,可能对编译链接原理和过程并不是很熟悉(其实我也不是计算机专业毕业的);很多时候我们并不一定要知道链接脚本的原理和使用,但是当我们需要执行一些复杂的链接过程时(例如将某个代码段放置在特定的位置、在MCU上想把代码放到RAM中加快代码执行速度),这个时候就需要知道一些链接脚本相关的知识点。
如果你是使用Keil编译器,那么你需要知道分散加载的概念以及分散加载文件如何编写,本篇主要介绍GCC编译时LD脚本文件如何编写。
首先让我们看一下GNU LD链接脚本到底是啥?(GNU LD链接脚本文件名称一般以.ld或者.lds结尾)
/*
* linker script for STM32H750XBHx with GNU ld
*/
/* Program Entry, set to mark it as "used" and avoid gc */
MEMORY
{
ROM (rx) : ORIGIN =0x08000000,LENGTH =128k
RAM (rw) : ORIGIN =0x24000000,LENGTH =512k
}
ENTRY(Reset_Handler)
_system_stack_size = 0x200;
SECTIONS
{
.text :
{
. = ALIGN(4);
_stext = .;
KEEP(*(.isr_vector)) /* Startup code */
. = ALIGN(4);
*(.text) /* remaining code */
*(.text.*) /* remaining code */
*(.rodata) /* read-only data (constants) */
*(.rodata*)
*(.glue_7)
*(.glue_7t)
*(.gnu.linkonce.t*)
/* section information for finsh shell */
. = ALIGN(4);
__fsymtab_start = .;
KEEP(*(FSymTab))
__fsymtab_end = .;
. = ALIGN(4);
__vsymtab_start = .;
KEEP(*(VSymTab))
__vsymtab_end = .;
/* section information for utest */
. = ALIGN(4);
__rt_utest_tc_tab_start = .;
KEEP(*(UtestTcTab))
__rt_utest_tc_tab_end = .;
/* section information for at server */
. = ALIGN(4);
__rtatcmdtab_start = .;
KEEP(*(RtAtCmdTab))
__rtatcmdtab_end = .;
. = ALIGN(4);
/* section information for initial. */
. = ALIGN(4);
__rt_init_start = .;
KEEP(*(SORT(.rti_fn*)))
__rt_init_end = .;
. = ALIGN(4);
PROVIDE(__ctors_start__ = .);
KEEP (*(SORT(.init_array.*)))
KEEP (*(.init_array))
PROVIDE(__ctors_end__ = .);
. = ALIGN(4);
_etext = .;
} > ROM = 0
/* .ARM.exidx is sorted, so has to go in its own output section. */
__exidx_start = .;
.ARM.exidx :
{
*(.ARM.exidx* .gnu.linkonce.armexidx.*)
/* This is used by the startup in order to initialize the .data secion */
_sidata = .;
} > ROM
__exidx_end = .;
/* .data section which is used for initialized data */
.data : AT (_sidata)
{
. = ALIGN(4);
/* This is used by the startup in order to initialize the .data secion */
_sdata = . ;
*(.data)
*(.data.*)
*(.gnu.linkonce.d*)
PROVIDE(__dtors_start__ = .);
KEEP(*(SORT(.dtors.*)))
KEEP(*(.dtors))
PROVIDE(__dtors_end__ = .);
. = ALIGN(4);
/* This is used by the startup in order to initialize the .data secion */
_edata = . ;
} >RAM
.stack :
{
. = ALIGN(4);
_sstack = .;
. = . + _system_stack_size;
. = ALIGN(4);
_estack = .;
} >RAM
__bss_start = .;
.bss :
{
. = ALIGN(4);
/* This is used by the startup in order to initialize the .bss secion */
_sbss = .;
*(.bss)
*(.bss.*)
*(COMMON)
. = ALIGN(4);
/* This is used by the startup in order to initialize the .bss secion */
_ebss = . ;
*(.bss.init)
} > RAM
__bss_end = .;
_end = .;
/* Stabs debugging sections. */
.stab 0 : { *(.stab) }
.stabstr 0 : { *(.stabstr) }
.stab.excl 0 : { *(.stab.excl) }
.stab.exclstr 0 : { *(.stab.exclstr) }
.stab.index 0 : { *(.stab.index) }
.stab.indexstr 0 : { *(.stab.indexstr) }
.comment 0 : { *(.comment) }
/* DWARF debug sections.
* Symbols in the DWARF debugging sections are relative to the beginning
* of the section so we begin them at 0. */
/* DWARF 1 */
.debug 0 : { *(.debug) }
.line 0 : { *(.line) }
/* GNU DWARF 1 extensions */
.debug_srcinfo 0 : { *(.debug_srcinfo) }
.debug_sfnames 0 : { *(.debug_sfnames) }
/* DWARF 1.1 and DWARF 2 */
.debug_aranges 0 : { *(.debug_aranges) }
.debug_pubnames 0 : { *(.debug_pubnames) }
/* DWARF 2 */
.debug_info 0 : { *(.debug_info .gnu.linkonce.wi.*) }
.debug_abbrev 0 : { *(.debug_abbrev) }
.debug_line 0 : { *(.debug_line) }
.debug_frame 0 : { *(.debug_frame) }
.debug_str 0 : { *(.debug_str) }
.debug_loc 0 : { *(.debug_loc) }
.debug_macinfo 0 : { *(.debug_macinfo) }
/* SGI/MIPS DWARF 2 extensions */
.debug_weaknames 0 : { *(.debug_weaknames) }
.debug_funcnames 0 : { *(.debug_funcnames) }
.debug_typenames 0 : { *(.debug_typenames) }
.debug_varnames 0 : { *(.debug_varnames) }
}
上图是RT-Studio里面使用的一段链接脚本(针对STM32H750XB Cortex-M7系列的单片机),STM32CubeIDE使用的链接脚本跟这个格式类似,后面会详细说明其组成。
下面就让我们从头开始讲解LD脚本的语法以及使用。
二、介绍
按照惯例,官方参考资料如下:
英文比较好的小伙伴就直接去看这个链接,本文接下来的所有讲解都是基于这个文档。
基本概念
在介绍链接脚本之前,我们需要回顾一下代码编译的整个过程
代码在编译成目标文件后,可以直接链接成可执行文件a.out或者打包成静态库或者链接成动态库。
每一次链接都需要链接脚本的参与,链接脚本是用链接器命令语言编写的(linker command language)。链接脚本的主要目的是描述输入文件中的段(section)应该如何映射到输出文件中的段(section),并且控制输出文件的内存布局(地址分配)。有些时候链接器脚本还可以使用链接器命令(linker command)指示链接器执行许多其他操作(例如设置程序入口、定义符号等)。
链接器总是使用一个链接脚本,如果你没有指定,那么会使用编译在链接器内部的默认链接脚本。通过命令`ld --verbose`可以查看默认的链接器脚本,某些命令行选项例如`-r`或者`-N`会影响默认的链接脚本。通过`-T`选项可以指定自己的链接脚本,这个时候就会替换默认的链接脚本。如果没有使用`-T`选项,而是直接将链接脚本文件作为命令行输入参数(这种方式也叫做隐式链接脚本方式,隐式方式不会替换默认的链接脚本),那么这种情况下链接脚本里面只能包含`INPUT`、`GROUP`、`VERSION`这些命令。
在命令行直接输入链接脚本(隐式方式)
ld foo.ld obj1.o obj2.o
通过`-T`命令行选项输入链接脚本 (显式方式)
ld -T foo.ld obj1.o obj2.o
链接器打开命令行指定的输入文件时,如果此文件不是目标文件或者存档文件(静态库),则会认为其是链接脚本文件,此时如果不能解析其文件格式,链接器就会报错。
链接脚本语言
链接器将输入文件组合成一个单一的输出文件,输出文件和输入文件一般都是有特定的数据格式(叫做目标文件格式),每个文件被叫做目标文件,输出文件也可以被叫做可执行文件。每个目标文件都有一些段(section)。输入文件中的段我们叫做input section,同理输出文件中的段我们叫做output section。
如何查看目标文件中的段名?
objdump -t foo.o
目标文件中的每个段都有一个名字和大小,大部分段也有一个与之关联的数据块,叫做段内容(section content)。
段可以被标记为可加载的(loadable),说明其内容在输出文件(程序)运行时需要加载到存储器中(在STM32类型的MCU中就是说需要将段内容放到FLASH中);
没有内容的段是可以被标记为分配的(allocatable),这意味着应该在存储器中(RAM)留出一个区域,但是不加载任何内容在里面(某些情况下还需要清零)。
段如果既不是可加载的又不是可分配的,一般是包含一些调试信息。
每个可加载或者可分配的输出段都包含两个地址,一个是VMA(virtual memory address),输出文件(程序)运行时的段的地址;一个是LMA(load memory address),输出文件中的段被加载到的地址;大部分情况下这两个地址是相同的。
所以一个image就会有两个视图,一个叫做加载视图,一个叫做执行视图。后面再根据具体实例给大家讲解。
一个典型的VMA和LMA不同的情形
在嵌入式平台中,代码一般是放在Flash中,如果是XIP执行方式,此时VMA和LMA相同;但是如果代码在RAM中执行,那么VMA就是代码在RAM中的地址,LMA就是代码在Flash中的地址。(程序运行的时候会从Flash中搬移到RAM中,这种方式主要是为了加快代码执行速度)
RW段VMA和LMA一定不同,因为RW段在程序执行时是要搬运到RAM中。RW段一般存放初始化的全局变量,所以必须在程序执行时拷贝到RAM中。
在MCU上运行程序只要记住ROM或者Flash的地址叫做LMA,RAM地址是VMA。
每个目标文件中也有一些符号,叫做符号表。符号可能是定义的或者未定义的,每个符号都有名字,每个定义的符号都有一个地址。如果我们编译一个C/C++程序,那么目标文件中就会有每一个定义的函数、全局变量或者静态变量的符号;输入文件中引用的未定义的函数或全局变量都将成为一个未定义的符号。
如何查看目标文件中的符号?
nm foo.o
或者
objdump -t foo.o
链接脚本格式
链接脚本文件是文本文件,由一系列命令(command)组成,每个命令要么是一个关键字紧跟着参数,要么是一个符号赋值操作,通过分号分隔不同的命令,空白字符被忽略;可以直接输入诸如文件名称或格式名称之类的字符串,如果文件名中有逗号,那么这个文件名就必须用双引号包围(因为逗号用于分隔文件名)。文件名中禁止有双引号。
链接脚本文件中使用C语言的注释方式,即/**/;注释在语法上会被看做空白字符。
简单的链接脚本示例
SECTIONS
{
. = 0x10000;
.text : { *(.text) }
. = 0x8000000;
.data : { *(.data) }
.bss : { *(.bss) }
}
大部分情况下链接脚本文件都是很简单的,最简单的链接脚本文件中只需要包含一个SECTIONS命令(其后的花括号是必须的),用于描述输出文件的内存布局(地址分配)。
正如上面的示例我们假设程序里面只有代码、已经初始化的数据以及未初始化的数据,它们分别在.text、.data、.bss段中;同时我们也假设输出文件中也仅有这些段名。
花括号内部可以包含符号赋值和输出段描述。
上面的链接脚本指示链接器将所有输入文件的代码段放到输出文件的代码段中,并且输出文件的代码段首地址是0x10000;
所有的输入文件中的数据段放到输出文件的数据段段中,其首地址为0x80000000;
所有输入文件的bss段放到输出文件的bss段,其地址紧跟data段之后。
语句说明
. = 0x10000;
注意示例中的点,其代表location counter,我们可以对其赋值,也可以对其取值,上面的示例中我们是对其赋值。
赋值语句或者取值语句必须以;结尾。
如果我们没有使用其他方式指定输出段的地址,那么输出段的地址就是location counter的值,默认值为0。location counter的值会自动增加输出段的大小。
如果上面的例子中没有“. = 0x8000000”这一行,那么.data输出段的地址就为0x10000加上.text输出段的大小。
那么还有哪些其他方式指定输出段的地址呢?这个我们后面会进一步讲解。
.text : { *(.text) }
这一行的中开头的.text前后都有一个空格(必须),其中.text是输出段的名称,具体有哪些名称要看编译的平台对输出文件的要求;冒号是必须的,花括号中就是输入段的描述,其中*是通配符,代表匹配所有字符,即匹配所有的输入文件,(.text)指输入文件中的.text段。
.bss : { *(.bss) }
所有输入文件的bss段放到输出文件的bss段,其地址为data段之后。也就是说bss段开始位置是location counter加上.data段的大小就是.bss段的开始位置。
以上就是链接脚本的基本内容。
设置程序入口函数
ENTRY ( symbol )
程序中执行的第一条指令叫做entry point(程序入口点),我们可以通过ENTRY命令来设置,也可以通过下面的方式(优先级由高到低)
- ld命令行选项“-e”指定的
- 链接脚本中ENTRY(symbol)指定的
- start符号值,如果定义了此符号
- .text段中的第一个字节地址,如果存在此输出段
- 地址0
处理文件的命令
一些链接器脚本命令处理文件
INCLUDE命令
INCLUDE filename
在当前脚本文件中调用此命令的位置包含其他脚本文件,在当前目录和在命令行选项-L指定的路径下搜索,INCLUDE命令最多可以嵌套10层
INPUT命令
INPUT (file , file , ...)
INPUT (file file ...)
指示链接器包含指定的这些文件,就像在命令行中输入这些文件名一样。
例如你总是要包含“subr.o”这个目标文件,但是不想每次敲LD命令时都敲写一遍,就可以在链接脚本文件中添加“INPUT (subr.o)”命令;设置你可以将所有的输入文件通过INPUT命令包含进来。文件的搜索路径首先是搜索当前文件夹;如果没有找到,链接器将会从“-L”选项指定的路径中搜索。如果使用”INPUT (-l file)“命令,ld会将file转换成lib file .a,也就是指定库名称。
GROUP命令
GROUP (file file ...)
类似INPUT,不同的是此处的file是静态库的文件名。
OUTPUT命令
OUTPUT (filename)
指定输出文件的名字,效果等同于命令行选项"-o filename",如果两种方式都使用了,命令行选项优先级更高。
SEARCH_DIR命令
SEARCH_DIR (path)
增加搜索静态库的路径;类似命令行选项"-L path";如果同时使用,链接器会搜索这些路径,但是命令行选项指定的路径优先搜索。
STARTUP命令
STARTUP ( filename )
类似INPUT命令,区别在于此处指定filename是第一个被链接的文件,就像ld命令行中指定的第一个文件一样。
处理目标文件格式的命令
OUTPUT_FORMAT命令
OUTPUT_FORMAT (bfdname)
OUTPUT_FORMAT(default, big, little)
指示输出文件是否哪种文件格式;
OUTPUT_FORMAT(bfdname)类似于在命令行输入`-oformat bfdname`
OUTPUT_FORMAT(default, big, little)方式配合命令行选项`-EB`、`-EL`使用,如果指定了`-EB`则输出文件格式为big参数值;如果指定了`-EL`则输出文件格式为little参数值;如果都没有指定则使用default参数值
TARGET命令
TARGET(bfdname)
指示链接器使用bfdname格式读取输入文件,影响后续的INPUT/GROUP命令;类似于命令行选项`-b bfdname`;如果使用了TARGET命令没有使用OUTPUT_FORMAT命令,那么最后一个TARGET命令指定的格式也作为输出文件的格式。
OUTPUT_ARCH命令
OUTPUT_ARCH( bfdarch )
指定一个特定的输出机器架构
符号赋值
在链接脚本中可以进行符号赋值(使用和C语言一样的语法),这同时定义了一个全局符号。
symbol = expression ;
symbol += expression ;
symbol -= expression ;
symbol *= expression ;
symbol /= expression ;
symbol <<= expression ;
symbol >>= expression ;
symbol &= expression ;
symbol |= expression ;
第一条定义了一个符号并且赋值,其他的表达式则必须先有符号才能操作。
特殊的符号名.代表location counter。一般只用在SECTIONS命令中。
表达式后面的分号是必须的。
你可以把符号赋值作为命令本身,或者作为' SECTIONS '命令中的语句,或者作为' SECTIONS '命令中输出段描述的一部分
符号赋值可以使用的位置举例
floating_point = 0;
SECTIONS
{
.text :
{
*(.text)
_etext = .;
}
_bdata = (. + 3) & ~ 4;
.data : { *(.data) }
}
上面的例子中,floating_point符号被定义为0;_etext符号被定义为最后一个' .text '输入段之后的地址;符号' _bdata '将被定义为' .text '输出段后向上对齐的4字节边界的地址。
PROVIDE命令(重要)
某些情况下我们希望链接脚本定义一个只是在目标文件内被引用,但没有在任何目标文件内被定义的符号。
SECTIONS
{
.text :
{
*(.text)
_etext = .;
PROVIDE(etext = .);
}
}
上面的示例中我们定义了_etext符号,如果程序(输入文件)中也定义了此符号,则链接器会报错(重复定义);
通过PROVIDE命令我们定义了etext符号,如果 程序中也定义了此符号,那么链接器使用程序中的定义;如果程序中没有定义但是引用了etext,那么就会使用链接脚本中PROVIDE命令定义的这个符号。
PROVIDE_HIDDEN
类似PROVIDE,区别在于不会被导出,所以其它源文件无法引用这个符号
SORT关键字
SORT(.text*))
对满足.text段名的输入段按照字母升序进行排序,举例如下
.init_array :
{
PROVIDE_HIDDEN (__init_array_start = .);
KEEP (*(SORT(.init_array.*)))
KEEP (*(.init_array*))
PROVIDE_HIDDEN (__init_array_end = .);
} >FLASH
内建函数
DEFINED函数
DEFINED( symbol )
如果‘symbol’在链接器符号表中并且被定义,则返回真;否则返回假。
一般使用此函数为符号提供默认值,例如
SECTIONS{ ...
.text : {
begin = DEFINED(begin) ? begin : . ;
...
}
...
}
上图begin符号如果没有定义,则使用location counter的值。
LOADADDR函数
LOADADDR( section )
返回“section”的LMA地址
SIZEOF函数
SIZEOF( section )
返回已经分配的“section”的大小,单位字节;如果执行这条命令时,指定的“section”还未分配,则链接器报错。
MIN/MAX函数
MAX( exp1 , exp2 )
MIN( exp1 , exp2 )
好,本篇就先介绍这么多,关于SECTION命令和MEMORY命令的说明见下篇。