3. Zephyr 内核与驱动模型

说实话,很多做嵌入式应用层的朋友,一听到「启动代码」和「链接脚本」就头大。我当年刚入行时也一样,觉得这玩意儿是硬件工程师的事,跟我BSP开发有什么关系?直到有一次,我调试一块新板子,CPU死活跑不起来,查了三天硬件,最后发现是链接脚本里RAM地址配错了——嗯,从那以后我再也不敢小看这两样东西了。

说白了,启动代码和链接脚本,就是芯片从复位到进入main()函数之前,那段「没人管」的真空地带。你写不好,CPU连C语言环境都没有,谈什么应用逻辑?

3.1 芯片启动流程:从复位向量到main()

先捋一遍典型ARM Cortex-M系列芯片的启动流程。我以STM32为例,其他架构大同小异。

  1. 上电复位:CPU从0x00000000地址读取栈顶指针(MSP),从0x00000004地址读取复位向量,跳转到复位处理函数。
  2. 系统初始化:关闭中断、配置时钟、初始化关键外设(如看门狗、PLL)。
  3. 数据段搬运:将.data段从Flash拷贝到RAM,将.bss段清零。
  4. 调用构造函数:对于C++工程,遍历.init_array段,调用全局对象的构造函数。
  5. 跳转main():准备好C运行时环境后,调用main()函数。

关键点:启动代码必须在链接脚本的配合下才能正确工作。链接脚本告诉启动代码「数据段在哪、BSS段多大、栈顶在哪」,启动代码则按照这些信息去执行初始化。

我见过不少新手,直接在启动代码里写死地址,结果换了Flash大小或者RAM布局,板子就挂了。记住:启动代码要读链接脚本的符号,不要写死地址。

3.2 汇编启动代码编写:手写Reset_Handler

虽然现在很多厂商提供现成的启动文件,但作为BSP开发者,你得能自己写。我习惯用汇编写启动代码,因为这时候C环境还没准备好,你没法调用任何C函数。

下面是一个精简的Cortex-M启动代码示例:

    .syntax unified
    .cpu cortex-m4
    .fpu softvfp
    .thumb

    .global  g_pfnVectors
    .global  Default_Handler

    /* 栈顶地址,由链接脚本提供 */
    .word    _sstack
    .word    Reset_Handler
    .word    NMI_Handler
    .word    HardFault_Handler
    /* ... 其他中断向量 ... */

    .section .text.Reset_Handler
    .weak    Reset_Handler
    .type    Reset_Handler, %function
Reset_Handler:
    /* 1. 关闭全局中断 */
    cpsid   i

    /* 2. 搬运 .data 段:从 Flash 到 RAM */
    ldr     r0, =_sidata
    ldr     r1, =_sdata
    ldr     r2, =_edata
    subs    r2, r1
    ble     .L_loop_bss
.L_loop_data:
    subs    r2, #4
    ldr     r3, [r0, r2]
    str     r3, [r1, r2]
    bgt     .L_loop_data

    /* 3. 清零 .bss 段 */
.L_loop_bss:
    ldr     r1, =_sbss
    ldr     r2, =_ebss
    subs    r2, r1
    ble     .L_call_main
    movs    r3, #0
.L_loop_bss_clear:
    subs    r2, #4
    str     r3, [r1, r2]
    bgt     .L_loop_bss_clear

    /* 4. 使能全局中断,跳转 main */
.L_call_main:
    cpsie   i
    bl      main
    /* main 返回后,进入死循环 */
.L_infinite:
    b       .L_infinite

我的习惯:在Reset_Handler开头加一段「栈指针检查」代码。曾经有一块板子,因为PCB布线问题导致栈顶地址读回来是0xFFFFFFFF,CPU直接跑飞。加个检查,至少能进HardFault,方便调试。

3.3 链接脚本(.lds)配置:内存布局的「宪法」

链接脚本,说白了就是告诉链接器:你的代码放哪、数据放哪、栈放哪。我把它比作「内存布局的宪法」——所有段都必须遵守它的规定。

一个典型的STM32链接脚本结构如下:

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

