LinkerScript脚本秘籍|语法深度解析与实战

原创 Linux二进制 2024-09-10 08:20

一、前言

编写完成一个 C/C++ 程序后,想要运行起来,通常必须要经过四个步骤:预处理、编译、汇编和链接。每个步骤都会生成对应的文件,如下图所示(注意后缀名):

程序从编译链接到运行的各个阶段想必很多人都知道,但是能将很多细节真正讲清楚的人怕是寥寥无几。比如,在 C/C++ 语言中,一个源文件代码(这里以 .c 为后缀的文件举例)变为可执行文件(.exe/.out 后缀)的过程,究竟有几个阶段,每个阶段又做了哪些工作?让我们娓娓道来。

 

拓展程序、数据和指令概念

  • 程序是由代码(指令)、数据、进程控制块组成的;

  • 数据指程序中的全局变量、静态变量、常量;

  • 指令指程序中除数据以外的内容;比如:

    int main(){
    int a=10;
    }

    这里 a 是局部变量,它并不是数据,而是一条指令,指令功能是在函数栈帧上开辟四个字节,并向其中写入指定值。

首先有两个环境:一个翻译环境,一个运行环境

  • 翻译环境:就是把源代码翻译为计算机可以识别出来的01 二进制代码

  • 运行环境:就是执行代码,运行代码的一个环境;

翻译环境完成的工作可概括为:编译+链接的过程,而编译又可分为:预处理,编译,汇编三个步骤;因此,在翻译环境中的工作总共分为 4 个步骤,即预处理、编译、汇编以及链接。每个步骤又做了哪些事情呢?详情可参考下图:

 

拓展:编译和链接

  • 编译:把文本形式源代码翻译为机器语言形式的目标文件的过程。

  • 链接:把目标文件、操作系统的启动代码和用到的库文件进行组织形成最终生成可加载、可执行代码的过程。

