【第09期】C语言的嵌入式特化 (三) —— 函数指针与回调 (Callback):解耦神器

在嵌入式开发中,你是否遇到过这样的困境:写一个串口驱动,为了兼容不同的协议解析(GPS、Modbus、私有协议),你在中断里写了无数个 if-elseswitch-case,导致代码越来越长,改一个协议就得动到底层驱动?

这一期,我们来研究如何用函数指针把“底层驱动”和“上层业务”彻底切开

1. 什么是函数指针?

我们习惯了指针指向一个变量(数据),但指针也可以指向一段代码(函数)。 函数名本身,其实就是函数的入口地址。

// 定义一个函数
void my_handler(int a) { ... }

// 定义一个能指向这类函数的指针
// 格式:返回值 (*指针名)(参数列表)
void (*p_func)(int); 

p_func = my_handler; // 指针指向函数
p_func(10);          // 通过指针调用函数,等价于 my_handler(10)

这有什么用?这就像是把函数变成了**“变量”**,可以被传递、被赋值、被替换。


2. 为什么需要回调 (Callback)?

场景痛点:强耦合 (Tight Coupling)

假设你写了一个通用的按键驱动 key_driver.c。当按键按下时,你需要执行某个动作。

初级写法:直接在驱动里调用业务函数。

// key_driver.c (底层)
#include "app_business.h" // 糟糕!底层竟然依赖上层

void Key_Scan(void) {
    if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0) {
        // 按键被按下了
        Open_Light();     // 强行调用具体的业务:开灯
        Play_Music();     // 或者是:放音乐
    }
}

问题

  1. 不通用:这个驱动只能用来开灯。如果下次我要用它做门铃,我得去改 key_driver.c 的代码。

  2. 依赖倒置:底层驱动不应该依赖上层应用。底层应该是通用的、独立的。

解决方案:回调函数

我们让上层把“想做的事”(函数)打包传给底层。底层只负责“检测按键”,检测到了就执行上层给它的任务。

高级写法

Step 1: 定义函数指针类型 (接口)

// key_driver.h
typedef void (*Key_Event_Callback_t)(void); // 定义一种函数类型

Step 2: 底层提供注册接口

// key_driver.c
static Key_Event_Callback_t  p_callback = NULL; // 内部保存这个指针

// 注册函数:上层把任务传进来
void Key_Register_Callback(Key_Event_Callback_t cb) {
    p_callback = cb;
}

// 扫描函数
void Key_Scan(void) {
    if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0) {
        // 检测到按下,但我不知道要干嘛
        // 如果上层注册了回调,我就执行它
        if (p_callback != NULL) {
            p_callback(); 
        }
    }
}

Step 3: 上层应用实现业务

// main.c
void My_DoorBell_Action(void) {
    printf("Ding Dong!\n");
}

int main(void) {
    // 初始化时,把“门铃逻辑”挂载到底层
    Key_Register_Callback(My_DoorBell_Action);
    
    while(1) {
        Key_Scan();
    }
}

妙处

  • key_driver.c 完全不知道 main.c 的存在,它只认函数指针。

  • 哪怕把这个驱动移直到另一个项目控制电机,底层代码一行都不用改,只需要在 main 里注册不同的回调即可。这就是解耦


3. 进阶:用结构体实现“面向对象”

在复杂的驱动(如 LCD、Sensor)中,我们通常把一堆函数指针封装在结构体里,这很像 C++ 的虚函数表 (V-Table)

场景:你要支持三种不同的温湿度传感器(DHT11, SHT30, AHT10),但上层业务逻辑只想调统一的接口。

// sensor_interface.h (抽象层)
typedef struct {
    uint8_t (*Init)(void);
    float   (*Read_Temp)(void);
    float   (*Read_Humi)(void);
} Sensor_Driver_t;

// sensor_sht30.c (具体实现)
float SHT30_Get_Temp(void) { ... }
float SHT30_Get_Humi(void) { ... }

// 填充接口表
Sensor_Driver_t SHT30_Driver = {
    .Init = SHT30_Init,
    .Read_Temp = SHT30_Get_Temp,
    .Read_Humi = SHT30_Get_Humi
};

// main.c (业务层)
Sensor_Driver_t *pSensor = &SHT30_Driver; // 这里可以随时切换成 DHT11_Driver

void Task(void) {
    // 统一调用,根本不管底下是哪个芯片
    float t = pSensor->Read_Temp(); 
}

这就是 Linux 内核驱动(File Operations)的核心思想,也是嵌入式实现 HAL (硬件抽象层) 的基石。


4. 陷阱:回调函数的“执行上下文”

使用回调函数时,有一个极其危险的坑,就是回调函数在谁的时间片里执行?

坑:中断里的回调

如果 Key_Scan 是在定时器中断里被调用的,那么 p_callback() 也会在中断上下文中执行。

这意味着: 上层传进来的 My_DoorBell_Action 函数:

  1. 不能耗时:不能有 Delay,不能有 printf(如果它是阻塞的)。

  2. 不能死锁:不能去拿会被主循环占用的 Mutex。

  3. 栈要够大:不要在里面定义大数组,否则会把 ISR 栈撑爆。

