GNU 链接脚本0 - 链接脚本基本介绍

关于程序的静态链接的介绍在之前的文章:程序的静态链接中已经相应的介绍,在该文章中,从理论出发,描述了程序为什么需要链接,以及链接的方式。

理论知识是必须的,也是基础,但是只有理论知识是完全不够的,我们需要深入到细节,看看链接过程到底是如何进行控制的,这就涉及到我们这章即将要讨论到的链接脚本。

链接器

通常在编译程序的时候并没有太过于关注编译的流程,从 C 语言程序生成最后的可执行文件通常就是一条指令的事,实际上在编译的四个步骤中,并不一直是 "gcc" 单个程序在工作,gcc 在早期叫做 GNU C Compiler,而在后来计算机的发展中,GCC逐渐兼容了C++,java等语言,发展为扩展版的GCC,全称为 GNU compiler collection,事实上它是指一套编译处理工具,而不再是单纯的C编译器,像g++,其实也是属于GCC工具中的一种。

在编译一个程序的时候可以添加上 -v 参数,在 gnu 的大多数工具中,-v 通常意思为 --verbose,即输出详细信息。从而,在输出的编译详细信息中,我们可以看到在编译 C 代码时,分别调用了 CPP、CC1、AS、COLLECT2 这四类编译工具,分别对应 预编译、编译、汇编、链接 这四个过程。

注:在编译的时候尤其是在编写 Makefile 脚本的时候,需要注意 CPP 是预编译器,而不是 C++ 的意思,C++ 通常用 CXX 表示,这里有很多人会搞混。

COLLECT2 对应链接过程,但它并不是链接器,它在链接器 ld 上做了一层封装,在链接之前对文件进行一些特殊的处理,最后再调用链接器 ld 进行工作。

链接脚本说明

链接器和编译器一样历史悠久,它并不仅仅针对于 linux,也不针对特定的平台,任何平台都有链接器的实现,所以在本系列链接器的文章中,我们着重介绍基于 linux 平台的 arm 链接器,对于某些平台比如没有MMU的,内存布局方式和 Linux 下的 arm 平台差别非常大,就不涉及太多了.

链接脚本的使用

程序的链接是一个单独的过程,尽管通常没有必要,但我们确实可以单独使用它:

ld  lib.o main.o -o main

该命令将两个目标文件 lib.o 和 main.o 链接成最终的可执行文件。

在之前的文章中,我们了解到链接的过程分为三个部分:

  • 空间和地址分配
  • 符号解析
  • 重定位

不知道你有没有过疑惑,链接器是如何执行这三个步骤的?

尤其是对于空间和地址分配而言,可执行文件将会被加载到内存的何处并运行?

每个单独的目标文件都存在那么多的段,这些段具体是如何被组织起来的?

这些控制行为是由编译器自行决定还是可以人为干预?

实际上,除了 lib.o 和 main.o 这两个显式的输入文件,还有一个隐式的输入文件,这个输入文件控制着程序的整个链接过程,即链接脚本,后缀为 lds。

通常情况下,我们都察觉不到链接脚本的存在,这是因为通常我们在编译程序时都使用系统默认提供的链接脚本,如果不是高度自定义化的应用,默认的链接脚本完全可以胜任链接的工作。

如果要查看系统的链接脚本,可以使用下面的指令将链接脚本的内容输出到终端:

ld --verbose

同时,可以将其重定位到文件再进行分析,但是一般而言,系统默认的链接脚本都是比较复杂的,我们将会在后续的文章中进一步分析。

链接参数

链接脚本针对的主要是内存空间和地址分配部分,对于链接过程其它的部分,是链接器自动处理的,毕竟符号解析和重定位通常不会涉及到自定义的操作,同时,可以通过传入命令行参数来控制链接过程,使用 ld --help 可以查看所有的链接器参数,下面我们就来介绍几个比较常用的链接参数:

  • -T:-T 参数表示指定链接脚本,用户可以通过 ld -T file 来指定使用自己的链接脚本,而不使用系统默认的,在一些特殊的场景中适用。
  • @file: 从文件中读取命令行参数,而不是手动指定,通常在脚本编程时使用这种做法。
  • -e entry、--entry=entry:这两个命令是同等效果,显示地指定程序开始的位置,通常情况下,需要指定程序内部的符号,如果给定的参数不是一个符号,链接器会尝试将参数解析成数字,表示从指定的地址开始执行程序。
  • -EB、-EL:指定大小端,这会覆盖掉系统默认的大小端设置。
  • -L、--library-path=searchdir:指定搜索的目录
  • -l :链接指定的库,库名通常是 libname.a 或者 libname.so,使用该参数时去掉库的前后缀,即 -lname
  • -o output、--output=output:指定输出文件名
  • -s、--strip-all:丢弃可执行文件中的符号,以减小尺寸。
  • -static:不使用动态库,静态地链接
  • -nostdlib:默认情况下链接标准库,该参数显示地指明不链接标准库。
  • -shared:创建一个动态库

