3. MCU启动原理

链接脚本,说白了就是告诉编译器「你的代码该放哪儿,数据该放哪儿」。很多做MCU开发的朋友,一开始都觉得这东西可有可无——反正IDE都帮我生成了。但等你真正做Bootloader移植的时候,就会发现:不懂链接脚本,你连代码都跑不起来。

我个人习惯把链接脚本比作「MCU的内存地图」。你想想看,芯片就那么点ROM和RAM,哪个段该放哪里,哪个地址是保留区,哪个地址是外设寄存器,全得靠这张地图来规划。

3.1 ROM与RAM的地址映射:芯片的「内存地图」

先看一个典型的MCU内存映射。以STM32F103为例,它的地址空间长这样:

地址范围 区域 用途
0x0000 0000 - 0x0000 FFFF 别名区/系统存储器 BootROM、中断向量表映射
0x0800 0000 - 0x0801 FFFF 主Flash(ROM) 代码段(.text)、只读数据(.rodata)
0x2000 0000 - 0x2000 FFFF SRAM 数据段(.data)、BSS段(.bss)、堆栈
0x4000 0000 - 0x4002 3FFF 外设寄存器 GPIO、USART、TIM等

嗯,这里要注意:ROM和RAM的地址映射,是由芯片硬件设计决定的,不是你想改就能改。但链接脚本可以决定:你的代码段从Flash的哪个偏移地址开始放,数据段从SRAM的哪个地址开始放。

核心概念:链接脚本的本质,就是把编译器生成的「逻辑段」(.text、.data、.bss)映射到芯片的「物理地址空间」(ROM、RAM)。

3.2 .text / .data / .bss 段的布局:代码和数据怎么「住」进芯片

这三个段,是嵌入式开发里最基础的三个段。我刚开始做Bootloader时,就栽在.data段的初始化上——代码能跑,但全局变量全是乱的。

先看它们各自是干什么的:

段名 存储位置 运行位置 内容
.text ROM(Flash) ROM(Flash) 可执行指令、常量字符串
.data ROM(Flash) RAM 已初始化的全局/静态变量
.bss 无(仅占位) RAM 未初始化的全局/静态变量

为什么.data段要放在ROM里,却要搬到RAM里运行?

因为Flash只能读,不能写。你定义了一个全局变量 int count = 10;,这个「10」必须存在Flash里,但程序运行时count的值可能会变,所以必须搬到RAM里。启动代码(startup_xx.s)会负责在main()之前把.data从Flash拷贝到RAM。

至于.bss段,它不需要在Flash里占空间——反正初始值都是0,启动代码直接清零RAM区域就行。

我的经验:做Bootloader时,我习惯把.data段和.bss段的起始地址、长度定义成全局符号,方便在启动代码里引用。比如:_sdata、_edata、_sbss、_ebss。这样写汇编启动代码时,直接ldr这些符号地址就行,不用硬编码。

3.3 链接脚本语法:MEMORY和SECTIONS命令

链接脚本的核心就两个命令:MEMORY 和 SECTIONS。一个定义「有什么内存」,一个定义「怎么放段」。

3.3.1 MEMORY命令:声明芯片的内存资源

语法很简单:

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

这里定义了:

  • FLASH:起始地址0x08000000,大小128KB,属性可读可执行(rx)
  • RAM:起始地址0x20000000,大小64KB,属性可读可写可执行(rwx)

属性里的r/w/x分别代表读、写、执行。Bootloader的代码段一般放在rx区域,数据段放在rwx区域。

我曾经踩过的坑:有一次我把RAM的属性写成了(rw),漏了x。结果代码里有个函数指针跳转到RAM执行,直接HardFault。查了半天才发现是链接脚本里RAM少了个x属性。所以做Bootloader时,如果要在RAM里执行代码(比如Flash擦写时的临时代码),记得加上x。

3.3.2 SECTIONS命令:把段「放」到内存里

这是链接脚本最核心的部分。一个典型的SECTIONS配置长这样:

SECTIONS
{
    . = ORIGIN(FLASH);
    
    .text :
    {
        KEEP(*(.isr_vector))    /* 中断向量表,必须保留 */
        *(.text*)               /* 所有.text段 */
        *(.rodata*)             /* 只读数据 */
        _etext = .;             /* .text结束地址 */
    } > FLASH
    
    . = ORIGIN(RAM);
    
    .data : AT(_etext)
    {
        _sdata = .;             /* .data起始地址 */
        *(.data*)               /* 所有.data段 */
        _edata = .;             /* .data结束地址 */
    } > RAM AT> FLASH
    
    .bss :
    {
        _sbss = .;              /* .bss起始地址 */
        *(.bss*)                /* 所有.bss段 */
        _ebss = .;              /* .bss结束地址 */
    } > RAM
}

