第4章:板级抽象——Board 工厂

运筹帷幄:把三块板子的差异,关进同一个接口

🪶

本章导师:诸葛亮

核心方法论:运筹帷幄,决胜千里

「兵法讲'兵无常势,水无常形'——同一支军队,要能应对不同的地形。三块板子就是三种地形:屏幕驱动不同、按键布线不同、电源时序不同。高明的统率不是为每种地形各写一套兵书,而是把'板子'抽象成一个接口,让调度者只认接口行事。这一章,我们就拆解这场'运筹帷幄'。」

4.1 为什么要抽象板级

仓库支持三块板子,但它们之间的差异堪称"三副面孔"。屏幕驱动:M5 Paper 用的是自己的 M5EPD_Driver(独立驱动),LilyGo T5 4.7" 与 EPDiy V6 用 EPDiy 驱动——但对外都呈现为同一个 Renderer(渲染器,就是负责往屏幕上画内容的那个对象)。按键:LilyGo 与 M5 Paper 是三个 GPIO(芯片上可读可写的通用引脚)直连、按下为低电平;EPDiy V6 只有 SELECT 直连 GPIO(且高电平有效),UP/DOWN 还得通过 I2C 上的 PCA9555 扩展芯片去读——但对外都呈现为同一个 ButtonControls。电源时序:LilyGo 的 power_up() 要主动 epd_poweron() 才能点亮屏幕并顺带给 SD 卡供电,M5 Paper 要一直 hold 住主电源脚,EPDiy V6 则什么都不用做。SD 卡的四根 SPI 引脚(芯片伸出来的物理针脚)、电池的 ADC 通道,三块板也各不相同。

如果主程序把这些差异全部硬编码进去,main.cpp 会立刻变成一个 #ifdef 地狱。作者的解法是第 3 章那层"渲染器抽象"的向上延伸:在 src/boards/ 定义 Board 抽象基类,把"三块板的差异"全部关进它的子类里。这种设计叫板级抽象(也叫适配层):把每块板各自的硬件差异(引脚、电源、驱动)藏进一层统一接口,让上层代码只对着接口说话,不关心自己在哪块板上。看 main.cpp 的主任务开头——它先 Board::factory() 拿到一个 Board*,然后一路 power_up()get_renderer()get_button_controls()……主循环从头到尾不知道自己跑在哪块板上。换板 = 换一个 env = 换一个 -DBOARD_TYPE_* 宏,主程序一行不用改。这就是抽象的威力:差异被集中,共性被复用

/* main.cpp(节选):主任务只跟 Board 抽象打交道 */
Board *board = Board::factory();        // 编译期选板,由 BOARD_TYPE_* 宏决定
board->power_up();
Renderer *renderer = board->get_renderer();
board->start_filesystem();
Battery *battery = board->get_battery();
ButtonControls *button_controls = board->get_button_controls(ui_queue);
TouchControls *touch_controls = board->get_touch_controls(renderer, ui_queue);
// …… 主循环只消费 renderer / battery / button_controls ……
board->stop_filesystem();
board->prepare_to_sleep();
; platformio.ini:三套 env 各定义一个 BOARD_TYPE_*,交给 factory 选板
[env:lilygo_t5_47]
-DBOARD_TYPE_LILIGO_T5_47     ; → new Lilygo_t5_47()
[env:epdiy]
-DBOARD_TYPE_EPDIY            ; → new Epdiy()
[env:m5_paper]
-DBOARD_TYPE_M5_PAPER         ; → new M5Paper()
维度LilyGo T5 4.7"EPDiy V6M5 Paper
屏幕驱动 / 渲染器EPDiy / EpdiyRendererEPDiy / EpdiyRendererM5EPD_Driver / M5PaperRenderer
按键引脚UP 34 · DOWN 39 · SELECT 35仅 SELECT 39UP 37 · DOWN 39 · SELECT 38
按键极性低电平(0)高电平(1)低电平(0)
UP / DOWN 读取GPIO 直连I2C(PCA9555 扩展芯片)GPIO 直连
power_up()epd_poweron()空实现空实现
prepare_to_sleep()epd_poweroff()epd_poweroff()rtc_gpio_hold_en 保持主电源
诸葛亮提示

