[其它产品/技术] 内存区域和链接脚本的奥秘

[复制链接]
 楼主| 星辰大海不退缩 发表于 2025-5-26 19:09 | 显示全部楼层 |阅读模式
用_sdata这样的变量来标记各个段在Flash或RAM中的位置。
ResetHandler的地址被精准地放在0x00000004,方便MCU找到。
这些操作看似天衣无缝,但实际上,它们要么依赖约定俗成的规则,要么是无数次复制粘贴的祖传代码。不同MCU的内存映射可能千差万别,启动脚本的变量命名可能五花八门,甚至程序段的划分也可能各不相同。既然没有统一标准,这些信息就得在项目中明确指定。对于用类Unix-ld工具链接的项目来说,这个指定工作就落在了链接脚本身上。

今天,我们继续以一个最简单的程序为例,拆解一下链接脚本的门道。

链接过程

链接是编译的最后一步,它把一堆编译好的目标文件合并成一个完整的程序,同时填上正确的地址,让所有内容各就各位。

在链接之前,编译器已经把你的源代码逐一编译成了机器码。在这个过程中,编译器会为地址留下占位符,因为它既不知道代码在整个程序中的最终位置,也对当前文件外的符号一无所知。

链接器的工作就是把这些目标文件,连同C标准库等外部依赖,合并成一个完整的程序。为了搞清楚每个部分该放哪儿,链接器需要一份地图——这就是链接脚本的用武之地。最后,链接器会把所有占位符替换成实际地址。

接下来就以我们的代码为例,看看main函数到底发生了什么。编译器先把minimal.c编译成目标文件:

arm-none-eabi-gcc -c -o build/objs/a/b/c/minimal.o minimal.c <CFLAGS>
用nm工具查看minimal.o的符号表:

  1. arm-none-eabi-nm build/objs/a/b/c/minimal.o
  2. ...
  3. 00000000 T main
  4. ...

可以看到,main函数的地址还是个0,说明还没分配具体位置。接下来,链接器登场:

  1. arm-none-eabi-gcc <LDFLAGS> build/objs/a/b/c/minimal.o <其他目标文件> -o build/minimal.elf

再次查看符号表:

  1. arm-none-eabi-nm build/minimal.elf
  2. ...
  3. 00000294 T main
  4. ...

这回main函数有了个具体的地址,链接器完成了它的使命。除了地址分配,链接器还能顺手生成调试信息、清理无用代码,或者做全程序优化(LTO)。不过今天我们先聚焦链接脚本的本质,别的功能下回再说。
 楼主| 星辰大海不退缩 发表于 2025-5-26 19:10 | 显示全部楼层
解剖链接脚本

链接脚本就像一份程序的施工图纸,主要包含四部分:

内存布局:告诉链接器有哪些内存区域可用,地址和大小是多少。
段定义:规定程序的各个部分该放哪儿。
选项:指定架构、入口点等额外配置。
符号:在链接时注入到程序中的变量。

程序的地基:内存布局

链接器要想分配空间,必须先知道有哪些内存可用,地址从哪儿开始,大小是多少。这部分信息由MEMORY定义提供。数据手册会写清楚内存映射如下:

内部Flash:起始地址0x00000000,大小256KB。
内部SRAM:起始地址0x20000000,大小32KB。
翻译成链接脚本的MEMORY定义,就是:

  1. MEMORY
  2. {
  3.   rom (rx)  : ORIGIN = 0x00000000, LENGTH = 0x00040000
  4.   ram (rwx) : ORIGIN = 0x20000000, LENGTH = 0x00008000
  5. }

这里的rom和ram只是名字,随你叫什么都行,flash和sram也常见。括号里的rx表示只读可执行,rwx表示可读写可执行。这些属性只是描述内存的性质,不是真的能改变硬件保护状态。

 楼主| 星辰大海不退缩 发表于 2025-5-26 19:11 | 显示全部楼层
地段划分:段定义

程序的代码和数据会被分到不同的段,段是内存中连续的区域。段的划分没有硬性规定,但通常会根据以下原则:

放进同一个内存区域的符号归一个段。
需要一起初始化的符号归一个段。
回顾上篇,我们提到了两类需要批量初始化的符号:

已初始化的静态变量,需要从Flash复制到RAM。
未初始化的静态变量,需要清零。
此外,链接脚本还要关心:

代码和常量数据,通常放Flash
保留的RAM区域,比如堆栈
按照惯例,段的命名如下:

  1. .text:代码和常量
  2. .bss:未初始化数据
  3. .stack:堆栈
  4. .data:已初始化数据

如果我们不定义任何段,只写内存布局:

  1. MEMORY
  2. {
  3.   rom (rx)  : ORIGIN = 0x00000000, LENGTH = 0x00040000
  4.   ram (rwx) : ORIGIN = 0x20000000, LENGTH = 0x00008000
  5. }

  6. SECTIONS
  7. {
  8.     /* 空荡荡! */
  9. }

链接器照样能工作,但用objdump检查生成的elf文件,会发现符号表空空如也。链接器虽然能靠默认假设链接程序,但至少需要知道入口点或.text段的内容。

 楼主| 星辰大海不退缩 发表于 2025-5-26 19:12 | 显示全部楼层
