> 技术文档 > 基于按键开源MultiButton框架深入理解代码框架(一)(指针的深入理解与应用)

基于按键开源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 counter uint8_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 level uint8_t button_id;  // button identifier uint8_t (*hal_button_level)(uint8_t button_id); // HAL function to read GPIO BtnCallback cb[BTN_EVENT_COUNT]; // callback function array Button* 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 level handle->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 counter uint8_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 level uint8_t button_id;  // button identifier uint8_t (*hal_button_level)(uint8_t button_id); // HAL function to read GPIO BtnCallback cb[BTN_EVENT_COUNT]; // callback function array Button* 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位。
  • 紧凑存储​:在嵌入式系统、网络协议等内存敏感场景中,位段能显著减少结构体总大小(如用户结构体中的repeateventstate等均为位段)。

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 identifier uint8_t (*hal_button_level)(uint8_t button_id); // HAL function to read GPIO BtnCallback cb[BTN_EVENT_COUNT]; // callback function array Button* 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 level handle->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,并且​自动零初始化(成员为 0NULL)。

使用static关键字的目的是:
static 修饰的变量(无论全局或局部)存储在静态数据区​(全局/静态存储区),其内存在程序启动时已分配。

在这里不得不引申一下:

我们知道RAM里面有栈空间、堆空间、bss、data段。

  • 栈空间(Stack)​​:存储函数调用的局部变量、参数、返回地址等,由系统自动管理,从高地址向下生长。

  • 堆空间(Heap)​​:用于动态内存分配(如 malloc),由程序员手动管理,从低地址向上生长。

  • ​.bss 段​:存储未初始化的全局变量和静态变量,程序启动时由系统自动清零。

  • ​.data 段​:存储已初始化的全局变量和静态变量,程序启动时从 Flash 复制初始值到 RAM。

但是需要声明的是在裸机开发中一般不使用堆空间,

并且函数的执行都是在==栈空间==,那说到这里还记不记得有一个栈顶空间,对的,这个栈顶空间就是给一个上限,因此栈空间的特殊性,是从上到下的,也就是高字节到低字节分配,

  • 栈是一种线性数据结构,仅允许在栈顶(Top)​进行插入(入栈)和删除(出栈)操作。类似一摞盘子,最后放上的盘子最先被取走。

这是因为在_main函数到mainARM内核还有一段代码需要执行,因此留出来的是这一段空间,然后才是我们自己写的main函数栈顶地址,就这后面的栈顶空间就可以循环利用了。

我们首先需要知道栈顶地址是怎么得到的?

这是整个RAM的空间:

基于按键开源MultiButton框架深入理解代码框架(一)(指针的深入理解与应用)

栈是RAM顶部的最后一个区域,符合典型设计。

 Exec Addr Load Addr Size Type Attr Idx E Section Name Object 0x20000000 COMPRESSED 0x00000024 Data RW  39 .data  main.o 0x20000024 COMPRESSED 0x00000040 Data RW 110 .data  modbus_app.o 0x20000064 COMPRESSED 0x000000b5 Data RW 183 .data  mb.o 0x20000119 COMPRESSED 0x00000003 PAD 0x2000011c COMPRESSED 0x0000000c Data RW 267 .data  mbrtu.o 0x20000128 COMPRESSED 0x00000008 Data RW 372 .data  modbus_slave.o 0x20000130 COMPRESSED 0x00000024 Data RW 581 .data  key_drv.o 0x20000154 COMPRESSED 0x00000024 Data RW 618 .data  led_drv.o 0x20000178 COMPRESSED 0x00000008 Data RW 668 .data  ntc_drv.o 0x20000180 COMPRESSED 0x00000006 Data RW 810 .data  rh_drv.o 0x20000186 COMPRESSED 0x00000002 PAD 0x20000188 COMPRESSED 0x0000000c Data RW 964 .data  systick.o 0x20000194 COMPRESSED 0x0000001c Data RW 1010 .data  usb2com_drv.o 0x200001b0 COMPRESSED 0x00000002 Data RW 1133 .data  portevent.o 0x200001b2 COMPRESSED 0x00000002 PAD 0x200001b4 COMPRESSED 0x00000018 Data RW 1168 .data  portserial.o 0x200001cc 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.o 0x200002d4 COMPRESSED 0x00000004 PAD 0x200002d8 - 0x00000030 Zero RW 580 .bss key_drv.o 0x20000308 - 0x00000014 Zero RW 666 .bss ntc_drv.o 0x2000031c COMPRESSED 0x00000004 PAD 0x20000320 - 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
基于按键开源MultiButton框架深入理解代码框架(一)(指针的深入理解与应用)

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 协议进行发布。

感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言。