模块化编程为何使用函数指针分析(一)(深入分析指针的实际应用)
文章目录
- 1、指针的使用
- 2、函数指针
- 2.1 问题解读
- 第三个问题:
- 第四个问题:
- 第二个问题:
- 第一个问题:
- 1. 从“依赖实现”到“依赖抽象”
- 2. 接口标准化实现“替换透明性”
- 1. 接口与实现分离
- 2. 生命周期可控性
- 3. 模块化测试与替换
- 4. 降低全局命名冲突风险
- 1.类型安全风险
- 2 多线程安全问题
- 3. 架构层级模糊
1、为什么使用函数指针?
2、函数指针与我们模块化编程的思想?
3、我们使用指针间接访问,其实不也是一种全局的操作,和全局变量有什么不一样的吗?
4、既然是使用了指针可以跨文件访问,我们使用不使用static又有什么关系?
1、指针的使用
在使用指针的时候要明确两个事情:
1、指针的类型
2、指针指向的地址,避免野指针
这两个事情缺一不可,少一个就是错误!注意是错误!不是代码编写不规范,是直接的错误!
其他使用指针的方法详情见文章:
深入理解C语言内存空间、函数指针(三)(重点是函数指针)
数据结构—链表结构体、指针深入理解(三)
2、函数指针
2.1 问题解读
我们以一个结构体为例
我们在一个multi_button.h
文件里面定义了一个结构体变量
typedef struct _Button Button;struct _Button {uint16_t ticks; // tick counteruint8_t repeat : 4; // repeat counter (0-15)uint8_t event : 4; // current event (0-15)uint8_t state : 3; // state machine state (0-7)uint8_t debounce_cnt : 3; // debounce counter (0-7)uint8_t active_level : 1; // active GPIO level (0 or 1)uint8_t button_level : 1; // current button leveluint8_t button_id; // button identifieruint8_t (*hal_button_level)(uint8_t button_id); // HAL function to read GPIOBtnCallback cb[BTN_EVENT_COUNT]; // callback function arrayButton* next; // next button in linked list
};
在后在A文件里面实例化
#include "multi_button.h"static Button btn1, btn2;
由于有static关键字的作用,我们的变量btn1和btn2
作用域只在A文件。但是我们想在B文件对btn1和btn2
进行初始化或者赋值操作,可以通过将btn1和btn2
的首地址传递给B文件里面的函数,也就是说在A文件中调用B文件中的函数(换句话理解就是B文件中有留给A文件的接口,进行接收相关变量),从而对btn1和btn2
进行相关操作。这里面需要说明的是其实B文件并不知道我现在处理的是btn1和btn2
的实例化,指知道我现在处理的是一个结构体。
可以总结为:
- static变量的作用域:
static Button btn1, btn2;
使这两个变量仅在A文件内可见- B文件无法直接访问
btn1/btn2
的符号
- 跨文件操作的正确方式:
- 传递指针:A文件将
&btn1
和&btn2
传递给B文件的函数 - 类型抽象:B文件只处理
Button*
类型指针,不关心具体实例名 - 接口封装:B文件提供初始化函数如
button_init(Button* btn)
- 传递指针:A文件将
- 封装性:
- B文件无需知道A文件的具体实现细节。
- 修改A文件的变量名不影响B文件。
由于static的作用,使得btn1和btn2
只作用于A文件,是不是说因为他是全局变量,所以并不会随着函数在栈空间的消失而消失。
- 存储位置与生命周期:
- 不在栈上:
static
全局变量存储在静态存储区(data段或bss段) - 永久存在:从程序启动时创建,直到程序结束才销毁
- 不受函数影响:不会随函数调用结束而消失(与局部变量完全不同)
- 不在栈上:
- 作用域限制:
- 文件作用域:
static
关键字将其可见性限制在定义它的文件内(A文件) - 全局存在:虽然作用域受限,但物理上仍是全局存储的变量
- 文件作用域:
但是这个里面就引出了开头说的第三个问题:
3、我们使用指针间接访问,其实不也是一种全局的操作,和全局变量有什么不一样的吗?
4、既然是使用了指针可以跨文件访问,我们使用不使用static又有什么关系?
第三个问题:
通过指针间接访问与直接使用全局变量确实都能实现跨文件操作数据,但它们在封装性、安全性和设计理念上有本质区别。
在物理内存层面,两种方式都是在操作全局存储区域的数据。但是我们全局变量我们没有办法进行一些约束和规范就相当于是全局变量 = 把金条堆在公共广场上任人拿取
,使用指针传递我们就可以进行一些约束和规范就相当于是- 指针传递 = 通过银行系统管理黄金,需要特定凭证才能操作
。
此外全局变量相当于是:所有模块都知道数据细节,牵一发而动全身,因为如果在一个地方进行了修改,那么跟其相关的都会进行修改,代码耦合度高,修改全局变量可能影响多个模块。
可是我们使用指针间接访问,不也会是会有这种影响。又怎么去理解?
首先如果是全局变量,其他文件要想访问必须要使用extern
关键字声明。这样就会导致任何一个文件都可能访问,像一个公交车。但是使用指针间接访问以后,我们虽然也是全文件访问,但是我们是通过暴露一些必要的接口才能给一些文件访问,这样就保证了约束性。并且我们还要在定义的地方使用关键字static
更加进一步限制了原本定义的作用域,更好的进一步保护相关变量。
但是如果滥用指针,若指向全局数据且无约束,其风险与全局变量无异。
指针间接访问的耦合风险取决于指针指向的数据作用域和使用方式。
因此在使用的时候一定要合理使用指针:通过显式传递和封装,可实现低耦合的模块间通信。如:使用函数封装指针操作、 将数据与操作绑定在类/结构体中,通过成员函数控制访问(如C++引用或智能指针)。
第四个问题:
针对这个问题,其实在第三个问题中已经进行了相关阐述,
- 作用域限制:
static
修饰的全局变量仅在当前文件内可见,其他文件无法通过extern
直接声明或访问。 - 间接访问的可能性:若通过函数返回变量地址或指针传递,其他文件可间接访问该变量。例如:
如果 不使用static
的全局变量 - 全局可见性:未加
static
的全局变量可通过extern
在其他文件中直接声明和访问:
尽管函数指针可绕过static
限制
强制封装,避免误用、降低耦合,使用static
全局变量,通过指针和函数控制访问。
避免符号污染 大型项目中,不同模块的同名辅助函数(如helper()
)用static
隔离可防止链接冲突:
- 指针的核心价值:在跨文件调用中实现动态性和解耦,但需警惕类型安全与性能开销。
-
static
的不可替代性:提供封装性和符号隔离,是模块化设计的基石。 - 协作策略:
- 用
static
保护不应直接暴露的函数(如硬件操作、算法核心); - 通过函数指针按需暴露可控接口(如回调注册、HAL层);
- 头文件中用
typedef
明确函数指针类型,提升类型安全性。
- 用
二者并非对立,而是互补关系。核心设计模式:用static
封装实现细节,通过函数指针暴露可控接口。
第二个问题:
函数指针与我们模块化编程的思想?
通过上述我们可以知道,虽然我们使用了static
仍然避免不了使用指针的间接跨文件访问,其实在这里本人一直有一个误区就是:
避免耦合度度高,我们可以避免使用全局变量,使用指针进行跨文件间接访问,但是这样不也是类似于全局变量,只不过进行了一些约束。
例如在一个项目中,显示内容需要根据按键的结果进行显示,那是不是就需要再显示文件引用按键的信息,或是按键文件调用显示的接口,还是进行了一些跨文件的访问按键或者显示,是不是我这样理解是错误的,解耦不是进制数据流动。不然就是孤立的一个个文件,没有联系了。
之前一直理解的就是孤立存在的, 有点禁止数据流流动的意思。
因此需要修正:
- 解耦不是禁止跨文件访问,而是通过抽象减少直接依赖。
- 指针/接口是解耦工具,但需结合设计模式(如观察者、依赖注入)规范使用。
- 你的按键-显示案例,最佳实践是事件驱动或消息传递,而非互相调用函数或共享全局变量。
第一个问题:
为什么使用函数指针?或者说为什么使用指针?
在嵌入式系统开发中,通过结构体指针间接访问静态变量(如 static Button btn1
)并配合初始化函数实现模块解耦,是一种常见的设计模式。这种设计的核心意义并非完全禁止跨文件访问变量,而是通过受控的接口暴露和访问权限约束,实现更高层次的架构灵活性、安全性和可维护性。
也就是说指针是这种解耦思想最好的工具,因此不管是使用函数指针还是指针其实很大程度都是这种思路。所以才是用指针和函数指针。
下面是关于这种模块化编程思想的进一步解读:
解耦的本质是权限约束而非物理隔离。
通过指针传递变量地址(如按键结构体)实现“跨层访问”,看似仍是跨文件操作变量,但本质是通过受限的接口暴露和权限约束实现逻辑解耦。
解耦的核心意义:尽管本质仍是跨文件操作变量,但此设计不同机制实现有约束的解耦。
通过指针间接访问变量是技术手段,但分层架构通过规则限制其使用范围:
- 变量定义受限:
static Button btn1
在文件 A 中定义,禁止外部文件直接通过变量名访问(如extern
声明会链接失败)。 - 访问权限受控:
文件 B 只能通过文件 A 主动传递的指针(如button_init(&btn1)
)访问变量。若文件 A 不主动暴露地址,文件 B 无法强行获取。 - 操作边界明确:
文件 B 通过函数指针head_handle
操作按键时,仅能调用文件 A 提供的接口函数(如button_read()
),而非直接读写结构体内部字段。这相当于在变量周围建立“防护墙”。
解耦的核心是“依赖倒置
解耦的核心:约束数据流动的路径与权限
分层解耦的核心意义不在于“禁止跨层访问数据”,而在于反转模块间的依赖关系:
1. 从“依赖实现”到“依赖抽象”
- 传统紧耦合模式:
应用层直接调用硬件层函数(如read_gpio(P1_0)
),更换硬件需重写应用层代码。 - 分层解耦模式:
应用层依赖驱动层提供的消息接口(如on_key_press()
),不关心底层硬件是 GPIO 还是 I²C 扩展芯片。硬件变更只需重写驱动层,应用层无感知。
2. 接口标准化实现“替换透明性”
- 驱动层对应用层提供统一按键消息(按下/松开/长按),无论底层按键是独立 IO 还是矩阵键盘。
// 硬件层变更:从GPIO切换到I²C
static void key_scan() {// 旧版:key_data = read_gpio(P1_0);// 新版:key_data = i2c_expander_read(0x20); // 应用层无需修改
}
紧耦合模式:硬件依赖贯穿应用层
// 应用层代码(app.c)
void control_heater() {// 直接调用硬件操作函数(依赖具体传感器型号)float temp = ds18b20_read(PORT_A, PIN_5); // 直接读取DS18B20的引脚if (temp < 45.0) {pwm_set_duty(HEATER_PWM_CH, 80); // 直接操作PWM寄存器} else if (temp >= 60.0) {pwm_set_duty(HEATER_PWM_CH, 0);}
}
- 硬件绑定:应用层直接调用
ds18b20_read()
和pwm_set_duty()
,函数名和参数(如PORT_A, PIN_5
)均依赖具体硬件型号; - 更换成本高:若温度传感器更换为I²C接口的LM75,需重写所有调用
ds18b20_read()
的代码; - 可测试性差:测试业务逻辑需搭建真实硬件环境。
分层解耦模式:接口抽象隔离硬件变更
// ---------- 驱动层 hal_driver.c ----------
#include "hal_interface.h"/* 具体硬件操作函数 (隐藏实现) */
static float read_ds18b20() { return ds18b20_read(PORT_A, PIN_5); // 单总线协议实现
}
static void set_pwm_duty(uint8_t duty) {pwm_set_duty(HEATER_PWM_CH, duty); // PWM寄存器操作
}/* 接口结构体初始化 */
static TempSensor s_temp_sensor = { .read_temp = read_ds18b20 };
static HeaterController s_heater = { .set_power = set_pwm_duty };/* 暴露接口的函数 */
TempSensor* hal_get_sensor() { return &s_temp_sensor; }
HeaterController* hal_get_heater() { return &s_heater; }// ---------- 应用层 app.c ----------
#include "hal_interface.h"void control_system() {TempSensor* sensor = hal_get_sensor(); // 获取传感器接口HeaterController* heater = hal_get_heater(); // 获取加热器接口float temp = sensor->read_temp(); // 抽象读取if (temp < 45.0) {heater->set_power(80); // 抽象控制}
}
以上两个例子,一个是通过注册回调函数指针的方式实现,一个是通过接口函数的方式实现,结果是一样的,但是思路不一样,后续会针对这两个进行详细的讲解。也就是下面的2.2和2.3章节。
解耦的本质不是禁止跨层访问,而是通过接口设计,让跨层访问变得规范、可控、可替换。如同电力插座的标准化:电器(应用层)无需关心发电厂(硬件)是火力还是风力发电,只需符合插座(驱动接口)规范即可工作。
解耦 ≠ 禁止数据流动:
而是规范数据流动的路径(如通过接口而非全局变量)。
解耦 ≠ 消除依赖:
而是将“具体实现依赖”升级为“抽象接口依赖”。
指针是工具而非目的:
指针传递是实现封装的手段,其价值在于限制变量被任意访问的自由度。
维度 | 全局变量 | 指针传递/接口访问 | 解耦意义 |
---|---|---|---|
可见性 | 全局可见,任意文件可读写 | 通过接口受限访问 | 避免数据被意外篡改 |
修改入口 | 分散在多处,无统一管控 | 仅限驱动层内部修改(如中断函数) | 修改行为可监控、可追溯 |
硬件依赖性 | 应用层代码包含硬件操作细节 | 应用层仅处理抽象事件 | 硬件升级仅需重写驱动层 |
测试可行性 | 需搭建真实硬件环境测试 | 应用层可通过模拟消息接口测试 | 脱离硬件进行逻辑验证 |
1. 接口与实现分离
- 文件 A 仅暴露初始化函数
button_init()
,隐藏btn1
的存在和内部结构细节; - 文件 B 仅依赖
Button*
类型指针,无需包含btn1
的具体定义(可通过前向声明减少头文件依赖)。
意义:文件 B 的代码不依赖文件 A 的具体实现,更换按键硬件时只需修改文件 A,文件 B 无需改动。
2. 生命周期可控性
btn1
的创建和销毁由文件 A 管理(如静态变量在程序启动时自动初始化);- 文件 B 通过指针使用
btn1
,但无权创建或释放它,避免多模块管理同一内存导致的重复释放或访问越界。
3. 模块化测试与替换
- 文件 B 可通过注入模拟指针进行单元测试(如指向模拟按键的结构体);
- 文件 A 可灵活替换
btn1
的实现(如改用不同型号的按键驱动),只要保证Button
结构的内存布局一致。
4. 降低全局命名冲突风险
static
关键字避免btn1
污染全局符号表,允许其他文件定义同名静态变量(如static Button btn1
在文件 C 中可独立存在)。
需要注意的是:
1.类型安全风险
- 若文件 B 误用
head_handle
指针(如强制转换为错误类型),会导致未定义行为。 - 优化:在头文件中用
typedef
明确定义Button
结构体,确保类型一致性。
所以会有按键的结构体在multi_button.h
中声明,并且在其他函数使用相关参数的时候,都会使用#include "multi_button.h"
进行头文件声明。
2 多线程安全问题
- 若文件 A 和 B 在不同任务中操作同一指针,需通过锁或原子操作保护。
- 优化:在初始化函数中完成指针传递,后续仅通过文件 B 的接口访问(如
get_button_state()
)。
3. 架构层级模糊
- 过度依赖指针传递可能使调用链复杂化,违背分层架构原则。
- 优化:引入明确的“中间层”(如事件总线或回调接口),彻底隔离模块
文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。
【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。
感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言。