而程序从预处理、编译、汇编、链接到运行的每步操作,都是由 编译工具链(Toolchain) 的各个组件完成的,各个组件及其作用如下:

  • 预处理器:处理源代码中的预处理指令,如宏定义 (#define)、文件包含 (#include)、条件编译指令 (#ifdef#ifndef#if#else#endif) 等;替换宏定义,展开包含的文件内容,处理条件编译语句;输出经过预处理的源代码文件,通常称为预处理后的文件,它不再包含任何预处理指令。
  • 编译器:将预处理后的源代码转换为目标代码或汇编语言,执行类型检查、语法分析、语义分析和代码优化,输出目标文件(.o 或 .obj 文件),包含机器代码和符号表。
  • 汇编器:将编译器生成的汇编语言转换为机器语言的目标代码(如果编译器直接生成目标代码,则此步骤可能被省略);输出目标文件(.o 或 .obj 文件),如果编译器没有直接生成目标代码的话。
  • 链接器:将多个目标文件和库文件组合成一个可执行文件或共享库;解决不同模块间的符号引用问题,即将函数名、变量名等符号与具体的实现关联起来;分配内存地址给程序的各个部分;输出可执行文件或动态/静态库。
  • 加载器:在程序运行前将可执行文件从磁盘加载到内存中,解析动态库的符号引用,确保所有依赖的库都被正确加载,确保程序在内存中的布局符合运行时的要求,准备好在操作系统环境中运行的程序。
 

注意:加载器会在程序运行时自动由操作系统调用。

上面内容主要帮助大家回顾一下程序编译链接流程,对于编译阶段不少人想必会更熟悉一点,但是对于链接阶段可能都知之甚少,大都是只有一个概念,真正了解的并不多。那么链接阶段,究竟做了哪些操作以及如何控制链接器做相应操作,这方面的文章也不多。本文将为大家讲解一下链接阶段的 Linker Script 脚本。

二、基本概念

链接(Linking)  本质上就是把各个模块之间相互引用的部分处理好,使得各个模块之间能够正确衔接。链接器一般采用一种叫  两步链接(Two-pass Linking)  的方法。即整个链接过程分为两步:

  • 第一步 地址与空间分配 扫描所有的输入目标文件,获得它们的各个节的长度、属性、位置,并将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局的符号表。这一步,链接器能够获得所有输入目标文件的节的长度,并将它们合并,计算出输出文件中各个节合并后的长度与位置,并建立映射关系。
  • 第二步 符号解析与重定位 使用前一步中收集到的所有信息,读取输入文件中节的数据、重定位信息,并且进行符号解析与重定位、调整代码、调整代码中的地址等。事实上,第二步是链接过程的核心,尤其是重定位。
 

拓展:符号概念】

 

在程序中,所有数据都会产生符号,而对于代码节只有函数名会产生符号。而且符号的作用域有 global 和 local 之分,对于未用 static 修饰过的全局变量和函数产生的均是 global 符号,这样的变量和函数可以被其他文件所看见和引用;而使用 static 修饰过的变量和函数,它们的作用域仅局限于当前文件,不会被其他文件所看见,即其他文件中也无法引用 local 符号的变量和函数。

 

注意:段(Segment)与节(Section)的区别。很多地方对两者有所混淆。段包括代码段和数据段等,段是由节组成的,多个节经过链接后被合并为一个段。段是程序执行的必要组成,当多个目标文件链接成一个可执行文件时,会将相同权限的节合并到一个段中。相比而言,节的粒度更小。如想进一步了解二者区别,可参考Linux ELF 二进制文件解析及实战。如 .data 节和 .bss 节可能被合并到一个段中。

用一句话总结即:链接阶段主要进行地址和空间分配,符号决议和重定位等。其中地址和空间分配则是通过链接脚本(Linker Script)实现的,后续内容则将重点讲解链接脚本。

链接脚本(Linker Script,一般以 lds 作为文件的后缀名)是 ld 命令实现链接操作的规范性语义描述文件,使用链接命令语言(Linker Command Language)进行书写。链接脚本最主要的功能是描述如何将输入文件的节区(Sections)映射合并到输出文件的节中,同时对输出文件的存储布局进行控制。另外,链接脚本中还定义了其它众多的命令,可以控制链接器完成很多高级操作。链接器有个默认的内置链接脚本,可以使用 ld -verbose 查看,而ld -T 命令则可以指定链接脚本,它将代替默认的链接脚本。

 

注意

  • 输入文件指的是目标文件或链接脚本文件。

  • 输出文件指的是目标文件或可执行文件。

三、链接脚本语法

链接脚本(Linker Script)是用于指导链接器如何将目标文件链接生成最终的可执行文件或者动态链接库的脚本。链接脚本描述了代码的内存分配、数据的布局、初始化、以及其他一些链接时需要的信息。下图我们通过一个例子,来说明链接脚本大致有哪些内容。如图所示:

链接脚本由一系列命令组成, 每个命令由一个关键字(一般在其后紧跟相关参数)或一条对符号的赋值语句组成。不同命令之间由分号 ; 分隔开。

文件名或格式名内如果包含分号 ; 或其他分隔符, 则要用引号将名字全称引用起来。否则无法处理含引号的文件名。注意 /* */ 之间的是注释。

链接脚本通常使用一种简单的编程语言,例如 GNU Linker 的链接脚本是使用类似 C 的语言写成的脚本。通常它包含了以下信息:

  • 地址空间布局:链接脚本可以指定代码节、数据节、BSS 节等在内存中的位置和大小,并决定它们的起始地址和结束地址。

  • 符号表:链接脚本可以定义符号表,这些符号可以是全局变量、函数、常量等,它们将在链接过程中被绑定。

  • 初始化和清除:链接器会按照链接脚本指定的顺序对节进行初始化,清除,或者进行其他的一些工作。

  • 程序入口:链接脚本中可以指定程序入口,例如 _start() 或 main() 函数所在的地址。

 

注意_start() 通常是程序第一个被执行的函数,即入口函数,而不是 main() 函数。我们的程序中通常并没有写 _start() ,它是通过链接器链接到可执行文件中的。

 

对于 gcc 编译器来说,在默认情况下:

  • 程序加载后,_start() 是第一个被执行的函数(_start()函数的入口地址就是代码节(.text)的起始地址)
  • _start()函数,准备好参数后,立即调用 __libc_start_main() 函数
  • __libc_start_main() 初始化运行环境后,调用 main() 函数执行

另外,我们可以自定义程序的入口函数,gcc 提供 -e 选项,用于在链接时指定入口函数,自定义入口函数时,必须使用选项  -nostartfiles  进行链接

 

拓展】链接器有多种方法设置进程入口地址(编号越前,优先级越高):

  1. -e 输入命令行选项;
  2. 链接器控制脚本中的 ENTRY(symbol) 命令;
  3. 符号 start 的值(如果存在)。
  4. .text 节的第一个字节的地址(如果存在);
  5. 地址 0 。

例如,您可以使用这些规则生成带有赋值语句的入口点:如果在输入文件中没有定义 start 符号,则可以简单地定义它,并为其分配适当的值:

 

start = 0x2020; 该示例显示了一个绝对地址,但您可以使用任何表达式。例如,如果您的输入对象文件使用其他符号名称约定作为入口点,你可以将包含起始地址的符号赋值给 start :start = other_symbol

知晓了链接脚本包含的信息,接下来就让我们一起了解一下链接脚本的语法,链接脚本语法由一系列的命令和符号构成,详情见下文。

1. OUTPUT_FORMAT

OUTPUT_FORMAT 是链接器脚本中的一个命令,用于指定输出文件的格式。这个命令对于确定输出文件的类型至关重要,因为不同的系统可能需要不同格式的输出文件。它通常出现在链接器脚本的开始部分,并且只能出现一次。语法如下:

OUTPUT_FORMAT(format, architecture)

参数解析如下:

  • format: 输出文件的格式,常见的格式包括  elf32elf64pe 等。

  • architecture: 目标架构,如  i386x86-64armaarch64,riscv32 ,riscv64 等。

 

拓展:OUTPUT_FORMAT 指令可以接受不同数量的参数来定义输出文件的格式

  1. 单参数形式

当 OUTPUT_FORMAT 接受单个参数时,该参数通常包含两部分,用短横线 - 分隔,表示输出文件的格式和目标架构。这种形式通常用于 ELF 文件格式。示例:

OUTPUT_FORMAT(elf32-i386)
  • elf32-i386:

    • elf32: 表示输出文件格式是 32 位的 ELF
    • i386: 表示目标架构是 32 位的 x86
  1. 多参数形式

当 OUTPUT_FORMAT 接受多个参数时,第一个参数通常表示输出文件的格式,第二个参数表示目标架构。这种形式通常用于 ELF 文件格式,特别是在需要指定字节序的情况下。示例:

OUTPUT_FORMAT(elf64-little, aarch64)
  • elf64-little:
    • elf64: 表示输出文件格式是 64 位的 ELF
    • little: 表示输出文件使用小端字节序。
  • aarch64:
    • 表示目标架构是 64 位的 ARM 架构(AARCH64)。

总结如下:

  • 单参数形式:

    • 通常用于 ELF 文件格式。
    • 参数中包含文件格式和目标架构的信息,用短横线  -  分隔。
  • 多参数形式:

    • 通常用于 ELF 文件格式。
    • 第一个参数表示文件格式和字节序。
    • 第二个参数表示目标架构。

2. OUTPUT_ARCH

OUTPUT_ARCH 是链接器脚本中的一个命令,用于指定输出文件的目标架构。这个指令对于确保输出文件与目标平台兼容非常重要。它通常出现在链接器脚本的开始部分,并且只能出现一次。语法如下:

OUTPUT_ARCH(architecture)

参数解析如下:

  • architecture: 目标架构的名称,例如  i386x86-64armaarch64riscv32riscv64  等。

阅读到这里,可能有人会疑惑,既然 OUTPUT_FORMAT 已经支持指定目标架构,OUTPUT_ARCH 还有必要同时存在?实际上,在大多数情况下,OUTPUT_FORMAT 和 OUTPUT_ARCH 不需要同时存在,因为 OUTPUT_FORMAT 已经包含了足够的信息来指定输出文件的格式以及目标架构。那么,为什么还要提供 OUTPUT_ARCH 命令?因为在某些情况下,你可能需要同时使用 OUTPUT_FORMAT 和 OUTPUT_ARCH,但这通常是比较少见的情况。

 

拓展:需要同时使用  OUTPUT_FORMAT  和  OUTPUT_ARCH 的场景

  • 兼容特定版本的链接器工具链:
    • 有些旧版本的工具链可能需要明确指定架构信息才能正确工作。在这种情况下,OUTPUT_ARCH  提供了必要的信息。
  • 与其他工具集成:
    • 如果其他工具期望链接器脚本中包含架构信息,则  OUTPUT_ARCH  可以确保这些工具能够正确地处理链接器脚本。

3. ENTRY

ENTRY(SYMBOL) 表示将符号 SYMBOL 的值设置为入口地址,入口地址是程序执行的第一条指令在程序地址空间的地址;当一个程序被加载到内存中准备执行时,操作系统会查找程序的入口点,并从那里开始执行。在大多数情况下,这个入口点就是 _start 函数。

_start  函数通常在启动文件(如  startup.S  或  startup.asm)中定义,这是一个汇编语言文件,处理程序启动时的初始化工作,例如设置栈指针、关闭中断等。_start  函数负责完成必要的初始化后调用  main()  函数。在  main()  函数结束后,通常会返回  _start  函数中,进行清理操作并调用  exit()  函数结束程序。

例如, ENTRY(_start)  告诉链接器程序的启动点是  _start  函数。当程序被加载到内存中时,CPU 会跳转到  _start  函数的地址开始执行。ENTRY(_start)  通常放在链接器脚本的开头部分,以便明确指示程序的启动位置。

看到这里,可能有人会有疑问,如果程序入口点既不是 _start 函数,又不是 main 函数,而是自定义的其它函数,程序能否正常运行呢?毕竟一直以来我们接触到的程序,都是以 _start 或者 main 函数为入口点的,下面让我们带着这个疑问,通过一个示例来验证一下。程序的入口点是一个自定义的函数 custom_entry,而不是 main 或 _start

我们的 C 代码如下所示,定义了一个 custom_entry 函数作为程序的入口点:

#include 
#include

void custom_entry() {
printf("Starting the program from custom_entry function.\n");
exit(0);
}

在这个示例中,我们只定义了 custom_entry 函数,没有定义main 函数,随后,通过链接器脚本来指定 custom_entry 作为程序的入口点。首先我们可以通过 ld -verbose 命令获取默认的链接脚本。在默认链接脚本的基础上仅更改程序入口点,改为 custom_entry 函数。修改后链接器脚本如下:

OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64",
"elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(custom_entry)
SEARCH_DIR("=/usr/x86_64-redhat-linux/lib64"); SEARCH_DIR("=/usr/lib64"); SEARCH_DIR("=/usr/local/lib64"); SEARCH_DIR("=/lib64"); SEARCH_DIR("=/usr/x86_64-redhat-linux/lib"); SEARCH_DIR("=/usr/local/lib"); SEARCH_DIR("=/lib"); SEARCH_DIR("=/usr/lib");
SECTIONS
{
/* Read-only sections, merged into text segment: */
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
.interp : { *(.interp) }
.note.gnu.build-id : { *(.note.gnu.build-id) }
.hash : { *(.hash) }
.gnu.hash : { *(.gnu.hash) }
.dynsym : { *(.dynsym) }
.dynstr : { *(.dynstr) }
.gnu.version : { *(.gnu.version) }
.gnu.version_d : { *(.gnu.version_d) }
.gnu.version_r : { *(.gnu.version_r) }
.rela.dyn :
{
*(.rela.init)
*(.rela.text .rela.text.* .rela.gnu.linkonce.t.*)
*(.rela.fini)
*(.rela.rodata .rela.rodata.* .rela.gnu.linkonce.r.*)
*(.rela.data .rela.data.* .rela.gnu.linkonce.d.*)
*(.rela.tdata .rela.tdata.* .rela.gnu.linkonce.td.*)
*(.rela.tbss .rela.tbss.* .rela.gnu.linkonce.tb.*)
*(.rela.ctors)
*(.rela.dtors)
*(.rela.got)
*(.rela.bss .rela.bss.* .rela.gnu.linkonce.b.*)
*(.rela.ldata .rela.ldata.* .rela.gnu.linkonce.l.*)
*(.rela.lbss .rela.lbss.* .rela.gnu.linkonce.lb.*)
*(.rela.lrodata .rela.lrodata.* .rela.gnu.linkonce.lr.*)
*(.rela.ifunc)
}
.rela.plt :
{
*(.rela.plt)
PROVIDE_HIDDEN (__rela_iplt_start = .);
*(.rela.iplt)
PROVIDE_HIDDEN (__rela_iplt_end = .);
}
.init :
{
KEEP (*(SORT_NONE(.init)))
}
.plt : { *(.plt) *(.iplt) }
.plt.got : { *(.plt.got) }
.plt.sec : { *(.plt.sec) }
.text :
{
*(.text.unlikely .text.*_unlikely .text.unlikely.*)
*(.text.exit .text.exit.*)
*(.text.startup .text.startup.*)
*(.text.hot .text.hot.*)
*(.text .stub .text.* .gnu.linkonce.t.*)
/* .gnu.warning sections are handled specially by elf32.em. */
*(.gnu.warning)
}
.fini :
{
KEEP (*(SORT_NONE(.fini)))
}
PROVIDE (__etext = .);
PROVIDE (_etext = .);
PROVIDE (etext = .);
.rodata : { *(.rodata .rodata.* .gnu.linkonce.r.*) }
.rodata1 : { *(.rodata1) }
.eh_frame_hdr : { *(.eh_frame_hdr) *(.eh_frame_entry .eh_frame_entry.*) }
.eh_frame : ONLY_IF_RO { KEEP (*(.eh_frame)) *(.eh_frame.*) }
.gcc_except_table : ONLY_IF_RO { *(.gcc_except_table
.gcc_except_table.*) }
.gnu_extab : ONLY_IF_RO { *(.gnu_extab*) }
/* These sections are generated by the Sun/Oracle C++ compiler. */
.exception_ranges : ONLY_IF_RO { *(.exception_ranges
.exception_ranges*) }
/* Adjust the address for the data segment. We want to adjust up to
the same address within the page on the next page up. */
. = DATA_SEGMENT_ALIGN (CONSTANT (MAXPAGESIZE), CONSTANT (COMMONPAGESIZE));
/* Exception handling */
.eh_frame : ONLY_IF_RW { KEEP (*(.eh_frame)) *(.eh_frame.*) }
.gnu_extab : ONLY_IF_RW { *(.gnu_extab) }
.gcc_except_table : ONLY_IF_RW { *(.gcc_except_table .gcc_except_table.*) }
.exception_ranges : ONLY_IF_RW { *(.exception_ranges .exception_ranges*) }
/* Thread Local Storage sections */
.tdata : { *(.tdata .tdata.* .gnu.linkonce.td.*) }
.tbss : { *(.tbss .tbss.* .gnu.linkonce.tb.*) *(.tcommon) }
.preinit_array :
{
PROVIDE_HIDDEN (__preinit_array_start = .);
KEEP (*(.preinit_array))
PROVIDE_HIDDEN (__preinit_array_end = .);
}
.init_array :
{
PROVIDE_HIDDEN (__init_array_start = .);
KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*)))
KEEP (*(.init_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .ctors))
PROVIDE_HIDDEN (__init_array_end = .);
}
.fini_array :
{
PROVIDE_HIDDEN (__fini_array_start = .);
KEEP (*(SORT_BY_INIT_PRIORITY(.fini_array.*) SORT_BY_INIT_PRIORITY(.dtors.*)))
KEEP (*(.fini_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .dtors))
PROVIDE_HIDDEN (__fini_array_end = .);
}
.ctors :
{
/* gcc uses crtbegin.o to find the start of
the constructors, so we make sure it is
first. Because this is a wildcard, it
doesn't matter if the user does not
actually link against crtbegin.o; the
linker won't look for a file to match a
wildcard. The wildcard also means that it
doesn't matter which directory crtbegin.o
is in. */
KEEP (*crtbegin.o(.ctors))
KEEP (*crtbegin?.o(.ctors))
/* We don't want to include the .ctor section from
the crtend.o file until after the sorted ctors.
The .ctor section from the crtend file contains the
end of ctors marker and it must be last */
KEEP (*(EXCLUDE_FILE (*crtend.o *crtend?.o ) .ctors))
KEEP (*(SORT(.ctors.*)))
KEEP (*(.ctors))
}
.dtors :
{
KEEP (*crtbegin.o(.dtors))
KEEP (*crtbegin?.o(.dtors))
KEEP (*(EXCLUDE_FILE (*crtend.o *crtend?.o ) .dtors))
KEEP (*(SORT(.dtors.*)))
KEEP (*(.dtors))
}
.jcr : { KEEP (*(.jcr)) }
.data.rel.ro : { *(.data.rel.ro.local* .gnu.linkonce.d.rel.ro.local.*) *(.data.rel.ro .data.rel.ro.* .gnu.linkonce.d.rel.ro.*) }
.dynamic : { *(.dynamic) }
.got : { *(.got) *(.igot) }
. = DATA_SEGMENT_RELRO_END (SIZEOF (.got.plt) >= 24 ? 24 : 0, .);
.got.plt : { *(.got.plt) *(.igot.plt) }
.data :
{
*(.data .data.* .gnu.linkonce.d.*)
SORT(CONSTRUCTORS)
}
.data1 : { *(.data1) }
_edata = .; PROVIDE (edata = .);
. = .;
__bss_start = .;
.bss :
{
*(.dynbss)
*(.bss .bss.* .gnu.linkonce.b.*)
*(COMMON)
/* Align here to ensure that the .bss section occupies space up to
_end. Align after .bss to ensure correct alignment even if the
.bss section disappears because there are no input sections.
FIXME: Why do we need it? When there is no .bss section, we don't
pad the .data section. */
. = ALIGN(. != 0 ? 64 / 8 : 1);
}
.lbss :
{
*(.dynlbss)
*(.lbss .lbss.* .gnu.linkonce.lb.*)
*(LARGE_COMMON)
}
. = ALIGN(64 / 8);
. = SEGMENT_START("ldata-segment", .);
.lrodata ALIGN(CONSTANT (MAXPAGESIZE)) + (. & (CONSTANT (MAXPAGESIZE) - 1)) :
{
*(.lrodata .lrodata.* .gnu.linkonce.lr.*)
}
.ldata ALIGN(CONSTANT (MAXPAGESIZE)) + (. & (CONSTANT (MAXPAGESIZE) - 1)) :
{
*(.ldata .ldata.* .gnu.linkonce.l.*)
. = ALIGN(. != 0 ? 64 / 8 : 1);
}
. = ALIGN(64 / 8);
_end = .; PROVIDE (end = .);
. = DATA_SEGMENT_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) }
.gnu.build.attributes : { *(.gnu.build.attributes .gnu.build.attributes.*) }
/* 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_line.* .debug_line_end ) }
.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) }
/* DWARF 3 */
.debug_pubtypes 0 : { *(.debug_pubtypes) }
.debug_ranges 0 : { *(.debug_ranges) }
/* DWARF Extension. */
.debug_macro 0 : { *(.debug_macro) }
.debug_addr 0 : { *(.debug_addr) }
.gnu.attributes 0 : { KEEP (*(.gnu.attributes)) }
/DISCARD/ : { *(.note.GNU-stack) *(.gnu_debuglink) *(.gnu.lto_*) *(.gnu_object_only) }
}
s) }
}

