三个实体按键看起来只是 GPIO 输入,但在当前小鸿 WS63 OpenHarmony 固件中,一次短按要经过电平采样、稳定去抖、时长分类、事件回调、主消息队列,再分发到音频、显示或 Agent 任务。直接在 GPIO 中断里改音量、刷新 LVGL 或启动网络,会把硬件时序、UI 线程和会话状态绑在同一个上下文中,后续很难处理抖动、重复事件和耗时操作。
本文从当前key_config.c、key_config.h和main.c的真实代码出发,说明 P13、P12、P11 三个按键怎样进入 LiteOS-M/CMSIS-RTOS2 消息队列,以及为什么主队列、音频队列、显示队列和 Agent 队列要分开。源码快照会固定本次文件哈希;本轮属于代码与构建产物核对,没有重新烧录后做三键实机回归。
三键先变成统一的事件编号
key_config.h不把 P11、P12、P13 直接暴露给业务层,而是定义七种动作:两个音量键各有短按、长按;中键有短按、普通长按和 5 秒长按。eKey_MaxCount还成为后续设置事件的编号起点,使主队列可以用一个字节承载按键和系统事件。
enum { eKey_VolUp_ShortPressed = 0, eKey_VolUp_LongPressed, eKey_VolDw_ShortPressed, eKey_VolDw_LongPressed, eKey_Wakeup_ShortPressed, eKey_Wakeup_LongPressed, eKey_Wakeup_LongPressed_5S, eKey_MaxCount, };事件枚举的意义是把“哪个引脚变了”转换为“用户完成了什么动作”。主任务不需要重新读 GPIO,也不需要知道中键使用上拉、音量键使用下拉;它只处理已经去抖和分级后的语义事件。
轮询任务负责稳定电平和按压时长
当前实现同时保留 GPIO 双边沿中断日志和一个独立KeyPoll任务。真正生成业务事件的是轮询状态机。它每 30 ms 采样一次,电平连续稳定 60 ms 才接受变化;短于 80 ms 的脉冲被忽略;同一按键事件还要经过 400 ms 防重复保护。这些阈值来自当前源码,不是通用键盘参数。
#define KEY_POLL_INTERVAL_MS 30U #define KEY_DEBOUNCE_MS 60U #define KEY_MIN_PRESS_MS 80U #define KEY_EVENT_GUARD_MS 400U #define KEY_EVENT_NONE 0xFFU #define KEY_SCAN_FIRST_PIN 11U #define KEY_SCAN_LAST_PIN 13U轮询比在 ISR 中直接执行业务更容易表达“按下—保持—释放”的完整过程。中断仍可用于观察边沿和诊断电平,但短按、长按最终以释放时的持续时间决定。这样可以避免按下沿先触发一次,释放沿又误触发一次。
短按、长按和五秒长按在释放时判定
key_poll_handle在稳定电平进入按下态时记录down_ms。恢复空闲电平后,用当前时间减去按下时间得到持续时长:小于 80 ms 丢弃;中键达到 5000 ms 选择 5 秒事件;其他达到 500 ms 选择普通长按;否则保留短按事件。最后再检查 400 ms guard,符合条件才调用send_key_event。
if (duration < KEY_MIN_PRESS_MS) { log_info("[KeyPoll] %s ignore short pulse duration[%u]\r\n", key->name, (unsigned)duration); return; } if ((key->long5_event != KEY_EVENT_NONE) && (duration >= KEY_LONG_PRESS_INTERVAL_5S)) { event = key->long5_event; } else if (duration >= KEY_LONG_PRESS_INTERVAL) { event = key->long_event; }这里有一个容易忽略的设计:5 秒事件必须先于普通长按判断,否则 5000 ms 同时满足 500 ms,永远只会进入普通长按。当前代码的判断顺序正确,音量键的long5_event则是KEY_EVENT_NONE,不会凭空产生 5 秒动作。
数据表把引脚与语义动作绑定
三个按键由同一个key_poll_state_t数组驱动。P13 的短按/长按对应音量加和亮度加,P12 对应音量减和亮度减,P11 的短按、长按、5 秒长按分别进入会话、语音打断开关和重新配网流程。数据表避免复制三份状态机,也把扫描范围稳定限定在确认过的按键宏上。
key_poll_state_t keys[] = { {.pin = KEY_VOLUP_PIN, .id = KEY_VOLUP_PIN, .short_event = eKey_VolUp_ShortPressed, .long_event = eKey_VolUp_LongPressed, .long5_event = KEY_EVENT_NONE, .name = "VolUp"}, {.pin = KEY_VOLDW_PIN, .id = KEY_VOLDW_PIN, .short_event = eKey_VolDw_ShortPressed, .long_event = eKey_VolDw_LongPressed, .long5_event = KEY_EVENT_NONE, .name = "VolDw"}, {.pin = KEY_WAKEUP_PIN, .id = KEY_WAKEUP_PIN, .short_event = eKey_Wakeup_ShortPressed, .long_event = eKey_Wakeup_LongPressed, .long5_event = eKey_Wakeup_LongPressed_5S, .name = "Wakeup"}, };为保证代码块确实来自项目,公开文章保留了字段名和枚举名,只对换行做了排版,没有把它改写成其他框架的伪代码。引脚的实际数值仍由board_config.h提供:13、12、11;P14 不在该数组中。
回调只投递,不在按键上下文执行重活
轮询任务调用send_key_event后,已注册的key_event_callback_cb把一个uint8_t放入g_main_event_qid。回调不直接访问 LVGL,不建立 WebSocket,也不清除文件系统配置。这是关键的线程边界:输入侧只生成事件,主任务负责决定动作。
uint32_t key_event_callback_cb(uint8_t event) { if (g_main_event_qid) { osMessageQueuePut( g_main_event_qid, (const void *) &event, 0, 0); } return 0; }当前回调没有处理osMessageQueuePut的返回值,因此主队列已满时缺少显式失败日志。这不意味着架构错误,但属于可改进点:输入事件如果要求不可丢,应记录失败计数,或设计合并策略。音量连续短按通常允许后续状态刷新覆盖,5 秒清配网却更值得记录投递失败。
四个队列按消费职责分开
MainTask创建主、音频、显示和 Agent 四个队列。主、显示队列各 8 槽;音频下行事件较密集,扩为 64 槽;Agent 音频帧和状态事件使用 32 槽。所有消息当前都是一个字节的枚举值,因此osMessageQueueNew的元素大小统一为sizeof(uint8_t)。
#define MSG_QUEUE_SIZE (8) #define AUD_MSG_QUEUE_SIZE (64) #define AGENT_MSG_QUEUE_SIZE (32) g_audx_event_qid = osMessageQueueNew( AUD_MSG_QUEUE_SIZE, sizeof(uint8_t), NULL); g_disp_event_qid = osMessageQueueNew( MSG_QUEUE_SIZE, sizeof(uint8_t), NULL); g_main_event_qid = osMessageQueueNew( MSG_QUEUE_SIZE, sizeof(uint8_t), NULL); g_agent_event_qid = osMessageQueueNew( AGENT_MSG_QUEUE_SIZE, sizeof(uint8_t), NULL);队列容量不同来自流量差异,不是任务优先级。音频下行的请求频率高,8 槽可能出现漏喂和卡顿;Agent 发送音频时也会连续投递。按键队列流量低,但还会承载 Wi-Fi、字库等设置事件,所以仍需要在 40 ms 轮询等待中持续消费。
音量短按同时更新持久状态、音频和界面
音量加短按进入主任务后,先判断设备是否正在重新配网。配网态下不改变音量,只要求显示 Wi-Fi 状态。正常状态下调用increase_volume()更新设置,再向音频队列发送eAud_VolUp,向显示队列发送eDisp_Volume_Update。三个步骤属于不同职责。
increase_volume(); msg_send = eAud_VolUp; osMessageQueuePut( g_audx_event_qid, (const void *) &msg_send, 0, 0); msg_send = eDisp_Volume_Update; osMessageQueuePut( g_disp_event_qid, (const void *) &msg_send, 0, 0);显示任务收到更新后刷新状态栏,并弹出音量覆盖层;音频任务负责实际音量动作。主任务不调用 LVGL 对象 API,这让 UI 仍只在自己的任务上下文更新。音量减走同一结构,只把设置和音频枚举换成 decrease/VolDw。
长按音量键改变的是背光而不是音量
P13、P12 的长按在主任务中分别调用increase_blk_level()、decrease_blk_level(),随后向显示队列发送eDisp_Bar_Update。状态栏和背光等级的应用仍留在显示/设置相关代码中。这使同一实体键通过按压时长承担两个动作,而不会在key_config.c里硬编码业务。
这个映射也解释了为什么三键测试不能只看中断日志。日志里看到 P13 电平变化,只证明输入链发生了变化;还要确认轮询产生正确枚举、主队列收到、设置值变化、显示队列刷新,以及真实背光或音量产生效果。
中键短按要根据 Agent 当前状态决定动作
中键不是简单的开关。短按先向显示队列发送亮屏事件,再读取 Agent 状态。若当前正在监听、播放、唤醒或连接,短按发送AGENT_EVT_TOGGLE_CHAT,用于结束输入、取消等待或打断 TTS;若处于空闲,则先向音频队列发送eAud_WakeUp,由 CI1302 的0x0102唤醒事件启动 Agent 会话。
if (agent_st == AGENT_STATE_LISTENING || agent_st == AGENT_STATE_SPEAKING || agent_st == AGENT_STATE_WAKEUP || agent_st == AGENT_STATE_CONNECTING) { agent_send_event(AGENT_EVT_TOGGLE_CHAT); } else { msg_send = eAud_WakeUp; osMessageQueuePut( g_audx_event_qid, (const void *) &msg_send, 0, 0); }普通长按中键切换voice_interrupt并持久化,5 秒长按则清除 Wi-Fi 配置后调用restart_system(1)。把三种动作放在主任务分支而非 GPIO 层,可以访问 Agent、设置和系统重启接口,同时保留明确的状态判断。
消息队列也需要过载与生命周期设计
当前创建队列失败时,主任务记录错误并保留早期显示,然后直接返回。这比继续使用空句柄安全。运行阶段还应关注 Put 失败、消费任务是否存活、队列深度与消息优先级。Agent 音频投递已经有队列满的错误日志,而按键主回调尚未记录 Put 结果,后续可以补充计数但不能在失败时无限阻塞输入任务。
还要避免把大块文本或音频数据直接塞进当前一字节队列。音频帧有自己的缓冲与事件协议,LVGL 文本也通过专用 pending buffer 交接。队列中的字节是“有一件事要处理”的控制消息,不是任意负载容器。
源码确认与实机闭环仍然分开
本文已经从当前源码确认三键映射、30/60/80/400 ms 时序阈值、500/5000 ms 长按边界、事件数据表、四个消息队列的容量,以及音量、亮度和中键的实际分支。第 03 篇记录的构建链已经产出过真实.fwpkg,但本文没有把源码阅读和历史包直接写成今天的三键实测。
完整设备验收还需要:烧入与本次源码哈希对应的包;启动日志确认固件;分别执行 P13/P12/P11 的短按、长按与中键 5 秒长按;观察设置值、音频、背光、UI、Agent 和重启行为;快速重复按压检查 guard 与队列满日志。只有这些步骤完成,才能把状态从“源码链路已确认”提升为“三键实机闭环”。