第5章:输入控制——三按键导航

一台没有触屏、没有键盘的阅读器,凭什么只用三个按键就完成"找书、翻页、返回"的全部操作?

🏛️

本章导师:狄仁杰

核心方法论:系统分析,把每个输入当作一条线索

「断案要讲证据,调试要讲因果。这部阅读器的"操作系统"只有三个按键,可每一次按键落下,都要穿过中断、去抖、消息队列、状态机,最后才在屏幕上留下一道痕迹。跟着这条『输入 → 事件 → 状态 → 渲染』的链路一路追下去,你会发现再简单的交互,背后都藏着一套严丝合缝的系统。今天我们就来当一回大理寺的仵作,把这条链路一帧一帧地验尸。」

5.1 阅读器的人机交互:UP / DOWN / SELECT 三按键的职责

上一章你已经在 Board.h 里见过 get_button_controls() 这个纯虚方法,它承诺"每一块板子都提供一套按键控制"。现在把这套交互放到用户面前,本质上只有一句话:三个按键,映射到两个世界。在书目列表里,UP/DOWN 移动选中项,SELECT 打开选中的书;在阅读模式里,UP/DOWN 翻页,SELECT 返回书目。README 把这套语义写得非常直白:UP 是"在书单里上移、或阅读时翻上一页",DOWN 是"下移、或翻下一页",SELECT 是"打开选中的书、或从阅读模式回到书单"。

但代码里,这套语义不是散落在三四处硬编码的,而是收敛成一种叫 UIAction规范化动作。它在 src/boards/controls/Actions.h 里定义,只有 NONEUPDOWNSELECT 四种取值。硬件层负责把"哪个引脚被按下"翻译成 UIAction,UI 层只认 UIAction 不认引脚——于是换一块板子、换一组 GPIO,界面代码一行都不用动。这个"把底层信号抽象成语义动作"的中间层,正是狄仁杰断案时最看重的那份"人证物证分离"。

/* src/boards/controls/Actions.h:按键动作的"规范说法" */
typedef enum
{
  NONE,              // 无动作(例如被触摸唤醒、单纯的重绘请求)
  UP,                // 书单上移 / 上一页
  DOWN,              // 书单下移 / 下一页
  SELECT,            // 打开选中的书 / 阅读中返回书目
  LAST_INTERACTION
} UIAction;

typedef std::function<void(UIAction)> ActionCallback_t; // 动作回调

这份分工落到 main.cpp 的三个 handler 上,每一层都是"查表办事"。列表态 handleEpubList 收到 UP/DOWN 就调 epub_list->prev()/next() 移动高亮,收到 SELECT 就构造一个 EpubToc 并切换状态;目录态 handleEpubTableContents 与阅读态 handleEpub 各自实现自己的 UP/DOWN/SELECT 语义。三张"按键→行为"的对照表合起来,就是 5.1 开头那句"两个世界"的完整注解。

/* main.cpp:列表态里,一次 UIAction 落到书单上(节选) */
switch (action)
{
case UP:
  epub_list->prev();              // 选中项上移
  break;
case DOWN:
  epub_list->next();              // 选中项下移
  break;
case SELECT:
  ui_state = SELECTING_TABLE_CONTENTS;  // 打开书 → 先进章节目录
  contents = new EpubToc(epub_list_state.epub_list[epub_list_state.selected_item],
                          epub_index_state, renderer);
  contents->load();
  handleEpubTableContents(renderer, NONE, true);
  return;
}
按键列表态 SELECTING_EPUB阅读态 READING_EPUB
UP选中项上移翻上一页(到节首则回上一节末页)
DOWN选中项下移翻下一页(跨过节尾自动续读)
SELECT打开选中的书 → 进入目录返回书目列表
狄仁杰提示