判断一个抽象是否成功,标准不是"它多优雅",而是"换一块板子要改几处"。在 Board 工厂下,换板等于改 platformio.ini 的一个 -D 宏——改动被压缩到最小、最局部。这正是"运筹"的工程化定义。

4.2 Board.h / Board.cpp:接口与工厂

Board.h 定义抽象基类(抽象类,也叫接口:只声明方法、不实现具体逻辑的类,具体做法交给子类去填)。方法分两层。纯虚方法virtual ... = 0)共 4 个:power_up()prepare_to_sleep()get_renderer()get_button_controls()——每块板都必须自己实现,因为它们最容易变。另有 4 个带默认实现的虚方法start_filesystem()stop_filesystem()get_battery()get_touch_controls()——大多数板子直接继承默认行为,只有需要特殊处理时才覆写(M5 Paper 就覆写了 stop_filesystem())。这个"纯虚强制 + 默认虚可选"的分层,让新板子只需要关心真正不同的那部分。

Board.cppfactory()编译期选板的关键——这种"用一个函数按类型返回对应对象"的写法就是工厂模式:调用方不用关心选板细节,一句 Board::factory() 就能拿到该拿的那块板。它用一连串 #ifdef BOARD_TYPE_* 决定 new 哪个子类——因为宏来自 platformio.ini,选板发生在编译期而非运行期,没有虚表查找的运行时开销,也不用在启动时做一堆 if 判断。注意宏名 BOARD_TYPE_LILIGO_T5_47(少了个 Y)是照抄 platformio.ini 的拼写,别试图"顺手修正"——改了反而匹配不上。

再看默认实现本身,它们也全是"编译期配置"的体现。start_filesystem():定义了 USE_SPIFFSnew SPIFFS("/fs"),否则 new SDCard("/fs", MISO, MOSI, CLK, CS),引脚从 SD_CARD_PIN_NUM_* 宏读取;get_battery():定义了 BATTERY_ADC_CHANNELnew ADCBattery(...),否则返回 nullptr(不显示电量);get_touch_controls():默认返回一个什么都不做的哑实现。这套"默认值"把 platformio.ini 的宏和 Board 的运行时对象牢牢绑在一起——宏变,对象变,代码不变。

/* Board.h:纯虚方法 = 每块板必须实现;默认虚方法 = 可继承 */
class Board {
protected:
#ifdef USE_SPIFFS
  SPIFFS *spiffs;                 // 文件系统二选一,编译期决定
#else
  SDCard *sdcard;
#endif
public:
  virtual void power_up() = 0;                    // 上电、开屏等启动工作
  virtual void prepare_to_sleep() = 0;           // 进深睡眠前的收尾
  virtual Renderer *get_renderer() = 0;          // 本板的渲染器
  virtual ButtonControls *get_button_controls(xQueueHandle ui_queue) = 0;

  virtual void start_filesystem();               // 默认:SPIFFS 或 SD 卡
  virtual void stop_filesystem();
  virtual Battery *get_battery();                // 默认:ADCBattery 或 nullptr
  virtual TouchControls *get_touch_controls(Renderer *r, xQueueHandle q); // 默认哑实现

  static Board *factory();                          // 编译期选板
};
/* Board.cpp:工厂方法——用 #ifdef 在编译期选板 */
Board *Board::factory()
{
#ifdef BOARD_TYPE_LILIGO_T5_47
  return new Lilygo_t5_47();
#endif
#ifdef BOARD_TYPE_EPDIY
  return new Epdiy();
#endif
#ifdef BOARD_TYPE_M5_PAPER
  return new M5Paper();
#endif
}

/* 默认文件系统实现:宏决定用 SPIFFS 还是 SD 卡 */
void Board::start_filesystem()
{
#ifdef USE_SPIFFS
  spiffs = new SPIFFS("/fs");
#else
  sdcard = new SDCard("/fs",
      SD_CARD_PIN_NUM_MISO, SD_CARD_PIN_NUM_MOSI,
      SD_CARD_PIN_NUM_CLK,  SD_CARD_PIN_NUM_CS);
#endif
}
注意

