第4章:启动代码与链接脚本
好,咱们今天聊聊RTOS内核最底层的那些事。启动代码和链接脚本,说白了就是告诉芯片「上电后先干啥、代码放哪里」。我刚开始做嵌入式时,总觉得这玩意儿是编译器自动搞定的,直到有一次自己写bootloader,芯片死活不跑……嗯,从那以后我老老实实把启动流程啃了一遍。
4.1 STM32的启动流程
STM32上电后,硬件会自动做几件事:
- 从0x00000000取出栈顶指针,赋给SP
- 从0x00000004取出复位向量,赋给PC
- 跳转到复位向量指向的地址执行
你想想看,这不就是芯片的「开机自检」吗?硬件帮你把最基本的运行环境搭好,剩下的交给软件。
关键点:STM32的启动方式有三种——内置Flash启动、系统存储器启动、内置SRAM启动。我们做RTOS开发,99%的情况用Flash启动。
复位向量指向的代码,就是我们写的启动文件。它会完成:
- 初始化全局变量(把.data段从Flash拷贝到RAM)
- 清零BSS段
- 设置系统时钟
- 初始化堆栈
- 跳转到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内核的第一个组件——任务调度器。到时候你会看到,今天讲的启动代码和链接脚本,是调度器能跑起来的基础。