------第二篇 把按键驱动重做一遍 基于stm32f103)
前言 上一篇把 GPIO 驱动重做了一遍从板级表到芯片实现拆成四层最后在板子上点了灯。这一篇写按键。 按键是很多人学单片机的第一个输入设备教程里一般就是读一个引脚的电平加个延时消抖。作者以前也这么觉得直到翻出自己几年前写的按键驱动发现光是消抖和事件判定就写了两百五十多行还带一个 TIM7 中断。这次把它放进新架构里重做看看哪一层该干什么。 旧系列也写过按键那篇叫《检测按键输入》用的是面向对象的写法写完一直放在草稿箱里没有发布。这次用新架构重做一遍也算把那篇补上了。 板子还是野火指南者STM32F103VE。两个按键KEY1 接 PA0KEY2 接 PC13按下是高电平。 这篇的文字也是 AI 帮忙整理的。1 旧按键驱动是怎么写的1.1 一个定时器扫两个按键 旧驱动在bsp/key下分板级和器件两层。板级文件里定义了两个按键的引脚还有 TIM7 的配置72 分频、计数值 2000。72 MHz 的定时器时钟分频之后是 1 MHz计 2000 个数就是 2 毫秒进一次中断。按键的扫描、消抖、事件判定全在这个中断里跑。#define KEY_TIMER_HARDWARE_MAPPING_TABLE \ {RCC_APB1PeriphClockCmd,RCC_APB1Periph_TIM7,TIM7,(2000-1),(72-1)} 这个思路对裸机程序来说很常见主循环不用管按键定时器每 2 毫秒推一次状态机有事件就回调。省得在主循环里到处塞KeyScan()。1.2 五个状态四个事件 状态机有五个状态空闲、按下消抖、已按下、释放消抖、弹起确认。按下之后要连续读到几次高电平才算数松开之后也要连续读几次低电平然后还有一段弹起确认窗口窗口结束才判定事件。 阈值都按 2 毫秒的 tick 数写#define KEY_Buffeting_Time (5) /* 消抖 5 次 */ #define KEY_UPSPRING_TIME (70) /* 弹起确认 140 ms */ #define KEY_LONG_TOUCH_TIME (200) /* 长按 400 ms */ 事件有四种单击、连击、长按、连击后长按。连击的判定绕一点第一次松开后弹起确认窗口还没结束就又按下连击计数加一窗口结束才结算。长按看的是按下状态持续的 tick 数。 状态机本体是定时器中断里的一个 switch这里把完整的贴出来后面新实现是照着它搬的static void KeyTimerCycleDeilWith(void) { KeyType_t key; KeyManager_t* pkeyManager NULL; for (key (KeyType_t)0; key KEY_MAX; key) { pkeyManager KeyManager[key]; switch (pkeyManager-Prop.Status) { case KEY_STATUS_RELEASE: if (CHECK_KEY_PIN_PRESS(KeyHardWare[key].GPIOx, KeyHardWare[key].Pin)) { pkeyManager-LastStatus pkeyManager-Prop.Status; pkeyManager-Prop.Status KEY_STATUS_PRESS_GLITCH_FILTER; } break; case KEY_STATUS_PRESS_GLITCH_FILTER: if (CHECK_KEY_PIN_PRESS(KeyHardWare[key].GPIOx, KeyHardWare[key].Pin)) { if ((pkeyManager-BuffetingCount) KEY_Buffeting_Time) { pkeyManager-BuffetingCount 0; pkeyManager-LastStatus pkeyManager-Prop.Status; pkeyManager-Prop.Status KEY_STATUS_PRESS; pkeyManager-Prop.ComboCount; pkeyManager-Prop.TapHoldConut 0; pkeyManager-UpspringTime 0; } } else { /* 干扰或误触发退回去 */ pkeyManager-BuffetingCount 0; pkeyManager-Prop.Status pkeyManager-LastStatus; } break; case KEY_STATUS_PRESS: if (CHECK_KEY_PIN_RELEASE(KeyHardWare[key].GPIOx, KeyHardWare[key].Pin)) { pkeyManager-LastStatus pkeyManager-Prop.Status; pkeyManager-Prop.Status KEY_STATUS_RELEASE_GLITCH_FILTER; } if (pkeyManager-Prop.TapHoldConut 65535) { pkeyManager-Prop.TapHoldConut; } break; case KEY_STATUS_RELEASE_GLITCH_FILTER: if (CHECK_KEY_PIN_RELEASE(KeyHardWare[key].GPIOx, KeyHardWare[key].Pin)) { if ((pkeyManager-BuffetingCount) KEY_Buffeting_Time) { pkeyManager-BuffetingCount 0; pkeyManager-LastStatus pkeyManager-Prop.Status; pkeyManager-Prop.Status KEY_STATUS_UNDETERMINED; } } else { pkeyManager-BuffetingCount 0; pkeyManager-Prop.Status pkeyManager-LastStatus; } break; case KEY_STATUS_UNDETERMINED: if (pkeyManager-UpspringTime KEY_UPSPRING_TIME) { pkeyManager-Prop.Status KEY_STATUS_RELEASE; if (pkeyManager-Prop.ComboCount 1) { pkeyManager-Prop.Event (pkeyManager-Prop.TapHoldConut KEY_LONG_TOUCH_TIME) ? KEY_EVENT_COMBO_LONG_PRESS : KEY_EVENT_COMBO; } else { pkeyManager-Prop.Event (pkeyManager-Prop.TapHoldConut KEY_LONG_TOUCH_TIME) ? KEY_EVENT_LONG_PRESS : KEY_EVENT_CLICK; } if (pkeyManager-CallBack) { pkeyManager-CallBack(key, pkeyManager-Prop); } memset(pkeyManager-Prop, 0, sizeof(KeyProp_t)); pkeyManager-BuffetingCount 0; pkeyManager-UpspringTime 0; } if (CHECK_KEY_PIN_PRESS(KeyHardWare[key].GPIOx, KeyHardWare[key].Pin)) { pkeyManager-LastStatus pkeyManager-Prop.Status; pkeyManager-Prop.Status KEY_STATUS_PRESS_GLITCH_FILTER; } break; default: break; } } }1.3 旧代码里不合适的地方 这段代码在旧工程里能用作者也没有把它当成错的。放进新架构之后有几处不合适。 事件回调是在 TIM7 的中断里直接调的。旧工程里回调只做了置标志所以没出问题。但驱动本身也没有限制使用者在回调里打日志、发队列或者申请内存。这些在中断上下文里都可能出问题。 头文件里直接带着 FreeRTOS 的宏#define KEY_MALLOC(size) pvPortMalloc(size) bsp 层的头文件里出现 RTOS 的东西说明这个驱动只能跑在 RTOS 里换裸机就得改头文件。 还有一些拼写比如KeyTimerCycleDeilWith、TapHoldConut。不影响运行读起来费劲新实现里都改了。2 新架构卡在哪里 新分层里设备层在device/它下面只有ops/和os/两层接口。设备层不能 include 芯片头文件也不能 include FreeRTOS 头文件。旧按键驱动干的那些事配 TIM7、开 NVIC、在中断里跑状态机设备层一件都做不了。 按键的周期驱动从哪来有两条路。 一条是让上层用任务周期调KeyProcess()和日志模块的LogProcess()一样。设备层不碰定时器谁要用谁自己建任务。代价是按键依赖调用者忘了调就没有反应。 另一条是给 os 接口加一张定时器表。设备类在KeyCreate里创建一个 5 毫秒的周期定时器状态机在定时器回调里跑上层不用管。 作者选了第二条。按键这类设备天然需要周期驱动以后做编码器、红外接收、长短按组合都会用到同一个东西。放在 os 层后面几个设备类可以直接复用走第一条路的话每个设备都得让上层建一个任务任务会越来越多。3 给 os 层加一张定时器表3.1 接口 定时器的接口和前面几张表一样不透明句柄、一张 const 方法表、一个访问函数。typedef struct OsTimer_t OsTimer_t; typedef void (*OsTimerCallback_t)(void* arg); typedef struct { OsTimer_t* (*Create)(uint32_t periodMs, OsTimerCallback_t callback, void* arg); void (*Start)(OsTimer_t* timer); void (*Stop)(OsTimer_t* timer); void (*Delete)(OsTimer_t* timer); }OsTimerOps_t; const OsTimerOps_t* OsTimerGetOps(void); 只有周期模式没有单次。按键用不上单次先不做。3.2 FreeRTOS 映射 实现放在os-freertos/freertos_timer.c用 FreeRTOS 的软件定时器。工程里本来就有timers.c把configUSE_TIMERS从 0 改成 1 就能用不需要往 Keil 里加文件。 FreeRTOS 的回调只有TimerHandle_t一个参数用户还要传自己的arg。包装结构体放在pvTimerID里struct OsTimer_t { TimerHandle_t Handle; OsTimerCallback_t Callback; void* Arg; }; static void FreeRtosTimerTrampoline(TimerHandle_t handle) { OsTimer_t* self (OsTimer_t*)pvTimerGetTimerID(handle); if ((NULL ! self) (NULL ! self-Callback)) { self-Callback(self-Arg); } } 启动、停止、删除都是把命令投进 FreeRTOS 的定时器队列static void FreeRtosTimerStart(OsTimer_t* timer) { if (NULL timer) { return; } (void)xTimerStart(timer-Handle, portMAX_DELAY); } static void FreeRtosTimerStop(OsTimer_t* timer) { if (NULL timer) { return; } (void)xTimerStop(timer-Handle, portMAX_DELAY); } static void FreeRtosTimerDelete(OsTimer_t* timer) { if (NULL timer) { return; } (void)xTimerStop(timer-Handle, portMAX_DELAY); (void)xTimerDelete(timer-Handle, portMAX_DELAY); vPortFree(timer); } 启动和停止都用portMAX_DELAY。接口契约规定这些方法只能在任务上下文调短暂阻塞可以接受总比命令队列满了静默失败强。 最后把四个方法装进表。表是 static 的外面只能通过访问函数拿到static const OsTimerOps_t g_osTimer { .Create FreeRtosTimerCreate, .Start FreeRtosTimerStart, .Stop FreeRtosTimerStop, .Delete FreeRtosTimerDelete, }; const OsTimerOps_t* OsTimerGetOps(void) { return g_osTimer; }3.3 回调跑在哪个上下文 回调跑在 FreeRTOS 的定时器服务任务里是任务上下文不是中断。所以在按键回调里打日志、发队列都行。 代价是它和所有软件定时器共用一个任务。这个任务的优先级是 31configMAX_PRIORITIES - 1栈 256 个字大约 1 KB。回调里如果做耗时操作所有定时器都会跟着推迟。头文件里写死了这条回调要短小不能阻塞不能在回调里删除自己所属的定时器。 删除操作要往定时器命令队列里投递在回调上下文里投递不可靠。接口注释里给了一条出路要停就在回调里置个 volatile 标志让别的任务去停。4 按键设备类4.1 板级表 板级标识和表项类型是这样typedef enum { KEY_0 0, KEY_1, KEY_MAX, }BoardKeyId_t; typedef struct { OpsPin_t Pin; /* 端口 位掩码 */ OpsGpioMode_t Mode; /* 输入模式 */ uint8_t ActiveLevel; /* 有效电平1 高电平为按下 */ }BoardKeyPin_t; 板级表只写板子上的事引脚、输入模式、有效电平。const BoardKeyPin_t BoardKeyPins[KEY_MAX] { [KEY_0] { { OPS_PORT_A, OPS_PIN(0) }, OPS_GPIO_IN_FLOAT, 1 }, [KEY_1] { { OPS_PORT_C, OPS_PIN(13) }, OPS_GPIO_IN_FLOAT, 1 }, }; 有效电平这一列是 1因为板上的按键按下接 3.3 V。设备层判断按下就是引脚电平等于这个值上层不需要知道按键是上拉还是下拉接的。 输入模式先用了浮空和旧代码一样。上板跑过之后没有误触发就没有改成下拉。如果换一块板子发现干扰改这一个字段就行。4.2 状态机照搬 状态机的转移逻辑是照旧代码搬的五个状态、四个事件都没变。改的是时基旧代码按 2 毫秒算 tick新代码按 5 毫秒。#define KEY_SCAN_PERIOD_MS (5u) #define KEY_DEBOUNCE_TICKS (4u) /* 第 5 次确认20 ms */ #define KEY_UPSPRING_TICKS (28u) /* 140 ms */ #define KEY_LONG_PRESS_MS (400u) 旧代码的 2 毫秒是为了让按住时间的精度高一点但按键事件本身用不着那么高的精度。5 毫秒扫描一次任务负担小20 毫秒消抖对机械按键也够。时基变了阈值按毫秒重新算消抖次数从 5 改成 4弹起确认从 70 改成 28长按直接用 400 毫秒判断。 对外暴露的按住时长改成毫秒。旧代码那个TapHoldConut是 2 毫秒单位的 tick 数上层换算起来容易错。 按键的状态、事件和属性都是类型化的typedef enum { KEY_STATE_RELEASE 0, KEY_STATE_PRESS_GLITCH_FILTER, KEY_STATE_PRESS, KEY_STATE_RELEASE_GLITCH_FILTER, KEY_STATE_UNDETERMINED, }KeyState_t; typedef enum { KEY_EVENT_NONE 0, KEY_EVENT_CLICK, KEY_EVENT_COMBO, KEY_EVENT_LONG_PRESS, KEY_EVENT_COMBO_LONG_PRESS, }KeyEvent_t; typedef struct { KeyState_t State; /* 当前状态 */ KeyEvent_t Event; /* 最近一次判定的事件 */ uint8_t ComboCount; /* 这一串按键里按了几次 */ uint16_t HoldTimeMs; /* 这一轮按住的时间毫秒 */ }KeyProp_t; 回调的签名带 id 和一份属性快照typedef void (*KeyCallback_t)(KeyId_t id, const KeyProp_t* prop); 设备对象里放着板级信息、回调还有状态机的几个计数struct DevKey_t { OpsPin_t Pin; const DevKeyOps_t* Ops; KeyId_t Id; uint8_t ActiveLevel; KeyCallback_t Callback; KeyState_t LastState; KeyProp_t Prop; uint16_t BuffetingCount; uint16_t UpspringCount; }; 新实现的状态机函数结构和旧代码一一对应static void KeyScanOne(DevKey_t* self) { uint8_t pressed (uint8_t)(KeyEnv.Chip-Read(self-Pin) self-ActiveLevel); switch (self-Prop.State) { case KEY_STATE_RELEASE: if (0u ! pressed) { self-LastState self-Prop.State; self-Prop.State KEY_STATE_PRESS_GLITCH_FILTER; } break; case KEY_STATE_PRESS_GLITCH_FILTER: if (0u ! pressed) { if (self-BuffetingCount KEY_DEBOUNCE_TICKS) { self-BuffetingCount 0; self-LastState self-Prop.State; self-Prop.State KEY_STATE_PRESS; self-Prop.ComboCount; self-Prop.HoldTimeMs 0; self-UpspringCount 0; } } else { /* 干扰或误触发退回上一次稳定状态 */ self-BuffetingCount 0; self-Prop.State self-LastState; } break; case KEY_STATE_PRESS: if (0u pressed) { self-LastState self-Prop.State; self-Prop.State KEY_STATE_RELEASE_GLITCH_FILTER; } if (self-Prop.HoldTimeMs (0xFFFFu - KEY_SCAN_PERIOD_MS)) { self-Prop.HoldTimeMs (uint16_t)(self-Prop.HoldTimeMs KEY_SCAN_PERIOD_MS); } break; case KEY_STATE_RELEASE_GLITCH_FILTER: if (0u pressed) { if (self-BuffetingCount KEY_DEBOUNCE_TICKS) { self-BuffetingCount 0; self-LastState self-Prop.State; self-Prop.State KEY_STATE_UNDETERMINED; } } else { self-BuffetingCount 0; self-Prop.State self-LastState; } break; case KEY_STATE_UNDETERMINED: /* 弹起确认窗口结束判定事件并通知 */ if (self-UpspringCount KEY_UPSPRING_TICKS) { self-LastState self-Prop.State; self-Prop.State KEY_STATE_RELEASE; if (self-Prop.ComboCount 1u) { if (self-Prop.HoldTimeMs KEY_LONG_PRESS_MS) { self-Prop.Event KEY_EVENT_COMBO_LONG_PRESS; } else { self-Prop.Event KEY_EVENT_COMBO; } } else if (self-Prop.HoldTimeMs KEY_LONG_PRESS_MS) { self-Prop.Event KEY_EVENT_LONG_PRESS; } else { self-Prop.Event KEY_EVENT_CLICK; } if (NULL ! self-Callback) { self-Callback(self-Id, self-Prop); } /* 一轮结束属性清零连击计数清空 */ (void)memset(self-Prop, 0, sizeof(self-Prop)); self-BuffetingCount 0; self-UpspringCount 0; } /* 弹起确认窗口内又按下保留连击计数重新进入按下消抖 */ if (0u ! pressed) { self-LastState self-Prop.State; self-Prop.State KEY_STATE_PRESS_GLITCH_FILTER; } break; default: self-Prop.State KEY_STATE_RELEASE; break; } }4.3 一个定时器扫全部按键 旧代码是一个 TIM7 中断扫两个按键新实现也是共用一个定时器没有按每个按键一个定时器做。static DevKey_t* KeyObjects[KEY_MAX]; static uint8_t KeyObjectCount; static OsTimer_t* KeyScanTimer; 定时器回调就是把这个表扫一遍static void KeyScanTrampoline(void* arg) { uint16_t index; (void)arg; for (index 0u; index (uint16_t)KEY_MAX; index) { if (NULL ! KeyObjects[index]) { KeyScanOne(KeyObjects[index]); } } } 第一个按键创建成功时创建并启动 5 毫秒的扫描定时器删除最后一个按键时停止并释放它。两个按键只占一个定时器对象也少两次命令队列操作。代价是多了一个引用计数KeyCreate和Delete里都要维护好在只有两行。 删除的代码要管这个计数static OpsStatus_t KeyDelete(DevKey_t* self) { uint16_t index; if (NULL self) { return OPS_ERR_STATE; } for (index 0u; index (uint16_t)KEY_MAX; index) { if (KeyObjects[index] self) { KeyObjects[index] NULL; KeyObjectCount--; if (0u KeyObjectCount) { KeyEnv.Timer-Stop(KeyScanTimer); KeyEnv.Timer-Delete(KeyScanTimer); KeyScanTimer NULL; } KeyEnv.Mem-Free(self); return OPS_OK; } } return OPS_ERR_PARAM; /* 不是本层创建的对象不删 */ } 设备类的方法表只有两个方法取属性和删除typedef struct { KeyProp_t (*GetProp)(DevKey_t* self); OpsStatus_t (*Delete)(DevKey_t* self); }DevKeyOps_t; static KeyProp_t KeyGetProp(DevKey_t* self) { KeyProp_t prop; (void)memset(prop, 0, sizeof(prop)); if (NULL ! self) { prop self-Prop; /* 按值返回快照 */ } return prop; } static const DevKeyOps_t KeyOps { .GetProp KeyGetProp, .Delete KeyDelete, };4.4 创建流程KeyCreate里一次做完取三张能力表、查板级表、申请对象、配引脚、建定时器、启动。DevKey_t* KeyCreate(KeyId_t id, KeyCallback_t callback) { DevKey_t* self; /* 设备层自己取能力表实现层是 static只能经接口头里的访问函数拿 */ KeyEnv.Chip OpsGpioGetOps(); KeyEnv.Timer OsTimerGetOps(); KeyEnv.Mem OsMemGetOps(); if ((NULL KeyEnv.Chip) || (NULL KeyEnv.Timer) || (NULL KeyEnv.Mem)) { return NULL; } if ((id (KeyId_t)KEY_MAX) || (0u BoardKeyPins[id].Pin.Pin)) { return NULL; /* 标识越界或该位未配置 */ } if (NULL ! KeyObjects[id]) { return KeyObjects[id]; /* 幂等已创建就直接返回 */ } self (DevKey_t*)KeyEnv.Mem-Alloc((uint32_t)sizeof(DevKey_t)); if (NULL self) { return NULL; } (void)memset(self, 0, sizeof(DevKey_t)); self-Id id; self-Pin BoardKeyPins[id].Pin; self-ActiveLevel BoardKeyPins[id].ActiveLevel; self-Callback callback; self-Ops KeyOps; self-Prop.State KEY_STATE_RELEASE; self-LastState KEY_STATE_RELEASE; if (OPS_OK ! KeyEnv.Chip-Init(self-Pin, BoardKeyPins[id].Mode)) { KeyEnv.Mem-Free(self); /* 硬件没配上内存还回去下次可以重试 */ return NULL; } /* 第一个按键创建并启动全局扫描定时器 */ if (0u KeyObjectCount) { KeyScanTimer KeyEnv.Timer-Create(KEY_SCAN_PERIOD_MS, KeyScanTrampoline, NULL); if (NULL KeyScanTimer) { KeyEnv.Mem-Free(self); return NULL; } KeyEnv.Timer-Start(KeyScanTimer); } KeyObjects[id] self; KeyObjectCount; return self; } 任何一步失败都要回滚对象内存还回去定时器没建成就返回 NULL下次可以重试。重复创建同一个 id 直接返回已有对象硬件不会配两次。5 上板测试 测试代码没有留在main.c里验完就删了。 单个按键单击回调收到一次 CLICK按住超过 400 毫秒收到 LONG_PRESS两次按下的间隔小于弹起确认窗口收到 COMBO先长按再连击收到 COMBO_LONG_PRESS。两个按键分别按事件里的 id 对得上。 用指甲快速弹一下按键短于 20 毫秒的毛刺没有产生事件。手不碰按键的时候串口也没有自己冒出事件。 os 定时器本身单独验了一次建一个 1 秒周期的定时器翻转蓝灯观察下来每秒一次没有漂。 PC 端的单元测试覆盖了状态机的时序推进和四个事件的判定加上原有的三套一共 214 项断言。结语 按键这轮加了 os 层的定时器表设备类本身反而简单了两百多行的状态机搬过来加一个创建流程就完了。 软件定时器多了一个常驻任务优先级最高栈 1 KB。按键回调跑在这个任务里回调里做耗时操作其它软件定时器都会被推迟。另外 5 毫秒的扫描周期决定了按住时间的精度是 5 毫秒要更细就得调小周期任务负担也会跟着上去。目前按键够用。 下一篇写串口。