找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4402

积分

0

好友

607

主题
发表于 3 小时前 | 查看: 4| 回复: 0

链接是C/C++程序构建过程中的关键一步,它负责将多个目标文件(.o)与系统库组合成最终的可执行文件。这个看似自动化的过程,实际上是由一个名为链接脚本(Linker Script)的配置文件精确控制的。无论是进行底层系统开发还是追求极致的性能优化,理解并掌握链接脚本都是不可或缺的技能。

一、链接脚本的基本概念

GCC的编译过程通常包括四个主要步骤:预处理、编译、汇编和链接。链接作为最后一步,其核心任务是将所有的.o文件与所需的库函数代码“缝合”在一起。而指挥这个缝合过程的“蓝图”,就是链接脚本。

链接器必须使用一个链接脚本。如果开发者不主动提供,链接器则会使用一个内置的默认脚本。你可以通过ld --verbose命令来查看这个默认脚本的内容。一些命令行选项(如-r-N)会影响默认脚本的行为。更常见的做法是使用-T选项来指定你自己的链接脚本,此时自定义脚本将完全替代默认脚本。

链接脚本的主要目的是描述输入文件中的各个“段”(Section)如何映射到输出文件中,并精确控制输出文件在内存中的存储布局。绝大多数链接脚本都服务于这个目的,但在必要时,它也能指挥链接器执行更多底层操作。

1.1 链接器脚本语言基本概念和术语

链接器将多个输入文件(目标文件)组合成一个输出文件(通常为可执行文件)。每个文件都遵循特定的目标文件格式,并包含一个段列表。输入文件中的段称为输入段,输出文件中的段则称为输出段

每个段都有名称和大小,多数段还包含实际的数据块,称为段内容。段可以被标记为:

  • 可加载的(Loadable):程序运行时,其内容需要被加载到内存中。
  • 可分配的(Allocatable):需要预留一块内存区域,但无需加载特定内容(有时需清零)。
  • 既不可加载也不可分配的段,通常包含调试信息。

每个可加载或可分配的输出段有两个关键地址:

  • VMA(Virtual Memory Address):程序运行时该段所在的虚拟内存地址。
  • LMA(Load Memory Address):该段内容被加载到的物理地址。

在大多数情况下,VMA和LMA相同。一个典型的例外是嵌入式系统:数据段可能被固化在ROM(LMA)中,系统启动时再被复制到RAM(VMA)里,以初始化全局变量。

可以使用objdump -h命令查看目标文件中的段信息。

此外,每个目标文件还有一个符号表。符号分为已定义和未定义两种。每个已定义的符号(如函数、全局变量)都有一个地址。可以使用nm命令或objdump -t来查看符号。

二、链接脚本格式

链接脚本是纯文本文件,由一系列命令构成。命令之间用分号分隔,空格通常被忽略。字符串(如文件名)可以直接书写,若包含特殊字符(如逗号),需用双引号括起。

注释采用与C语言相同的格式,即/* 注释内容 */,注释在语法上被视为空格。

三、最简单的链接脚本示例

最简单的链接脚本只需要一个命令:SECTIONS。它用于描述输出文件的内存布局。

假设我们的程序只包含代码(.text)、已初始化数据(.data)和未初始化数据(.bss)段。我们希望代码从地址0x10000开始加载,数据从0x8000000开始。链接脚本可以这样写:

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

SECTIONS命令以关键字SECTIONS开头,后跟由花括号括起来的符号赋值和输出段描述。

  1. 第一行设置了特殊符号.的值,它代表位置计数器。如果未指定输出段地址,地址将基于位置计数器的当前值设置,随后位置计数器随段大小递增。SECTIONS开始时,位置计数器为0。
  2. 第二行定义了输出段.text。冒号是必须的语法。花括号*(.text)是一个输入段描述,通配符*匹配所有输入文件中的所有.text段。
  3. 由于定义.text段时位置计数器为0x10000,链接器会将该段的地址(VMA)设为0x10000
  4. 后续行定义了.data.bss段。链接器会将.data段放置在0x8000000。放置后,位置计数器变为0x8000000加上.data段的大小,从而使得.bss段紧跟在.data段之后。
  5. 链接器会确保每个输出段满足其对齐要求,这可能需要在段之间插入填充。

四、简单的链接脚本命令

4.1 设置入口点

程序执行的第一个指令地址称为入口点。使用ENTRY(symbol)命令设置。链接器按以下顺序确定入口点:

  1. -e命令行选项。
  2. 链接脚本中的ENTRY(symbol)命令。
  3. 目标文件格式特定的符号(如start)。
  4. .text段的起始地址。
  5. 地址0。