注意 SELECT 在列表态打开的并不是"直接开始读",而是先进入章节目录EpubToc),让你选择从哪一章开始。这个细节在 README 的按钮说明里没提,只有读 handleEpubList 源码才能看见——又是文档比代码滞后的一处。啃源码时别放过这种"多一跳"。

5.2 按键电路与电平:active high vs active low

物理世界里,一个按键(物理按钮:按下时让电路导通、松开后断开)只有两种状态:按下松开。但同一件事放到单片机的 GPIO 引脚上,却有两种截然相反的接法——差别全在引脚读到哪种电平(高/低电平,说白了就是引脚上量到 3.3V 还是 0V)。如果按键一端接地、另一端接 GPIO 并外接(或启用内部)上拉电阻,那么松开时引脚读到高电平、按下时被拉到低电平——这叫低电平有效(active low):说人话就是"按下时引脚读到低电平,这次按压才有效";反过来,按键一端接电源、引脚下拉,松开读低、按下读高,就是高电平有效(active high)——按下时引脚读到高电平才算有效。项目里的 active_level 参数,就是告诉驱动"按下时引脚到底是 0 还是 1"。

这个选择不是随心所欲的,它直接决定了硬件的接法,进而决定了软件配置。看 GPIOButton 构造函数:它根据 active_level 二选一地设置内部电阻——低有效配上拉(GPIO_PULLUP_ONLY),高有效配下拉(GPIO_PULLDOWN_ONLY);中断触发类型同样二选一——低有效用 GPIO_INTR_LOW_LEVEL,高有效用 GPIO_INTR_HIGH_LEVEL。注意这里用的是电平触发而不是边沿触发,原因在 5.3 揭晓。更深一层,低有效按键在深睡眠时只能靠 ULP 协处理器唤醒,高有效则可以用更省事的 EXT1 唤醒——这个差异到第 15 章会展开,现在先记住"电平决定唤醒路线"这条线索。

/* GPIOButton.cpp:根据 active_level 一次性配好电阻与中断类型 */
GPIOButton::GPIOButton(gpio_num_t gpio_pin, int active_level, ButtonCallback_t callback)
    : gpio_pin(gpio_pin), active_level(active_level), callback(callback)
{
  gpio_set_direction(gpio_pin, GPIO_MODE_INPUT);
  // active_level==0(低有效)拉上;==1(高有效)拉下
  gpio_set_pull_mode(gpio_pin, active_level == 0 ? GPIO_PULLUP_ONLY : GPIO_PULLDOWN_ONLY);
  gpio_isr_handler_add(gpio_pin, button_interrupt_handler, this);
  button_pressed = false;
  // 电平触发:按下保持 0(或 1),松开回到另一电平
  gpio_set_intr_type(gpio_pin, active_level == 0 ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL);
  gpio_intr_enable(gpio_pin);
}

具体到每一块板子,答案写在各自的 src/boards/ 实现里。LilyGo T5 4.7" 与 M5 Paper 都是低电平有效,EPDiy V6 是高电平有效(而且 UP/DOWN 走 I2C 扩展的 PCA9555,只给 SELECT 留了一个 GPIO)。细心的你还会发现一个彩蛋:板子里定义的宏叫 BUTONS_ACTIVE_LEVEL——注意是 BUTON 少了两个字符,既不是 BUTTONS_ACTIVE_LEVEL,也没按 README 里写的用 -DBUTONS_ACTIVE_LEVEL=0 编译期注入,而是直接 #define 写死在板子文件里。真实项目里的拼写与注释,往往没有教科书那么工整。