这里有几个关键点:

  • KEEP():告诉链接器,这个段即使没人引用也要保留。中断向量表必须用KEEP,否则优化时会被删掉。
  • AT():指定加载地址(LMA)。.data段的运行地址(VMA)在RAM,但加载地址(LMA)在Flash的_etext处。启动代码就是根据这个差值来拷贝数据的。
  • > FLASH 和 > RAM:指定段放在哪个内存区域。

VMA vs LMA:VMA(Virtual Memory Address)是程序运行时的地址,LMA(Load Memory Address)是程序烧录时的地址。对于.text段,VMA和LMA相同;对于.data段,VMA在RAM,LMA在Flash。

3.4 自定义段(.boot_header)的实现:给Bootloader加个「身份证」

做Bootloader时,经常需要在固件开头放一个自定义头部,里面包含:固件版本、校验和、长度、入口地址等信息。这个头部不能随便放,必须放在Flash的最前面,而且格式要固定。

怎么做?用自定义段。

首先,在C代码里定义一个结构体,并指定它放在自定义段里:

// boot_header.h
typedef struct {
    uint32_t magic;          // 魔数,用于识别
    uint32_t version;        // 固件版本
    uint32_t length;         // 固件总长度
    uint32_t checksum;       // 校验和
    uint32_t entry_point;    // 入口地址
    uint32_t reserved[3];    // 保留
} boot_header_t;

// 放在自定义段 .boot_header 中
__attribute__((section(".boot_header")))
const boot_header_t g_boot_header = {
    .magic      = 0xBOOTLOAD,
    .version    = 0x0100,
    .length     = 0,
    .checksum   = 0,
    .entry_point= 0x08000000,
    .reserved   = {0, 0, 0}
};

然后,在链接脚本里定义这个段,并强制放在Flash的最开头:

SECTIONS
{
    . = ORIGIN(FLASH);
    
    .boot_header :
    {
        KEEP(*(.boot_header))
        . = ALIGN(4);
    } > FLASH
    
    .text :
    {
        KEEP(*(.isr_vector))
        *(.text*)
        *(.rodata*)
        _etext = .;
    } > FLASH
    
    /* ... 其余段 ... */
}

这样编译出来的固件,前32个字节就是boot_header。上位机烧录工具可以直接读取这个头部,验证魔数和校验和,确认固件是否合法。

我建议:自定义段的起始地址最好4字节对齐,因为MCU的Flash通常按字(4字节)访问。另外,记得用KEEP()防止链接器优化掉这个段——毕竟它没有被任何代码显式引用。

3.5 本章知识体系:链接脚本全景图

下面这张图,把链接脚本的核心逻辑串起来了:

链接脚本核心知识体系 链接脚本核心知识体系 链接脚本(.ld) MEMORY 命令 SECTIONS 命令 FLASH (rx) ORIGIN=0x08000000 LENGTH=128K RAM (rwx) ORIGIN=0x20000000 LENGTH=64K .boot_header 自定义段 .text 代码段(VMA=LMA) .data 数据段(VMA≠LMA) .bss 零初始化段 关键概念 • VMA(运行地址):程序运行时段所在的地址 • LMA(加载地址):段在Flash中存储的地址 • 启动代码负责:拷贝.data(Flash→RAM)、清零.bss

3.6 避坑指南:链接脚本常见问题

做Bootloader移植时,链接脚本最容易出问题的几个地方:

  1. 中断向量表偏移:如果Bootloader占用了Flash前段空间,应用程序的中断向量表就不能放在0x08000000了。需要在链接脚本里把应用程序的Flash起始地址偏移,同时修改SCB->VTOR寄存器。
  2. .data段拷贝不对齐:启动代码拷贝.data时,如果源地址或目标地址没有4字节对齐,在Cortex-M3/M4上会触发异常。我建议在链接脚本里用. = ALIGN(4);保证对齐。
  3. 符号引用错误:启动代码里引用的_sdata、_etext等符号,必须在链接脚本里正确定义。少定义一个,链接就报错。
  4. 自定义段被优化:记得用KEEP(),否则-O2优化下,链接器可能认为这个段是「死代码」直接删掉。

我曾经遇到的一个诡异问题:Bootloader能正常跳转到APP,但APP里的全局变量全是乱的。查了两天,最后发现是链接脚本里.data段的LMA地址写错了——AT()指定的地址和实际Flash里存放的位置差了4个字节。启动代码拷贝时,源地址偏移了,拷到RAM里的数据全是错的。

嗯,链接脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑:段是逻辑的,地址是物理的,链接脚本就是连接这两者的桥梁。做Bootloader移植时,花半小时把链接脚本吃透,能省下后面几天的调试时间。