factory() 的三个 #ifdef 是互相独立的,不是 #elif 链。这意味着理论上可以同时定义两个 BOARD_TYPE_* 宏——那样 new 会先命中写在前面的分支,另一块板的代码永远不会被实例化。真正的 platformio.ini 只定义其一,但读懂"谁先谁后"能帮你理解这种写法的不严谨之处。

4.3 三块板实现对比

Lilygo_t5_47.cpp 是最典型的"EPDiy 板"。按键引脚 34 / 39 / 35,按下为低电平(BUTONS_ACTIVE_LEVEL 0);power_up()epd_poweron(),注释说得很明白——"Need to power on the EDP to get power to the SD Card",点亮屏幕才能给 SD 卡供电;prepare_to_sleep() 则对称地 epd_poweroff()。渲染器用 EpdiyRenderer;若定义了 USE_L58_TOUCH,触摸走 L58TouchControls,否则退回哑实现。

M5Paper.cpp 的屏幕不是 EPDiy 驱动,而是 M5 自家的 M5EPD_Driver,所以渲染器是 M5PaperRenderer。按键 37 / 39 / 38,低电平有效。它的电源策略最特殊:prepare_to_sleep() 不是关电,而是把主电源脚 M5EPD_MAIN_PWR_PINGPIO_NUM_2)用 rtc_gpio_hold_en() 保持在高电平——因为进的是深睡眠而不是彻底关机,主电源要一直维持。它还覆写了 stop_filesystem(),故意不 delete sdcard,源码注释交代原因:"seems to cause issues with the M5 Paper"。

Epdiy.cpp 的按键结构最与众不同:V6 板上只有 SELECT 直连 GPIO(GPIO_NUM_39),UP/DOWN 通过 I2C 上的 PCA9555 IO 扩展芯片读取(EpdiyV6ButtonControls 里一个 control_task 每 50 ms 轮询一次 I2C)。所以它返回的不是 GPIOButtonControls,而是专门的 EpdiyV6ButtonControls。极性是高电平有效BUTONS_ACTIVE_LEVEL 1),源码注释解释了原因:"For some reason in EPDiy on low it wakes up directly from deepsleep"——低电平方案在 EPDiy 上有深睡眠误唤醒的怪问题。而 power_up() 特意什么都不做("do not call epd_poweron in this case"),V6 板不需要手动开屏。

/* Lilygo_t5_47.cpp(节选):按键 GPIO 直连、低电平有效,power_up 开屏给 SD 供电 */
#define BUTTON_UP_GPIO_NUM     GPIO_NUM_34
#define BUTTON_DOWN_GPIO_NUM   GPIO_NUM_39
#define BUTTON_SELECT_GPIO_NUM GPIO_NUM_35
#define BUTONS_ACTIVE_LEVEL    0   // 按下为低电平

void Lilygo_t5_47::power_up()
{
  // Need to power on the EDP to get power to the SD Card
  epd_poweron();
}
void Lilygo_t5_47::prepare_to_sleep()
{
  epd_poweroff();
}
/* Epdiy.cpp(节选):只有 SELECT 直连 GPIO,UP/DOWN 走 I2C 扩展芯片 */
// Only select is managed in EPDiy V6 version, up and down are read by I2C
#define BUTTON_SELECT_GPIO_NUM GPIO_NUM_39
#define BUTONS_ACTIVE_LEVEL    1   // 高电平有效(EPDiy 上低电平会误唤醒深睡眠)

void Epdiy::power_up()
{
  // nothing to do for this board - do not call epd_poweron in this case
}
ButtonControls *Epdiy::get_button_controls(xQueueHandle ui_queue)
{
  return new EpdiyV6ButtonControls(
      BUTTON_SELECT_GPIO_NUM, BUTONS_ACTIVE_LEVEL,
      [ui_queue](UIAction action) { xQueueSend(ui_queue, &action, 0); });
}
注意

BUTONS_ACTIVE_LEVEL 在 README 和全部三块板的源码里都拼成 "BUTONS"(少一个 O)——这是"错得一致"的典型:只要 #define 与使用处拼写相同,编译就通过。读到这种奇怪命名别急着"纠正",先 grep -rn "BUTONS" src/ 全仓搜一遍再决定。

4.4 扩展新板的步骤