/* src/boards/Lilygo_t5_47.cpp:导航按键的引脚与有效电平 */
// setup the pins to use for navigation
#define BUTTON_UP_GPIO_NUM     GPIO_NUM_34
#define BUTTON_DOWN_GPIO_NUM   GPIO_NUM_39
#define BUTTON_SELECT_GPIO_NUM GPIO_NUM_35
// buttons are low when pressed
#define BUTONS_ACTIVE_LEVEL 0   // 低电平有效(拼写少了个 T,照搬原仓库)
配置项低电平有效(active_level=0)高电平有效(active_level=1)
按下时引脚电平0(被拉低)1(被拉高)
内部电阻上拉 GPIO_PULLUP_ONLY下拉 GPIO_PULLDOWN_ONLY
触发中断类型GPIO_INTR_LOW_LEVELGPIO_INTR_HIGH_LEVEL
深睡眠唤醒路线ULP 协处理器EXT1 唤醒
本项目实际板子LilyGo T5 4.7"、M5 PaperEPDiy V6(仅 SELECT)
狄仁杰提示

要确认一块板子的按键到底是高有效还是低有效,最快的方法是查该板 PCB 的原理图:按键是否把引脚接地(多为低有效)、是否有上/下拉电阻。其次才是在代码里找 BUTONS_ACTIVE_LEVEL 的定义。本例 README 让你加 -DBUTONS_ACTIVE_LEVEL=0 编译宏,可仓库里根本没有这个宏——三块板全是直接 #define。文档漂移无处不在,以代码为准。

5.3 GPIOButtonControls:去抖与按下/释放检测

机械按键有一个天敌叫抖动:触点接触的瞬间,金属簧片会高速弹跳几十毫秒,导致同一个按键在极短时间内被"读"到很多次电平翻转。如果直接在这些翻转上触发动作,一次按键会被翻译成四五次"上一页",用户就会看到画面连跳好几页。所以任何正经的按键驱动,第一件事都是去抖(debounce,把按键抖动造成的多次触发过滤掉,让一次物理按压只算一次)。最常见的做法是"检测到电平变化后延时十几毫秒再读一次",本项目的做法却更有嵌入式味道:用时间戳判断一次按压的时长

GPIOButton::handle_interrupt() 的逻辑:它维护一个 button_pressed 标志位和 button_press_start 时间戳,每次中断都让引脚在"按下"与"松开"两种触发类型间翻转。按下时记下起始时刻,松开时计算持续时长——只有当 松开时刻 - 按下时刻 > BUTTON_DEBOUNCE 时才把这次按压算作有效,调用回调。抖动的本质是毫秒级的快速翻转,不可能超过去抖阈值,所以它天然被过滤掉;而真正的长按(哪怕只持续 100 ms)则稳稳通过。

// GPIOButton.cpp:BUTTON_DEBOUNCE 单位是微秒
// 注释写的是 "100 ms",但 50000 µs = 50 ms——又一个注释与代码不符
const int BUTTON_DEBOUNCE = 50000;

void GPIOButton::handle_interrupt()
{
  if (button_pressed)
  {
    // 已按下 → 现在必是松开:看按了多久
    button_pressed = false;
    int64_t release = esp_timer_get_time();
    if (release - button_press_start > BUTTON_DEBOUNCE)
    {
      callback();                      // 按压够久,算一次有效动作
    }
    gpio_set_intr_type(gpio_pin, active_level == 0 ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL);
  }
  else
  {
    // 未按下 → 现在必是按下:记下起始时刻
    button_pressed = true;
    button_press_start = esp_timer_get_time();
    gpio_set_intr_type(gpio_pin, active_level == 0 ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL);
  }
}

为什么用"电平触发 + 时长判定"而不是"边沿触发"?代码注释给了答案:edge interrupts seem to be flaky(边沿中断似乎不太可靠)。电平触发在引脚状态稳定时只触发一次,而边沿触发可能漏掉、重复触发;再加上 ESP32 的中断服务函数运行在 IRAM、不能做耗时操作,所以中断里只记时间戳,把"判定"留在中断上下文中完成,也算量小可控。整个检测是无阻塞的:没有 delay(),只读一次 esp_timer_get_time(),把对主线程的影响压到最低。

