C++ 链接器脚本(Linker Scripts):自定义内存布局与符号解析

好的,各位观众,欢迎来到今天的“C++ 链接器脚本奇妙之旅”!今天咱们不讲虚头巴脑的理论,直接上手,用大白话把这神秘的链接器脚本给扒个精光。

开场白:链接器,程序的幕后大佬

咱们写的C++代码,编译完了之后,那还只是一个个“零件”,得靠链接器(Linker)这位大佬,把这些零件组装成一个完整的、能跑的程序。链接器干的事情可多了,比如:

  • 符号解析: 找到代码里用到的变量、函数,看看它们到底定义在哪里。
  • 重定位: 调整代码里的地址,让程序知道该往哪里跳,该读写哪个内存位置。
  • 内存布局: 把代码、数据放到内存的哪个位置,安排得明明白白。

而链接器脚本,就是咱们指挥这位大佬的“剧本”,告诉它该怎么组装、怎么安排。

第一幕:为啥要用链接器脚本?

可能有人会问,链接器自己不是挺能干的吗?为啥还要咱们手动写脚本?

简单来说,默认情况下,链接器会按照一套它自己的规则来组装程序。但有时候,咱们需要更精细的控制,比如:

  • 嵌入式系统: 内存资源有限,需要把代码、数据放到指定的内存区域,比如 Flash、RAM 等。
  • 驱动开发: 需要把某些代码放到特定的地址,才能让硬件正确工作。
  • 优化: 为了提高性能,可能需要把经常一起使用的代码放到相邻的内存区域。
  • 诊断: 为了调试,可能需要人为划分代码、数据区域,例如为了方便查找溢出。

这时候,链接器脚本就派上大用场了!它可以让我们精确地控制程序的内存布局,实现各种高级操作。

第二幕:链接器脚本的基本结构

链接器脚本的语法有点像配置文件的感觉,主要分为几个部分:

  1. 入口点(Entry Point): 程序从哪里开始执行?通常是 _start 函数。
  2. 内存区域(Memory Regions): 定义了可用的内存区域,比如 ROM、RAM 等。
  3. 段(Sections): 代码、数据会被分成不同的段,比如 .text(代码段)、.data(已初始化数据段)、.bss(未初始化数据段)等。
  4. 段的分配(Section Placement): 把各个段放到哪个内存区域。
  5. 符号定义(Symbol Definitions): 定义一些全局符号,可以在代码里使用。

下面是一个简单的链接器脚本示例:

ENTRY(_start) /* 定义入口点 */

MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M /* FLASH 区域,可读可执行,起始地址 0x08000000,大小 1MB */
  RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K /* RAM 区域,可读可写可执行,起始地址 0x20000000,大小 128KB */
}

SECTIONS
{
  .text : { /* 代码段 */
    *(.text*) /* 匹配所有 .text 开头的段 */
  } > FLASH /* 放到 FLASH 区域 */

  .data : { /* 已初始化数据段 */
    *(.data*) /* 匹配所有 .data 开头的段 */
  } > RAM /* 放到 RAM 区域 */

  .bss : { /* 未初始化数据段 */
    *(.bss*) /* 匹配所有 .bss 开头的段 */
  } > RAM /* 放到 RAM 区域 */
}

这个脚本做了啥呢?

  • 定义了程序的入口点是 _start 函数。
  • 定义了两个内存区域:FLASH 和 RAM,并指定了它们的起始地址和大小。
  • .text 段放到 FLASH 区域,把 .data.bss 段放到 RAM 区域。

第三幕:内存区域(MEMORY)

MEMORY 命令用来定义可用的内存区域。每个区域都需要指定一个名字、起始地址和大小。

MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
  RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
  • FLASH (rx):定义了一个名为 FLASH 的区域,rx 表示可读可执行。
  • ORIGIN = 0x08000000:指定了起始地址为 0x08000000
  • LENGTH = 1M:指定了大小为 1MB。

括号里的 rx, rw, rwx 等表示权限:

权限 含义
r 可读
w 可写
x 可执行
a 可分配
i 已初始化
l 链接

第四幕:段(SECTIONS)

SECTIONS 命令用来定义各个段,并指定它们应该放到哪个内存区域。

SECTIONS
{
  .text : {
    *(.text*)
  } > FLASH

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

  .bss : {
    *(.bss*)
  } > RAM
}
  • .text : { ... } > FLASH:定义了一个名为 .text 的段,并把它放到 FLASH 区域。
  • *(.text*):这是一个段描述符,表示匹配所有以 .text 开头的段。 简单来说,就是把所有编译单元(.o 文件)里面的所有.text段都放在一起组成最终可执行文件的.text段。
  • > 符号:指定了段应该放到哪个内存区域。

段描述符的用法

段描述符是链接器脚本里最灵活的部分,可以用来匹配各种段。常用的语法有:

  • *(.text):匹配所有 .text 段。
  • *(.text*):匹配所有以 .text 开头的段,比如 .text.startup.text.init 等。
  • myfile.o(.text):匹配 myfile.o 文件里的 .text 段。
  • *(SORT_BY_NAME(.init_array.*)):匹配所有以 .init_array. 开头的段,并按照名字排序。

段的属性

除了指定内存区域,还可以为段指定一些属性,比如:

  • KEEP():防止链接器优化掉这个段。
  • NOLOAD:不把这个段加载到内存里。