SECTIONS
{
    /* 中断向量表:必须放在Flash起始地址 */
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

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

    /* 初始化数据段(在Flash中的镜像) */
    _sidata = .;

    /* 数据段:运行时在RAM,初始值从Flash拷贝 */
    .data : AT(_sidata)
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM

    /* BSS段:运行时在RAM,初始值为0 */
    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM

    /* 栈段 */
    .stack :
    {
        . = ALIGN(8);
        _sstack = .;
        . = . + 1024;   /* 1KB栈空间 */
        . = ALIGN(8);
        _estack = .;
    } > RAM
}

我曾经踩过的坑:.data段一定要用AT(_sidata)指定加载地址(LMA)和运行地址(VMA)。如果不写AT,链接器默认VMA等于LMA,启动代码就不知道该从哪拷贝数据。结果就是RAM里的全局变量全是垃圾值,程序跑起来各种诡异。

3.4 启动流程与链接脚本的协作关系

为了让你更直观地理解,我画了一张图,展示从芯片上电到main()的完整流程,以及链接脚本在其中扮演的角色。

芯片启动流程与链接脚本协作关系 ① 上电复位 ② 读取向量表(.isr_vector段) ③ 跳转 Reset_Handler ④ 搬运.data / 清零.bss(读链接脚本符号) ⑤ 跳转 main() 链接脚本提供的符号 _sstack:栈顶地址 _sidata:.data在Flash的起始 _sdata:.data在RAM的起始 _edata:.data在RAM的结束 _sbss:.bss在RAM的起始 _ebss:.bss在RAM的结束 _etext:代码段结束地址 这些符号由链接器自动生成 启动代码通过 ldr 指令 读取这些符号的地址 完成内存初始化 读取

3.5 常见问题与避坑指南

问题现象 根本原因 解决方案
上电后程序跑飞,进入HardFault 栈指针未正确初始化,或向量表地址不对 检查链接脚本中.isr_vector段是否放在起始地址,确认_sstack符号正确
全局变量初始值不对 .data段搬运失败,或搬运地址错误 检查启动代码中_sidata、_sdata、_edata的读取逻辑,确认AT()指定正确
BSS段变量不为0 .bss段未清零,或清零范围不对 确认_sbss和_ebss符号正确,检查清零循环的边界条件
链接时报错「region RAM overflowed」 RAM空间不足,或段布局不合理 调整链接脚本中段的顺序,或优化代码减少RAM占用

我曾经踩过的坑:有一次我移植一个RTOS到新平台,启动代码一直报HardFault。查了两天,最后发现是链接脚本里RAM的ORIGIN写错了——把0x20000000写成了0x20000001。地址没对齐,CPU直接罢工。从那以后,我每次改链接脚本都会用readelf -S检查各段的实际地址。

3.6 多核/多处理器系统的启动差异

如果你做的是多核芯片(比如Cortex-A系列+ Cortex-M系列),启动流程会更复杂。我简单提几个要点:

  • 主核先启动:通常主核(如A核)先运行,负责初始化DDR、加载固件到从核。
  • 从核等待唤醒:从核(如M核)启动后,通常先进入WFI(Wait For Interrupt)状态,等待主核通过核间通信唤醒。
  • 每个核有自己的链接脚本:每个核的代码段、数据段、栈段必须独立配置,不能重叠。

我做过一个项目,主核跑Linux,从核跑裸机程序。从核的启动代码里,我特意加了一段「自检逻辑」——先检查主核是否已经把DDR初始化好,再决定是否继续。否则从核一上来就访问DDR,数据全是乱的。

3.7 小结

启动代码和链接脚本,是BSP开发中最基础也最容易出错的部分。我的建议是:

  • 不要直接复制粘贴厂商的启动文件,自己手写一遍,理解每一行的作用。
  • 链接脚本里的符号命名要规范,方便启动代码引用。
  • 每次修改链接脚本后,用objdump -h或readelf -S检查各段的实际地址。
  • 在启动代码中加一些「自检」逻辑,比如检查栈指针是否合法、检查.data段搬运是否完成。

记住:启动代码是芯片的「第一口气」,这口气顺了,后面的C代码才能跑得稳。