第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 .
4.3 设置栈指针
栈指针SP是C语言运行的基础。没有栈,局部变量、函数调用、中断嵌套全都玩不转。我在一个项目中遇到过这样的问题:栈指针设得太小,程序跑着跑着就莫名其妙地死机了。查了两天才发现是栈溢出把关键数据给覆盖了。
设置栈指针其实就一句话:
LDR SP, =_estack
这里的_estack是链接脚本里定义的栈顶地址。通常放在SRAM的最高地址处,因为栈是向下生长的。你想想看,如果栈顶设在SRAM中间,那栈向下生长时很容易就跑到别的数据区去了。
4.4 跳转到C语言main函数
做完所有初始化工作后,最后一步就是跳转到main函数。这里用的是BL main指令——带返回的跳转。为什么不用B?因为如果main函数执行完了,它还能返回到启动代码里做后续处理(比如进入低功耗模式)。
你看最后那行B .,这是个死循环。万一main函数真的返回了,CPU就停在这里。我一般会在后面加个BKPT指令,这样调试器能捕捉到异常返回的情况。
BL main
BKPT /* 如果main返回,触发断点 */
B .
4.5 完整的启动流程总结
咱们把整个启动流程串起来看看:
- 上电复位:CPU从0x00000000读取栈指针,从0x00000004读取Reset_Handler地址
- 执行Reset_Handler:设置栈指针、初始化数据段、清零BSS段
- 调用SystemInit:配置系统时钟、PLL等(有些芯片放在这里做)
- 跳转到main:进入C语言世界
嗯,这一章的内容就到这里。下一章咱们会深入链接脚本,看看那些_sdata、_estack到底是怎么来的。到时候你会发现,启动代码和链接脚本是密不可分的两兄弟。