驱动的职责:把时序翻译成寄存器操作
协议手册规定了波形: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), ®, 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。理解这一点,就能明白“为什么改硬件配置只需要改设备树,而不用改驱动代码”。
中断与 DMA 在驱动里的位置
驱动做到一定复杂度,瓶颈会从“怎么配置”变成“怎么不占用 CPU”。中断与 DMA 是仅有的两个答案。
/* DMA 双缓冲接收:一帧处理时另一帧继续接收,适合持续数据流(如 ADC 连续采样) */
#define FRAME_SAMPLES 256
static uint16_t dma_buf[2][FRAME_SAMPLES];
void adc_dma_start(void)
{
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)dma_buf, FRAME_SAMPLES * 2); /* 双缓冲模式 */
}
/* 半传输回调:第一块已填满 */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
process_frame(dma_buf[0], FRAME_SAMPLES);
}
/* 传输完成回调:第二块已填满 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
process_frame(dma_buf[1], FRAME_SAMPLES);
}
- 中断里只做“搬运标志与轻量计算”,重活交给主循环或任务,避免拉长中断响应时间。
- 使用 DMA 时必须处理缓存一致性:带数据缓存(D-Cache)的芯片上,启动 DMA 前要 clean,读取前要 invalidate,否则会读到旧数据。
- DMA 传输期间不要复用同一块缓冲区,双缓冲(或队列 + 拷贝)是标准做法。
- 所有 DMA 通道都要有异常兜底:传输错误中断里复位外设并重新启动,否则会出现“永久不接收”的静默故障。
调试驱动的工具箱
驱动调试靠猜是没有出路的,需要让波形自己说话。下面这几件工具按性价比排序。
| 工具 | 能回答的问题 | 使用要点 |
|---|---|---|
| 逻辑分析仪 | 时序对不对、有没有 ACK、波特率准不准 | 采样率至少为时钟频率的 4 倍以上 |
| 示波器 | 上升沿是否过缓、电平是否达标、有无过冲 | 优先看信号完整性,尤其是长线与上拉偏弱时 |
| 寄存器 dump | 外设是否真的被配置成预期值 | 初始化后立即打印关键寄存器 |
| I2C 地址扫描 | 设备是否在线、地址是否写错 | 遍历 0x08~0x77 发送地址,看是否有 ACK |
| 引脚翻转计时 | 某段代码耗时多少 | 在进入/退出处置电平,用示波器测宽度 |
/* I2C 总线扫描:现场排查“设备到底在不在”的第一步 */
void i2c_scan(I2C_HandleTypeDef *hi2c)
{
for (uint8_t addr = 0x08; addr < 0x78; addr++) {
if (HAL_I2C_IsDeviceReady(hi2c, (uint16_t)(addr << 1), 2, 5) == HAL_OK) {
log_printf("found device at 0x%02X\n", addr);
}
}
}
写驱动的顺序应该是:先看手册把时序画出来,再用逻辑分析仪验证引脚行为,最后才写代码。反过来先写代码再猜问题,往往会在“明明配置都对了”的自我怀疑里消耗掉大量时间。
把这套方法用在 GPIO、UART、I2C 上跑通一遍,你会发现后续接 SPI、CAN、USB 时,需要新学的只是协议细节,而不是整套调试思路。