驱动的职责:把时序翻译成寄存器操作

协议手册规定了波形:SCL 高电平时 SDA 从高变低是起始条件,第九个时钟的固定电平是应答位。驱动的工作就是把这些波形要求,转换成“在正确的时刻把某个寄存器位置 1 或清 0”。换句话说,驱动是协议与芯片之间的一层翻译。

驱动的本质是把时序翻译成寄存器操作:协议规定了波形,驱动负责在正确的时刻翻转电平,并把物理世界的结果翻译成上层能理解的返回值。
驱动开发配图:芯片引脚与信号时序方波
驱动的本质是把时序翻译成寄存器操作:协议规定了波形,驱动负责在正确的时刻翻转电平

好的驱动还有第二个特征:上层不关心它是用硬件外设实现还是用两个普通 IO 口模拟的。只要接口稳定、错误语义清晰,实现方式可以随时替换——这也是“裸机驱动”走向“设备模型”的动机。

GPIO 驱动:模式矩阵与封装

GPIO 看似最简单,但模式选择错误会带来难以定位的现象:本该上拉的引脚浮空导致读数随机,本该开漏的输出直接对地短路。先把模式矩阵记清楚。

模式典型用途常见错误
输入浮空外部已有上下拉的信号外部无上下拉时读数随机跳动
输入上拉/下拉按键、片选默认电平与外部上拉方向冲突,形成分压
模拟输入ADC 采样通道误设为数字输入,导致采样值异常
推挽输出LED、驱动能力足够的控制线两个推挽输出对接会短路
开漏输出I2C、共享中断线忘记外部上拉,线路永远拉不高
复用推挽/开漏UART、SPI、PWM 等外设引脚未配置复用就启用外设,引脚无输出
/* 统一的引脚描述与操作封装:把端口、引脚、有效电平收在一起 */
typedef struct {
    void     *port;          /* GPIO_TypeDef * */
    uint16_t  pin;
    uint8_t   active_low;    /* 1 表示低电平有效 */
} gpio_pin_t;

static void pin_write(const gpio_pin_t *p, int level)
{
    HAL_GPIO_WritePin((GPIO_TypeDef *)p->port, p->pin,
                      (p->active_low ? !level : level) ? GPIO_PIN_SET : GPIO_PIN_RESET);
}

static int pin_read(const gpio_pin_t *p)
{
    GPIO_PinState s = HAL_GPIO_ReadPin((GPIO_TypeDef *)p->port, p->pin);
    return p->active_low ? (s == GPIO_PIN_RESET) : (s == GPIO_PIN_SET);
}

/* 用描述表代替散落的初始化代码,增删设备只改表 */
static const gpio_pin_t PIN_LED  = { GPIOC, GPIO_PIN_13, 1 };  /* 低电平点亮 */
static const gpio_pin_t PIN_RELAY= { GPIOB, GPIO_PIN_0,  0 };

把“有效电平”纳入描述,可以避免大量 `if (active_low)` 散落在业务逻辑里。这类小封装几乎不增加代码量,但能让后续接手的同事少踩很多坑。

UART:帧格式、波特率与不定长接收

串口是调试与设备互联的第一选择,配置上的两个要点是:两端帧格式一致(数据位、校验、停止位),以及波特率误差控制在几个百分点以内。

/* 阻塞发送:简单可靠,适合调试输出 */
int uart_send(const uint8_t *buf, uint16_t len)
{
    return HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 100) == HAL_OK ? 0 : -1;
}

/* 不定长接收:空闲中断 + DMA,最省 CPU 的通用做法 */
#define RX_BUF_SIZE 256
static uint8_t rx_buf[RX_BUF_SIZE];
static volatile uint16_t rx_len = 0;

void uart_start_rx(void)
{
    /* 收到一帧后(总线空闲)触发回调,DMA 自动搬运到 rx_buf */
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_BUF_SIZE);
}

/* 回调里只做最轻的事情:记录长度、置标志 */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) {
        rx_len = Size;
        g_rx_ready = 1;                       /* 交给主循环处理 */
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_BUF_SIZE);  /* 重新武装 */
    }
}
串口驱动的三个实践要点
  • 不定长用空闲中断用“接收完成中断”只能处理固定长度,不定长报文必须靠总线空闲判定帧结束。
  • DMA 接收要防止覆盖回调里立即重新武装接收,同时上层处理要快于下一帧到达,否则数据被覆盖。
  • 加软件超时保护线路断开时可能永远等不到空闲中断,需要一个超时机制兜底。

