28、汇编与C的混合编程:在启动代码中调用C函数
说实话,很多做嵌入式开发的朋友,写了几年C代码,一碰到汇编就头疼。但启动代码这块,你绕不开它。我刚开始做裸机移植的时候,也踩过不少坑——明明C函数写得没问题,一上电就跑飞了。后来才明白,问题出在汇编怎么调C这个环节上。
今天我们就来聊聊,在启动代码里,汇编是怎么把控制权交给C函数的。说白了,就是那个关键的跳转——从复位向量到main()之间的那座桥。
为什么要在汇编里调C函数?
你想想看,芯片上电后,RAM还没初始化,C语言的全局变量都没地方放。这时候你没法直接跑C代码。必须先用汇编把最基本的硬件环境搭好:
- 关看门狗
- 初始化堆栈指针
- 清零BSS段
- 拷贝数据段
这些事干完了,才能放心地跳进C的世界。我在项目中遇到过一种情况:有人图省事,直接在汇编里把BSS清零和main()调用写在一起,结果全局变量初始化顺序乱了,程序跑起来数据全是错的。嗯,这种坑一次就够了。
核心机制:BL指令与调用约定
ARM汇编里调C函数,最常用的就是BL指令。它干了两件事:
- 把下一条指令的地址存入LR寄存器
- 跳转到目标地址执行
C函数执行完,通过BX LR就能返回。听起来简单吧?但这里有个关键点——你得保证调用前的环境是C函数能接受的。
核心原则:在调用C函数之前,必须确保堆栈指针(SP)已经指向有效的RAM区域,并且堆栈对齐到8字节(ARM AAPCS标准要求)。
我曾经在一个Cortex-M3的项目上,因为堆栈只对齐了4字节,结果调用printf()时直接进了HardFault。查了两天才发现是堆栈对齐的问题。从那以后,我每次写启动代码都会在SP初始化后面加一句注释:/* 8-byte stack alignment */。
标准启动流程中的C函数调用
一个典型的启动代码,调用C函数的顺序大概是这样的:
; 汇编启动代码片段 (ARM Cortex-M)
Reset_Handler:
; 1. 设置堆栈指针
LDR SP, =_estack
; 2. 调用底层初始化(C函数)
BL SystemInit
; 3. 调用C运行时初始化
BL __libc_init_array
; 4. 跳转到main()
BL main
; 5. main返回后的死循环
B .
你看,这里连续调了三个C函数。每个函数都有它的职责:
| 函数名 | 职责 | 调用时机 |
|---|---|---|
| SystemInit | 配置系统时钟、PLL、Flash等待周期 | 堆栈就绪后立即调用 |
| __libc_init_array | 初始化C库、调用全局构造函数 | BSS清零、数据段拷贝之后 |
| main | 用户应用程序入口 | 所有初始化完成后 |
小技巧:我习惯在SystemInit()里加一个GPIO翻转,用来示波器观察启动时间。这样能直观看到从复位到main()花了多少微秒,调试启动性能时特别好用。
参数传递:汇编怎么给C函数传参?
ARM的调用约定规定,前4个参数通过R0-R3传递。所以汇编里要传参,直接把值放进这些寄存器就行:
; 调用 C 函数:void delay_init(uint32_t freq, uint8_t mode)
MOV R0, #72000000 ; 第一个参数:系统频率
MOV R1, #1 ; 第二个参数:模式选择
BL delay_init
如果参数超过4个,就得用堆栈传了。不过说实话,我在启动代码里很少见到超过4个参数的C函数——启动代码讲究简洁高效,搞那么复杂干嘛?
返回值怎么拿?
C函数的返回值放在R0里。汇编拿到后可以直接用:
BL get_cpu_id ; 返回值在R0
LDR R1, =CPU_ID_ADDR
STR R0, [R1] ; 保存CPU ID到指定地址
嗯,这里要注意一点:如果C函数返回的是64位值(比如long long),那R0和R1会各占一半。我早期做32位MCU时没注意这个,把一个64位时间戳当32位用,结果时间每4秒就回绕一次……
避坑指南:汇编调C的常见陷阱
陷阱一:堆栈未初始化就调C函数
C函数哪怕只是定义一个局部变量,也要用堆栈。堆栈指针没设好,一调用就崩。我曾经见过有人把BL main写在SP初始化前面,结果程序永远跑不到第一行C代码。
陷阱二:中断未关闭导致重入
启动过程中如果开了中断,而中断服务程序又调用了C函数,而此时全局变量还没初始化好——恭喜你,喜提一个随机崩溃。我建议在进入main()之前,保持全局中断关闭。
陷阱三:BSS段未清零就使用全局变量
C函数里如果引用了未初始化的全局变量,它们的默认值应该是0。但BSS段没清零的话,这些变量里全是随机值。我有个同事因为这个bug查了三天,最后发现是启动代码里忘了调用BSS清零函数。
实战:一个完整的汇编调C示例
下面是一个简化但完整的启动流程,展示了汇编如何一步步把控制权交给C:
; startup.s
AREA RESET, CODE, READONLY
EXPORT Reset_Handler
IMPORT SystemInit
IMPORT __main ; C库入口,包含BSS清零等
Reset_Handler
; 第一步:堆栈必须最先搞定
LDR SP, =_estack
; 第二步:关看门狗(通常用C函数做)
BL SystemInit
; 第三步:C运行时初始化
; __main 内部会调用:
; - __scatterload (数据段拷贝)
; - __rt_entry (BSS清零)
; - 最后跳转到 main()
BL __main
; 正常情况下不会执行到这里
B .
END
对应的C文件:
/* system.c */
void SystemInit(void)
{
/* 关闭看门狗 */
*((volatile uint32_t *)0x40000000) = 0x5A5A;
/* 配置系统时钟为72MHz */
/* ... 时钟配置代码 ... */
/* 我习惯在这里点一个LED,表示启动代码跑通了 */
*((volatile uint32_t *)0x40001000) = 0x01;
}
int main(void)
{
/* 用户应用程序 */
while(1)
{
/* 主循环 */
}
return 0;
}
SVG流程图:汇编调C的完整调用链
这张图把整个调用链串起来了。你注意看,从复位到main(),中间经过了两次C函数调用。第一次是SystemInit,干的是硬件层面的脏活累活;第二次是__main,干的是C运行时的家务活。两次都搞定,main()才能安心跑起来。
总结一下
汇编调C函数,说白了就三件事:
- 环境准备好——堆栈、时钟、内存段
- 参数传对——R0-R3放参数,别超4个
- 收尾处理好——返回值从R0拿,别忘关中断
我个人习惯在启动代码里加一个短延迟函数,用汇编的循环指令写,不依赖任何C库。这样在调C函数之前,我可以先确认CPU跑在正确的频率上。这个小技巧帮我省了不少调试时间。
嗯,关于汇编与C的混合编程,今天就聊到这儿。记住一点:启动代码是嵌入式系统的地基,地基不稳,上面盖的楼再漂亮也没用。
公众号:蓝海资料掘金营,微信deep3321