07 硬件接口抽离:嵌入式库的可移植性设计

关键词:端口抽象层(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_rowread_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_ReadPinxTaskGetTickCount
链接时选择平台 编译时链接 button_port_stm32.cbutton_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_WritePinButtonPort_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(本案) 链接即切换,运行时可覆盖,零运行时开销 需要链接器支持(嵌入式工具链均支持)

本案选择了第三条路,兼顾了编译期和运行期的灵活性。


项目仓库

免责声明

本文内容仅作为技术研究与学习交流 之用,不构成任何形式的产品设计建议、电子工程建议或商业推荐。文中涉及的代码片段、状态机模型、消抖策略等技术方案,基于特定嵌入式场景与硬件条件设计,直接用于生产环境前请务必进行充分的测试与验证

使用本文内容所导致的任何直接或间接后果(包括但不限于设备损坏、数据丢失、商业损失等),作者及 AZE-BlackCore 不承担任何责任。 请根据你的实际项目需求,结合硬件手册、行业规范与最佳实践进行独立判断和决策。

版权声明:本文版权归 AZE-BlackCore 所有,转载请注明出处。封面与示意图由 AI 生成,仅供示意参考。