第1章:链接脚本与内存布局
大家好,我是你们的Bootloader讲师。今天咱们聊聊链接脚本和内存布局——这玩意儿说难不难,说简单吧,我见过太多人在上面栽跟头了。
先问个问题:你写好的C代码,编译后是怎么变成二进制文件的?代码放哪儿?数据放哪儿?未初始化的全局变量又去哪儿了?嗯,答案就在链接脚本里。
1.1 链接脚本(.lds)语法详解
链接脚本,说白了就是告诉链接器:你的程序各部分该往内存的哪个地址放。我刚开始做嵌入式时,觉得这玩意儿可有可无,直到有一次程序跑飞了,查了两天才发现是链接脚本里地址写错了……从那以后,我再也不敢小看它了。
一个典型的链接脚本长这样:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
*(.isr_vector)
*(.text)
*(.text.*)
*(.rodata)
*(.rodata.*)
_etext = .;
} > FLASH
.data :
{
_sdata = .;
*(.data)
*(.data.*)
_edata = .;
} > RAM AT> FLASH
.bss :
{
_sbss = .;
*(.bss)
*(.bss.*)
*(COMMON)
_ebss = .;
} > RAM
}
咱们拆开来看:
- ENTRY:指定程序入口点。我习惯用Reset_Handler,也就是上电后第一条指令的位置。
- MEMORY:定义可用的内存区域。Flash是只读的,RAM是可读写的。
- SECTIONS:定义各段怎么放。这是核心部分。
小技巧:ORIGIN和LENGTH这两个参数,一定要和芯片的数据手册对上。我曾经见过有人把Flash起始地址写成了0x08001000,结果程序死活跑不起来——因为中断向量表放错位置了。
1.2 代码段/数据段/BSS段布局
咱们的程序编译后,主要分成三个段:
| 段名 | 内容 | 存放位置 | 特点 |
|---|---|---|---|
| .text | 代码、常量、中断向量表 | Flash | 只读,掉电不丢失 |
| .data | 已初始化的全局/静态变量 | 运行时在RAM,加载时在Flash | 需要从Flash拷贝到RAM |
| .bss | 未初始化的全局/静态变量 | RAM | 启动时需清零 |
你想想看,为什么.data段要搞这么复杂?因为变量初始值得保存在Flash里,但运行时变量是要读写的,所以得搬到RAM里。这个搬运工作,就是Bootloader启动代码要干的第一件事。
重点来了:我在项目中遇到过一个问题——.data段拷贝没做好,结果全局变量初始值全是乱的。排查了半天,最后发现是链接脚本里AT> FLASH这个标记写漏了。没有这个标记,链接器会把.data段也放在RAM地址上,但Flash里根本没存初始值。
1.3 运行时地址与加载地址
这两个概念,我当年学的时候也绕了好久。简单说:
- 加载地址(LMA):程序烧录时放在哪。通常是Flash地址。
- 运行时地址(VMA):程序执行时实际在哪。代码段一般在Flash里直接跑,数据段得搬到RAM里跑。
为什么会这样?因为Flash读取速度慢,而且不能写。所以:
- 代码段(.text)直接在Flash里执行——反正只读,不用改。
- 数据段(.data)必须搬到RAM里——因为要读写。
- BSS段在RAM里,启动时清零——因为初始值都是0,不用从Flash拷。
看看这个例子就明白了:
// 假设全局变量
int g_counter = 100; // 这个在.data段
int g_buffer[1024]; // 这个在.bss段
// 启动代码里要做的:
// 1. 从Flash拷贝g_counter的初始值到RAM
// 2. 把g_buffer所在的RAM区域全部清零
注意:我曾经踩过一个坑——Bootloader里用了一个大数组做缓存,放在.bss段。结果启动时清零花了太长时间,导致看门狗超时复位了。后来我改成在链接脚本里把这个数组放到一个单独的段,启动时不自动清零,手动按需初始化。
1.4 链接脚本中的符号与变量
链接脚本里定义的符号,C代码可以直接用。比如前面脚本里的_sdata、_edata、_sbss、_ebss,还有_etext。这些符号在C代码里要这么声明:
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
extern uint32_t _etext;
// 然后就可以用了
void startup_handler(void)
{
// 拷贝.data段
uint32_t *src = &_etext;
uint32_t *dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
// 清零.bss段
dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
}
嗯,这里要注意:链接脚本里的符号,取地址时得到的是链接地址,不是运行地址。不过对于.data段来说,_sdata和_edata是运行时地址(VMA),而_etext是加载地址(LMA)——因为代码段后面紧跟着数据段的初始值。
个人习惯:我一般会在链接脚本里多定义几个辅助符号,比如_heap_start和_heap_end,方便管理堆空间。这样C代码里malloc的实现就简单多了。
1.5 常见问题与避坑指南
最后,分享几个我实际项目中遇到的坑:
- 段对齐问题:ARM芯片要求中断向量表必须4字节对齐,有些还要求256字节对齐。链接脚本里用. = ALIGN(4)可以搞定。
- 内存重叠:两个段不小心放到了同一个地址范围,程序跑起来会互相覆盖。我建议每次改链接脚本后,都看看生成的.map文件确认一下。
- 栈位置:栈一般放在RAM的末尾,向下生长。链接脚本里要预留足够的栈空间,不然函数调用深了就栈溢出。
好了,这一章就到这里。链接脚本这东西,说白了就是个地址分配器。你给它说清楚「代码放哪、数据放哪」,它就能帮你生成正确的二进制文件。下一章咱们聊聊启动代码的具体实现,到时候会用到今天讲的这些符号和段布局。