接着,让我们编译和运行这个程序:

[root@localhost test]# gcc -c entry_example.c -o entry_example.o
[root@localhost test]# gcc -nostartfiles -T linker_script.lds entry_example.o -o entry_example
[root@localhost test]# ls -l
total 28
-rwxr-xr-x. 1 root root 6464 Aug 20 08:27 entry_example
-rw-r--r--. 1 root root 249 Aug 20 08:20 entry_example.c
-rw-r--r--. 1 root root 1600 Aug 20 08:27 entry_example.o
-rw-r--r--. 1 root root 8664 Aug 20 08:16 linker_script.lds
[root@localhost test]# ./entry_example
Starting the program from custom_entry function.

上面内容表明程序成功从 custom_entry 函数开始执行。另外,也说明我们的程序即使没有 main 函数,依然可以运行。

 

拓展-nostartfiles 选项告诉 GCC 链接器不使用默认的启动文件(start-up files)。在默认情况下,GCC 会在链接阶段自动包含一些启动文件,这些文件提供了 C/C++ 程序的标准启动逻辑,包括初始化 C 运行时环境、设置栈和堆等。这些文件通常是操作系统特定的,并且依赖于你的目标平台。

 

当你使用 -nostartfiles 选项时,链接器将跳过这些启动文件的使用,这意味着它不会自动包含以下内容:

  • 标准 C 运行时库(如  libc)的初始化代码。
  • 全局构造器和析构器的处理。
  • 标准输入/输出流的初始化(如  stdinstdoutstderr)。
  • 默认的异常处理机制。
  • 标准 C 运行时环境的清理。

由于 -nostartfiles 选项阻止了这些启动文件的加载,因此你的程序必须自己提供必要的初始化代码。这对于创建最小化的可执行文件或自定义启动过程非常有用,但同时也要求程序员自己处理这些通常由 C 运行时库自动完成的任务。

 

虽然使用 -nostartfiles 选项时,链接器不会自动包含默认的启动文件和 C 运行时库。然而,即使使用了 -nostartfilesGCC 仍然能够正确地链接 C 运行时库中的某些函数,这是因为 GCC 有一个特殊机制来处理这种情况。

 

当你在程序中使用了 printf 函数,GCC 编译器会自动产生对 printf 的引用。即使使用了 -nostartfiles,链接器仍然会尝试满足这些未解析的符号引用。在这种情况下,链接器会自动链接 libc 库来提供 printf 函数。

 

但是,需要注意的是,虽然 printf 可以正常工作,但 main 函数和其他一些 C 运行时环境的特性则不会自动被链接,除非你显式地链接 libc 库。

4. 定位计数器 .

定位计数器 . 是链接器脚本中的一个特殊变量,表示当前的内存地址位置。它用于确定如何放置各个节和数据,总是代表输出文件中的一个地址(根据输入文件Section 的大小不断增加,不能倒退,且只用于 SECTIONS 命令中)。对定位计数器  .  赋值可指定其后内容的存储位置,如果没有以其它的方式指定输出节的地址,则地址值就会被设为定位计数器的当前值,下面举例说明:

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

上述示例中使用 SECTIONS 来描述输出文件各节的内存布局,需要注意的是,在 SECTIONS 命令的开始处, 定位计数器当前值为0。让我们一起解析一下上面示例的含义:

  • .= 0x10000:表示将定位计数器赋值为0x10000
  • .text 即定义 .text 代码节,且其地址即为定位计数器的当前值 0x10000,通配符 * 代表所有的输入文件,即表示所有参与链接的文件中的 .text 节(例如:*main.o(.text)代表 main.o 文件中所有 .text 节);
  • .data 即定义 .data 数据节,且其地址为定位计数器当前值 0x8000000, *(.data)  代表所有参与链接文件中的 .data 节;(注意,*(.data.*) 则表示所有参与链接文件的 .data 节中的全部数据);
  • .bss 即定义.bss 数据节,其地址为 0x8000000 + .data section length ,(.bss) 代表所有参与链接文件中的 .bss 节。
 