三路按键由 GPIOButtonControls 统一创建:构造时对 up/down/select 三个 GPIO 各 new 一个 GPIOButton,并把各自的回调包装成"往 on_action 里塞一个 UIAction"。而 on_action 来自板子的 get_button_controls()——它是个 lambda,把 UIAction 通过 xQueueSend 扔进 FreeRTOS 消息队列。这里的每一次按键就是一条事件("刚发生了什么"的通知),队列(queue)则是存放这些事件、先进先出的"待办清单":中断只管往队尾投,主程序忙完一件再取一件,来得再急也不怕丢。于是按键与 UI 之间隔着一道"队列缓冲",中断里只入队,主任务排队出队,天然解耦了实时中断与慢速渲染。

/* GPIOButtonControls.cpp:三个 GPIOButton + 三条回调,统一汇入 on_action */
GPIOButtonControls::GPIOButtonControls(
    gpio_num_t gpio_up, gpio_num_t gpio_down, gpio_num_t gpio_select,
    int active_level, ActionCallback_t on_action)
    : gpio_up(gpio_up), gpio_down(gpio_down), gpio_select(gpio_select),
      active_level(active_level), on_action(on_action)
{
  gpio_install_isr_service(0);
  up = new GPIOButton(gpio_up, active_level, [this]() { this->on_action(UIAction::UP); });
  down = new GPIOButton(gpio_down, active_level, [this]() { this->on_action(UIAction::DOWN); });
  select = new GPIOButton(gpio_select, active_level, [this]() { this->on_action(UIAction::SELECT); });
}

/* Lilygo_t5_47.cpp:板子把"动作"对接进消息队列 */
return new GPIOButtonControls(
    BUTTON_UP_GPIO_NUM, BUTTON_DOWN_GPIO_NUM, BUTTON_SELECT_GPIO_NUM,
    BUTONS_ACTIVE_LEVEL,
    [ui_queue](UIAction action) { xQueueSend(ui_queue, &action, 0); });
狄仁杰提示

去抖的阈值单位要盯紧:BUTTON_DEBOUNCE 配合 esp_timer_get_time()(返回微秒),所以 5000050 毫秒,而它头顶的注释却写着 "100 ms"。这个项目里"注释与代码打架"已经第二次出现了(上一处是深睡眠 30 秒 vs 120 秒)。嵌入式里时间单位尤其容易埋雷:微秒、毫秒、系统 tick 混在一起,读代码永远先确认单位。

5.4 状态机:列表态 ↔ 阅读态的按键路由

同一个 UP 键,在书单里是"上移",在阅读里是"上一页"——它凭什么知道自己该干嘛?答案是:UI 层有一个当前状态,按键行为完全由这个状态决定。这种"程序守在几个状态里、收到事件就在状态之间切换"的设计,就叫状态机(state machine,一个在不同"状态"之间按事件跳转的模型)。这个状态就是 main.cpp 顶部的 UIState,共三态:SELECTING_EPUB(书目列表)、SELECTING_TABLE_CONTENTS(章节目录)、READING_EPUB(阅读)。注意它挂在 RTC_NOINIT_ATTR 上——这段内存深睡眠期间不断电,醒来后设备还记得自己在哪个界面。这与第 15 章的深睡眠紧密相关。

路由的总开关是 handleUserInteraction():进来先看 ui_state 在哪,再把动作派发给对应的 handler。于是"三按键"复用出三层界面,每次按键都像一次"案件移送":大理寺先看卷宗编号(状态),再决定派哪个衙役(handler)处理。SELECT 的三种去处也因此一目了然——列表态进目录、目录态进阅读、阅读态删掉 reader 回列表。这套状态机简单到只有一条链路,却是全书交互的地基。

/* main.cpp:三态状态机 + 路由总开关(节选) */
typedef enum
{
  SELECTING_EPUB,           // 书目列表
  SELECTING_TABLE_CONTENTS, // 章节目录
  READING_EPUB              // 阅读
} UIState;

