23、自定义启动流程:如何裁剪标准启动代码

说实话,标准启动代码这东西,就像一件均码西装。大部分人穿上都能凑合,但想真正合身?你得自己动手改。我在做低功耗物联网项目时,遇到过Flash只有8KB的MCU,标准启动代码光初始化就占了2KB——这谁能忍?

今天我们就聊聊,怎么把这件「均码西装」改成「量身定制」。说白了,就是去掉那些你用不到的初始化步骤,让芯片上电后直奔主题。

标准启动代码到底干了什么?

先看看标准启动代码的「标准动作」。以ARM Cortex-M为例,典型的启动流程是这样的:

; 标准启动代码伪代码
Reset_Handler:
    1. 设置堆栈指针 (SP)
    2. 复制 .data 段从Flash到RAM
    3. 清零 .bss 段
    4. 调用 SystemInit() 初始化时钟
    5. 调用 C 库初始化 (__libc_init_array)
    6. 跳转到 main()

嗯,这里要注意:不是每一步你都需要。比如你的程序只用全局变量,没有未初始化数据,那.bss清零就是多余的。再比如你压根不用C库的printf,那库初始化也可以砍掉。

核心思路:启动代码的裁剪,本质上是「按需加载」。只保留你的程序真正依赖的初始化步骤。

第一步:分析你的程序依赖

我个人习惯,先画一张依赖图。你想想看,你的程序到底需要哪些运行时环境?

启动代码裁剪决策 有全局/静态变量吗? 有 → 保留.data复制 无 → 裁剪.data复制 有未初始化全局变量吗? 有 → 保留.bss清零 无 → 裁剪.bss清零 使用malloc/printf等库函数?

这张图其实已经说得很清楚了。你只需要回答三个问题:

  • 有没有全局变量? 没有的话,.data复制直接删。
  • 有没有未初始化的全局变量? 没有的话,.bss清零也删。
  • 用不用C标准库? 不用的话,__libc_init_array整个砍掉。

第二步:动手裁剪——实战案例

我记得有一次做一款智能传感器,MCU是STM32G030,Flash只有16KB。标准启动代码里,光是SystemInit()就占了将近1KB——里面初始化了PLL、Flash预取、总线矩阵等等。但我的程序只需要内部RC振荡器,8MHz主频就够了。

于是我把SystemInit()替换成了这样:

// 裁剪后的系统初始化
void SystemInit(void)
{
    // 只开启必要的时钟
    RCC->CR |= RCC_CR_HSION;           // 开启HSI
    while(!(RCC->CR & RCC_CR_HSIRDY)); // 等待稳定
    
    // 配置Flash等待周期(8MHz不需要等待)
    FLASH->ACR = 0;                     // 0等待周期
    
    // 配置系统时钟为HSI
    RCC->CFGR = 0;                     // 默认HSI作为系统时钟
}

小技巧:很多MCU的SystemInit()是厂商提供的模板代码,里面包含了所有外设的时钟使能。如果你只用GPIO和UART,完全可以只保留这两项的时钟。

第三步:链接脚本的配合

光改启动代码还不够,链接脚本也得跟着动。我见过有人只改了启动代码,结果链接脚本里还是把整个RAM都分配给了.data和.bss——这不白忙活吗?

裁剪后的链接脚本,应该只分配实际用到的内存区域:

/* 裁剪后的链接脚本片段 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 8K   /* 实际只用8KB */
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 2K   /* 实际只用2KB */
}

SECTIONS
{
    /* 如果不用.data段,可以完全注释掉 */
    /*
    .data :
    {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT> FLASH
    */
    
    /* 如果不用.bss段,也注释掉 */
    /*
    .bss :
    {
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM
    */
    
    /* 只保留栈空间 */
    .stack (NOLOAD) :
    {
        . = ALIGN(8);
        _sstack = .;
        . = . + 256;  /* 256字节栈,够用 */
        _estack = .;
    } > RAM
}

警告:裁剪链接脚本时,一定要确认你的程序真的不需要这些段。我曾经犯过一个错:把.bss段注释了,但程序里有个全局标志位没初始化,结果上电后标志位是随机值,导致逻辑判断出错。排查了整整一下午。

第四步:验证裁剪结果

裁剪完了,怎么知道效果?我一般用两个方法:

  1. 看map文件:编译后生成的.map文件里,会列出每个段的起始地址和大小。对比裁剪前后的.text、.data、.bss大小,一目了然。
  2. 看反汇编:用objdump -d查看启动代码部分,确认那些被裁剪的初始化函数确实不存在了。

举个例子,裁剪前我的map文件显示:

起始地址 大小
.text 0x08000000 4096 bytes
.data 0x20000000 128 bytes
.bss 0x20000080 256 bytes

裁剪后:

起始地址 大小
.text 0x08000000 2048 bytes
.data 0 bytes
.bss 0 bytes

你看,Flash占用从4KB降到了2KB,RAM占用从384字节降到了0(栈不算在段内)。对于资源紧张的MCU来说,这省出来的空间可能就是决定性的。

避坑指南

最后,分享几个我踩过的坑:

  • 不要裁剪中断向量表:哪怕你不用中断,向量表的前两个word(栈指针和复位向量)是必须的,否则芯片上电直接跑飞。
  • 注意C库的隐式依赖:你以为没用C库,但编译器可能悄悄插入了memset、memcpy的调用。这时候裁剪__libc_init_array会导致链接错误。
  • 调试器可能依赖标准初始化:有些调试器(比如J-Link)在连接时会检查RAM内容。如果你把.bss清零去掉了,调试器可能报错。这时候可以加个条件编译,调试版本保留初始化,发布版本裁剪掉。

嗯,说到底,裁剪启动代码不是什么高深的技术,但需要你对程序的运行时环境有清晰的认识。别一上来就照着网上的模板改,先搞清楚你的程序到底需要什么,再动手。这样裁剪出来的代码,才是真正「量身定制」的。


公众号:蓝海资料掘金营,微信deep3321