基于按键开源MultiButton框架深入理解代码框架(二)(指针的深入理解与应用)
文章目录
- 2、针对该开源框架理解
- 3、分析代码
- 3.1 再谈指针、数组、数组指针
- 3.2 继续分析源码
2、针对该开源框架理解
在编写按键模块的框架中,一定要先梳理按键相关的结构体、枚举等变量。这些数据是判断按键按下、状态跳转、以及绑定按键事件的核心。
这一部分定义是在驱动层文件 "multi_button.h"
,这个里面的数据类型虽然都是跟按键有关的,并且主要是驱动层在使用,但是这个地方需要走出一个误区:
关于按键的相关的结构体、枚举等变量。这一部分定义是在驱动层文件 "multi_button.h"
,这个里面的数据类型虽然都是跟按键有关的,并且主要是驱动层在使用,但是应用层也需要知道按键的样子是什么样子,这样的目的是为了保证数据流的有效流动,是必要的。如果不这样,每一个模块的数据都是孤立存在的,产生不了联系,这不算是使用全局变量。
multi_button.h
中定义的按键结构体(如 Button
)和事件枚举(如 PRESS_DOWN
、LONG_PRESS
等)属于驱动层对应用层的接口规范。
-
应用层需要知道数据结构格式:因应用层需创建按键实例(如
Button btn1;
)并传递给button_init()
,必须了解结构体成员(如pin
、event
)以正确初始化和处理事件回调。 -
驱动层隐藏实现细节:虽然结构体定义在头文件中,但驱动层内部的状态机逻辑、链表管理等实现仍封装在
.c
文件中,对应用层不可见。
应用层通过read_button_gpio()
回调函数向驱动层提供硬件状态,驱动层通过事件枚举(如CLICK
)向应用层传递抽象结果。结构体/枚举作为数据传递的载体,是层间通信的契约,确保数据格式一致。 -
按键结构体/枚举在
multi_button.h
中的定义是必要的接口契约,用于保证驱动层与应用层间数据流的有效流动,不属于全局变量滥用。 -
这种设计在满足数据交互需求的同时,通过封装驱动实现细节(如链表管理、状态机),依然符合分层架构的高内聚、低耦合原则。
-
若需进一步隔离,可采用“句柄化”或标准化事件接口,但需权衡实现复杂度与资源消耗。
我觉得一定要带着这种思想才能深入的去理解该按键框架的意义,体会作者的用意,知其所以然。
3、分析代码
// 按钮事件类型
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_EVENT_COUNT, // 事件总数(用于数组定义)BTN_NONE_PRESS // 无事件(空闲状态)
} ButtonEvent;// 按钮状态机状态
typedef enum {BTN_STATE_IDLE = 0, // 空闲状态(等待按下)BTN_STATE_PRESS, // 按下状态(检测到有效按下)BTN_STATE_RELEASE, // 释放状态(等待超时以区分单击/双击)BTN_STATE_REPEAT, // 重复按下状态(连按计数中)BTN_STATE_LONG_HOLD // 长按保持状态(持续检测长按)
} ButtonState;// 按钮控制结构体
struct _Button {uint16_t ticks; // 时间戳计数器(用于超时判断)uint8_t repeat : 4; // 重复按下计数(4位,范围0-15)uint8_t event : 4; // 当前事件类型(4位,对应ButtonEvent)uint8_t state : 3; // 状态机状态(3位,对应ButtonState)uint8_t debounce_cnt : 3; // 消抖计数(3位,范围0-7)uint8_t active_level : 1; // 有效触发电平(1位,0=低电平有效,1=高电平有效)uint8_t button_level : 1; // 当前按钮电平(1位,实时GPIO状态)uint8_t button_id; // 按钮标识符(用于多按钮区分)uint8_t (*hal_button_level)(uint8_t button_id); // HAL层按钮电平读取函数指针BtnCallback cb[BTN_EVENT_COUNT]; // 事件回调函数数组(按事件类型注册)Button* next; // 链表指针(支持多按钮管理)
};
主要需要区分的是按键状态机和按键事件类型。
按键事件的目的是为了和该时间相应的动作绑定,也就是在什么状态下,执行什么应用逻辑。通过函数 button_attach
进行绑定,
这是调用文件:
button_attach(&btn1, BTN_PRESS_REPEAT, btn1_press_repeat_handler);具体实现是在multi_button.c文件
void button_attach(Button* handle, ButtonEvent event, BtnCallback cb)
{if (!handle || event >= BTN_EVENT_COUNT) return; // parameter validationhandle->cb[event] = cb;
}
其中 handle->cb[event] = cb;
这个目的就是接收传递的相关事件处理函数。
并且要想保证传递的按键处理函数有消息,我们其实是需要定义一个指针函数的,只有这样传递进来的按键处理函数入口地址才能被编译器有效识别为函数入口地址,不然编译器只是知道这是一个地址。这也就是在之前工作中 胡哥
告诉我的。在函数传递的时候无所谓什么,只要是一个地址就行 ,但是在函数定义的时候,定义输入参数的时候,我们必须要对这个参数是什么类型进行说明,例如是地址,我们就要声明这是什么地址,是函数指针地址、一个指针变量还是单纯的一个数组的入口地址。(可以理解为强转,也可以理解为告诉编译器或者告诉这个函数我这个地址本质是什么地址)。根本原因就是上述分析。
所以我们能看到在文件 multi_button.h
这行代码声明一个函数指针,就用来定义按键事件处理函数的入口地址。
typedef void (*BtnCallback)(Button* btn_handle);
3.1 再谈指针、数组、数组指针
此外由于这里使用到了==指针、数组、数组指针==,所以有必要进行说明一下其中的区别与联系:
在 C 语言中,数组的本质是连续内存块,其内容完全由定义时的类型决定。
在C语言中,数组确实可以存储任何类型,但必须明确定义元素类型,这样才能保证正确访问内存和调用函数。
数组和指针的本质理解:
数组是什么,数组的本质是连续的内存块,注意是连续,也就是说可以通过下标进行访问,并且申请的内存空间是和存储的数据类型强相关的。本质也是一个个的连续地址。
指针是什么,指针就是地址,如果我们需要存储一个函数,那么就需要将这个函数存储到一块内存空间,相当于是申请了一块内存,那么编译器在访问的时候其实是先找到这个函数的入口地址从而进行访问。而我们的指针变量就是存储这个函数入口地址的内存空间,纵使这个函数的内存很大,需要很多个地址存储,但是体现在指针变量里面就只是保存了一个入口地址,至于剩下的怎么执行完整这个函数,就不需要我们操心了。这个地方就是区别于数组,数组存储一些内容需要的申请完整的内存空间,来存储我们需要存储数组里面的所有内容,也就是都要找到对应的地址空间。
数组是申请内存空间存储内容,数组元素存储的是实际数据值,除非该数组是指针数组(如 int* arr[5]
)。
int arr[3] = {10, 20, 30};
那这个10是怎么存储的呐? 10表示的是0x0A 00 00 00
首先是申请12个字节内存空间,毕竟一个int数据占用的是四个字节,一个地接就需要一个地址存储。
- 字节 0(低地址):`00001010` → `0x0A`
- 字节 1:`00000000` → `0x00`
- 字节 2:`00000000` → `0x00`
- 字节 3(高地址):`00000000` → `0x00`
这里其实我们经常说的一个字节,指的就是内存空间中的一个地址,对的就是一个实实在在的物理地址。
所以12个字节,其实就是需要12个实实在在的物理地址空间做支撑。
如果是该数组是指针数组,其实数组存储的原本的10=0x0A 00 00 00此时存储的是一个地址,毕竟地址也是一个16进制数,所以也只是将这个地址拆分,然后存储到数组申请的实实在在的物理空间。例如
int a = 10, b = 20;
int* ptr_arr[2] = {&a, &b};
// ptr_arr[0]存储a的地址(如0x2000),ptr_arr[1]存储b的地址(如0x2004)
`&a`=`0x1000`32 位系统:地址值占 **4 字节**(如 `0x00001000`) 也是四个字节。
虽然我们存储的是地址,但是有点类似于int数据类型的存储。
至此我们明白了数组存内容是怎么储存的,
接下来再看指针:
指针也是申请内存空间存储内容:存储的是地址,不会存储数据。
定义一个指针变量,其实本质也是在物料地址空间,申请位置,但是需要告诉编译器我们是什么地址,毕竟内容不一样需要申请的空间不一样,但是需要注意的是这个指针变量如果是存储函数或者是数组,仅仅只存储它们的入口地址或者是首地址。
如果我们把这个这个函数存在数组中,其实也只是存储的这个函数的入口地址,但是我们需要将这个数组类型进行声明,因为只有这样编译器才知道我们使用的这个数组存储的是一个指针数组。
从某种程度来说指针和数组没有区别,但是又存在一些细微的区别。
数组更像是一种数据集合,固定大小,类型一致。
地址更像是一个地址容器,动态指向。
并且他们访问形式是不一样的。
3.2 继续分析源码
继续言归正传分析按键开源框架:
接下来就是分析按键的状态机跳转,也就是如何判断按键状态的核心逻辑。
/*** @brief Button driver core function, driver state machine* @param handle: the button handle struct* @retval None*/static void button_handler(Button* handle)
{uint8_t read_gpio_level = button_read_level(handle);// Increment ticks counter when not in idle stateif (handle->state > BTN_STATE_IDLE) {handle->ticks++;}/*------------Button debounce handling---------------*/if (read_gpio_level != handle->button_level) {// Continue reading same new level for debounceif (++(handle->debounce_cnt) >= DEBOUNCE_TICKS) {handle->button_level = read_gpio_level;handle->debounce_cnt = 0;}} else {// Level not changed, reset counterhandle->debounce_cnt = 0;}/*-----------------State machine-------------------*/switch (handle->state) {case BTN_STATE_IDLE:if (handle->button_level == handle->active_level) {// Button press detectedhandle->event = (uint8_t)BTN_PRESS_DOWN;EVENT_CB(BTN_PRESS_DOWN);handle->ticks = 0;handle->repeat = 1;handle->state = BTN_STATE_PRESS;} else {handle->event = (uint8_t)BTN_NONE_PRESS;}break;case BTN_STATE_PRESS:if (handle->button_level != handle->active_level) {// Button releasedhandle->event = (uint8_t)BTN_PRESS_UP;EVENT_CB(BTN_PRESS_UP);handle->ticks = 0;handle->state = BTN_STATE_RELEASE;} else if (handle->ticks > LONG_TICKS) {// Long press detectedhandle->event = (uint8_t)BTN_LONG_PRESS_START;EVENT_CB(BTN_LONG_PRESS_START);handle->state = BTN_STATE_LONG_HOLD;}break;case BTN_STATE_RELEASE:if (handle->button_level == handle->active_level) {// Button pressed againhandle->event = (uint8_t)BTN_PRESS_DOWN;EVENT_CB(BTN_PRESS_DOWN);if (handle->repeat < PRESS_REPEAT_MAX_NUM) {handle->repeat++;}EVENT_CB(BTN_PRESS_REPEAT);handle->ticks = 0;handle->state = BTN_STATE_REPEAT;} else if (handle->ticks > SHORT_TICKS) {// Timeout reached, determine click typeif (handle->repeat == 1) {handle->event = (uint8_t)BTN_SINGLE_CLICK;EVENT_CB(BTN_SINGLE_CLICK);} else if (handle->repeat == 2) {handle->event = (uint8_t)BTN_DOUBLE_CLICK;EVENT_CB(BTN_DOUBLE_CLICK);}handle->state = BTN_STATE_IDLE;}break;case BTN_STATE_REPEAT:if (handle->button_level != handle->active_level) {// Button releasedhandle->event = (uint8_t)BTN_PRESS_UP;EVENT_CB(BTN_PRESS_UP);if (handle->ticks < SHORT_TICKS) {handle->ticks = 0;handle->state = BTN_STATE_RELEASE; // Continue waiting for more presses} else {handle->state = BTN_STATE_IDLE; // End of sequence}} else if (handle->ticks > SHORT_TICKS) {// Held down too long, treat as normal presshandle->state = BTN_STATE_PRESS;}break;case BTN_STATE_LONG_HOLD:if (handle->button_level == handle->active_level) {// Continue holdinghandle->event = (uint8_t)BTN_LONG_PRESS_HOLD;EVENT_CB(BTN_LONG_PRESS_HOLD);} else {// Released from long presshandle->event = (uint8_t)BTN_PRESS_UP;EVENT_CB(BTN_PRESS_UP);handle->state = BTN_STATE_IDLE;}break;default:// Invalid state, reset to idlehandle->state = BTN_STATE_IDLE;break;}
}
逐段代码进行分析:
static void button_handler(Button* handle)
{}
uint8_t read_gpio_level = button_read_level(handle);
我们首先要依据GPIO口的高低电平来进行按键的判断按键是否按下,因此首先就要将GPIO读取函数给获取到,因为在初始化按键结构体的时候我们已经装填了获取GPIO电平的函数,这个按键的结构体包含了按键判断所有的条件,包括但不限于按键事件、按键状态、按键电平、按键什么电平有效等等,前面已经解释过了,这里就不一一赘述了,只是简单提醒,要带着这种思想。并且这些信息都存在了函数的输入参数里面 handle
并且每一个按键的这些时间都是独立计算的。按键与按键之间分开计算。
// Increment ticks counter when not in idle stateif (handle->state > BTN_STATE_IDLE) {handle->ticks++;}
这个时间的递增是怎么实现的?
通俗来讲,如果是 5ms
一次调用这个函数,那就可以理解为 5ms
变量 ticks
就会增加1,以此类推,从而实现了时间累计,并将这个是时间应用到下面代码的逻辑跳转。
这里在定时器还需要解决一个问题:定时精度依赖:若 button_ticks()
调用间隔不稳定(如被高优先级中断阻塞),会导致时间计算误差。也就是在滴答定时器章节留下的问题。
ARM单片机滴答定时器理解与应用(一)(详细解析)-CSDN博客
ARM单片机滴答定时器理解与应用(二)(详细解析)(完)-CSDN博客
接着是按键软件消抖动:
/*------------Button debounce handling---------------*/if (read_gpio_level != handle->button_level) {// Continue reading same new level for debounceif (++(handle->debounce_cnt) >= DEBOUNCE_TICKS) {handle->button_level = read_gpio_level;handle->debounce_cnt = 0;}} else {// Level not changed, reset counterhandle->debounce_cnt = 0;}
这要结合按键结构体的初始化,在初始化的时候我们将 handle->button_level
初始化为有效电平的相反数,
handle->button_level = !active_level; // initialize to opposite of active level
假设active_level
:表示按键按下时的有效逻辑电平(例如 0
表示低电平触发)。
并且假设系统启动时按键处于释放状态,也就是按键没有按下,此时物理电平应与有效电平相反(例如:若 active_level=0
,则释放时应为高电平 1
)。
避免首次扫描的误触发
- 若初始化时
button_level
与active_level
相同(例如均为0
),则首次调用button_handler
时:
read_gpio_level == handle->button_level // 均为0 → 电平“未变化”
即使按键实际处于释放状态(物理电平应为 1
),框架也会误判为“按键已按下”,导致状态机错误触发 PRESS_DOWN
事件。
确保消抖机制正确启动
- 当按键首次被按下时,物理电平从释放状态(
1
)变为按下状态(0
),此时:
read_gpio_level (0) != handle->button_level (1) // 电平变化 → 启动消抖计数
若未初始化 button_level = !active_level
,首次按下可能因电平“未变化”而跳过消抖计数,直接误判为稳定状态。
经过上述的初始化分析:保证了我们的按键函数可以准确的进入到消抖动分析,
整体逻辑如下:
A[检测电平变化] --> B{连续N次相同?}
B – 是 --> C[更新button_level]
B – 否 --> D[重置计数器]
体现在代码就是:
if (++(handle->debounce_cnt) >= DEBOUNCE_TICKS) {handle->button_level = read_gpio_level;handle->debounce_cnt = 0;}
首先进行次数判断,为何必须用前置 ++
而非后置?
- 逻辑一致性:消抖需在连续
DEBOUNCE_TICKS
次变化后立即响应,前置++
确保本次扫描被计入后立刻判断。 - 避免漏判:后置
++
会延迟计数更新,导致本次扫描的检测结果未被纳入当前判断。
假设连续三次稳定了,那么我们就认为电平稳定了,覆盖掉初始化的结果,将当前检测的结果赋值给 handle->button_level = read_gpio_level;
这样在下一次扫描就不会再执行这个函数,然后将消抖的计数器给清空。
接着就是状态机的跳转:
case BTN_STATE_IDLE:if (handle->button_level == handle->active_level) {// Button press detectedhandle->event = (uint8_t)BTN_PRESS_DOWN;EVENT_CB(BTN_PRESS_DOWN);handle->ticks = 0;handle->repeat = 1;handle->state = BTN_STATE_PRESS;} else {handle->event = (uint8_t)BTN_NONE_PRESS;}break;
因为前面已经将电平更新为实际检测到的 handle->button_level = read_gpio_level;
之所以这样是因为消抖完成,所以在状态满足 if
语句 handle->button_level == handle->active_level
就是说我检测到的电平和有效电平是一致的,那就是有效按键,然后开始清空相关内容,并将状态设置为按键按下 handle->state = BTN_STATE_PRESS;
在消抖的时候按键事件一直是 handle->event = (uint8_t)BTN_NONE_PRESS;
,按键状态一直是空闲状态(等待按下) handle->state = BTN_STATE_IDLE;
,
handle->ticks = 0; 清空的时机需要多留心一下。
然后接着跳转到:
default:// Invalid state, reset to idlehandle->state = BTN_STATE_IDLE;break;
在消抖过程形成完美闭环。
文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。
【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。
感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言。