RTC_NOINIT_ATTR UIState ui_state = SELECTING_EPUB;
RTC_DATA_ATTR EpubListState epub_list_state;
RTC_DATA_ATTR EpubTocState epub_index_state;

void handleUserInteraction(Renderer *renderer, UIAction ui_action, bool needs_redraw)
{
  switch (ui_state)
  {
  case READING_EPUB:            handleEpub(renderer, ui_action); break;
  case SELECTING_TABLE_CONTENTS: handleEpubTableContents(renderer, ui_action, needs_redraw); break;
  case SELECTING_EPUB:
  default:                       handleEpubList(renderer, ui_action, needs_redraw); break;
  }
}

把整条"按键 → 屏幕"的流水线串起来看:GPIOButton 的中断服务函数在 IRAM 里翻转触发类型、记下时间戳;去抖通过时长判定完成后,回调把 UIAction 塞进 ui_queue(容量 10 个的 FreeRTOS 队列);主任务 main_task 阻塞在 xQueueReceive 上,一旦取出动作就调用 handleUserInteraction,最后 renderer->flush_display() 把新画面刷上墨水屏。从引脚电平变化到屏幕定格,一条单向、无环的管线,谁都不越界。这也解释了为什么读取可以放心阻塞等待——墨水屏更新本来就是秒级操作。

还有一个细节值得注意:按键也负责把设备从深睡眠叫醒。入睡前 button_controls->setup_deep_sleep() 会按有效电平配置唤醒(低有效走 ULP、高有效走 EXT1);醒来后先 did_wake_from_deep_sleep() 判断是不是按键唤醒,若是则 hydrate() 恢复屏幕画面、get_deep_sleep_action() 把"刚才按的是哪个键"翻译成一次 UIAction 直接执行——于是你按一下 UP,屏幕从上一页醒来,无缝继续。这套唤醒机制是第 15 章的主角,这里先记住:三按键不仅管导航,还管"叫醒服务"。

/* main.cpp:主任务里的一条流水线 */
xQueueHandle ui_queue = xQueueCreate(10, sizeof(UIAction));   // 最多缓存 10 个动作
ButtonControls *button_controls = board->get_button_controls(ui_queue);

while (esp_timer_get_time() - last_user_interaction < 120 * 1000 * 1000)
{
  UIAction ui_action = NONE;
  if (xQueueReceive(ui_queue, &ui_action, pdMS_TO_TICKS(60000)) == pdTRUE)
  {
    if (ui_action != NONE)
    {
      last_user_interaction = esp_timer_get_time();   // 刷新"空闲倒计时"
      handleUserInteraction(renderer, ui_action, false); // 按当前状态路由
    }
  }
  renderer->flush_display();
}
当前状态UPDOWNSELECT
SELECTING_EPUB(列表)选中项上移选中项下移打开选中书 → 目录态
SELECTING_TABLE_CONTENTS(目录)目录项上移目录项下移跳到所选章节 → 阅读态
READING_EPUB(阅读)上一页下一页返回书目 → 列表态
注意

这版仓库的三态状态机里,SELECT 从列表态进去的不是直接阅读,而是章节目录。如果你在别处看到"SELECT 直接进入阅读"的表述(包括 README 的按钮说明),那都是对旧版或简化版的描述——以 main.cpphandleEpubList 为准:SELECT → SELECTING_TABLE_CONTENTS

章末练习

练习 1:三按键职责 入门

用自己的话,分别说出 UP / DOWN / SELECT 在书目列表态和阅读态下各自的职责。为什么要把动作抽象成 UIAction 而不是直接在各处读写 GPIO?

提示

对照 5.1 的表格与 README 的三行按钮说明;想想换一块板子时,哪些代码会变、哪些不用变。

参考答案

