基于按键开源MultiButton框架深入理解代码框架(一)(指针的深入理解与应用)
文章目录
- 1、函数指针应用
- 1.1 传递函数指针的语法
- 1.2 按键函数的初始化
- 1.3 按键相关变量
- 1.3.1 按键属性结构体
- 1.3.2 按键结构体参数意义
- 1.4 初始化引起的思考
1、函数指针应用
前面讲解了函数指针,这里就是指针的实际应用。
深入理解C语言内存空间、函数指针(三)(重点是函数指针)-CSDN博客
void button_init(Button* handle, uint8_t(*pin_level)(uint8_t),
uint8_t active_level, uint8_t button_id) 这是声明的函数,
uint8_t(*pin_level)(uint8_t)表示函数指针变量 uint8_t read_button_gpio(uint8_t button_id)
{ switch (button_id) { case 1: return btn1_state; case 2: return btn2_state; default: return 0; }
} 但是在使用传递参数过程中,直接就传递了函数名 button_init(&btn1, read_button_gpio, 1, 1);
C语言规定,函数名本身是一个指向该函数代码的指针常量。
uint8_t (*func_ptr)(uint8_t) = read_button_gpio; // 正确,函数名隐式转换为地址
或者显式取地址
uint8_t (*func_ptr)(uint8_t) = &read_button_gpio; // 等价写法
两者效果相同。
1.1 传递函数指针的语法
调用时直接传递函数名:
button_init(&btn1, read_button_gpio, 1, 1);
应用场景 | 关键技术点 | 代表函数/库 |
---|---|---|
回调函数 | 事件驱动、异步通知 | GUI 事件处理、Node.js |
策略模式 | 算法可替换 | C 标准库 qsort() |
分派表 | 状态机、命令解析 | 计算器、协议处理器 |
线程池任务 | 并发任务抽象 | 线程池框架(如 Pthread) |
插件架构 | 动态加载、热更新 | 动态链接库(dlopen ) |
1.2 按键函数的初始化
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};
这个地方可以给自己规定死,也就是只要出现传递的参数是指针的,那么最开始一定要做的一件事就判断地址的合法性,也就是不能为NULL。
如果是NULL,就直接退出初始化
if (!handle || !pin_level) return; // parameter validation
- **`ptr`**:目标内存起始地址(此处 `handle` 需是 `Button*` 类型)。
- **`value`**:填充值(仅低 8 位有效,故 `0` 或 `0xFF` 等单字节值安全)。
- **`num`**:填充字节数(`sizeof(Button)` 确保覆盖整个结构体)。void* memset(void* ptr, int value, size_t num);memset(handle, 0, sizeof(Button));
清零内存区域
memset
按字节填充内存,此处0
表示将sizeof(Button)
字节的内存全部设为0
。- 对于结构体
Button
,其所有成员(包括整型、字符数组、指针等)的二进制值均被置零:- 数值类型(如
int
)变为0
。 - 字符数组/字符串变为空字符串(
\0
填充)。 - 指针变为
NULL
(空指针)。
- 数值类型(如
初始化思想:
memset(handle, 0, sizeof(Button));handle->event = (uint8_t)BTN_NONE_PRESS;handle->hal_button_level = pin_level;handle->button_level = !active_level; // initialize to opposite of active levelhandle->active_level = active_level;handle->button_id = button_id;handle->state = BTN_STATE_IDLE;
我们第一步是使用memset
函数将sizeof(Button)
字节的内存全部设为 0
。
但是有一部分还是需要一些默认值,所以后面的就是通过结构体指针特有的方式进行访问相关成员函数。这是结构体指针变量特有的方式。
1.3 按键相关变量
1.3.1 按键属性结构体
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};
static Button btn1, btn2;
需要说明的是:
uint8_t repeat : 4; // repeat counter (0-15)uint8_t event : 4;
这里为什么我们使用后面的4?
这是因为这是一种 位段(Bit Field) 的声明方式,其核心作用是精确控制成员变量占用的内存位数,以节省空间并高效表示小范围整数值。
uint8_t
:基础类型(无符号8位整数),表示该位段基于1字节(8位)内存单元分配。repeat
:成员变量名。: 4
:指定该成员占用 4个比特位(bit),而非完整的1字节(8位)。- 取值范围:4位二进制数的范围是
0~15
(24=16 种可能值),适合存储小范围整数(如计数器、状态标志)。 - 节省内存:若
repeat
只需表示0-15的值,使用完整uint8_t
(8位)会浪费4位空间,位段将其压缩至4位。 - 紧凑存储:在嵌入式系统、网络协议等内存敏感场景中,位段能显著减少结构体总大小(如用户结构体中的
repeat
、event
、state
等均为位段)。
1.3.2 按键结构体参数意义
变量名 | 数据类型/位宽 | 含义说明 | 功能作用 |
---|---|---|---|
**ticks ** | uint16_t | 时间计数器 | 记录按键状态持续的毫秒数,用于计算单击、长按等事件的时间阈值。 |
**repeat ** | uint8_t : 4 | 连击次数计数器 | 记录连续快速按下的次数(如双击、三击),取值范围 0~15。 |
**event ** | uint8_t : 4 | 当前事件标识 | 存储按键触发的事件类型(如按下、松开、单击等),用枚举值表示。 |
**state ** | uint8_t : 3 | 状态机当前状态 | 标识按键在状态机中的位置(共 8 种状态),驱动内部逻辑流转用。 |
**debounce_cnt ** | uint8_t : 3 | 消抖计数器 | 记录按键电平稳定的持续周期数,用于消除机械抖动干扰(通常 5ms 周期)。 |
**active_level ** | uint8_t : 1 | 有效触发电平 | 定义按键按下时的有效电平(0=低电平有效,1=高电平有效)。 |
**button_level ** | uint8_t : 1 | 当前实际电平 | 存储通过 hal_button_level 读取的当前 GPIO 电平值。 |
**button_id ** | uint8_t | 按键标识符 | 区分多个按键的唯一 ID,用于共享电平读取函数时识别不同按键。 |
**hal_button_level ** | 函数指针 | 硬件抽象层电平读取函数 | 指向用户实现的 GPIO 读取函数,参数为 button_id ,返回当前电平。 |
**cb ** | BtnCallback[] | 回调函数数组 | 存储不同事件(如按下、长按)对应的回调函数指针,事件触发时调用。 |
**next ** | Button* | 链表指针 | 指向下一个按键对象,支持无限扩展按键,形成全局链表统一处理。 |
uint8_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
ticks
表示的按下按键的持续时长,用于计算单击、长按等事件的时间阈值。这个数据是核心,只有根据这个数据才能判断是长按、短按。并且一般单位都是ms,在初始化的时候默认是0。
repeat
表示的是单击、双击、三击等 如果是两次就表示是双击。注意我们在设计的时候没有把这个数设计的那么大,这是因为我们的连击是有常用的可能的,不可能说能连击255次,因此只需要设计合理的范围就行,这样还能节约空间。
event
表示的按键事件标志或者说是代表是按下、还是松开、还是怎么其他的可能。这是一个枚举变量,在使用枚举变量的时候其实是有一些细节的。
参考枚举文章C语言关键字—枚举
typedef enum {BTN_PRESS_DOWN = 0, // 按键按下BTN_PRESS_UP, // 按键抬起BTN_PRESS_REPEAT, // 重复按下检测BTN_SINGLE_CLICK, // 单击完成BTN_DOUBLE_CLICK, // 双击完成BTN_LONG_PRESS_START, // 长按开始BTN_LONG_PRESS_HOLD, // 长按保持BTN_NONE_PRESS // 无事件} ButtonEvent;
state
表示状态机当前状态 ,标识按键在状态机中的位置(共 8 种状态),驱动内部逻辑流转用。
typedef enum {BTN_STATE_IDLE = 0, // idle state 空闲状态BTN_STATE_PRESS, // pressed state 按下状态BTN_STATE_RELEASE, // released state waiting for timeout 释放状态BTN_STATE_REPEAT, // repeat press state 重复按下状态BTN_STATE_LONG_HOLD // long press hold state 长按保持状态} ButtonState;
debounce_cnt
消抖计数器,这个时间和时间计数器是不是可以产生关联。
``
active_level
有效电平的触发,也就说在按键检测时候,有可能是高电平检测有效,也有可能是低电平检测有效。
button_level
实际有效的电平,也就是通过GPIO口检测到的电平,
handle->button_level = !active_level; // initialize to opposite of active levelhandle->active_level = active_level;
这个地方也是一个编程技巧,也就是我们初始化的实际电平一定是有效电平的相反数,不然可能会出现还没有按键,就导致按键检测的是有效的,避免系统出现紊乱。
开发的严谨性。
hal_button_level
GPIO检测函数
输入参数:uint8_t(*pin_level)(uint8_t)可以看出我们传进去的是一个函数指针,相当于是这个函数的入口地址。handle->hal_button_level = pin_level;
button_id
按键的ID,因为一个项目可能出现多个按键。
cb
回调函数数组。
next
按键链表指针,
void button_init(Button* handle, uint8_t(*pin_level)(uint8_t), uint8_t active_level, uint8_t button_id)
综上所述,在初始化一个按键的时候,我们需要关注的传入参数:
1、按键的属性集合,也就是按键的结构体
2、按键的检测函数,检测GPIO电平的。
3、按键按下是高电平有效还是低电平有效
4、按键的ID,表示我正在初始的是那个按键
1.4 初始化引起的思考
static Button btn1, btn2;
首先是声明按键结构体:程序启动时分配,地址固定,保证地址有效性&btn1
永不为 NULL
,并且自动零初始化(成员为 0
或 NULL
)。
使用static
关键字的目的是:
static
修饰的变量(无论全局或局部)存储在静态数据区(全局/静态存储区),其内存在程序启动时已分配。
在这里不得不引申一下:
我们知道RAM里面有栈空间、堆空间、bss、data段。
-
栈空间(Stack):存储函数调用的局部变量、参数、返回地址等,由系统自动管理,从高地址向下生长。
-
堆空间(Heap):用于动态内存分配(如
malloc
),由程序员手动管理,从低地址向上生长。 -
.bss 段:存储未初始化的全局变量和静态变量,程序启动时由系统自动清零。
-
.data 段:存储已初始化的全局变量和静态变量,程序启动时从 Flash 复制初始值到 RAM。
但是需要声明的是在裸机开发中一般不使用堆空间,
并且函数的执行都是在==栈空间==,那说到这里还记不记得有一个栈顶空间,对的,这个栈顶空间就是给一个上限,因此栈空间的特殊性,是从上到下的,也就是高字节到低字节分配,
- 栈是一种线性数据结构,仅允许在栈顶(Top)进行插入(入栈)和删除(出栈)操作。类似一摞盘子,最后放上的盘子最先被取走。
这是因为在_main函数到mainARM内核还有一段代码需要执行,因此留出来的是这一段空间,然后才是我们自己写的main函数栈顶地址,就这后面的栈顶空间就可以循环利用了。
我们首先需要知道栈顶地址是怎么得到的?
这是整个RAM的空间:
栈是RAM顶部的最后一个区域,符合典型设计。
Exec Addr Load Addr Size Type Attr Idx E Section Name Object0x20000000 COMPRESSED 0x00000024 Data RW 39 .data main.o0x20000024 COMPRESSED 0x00000040 Data RW 110 .data modbus_app.o0x20000064 COMPRESSED 0x000000b5 Data RW 183 .data mb.o0x20000119 COMPRESSED 0x00000003 PAD0x2000011c COMPRESSED 0x0000000c Data RW 267 .data mbrtu.o0x20000128 COMPRESSED 0x00000008 Data RW 372 .data modbus_slave.o0x20000130 COMPRESSED 0x00000024 Data RW 581 .data key_drv.o0x20000154 COMPRESSED 0x00000024 Data RW 618 .data led_drv.o0x20000178 COMPRESSED 0x00000008 Data RW 668 .data ntc_drv.o0x20000180 COMPRESSED 0x00000006 Data RW 810 .data rh_drv.o0x20000186 COMPRESSED 0x00000002 PAD0x20000188 COMPRESSED 0x0000000c Data RW 964 .data systick.o0x20000194 COMPRESSED 0x0000001c Data RW 1010 .data usb2com_drv.o0x200001b0 COMPRESSED 0x00000002 Data RW 1133 .data portevent.o0x200001b2 COMPRESSED 0x00000002 PAD0x200001b4 COMPRESSED 0x00000018 Data RW 1168 .data portserial.o0x200001cc COMPRESSED 0x00000004 Data RW 3445 .data mc_w.l(stderr.o)0x200001d0 COMPRESSED 0x00000004 Data RW 3734 .data mc_w.l(stdout.o)0x200001d4 - 0x00000100 Zero RW 265 .bss mbrtu.o0x200002d4 COMPRESSED 0x00000004 PAD0x200002d8 - 0x00000030 Zero RW 580 .bss key_drv.o0x20000308 - 0x00000014 Zero RW 666 .bss ntc_drv.o0x2000031c COMPRESSED 0x00000004 PAD0x20000320 - 0x00000400 Zero RW 3383 STACK startup_gd32f30x_hd.o
通过工程的map
文件可以看出在栈空间确定之前,首先确定的是data、bss
数据占用的RAM空间,最后确定出栈空间的最低地址是多少。通过代码可以看出是0x20000320
,大小是0x00000400
,其中栈的大小是可以自己设定的。那么两者相加就是0x20000320 + 0x00000400 = 0x20000720
。
pxMBFrameCBByteReceived 0x2000007c Data 4 mb.o(.data)pxMBFrameCBTransmitterEmpty 0x20000080 Data 4 mb.o(.data)pxMBPortCBTimerExpired 0x20000084 Data 4 mb.o(.data)pxMBFrameCBReceiveFSMCur 0x20000088 Data 4 mb.o(.data)pxMBFrameCBTransmitFSMCur 0x2000008c Data 4 mb.o(.data)__stderr 0x200001cc Data 4 stderr.o(.data)__stdout 0x200001d0 Data 4 stdout.o(.data)ucRTUBuf 0x200001d4 Data 256 mbrtu.o(.bss)__initial_sp 0x20000720 Data 0 startup_gd32f30x_hd.o(STACK)
从最后一行代码也可以看出该工程的栈顶地址是0x20000720
bss和data不会释放的,会一直占用。
即使 static
变量地址有效,若函数通过参数接收外部指针(如 button_init(&btn1, ...)
),仍需检查该参数是否为空:
因此初始化的时候首先要进行检测的就是判断地址的合法性。
void button_init(Button* handle, ...) {if (!handle) return; // 必须检查,避免外部误传 NULL
}
文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。
【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。
感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言。