README 的 "Porting to other boards" 一节给出了清晰的路线图,核心是两条腿:(1) 新建板类;(2) 新增 platformio.ini 环境。但有一条重要的"快捷通道":如果你的屏幕本身就是 EPDiy 并行屏,板类这一步可以整个省略——只要在 env 里定义 BOARD_TYPE_EPDIY + CONFIG_EPD_DISPLAY_TYPE_* + CONFIG_EPD_BOARD_REVISION_*factory() 就会自动 new Epdiy()。真正需要写 Board 子类的,是那些"既不是 EPDiy 屏、也没有现成板类"的全新板。

全新板的步骤再细化就是 4 步。第一步,在 src/boards/ 新建 MyBoard.h/.cpp,实现 Board.h 的 4 个纯虚方法——最低限度要返回一个能画到屏幕上的 Renderer 和一个能翻页的 ButtonControls;README 的原话是 "Have a look at M5Paper.h for inspiration"——因为它把"继承默认实现"和"覆写差异"平衡得最好。第二步,把新板类型加进 Board::factory(),加一个 #ifdef BOARD_TYPE_MY_BOARD 分支。第三步,在 platformio.ini 新增 [env:my_board],写入屏幕型号、BOARD_TYPE、SD 引脚、BUTONS_ACTIVE_LEVEL、电池 ADC 通道等宏。第四步,作者还请你把移植成果发回仓库——README 明确说欢迎 Pull Request。

README 点名的"重要设置",其实就是 4.3 那些宏的通用版:前两个来自 epdiy 库(屏幕型号 + 板卡修订,见第 3 章);BUTONS_ACTIVE_LEVEL 告诉代码按键是按下为低还是按下为高(深睡眠唤醒机制也随之不同——低电平用 ULP、高电平用 EXT1,详见第 15 章);然后是 SD 卡的 4 根 SPI 引脚、可选的 L58 触摸、以及电池电压分压器接的 ADC 通道。把这些宏填对,一块新板通常就能跑起来——剩下的交给 Board 抽象。

# README "Porting to other boards":两条腿的路线图
# 1. Create a new board class in src/boards that implements Board.h
#    - 若用 EPDIY 板:只需 -DBOARD_TYPE_EPDIY,一切由预处理宏搞定
#    - 全新板:至少返回一个 Renderer + 一个 GPIOButtonControls
#      Have a look at M5Paper.h for inspiration.
#    - 并把新板类型加进 Board::factory()
# 2. Add a new environment to platformio.ini
#    关键设置:屏幕型号、板卡修订、BUTONS_ACTIVE_LEVEL、SD 引脚、ADC 通道
; platformio.ini:为一块全新板子新增 env(最小配置模板)
[env:my_board]
extends = esp32_common
build_flags =
  ${common.build_flags}
  ; 让工厂实例化你的板类
  -DBOARD_TYPE_MY_BOARD
  ; EPDiy 屏幕型号 + 板卡修订(若你的屏兼容 EPDiy)
  -DCONFIG_EPD_DISPLAY_TYPE_ED060XC3
  -DCONFIG_EPD_BOARD_REVISION_V6
  ; 按键极性:按下为 0(低有效)或 1(高有效)
  -DBUTONS_ACTIVE_LEVEL=0
  ; SD 卡四根 SPI 引脚
  -DSD_CARD_PIN_NUM_MISO=GPIO_NUM_36
  -DSD_CARD_PIN_NUM_MOSI=GPIO_NUM_0
  -DSD_CARD_PIN_NUM_CLK=GPIO_NUM_13
  -DSD_CARD_PIN_NUM_CS=GPIO_NUM_14
步骤动作何时可省
1. 板类src/boards/ 新建子类,实现 Board.h 纯虚方法;加入 factory()屏幕兼容 EPDiy 时——只需 -DBOARD_TYPE_EPDIY
2. 渲染器get_renderer() 返回本板 RendererEPDiy 并行屏直接返回 EpdiyRenderer
3. 按键get_button_controls() 返回 GPIOButtonControls(或定制类)
4. 环境platformio.ini 新增 [env:my_board],写屏幕 / 引脚 / 极性宏
诸葛亮提示