链接脚本概览

链接脚本主要的工作就是告诉链接器如何将各个目标文件、库中的段进行组织,生成输出文件,该输出文件可以是动态库、可执行文件,大部分的链接脚本都只做这个事,因此链接并不算是一个很难理解的过程。

通常每个输入文件中都存在多个段,每个段的描述信息被单独保存在段表中,而段的信息单独保存,链接器通过分析段表中的内容,获取段相应的信息,比如段的读写属性、size、文件内偏移。

链接过程之后生成可加载的输出文件,通常是可执行文件,对于输出文件而言,内容是以 segment 的形式进行组织的,组织的依据是根据各个段的读写属性来确定的,对于代码部分通常是 读+执行 的权限,对于数据通常是 读+写 的属性,相同属性的段被组织在一起,最终在执行时被加载到内存中。

每个 segment 实际上对应两个地址,分别是加载地址 LMA(load memory address) 和虚拟地址 VMA(virtual memory address),加载地址是程序被加载的地址,而虚拟地址是程序运行的地址,通常情况下这两者是相等的,可以想到,当执行程序时,加载器将程序直接拷贝到它的执行地址,程序就可以直接执行,完全没必要让这两个地址不同,这并没有问题。

至于需要区分 LMA 和 VMA 主要是针对一些特殊情况,比如在某些情况下 .data 段被加载到只读的 ROM 中而不是其需要执行的内存位置,然后由程序将这部分 data 数据拷贝到内存中,出现这种情况可能是因为.data 对应的那块内存还没初始化完成,这种情况通常在启动代码中可以看到。

在 kernel 中同样可以看到 LMA 和 VMA 不同的情况,所有的内核代码将会编译成一个 vmlinux 的可执行文件,该文件中的 vectors(中断向量) 部分在运行时的地址应该是 0xffff0000,但是在加载时紧随着程序部分,在后续程序执行时才会被 copy 到对应的运行位置。导致 vectors LMA 和 VMA 不同的原因在于:vmlinux 并不是最终执行的文件,它会被 strip 成一个纯数据类型的 Image,被 uboot 加载到一个统一的地址,如果要保证 vector 的 VMA 和 LMA 一致,就得把 Image 加载到 0xffff0000 附近,要不就专门为 vector 加载一次,实际情况自然是不允许的。

链接脚本简单示例

链接脚本是一个文本文件,命名方式并不强制,除非你特意隐藏链接脚本,不然最好还是以 lds 为后缀,链接脚本中是一系列的命令,这些命令可能是一些关键字以及带的一些参数,也可以是一些符号相关的处理,在链接脚本中添加注释的方式和 C 一样可以使用 /**/,下面是一个最简单的链接脚本示例:

SECTIONS
{
    . = 0x10000;
    .text : { *(.text) }
    . = 0x8000000;
    .data : { *(.data) }
    .bss : { *(.bss) }
}

当输入的文件只包含 .text, .data ,.bss 三个段时,这就是一个完整可用的链接脚本,看起来非常简单。

其中,SECTIONS 是链接脚本中关键字,它表示各数据段布局的开始,伴随着 SECTIONS 命令的是 ".",这个 "." 是一个地址定位符,表示随后数据段对应的内存地址,在 SECTIONS 开始处,符号 "." 被定义且初始值为 0。

在上述的示例中,定位符 "." 被赋值为 0x1000,表示 .text 段的开始地址为 0x1000,而后面的 { *(.text) } 表示将所有输入文件中的 .text 段放在输出文件的 .text 段中,* 是通配符。

紧接着,地址定位符赋值为 0x8000000,随后放 .data 段,紧接着就是 .bss 段,可以发现这中间没有为地址定位符赋值,在这种情况下,地址定位符 "." 的值为 0x8000000 + sizeof(.data),即 "." 的值紧随着上一个放置段结束的位置,当然,通常情况下,中间会添加一个对齐参数。

