运筹帷幄:把三块板子的差异,关进同一个接口
核心方法论:运筹帷幄,决胜千里
「兵法讲'兵无常势,水无常形'——同一支军队,要能应对不同的地形。三块板子就是三种地形:屏幕驱动不同、按键布线不同、电源时序不同。高明的统率不是为每种地形各写一套兵书,而是把'板子'抽象成一个接口,让调度者只认接口行事。这一章,我们就拆解这场'运筹帷幄'。」
仓库支持三块板子,但它们之间的差异堪称"三副面孔"。屏幕驱动: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 V6 | M5 Paper |
|---|---|---|---|
| 屏幕驱动 / 渲染器 | EPDiy / EpdiyRenderer | EPDiy / EpdiyRenderer | M5EPD_Driver / M5PaperRenderer |
| 按键引脚 | UP 34 · DOWN 39 · SELECT 35 | 仅 SELECT 39 | UP 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 宏——改动被压缩到最小、最局部。这正是"运筹"的工程化定义。
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.cpp 的 factory() 是编译期选板的关键——这种"用一个函数按类型返回对应对象"的写法就是工厂模式:调用方不用关心选板细节,一句 Board::factory() 就能拿到该拿的那块板。它用一连串 #ifdef BOARD_TYPE_* 决定 new 哪个子类——因为宏来自 platformio.ini,选板发生在编译期而非运行期,没有虚表查找的运行时开销,也不用在启动时做一堆 if 判断。注意宏名 BOARD_TYPE_LILIGO_T5_47(少了个 Y)是照抄 platformio.ini 的拼写,别试图"顺手修正"——改了反而匹配不上。
再看默认实现本身,它们也全是"编译期配置"的体现。start_filesystem():定义了 USE_SPIFFS 就 new SPIFFS("/fs"),否则 new SDCard("/fs", MISO, MOSI, CLK, CS),引脚从 SD_CARD_PIN_NUM_* 宏读取;get_battery():定义了 BATTERY_ADC_CHANNEL 就 new 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 只定义其一,但读懂"谁先谁后"能帮你理解这种写法的不严谨之处。
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_PIN(GPIO_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/ 全仓搜一遍再决定。
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() 返回本板 Renderer | EPDiy 并行屏直接返回 EpdiyRenderer |
| 3. 按键 | get_button_controls() 返回 GPIOButtonControls(或定制类) | — |
| 4. 环境 | platformio.ini 新增 [env:my_board],写屏幕 / 引脚 / 极性宏 | — |
动手移植前,先回答三个问题:(1) 屏幕驱动是 EPDiy 并行屏还是别的?(2) 按键是 GPIO 直连还是有 IO 扩展芯片?(3) 供电要不要手动开?三个答案决定了你要写多少代码——三个都是"是",你可能只需复制 [env:epdiy] 改几个引脚宏。
打开 src/boards/Board.cpp 的 factory(),列出三个 #ifdef 分支各自实例化哪个板类。如果某个 env 同时定义了 BOARD_TYPE_EPDIY 和 BOARD_TYPE_M5_PAPER 两个宏,会发生什么?
三个分支是独立的 #ifdef 不是 #elif;想想编译器遇到多个匹配分支时哪个 return 先执行。
三个分支依次是 BOARD_TYPE_LILIGO_T5_47 → new Lilygo_t5_47()、BOARD_TYPE_EPDIY → new Epdiy()、BOARD_TYPE_M5_PAPER → new M5Paper()。若同时定义 EPDIY 与 M5_PAPER,#ifdef BOARD_TYPE_EPDIY 分支先命中,函数在 new Epdiy() 处 return,M5Paper 分支永远不会执行——后面的代码是死代码,但能通过编译。
对比 Lilygo_t5_47.cpp 与 Epdiy.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 自相矛盾,属于注释漂移,仍以代码值为准。
为一块"未知品牌、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()。
Board 抽象把文件系统、电池、触摸做成"带默认实现的虚方法"。请各写两条:这个设计的好处,和它付出的代价(从"新板要写的代码量"与"接口的可读性/职责"两个角度展开)。
好处想"默认行为省事";代价想"一个类同时是板子又是文件系统/电池工厂"——它违反了某种单一职责吗?
好处:(1) 新板子只需实现 4 个纯虚方法,其余全继承默认行为,起点很低;(2) 三块板的文件系统、电池逻辑被复用在一处,改动只需改 Board.cpp 一处。代价:(1) Board 职责过载——它既是"板子"又是文件系统、电池、触摸的工厂,接口膨胀,读者要记住哪些可覆写、哪些别乱动;(2) 默认实现把"编译期宏"和"运行时对象"隐式耦合,读懂 start_filesystem() 必须同时盯着 USE_SPIFFS、SD 引脚宏和继承覆写关系,认知负担不小。取舍的实质:用"接口的适度臃肿"换"移植成本的显著降低"。