4.2 处理文件的命令

  • INCLUDE filename:包含另一个链接脚本文件。
  • INPUT(file, file, …):指示链接器包含指定文件,如同在命令行中输入一样。
  • GROUP(file, file, …):与INPUT类似,但要求文件是归档文件(.a),会循环搜索直到解析完所有未定义引用。
  • OUTPUT(filename):指定输出文件名,等同于-o选项。
  • SEARCH_DIR(path):添加库文件搜索路径,等同于-L选项。
  • STARTUP(filename):指定第一个输入文件,类似于将该文件放在命令行首位。

4.3 处理目标文件格式的命令

  • OUTPUT_FORMAT(bfdname):指定输出文件的BFD格式,等同于--oformat选项。也可用OUTPUT_FORMAT(default, big, little)根据-EB(大端)或-EL(小端)选项选择格式。
  • TARGET(bfdname):指定读取输入文件时使用的BFD格式,影响后续INPUTGROUP命令。

4.4 为内存区域分配别名名称

使用MEMORY命令定义内存区域后,可以用REGION_ALIAS(alias, region)为其创建别名。这在需要灵活映射输出段到不同内存区域的嵌入式系统中非常有用。

例如,一个嵌入式系统可能有RAM、ROM、ROM2等不同存储器。通过定义通用的SECTIONS脚本和不同的linkcmds.memory文件,可以轻松适配多种硬件配置(变体A、B、C)。

基础链接脚本 (link.ld)

INCLUDE linkcmds.memory
SECTIONS
{
    .text :
    {
        *(.text)
    } > REGION_TEXT
    .rodata :
    {
        *(.rodata)
        rodata_end = .;
    } > REGION_RODATA
    .data : AT (rodata_end)
    {
        data_start = .;
        *(.data)
    } > REGION_DATA
    data_size = SIZEOF(.data);
    data_load_start = LOADADDR(.data);
    .bss :
    {
        *(.bss)
    } > REGION_BSS
}

变体A的 linkcmds.memory (所有段在RAM)

MEMORY
{
    RAM : ORIGIN = 0, LENGTH = 4M
}
REGION_ALIAS("REGION_TEXT", RAM);
REGION_ALIAS("REGION_RODATA", RAM);
REGION_ALIAS("REGION_DATA", RAM);
REGION_ALIAS("REGION_BSS", RAM);

变体B的 linkcmds.memory (代码和只读数据在ROM,数据在RAM/ROM)

MEMORY
{
    ROM : ORIGIN = 0, LENGTH = 3M
    RAM : ORIGIN = 0x10000000, LENGTH = 1M
}
REGION_ALIAS("REGION_TEXT", ROM);
REGION_ALIAS("REGION_RODATA", ROM);
REGION_ALIAS("REGION_DATA", RAM);
REGION_ALIAS("REGION_BSS", RAM);

系统初始化时,可以用C语言编写通用例程,根据data_startdata_load_startdata_size这些链接脚本定义的符号,将.data段从加载地址(ROM/ROM2)复制到运行地址(RAM)。

4.5 其他链接器脚本命令

  • ASSERT(exp, message):断言表达式exp非零,否则报错退出。
  • EXTERN(symbol ...):强制将符号作为未定义符号引入,以链接额外模块,等同于-u选项。
  • FORCE_COMMON_ALLOCATION:等同于-d,强制为公共符号分配空间。
  • INHIBIT_COMMON_ALLOCATION:等同于--no-define-common,忽略对公共符号的地址分配。
  • FORCE_GROUP_ALLOCATION:强制将段组成员视为普通输入段放置。
  • NOCROSSREFS(section ...):禁止列出的输出段之间互相引用,否则报错。
  • OUTPUT_ARCH(bfdarch):指定输出文件的机器架构。
  • LD_FEATURE(string):修改链接器行为,如“SANE_EXPR”使绝对符号和数字被始终视为数字。

五、为符号赋值

可以在链接脚本中定义全局符号。这常用于在程序中暴露内存布局的关键地址。

5.1 简单的赋值

使用C风格的赋值运算符:

symbol = expression;
symbol += expression; /* 等等 */

特殊符号.代表位置计数器,只能在SECTIONS命令内使用。赋值语句可以独立存在,也可以放在SECTIONS命令内或输出段描述中。

5.2 HIDDEN

对于ELF目标,HIDDEN(symbol = expression)定义的符号将被隐藏,不会被导出到其他模块。