实际上的链接脚本要相对复杂一些,体现在一些灵活的命令以及参数配置上,但整体框架和基本功能就是和上例一样,接下来我们来看看链接脚本中的其它命令。

链接脚本命令

程序的入口

程序执行的第一条指令被称为程序的入口,这个入口通常就是在链接脚本指定的,链接脚本中的 ENTRY() 命令可以指定入口地址,关于程序入口地址的指定规则和优先级依次是这样的(优先级从高到低):

  • 命令行通过 -e entry 指令入口地址为 entry,这个 entry 可以是一个符号。
  • 链接脚本中的 ENTRY(symbol) 命令,这个 symbol 是一个符号
  • 如果程序中定义了 start 符号,以这个符号作为入口地址
  • .text 段的起始地址
  • CPU 的 0 地址处开始

文件相关

INCLUDE filename: 包含某个文件,当链接器执行到该指令时,会在当前目录下和指定的 .L 目录下搜索该文件,链接脚本中最多可以嵌套调用 INCLUDE 10次,

INPUT(file,file,...):包含指定的文件,也可以包含库,比如 INPUT(-lfile),链接器会把 -lfile 翻译成 libfile.a。

STARTUP(filename):和 INPUT 关键字一样指定一个输入文件,同时 STARTUP 还将指定这个输入文件作为链接器的第一个输入文件,这意味着该文件中的各个段在链接时都被放在开头的位置。

OUTPUT(filename):指定输出的文件,一般情况下习惯在命令行中使用 -o file 来指定输出的文件,命令行的优先级要大于链接脚本中的 OUTPUT 指定。

SEARCH_DIR(path):指定搜索的路径,该路径会被添加到 ld 链接器待搜索的路径中,这个关键字和命令行中的 -L 参数是一样的效果,且两者不冲突。

OUTPUT_FORMAT(bfdname) OUTPUT_FORMAT(default, big, little):指定输出文件的 BFD 格式,当该命令只有一个参数时,直接指定文件格式,当命令带三个参数时,分别表示:默认格式,指定输出文件为大端时的格式,指定输出文件为小端时的格式,例如:

OUTPUT_FORMAT(elf32-bigmips, elf32-bigmips, elf32-littlemips)

OUTPUT_ARCH(bfdarch):输出文件的架构,比如 elf32-littlearm。

ASSERT(exp,message):和其它语言中的 assert 一样,判断 exp 是否为逻辑 0,如果为 0 则退出当前链接过程,打印 message,否则该语句不做任何事。

变量符号的操作

链接脚本中除了组织各个段之外,还可以定义符号,链接脚本中定义的符号被添加到全局符号中,和 C 语言中定义的全局变量性质是一样的,这样就可以通过这些变量符号将链接的信息传递到程序中,程序中可以使用,比如 bss 的开始地址为 __bss_start。

对变量符号的操作可以是以下的方式:

symbol = expression ;
symbol += expression ;
symbol -= expression ;
symbol *= expression ;
symbol /= expression ;
symbol <<= expression ;
symbol >>= expression ;
symbol &= expression ;
symbol |= expression ;

第一个表达式表示定义一个符号,后续的表达式对符号值进行操作,中间的空格是必须的,通常情况下,链接脚本中只会操作在脚本中定义的变量,而不会去操作程序中定义的变量,尽管这是被允许的。

这是因为链接脚本中定义的变量符号都是地址,和程序中定义的变量不是一个概念。首先,我们来看看 C 语言中是如何引用链接脚本中的变量的:

extern int __bss_start;
int *p = &__bss_start;
printf("bss start addr = 0x%x\n",p);

上述的三行代码引用了链接脚本中定义的 __bss_start,在链接脚本中定义的值是 bss 段的起始地址(__bss_start = xxx),但是实际上在 C 语言中引用它时, __bss_start 这个符号的地址才是 bss 段的起始地址,这看起来有点奇怪,不过确实说明了一点:链接脚本中定义的变量符号实际上是一个地址。

当程序和链接脚本中同时定义了变量符号时,链接脚本中的符号会覆盖掉程序中定义的符号,参考下面的代码:

链接脚本中:

    ...
    value = 0x1000;
    ...

C 程序中:

int value = 0x10;
void main()
{
    printf("value = 0x%x\n",value);
    printf("value_addr = 0x%x\n",&value);
}

对应输出的结果为:

value = 0x10f18
value_addr = 0x11000

在程序中定义的 value 值为 0x10,打印出来的 value 值却是 0x10f18,而 value 的地址为 0x11000。

