3. 外设接口基础

嵌入式系统移植,说白了就是让同一套代码能在不同芯片上跑起来。而外设接口的抽象,是其中最绕不开的坎。我做了十几年嵌入式,见过太多项目因为接口耦合太死,换颗芯片就得重写大半驱动——那滋味,真不好受。

这一章,咱们就聊聊怎么把这些常见外设接口抽象好。不是讲芯片手册,而是讲架构思路。

3.1 外设接口的共性特征

先想一个问题:GPIO、UART、I2C、SPI、定时器、PWM、ADC/DAC,它们有什么共同点?

我个人习惯把它们分成三层来看:

  • 物理层:引脚、电平、时序。这部分芯片差异最大。
  • 逻辑层:寄存器配置、中断、DMA。这部分可以封装。
  • 服务层:读写接口、回调、超时。这部分应该完全统一。

抽象的目标,就是把服务层固定下来,让上层代码不关心底层是谁家的IP。

核心原则:接口抽象不是消灭差异,而是隔离差异。差异还在,只是不影响你上层的业务逻辑。

3.2 GPIO抽象方法

GPIO看似简单,但坑最多。我记得有一次,某款芯片的GPIO输出寄存器需要先写1再清0才能翻转,而另一款直接写异或就行。如果上层代码直接操作寄存器,移植时得改几百处。

我的做法是这样:

// gpio_hal.h — 硬件抽象层接口
typedef struct {
    uint8_t port;
    uint8_t pin;
    void   (*set)(void *obj, uint8_t level);
    uint8_t (*get)(void *obj);
    void   (*toggle)(void *obj);
    void   (*config)(void *obj, uint32_t mode);
} gpio_ops_t;

// 使用示例
gpio_ops_t led;
led.set(&led, 1);   // 不管底层怎么翻转,上层只管写1

这里的关键是函数指针表。每个GPIO实例都挂一套操作函数,底层实现可以完全不同,但调用方式一致。

我的经验:GPIO抽象时,别忘了把中断也包进去。很多芯片的GPIO中断触发方式不一样——边沿、电平、双边沿,统一成 gpio_irq_register(pin, mode, callback) 就好。

3.3 UART抽象方法

UART的抽象相对成熟。无非是初始化、发送、接收、中断/轮询。但有个细节容易被忽略——流控。

我曾经在一个项目里,芯片A的UART支持硬件流控(RTS/CTS),芯片B不支持。上层代码直接调了流控使能函数,结果B平台编译不过。后来我改成这样:

// uart_hal.h
typedef struct {
    int (*init)(uart_cfg_t *cfg);
    int (*send)(uint8_t *data, uint32_t len, uint32_t timeout);
    int (*recv)(uint8_t *data, uint32_t len, uint32_t timeout);
    int (*ioctl)(uint32_t cmd, void *arg);  // 流控、波特率、校验等
} uart_ops_t;

把流控、波特率、校验位这些“可选功能”全部塞进 ioctl。不支持流控的平台,直接返回 -ENOTSUP。上层代码先查返回值,再决定是否降级。

功能 ioctl命令 参数类型 不支持时返回
设置波特率 UART_IOCTL_SET_BAUD uint32_t 0(成功)
使能硬件流控 UART_IOCTL_SET_FLOWCTRL uint8_t -ENOTSUP
设置校验模式 UART_IOCTL_SET_PARITY uint8_t 0
清空接收FIFO UART_IOCTL_FLUSH_RX void 0

3.4 I2C与SPI抽象方法

I2C和SPI都是总线型接口,抽象思路类似:主从模式分离。

我习惯把I2C抽象成两层:

  • 总线层:start、stop、写字节、读字节、应答检测。
  • 设备层:基于总线层封装具体器件驱动(如温度传感器、EEPROM)。
// i2c_hal.h
typedef struct {
    int (*master_send)(uint8_t addr, uint8_t *data, uint16_t len);
    int (*master_recv)(uint8_t addr, uint8_t *data, uint16_t len);
    int (*mem_write)(uint8_t addr, uint16_t mem_addr, uint8_t *data, uint16_t len);
    int (*mem_read)(uint8_t addr, uint16_t mem_addr, uint8_t *data, uint16_t len);
} i2c_ops_t;

