第四章:启动流程与链接脚本

芯片上电后发生了什么?

这个问题,我当年刚入行时也琢磨了很久。说白了,就是CPU从复位向量开始,一步步把我们的程序跑起来。今天咱们就聊聊这个启动流程,还有那个让很多人头疼的链接脚本。

4.1 芯片上电启动流程

ARM Cortex-M系列芯片上电后,硬件会自动做几件事:

  • 从0x00000000地址读取栈顶指针(MSP)
  • 从0x00000004地址读取复位向量(Reset_Handler)
  • 跳转到Reset_Handler执行

嗯,这里要注意。不同架构的启动方式不一样。比如Cortex-A系列,它需要先初始化MMU和Cache,比M系列复杂得多。我在做一款医疗影像设备时,用的就是i.MX6ULL(Cortex-A7),那启动流程真是绕了好几圈。

核心要点:启动流程的本质,就是把CPU从复位状态带到C语言环境。这中间要完成时钟配置、内存初始化、BSS段清零、数据段拷贝等操作。

4.2 Bootloader设计

Bootloader是什么?

你可以把它理解成「程序的搬运工」。它负责把应用程序从Flash搬到RAM里,然后跳转过去执行。我见过不少新手直接跳过了Bootloader,结果产品量产时发现没法远程升级,那叫一个尴尬。

一个典型的Bootloader流程:

  1. 初始化时钟和看门狗
  2. 初始化串口(用于打印调试信息)
  3. 初始化Flash和RAM
  4. 检查升级标志(比如某个GPIO电平)
  5. 如果需要升级,进入升级模式;否则跳转到应用程序

个人经验:我习惯在Bootloader里加一个「超时等待」机制。上电后等个几百毫秒,如果串口收到特定字符就进入升级模式,否则正常启动。这样既不影响正常使用,又方便调试。

我曾经遇到过一个坑:Bootloader和应用程序用了同一个中断向量表。结果跳转后,中断一触发就跑飞了。后来我强制在跳转前关闭所有中断,并在应用程序入口重新配置向量表偏移,问题才解决。

4.3 链接脚本(.lds)编写

链接脚本,说白了就是告诉链接器:「你的代码该放哪儿,数据该放哪儿」。很多嵌入式开发者觉得这玩意儿难,其实你只要搞懂几个关键概念就行。

一个典型的STM32链接脚本:

/* 内存区域定义 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

/* 段定义 */
SECTIONS
{
    /* 中断向量表 */
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

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

    /* 只读数据段 */
    .rodata :
    {
        . = ALIGN(4);
        *(.rodata)
        *(.rodata*)
        . = ALIGN(4);
    } > FLASH

    /* 数据段(初始化的全局变量) */
    _sidata = .;
    .data : AT(_sidata)
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM

    /* BSS段(未初始化的全局变量) */
    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM
}

你想想看,为什么数据段要定义两个地址?

因为数据段在Flash里存储(_sidata),但运行时要在RAM里(_sdata到_edata)。启动代码需要把数据从Flash拷贝到RAM。这就是为什么启动文件里会有那段拷贝代码。

避坑指南:我曾经在项目里把BSS段和DATA段的地址搞反了,结果全局变量初始化全乱套。调试了整整两天才发现是链接脚本里少写了一个ALIGN。所以,链接脚本里的对齐操作一定不能省。

4.4 分散加载文件

分散加载文件(Scatter File)是ARM编译器特有的东西,功能跟链接脚本类似,但语法更灵活。在医疗设备开发中,我经常用它来实现「代码在Flash运行,数据在RAM运行」的布局。

一个典型的分散加载文件:

LOAD_REGION 0x08000000 0x00080000
{
    VECTORS 0x08000000
    {
        * (RESET, +First)
    }

    CODE 0x08000000
    {
        * (+RO)
    }

    DATA 0x20000000 0x00020000
    {
        * (+RW, +ZI)
    }
}

分散加载文件的好处是,你可以把不同的模块放到不同的内存区域。比如,把实时性要求高的代码放到TCM(紧耦合内存)里,把大块数据放到SDRAM里。

实战经验:我在做一款便携式监护仪时,把心电算法放到了TCM里,把显示缓冲放到了外部SDRAM里。这样既保证了算法的实时性,又节省了内部RAM空间。分散加载文件就是靠这个「分区」能力,帮我们解决了内存紧张的问题。

4.5 启动流程中的关键代码

启动文件(startup_xxx.s)里,有几个关键步骤:

  1. 设置栈指针(从向量表读取)
  2. 调用SystemInit()初始化时钟
  3. 拷贝数据段(从Flash到RAM)
  4. 清零BSS段
  5. 调用main()进入应用程序

嗯,这里有个细节。有些芯片的SystemInit()里会配置Flash等待周期。如果时钟频率高了,Flash读取跟不上,程序就会跑飞。我遇到过一位同事,把STM32F103超频到128MHz,结果程序时不时死机,最后发现是Flash等待周期没配够。

小技巧:我习惯在启动文件里加一个「看门狗喂狗」操作。如果Bootloader执行时间较长,看门狗可能会复位。提前喂一下,能避免这种意外。

4.6 总结与建议

启动流程和链接脚本,是嵌入式开发的「地基」。地基没打好,上面盖的房子再漂亮也白搭。我个人建议:

  • 先搞懂芯片的启动流程,再看链接脚本
  • 链接脚本不要照搬,要根据实际内存布局修改
  • Bootloader一定要留升级接口,这是血的教训
  • 调试时多打印信息,启动流程出问题很难直接定位

我曾经在一个项目里,因为链接脚本里少定义了一个内存区域,导致程序在RAM里跑着跑着就崩了。后来用调试器一看,发现代码和数据重叠了。从那以后,我每次写完链接脚本都会用map文件检查一遍内存布局。

好了,这一章就聊到这儿。下一章咱们讲讲中断系统和异常处理,那可是嵌入式开发的「灵魂」所在。