5.3 PROVIDE

PROVIDE(symbol = expression)仅在符号被引用但未在任何输入目标文件中定义时才定义它。这解决了用户代码可能使用相同符号名的问题。

5.4 PROVIDE_HIDDEN

类似PROVIDE,但定义的符号是隐藏的。

5.5 源代码引用

重要:链接脚本定义的符号,其本身只是一个地址值(VMA),并没有在对应的内存位置存储该值。因此,在C源代码中引用时,必须取符号的地址,而不是它的“值”。

例如,链接脚本定义:

foo = 0x2345;

在C代码中应这样使用:

extern int foo; /* 实际上是声明 foo 的地址 */
int *p = &foo;  /* 正确:获取地址 */
int x = foo;    /* 错误:试图读取地址 0x2345 处的值,这通常是非法的 */

更安全的做法是将它们视为数组名:

extern char foo[]; /* 表明 foo 是一个地址 */
memcpy(dest, foo, len); /* 直接使用地址 */

六、SECTIONS 命令

SECTIONS是链接脚本的核心,它详细描述了输入段到输出段的映射及内存放置。

6.1 输出段描述

一个完整的输出段描述语法如下(大多数属性可选):

section [address] [(type)] :
  [AT(lma)]
  [ALIGN(section_align) | ALIGN_WITH_INPUT]
  [SUBALIGN(subsection_align)]
  [constraint]
  {
    output-section-command
    output-section-command
    ...
  } [>region] [AT>lma_region] [:phdr :phdr ...] [=fillexp] [,]

每个output-section-command可以是:符号赋值、输入段描述、直接数据值或特殊关键字。

6.4 输入段描述

这是最常用的命令,基本形式为filename(section)filenamesection都可以使用通配符。

  • *(.text):包含所有输入文件的.text段。
  • EXCLUDE_FILE (file1.o file2.o) *(.ctors):排除指定文件后的.ctors段。
  • 排序:SORT_BY_NAME(.text*)SORT_BY_ALIGNMENT(.data*)SORT_BY_INIT_PRIORITY(.init_array*)
  • 保留段(用于垃圾回收):KEEP(*(.init))

6.4.3 通用符号的输入段

通用符号(Common Symbols)被放置在名为COMMON的特殊输入段中。通常将它们与.bss段一起放置:

.bss { *(.bss) *(COMMON) }

6.5 输出段数据

可以在段内直接嵌入数据:

  • BYTE(1)SHORT(0x1234)LONG(addr)QUAD(0x123456789ABCDEF):分别插入1、2、4、8字节数据。
  • FILL(0x90909090):设置从当前开始到段结束的填充值。

6.8 输出段属性

  • 类型 (type):如NOLOAD(运行时不被加载)、READONLY
  • 加载地址 (LMA):由AT(lma_expression)AT>region指定。如果不指定,LMA通常等于VMA。
  • 填充 (fillexp):使用=fillexpFILL()命令设置段内未指定区域的填充值。
  • 区域约束:使用>region将段分配到特定内存区域。

七、MEMORY 命令

MEMORY命令定义了目标平台上的物理内存布局,链接器将在此框架内放置输出段。

语法:

MEMORY
{
  name [(attr)] : ORIGIN = origin, LENGTH = len
  ...
}
  • name:区域名称,在链接脚本内使用。
  • attr:属性字符串,可选,定义可放入此区域的段类型(R只读, W可写, X可执行, A可分配, I/L已初始化, !取反)。
  • origin / len:区域的起始地址和长度。

示例:

MEMORY
{
  rom (rx)  : ORIGIN = 0,          LENGTH = 256K
  ram (!rx) : ORIGIN = 0x40000000, LENGTH = 4M
}

此例定义了一个只读、可执行的rom区域和一个非可执行、可读写的ram区域。未明确映射的只读/可执行段会进入rom,其他段进入ram

通过本文对GCC链接脚本从概念到语法的全面梳理,我们可以看到,这个强大的工具为我们提供了精准控制程序内存布局的能力。无论是进行嵌入式开发、操作系统内核移植,还是实现高级的内存管理与优化,深入理解链接脚本都是突破技术瓶颈的关键。希望这份指南能帮助你在云栈社区的交流与实践中,更自信地驾驭程序构建的最终环节。




上一篇:AI代码审计实战:用Trae工具自动化挖掘MiniCMS漏洞,高效构建Source-to-Sink证据链
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-17 18:21 , Processed in 0.463509 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表