链接脚本(Linker Script)深入:内存布局定义、段管理、符号解析与模板实战
大家好,我是老周。今天咱们聊聊链接脚本——这个在RISC-V固件开发里绕不开,却又经常被忽视的“幕后功臣”。
说实话,我刚开始做嵌入式那会儿,对链接脚本的态度就是“能用就行”。直到有一次,一个看似简单的内存越界问题,硬是让我调了三天。最后发现,是链接脚本里一个段地址没对齐。嗯,从那以后,我再也不敢小看这玩意儿了。
为什么链接脚本这么重要?
你想想看,我们写的C代码、汇编代码,最终都要变成二进制文件烧到芯片里。但芯片的内存就那么点地方,代码放哪、数据放哪、堆栈放哪,总得有个规矩吧?链接脚本就是干这个的。
在RISC-V的启动代码里,链接脚本尤其关键。因为RISC-V的启动流程,说白了就是从复位向量开始,一步步把各个段搬到正确的位置。如果链接脚本写错了,轻则程序跑飞,重则芯片直接“死机”。
核心观点:链接脚本不是“配菜”,它是固件工程的“骨架”。骨架歪了,肉长得再好也没用。
内存布局定义:给芯片画一张“地图”
我们先从最基础的开始。链接脚本的第一件事,就是告诉链接器:芯片的内存长什么样。
以我常用的RISC-V芯片为例,它通常有这几个区域:
- Flash(或ROM):存放代码和只读数据,掉电不丢失
- RAM:存放变量、堆栈,掉电就没了
- MMIO区域:外设寄存器,直接映射到内存地址空间
在链接脚本里,我们用 MEMORY 命令来定义这些区域:
MEMORY
{
FLASH (rx) : ORIGIN = 0x20000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128K
STACK (rw) : ORIGIN = 0x80020000, LENGTH = 16K
}
这里每个区域都有三个属性:起始地址(ORIGIN)、长度(LENGTH)、访问权限(r/w/x)。我个人习惯把RAM和STACK分开定义,这样能更精细地控制栈的位置,避免栈溢出时踩到变量区。
小技巧:我在项目中遇到过,有些芯片的RAM起始地址不是0x80000000,而是0x00000000。这时候一定要仔细看芯片手册,别想当然。
段(Section)管理:把代码和数据“分门别类”
内存地图画好了,接下来就是把代码和数据放到对应的位置。这就是 SECTIONS 命令的活。
常见的段有这些:
| 段名 | 内容 | 存放位置 |
|---|---|---|
| .text | 可执行代码 | Flash |
| .rodata | 只读数据(字符串、常量) | Flash |
| .data | 已初始化的全局变量 | RAM(初始值在Flash) |
| .bss | 未初始化的全局变量 | RAM |
| .stack | 栈空间 | RAM |
一个典型的段定义长这样:
SECTIONS
{
. = ORIGIN(FLASH);
.text : {
*(.text.init) /* 启动代码放最前面 */
*(.text)
*(.text.*)
} > FLASH
.rodata : {
*(.rodata)
*(.rodata.*)
} > FLASH
. = ORIGIN(RAM);
.data : {
_sdata = .;
*(.data)
*(.data.*)
_edata = .;
} > RAM AT> FLASH
.bss : {
_sbss = .;
*(.bss)
*(.bss.*)
*(COMMON)
_ebss = .;
} > RAM
}
注意看 .data 段后面的 AT> FLASH。这表示:这个段运行时在RAM,但初始值存在Flash里。启动代码需要把这些初始值从Flash拷贝到RAM。这就是为什么启动代码里总有一段“搬运”逻辑。
避坑指南:我曾经犯过一个低级错误——忘了给 .bss 段清零。结果全局变量初始值全是随机的,程序跑起来各种诡异。后来我养成了习惯:在启动代码里,一定要用 _sbss 和 _ebss 这两个符号来清零 .bss 段。
符号解析:链接器怎么“找人”
链接脚本里经常会出现一些“符号”,比如 _sdata、_ebss。这些符号不是C代码里定义的,而是链接脚本“造”出来的。它们的作用,就是告诉程序:某个段的起始地址在哪、结束地址在哪。
举个例子,启动代码里要清零 .bss 段,代码会这么写:
extern uint32_t _sbss, _ebss;
uint32_t *p = &_sbss;
while (p < &_ebss) {
*p++ = 0;
}
这里 _sbss 和 _ebss 就是链接脚本里定义的符号。链接器在链接时,会把它们的值替换成实际的地址。
我个人习惯在链接脚本里定义这些符号:
_sdata/_edata:.data段的起始和结束_sbss/_ebss:.bss段的起始和结束_stack_top:栈顶地址_heap_start/_heap_end:堆的起始和结束
注意:链接脚本里的符号,在C代码里要用 extern 声明,并且取地址时要加 &。因为链接器把符号当成地址,而不是变量。
链接脚本模板编写实战
光说不练假把式。下面我给出一个完整的RISC-V链接脚本模板,你可以直接拿去用:
/* RISC-V 链接脚本模板 - 适用于标准启动流程 */
OUTPUT_ARCH(riscv)
ENTRY(_start)
MEMORY
{
FLASH (rx) : ORIGIN = 0x20000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128K
}
SECTIONS
{
/* 中断向量表放在最前面 */
. = ORIGIN(FLASH);
.vectors : {
*(.vectors)
} > FLASH
/* 代码段 */
.text : {
*(.text.init)
*(.text)
*(.text.*)
*(.gnu.linkonce.t.*)
} > FLASH
/* 只读数据 */
.rodata : {
*(.rodata)
*(.rodata.*)
*(.gnu.linkonce.r.*)
} > FLASH
/* 初始化数据 - 运行时在RAM,初始值在Flash */
. = ORIGIN(RAM);
.data : ALIGN(4) {
_sdata = .;
*(.data)
*(.data.*)
*(.gnu.linkonce.d.*)
_edata = .;
} > RAM AT> FLASH
/* BSS段 - 未初始化数据 */
.bss : ALIGN(4) {
_sbss = .;
*(.bss)
*(.bss.*)
*(.gnu.linkonce.b.*)
*(COMMON)
_ebss = .;
} > RAM
/* 栈空间 - 从RAM末尾向下生长 */
.stack : ALIGN(16) {
_stack_bottom = .;
. += 16K; /* 栈大小 */
_stack_top = .;
} > RAM
/* 堆空间 - 从BSS末尾到栈底 */
.heap : ALIGN(4) {
_heap_start = .;
. = _stack_bottom;
_heap_end = .;
} > RAM
/* 定义Flash中.data初始值的结束地址 */
_data_loadaddr = LOADADDR(.data);
}
这个模板有几个关键点:
- ALIGN(4):强制4字节对齐,避免RISC-V的未对齐访问异常
- ENTRY(_start):指定入口点,通常是复位向量
- LOADADDR:获取.data段在Flash中的加载地址
- 栈向下生长:栈顶在RAM末尾,栈底在堆上方
实战经验:我建议你在链接脚本里加上 ASSERT 断言,比如检查栈大小是否超过RAM。这样链接时就能发现问题,而不是等到运行时崩溃。
知识体系总览
下面这张图,是我自己总结的链接脚本知识体系。你可以把它当成一个“思维导图”来看:
这张图把链接脚本的四个核心模块串起来了。你写链接脚本时,就按这个思路来:先定义内存,再安排段,然后定义符号,最后套用模板。
写在最后
链接脚本这东西,说难不难,说简单也不简单。我见过很多工程师,写C代码一把好手,但一碰到链接脚本就头疼。其实你只要记住:链接脚本就是给芯片画地图、给代码分房间、给变量贴标签。搞懂了这三件事,你就掌握了链接脚本的精髓。
好了,今天就聊到这儿。下次咱们聊聊启动代码里的“搬运工”——那个把.data段从Flash搬到RAM的循环。嗯,那也是个容易踩坑的地方。