也就是说,链接脚本中定义的 value 覆盖了程序中 value 的定义,链接脚本中 value = 0x11000,映射到 C 语言程序中就是 value 的地址值为 0x11000,而 value 的值为 0x11000 地址上的值,即 0x10f18,这个值是不确定的。

既然链接脚本中定义的值会覆盖程序中定义的值,如果链接脚本中修改程序中的变量值会出现什么样的情况?实际操作结果会变得很奇怪,我建议你动手试试,其结果总归是符合上述规则:链接脚本中操作的是变量的地址。

其他定义变量的命令

直接使用 "=" 表示无条件定义一个变量,当我们并不确定某个变量程序中是否需要使用到而犹豫该不该定义时,可以使用 PROVIDE 命令来定义变量符号

PROVIDE(symbol = exp) :定义的变量只有当被程序引用的时候才存在,不引用的时候该符号会被删除.

PROVIDE_HIDDEN(symbol = exp):和 PROVIDE 类似,不同的是该符号不会被导出,外部程序不能使用,在实际的编译器实现中有所不同,比如在 arm-linux-gnueabihf 4.8 版本的编译器实现中外部程序就可以使用 PROVIDE_HIDDEN 类型的符号.在后续 7.x 的版本不可访问.

内存相关操作命令

对于各个段对应的虚拟地址,链接脚本有默认的规则,即根据 SEGTIONS 中地址定位符来确定,同样也可以通过链接脚本的 MEMORY 命令进行自定义一个区域,可以将段放置在当前区域中.

MEMORY 的语法是这样的:

MEMORY
{
    name [(attr)] : ORIGIN = origin, LENGTH = len
    ...
}

MEMORY 命令中的 name 用于在链接脚本内部使用,并不会导出到外部,该名字会被保存在不同的名字空间中,和全局符号,文件名和段名并不冲突,尽管对于名字没有硬性规定,不过一般来说都是 ROM,RAM 等具有代表性且可识别的名称.

属性 attr 部分是可选的,它主要有以下几个选项:

  • 'R':只读段
  • 'W':读写段
  • 'X':可执行段
  • 'A':需要分配内存的段
  • 'I','L':初始化段
  • '!':和上述的属性合并使用,表示反转给出的属性

ORIGIN 表示区域的开始地址,也可以写成 org 或者 o,LENGTH 表示区域的长度.

如果定义内存区域,就不再使用地址定位符进行内存地址的确定,意味着所有的段都对应已定义的内存区域,一个段没有显示地指定将要添加到哪个区域,将会对段的属性和区域的属性进行匹配,如果未映射的段和 区域中 ! 以外的任何属性匹配,就会被放在该区域中,而带有 ! 的区域,则需要段的属性与列出的属性都不匹配时,才会将段放置在该区域中.如果一个段的不匹配所有区域的属性,它将不能放在任何区域中,链接过程会报错.

一个段可以显式地指定需要放在哪个区域中,使用 > 符号进行指定,来看下面的示例:

在上述示例中,定义了两个内存区域,rom 区域从 0 开始,占据 256K 字节,而 ram 区域从 0x40000000 开始,占用 4M 空间.

而输出段 .text 指定放在 rom 中, .data 放在 ram 区域, .bss 没有指定,但是由于 .bss 段的属性是 'w' 类型的,所以匹配的区域是 ram,被放在 .data 随后的地址处.

而地址定位符的赋值语句 ". = 0x80000000;" 会被忽略,编译完成之后可以通过 readelf -S 命令查看输出文件,可以看到各个段对应的虚拟地址.

在指定段所对应的地址时,理论上是可以随意进行映射的,比如将 .text 段指定放在 ram 区域中,自然这样也会带来一些问题.

在 linux 系统中,通常不会使用这种指定内存区域的定义方式,而是使用 SECTIONS 中的地址定位符,因为 linux 中对于系统内存的规定是比较严格的,通常不支持自定义的内存区域,而在裸机或者实时操作系统中这种方式使用得比较多.

在上文中我们提到了每个段都对应两个地址,VMA 和 LMA,MEMORY 命令和地址定位符都是针对 VMA 的,对于 LMA 我们可以使用 AT 命令来指定加载地址 lma,当指定段所属区域时,使用 AT> .

如果内存区域超出,链接器将会报错.

本章主要针对链接脚本做一个简要的介绍,链接脚本对目标文件中 section 信息的处理见后续章节。

参考

The GNU Link

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值