28、汇编与C的混合编程:在启动代码中调用C函数

说实话,很多做嵌入式开发的朋友,写了几年C代码,一碰到汇编就头疼。但启动代码这块,你绕不开它。我刚开始做裸机移植的时候,也踩过不少坑——明明C函数写得没问题,一上电就跑飞了。后来才明白,问题出在汇编怎么调C这个环节上。

今天我们就来聊聊,在启动代码里,汇编是怎么把控制权交给C函数的。说白了,就是那个关键的跳转——从复位向量到main()之间的那座桥。

为什么要在汇编里调C函数?

你想想看,芯片上电后,RAM还没初始化,C语言的全局变量都没地方放。这时候你没法直接跑C代码。必须先用汇编把最基本的硬件环境搭好:

  • 关看门狗
  • 初始化堆栈指针
  • 清零BSS段
  • 拷贝数据段

这些事干完了,才能放心地跳进C的世界。我在项目中遇到过一种情况:有人图省事,直接在汇编里把BSS清零和main()调用写在一起,结果全局变量初始化顺序乱了,程序跑起来数据全是错的。嗯,这种坑一次就够了。

核心机制:BL指令与调用约定

ARM汇编里调C函数,最常用的就是BL指令。它干了两件事:

  1. 把下一条指令的地址存入LR寄存器
  2. 跳转到目标地址执行

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的完整调用链

汇编调用C函数完整流程 复位向量 → Reset_Handler 汇编阶段:设置SP、关看门狗、配置时钟 BL SystemInit(C函数:底层硬件初始化) BL __main(C库:BSS清零 + 数据段拷贝) 进入 main() 用户代码 汇编环境 第一次进入C 运行时初始化 用户应用程序

这张图把整个调用链串起来了。你注意看,从复位到main(),中间经过了两次C函数调用。第一次是SystemInit,干的是硬件层面的脏活累活;第二次是__main,干的是C运行时的家务活。两次都搞定,main()才能安心跑起来。

总结一下

汇编调C函数,说白了就三件事:

  • 环境准备好——堆栈、时钟、内存段
  • 参数传对——R0-R3放参数,别超4个
  • 收尾处理好——返回值从R0拿,别忘关中断

我个人习惯在启动代码里加一个短延迟函数,用汇编的循环指令写,不依赖任何C库。这样在调C函数之前,我可以先确认CPU跑在正确的频率上。这个小技巧帮我省了不少调试时间。

嗯,关于汇编与C的混合编程,今天就聊到这儿。记住一点:启动代码是嵌入式系统的地基,地基不稳,上面盖的楼再漂亮也没用。


公众号:蓝海资料掘金营,微信deep3321