.text段

我们先给.text段安排上,它得住进ROM:

  1. SECTIONS
  2. {
  3.     .text :
  4.     {
  5.     } > rom
  6. }

接下来要告诉链接器,哪些东西该放进.text段。用objdump检查目标文件:

arm-none-eabi-objdump -h build/objs/a/b/c/minimal.o
会看到一堆.text开头的段,这是因为我们用了-ffunction-sections和-fdata-sections编译选项,每个函数和数据都有自己的段。如果没用这些选项,编译器可能会把多个函数合并到一个.text.<某标识>段。

要把所有.text开头的段放进.text,我们用通配符*:

  1. .text :
  2. {
  3.     KEEP(*(.vector*))
  4.     *(.text*)
  5. } > rom

这里的.vector*是向量表,包含ResetHandler等关键函数,必须放在.text段开头,确保MCU能找到。向量表的故事我们下回细聊。

检查elf文件,代码相关符号都出现了,数据符号还没影。

 楼主| 星辰大海不退缩 发表于 2025-5-26 19:12 | 显示全部楼层
.bss段-未初始化数据
.bss段存放未初始化的静态变量,分配在RAM中,但不需要加载内容(因为都是0)。定义如下:

  1. .bss (NOLOAD) :
  2. {
  3.     *(.bss*)
  4.     *(COMMON)
  5. } > ram

*(COMMON)是全局未初始化变量的特殊段,比如int foo;会放在这,而static int foo;不会。NOLOAD表示这个段不需要加载内容。

.stack段-栈

.stack段也在RAM,不需要加载内容,但得明确指定大小,还要按ARM调用规范(AAPCS)对齐到8字节边界。

这里要用到链接器的“位置计数器” . ,它记录当前内存区域的偏移。随着段的添加,计数器会自动前进。你可以用ALIGN函数强制对齐,或者通过赋值调整偏移,但不能往回退,否则链接器会报错。

.stack段的定义如下:

  1. STACK_SIZE = 0x2000; /* 8KB */

  2. .stack (NOLOAD) :
  3. {
  4.     . = ALIGN(8);
  5.     . = . + STACK_SIZE;
  6.     . = ALIGN(8);
  7. } > ram

 楼主| 星辰大海不退缩 发表于 2025-5-26 19:14 | 显示全部楼层
.data段
.data段存放有初始值的静态变量。设备断电时RAM清空,所以这些数据得从Flash复制到RAM。ResetHandler会在启动时完成这个搬家工作。

为此,每个段需要两个地址:

LMA(加载地址):加载器把数据写到这(一般是Flash)。
VMA(虚拟地址):程序运行时数据在这(一般是RAM)。
定义.data段时,指定VMA和LMA:

  1. .data :
  2. {
  3.     *(.data*);
  4. } > ram AT > rom
  5. 也可以显式指定地址:

  6. .data ORIGIN(ram) : AT(ORIGIN(rom))
  7. {
  8.     . = ALIGN(4);
  9.     _sdata = .;
  10.     *(.data*);
  11.     . = ALIGN(4);
  12.     _edata = .;
  13. }



链接器符号

上篇提到的_ebss、_sdata等变量,其实是链接器生成的符号,用于告诉代码各段的地址。定义方式类似C语言赋值:

  1. .text :
  2. {
  3.     KEEP(*(.vectors .vectors.*))
  4.     *(.text.*)
  5.     *(.rodata.*)
  6.     _etext = .;
  7. } > rom

  8. .bss (NOLOAD) :
  9. {
  10.     _sbss = . ;
  11.     *(.bss .bss.*)
  12.     *(COMMON)
  13.     _ebss = . ;
  14. } > ram

  15. .data :
  16. {
  17.     _sdata = .;
  18.     *(.data*);
  19.     _edata = .;
  20. } > ram AT >rom

注意,引用这些符号时要用取地址操作,比如:

uint8_t *data_byte = &_sdata;
直接用_sdata会出问题。

链接脚本就像一张地图,告诉链接器如何把代码和数据安顿好。但其实理解了内存布局、段定义和符号的套路,你也能轻松理解。

daichaodai 发表于 2025-5-26 19:30 来自手机 | 显示全部楼层
这个和汇编链接什么关系
suncat0504 发表于 2025-5-27 09:24 | 显示全部楼层
在C中使用这种连接,会不会影响程序运行?毕竟声明变量、分配内存等操作会动态使用内存的。
suncat0504 发表于 2025-5-27 09:25 | 显示全部楼层
老早以前,在流程式编程的时候,有GOTO语句,后来不提倡,因为不利于程序解读、维护。固定内存指定区域的操作,会不会有其它负面影响呢。
小夏天的大西瓜 发表于 2025-5-27 09:54 | 显示全部楼层
动态解析还是需要资源的
地瓜patch 发表于 2025-5-27 17:31 来自手机 | 显示全部楼层
这个有深度,看一遍理解不了
您需要登录后才可以回帖 登录 | 注册

本版积分规则

287

主题

2525

帖子

6

粉丝
快速回复 在线客服 返回列表 返回顶部