拓展.bss 节特点

  1. 未初始化的数据:

  • .bss  节通常用于存放未初始化的全局变量和静态局部变量。这些变量在程序运行时会被自动初始化为零。
  • 零填充:

    • 因为  .bss  节中的数据在程序开始执行时被初始化为零,所以这个节不包含任何实际的数据,只有大小信息。
  • 内存占用:

    • 由于  .bss  节的数据被初始化为零,因此在生成的可执行文件中并不包含  .bss  节的实际数据,这有助于减小程序的大小。
  • 动态分配:

    • .bss  节中的数据在程序运行时由操作系统动态分配。这意味着,虽然编译时指定了  .bss  节的大小,但实际的数据分配发生在程序加载时。
  • 对齐要求:

    • .bss  节中的数据通常需要满足一定的对齐要求,以确保数据的正确访问。链接器脚本可以用来指定对齐方式。

    .bss 节用于存放未初始化的数据,这些数据在程序运行时由操作系统自动初始化为零。它有助于减小程序的大小,并确保未初始化的数据具有一个已知的初始状态。在链接器脚本中,可以通过特定的指令来定义 .bss 节的位置、大小以及对齐方式。

    5. SECTIONS

    在链接器脚本中,SECTIONS 命令用于定义如何组织和放置不同的Sections。这是链接器脚本的核心部分,因为它决定了最终可执行文件中各个节的布局。SECTIONS 命令告诉链接器如何将输入节映射到输出节,以及如何将输出节放在内存中。基本语法如下:

    SECTIONS
    {
    SECTIONS-COMMAND
    SECTIONS-COMMAND
    ...
    }

    每个 SECTIONS-COMMAND 可以是下列命令之一:

    • ENTRY 命令
    • 符号赋值语句
    • 一个输出 section 的描述 (output section description)
    • 一个 section 叠加描述 (overlay description)

    为了方便在 SECTIONS 命令中使用位置计数器,允许在 SECTIONS 命令中使用 ENTRY 命令和符号赋值。这也可以使链接器脚本更容易理解,因为您可以在输出文件布局中在有意义的位置使用这些命令。

     

    注意:如果整个链接脚本内没有 SECTIONS 命令, 那么 ld 将所有同名输入 section 合成到一个输出 section 内, 各输入 section 的顺序为它们被链接器发现的顺序。第一个节将在地址 0 处。如果某输入 section 没有在 SECTIONS 命令中提到, 那么该 section 将被直接拷贝成输出 section

    1)ENTRY 命令

    ENTRY 命令用来指定程序的入口点,即程序开始执行的位置。在链接器脚本中,ENTRY 命令通常位于 SECTIONS 块之外,直接放在链接器脚本的顶层。然而,在某些情况下,ENTRY 命令可能会放在 SECTIONS 块中,尽管这不是典型的用法。尽管不是常见做法,但在某些情况下,ENTRY 命令可以放在 SECTIONS 块中,这可能是为了实现特定的目的。例如,如果你想在一个特定的段中指定入口点,或者为了兼容某个特定的工具链或平台的要求,你可能会这样做。

    假设你有一个特殊的需要,希望确保程序的入口点 custom_entry 必须位于 .text 段中,并且在链接过程中能够正确处理这个入口点。你可以这样做:

    SECTIONS
    {
    .text : { ENTRY(custom_entry) *(.text) }
    .data : { *(.data) }
    .bss : { *(.bss) }
    }

    在这个示例中,ENTRY(custom_entry) 被放在了 .text 段的定义中。这通常不是一个推荐的做法,因为 ENTRY 指令最好位于 SECTIONS 块之外,以确保它被正确处理。

    2) 符号赋值语句

    在 SECTIONS 块内,你也可以使用符号赋值语句来定义符号及其值。尽管在 SECTIONS 块内定义符号不是最典型的用法,但在某些情况下,你可能需要这样做以满足特定的需求。下面是对 SECTIONS 块内符号赋值的总结:

    • 对符号的赋值只对全局变量起作用;
    • 赋值语句包含 4 个语法元素:符号名、操作符、表达式、分号,一个也不能少;
    • .  是一个特殊的符号,它是定位计数器,一个位置指针,指向程序地址空间内的某位置。该符号只能在 SECTIONS 命令内使用。

    赋值语句可以出现在链接脚本的三个地方:

    • SECTIONS 命令内;

    • SECTIONS 命令内的 section 描述内;

    • 全局位置。

    示例如下:

    floating_point = 0;   /* 全局位置 */
    SECTIONS {
    . = ALIGN(4);
    .text :
    {
    sram/*(.text*)
    sram/*(.data*)
    sram/*(.rodata*)
    *(.reset_patch)
    *(.sram_text)
    *(.sram)
    } > sram

    . = ALIGN(0x8000);
    .data :
    {
    *(.text*)
    . = ALIGN(4); /* section 描述内 */
    *(.data*)
    *(.rodata*)
    . = ALIGN(16);
    } > ddram

    . = ALIGN(4); /* SECTIONS 命令内 */
    .bss :
    {
    . = ALIGN(4);
    __bss_start__ = .;
    *(.bss*)
    *(COMMON)
    . = ALIGN(4);
    __bss_end__ = .;
    } > ddram
    }

    3) 输出 section 的描述

    一个完整的输出节的描述格式如下:

    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] [,]

    链接脚本本质就是描述输入和输出。上述字段解析如下:

    • SECTION: 节的名称,例如  .text.data, 或  .bss 等。

    • ADDRESS: 节的起始地址,如果指定,链接器会将节放置在该地址。

    • Type:节的类型,常见类型如下:

      • NOLOAD:该段被标记为不可加载, 所以在程序运行时不会加载到内存中;

      • DSECTCOPYINFOOVERLAY:为了向后兼容,支持这些类型名,因此很少使用。它们都具有相同的效果:应该将该节标记为不可分配的,这样在程序运行时就不会为该节分配内存;

    • AT(LMA)AT 用表达式做参数,指定实际的加载内存地址(Load Memory Address, LMA);LMA 表示节的加载内存地址,编译后的程序在内存中的起始位置,即数据实际存储的内存地址;

    • ALIGN(SECTION_ALIGN) | ALIGN_WITH_INPUT:指定节的对齐方式:

      • SECTION_ALIGN 是具体的对齐值,例如:ALIGN(4)  表示段对齐到 4 字节边界;

      • ALIGN_WITH_INPUT 指示该节应该与输入文件中的对应节具有相同的对齐方式;

    • SUBALIGN(SUBSECTION_ALIGN)SUBALIGN 用于指定子节的对齐方式,SUBSECTION_ALIGN 是子节的对齐值;

    • CONSTRAINT:一个约束条件,用于限制节的位置或大小等属性;

    • { OUTPUT-SECTION-COMMAND ... }:这部分是节的主体,包含了一系列的输出节命令,用于定义该节应包含哪些输入节(例如,特定的代码或数据块)、如何处理这些输入节(如合并、重定位等),以及节内的任何特定布局需求。

    • >REGIONREGION 指定该节应该被放置在哪个内存区域,表示存储器的种类,一般通过 MEMORY 指令定义(如RAM、ROM等)。这里的 REGION 又被称为 VMA(Virtual memory address ) ,即虚拟内存地址;

    • AT>LMA_REGION:类似于 AT(LMA),但是 AT>  使用内存区域名做参数,这里 LMA_REGION 是指定的内存区域名,用于进一步控制节的加载地址。

       

      拓展:VMA 与 LMA 区别

       

      通常 VMA 和 LMA 是相同的。VMA 是执行输出文件时 section 所在的地址,而 LMA 是加载输出文件时 section 所在的地址。一般而言,某 section 的 VMA == LMA。但在嵌入式系统中,经常存在加载地址和执行地址不同的情况:例如,将输出文件加载到开发板的 flash 中 (由 LMA 指定), 而运行时将位于 flash

       

      中的输出文件复制到 SDRAM 中 (由 VMA 指定) 。

    • :PHDR :PHDR ...:指定哪些节应该包含在程序头表中(Program Header, PHDR)。多个:PHDR 则表示该节包含在多个程序头表中。程序头表使用 PHDRS 命令定义。如果一个节(section)会被分配给多个段 (segment),那么通过显示的指明 :PHDR 可以改变可分配节被分配给段的顺序。使用 :NONE 告诉链接器不要将该节分配给任何段。下面一个简单的例子:

      PHDRS {
      interp PT_INTERP;
      text PT_LOAD;
      }
      SECTIONS {
      .interp : { *(.interp) } :text :interp
      .text : { *(.text) } :text
      }
       

      注意:一旦在链接脚本内使用了 PHDRS 命令,那么链接器仅会创建 PHDRS 命令指定的信息,所以使用时须谨慎。

    • =FILLEXP:你可以使用 =FILLEXP 来设定填充模式,FILLEXP 是一个表达式。任何没有指定的输出节的内存区域(比如,因为输入节的对齐要求而产生的裂缝)会被填入这个值。如果填充表达式是一个简单的十六进制值, 比如, 一个以 0x 开始的十六进制数字组成的字符串, 并且尾部不是 k 或 M,那一个任意的十六进制数字长序列可以被用来指定填充样式;前导零也变为样式的一部分。对于所有其他的情况,包含一个附加的括号或一元操作符 +,那填充样式是表达式的最低四字节的值。在所有的情况下,数值是 big-endian。下面是一个例子:

      SECTIONS { .text : { *(.text) } =0x90909090 }
       

      注意:可能使用 FILL 命令来为当前节设置填充样式。它后面跟有一个括号中的表达式。任何未指定的节内内存区域(比如,因为输入节的对齐要求而造成的裂缝)会以这个表达式的值进行填充。一个 FILL 语句会覆盖到它本身在节定义中出现的位置后面的所有内存区域;通过引入多个 FILL 语句,你可以在输出节的不同位置拥有不同的填充样式。这个例子显示如何在未被指定的内存区域填充 0x90FILL(0x90909090)FILL 命令跟输出节的 =FILLEXP 属性相似,但它只影响到节内跟在 FILL 命令后面的部分,而不是整个节。如果两个都用到了,那 FILL 命令优先。

    4) 叠加 section 的描述

    覆盖描述提供了一种简单的方法来描述要作为单个内存映像的一部分加载但要在同一内存地址上运行的部分。在运行时,某种类型的覆盖管理器将根据需要将覆盖的部分复制到运行时内存地址中或从运行时内存地址中复制出来,可能只是通过简单地操作寻址位。例如,当内存的某个区域比另一个区域快时,这种方法可能很有用。

    使用 OVERLAY 命令描述覆盖。OVERLAY 命令在 SECTIONS 命令中使用,就像输出部分描述一样。OVERLAY 命令的完整语法如下:

    OVERLAY [START] : [NOCROSSREFS] [AT ( LDADDR )]
    {
    SECNAME1
    {
    OUTPUT-SECTION-COMMAND
    OUTPUT-SECTION-COMMAND
    ...
    } [:PHDR...] [=FILL]
    SECNAME2
    {
    OUTPUT-SECTION-COMMAND
    OUTPUT-SECTION-COMMAND
    ...
    } [:PHDR...] [=FILL]
    ...
    } [>REGION] [:PHDR...] [=FILL…]

    除了 OVERLAY 关键字, 所有的都是可选的, 每一个节必须有一个名字(上面的 SECNAME1 和 SECNAME2)。在 OVERLAY 结构中的节定义跟通常的 SECTIONS 结构中的节定义是完全相同的,除了一点,就是在 OVERLAY 中没有地址跟内存区域的定义。

    • 节都被定义为同一个开始地址

      这些节都用相同的起始地址(VMA)定义。节的加载地址是这样安排的,它们在内存中是连续的,从用于 OVERLAY 的加载地址作为一个整体开始(与正常的节定义一样,加载地址是可选的,默认为开始地址;起始地址也是可选的,默认为位置计数器的当前值)。

    • 如果使用NOCROSSREFS 关键字,并且节之间有任何引用,则链接器将报告错误。由于所有节都运行在相同的地址,因此一个节直接引用另一个节通常是没有意义的。

    • 对于 OVERLAY 中的每个部分,链接器自动提供两个符号。符号 __load_start_secname 被定义为该 section 的起始加载地址。符号__load_stop_secname 被定义为该 section 的最终加载地址。secname 中任何在 C 标识符中不合法的字符都将被删除。C (或汇编)代码可以根据需要使用这些符号来移动覆盖的部分。

    • 在覆盖结束时,位置计数器的值被设置为覆盖的起始地址加上最大部分的大小。

    这里有一个例子。记住,这将出现在一个 SECTIONS 构造中。

     OVERLAY 0x1000 : AT (0x4000)
    {
    .text0 { o1/*.o(.text) }
    .text1 { o2/*.o(.text) }
    }

    这段代码会定义 .text0 和 .text1,它们都从地址 0x1000 开始。.text0 会被载入到地址 0x4000 处,而 .text1 会被载入到紧随 .text0 后的位置。下面的几个符号会被定义: __load_start_text0__load_stop_text0, __load_start_text1__load_stop_text1

    拷贝 .text1 到覆盖区域的 C 代码看上去可能会像下面这样:

    extern char __load_start_text1, __load_stop_text1;
    memcpy ((char *) 0x1000, &__load_start_text1,
    &__load_stop_text1 - &__load_start_text1);

    注意 OVERLAY 命令只是为了语法上的便利, 因为它所做的所有事情都可以用更加基本的命令加以代替。上面的例子可以用下面的完全特效的写法:

      .text0 0x1000 : AT (0x4000) { o1/*.o(.text) }
    PROVIDE (__load_start_text0 = LOADADDR (.text0));
    PROVIDE (__load_stop_text0 = LOADADDR (.text0) + SIZEOF (.text0));
    .text1 0x1000 : AT (0x4000 + SIZEOF (.text0)) { o2/*.o(.text) }
    PROVIDE (__load_start_text1 = LOADADDR (.text1));
    PROVIDE (__load_stop_text1 = LOADADDR (.text1) + SIZEOF (.text1));
    . = 0x1000 + MAX (SIZEOF (.text0), SIZEOF (.text1));

    6. MEMORY

    链接器的默认配置允许分配所有可用内存。可以使用 MEMORY 命令覆盖该配置。MEMORY 命令描述目标中内存块的位置和大小,通过仔细使用它,您可以描述链接器可以使用哪些内存区域,以及它必须避免使用哪些内存区域。链接器不会打乱部分以适应可用的区域,而是将请求的部分移动到正确的区域,并在区域太满时发出错误。一个命令文件最多只能包含一次对 MEMORY 命令的使用;但是,您可以根据需要在 MEMORY 中定义任意数量的内存块。

    MEMORY 块对于定义不同节的内存位置非常重要,特别是当你要在特定地址加载代码或数据时。通常每个区域都有一个名称、类型和大小。基本语法如下:

    MEMORY
    {
    NAME1 [(ATTR)] : ORIGIN = ORIGIN1, LENGTH = LEN1
    NAME2 [(ATTR)] : ORIGIN = ORIGIN2, LENGTH = LEN2
    }

    MEMORY 命令定义了存储空间。

    • NAME:内存区域的名字,每一块内存区域必须有一个唯一的名字;

    • ATTR:定义该存储区域的属性。ATTR 属性内可以出现以下 7 个字符:

      • R  只读 section

      • W  读/写 section

      • X  可执行 section

      • A  可分配的 section

      • I  初始化了的 section

      • L  同I

      • !  反转以上任何属性的含义

    • ORIGIN:物理内存中区域的起始地址。它是一个表达式,必须在执行内存分配之前求值为常数。关键字 ORIGIN 可以缩写为 org 或 o(但不能写成 ORG);

    • LENGTH:以字节为单位的地址空间的长度,可缩写为lenl

    MEMEORY 定义示例如下:

    MEMORY
    {
    rom (rx) : ORIGIN = 0, LENGTH = 256K /* 只读和可执行存储器rom,起始地址0,大小256K字节 */
    ram (!rx) : org = 0x40000000, l = 4M /* 不可读且不能执行存储器ram,其地址0X40000000,大小4M字节 */
    }

    一旦定义了一个名为 mem 的内存区域,就可以在 SECTIONS 命令中使用以 >mem 结尾的命令,将特定的输出部分定向到该区域。如果指向某个区域的组合输出部分对于该区域来说太大,则链接器将发出错误消息。示例如下:

    ENTRY(_start) /* 程序入口点 */

    MEMORY /* 定义存储区 */
    {
    ROM (rx) : ORIGIN = 0X00000000, LENGTH = 2M
    RAM (rw) : ORIGIN = 0X30000000 , LENGTH = 64M
    }

    SECTIONS
    {
    . = 0; /* 程序起始地址 */
    .text BLOCK(4) (NOLOAD): { /* 块4字节对齐, 不加载 */
    *(.text)
    } > ROM /* 存放在ROM中,ROM起始地址为MEMORY定义的 */

    .rodata : { /* 只读数据节 */
    *(.rodata)
    *(.rodata.*)
    . = ALIGN(4); /* 进行4字节对齐 */
    } > ROM /* 同样存放在ROM中,紧挨着上面的.text存放 */

    load_addr = LOADADDR(.rodata) + SIZEOF(.rodata); /* 计算当前的加载地址, 相当于load_addr = .; */
    .data : AT(load_addr) { /* .data存放在ROM中load_addr处 */
    . = ALIGN(4);
    __data_start = .;
    *(.data)
    *(.data.*)
    __data_end = .; /* 用于重定向的时候操作标志符号 */
    } > RAM /* 其实这里的RAM其实就相当于指定了运行地址.相当于startaddr */


    .bss : { /* 同.data,紧挨着存放 */
    . = ALIGN(4);
    __bss_start = .;
    *(.bss)
    *(.COMMON)
    __bss_end = .;
    } > RAM
    }

    7. PHDRS

    在链接器脚本中,PHDRS 命令用于定义程序头表(Program Header Table)。程序头表描述了如何将 ELF 文件映射到内存中,并包含了段的属性信息,如段的起始地址、长度、权限等。通过使用 PHDRS 命令,您可以定义多个程序头表条目,从而控制段的加载方式。

    PHDRS 命令的基本语法如下:

    PHDRS
    {
    NAME TYPE [ FILEHDR ] [ PHDRS ] [ AT ( ADDRESS ) ] [ FLAGS ( FLAGS ) ] ;
    }

    其中FILEHDRPHDRSATFLAGS为关键字。

    • NAME:为程序段名,此名字可以与符号名、section 名、文件名重复,因为它在一个独立的名字空间内。此名字只能在 SECTIONS 命令内使用;一个程序段可以由多个可加载的 section 组成。通过输出 section 描述的属性:PHDRS 可以将输出 section 加入一个程序段,: PHDRS 中的 PHDRS 为程序段名。在一个输出 section 描述内可以多次使用 :PHDRS 命令,也即可以将一个 section 加入多个程序段。如果在一个输出 section 描述内指定了 :PHDRS 属性,那么其后的输出 section 描述将默认使用该属性,除非它也定义了 :PHDRS 属性。显然当多个输出 section 属于同一程序段时可简化书写;

    • FILEHDR:在 TYPE 属性后存在 FILEHDR 关键字,表示该段包含 ELF 文件头信息;

    • PHDRS:表示该段包含 ELF 程序头信息;

    • TYPETYPE 可以是以下八种形式:

      • PT_NULL(0) :表示未被使用的程序段

      • PT_LOAD(1) :表示该程序段在程序运行时应该被加载

      • PT_DYNAMIC(2) :表示该程序段包含动态连接信息

      • PT_INTERP(3) :表示该程序段内包含程序加载器的名字,在 Linux 下常见的程序加载器是 ld-linux.so.2

      • PT_NOTE(4) :表示该程序段内包含程序的说明信息

      • PT_SHLIB(5) :一个保留的程序头类型,没有在 ELF ABI 文档内定义

      • PT_PHDR(6) :表示该程序段包含程序头信息。

      • EXPRESSION:表达式值,以上每个类型都对应一个数字,该表达式定义一个用户自定的程序头。

    • AT(ADDRESS) :定义该程序段的加载位置(LMA),该属性将覆盖该程序段内的 section 的 AT() 属性;

    • FLAGS:链接器通常会基于组成段的节来设置段属性。你可以通过使用 FLAGS 关键字来显式指定段标志。 FLAGS 的值必须是一个整型值。它被用来设置程序头的 p_flags域;

    下面是一个关于 PHDRS 的例子。它展示一个在纯 ELF 系统上的一个标准的程序头设置。

    PHDRS
    {
    headers PT_PHDR PHDRS ;
    interp PT_INTERP ;
    text PT_LOAD FILEHDR PHDRS ;
    data PT_LOAD ;
    dynamic PT_DYNAMIC ;
    }
    SECTIONS
    {
    . = SIZEOF_HEADERS;
    .interp : { *(.interp) } :text :interp
    .text : { *(.text) } :text
    .rodata : { *(.rodata) } /* defaults to :text */

    . = . + 0×1000; /* move to a new page in memory */
    .data : { *(.data) } :data
    .dynamic : { *(.dynamic) } :data :dynamic

    }

    这个示例定义了一个自定义的程序头(Program Header)布局,并展示了如何将特定的节(sections)映射到这些程序头中。下面是对脚本的详细解析:

    PHDRS 命令

    PHDRS 命令用于定义程序头表中的条目,这些条目将被用在 ELF 程序的程序头表中。在这个例子中,定义了以下几种类型的程序头:

    1. headers: 一个特殊的程序头,指向程序头表自身。
    2. interp: 用于动态链接的程序,指向程序的解释器(通常是  ld.so)。
    3. text: 包含程序的文本(代码)部分。
    4. data: 包含程序的初始化和未初始化数据部分。
    5. dynamic: 包含动态链接信息。

    SECTIONS 命令

    SECTIONS 命令定义了程序的内存布局和各个节的属性。

    1. . = SIZEOF_HEADERS;: 设置当前位置计数器(.)为头部大小,这是所有节的起始位置。

    2. .interp: 定义了 .interp 节,它包含了程序解释器的路径。这个节被映射到 :text 和 :interp 程序头中。:text 表示它与 text 程序头相关联,:interp 是自定义的程序头。

    3. .text: 定义了 .text 节,包含了程序的可执行代码,并映射到 :text 程序头。

    4. .rodata: 定义了 .rodata 节,包含了只读数据。默认情况下,它也映射到 :text 程序头。

    5. 接下来的代码 . = . + 0x1000; 将位置计数器增加 0x1000 字节,这通常是为了在内存中移动到新的一页,确保 .data 节按照页对齐。

    6. .data: 定义了 .data 节,包含了初始化的全局变量和静态变量,并映射到 :data 程序头。

    7. .dynamic: 定义了 .dynamic 节,包含了动态链接信息,并映射到 :data 和 :dynamic 程序头。:dynamic 是自定义的程序头,用于指定包含动态链接信息的段。

    程序头的映射

    • :TEXT, :DATA: 这些是伪程序头名称,用于将节映射到程序头。:TEXT  通常与代码相关,:DATA  与数据相关。
    • :interp  和  :dynamic  是在这个脚本中自定义的程序头名称,它们通过  PHDRS  命令定义,并在  SECTIONS  命令中使用。

    这个链接脚本通过 PHDRS 定义了程序头的布局,并通过 SECTIONS 将特定的节映射到这些程序头中。自定义的程序头名称允许更细粒度的控制程序的加载和执行。例如,自定义的 :interp 程序头可以用于指定解释器的加载位置,而 :dynamic 程序头可以用于指定动态链接信息的加载位置。

    8. PROVIDE

    在链接器脚本中,PROVIDE 命令是一个非常有用的指令,它用于在链接过程中提供全局符号。PROVIDE 命令允许您在链接器脚本中定义全局符号的值,这些符号可以在程序的其他部分引用。这对于解决某些符号未定义的情况特别有用,或者用于定义一些全局变量的默认值。PROVIDE 命令的基本语法如下:

    PROVIDE ( symbol = value );

    这里,symbol 是您要定义的全局符号,value 是该符号的初始值。PROVIDE 命令会在链接过程中为该符号提供一个定义,即使在其他地方没有定义该符号。

    PROVIDE 命令的特点:

    • 覆盖规则

      • 如果在其他地方已经定义了相同的符号,PROVIDE  命令提供的定义会被忽略。
      • 如果没有其他定义,PROVIDE  提供的定义就会生效。
    • 灵活性

      • PROVIDE  命令可以用来提供默认值或作为备用定义。
      • 如果需要在链接器脚本中定义某些符号的默认值,但又允许其他部分覆盖这些值,PROVIDE  是一个理想的选择。
    • 全局可见性

      • 通过  PROVIDE  命令定义的符号在整个链接过程中都是全局可见的。

    PROVIDE 命令的使用场景:

    • 提供全局变量的默认值

      • 在链接过程中,如果某个全局变量在源代码中被声明但未在任何地方定义(即,它是一个外部变量),链接器可能会报错,因为它不知道这个变量的地址。PROVIDE  指令可以在链接器脚本中为这个变量提供一个默认的地址(或说是“定义”),这样即使源代码中没有显式定义这个变量,链接也能成功进行。这个地址通常指向某个内存位置,该位置的内容将作为该全局变量的初始值。
    • 解决未定义的符号

      • 这与第一点相似,但更侧重于解决链接时遇到的未定义符号(可能是函数或变量)的问题。通过  PROVIDE,可以为这些未定义的符号指定一个默认的地址,从而避免链接错误。然而,需要注意的是,PROVIDE  通常用于变量,而对于函数,通常需要使用其他机制(如链接器脚本中的  ENTRY  指令或编译器提供的弱函数定义)来提供默认的实现。
    • 内存布局的控制

      • 在嵌入式系统或需要精确控制内存布局的应用中,PROVIDE  可以用来指定全局变量或特定内存区域的地址。这有助于确保数据或代码被放置在内存中的预期位置,这对于满足硬件要求或优化性能至关重要。

    PROVIDE  提供的地址通常是一个占位符,它告诉链接器在找不到符号定义时使用该地址。但是,如果链接器在后续的链接过程中找到了该符号的实际定义,它将使用实际定义中的地址,而不是  PROVIDE  指定的地址。这种机制允许开发者在需要时覆盖默认设置。

    下面让我们通过一个示例说明 PROVIDE 的作用:

    test.c 源文件:

    #include 

    extern int my_global_var;

    void init(void) {
    /* 定义一个指向该地址的指针 */
    volatile int* ptr = &my_global_var;

    /* 通过指针写入值 100 */
    *ptr = 0x64; // 现在,地址处的值被设置为 0x64
    }

    int main(void)
    {
    init();
    printf("Value of my_global_var: %d\n", my_global_var);

    return 0;
    }

    使用 ld -verbose > test.lds 把编译器默认的链接脚本输出到 test.lds 文件,修改链接脚本 (test.lds),增加 PROVIDE(my_global_var = .); 用于定义全局符号 my_global_vartest.lds 链接脚本文件部分内容如下:

    .data           :
    {
    PROVIDE(my_global_var = .); /* 新增使用PROVIDE定义 my_global_var 全局符号 */
    *(.data .data.* .gnu.linkonce.d.*)
    SORT(CONSTRUCTORS)
    }

    通过如下命令编译运行程序,如下:

    gcc -O0 test.c -T test.lds -o test -g -Wl,-Map=test.map

    这个命令使用 GCC 编译器,指定了优化级别为 0-O0),链接器脚本为  test.lds,输出文件为  test,启用了调试信息(-g),并生成了一个映射文件  test.map(通过  -Wl,-Map=test.map  传递给链接器)。运行结果如下:

    [root@localhost provider]# ls
    test test.c test.lds test.map
    [root@localhost provider]# cat test.map | grep my_global_var
    0x0000000000601020 PROVIDE (my_global_var = .)
    [root@localhost provider]# ./test
    Value of my_global_var: 100

    通过 test.map 文件可知 my_global_var  被分配到了地址  0x0000000000601020,当运行程序时,init  函数被调用,它修改了  my_global_var  的值为 100(十六进制表示为  0x64)。然后,main  函数打印出这个值,与预期相符。

    这个程序成功地使用了链接器脚本中的  PROVIDE  指令来定义一个全局变量  my_global_var,并在运行时通过指针修改了这个变量的值。展示了如何在没有显式在源代码中分配内存的情况下,通过链接器脚本来定义全局变量的地址,并在程序中通过指针来访问和修改这个变量的值。

     

    注意:经过测试,实际上不加 PROVIDE 关键字,在链接文件中直接定义的变量(符号)也可以在目标文件中直接使用。在链接文件中直接定义变量与加 PROVIDE 关键字区别如下:

    1. 可见性和强制性:直接在链接脚本中定义符号是强制性的,它会在链接脚本的当前位置创建一个符号。而 PROVIDE 则是可选的,它只在需要时才“提供”符号,如果链接器在其他地方找到了这个符号的定义,它就不会使用 PROVIDE 提供的地址。

    2. 灵活性PROVIDE 提供了更高的灵活性,因为它允许链接器在需要时“填补”符号的空白,而不是强制在链接脚本的某个具体位置定义它。

    3. 用途:直接在链接脚本中定义符号通常用于指定内存布局中的固定点,如段的起始地址。而 PROVIDE 则更适用于处理可选的或条件性的符号定义,以避免链接错误。

    因此,在选择使用哪种方式时,你需要根据你的具体需求来决定。如果你需要确保某个符号在链接脚本的特定位置被定义,那么直接在链接脚本中定义它可能是更好的选择。如果你希望链接器能够更灵活地处理符号的存在与否,那么使用 PROVIDE 可能更合适。

    9. REGION_ALIAS

    REGION_ALIAS 命令用于在链接器脚本中为已定义的内存区域创建别名,使得在链接器脚本中可以使用更有意义的名字来引用这些区域。这样做的好处包括提高脚本的可读性、可维护性,并简化脚本的编写。REGION_ALIAS 的基本语法如下:

    REGION_ALIAS("别名", 已定义的内存区域);
    • "别名":给内存区域定义一个新的名称。
    • 已定义的内存区域:在  MEMORY  块中定义的内存区域名称。

    下面是一个详细的示例,展示了如何使用 REGION_ALIAS 来定义别名,并在脚本中引用这些别名。

    链接脚本 test.lds

    MEMORY
    {
    RAM : ORIGIN = 0, LENGTH = 4M
    }

    SECTIONS
    {
    .text :
    {
    *(.text)
    } > RAM
    .rodata :
    {
    *(.rodata)
    rodata_end = .;
    } > RAM
    .data : AT (rodata_end)
    {
    data_start = .;
    *(.data)
    } > RAM
    data_size = SIZEOF(.data);
    data_load_start = LOADADDR(.data);
    .bss :
    {
    *(.bss)
    } > RAM
    }

    这个里面,我们定义了一个内存区域,叫做 RAM;上面的链接脚本,所有的东西都被 Layout 到 RAM 中;试想一下,如果现在需要将 .text 和 .rodata 节放到另一个内存区域 ROM,岂不是要将每个需要放到 ROM 中的节后的 > RAM 都修改一遍?这里只修改两处内容,改起来工作量还好,如果需要修改更多处位置呢?每次改起来岂不是很麻烦!

    假如我们使用 REGION_ALIAS 定义了 4 个别名:REGION_TEXT、REGION_RODATA、REGION_DATA、REGION_BSS;并在 SECTIONS 中,引用这些别名,可以使得我们的链接脚本则会更加健壮!修改后的链接脚本 test.lds 如下:

    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);

    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
    }

    如果使用这种方式,就不用每次都修改各个节后的 > REGION_XXX,仅直接改这个存储描述文件的如下部分即可:

    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);

    SECTIONS 中的内容则无需任何修改,这样当链接脚本内容较多时,工作量是不是比原来小多了?

    通过使用 REGION_ALIAS,您可以使链接器脚本更加简洁、易读和易于维护。这对于大型项目尤其重要,因为良好的组织结构可以显著减少错误并提高开发效率。REGION_ALIAS 使得脚本更加模块化和灵活,从而提高了开发者的生产力。

    10. HIDDEN

    HIDDEN 命令用于将符号的可见性设置为隐藏,这样符号就只在本模块内可见,不会暴露给外部模块。这在控制符号范围和避免命名冲突时非常有用。当我们说 HIDDEN (symbol = expression) 时,这意味着我们正在定义一个隐藏的符号,并为其赋值。下面我们将详细介绍 HIDDEN 命令的使用方式,并通过具体例子来说明其应用。语法如下:

    HIDDEN (symbol_name)
    • symbol_name:要隐藏的符号名称。
    • expression:为符号赋值的表达式。

    HIDDEN 命令的作用:

    1. 隐藏符号
    • 通过  HIDDEN  命令定义的符号在最终的输出文件中是不可见的,这意味着外部代码无法直接访问这些符号。
  • 赋值
    • 可以为隐藏的符号指定一个初始值,这个值可以是常量或表达式的结果。

    示例如下:

    SECTIONS
    {
    .text : {
    HIDDEN(hidden_symbol = .);
    *(.text)
    HIDDEN(hidden_end = .);
    } > FLASH
    }

    在这个例子中:hidden_symbol  和  hidden_end  是隐藏的符号,只在本模块内可见。

    11. PROVIDE_HIDDEN

    PROVIDE_HIDDEN 命令同 PROVIDE,区别是 PROVIDE 定义的符号是全局符号,PROVIDE_HIDDEN 定义的符号只在本脚本中可见,PROVIDE_HIDDEN  结合了  PROVIDE  和  HIDDEN  的功能,用于有条件地定义一个隐藏的符号。如果符号在其他地方已经定义,则不会重新定义它。语法如下:

    PROVIDE_HIDDEN(symbol = expression);
    • symbol  是符号的名称。
    • expression  是要赋给符号的值。

    示例如下:

    SECTIONS
    {
    .text : {
    PROVIDE_HIDDEN(__hidden_start = .);
    *(.text)
    PROVIDE_HIDDEN(__hidden_end = .);
    } > FLASH
    }

    在这个例子中,__hidden_start  和  __hidden_end  将在没有其他定义的情况下被赋值,并且这些符号是隐藏的,只在本模块内可见。

    12. KEEP

    KEEP 命令用于在链接过程中保留某些符号或节,即使它们看起来不是必需的也会被包含在最终的输出文件中。这通常用于保留调试信息、未使用的代码节或特殊配置项等。KEEP 命令的基本语法如下:

    KEEP (expression)
    • expression:需要保留的符号或节的表达式。

    KEEP 命令通常用于以下场景:

    • 保留调试信息:确保调试信息不会被链接器优化掉。
    • 保留未使用的代码段:确保某些未使用的代码段不会被链接器删除。
    • 特殊配置项:保留某些特定的配置项或初始化代码。

    当你使用 GCC(或类似的编译器)并希望启用链接器节(section)垃圾收集(-Wl,--gc-sections)来优化最终的可执行文件或库大小时,你可能会希望保留某些特定的节或符号。示例如下:

    源文件 test.c :

    #include 

    // 初始化函数,通常在程序启动时调用
    void __attribute__((constructor)) init_func(void) {
    printf("Initialization function called.\n");
    }

    // 不常使用的函数,可能会被编译器优化掉
    void unused_func(void) {
    printf("This function is not often used.\n");
    }

    int main(void) {
    printf("Main function called.\n");
    return 0;
    }

    链接脚本 linker.lds

    ENTRY(_start)

    SECTIONS
    {

    . = 0x2000;
    .rela.plt :
    {
    *(.rela.plt)
    }
    . = 0x1000;
    .text . : { *(.text) }

    .data . : { *(.data)}

    .bss : { *(.bss) }

    _bss_end = (. + 0x20000 - 1) / 0x20000 * 0x20000;
    __init_array_start = .;
    __init_array_end = .;
    /DISCARD/ : {*(.note.*)}
    }

    基于上面代码和链接脚本,编译生成可执行文件如下:

    [root@localhost keep]# gcc -O2 -ffunction-sections -fdata-sections -o test test.c -Wl,--gc-sections -Tlinker.lds
    /usr/bin/ld: warning: .note.gnu.build-id section discarded, --build-id ignored.
    [root@localhost keep]# readelf -s test | grep unused_func

    在上面编译命令中:

    • -O2  启用了优化,这可能会导致未使用的函数被优化掉。
    • -ffunction-sections  和  -fdata-sections  选项告诉编译器将每个函数和变量分别放入自己的节中,这样链接器就可以单独地对它们进行垃圾收集。
    • -Wl,--gc-sections  启用了链接器的垃圾收集功能。
    • -Tlinker.lds  指定了链接脚本。

    紧接着,在生成的可执行文件 test 中检索函数符号 unused_func ,发现并未包含其中,可见使用 -O2 编译选项确实导致未使用的函数被优化掉了。如果我不想让 unused_func 函数被优化掉,该怎么办呢?这就得使用我们这里提到的 KEEP 命令了。让我们修改链接脚本和源码文件。

    修改后的 linker.lds

    ENTRY(_start)

    SECTIONS
    {

    . = 0x2000;
    .rela.plt :
    {
    *(.rela.plt)
    }
    . = 0x1000;
    .text . : { *(.text) }

    /* 保留未使用的函数 */
    .text_unused :
    {
    KEEP(*(.text_unused))
    }

    .data . : { *(.data)}

    .bss : { *(.bss) }


    _bss_end = (. + 0x20000 - 1) / 0x20000 * 0x20000;
    __init_array_start = .;
    __init_array_end = .;
    /DISCARD/ : {*(.note.*)}
    }

    在这个链接脚本中,我们创建了一个名为 .text_unused 的新节,并使用 KEEP 命令来保留其中的符号。在 C 代码中,我们将 unused_func 函数标记为放在 .text_unused 节中。

    修改后的 test.c

    #include 

    // 初始化函数,通常在程序启动时调用
    void __attribute__((constructor)) init_func(void) {
    printf("Initialization function called.\n");
    }

    // 不常使用的函数,可能会被编译器优化掉
    void __attribute__((section(".text_unused"))) unused_func(void) {
    printf("This function is not often used.\n");
    }

    int main(void) {
    printf("Main function called.\n");
    return 0;
    }

    在这个修改后的 C 代码中,我们使用 __attribute__((section(".text_unused"))) 将 unused_func 函数放入 .text_unused 节中,这样链接脚本中的 KEEP 命令就可以保留这个函数。

    编译时,我们需要确保 C 代码中的函数被放置到正确的节中,并且使用链接脚本来控制最终的二进制文件。

    [root@localhost keep]# gcc -O2 -ffunction-sections -fdata-sections -o test test.c -Wl,--gc-sections -Tlinker.lds
    /usr/bin/ld: warning: .note.gnu.build-id section discarded, --build-id ignored.
    [root@localhost keep]# ls
    linker.lds test test.c test.map
    [root@localhost keep]# readelf -s test | grep unused_func
    52: 00000000000011d0 10 FUNC GLOBAL DEFAULT 8 unused_func

    紧接着,在生成的可执行文件 test 中检索函数符号 unused_func ,发现包含其中,说明我们的策略生效了,KEEP 命令在链接过程中保留了我们的节和其中的符号。通过这种方式,即使启用了垃圾收集,unused_func 函数也会被保留在最终的二进制文件中。

    13. LOADADDR

    LOADADDR 是一个内置函数,用于获取指定节的加载地址,这个地址是节在程序内存映射中的位置,而不是它在可执行文件中的位置。这个函数非常有用,特别是在需要计算节的大小或者在节之间需要保持某种特定关系的时候。与 AT 不同的是,LOADADDR 不用于指定加载地址,而是获取已经设置的加载地址。LOADADDR 命令的基本语法如下:

    LOADADDR(section)
    • section:是节的名称,例如  .text.data  或者用户定义的节。。
     

    拓展:加载地址与运行地址

    • 加载地址:加载器决定将程序加载到内存的哪个位置。这个位置是程序在内存中的起始地址,称为加载地址。
    • 运行地址:程序在内存中实际执行时的地址。在没有地址空间布局随机化(ASLR)的情况下,运行地址通常与加载地址相同。

    假设我们有一个嵌入式系统的应用程序,我们希望 .text 节加载到特定的内存地址,比如 0x08000000。我们可以使用 LOADADDR 来获取这个地址:

    MEMORY //定义存储区
    {
    ROM (rx) : ORIGIN = 0x08000000, LENGTH = 2M
    RAM (rw) : ORIGIN = 0X30000000 , LENGTH = 64M
    }

    ENTRY(_start) //开始地址,这个不一定正确

    SECTIONS
    {
    . = 0; //程序起始地址
    .text : AT(ROM) { *(.text) } > RAM
    load_addr = LOADADDR(.text) + SIZEOF(.text); //计算当前的加载地址, 相当于load_addr = .;
    .data : AT(load_addr) { //.data存放在ROM中load_addr处
    . = ALIGN(4);
    __data_start = .;
    *(.data)
    *(.data.*)
    __data_end = .; //用于重定向的时候操作标志符号
    } > RAM //其实这里的RAM其实就相当于指定了运行地址.相当于startaddr


    .bss : { //同.data,紧挨着存放
    . = ALIGN(4);
    __bss_start = .;
    *(.bss)
    *(.COMMON)
    __bss_end = .;
    } > RAM
    }

    在这个例子中,LOADADDR 用于创建一个符号 load_addr,该符号代表了 .data 节的加载地址。这个符号可以在程序中使用,例如,用于初始化代码,或者在启动时将数据从 ROM 复制到 RAM

    LOADADDR 是链接脚本中的一个有用工具,它可以帮助开发者在复杂的内存布局中管理和引用节的加载地址。通过在链接脚本中使用 LOADADDR,可以确保程序的各个部分被加载到预期的内存位置。

    14. 其它命令简介

    1) 节命令

    (1) ALIGN

    在链接脚本中,ALIGN 命令用于确保节(section)的内容在内存中按照特定的边界对齐。对齐可以提高内存访问的效率,因为许多处理器在访问对齐的内存地址时会有更快的访问速度。此外,一些硬件平台可能要求特定的数据结构必须对齐到特定的边界。ALIGN 命令的基本语法如下:

    . = ALIGN(value);
    • .  是一个特殊的符号,代表当前节的地址。
    • value  是一个整数,表示对齐的边界。这个值通常是 2 的幂次方,如 4、8、16、32等。

    ALIGN 命令的作用是将当前位置计数器,即. 调整到最接近的、大于当前地址的 value 的倍数。这样,接下来的数据或代码将从这个对齐的地址开始。

    假设你有一个链接脚本,你希望确保 .data 节的内容在 8 字节边界上对齐:

    SECTIONS
    {
    .data :
    {
    *(.data)
    . = ALIGN(8);
    *(.data.*)
    } > RAM
    }

    在这个例子中,. = ALIGN(8); 命令确保 .data 节的内容在 8 字节边界上对齐。这意味着,即使 .data 节中的数据大小不是 8 的倍数,链接器也会在节的末尾添加足够的填充(padding),以确保下一个数据或代码段从 8 的倍数地址开始。

    ALIGN 命令是链接脚本中的一个有用工具,它允许开发者在内存中精确控制节的对齐。这有助于优化程序的性能,同时确保程序符合硬件平台的要求。然而,使用 ALIGN 命令时需要考虑对齐带来的内存使用增加,以及对齐值对性能的实际影响。

    (2) FILL

    在链接脚本中,FILL 命令用于在指定的输出节中插入填充字节。这通常用于确保节之间的空间或者在特定的地址范围内创建一个特定的模式。FILL 命令的基本语法如下:

    FILL(value)
    • value  是一个表达式,表示要填充的字节值。如果  value  是单个字节(8位),则可以是一个介于 0 和 255 之间的整数。如果需要填充多字节值,可以使用  {long}  语法来指定一个长整型数。

    假设你想要在 .data 节和 .bss 节之间插入 32 字节的填充,你可以在链接脚本中这样写:

    SECTIONS
    {
    .data :
    {
    *(.data)
    } >RAM

    /* 插入32字节的填充 */
    FILL(32) >RAM

    .bss :
    {
    *(.bss)
    } >RAM
    }

    在这个例子中,FILL(32) 命令会在 .data 节和 .bss 节之间创建 32 字节的填充。填充的值默认为 0,除非你指定了其他值。

    FILL 命令是链接脚本中的一个有用工具,它允许你在输出文件的特定位置插入填充字节。这可以用于内存对齐、创建特定的内存模式或者简单地分隔不同的节。使用 FILL 命令时,需要确保你了解填充的用途和目标平台的内存布局要求。

    (3) /DISCARD/

    在链接脚本中,/DISCARD/ 是一个特殊的节,它用于指示链接器应该丢弃所有分配给这个节的内容。任何放入 /DISCARD/ 节的内容都不会被包含在最终的输出文件中,相当于被“丢弃”了。

    /DISCARD/ 节通常用于以下几种情况:

    1. 移除未使用的符号: 如果链接器在进行垃圾收集(garbage collection)时需要丢弃未使用的符号,可以将这些符号放入 /DISCARD/ 节。

    2. 移除调试信息: 为了减小最终二进制文件的大小,可以将所有的调试信息(如 .debug.line 等节)放入 /DISCARD/ 节。

    3. 移除注释: 如果不需要保留注释信息(如 .comment 节),也可以将其放入 /DISCARD/ 节。

    4. 移除特定节: 有时候,你可能想要丢弃特定的节,比如 .note 节或者其他任何不需要的节。

    以下是一个链接脚本的示例,展示了如何使用 /DISCARD/ 节:

    SECTIONS
    {
    .text : {
    *(.text)
    }
    .data : {
    *(.data)
    }
    .bss : {
    *(.bss)
    }
    /* 丢弃所有调试信息 */
    /DISCARD/ : {
    *(.debug*)
    *(.note*)
    *(.comment)
    }
    }

    在这个例子中,所有以 .debug.note 和 .comment 开头的节都会被放入 /DISCARD/ 节,因此它们不会出现在最终的输出文件中。

     

    注意

    • 使用 /DISCARD/ 时,确保你不会意外地丢弃需要的内容。
    • 在某些情况下,你可能想要保留调试信息或其他特殊节,因此在使用 /DISCARD/ 时要谨慎。
    • /DISCARD/ 节通常与其他链接脚本命令结合使用,以实现更复杂的链接逻辑。

    /DISCARD/ 是链接脚本中的一个有用工具,它允许你控制哪些内容应该被包含在最终的输出文件中,哪些应该被丢弃。这可以用于优化输出文件的大小,或者在不需要某些信息时移除它们。在使用 /DISCARD/ 时,应该仔细考虑哪些内容是真正不需要的,以避免意外地丢弃重要信息。

    2) 符号命令

    (1) ASSERT

    在链接脚本中,ASSERT 命令用于在链接时检查某个条件是否为真。如果条件为假(即检查失败),链接器会报错并停止链接过程。这通常用于确保程序的内存使用不超过特定限制,或者确保某些符号在预期的地址范围内。ASSERT 命令的一般语法如下:

    ASSERT(expression, message);
    • expression  是一个在链接时评估为真或假的表达式。
    • message  是一个字符串,当表达式评估为假时,链接器会显示这个消息。

    示例如下:

    ASSERT(_end <= 0x20008000, "Not enough RAM");

    这行命令的意思是:

    • _end  通常是程序中最后一个使用的内存地址的符号,它由链接器在处理  .bss.data  等节后自动计算得出。
    • 0x20008000  是一个十六进制数,表示一个内存地址,这里假设是 RAM 的最大地址。
    • ASSERT  命令检查  _end  是否小于或等于  0x20008000。如果是,条件为真,链接过程继续。如果不是,条件为假,链接器会报错,并显示消息 "Not enough RAM"

    这个 ASSERT 命令的作用是确保程序的总内存使用量不超过 RAM 的大小。如果程序的末尾地址 _end 超过了 RAM 的最大地址 0x20008000,这意味着程序需要的内存超出了可用的 RAM,链接器会报错,提示内存不足。

    ASSERT 命令在链接脚本中用于条件检查,可以帮助确保程序的内存布局符合预期。

    (2) EXTERN

    在链接脚本中,EXTERN 命令用于声明外部符号,这些符号在链接过程中需要被解析,但它们的定义不在当前的链接脚本或对象文件中。通常,这些外部符号会在其他对象文件或库中定义,或者在程序的其他部分定义。EXTERN 命令的基本语法如下:

    EXTERN symbol [, symbol ...];
    • symbol  是一个或多个要在链接过程中解析的外部符号的名称。

    EXTERN 命令的作用是告诉链接器,即使在当前的链接单元中没有定义这些符号,也应该在链接过程中保留这些符号。

    假设你有一个全局变量 global_var 和一个函数 global_func,它们在另一个源文件或库中定义。你可以在链接脚本中这样声明它们:

    EXTERN global_var;
    EXTERN global_func;

    这样,链接器在处理链接脚本时会知道 global_var 和 global_func 是外部符号,需要在链接过程中解析。可能有人会思考,一般这种不是需要在源文件中用 extern 声明吗?源文件中用 extern 声明了,为什么还要在链接脚本中用 EXTERN ?

    因为在某些情况下,即使全局变量或函数在源代码中被声明为 extern,链接器在处理多个对象文件和库时可能会因为优化而移除未引用的符号。在链接脚本中使用 EXTERN 可以确保这些符号在链接过程中被保留。

    EXTERN 命令是链接脚本中的一个有用工具,它允许你在链接过程中声明和保留外部符号。这在处理大型项目或使用外部库时尤其重要,因为它确保了所有必要的符号都能被正确解析。

    3) 文件命令

    (1) INCLUDE

    在链接脚本中,INCLUDE 命令用于包含另一个链接脚本文件的内容。这使得链接脚本的编写更加模块化,允许将复杂的链接逻辑分散到多个文件中,从而提高可读性和可维护性。

    INCLUDE 命令的基本语法如下:

    INCLUDE "filename"

    INCLUDE
    • filename  是要包含的链接脚本文件的名称。如果文件名用双引号包围,它会在当前目录中查找文件。如果使用尖括号,链接器会在标准目录中查找文件。

    INCLUDE 命令的作用是将指定文件的内容插入到当前链接脚本的位置。这可以用来:

    1. 重用代码:将常用的链接脚本片段放入单独的文件中,然后在需要时包含它们。

    2. 组织复杂的链接逻辑:对于大型项目,将链接脚本分割成多个小文件,每个文件处理特定部分的链接逻辑。

    3. 条件包含:根据编译时的某些条件,包含不同的链接脚本片段。

    4. 简化维护:当链接脚本需要更新时,只需修改被包含的文件,而不必修改主链接脚本。

    假设你有一个名为 memory.ld 的文件,它定义了内存布局:

    /* memory.ld */
    MEMORY
    {
    FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
    RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 256K
    }

    你可以在主链接脚本中包含这个文件:

    /* main.ld */

    INCLUDE "memory.ld"

    SECTIONS
    {
    .text : { *(.text) } > FLASH
    .data : { *(.data) } > RAM
    .bss : { *(.bss) } > RAM
    }

    在这个例子中,memory.ld 文件的内容会被插入到 INCLUDE 命令的位置,从而定义了内存布局。

     

    注意

    • 确保被包含的文件存在并且路径正确。
    • 如果被包含的文件名包含空格或特殊字符,确保使用正确的引用方式。
    • INCLUDE  命令不仅限于包含其他链接脚本文件,也可以用于包含其他文本文件,只要这些文件的内容是有效的链接脚本命令。

    INCLUDE 命令是链接脚本中的一个重要工具,它允许你将链接逻辑分散到多个文件中,使得链接脚本更加清晰和易于管理。通过使用 INCLUDE,你可以创建更加模块化的链接脚本,提高构建过程的可维护性和可扩展性。

    (2) SEARCH_DIR

    SEARCH_DIR 命令在链接脚本中用于添加链接器搜索库文件的目录。这个命令告诉链接器在哪些额外的路径中查找需要链接的库文件。这与在命令行中使用 -L 选项指定库文件搜索路径是等效的。SEARCH_DIR 命令基本语法如下:

    SEARCH_DIR("path");
    • path  是要添加到链接器搜索路径中的目录路径。

    SEARCH_DIR 命令的作用是扩展链接器的库文件搜索路径。这对于以下情况很有用:

    1. 自定义库路径:当你的库文件不在标准路径下时,你可以使用  SEARCH_DIR  来指定库文件的位置。
    2. 模块化链接脚本:在大型项目中,你可能有多个链接脚本文件,每个文件处理不同的部分。使用  SEARCH_DIR  可以在主链接脚本中统一指定库文件的搜索路径。
    3. 环境配置:在某些构建环境中,库文件的路径可能会变化。通过在链接脚本中使用  SEARCH_DIR,可以避免在命令行中每次都指定这些路径。

    假设你的库文件位于 /opt/mylibs 目录下,你可以在链接脚本中这样使用 SEARCH_DIR

    SEARCH_DIR("/opt/mylibs");

    这样,链接器在搜索库文件时,会包括 /opt/mylibs 目录。

     

    注意

    • 使用  SEARCH_DIR  时,确保路径是正确的,并且库文件确实存在于指定的位置。
    • 如果路径以  =  开头,这通常表示该路径是一个相对路径,相对于  sysroot  目录。例如,SEARCH_DIR("=/usr/local/lib");  会将  sysroot  目录下的  /usr/local/lib  作为搜索路径。
    • SEARCH_DIR  可以多次使用,以添加多个搜索路径。

    SEARCH_DIR 是链接脚本中的一个实用命令,它允许你指定链接器在链接过程中搜索库文件的额外路径。这使得链接脚本更加灵活,能够适应不同的库文件布局和构建环境。

    (3) INPUT

    在链接脚本中,INPUT 命令用于显式地指定链接器应该包含哪些文件作为输入。这与在命令行上直接指定文件的方式相似,但INPUT命令允许你在链接脚本中指定这些文件,而不是在命令行上。INPUT 命令基本语法如下:

    INPUT(file, ...)
    INPUT(file file ...)
    • file  是要包含在链接过程中的文件名,可以是一个对象文件(.o)、库文件(.a 或 .so)或其他链接器输入文件。

    INPUT 命令的作用是告诉链接器在链接过程中包含指定的文件。这可以用来确保特定的文件被包含在最终的输出文件中,即使它们没有被程序的其他部分直接引用。

    假设你有一个链接脚本,你想要确保特定的对象文件 myobject.o 和库文件 mylib.a 被包含在链接过程中:

    INPUT("myobject.o" "mylib.a")

    在这个例子中,myobject.o 和 mylib.a 将被链接器作为输入文件处理,即使在命令行中未直接指定链接该文件。

     

    注意

    • INPUT  命令通常用于确保即使某些文件中的符号未被引用,它们也会被包含在最终的输出文件中。
    • 如果文件名包含空格或特殊字符,可以使用双引号将文件名括起来。
    • INPUT  命令可以与  GROUP  命令结合使用,后者用于处理库文件,确保只有在实际需要时才包含库文件中的符号。

    INPUT 命令是链接脚本中的一个有用工具,它允许你在链接脚本中指定输入文件,而不是在命令行上。这可以提高链接过程的可配置性和灵活性,特别是在复杂的构建系统中。

    (4) OUTPUT

    在链接脚本中,OUTPUT 命令用于指定生成的输出文件的名称。这个命令对于重写默认的输出文件名(通常是 a.out)或者当你想要明确指定输出文件名时非常有用。OUTPUT 命令基本语法如下:

    OUTPUT("filename")
    • "filename"  是你想要为输出文件指定的名称,包括扩展名。如果文件名包含空格或特殊字符,必须使用双引号。

    OUTPUT 命令的作用是告诉链接器输出文件应该使用指定的名称。如果不使用 OUTPUT 命令,链接器会使用默认的输出文件名,这通常是 a.out,或者如果你在命令行上使用了 -o 选项,那么会使用 -o 选项后面指定的名称。

    假设你有一个链接脚本 mylink.lds,你想要生成的可执行文件名为 myprogram

    OUTPUT("myprogram")
    SECTIONS
    {
    .text : { *(.text) }
    .data : { *(.data) }
    .bss : { *(.bss) }
    }

    在这个例子中,链接器会生成一个名为 myprogram 的可执行文件。

     

    注意

    • OUTPUT  命令通常放在链接脚本的开始部分。
    • 如果你在命令行上使用了  -o  选项,那么该选项指定的文件名会覆盖链接脚本中的  OUTPUT  命令。
    • OUTPUT  命令只能指定一个输出文件名,如果你需要生成多个输出文件,你需要为每个文件编写单独的链接脚本。

    OUTPUT 命令是链接脚本中的一个基本工具,它允许你控制生成的输出文件的名称。这对于创建具有特定名称的可执行文件或库文件非常有用,尤其是在自动化构建系统中,你可能需要确保输出文件名符合特定的命名约定。

    四、总结

    Linker Script 是嵌入式系统和高级软件工程中不可或缺的工具,它提供了对程序内存布局的精细控制。通过 Linker Script,开发者可以精确指定程序各部分在内存中的位置,这对于内存资源有限的嵌入式系统尤为重要。Linker Script 能够优化内存使用,确保程序各部分按预定顺序加载,从而提升程序性能。它还可以帮助开发者保留关键功能,便于调试和未来的功能扩展。通过定义固定的内存布局,Linker Script 增强了代码的一致性和可维护性,确保团队成员遵循相同的规范。总体而言,Linker Script 通过优化内存布局,提高了程序的稳定性和效率,是现代软件开发中的重要组成部分。

     

    本文仅列出部分链接脚本命令,具体详情请参见官方文档 GNU Linker。

    https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_chapter/ld_toc.html#TOC6


    Linux二进制 Linux编程、内核模块、网络原创文章分享,欢迎关注"Linux二进制"微信公众号
    评论
    • 11-29学习笔记11-29学习笔记习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习笔记&记录学习习笔记&记学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&学习学习笔记&记录学习学习笔记&记录学习学习笔记&记
      youyeye 2024-12-02 23:58 71浏览
    • TOF多区传感器: ND06   ND06是一款微型多区高集成度ToF测距传感器,其支持24个区域(6 x 4)同步测距,测距范围远达5m,具有测距范围广、精度高、测距稳定等特点。适用于投影仪的无感自动对焦和梯形校正、AIoT、手势识别、智能面板和智能灯具等多种场景。                 如果用ND06进行手势识别,只需要经过三个步骤: 第一步&
      esad0 2024-12-04 11:20 50浏览
    •         温度传感器的精度受哪些因素影响,要先看所用的温度传感器输出哪种信号,不同信号输出的温度传感器影响精度的因素也不同。        现在常用的温度传感器输出信号有以下几种:电阻信号、电流信号、电压信号、数字信号等。以输出电阻信号的温度传感器为例,还细分为正温度系数温度传感器和负温度系数温度传感器,常用的铂电阻PT100/1000温度传感器就是正温度系数,就是说随着温度的升高,输出的电阻值会增大。对于输出
      锦正茂科技 2024-12-03 11:50 109浏览
    • 光伏逆变器是一种高效的能量转换设备,它能够将光伏太阳能板(PV)产生的不稳定的直流电压转换成与市电频率同步的交流电。这种转换后的电能不仅可以回馈至商用输电网络,还能供独立电网系统使用。光伏逆变器在商业光伏储能电站和家庭独立储能系统等应用领域中得到了广泛的应用。光耦合器,以其高速信号传输、出色的共模抑制比以及单向信号传输和光电隔离的特性,在光伏逆变器中扮演着至关重要的角色。它确保了系统的安全隔离、干扰的有效隔离以及通信信号的精准传输。光耦合器的使用不仅提高了系统的稳定性和安全性,而且由于其低功耗的
      晶台光耦 2024-12-02 10:40 120浏览
    • 作为优秀工程师的你,已身经百战、阅板无数!请先醒醒,新的项目来了,这是一个既要、又要、还要的产品需求,ARM核心板中一个处理器怎么能实现这么丰富的外围接口?踌躇之际,你偶阅此文。于是,“潘多拉”的魔盒打开了!没错,USB资源就是你打开新世界得钥匙,它能做哪些扩展呢?1.1  USB扩网口通用ARM处理器大多带两路网口,如果项目中有多路网路接口的需求,一般会选择在主板外部加交换机/路由器。当然,出于成本考虑,也可以将Switch芯片集成到ARM核心板或底板上,如KSZ9897、
      万象奥科 2024-12-03 10:24 68浏览
    • 遇到部分串口工具不支持1500000波特率,这时候就需要进行修改,本文以触觉智能RK3562开发板修改系统波特率为115200为例,介绍瑞芯微方案主板Linux修改系统串口波特率教程。温馨提示:瑞芯微方案主板/开发板串口波特率只支持115200或1500000。修改Loader打印波特率查看对应芯片的MINIALL.ini确定要修改的bin文件#查看对应芯片的MINIALL.ini cat rkbin/RKBOOT/RK3562MINIALL.ini修改uart baudrate参数修改以下目
      Industio_触觉智能 2024-12-03 11:28 84浏览
    • 最近几年,新能源汽车愈发受到消费者的青睐,其销量也是一路走高。据中汽协公布的数据显示,2024年10月,新能源汽车产销分别完成146.3万辆和143万辆,同比分别增长48%和49.6%。而结合各家新能源车企所公布的销量数据来看,比亚迪再度夺得了销冠宝座,其10月新能源汽车销量达到了502657辆,同比增长66.53%。众所周知,比亚迪是新能源汽车领域的重要参与者,其一举一动向来为外界所关注。日前,比亚迪汽车旗下品牌方程豹汽车推出了新车方程豹豹8,该款车型一上市就迅速吸引了消费者的目光,成为SUV
      刘旷 2024-12-02 09:32 119浏览
    • 学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习笔记&记录学习习笔记&记学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&
      youyeye 2024-11-30 14:30 78浏览
    • 当前,智能汽车产业迎来重大变局,随着人工智能、5G、大数据等新一代信息技术的迅猛发展,智能网联汽车正呈现强劲发展势头。11月26日,在2024紫光展锐全球合作伙伴大会汽车电子生态论坛上,紫光展锐与上汽海外出行联合发布搭载紫光展锐A7870的上汽海外MG量产车型,并发布A7710系列UWB数字钥匙解决方案平台,可应用于数字钥匙、活体检测、脚踢雷达、自动泊车等多种智能汽车场景。 联合发布量产车型,推动汽车智能化出海紫光展锐与上汽海外出行达成战略合作,联合发布搭载紫光展锐A7870的量产车型
      紫光展锐 2024-12-03 11:38 101浏览
    • 概述 说明(三)探讨的是比较器一般带有滞回(Hysteresis)功能,为了解决输入信号转换速率不够的问题。前文还提到,即便使能滞回(Hysteresis)功能,还是无法解决SiPM读出测试系统需要解决的问题。本文在说明(三)的基础上,继续探讨为SiPM读出测试系统寻求合适的模拟脉冲检出方案。前四代SiPM使用的高速比较器指标缺陷 由于前端模拟信号属于典型的指数脉冲,所以下降沿转换速率(Slew Rate)过慢,导致比较器检出出现不必要的问题。尽管比较器可以使能滞回(Hysteresis)模块功
      coyoo 2024-12-03 12:20 111浏览
    • RDDI-DAP错误通常与调试接口相关,特别是在使用CMSIS-DAP协议进行嵌入式系统开发时。以下是一些可能的原因和解决方法: 1. 硬件连接问题:     检查调试器(如ST-Link)与目标板之间的连接是否牢固。     确保所有必要的引脚都已正确连接,没有松动或短路。 2. 电源问题:     确保目标板和调试器都有足够的电源供应。     检查电源电压是否符合目标板的规格要求。 3. 固件问题: &n
      丙丁先生 2024-12-01 17:37 100浏览
    • 戴上XR眼镜去“追龙”是种什么体验?2024年11月30日,由上海自然博物馆(上海科技馆分馆)与三湘印象联合出品、三湘印象旗下观印象艺术发展有限公司(下简称“观印象”)承制的《又见恐龙》XR嘉年华在上海自然博物馆重磅开幕。该体验项目将于12月1日正式对公众开放,持续至2025年3月30日。双向奔赴,恐龙IP撞上元宇宙不久前,上海市经济和信息化委员会等部门联合印发了《上海市超高清视听产业发展行动方案》,特别提到“支持博物馆、主题乐园等场所推动超高清视听技术应用,丰富线下文旅消费体验”。作为上海自然
      电子与消费 2024-11-30 22:03 98浏览
    • 《高速PCB设计经验规则应用实践》+PCB绘制学习与验证读书首先看目录,我感兴趣的是这一节;作者在书中列举了一条经典规则,然后进行详细分析,通过公式推导图表列举说明了传统的这一规则是受到电容加工特点影响的,在使用了MLCC陶瓷电容后这一条规则已经不再实用了。图书还列举了高速PCB设计需要的专业工具和仿真软件,当然由于篇幅所限,只是介绍了一点点设计步骤;我最感兴趣的部分还是元件布局的经验规则,在这里列举如下:在这里,演示一下,我根据书本知识进行电机驱动的布局:这也算知行合一吧。对于布局书中有一句:
      wuyu2009 2024-11-30 20:30 122浏览
    我要评论
    0
    点击右上角,分享到朋友圈 我知道啦
    请使用浏览器分享功能 我知道啦