第4章:汇编基础与启动代码

好,咱们进入嵌入式开发的第一个硬核环节——汇编与启动代码。说实话,很多初学者觉得汇编已经过时了,直接写C不香吗?嗯,我在项目里也这么想过,直到有一次芯片上电后直接跑飞,我才意识到:没有汇编启动代码,C语言连个站的地方都没有。

4.1 ARM/Thumb指令集基础

ARM处理器有两种工作状态:ARM状态和Thumb状态。说白了,ARM指令是32位的,功能强但占地方;Thumb指令是16位的,代码密度高但功能弱一些。我刚开始做项目时,为了省Flash空间,全部用Thumb指令,结果发现有些复杂运算得切回ARM状态,折腾了好几天。

这里我列几个最常用的指令,你写启动代码时基本就靠它们了:

指令 功能 示例
LDR 从内存加载数据到寄存器 LDR R0, =0x20000000
STR 将寄存器数据存储到内存 STR R1, [R0]
MOV 寄存器间数据传送 MOV SP, R0
B 无条件跳转 B main
BL 带返回的跳转(调用函数) BL SystemInit
PUSH/POP 压栈/出栈 PUSH {R4-R7, LR}
我的小习惯:写启动代码时,我一般用大写指令名,这样一眼就能看出是汇编代码。另外,注释一定要写清楚每条指令的目的——别问我为什么,问就是曾经被自己写的无注释汇编坑过。

4.2 启动汇编代码(Reset_Handler)

芯片上电后,CPU会从复位向量表里找到Reset_Handler的地址,然后跳过去执行。这个Reset_Handler就是整个系统的入口点。我见过不少新手直接拿现成的启动文件用,结果出了问题根本不知道从哪查起。

咱们来看一个典型的Reset_Handler实现:

Reset_Handler:
    /* 设置栈指针 */
    LDR SP, =_estack
    
    /* 复制.data段到SRAM */
    LDR R0, =_sdata
    LDR R1, =_edata
    LDR R2, =_sidata
    MOVS R3, #0
    B LoopCopyDataInit
    
CopyDataInit:
    LDR R4, [R2, R3]
    STR R4, [R0, R3]
    ADDS R3, R3, #4
    
LoopCopyDataInit:
    ADDS R4, R0, R3
    CMP R4, R1
    BCC CopyDataInit
    
    /* 清零.bss段 */
    LDR R0, =_sbss
    LDR R1, =_ebss
    MOVS R2, #0
    B LoopFillZerobss
    
FillZerobss:
    STR R2, [R0]
    ADDS R0, R0, #4
    
LoopFillZerobss:
    CMP R0, R1
    BCC FillZerobss
    
    /* 跳转到C语言main函数 */
    BL main
    B .
关键点:这段代码做了三件大事——设置栈指针、初始化数据段、清零BSS段。少一步,你的C程序就跑不起来。

4.3 设置栈指针

栈指针SP是C语言运行的基础。没有栈,局部变量、函数调用、中断嵌套全都玩不转。我在一个项目中遇到过这样的问题:栈指针设得太小,程序跑着跑着就莫名其妙地死机了。查了两天才发现是栈溢出把关键数据给覆盖了。

设置栈指针其实就一句话:

LDR SP, =_estack

这里的_estack是链接脚本里定义的栈顶地址。通常放在SRAM的最高地址处,因为栈是向下生长的。你想想看,如果栈顶设在SRAM中间,那栈向下生长时很容易就跑到别的数据区去了。

我曾经踩过的坑:有一次我用的芯片SRAM只有8KB,我随手把栈设成了4KB,结果程序里用了大量局部数组,直接栈溢出。后来我养成了一个习惯:在链接脚本里明确分配栈大小,并且在启动代码里加一个栈溢出检测。

4.4 跳转到C语言main函数

做完所有初始化工作后,最后一步就是跳转到main函数。这里用的是BL main指令——带返回的跳转。为什么不用B?因为如果main函数执行完了,它还能返回到启动代码里做后续处理(比如进入低功耗模式)。

你看最后那行B .,这是个死循环。万一main函数真的返回了,CPU就停在这里。我一般会在后面加个BKPT指令,这样调试器能捕捉到异常返回的情况。

    BL main
    BKPT        /* 如果main返回,触发断点 */
    B .

4.5 完整的启动流程总结

咱们把整个启动流程串起来看看:

  1. 上电复位:CPU从0x00000000读取栈指针,从0x00000004读取Reset_Handler地址
  2. 执行Reset_Handler:设置栈指针、初始化数据段、清零BSS段
  3. 调用SystemInit:配置系统时钟、PLL等(有些芯片放在这里做)
  4. 跳转到main:进入C语言世界
我的建议:刚开始学的时候,别急着用IDE自动生成的启动文件。自己手写一遍,哪怕只是抄一遍,你对整个系统的理解都会上一个台阶。我当年就是这么过来的,虽然写得很丑,但收获巨大。

嗯,这一章的内容就到这里。下一章咱们会深入链接脚本,看看那些_sdata_estack到底是怎么来的。到时候你会发现,启动代码和链接脚本是密不可分的两兄弟。