列表态:UP/DOWN 移动书单选中项,SELECT 打开选中书(进章节目录);阅读态:UP/DOWN 翻上一页/下一页,SELECT 返回书目。抽象成 UIAction 后,硬件层只负责"把引脚变化翻译成动作",UI 层只认动作——换板子、换引脚,界面代码与状态机一行都不用改,实现了"人证(动作)与物证(引脚)分离"。

练习 2:电平与去抖 进阶

GPIOButton.cpp,回答:(a) active_level 为 0 与 1 时,内部电阻分别怎么配、触发什么类型的中断?(b) 本项目为什么用"电平触发 + 时长判定"而不是延时去抖或边沿触发?(c) BUTTON_DEBOUNCE = 50000 实际是多久?

提示

注意 esp_timer_get_time() 的单位;看中断函数里如何切换触发类型、如何用时间戳过滤抖动。

参考答案

(a) 低有效(0)配 GPIO_PULLUP_ONLY、触发 GPIO_INTR_LOW_LEVEL;高有效(1)配 GPIO_PULLDOWN_ONLY、触发 GPIO_INTR_HIGH_LEVEL。(b) 边沿中断在抖动下"似乎不太可靠"(源码注释原话),而电平触发在状态稳定时只触发一次;中断里若用 delay() 去抖会在 IRAM 里阻塞,所以改用时间戳:按下记起点、松开算时长,只有时长超过 BUTTON_DEBOUNCE 才视为有效按下,天然滤掉毫秒级抖动。(c) 50000 配合微秒计时 = 50 ms(头顶注释写 "100 ms",是文档/注释漂移的又一例)。

练习 3:追中断链路 进阶

把一次"阅读中按 DOWN"的完整旅程写下来,从引脚电平变化开始,到屏幕翻页结束,标注每一步在哪个文件。特别说明中断服务函数为什么不能调用 delay() 或打印日志。

提示

线索顺序:GPIOButton.cppGPIOButtonControls.cpp → 板子的 get_button_controls()(lambda 入队)→ main.cppxQueueReceivehandleUserInteractionhandleEpubreader->next() → 渲染。

参考答案

引脚被拉至有效电平 → GPIOButton::handle_interrupt()(IRAM 中断)翻转触发类型、记下按下时刻 → 松开时校验时长超过 BUTTON_DEBOUNCE 后调用回调 → 回调经 GPIOButtonControls 转发为 on_action(UIAction::DOWN) → 板子的 lambda 把动作 xQueueSendui_queue → 主任务 xQueueReceive 取出 → handleUserInteractionui_state 路由到 handleEpubreader->next() 推进页码 → 重绘并 flush_display()。中断服务函数运行在 ISR 上下文,必须短小、可重入,不能 delay()(会阻塞整个系统)也不能直接打日志/操作文件系统,所以它只干"记时间戳、翻转触发类型"这种微操。

练习 4:状态机推演 挑战

画出 SELECTING_EPUB → SELECTING_TABLE_CONTENTS → READING_EPUB 的完整状态转移图,标注每个转移由哪个 handler 触发、SELECT 每次按下分别做了什么。再回答:状态为什么必须放在 RTC_NOINIT_ATTR / RTC_DATA_ATTR 内存里?

提示

转移都发生在 SELECT 的 case 分支里;想想设备进入深睡眠后普通内存会发生什么,RTC 域为什么能保住这些状态。

参考答案

三处转移:列表态按 SELECT → handleEpubList 构造 EpubToc、置 ui_state=SELECTING_TABLE_CONTENTS;目录态按 SELECT → handleEpubTableContents 构造 EpubReaderset_state_section 定位章节、置 READING_EPUB;阅读态按 SELECT → handleEpub 删除 reader、置 SELECTING_EPUB 并重绘书单。因为设备会深睡眠,普通内存(RAM)掉电即失,而 RTC_NOINIT_ATTR/RTC_DATA_ATTR 放在不断电的 RTC 域:醒来后设备依然记得"我在哪个界面、读哪本书、翻到第几页",才能无缝续读。