例如:

SECTIONS
{
  .my_section : {
    KEEP(*(.my_section)) /* 防止被优化掉 */
  } > RAM
}

第五幕:符号定义(Symbol Definitions)

链接器脚本里可以定义一些全局符号,这些符号可以在代码里使用,用来获取段的起始地址、大小等信息。

SECTIONS
{
  .text : {
    _text_start = .; /* . 表示当前地址 */
    *(.text*)
    _text_end = .;
  } > FLASH
}

在这个例子里,我们定义了两个符号:_text_start_text_end,分别表示 .text 段的起始地址和结束地址。

在 C++ 代码里,就可以这样使用这些符号:

extern unsigned int _text_start;
extern unsigned int _text_end;

int main() {
  unsigned int text_size = (unsigned int)&_text_end - (unsigned int)&_text_start;
  // ...
}

. 的含义

在链接器脚本里,. 表示当前地址。它的值会随着链接器的处理而不断变化。

ABSOLUTE() 的用法

ABSOLUTE() 函数可以用来定义绝对地址的符号。

_my_address = ABSOLUTE(0x12345678);

这样,_my_address 的值就是 0x12345678,不会受到链接器的影响。

第六幕:高级技巧

  1. 条件语句

链接器脚本也支持条件语句,可以根据不同的条件来选择不同的配置。

#ifdef USE_FLASH
  MEMORY
  {
    FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
  }
#else
  MEMORY
  {
    RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
  }
#endif
  1. INCLUDE 命令

可以使用 INCLUDE 命令来包含其他的链接器脚本。

INCLUDE "my_common.ld"
  1. OVERLAY 命令

OVERLAY 命令可以用来定义重叠区域,多个段可以共享同一块内存。这在内存资源非常有限的情况下很有用。

OVERLAY : AT(0x20000000)
{
  .overlay1 {
    *(.overlay1)
  }
  .overlay2 {
    *(.overlay2)
  }
}

在这个例子里,.overlay1.overlay2 段会共享从 0x20000000 开始的内存区域。

第七幕:实战演练

假设我们有一个嵌入式系统,需要把启动代码放到 Flash 的开头,把中断向量表放到 Flash 的特定地址,把其他代码放到 Flash 的剩余空间,把数据放到 RAM。

首先,定义内存区域:

MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
  RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

然后,定义段的分配:

SECTIONS
{
  .isr_vector : { /* 中断向量表 */
    KEEP(*(.isr_vector)) /* 防止被优化掉 */
  } > FLASH AT > 0x08000000 /* 放到 FLASH,起始地址 0x08000000 */

  .text : { /* 代码段 */
    _text_start = .;
    *(.text*)
    _text_end = .;
  } > FLASH

  .data : { /* 已初始化数据段 */
    _data_start = .;
    *(.data*)
    _data_end = .;
  } > RAM

  .bss : { /* 未初始化数据段 */
    _bss_start = .;
    *(.bss*)
    _bss_end = .;
  } > RAM
}

在这个例子里,我们把 .isr_vector 段放到了 FLASH 的开头,并使用 AT 命令指定了它的起始地址。其他代码和数据按照默认的方式分配。

第八幕:调试技巧

链接器脚本写错了怎么办?别慌,有一些调试技巧可以帮助你:

  1. Map 文件: 链接器会生成一个 Map 文件,里面包含了程序的内存布局信息,可以用来查看各个段的地址和大小。
  2. 链接器错误信息: 仔细阅读链接器的错误信息,通常会告诉你哪里出了问题。
  3. GDB: 使用 GDB 调试器,可以查看程序的内存布局,验证链接器脚本是否正确。

第九幕:总结

链接器脚本是一个强大的工具,可以让我们精确地控制程序的内存布局。虽然它的语法有点复杂,但只要掌握了基本概念和常用技巧,就能轻松应对各种复杂的链接需求。

友情提示

  • 不同工具链(比如 GCC、Clang)的链接器脚本语法可能略有不同,需要参考相应的文档。
  • 在修改链接器脚本之前,一定要备份原来的版本,以防万一。
  • 多做实验,多查资料,才能真正掌握链接器脚本的精髓。

好了,今天的“C++ 链接器脚本奇妙之旅”就到这里了。希望大家有所收获,谢谢!

附录:常用链接器脚本命令

命令 含义
ENTRY() 定义程序的入口点。
MEMORY 定义内存区域。
SECTIONS 定义段的分配。
> 指定段应该放到哪个内存区域。
AT() 指定段的起始地址。
KEEP() 防止链接器优化掉这个段。
NOLOAD 不把这个段加载到内存里。
ABSOLUTE() 定义绝对地址的符号。
INCLUDE 包含其他的链接器脚本。
OVERLAY 定义重叠区域。
SORT_BY_NAME() 对段进行排序,常用的方法有 SORT_BY_NAME, SORT_BY_ALIGNMENT, SORT_BY_INIT_PRIORITY 等。
. = 设置当前地址。
PROVIDE() 如果符号未定义,则定义符号。例如:PROVIDE(_stack = DEFINED(_estack) ? _estack : ORIGIN(RAM) + LENGTH(RAM));,表示如果 _estack 符号已经定义了,那么 _stack 就等于 _estack,否则 _stack 就等于 RAM 的起始地址加上 RAM 的长度。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注