第4章:启动代码与链接脚本

好,咱们今天聊聊RTOS内核最底层的那些事。启动代码和链接脚本,说白了就是告诉芯片「上电后先干啥、代码放哪里」。我刚开始做嵌入式时,总觉得这玩意儿是编译器自动搞定的,直到有一次自己写bootloader,芯片死活不跑……嗯,从那以后我老老实实把启动流程啃了一遍。

4.1 STM32的启动流程

STM32上电后,硬件会自动做几件事:

  • 从0x00000000取出栈顶指针,赋给SP
  • 从0x00000004取出复位向量,赋给PC
  • 跳转到复位向量指向的地址执行

你想想看,这不就是芯片的「开机自检」吗?硬件帮你把最基本的运行环境搭好,剩下的交给软件。

关键点:STM32的启动方式有三种——内置Flash启动、系统存储器启动、内置SRAM启动。我们做RTOS开发,99%的情况用Flash启动。

复位向量指向的代码,就是我们写的启动文件。它会完成:

  1. 初始化全局变量(把.data段从Flash拷贝到RAM)
  2. 清零BSS段
  3. 设置系统时钟
  4. 初始化堆栈
  5. 跳转到main()

我在项目中遇到过一个问题:全局变量没初始化,程序跑起来全是随机值。查了半天,发现是启动代码里忘了拷贝.data段。从那以后,我每次写启动文件都会反复检查这一步。

4.2 汇编启动代码分析

咱们直接看一段典型的STM32启动代码(简化版):

; 中断向量表
__Vectors       DCD     __initial_sp          ; 栈顶指针
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler           ; NMI中断
                DCD     HardFault_Handler     ; 硬错误
                ; ... 其他中断向量

; 复位处理函数
Reset_Handler   PROC
                EXPORT  Reset_Handler
                
                ; 1. 拷贝.data段
                LDR     r0, =__etext
                LDR     r1, =__data_start__
                LDR     r2, =__data_end__
                
copy_loop       CMP     r1, r2
                BHS     copy_done
                LDR     r3, [r0], #4
                STR     r3, [r1], #4
                B       copy_loop
copy_done
                
                ; 2. 清零.bss段
                LDR     r1, =__bss_start__
                LDR     r2, =__bss_end__
                MOV     r0, #0
                
zero_loop       CMP     r1, r2
                BHS     zero_done
                STR     r0, [r1], #4
                B       zero_loop
zero_done
                
                ; 3. 跳转到main
                BL      main
                ENDP

个人习惯:我一般会在Reset_Handler开头加一段栈检查代码,防止栈溢出。虽然会多几条指令,但调试时能省不少时间。

这段代码里,有几个地方要特别注意:

  • __etext__data_start__这些符号,是链接脚本里定义的。它们告诉启动代码数据在Flash里的位置、要拷贝到RAM的哪里。
  • 拷贝和清零用的是LDR/STR指令,每次处理4字节。如果你追求极致性能,可以用LDM/STM批量操作。
  • 最后跳转到main用的是BL指令,这样main返回后还能执行一些清理工作。不过RTOS里main一般不会返回。

4.3 链接脚本详解

链接脚本(.ld文件)是连接器的工作说明书。它告诉连接器:

  • 代码段放哪里
  • 数据段放哪里
  • 各个段的排列顺序
  • 内存的起始地址和大小

来看一个STM32F103的典型链接脚本:

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

SECTIONS
{
    .text :
    {
        KEEP(*(.isr_vector))    ; 中断向量表
        *(.text*)               ; 代码段
        *(.rodata*)             ; 只读数据
        __etext = .;            ; 数据拷贝源地址
    } > FLASH

    .data : AT (__etext)
    {
        __data_start__ = .;
        *(.data*)
        __data_end__ = .;
    } > RAM

    .bss :
    {
        __bss_start__ = .;
        *(.bss*)
        __bss_end__ = .;
    } > RAM

    .stack (NOLOAD) :
    {
        . = ALIGN(8);
        __stack_start__ = .;
        . += 0x1000;            ; 4KB栈空间
        __stack_end__ = .;
        __initial_sp = .;       ; 栈顶指针
    } > RAM
}

避坑指南:我曾经把栈大小设成0x200(512字节),结果任务切换时栈溢出,程序跑飞。后来我养成了一个习惯:每个任务至少分配1KB栈空间,RTOS内核本身再额外留2KB。

链接脚本里几个关键点:

  • KEEP关键字:告诉连接器不要优化掉中断向量表。有些编译器会自作聪明地把没用的段删掉,但中断向量表必须保留。
  • AT关键字:指定.data段在Flash里的加载地址。运行时它在RAM里,但初始值存在Flash中。
  • NOLOAD:告诉连接器这个段不需要初始化,上电时是随机值。栈就是典型的NOLOAD段。

4.4 堆栈设置与中断向量表

堆栈设置是RTOS的命门。你想想看,每个任务都有自己的栈,任务切换时要保存上下文,栈不够用就全完了。

中断向量表,说白了就是一张「中断处理函数地址表」。STM32的中断向量表默认放在0x08000000(Flash起始地址)。但RTOS运行时,有时需要把向量表搬到RAM里,方便动态修改。

搬移向量表的代码:

void vector_table_relocate(void)
{
    uint32_t *pSrc = (uint32_t *)0x08000000;
    uint32_t *pDst = (uint32_t *)0x20000000;
    int i;
    
    for (i = 0; i < 128; i++)  // STM32F103有128个中断向量
    {
        pDst[i] = pSrc[i];
    }
    
    // 设置VTOR寄存器指向新的向量表
    SCB->VTOR = (uint32_t)pDst;
}

我建议:如果你的RTOS需要动态注册中断处理函数,一定要把向量表搬到RAM。否则每次修改都要擦写Flash,速度慢不说,Flash还有擦写寿命限制。

堆栈初始化在启动代码里已经做了:

; 设置主栈指针
LDR     r0, =__initial_sp
MSR     MSP, r0

; 设置进程栈指针(如果使用双栈模式)
LDR     r0, =__process_stack
MSR     PSP, r0

这里有个细节:Cortex-M3/M4支持双栈模式——主栈(MSP)和进程栈(PSP)。RTOS内核通常用MSP,用户任务用PSP。这样即使任务栈溢出,也不会破坏内核的栈空间。

嗯,说到这我想起一个坑。有一次我调试一个RTOS,任务切换时总是莫名其妙地跑飞。查了两天才发现,是中断向量表没对齐到256字节边界。Cortex-M3要求VTOR寄存器必须按256字节对齐,否则硬件会忽略你的设置。

最后总结一下本章的核心要点:

组件 作用 注意事项
启动代码 初始化硬件、拷贝数据、跳转main 别忘了拷贝.data和清零.bss
链接脚本 定义内存布局、段位置 栈大小要留够,中断向量表要KEEP
中断向量表 存储中断处理函数地址 RAM搬移时注意对齐要求
堆栈设置 为任务提供运行空间 建议使用双栈模式隔离内核和任务

下一章咱们开始真正写RTOS内核的第一个组件——任务调度器。到时候你会看到,今天讲的启动代码和链接脚本,是调度器能跑起来的基础。