防御性编程: 底层驱动在文档中必须注明:“此回调在中断中执行,请勿执行耗时操作!” 或者,回调函数只负责置标志位/发送信号量,把重活交给主线程去做(这也是 RTOS 中常用的 Bottom Half 机制)。

5. 归纳一下

  1. 本质:函数指针是把函数当变量用。

  2. 核心价值解耦。让底层驱动不再依赖上层逻辑,实现模块化复用。

  3. 高级玩法:结构体封装函数指针 = C语言的类和多态。

  4. 注意:关注回调函数的执行环境(中断还是线程),防止因执行时间过长导致系统实时性崩溃。

附录:

 我们讲了普通的函数指针。但在嵌入式启动文件(startup_stm32.s)的中断向量表里,存放的其实也是一堆函数指针(ISR入口地址)。 为什么那里不直接用C语言的函数指针数组定义,而是往往要用汇编或者特殊的 __attribute__((section(...))) 来写? 提示:这跟指针存放的物理地址(Flash的0地址)有关。

简单的答案是:标准C语言无法保证变量的“绝对物理地址”,而CPU启动需要绝对地址。

以下是详细的深度解析,帮你揭开启动文件的面纱:

1. 硬件的死板规矩:地址必须是 0x00000000

ARM Cortex-M 核在复位(Reset)那一瞬间,它的硬件逻辑是写死的。它只会做两件事:

  1. 从物理地址 0x00000000 读取 4字节,赋给 MSP (主堆栈指针)。

  2. 从物理地址 0x00000004 读取 4字节,赋给 PC (程序计数器,即 Reset_Handler 的入口)。

这意味着,中断向量表(Vector Table)必须精确地、毫无偏差地“躺”在 Flash 的最开始位置。

2. 标准 C 语言的无力感

如果你在 C 语言里写一个普通的函数指针数组:

// 普通写法
void (*vector_table[])(void) = {
    (void (*)(void))0x20001000, // Stack Top
    Reset_Handler,              // Reset Handler
    NMI_Handler,                // ...
};

这就产生了一个巨大的问题:编译器和链接器(Linker)有权决定这个数组放在哪。

  • 随机性:链接器通常会把这个数组放在 .rodata 段(只读数据段)。但是 .rodata 段里可能还有字符串常量、const 变量等。

  • 乱序:链接器可能会为了优化空间,把这个数组放在 Flash 的中间位置(比如 0x08000500)。

  • 结果:CPU 上电去查 0x00000000,发现那里可能是一堆字符串或者空数据,根本不是堆栈指针,直接 HardFault 或无法启动。

3. __attribute__((section(...))) 的作用:给链接器下命令

为了让这个数组乖乖待在 Flash 的起始位置,我们需要绕过 C 语言的默认规则,直接指挥链接器。

Step 1: 打标签 (C 代码) 我们在代码里用 __attribute__ 给这个数组贴上一个特殊的标签(Section Name),比如叫 .isr_vector

// 特殊写法
__attribute__((section(".isr_vector")))
const void (*g_pfnVectors[])(void) = {
    (void *)0x20005000,   // Initial Stack Pointer
    Reset_Handler,        // Reset Handler
    ...
};

这就告诉编译器:“别把它当普通数据,把它扔到 .isr_vector 这个特殊的盒子里去。”

Step 2: 定位置 (Linker Script / .ld 文件) 然后,在链接脚本(Linker Script)里,我们会明确规定:.isr_vector 这个盒子,必须放在 Flash 的最开头!

/* STM32 Linker Script 片段 */
MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
}

SECTIONS
{
  .text :
  {
    KEEP(*(.isr_vector)) /* 把 .isr_vector 放在最前面! */
    *(.text*)            /* 然后才是普通代码 */
    ...
  } > FLASH
}

4. 汇编 vs C 的初始化悖论

还有一个更深层的原因:“先有鸡还是先有蛋?”

  • 标准 C 数组的初始化:普通的全局变量(即使是 const),在 C 语言的世界观里,可能需要 C 运行时(CRT)的初始化代码来搬运或赋值。

  • 启动需求:中断向量表包含 Reset_Handler。而 Reset_Handler 就是 负责初始化 C 运行环境的代码!

  • 悖论:如果向量表本身需要被初始化才能用,那谁来执行初始化呢?

所以,向量表必须是 Raw Binary Data(纯二进制数据),直接烧录在 Flash 里,上电即用,不需要任何代码去“生成”它。

用汇编 (.s) 写,或者用 C 语言配合 const + section,本质都是为了生成这份纯粹的、位置固定的静态数据表

所以答案是:

之所以不用标准 C 数组,是因为:

  1. 位置失控:标准 C 无法保证数组位于物理地址 0x00000000,而 CPU 硬件强行要求这一点。

  2. 依靠链接器:我们需要通过 section 属性配合链接脚本(Linker Script),强制将这段数据固定在 Flash 的起始位置。

  3. 脱离 C Runtime:向量表必须在 C 语言环境建立之前就已经存在并生效,它是系统启动的“第一推动力”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值