关键词:端口抽象层(Platform Abstraction Layer)、零成本抽象、弱符号覆盖、运行时回调注册
适用读者:编写或维护嵌入式 C 语言库的工程师,对跨平台移植有需求
1. 问题:业务代码不应知道硬件长什么样
嵌入式项目中最影响代码复用的因素之一,就是业务逻辑与硬件访问紧耦合。
以 keyflow 按键检测库为例,早期版本的 ButtonManager_AddButton 签名如下:
c
Button* ButtonManager_AddButton(ButtonManager *mgr,
ButtonConfig config,
bool (*read_pin)(uint16_t pin), // ← 硬件细节
ButtonEventCallback callback,
void *user_data);
这里 read_pin 是一个函数指针,由调用方实现后传入。这是做到了"不依赖具体 GPIO 操作",但问题在于:
每次使用 keyflow,都需要写一个 read_pin 回调函数,哪怕项目里用的是 STM32 HAL:
c
// 每次都要写这个重复的 glue code
bool hal_read_pin(uint16_t pin) {
return HAL_GPIO_ReadPin(GPIOA, pin) == GPIO_PIN_SET;
}
ButtonManager_AddButton(&mgr, cfg, hal_read_pin, callback, NULL);
同样的问题在矩阵键盘扩展中更严重------MatrixKey_Init 需要同时传入 write_row 和 read_col 两个回调:
c
MatrixKey_Init(&scanner, &cfg, cells,
hal_write_row, // ← 又要写 glue
hal_read_col, // ← 又要写 glue
on_event, NULL);
当项目需要从 STM32 迁移到 ESP32,或者希望在同一份代码中支持多种 GPIO 扩展(如矩阵键盘 + 独立按键共用不同引脚),这些 glue code 就变成维护负担。
2. 设计目标
我们希望在不改业务代码的前提下,让 keyflow 运行在任何硬件平台上。具体目标:
| 目标 | 说明 |
|---|---|
| 硬件相关代码集中 | 所有 GPIO / 定时器操作集中在一个 port 层,业务层(button.c)完全不出现 HAL_GPIO_ReadPin 或 xTaskGetTickCount |
| 链接时选择平台 | 编译时链接 button_port_stm32.c 或 button_port_esp32.c,业务层零改动 |
| 运行时可覆盖 | 支持用 ButtonPort_SetOps() 动态注册硬件操作,适用于单元测试、模拟器、多外设共存 |
| 零成本 | 未启用多平台时不产生任何额外代码或数据 |
3. 方案:ButtonPort 端口抽象层
3.1 接口设计
新建一个最小接口文件 button_port.h,只暴露三个操作:
c
// 读取引脚电平(0 或 1)
uint8_t ButtonPort_ReadPin(uint16_t pin);
// 设置引脚输出电平(矩阵键盘行线驱动)
void ButtonPort_WritePin(uint16_t pin, uint8_t level);
// 获取系统时间(毫秒)
uint32_t ButtonPort_GetTickMs(void);
// 运行时注册自定义实现(可选;NULL 恢复默认)
void ButtonPort_SetOps(const ButtonPortOps *ops);
ButtonPortOps 是这三个操作的函数指针集合:
c
typedef struct {
uint8_t (*read_pin)(uint16_t pin);
void (*write_pin)(uint16_t pin, uint8_t level);
uint32_t (*get_tick_ms)(void);
} ButtonPortOps;
3.2 两层 fallback 机制
业务层(如 button.c)永远只调用 ButtonPort_ReadPin(),不直接调用 HAL 或 SDK 函数。但这个函数本身有两级 fallback:
业务层代码
↓
ButtonPort_ReadPin(pin) ← 业务层入口
↓
s_port_ops.read_pin ? ← 运行时 SetOps 注册的回调
↓ (fallback)
button_port_read_pin(pin) ← 链接时弱符号覆盖的版本
这意味着:
- 默认编译 :使用
button_port.c中的默认实现(weak 函数,默认返回 0 或模拟 tick) - 链接覆盖 :在工程中加入
button_port_stm32.c,链接器会用它覆盖 weak 符号 - 运行时替换 :调用
ButtonPort_SetOps(&my_ops)动态注册,无需重新编译
3.3 引脚编号约定
为了在 ButtonPort_ReadPin(pin) 中传达"哪个 GPIO",需要一个跨平台一致的编号方案。本设计使用:
pin = (port_index << 8) | pin_index
示例:
- STM32:
pin = (0 << 8) | 5→ PA5;pin = (1 << 8) | 3→ PB3 - 51 单片机:
pin = (1 << 8) | 0→ P1.0 - ESP32: 直接用 GPIO 编号
4→ GPIO4 - Linux: 直接用 sysfs GPIO 编号
17→/sys/class/gpio/gpio17
每个平台的 port 实现文件负责解码这个编号。
4. 弱符号(weak symbol)实现
GCC/ARMCC/IAR 等嵌入式编译器支持 __attribute__((weak)),允许链接时用非 weak 版本覆盖 weak 版本。本方案利用这一机制实现平台无关的默认实现:
button_port.c(默认 / 模拟实现):
c
// weak 函数:若无平台实现链接进来,就用这个
__attribute__((weak))
uint8_t button_port_read_pin(uint16_t pin)
{
(void)pin;
return 0; // 默认返回低电平
}
// 运行时回调(优先于 weak 函数)
static ButtonPortOps s_port_ops = { NULL, NULL, NULL };
uint8_t ButtonPort_ReadPin(uint16_t pin)
{
if (s_port_ops.read_pin != NULL)
return s_port_ops.read_pin(pin);
return button_port_read_pin(pin); // fallback 到 weak
}
button_port_stm32.c(链接后替换 weak):
c
// 链接时自动覆盖 button_port.c 的 weak 函数
uint8_t button_port_read_pin(uint16_t pin)
{
GPIO_TypeDef *port = get_port_base(pin >> 8);
uint16_t mask = 1U << (pin & 0xFF);
return (HAL_GPIO_ReadPin(port, mask) == GPIO_PIN_SET) ? 1U : 0U;
}
只要在 Makefile 或 CMakeLists.txt 中同时编译 button.c + button_port.c + button_port_stm32.c,STM32 版本就会覆盖默认实现,业务代码完全不用改。
5. 业务层改造
5.1 button.c:从回调优先变为 port 优先
旧的读取逻辑是"回调优先":
c
// 旧逻辑
#define RAW_PRESSED(btn) \
((btn)->read_pin ? ((btn)->read_pin((btn)->config.pin) == ... ) : false)
新的逻辑是"port 兜底,回调可选":
c
// 新逻辑:button_raw_read 优先用回调,回退到 ButtonPort
static uint8_t button_raw_read(Button *btn)
{
if (btn->read_pin != NULL)
return btn->read_pin(btn->config.pin) ? 1U : 0U;
return ButtonPort_ReadPin(btn->config.pin);
}
这保留了原有的灵活性(read_pin 仍然可用,支持自定义逻辑如多路 ADC 按键),同时新增了"不传回调就用 port"的默认行为。
5.2 button_matrix.c:引脚编号替代回调
旧接口需要传两个回调:
c
// 旧接口(需要 glue code)
MatrixKey_Init(&scanner, &cfg, cells,
hal_write_row, // ← 需要写函数
hal_read_col, // ← 需要写函数
on_event, NULL);
新接口改为传递引脚编号数组:
c
// 新接口(无 glue code)
uint16_t row_pins[4] = {10, 11, 12, 13}; // 硬件相关:在板级初始化时定义
uint16_t col_pins[4] = {20, 21, 22, 23};
MatrixKeyConfig cfg = {
.row_pins = row_pins,
.col_pins = col_pins,
.row_count = 4,
.col_count = 4,
.active_level = 1,
.scan_interval_ms = 10,
.debounce_ms = 15,
};
MatrixKey_Init(&scanner, &cfg, cells, on_event, NULL);
内部实现中,行扫描和列读取直接调用 ButtonPort_WritePin 和 ButtonPort_ReadPin,无需任何 glue code。
6. 各平台 port 实现一览
| 平台文件 | 定时器 | GPIO 读写 | 适用场景 |
|---|---|---|---|
button_port.c |
模拟递增 | 无操作 | PC 模拟、单元测试 |
button_port_stm32.c |
HAL_GetTick() |
HAL_GPIO_ReadPin/WritePin |
STM32 全系列 |
button_port_51.c |
外部 s_system_ms |
直接操作 P0/P1/P2/P3 SFR | STC89/AT89S51 |
button_port_esp32.c |
xTaskGetTickCount() |
gpio_get_level/set_level |
ESP32 (FreeRTOS) |
button_port_linux.c |
clock_gettime(CLOCK_MONOTONIC) |
sysfs /sys/class/gpio |
Linux PC 验证 |
7. API 变化与迁移指南
7.1 API 签名变化
| 旧 API | 新 API |
|---|---|
ButtonManager_AddButton(mgr, cfg, read_pin, cb, ud) |
ButtonManager_AddButton(mgr, cfg, cb, ud) |
ButtonManager_SetTimeSource(mgr, get_tick) |
ButtonPort_SetOps(&(ButtonPortOps){read_pin, NULL, get_tick_ms}) |
MatrixKey_Init(scanner, cfg, cells, write_row, read_col, cb, ud) |
MatrixKey_Init(scanner, cfg, cells, cb, ud) |
7.2 迁移步骤(以 STM32 为例)
Step 1. 在工程中添加 src/button/button_port_stm32.c
Step 2. 在 main 初始化中,调用一次 ButtonPort_SetOps:
c
// main.c 或 board.c 中
static uint8_t my_read_pin(uint16_t pin) {
GPIO_TypeDef *port = ...; // 解码 pin
return HAL_GPIO_ReadPin(port, mask) ? 1U : 0U;
}
static uint32_t my_get_tick(void) { return HAL_GetTick(); }
ButtonPort_SetOps(&(ButtonPortOps){
.read_pin = my_read_pin,
.write_pin = NULL, // 普通按键不需要写
.get_tick_ms = my_get_tick,
});
如果 read_pin 的实现只是简单透传给 HAL,可以省略
SetOps,直接链接button_port_stm32.c即可------后者已经实现了 HAL 版本的 read_pin 和 get_tick。
Step 3. 删除旧的 hal_read_pin glue code
Step 4. 删除 ButtonManager_SetTimeSource 调用(GetTickMs 已由 port 提供)
8. 设计反思:为什么不用宏切换平台
嵌入式社区常见的平台切换方案是条件编译:
c
#if defined(STM32_HAL)
#define READ_PIN(pin) HAL_GPIO_ReadPin(...)
#elif defined(ESP32_IDF)
#define READ_PIN(pin) gpio_get_level(...)
#endif
这种方案的缺点:
- 所有平台代码揉在一个文件里,难以维护
- 添加新平台需要修改核心源码(违反开闭原则)
- 难以在运行时切换(模拟器 / 单元测试)
另一种思路是用函数指针注册,与本方案相比:
| 方案 | 优点 | 缺点 |
|---|---|---|
条件编译 #if |
无运行时开销 | 改平台要改源码 |
| 函数指针注册 | 运行时可切 | 每个调用点都要传回调 |
| 弱符号 + SetOps(本案) | 链接即切换,运行时可覆盖,零运行时开销 | 需要链接器支持(嵌入式工具链均支持) |
本案选择了第三条路,兼顾了编译期和运行期的灵活性。
项目仓库
- GitCode 仓库 :https://gitcode.com/AZE-BlackCore/keyflow
免责声明
本文内容仅作为技术研究与学习交流 之用,不构成任何形式的产品设计建议、电子工程建议或商业推荐。文中涉及的代码片段、状态机模型、消抖策略等技术方案,基于特定嵌入式场景与硬件条件设计,直接用于生产环境前请务必进行充分的测试与验证。
使用本文内容所导致的任何直接或间接后果(包括但不限于设备损坏、数据丢失、商业损失等),作者及 AZE-BlackCore 不承担任何责任。 请根据你的实际项目需求,结合硬件手册、行业规范与最佳实践进行独立判断和决策。
版权声明:本文版权归 AZE-BlackCore 所有,转载请注明出处。封面与示意图由 AI 生成,仅供示意参考。