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

大家好,欢迎来到第四章。这一章我们聊聊芯片上电后到底发生了什么。说实话,很多做嵌入式开发的朋友,写了几年代码,却从来没关心过启动流程。我个人觉得,这就像开车不懂发动机原理——能开,但真出了问题就抓瞎了。

4.1 芯片上电启动流程

芯片上电那一刻,发生了什么?

我简单梳理一下:

  1. 电源稳定:芯片检测到VDD电压稳定后,内部复位电路释放复位信号。
  2. 硬件自举:CPU从预设的启动地址取第一条指令。这个地址通常是0x00000000或者0x08000000,具体看芯片设计。
  3. 向量表加载:CPU读取向量表,找到复位向量,跳转到复位处理函数。
  4. 系统初始化:设置时钟、配置内存控制器、初始化关键外设。
  5. 跳转到main:最后,调用你的main函数。

嗯,这里要注意一点:没有操作系统的环境,所有初始化都得你自己写。我在项目中遇到过,有人以为芯片上电后自动就把C运行环境准备好了——结果程序跑飞了都不知道为什么。

核心要点:无OS环境下,启动代码(startup code)是程序员自己写的。它负责把硬件从“原始状态”带到“可运行C语言”的状态。

4.2 向量表详解

向量表是什么?说白了,就是一张“中断处理函数地址表”。

芯片发生中断时,CPU会查这张表,找到对应的处理函数跳过去。ARM Cortex-M系列的向量表长这样:

; 向量表示例(ARM Cortex-M)
__Vectors       DCD     __initial_sp          ; 栈顶指针
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler           ; NMI中断
                DCD     HardFault_Handler     ; 硬错误
                DCD     MemManage_Handler     ; 内存管理错误
                DCD     BusFault_Handler      ; 总线错误
                DCD     UsageFault_Handler    ; 使用错误
                ; ... 其他中断向量

注意看,向量表的第一个条目是栈指针,不是代码。这是ARM Cortex-M的特点——上电后CPU自动从0x00000000加载SP,从0x00000004加载PC。

个人经验:我刚开始做STM32开发时,有一次修改了链接脚本,结果向量表地址偏移了。程序死活跑不起来,查了两天才发现是向量表位置不对。从那以后,我每次改链接脚本都会先检查向量表地址。

4.3 栈指针初始化

栈指针初始化,说白了就是告诉CPU:“你的临时数据放这里”。

为什么需要手动初始化?因为C语言要用栈来保存局部变量、函数调用返回地址等。没有栈,C代码根本跑不起来。

初始化代码通常长这样:

; 汇编启动代码中的栈初始化
__initial_sp   EQU   0x20008000    ; 栈顶地址

Reset_Handler  PROC
                EXPORT  Reset_Handler
                LDR     SP, =__initial_sp    ; 设置栈指针
                BL      SystemInit           ; 系统时钟初始化
                BL      main                 ; 跳转到主函数
                B       .
                ENDP

这里有个坑:栈大小要算好。我曾经在一个项目中,栈设得太小,函数嵌套调用多了就栈溢出,程序随机崩溃。排查了整整一周,最后发现是栈空间不够。

避坑指南:我曾经见过有人把栈顶地址设在了RAM范围之外,结果程序一运行就进HardFault。记住:栈顶地址 = RAM起始地址 + RAM大小。比如RAM从0x20000000开始,大小32KB,那栈顶就是0x20008000。

4.4 链接脚本详解

链接脚本,说白了就是告诉链接器:“你的代码放哪里,数据放哪里”。

没有链接脚本,编译器不知道把.text段放在Flash的哪个地址,也不知道.bss段该在RAM的哪个位置。

一个典型的链接脚本(GCC LD格式):

/* 链接脚本示例 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text :
    {
        *(.isr_vector)      /* 向量表 */
        *(.text*)           /* 代码段 */
        *(.rodata*)         /* 只读数据 */
        _etext = .;         /* 代码结束地址 */
    } > FLASH

    .data :
    {
        _sdata = .;         /* 数据段起始 */
        *(.data*)           /* 初始化数据 */
        _edata = .;         /* 数据段结束 */
    } > RAM AT > FLASH

    .bss :
    {
        _sbss = .;          /* BSS段起始 */
        *(.bss*)            /* 未初始化数据 */
        _ebss = .;          /* BSS段结束 */
    } > RAM
}

我来解释一下关键点:

  • MEMORY:定义芯片的物理内存布局。Flash用来放代码,RAM用来放数据。
  • SECTIONS:定义各个段放在哪个内存区域。
  • .data段:注意它有两个地址——加载地址(Flash)和运行地址(RAM)。启动代码需要把.data从Flash拷贝到RAM。
  • .bss段:启动代码需要把它清零,因为C标准要求未初始化的全局变量为0。

关键理解:链接脚本不是随便写的。它必须和芯片的内存映射匹配,也必须和启动代码配合。比如,启动代码里拷贝.data、清零.bss时,用的就是链接脚本里定义的符号(_sdata、_edata等)。

4.5 启动代码与链接脚本的配合

启动代码和链接脚本是“搭档”。链接脚本告诉启动代码“数据在哪”,启动代码负责“搬数据”。

典型的启动代码流程:

Reset_Handler:
    ; 1. 设置栈指针
    LDR     SP, =_estack

    ; 2. 拷贝.data段从Flash到RAM
    LDR     R0, =_sdata
    LDR     R1, =_edata
    LDR     R2, =_sidata
    BL      copy_data

    ; 3. 清零.bss段
    LDR     R0, =_sbss
    LDR     R1, =_ebss
    BL      zero_bss

    ; 4. 调用main
    BL      main

你想想看,如果没有链接脚本定义的这些符号,启动代码根本不知道数据在哪。这就是为什么我说“链接脚本是启动代码的地图”。

个人习惯:我一般会在链接脚本里多定义几个符号,比如_heap_start、_heap_end,方便后面实现malloc。虽然嵌入式里不推荐动态内存分配,但有时候确实需要。

4.6 常见问题与调试技巧

启动阶段出问题,最常见的现象就是“程序跑不起来”。我总结了几种情况:

现象 可能原因 排查方法
上电后无反应 向量表地址错误 检查链接脚本中向量表是否放在起始地址
进入HardFault 栈指针未正确初始化 检查栈顶地址是否在RAM范围内
全局变量值不对 .data段未正确拷贝 检查启动代码中的拷贝逻辑
全局变量非零 .bss段未清零 检查启动代码中是否有清零操作

调试技巧:

  • 用调试器查看PC寄存器的值,看它是否指向了正确的地址。
  • 查看SP寄存器的值,确认栈指针在RAM范围内。
  • 查看向量表第一个字(栈顶地址)和第二个字(复位向量)是否正确。

嗯,这一章的内容就到这里。启动流程和链接脚本是嵌入式开发的“地基”,地基不稳,上面盖的楼再漂亮也没用。我个人建议,每换一个芯片平台,第一件事就是仔细阅读它的启动代码和链接脚本——这能帮你避免很多坑。

下一章我们聊聊内存管理,在无OS环境下怎么高效使用那点可怜的RAM。到时候见。