在嵌入式开发中,你是否遇到过这样的困境:写一个串口驱动,为了兼容不同的协议解析(GPS、Modbus、私有协议),你在中断里写了无数个 if-else 或 switch-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(); // 或者是:放音乐
}
}
问题:
-
不通用:这个驱动只能用来开灯。如果下次我要用它做门铃,我得去改
key_driver.c的代码。 -
依赖倒置:底层驱动不应该依赖上层应用。底层应该是通用的、独立的。
解决方案:回调函数
我们让上层把“想做的事”(函数)打包传给底层。底层只负责“检测按键”,检测到了就执行上层给它的任务。
高级写法:
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 函数:
-
不能耗时:不能有
Delay,不能有printf(如果它是阻塞的)。 -
不能死锁:不能去拿会被主循环占用的 Mutex。
-
栈要够大:不要在里面定义大数组,否则会把 ISR 栈撑爆。
防御性编程: 底层驱动在文档中必须注明:“此回调在中断中执行,请勿执行耗时操作!” 或者,回调函数只负责置标志位/发送信号量,把重活交给主线程去做(这也是 RTOS 中常用的 Bottom Half 机制)。
5. 归纳一下
-
本质:函数指针是把函数当变量用。
-
核心价值:解耦。让底层驱动不再依赖上层逻辑,实现模块化复用。
-
高级玩法:结构体封装函数指针 = C语言的类和多态。
-
注意:关注回调函数的执行环境(中断还是线程),防止因执行时间过长导致系统实时性崩溃。
附录:
我们讲了普通的函数指针。但在嵌入式启动文件(startup_stm32.s)的中断向量表里,存放的其实也是一堆函数指针(ISR入口地址)。 为什么那里不直接用C语言的函数指针数组定义,而是往往要用汇编或者特殊的 __attribute__((section(...))) 来写? 提示:这跟指针存放的物理地址(Flash的0地址)有关。
简单的答案是:标准C语言无法保证变量的“绝对物理地址”,而CPU启动需要绝对地址。
以下是详细的深度解析,帮你揭开启动文件的面纱:
1. 硬件的死板规矩:地址必须是 0x00000000
ARM Cortex-M 核在复位(Reset)那一瞬间,它的硬件逻辑是写死的。它只会做两件事:
-
从物理地址
0x00000000读取 4字节,赋给 MSP (主堆栈指针)。 -
从物理地址
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 数组,是因为:
-
位置失控:标准 C 无法保证数组位于物理地址
0x00000000,而 CPU 硬件强行要求这一点。 -
依靠链接器:我们需要通过
section属性配合链接脚本(Linker Script),强制将这段数据固定在 Flash 的起始位置。 -
脱离 C Runtime:向量表必须在 C 语言环境建立之前就已经存在并生效,它是系统启动的“第一推动力”。
2233

被折叠的 条评论
为什么被折叠?