I2C:时序、应答与总线恢复

I2C 只有两根线,却是最容易“挂死”的总线。原因是它依赖开漏与上拉电阻,任何一方异常拉低都会让总线锁死。理解时序是写出健壮驱动的前提。

/* 用 HAL 读写寄存器型传感器的标准封装:地址、寄存器地址、数据长度 */
#define I2C_TIMEOUT_MS  50
#define I2C_RETRY       3

int i2c_read_reg(uint8_t dev, uint8_t reg, uint8_t *buf, uint16_t len)
{
    for (int i = 0; i < I2C_RETRY; i++) {
        if (HAL_I2C_Master_Transmit(&hi2c1, (uint16_t)(dev << 1), &reg, 1,
                                    I2C_TIMEOUT_MS) != HAL_OK) continue;
        if (HAL_I2C_Master_Receive(&hi2c1, (uint16_t)((dev << 1) | 1), buf, len,
                                   I2C_TIMEOUT_MS) == HAL_OK) return 0;
        i2c_bus_recover();      /* 失败后尝试恢复总线再重试 */
    }
    return -1;
}

/* 总线恢复:主机发 9 个时钟脉冲,把被拉低的从机“推”出当前字节,再补一个 STOP */
void i2c_bus_recover(void)
{
    gpio_set_mode_open_drain(SCL_PIN);
    gpio_set_mode_open_drain(SDA_PIN);
    gpio_write(SCL_PIN, 1); gpio_write(SDA_PIN, 1);
    for (int i = 0; i < 9; i++) {
        gpio_write(SCL_PIN, 0); delay_us(5);
        gpio_write(SCL_PIN, 1); delay_us(5);
        if (gpio_read(SDA_PIN)) break;     /* 从机已释放 SDA */
    }
    gpio_write(SDA_PIN, 0); delay_us(5);
    gpio_write(SCL_PIN, 1); delay_us(5);
    gpio_write(SDA_PIN, 1); delay_us(5);   /* 补一个 STOP:SCL 高时 SDA 上升 */
}

这里体现了两条驱动开发的通用经验:任何阻塞式外设操作都必须带超时,否则总线异常会让程序永久卡住;失败要能自愈,重试加总线恢复,比等运维到现场重启有效得多。

从裸机到设备模型:统一接口与注册匹配

当驱动数量变多,靠“在 main 里依次调用初始化函数”会迅速失控。成熟的系统会引入设备模型:驱动注册自己,系统负责匹配与调用。

/* 第一步:定义统一的设备操作接口,上层只依赖它 */
typedef struct sensor_ops {
    int  (*init)(struct sensor_dev *dev);
    int  (*read)(struct sensor_dev *dev, float *value);
    int  (*set_mode)(struct sensor_dev *dev, int mode);
} sensor_ops_t;

typedef struct sensor_dev {
    const char          *name;
    const sensor_ops_t  *ops;
    void                *priv;      /* 驱动私有数据:总线句柄、地址、标定参数 */
    int                  id;
} sensor_dev_t;

static int sensor_read_all(void)
{
    for (int i = 0; i < g_sensor_count; i++) {
        float v;
        if (g_sensors[i].ops->read(&g_sensors[i], &v) == 0) {
            publish_value(g_sensors[i].id, v);
        }
    }
    return 0;
}

/* 第二步:驱动通过注册表接入,业务代码不认识具体型号 */
static const sensor_dev_t g_sensors[] = {
    { "sht30",     &sht30_ops, NULL, 1 },
    { "mpu6050",   &mpu6050_ops, NULL, 2 },
    { "ds18b20",   &ds18b20_ops, NULL, 3 },
};

在 Linux 侧,这套思想被形式化为设备树与 platform_driver 的 compatible 匹配:驱动声明自己能处理哪类硬件,内核负责找到并调用 probe。理解这一点,就能明白“为什么改硬件配置只需要改设备树,而不用改驱动代码”。