动手移植前,先回答三个问题:(1) 屏幕驱动是 EPDiy 并行屏还是别的?(2) 按键是 GPIO 直连还是有 IO 扩展芯片?(3) 供电要不要手动开?三个答案决定了你要写多少代码——三个都是"是",你可能只需复制 [env:epdiy] 改几个引脚宏。

章末练习

练习 1:读工厂方法 入门

打开 src/boards/Board.cppfactory(),列出三个 #ifdef 分支各自实例化哪个板类。如果某个 env 同时定义了 BOARD_TYPE_EPDIYBOARD_TYPE_M5_PAPER 两个宏,会发生什么?

提示

三个分支是独立的 #ifdef 不是 #elif;想想编译器遇到多个匹配分支时哪个 return 先执行。

参考答案

三个分支依次是 BOARD_TYPE_LILIGO_T5_47new Lilygo_t5_47()BOARD_TYPE_EPDIYnew Epdiy()BOARD_TYPE_M5_PAPERnew M5Paper()。若同时定义 EPDIY 与 M5_PAPER,#ifdef BOARD_TYPE_EPDIY 分支先命中,函数在 new Epdiy()return,M5Paper 分支永远不会执行——后面的代码是死代码,但能通过编译。

练习 2:按键极性差异 进阶

对比 Lilygo_t5_47.cppEpdiy.cpp:两块板的 BUTONS_ACTIVE_LEVEL 分别是什么?结合源码注释,解释为什么 EPDiy V6 板选择了高电平有效。

提示

前者 0、后者 1;看 Epdiy.cpp 里 BUTONS_ACTIVE_LEVEL 正上方那行英文注释。

参考答案

LilyGo 是 0(低电平有效),EPDiy 是 1(高电平有效)。源码注释给出的原因:"For some reason in EPDiy on low it wakes up directly from deepsleep"——在 EPDiy 板上若按低电平有效处理,一进深睡眠就会被直接误唤醒,所以作者改为高电平有效避开这个问题。注意 Epdiy.cpp 里"buttons are low when pressed"那句注释与实际的 1 自相矛盾,属于注释漂移,仍以代码值为准。

练习 3:最小移植 env 进阶

为一块"未知品牌、EPDiy 并行屏、按键按下为低电平、SD 引脚 MISO36/MOSI0/CLK13/CS14"的板子写出最小 [env:my_board](含必需宏),并说明哪些步骤可以省略。

提示

屏兼容 EPDiy → 可省板类;极性是低 → BUTONS_ACTIVE_LEVEL=0;对照 [env:epdiy] 的结构来写。

参考答案

env 只需四组宏:-DBOARD_TYPE_EPDIY(复用 Epdiy 板类,省略新建子类)、-DCONFIG_EPD_DISPLAY_TYPE_ED060XC3 + -DCONFIG_EPD_BOARD_REVISION_V6(屏幕,按实际型号填)、-DBUTONS_ACTIVE_LEVEL=0(低有效)、-DSD_CARD_PIN_NUM_MISO=GPIO_NUM_36 等 4 行 SD 引脚。因为复用了 Epdiy 板类,1–3 步全部省略,只剩第 4 步写 env。若这块屏是独立驱动而非 EPDiy,则必须新建板类并加入 factory()

练习 4:抽象设计评价 挑战

Board 抽象把文件系统、电池、触摸做成"带默认实现的虚方法"。请各写两条:这个设计的好处,和它付出的代价(从"新板要写的代码量"与"接口的可读性/职责"两个角度展开)。

提示

好处想"默认行为省事";代价想"一个类同时是板子又是文件系统/电池工厂"——它违反了某种单一职责吗?

参考答案

好处:(1) 新板子只需实现 4 个纯虚方法,其余全继承默认行为,起点很低;(2) 三块板的文件系统、电池逻辑被复用在一处,改动只需改 Board.cpp 一处。代价:(1) Board 职责过载——它既是"板子"又是文件系统、电池、触摸的工厂,接口膨胀,读者要记住哪些可覆写、哪些别乱动;(2) 默认实现把"编译期宏"和"运行时对象"隐式耦合,读懂 start_filesystem() 必须同时盯着 USE_SPIFFS、SD 引脚宏和继承覆写关系,认知负担不小。取舍的实质:用"接口的适度臃肿"换"移植成本的显著降低"。