SPI也类似,但多了片选管理和模式配置(CPOL/CPHA)。我建议把片选操作也抽象进去,因为有些芯片的片选是硬件自动控制,有些需要GPIO手动拉。

注意:I2C的时钟延展(clock stretching)在不同芯片上表现差异很大。抽象层最好提供一个超时参数,避免某些从设备拖死总线。我曾经被一个EEPROM坑过——它延展了5ms,而默认超时只有1ms。

3.5 定时器与PWM抽象方法

定时器是最容易“过度抽象”的外设。你想想看,定时器的用途太多了:系统心跳、延时、输入捕获、输出比较、PWM生成……

我的建议是:按用途分接口,不按硬件分。

// timer_hal.h
// 系统心跳
typedef uint64_t (*timer_get_tick)(void);
typedef void (*timer_delay_ms)(uint32_t ms);

// PWM输出
typedef struct {
    int (*init)(uint32_t freq, uint32_t duty);  // duty: 0-1000
    int (*set_duty)(uint32_t duty);
    int (*start)(void);
    int (*stop)(void);
} pwm_ops_t;

// 输入捕获
typedef struct {
    int (*init)(uint32_t edge);
    uint32_t (*capture)(void);
    int (*set_callback)(void (*cb)(uint32_t value));
} capture_ops_t;

这样,系统心跳用一个定时器,PWM用另一个,互不干扰。移植时只需要改底层实现,上层代码完全不动。

3.6 ADC/DAC抽象方法

ADC/DAC的抽象相对直接。但有个问题——分辨率。有的ADC是10位,有的是12位,还有16位的。上层代码如果直接拿原始值用,移植就崩了。

我习惯统一成归一化值:

// adc_hal.h
typedef struct {
    int (*init)(uint8_t channel);
    float (*read_voltage)(uint8_t channel);  // 返回电压值,单位V
    uint32_t (*read_raw)(uint8_t channel);   // 返回原始值,用于调试
} adc_ops_t;

上层代码只调 read_voltage,底层根据参考电压和分辨率做换算。DAC同理,统一用电压值或百分比输入。

避坑指南:ADC的采样时间在不同芯片上差异很大。抽象层最好提供一个 set_sample_time 接口,或者干脆在初始化时自动适配。我曾经遇到过,某款芯片的ADC采样时间默认太短,高阻抗信号源根本采不准。

3.7 接口抽象的整体架构

说了这么多,咱们用一张图来总结整个抽象体系:

外设接口抽象层架构 应用层(业务逻辑) 统一服务接口(gpio_ops_t / uart_ops_t / i2c_ops_t ...) 硬件抽象层(HAL) GPIO HAL UART HAL I2C/SPI HAL TIM/PWM/ADC 芯片驱动层(寄存器操作 / 库函数) 硬件(GPIO / UART / I2C / SPI / TIM / ADC) 移植 修改 范围 只改 驱动层 和HAL 实现 上层 不动

这张图表达的核心思想是:越往上越稳定,越往下越易变。应用层和统一服务接口是跨平台通用的,硬件抽象层是平台相关的但接口固定,芯片驱动层则是完全平台相关的。

3.8 抽象层的实现策略

最后聊点实战中的策略选择:

  1. 函数指针表 vs 条件编译:函数指针表灵活,适合多实例场景;条件编译简单,适合单实例、资源紧张的场景。我一般混合用——核心接口用函数指针,配置选项用条件编译。
  2. 静态注册 vs 动态注册:静态注册在编译时确定,省内存;动态注册在运行时绑定,灵活。小系统用静态,大系统用动态。
  3. 错误处理统一化:所有接口返回标准错误码(0成功,负值错误),上层统一处理。别一个接口返回NULL,另一个返回-1,再一个返回false。

总结一句话:外设接口抽象不是技术难题,是设计习惯。你每次写驱动时多问自己一句——“如果换颗芯片,这段代码要改几处?”答案越少,抽象越好。