3. 驱动开发环境搭建
说实话,很多做嵌入式开发的朋友,一开始接触的都是“面向过程”的写法。一个LED驱动,一个UART驱动,都是各自为战。代码写多了,你会发现一个问题:换个芯片,或者加个同类型的新设备,代码得重写一大半。
我早年做项目时也踩过这个坑。一个项目里挂了三个不同厂家的温度传感器,每个传感器的初始化、读取、配置流程都不一样。我那时候图省事,写了三个独立的驱动文件,每个文件里一堆全局变量和函数。后来产品要升级,加第四个传感器……嗯,那感觉,就像在乱麻里找线头。
为什么会这样?因为我们缺少一个统一的“设备对象模型”。说白了,就是没把设备当成一个“对象”来管理。这一章,我们就来聊聊怎么用C语言实现面向对象的思想,把设备抽象成一个结构体,再用函数指针表把操作统一起来。
3.1 为什么C语言也需要“对象”
你可能会问:“C语言又不是C++,搞什么对象?”
其实,面向对象是一种思想,不是某种语言的专利。C语言虽然没有class关键字,但结构体(struct)和函数指针(function pointer)组合起来,完全可以模拟出“封装”、“继承”和“多态”的效果。
我在项目中总结了一个经验:驱动框架的核心,就是把“设备”这个实体,抽象成一个可操作的数据结构。这个数据结构里,既要有设备的属性(比如寄存器基地址、中断号、状态标志),也要有设备的行为(比如初始化、读、写、控制)。
核心思想: 设备 = 属性 + 行为。属性用结构体成员表示,行为用函数指针表示。
这样做的好处很明显:
- 代码复用: 同类型设备,只需要换一套操作函数,结构体定义可以共用。
- 易于扩展: 新设备加入,只需要实现对应的操作函数,然后注册到框架里。
- 分层清晰: 上层应用不需要关心底层硬件细节,它只跟“设备对象”打交道。
3.2 设备结构体设计:从零开始搭积木
我们先来看一个最基础的设备结构体应该长什么样。我个人习惯,把设备结构体分成三个区域:标识区、资源区、操作区。
| 区域 | 包含内容 | 作用 |
|---|---|---|
| 标识区 | 设备名称、设备ID、设备类型 | 用于区分不同的设备实例 |
| 资源区 | 寄存器基地址、中断号、GPIO引脚、时钟源 | 描述设备占用的硬件资源 |
| 操作区 | 函数指针表(ops) | 定义设备支持的操作接口 |
下面是一个典型的UART设备结构体定义。你可以把它当成一个模板,以后写其他设备驱动时,照着这个思路来就行。
/* uart_device.h */
#ifndef __UART_DEVICE_H__
#define __UART_DEVICE_H__
#include <stdint.h>
/* 前向声明 */
struct uart_device;
/* 操作函数指针表 */
struct uart_ops {
int32_t (*init)(struct uart_device *dev);
int32_t (*send)(struct uart_device *dev, const uint8_t *data, uint32_t len);
int32_t (*recv)(struct uart_device *dev, uint8_t *buffer, uint32_t len);
int32_t (*ioctl)(struct uart_device *dev, uint32_t cmd, void *arg);
};
/* UART设备结构体 */
struct uart_device {
/* 标识区 */
char name[16]; /* 设备名称,如 "uart1" */
uint32_t dev_id; /* 设备ID,用于索引 */
/* 资源区 */
uint32_t base_addr; /* 寄存器基地址 */
uint32_t irq_num; /* 中断号 */
uint32_t baud_rate; /* 波特率 */
/* 操作区 */
const struct uart_ops *ops; /* 指向操作函数指针表 */
/* 私有数据(可选) */
void *private_data; /* 指向驱动内部状态 */
};
#endif /* __UART_DEVICE_H__ */
注意看,我把操作函数指针表定义成了另一个结构体 struct uart_ops,然后在设备结构体里只放了一个指向它的指针。这样做的好处是:同一个设备的不同实例,可以共享同一套操作函数,节省内存。而且,如果以后要扩展操作接口,只需要修改 uart_ops 结构体,设备结构体本身不用动。
我的小技巧: 操作函数指针表通常定义为 const 类型,放在只读数据段(如Flash),这样既能防止意外修改,又能节省RAM。我在一个资源紧张的MCU项目里,靠这个技巧省出了几百字节的RAM。
3.3 操作函数指针表:让设备“动”起来
结构体定义好了,接下来就是怎么用。操作函数指针表,说白了就是一组函数指针的集合。每个指针指向一个具体的驱动函数。
我们来看一个具体的实现例子。假设我们有一个基于STM32的UART驱动,它的操作函数表可以这样定义:
/* uart_stm32.c */
#include "uart_device.h"
/* 具体的驱动函数实现 */
static int32_t stm32_uart_init(struct uart_device *dev) {
/* 这里写STM32 UART的初始化代码 */
/* 比如:使能时钟、配置GPIO、设置波特率、使能中断 */
printf("STM32 UART %s init OK, base addr: 0x%08X\n", dev->name, dev->base_addr);
return 0;
}
static int32_t stm32_uart_send(struct uart_device *dev, const uint8_t *data, uint32_t len) {
/* 发送数据,轮询或DMA方式 */
for (uint32_t i = 0; i < len; i++) {
/* 等待发送寄存器空 */
while (!(*(volatile uint32_t *)(dev->base_addr + 0x00) & (1 << 7)));
/* 写入数据寄存器 */
*(volatile uint32_t *)(dev->base_addr + 0x04) = data[i];
}
return len;
}
static int32_t stm32_uart_recv(struct uart_device *dev, uint8_t *buffer, uint32_t len) {
/* 接收数据,这里简化处理 */
for (uint32_t i = 0; i < len; i++) {
/* 等待接收数据有效 */
while (!(*(volatile uint32_t *)(dev->base_addr + 0x00) & (1 << 5)));
buffer[i] = *(volatile uint32_t *)(dev->base_addr + 0x04);
}
return len;
}
static int32_t stm32_uart_ioctl(struct uart_device *dev, uint32_t cmd, void *arg) {
/* 控制命令,比如修改波特率、设置校验位等 */
switch (cmd) {
case 0x01: /* 设置波特率 */
dev->baud_rate = *(uint32_t *)arg;
/* 重新配置硬件 */
break;
default:
break;
}
return 0;
}
/* 定义操作函数指针表,注意是const */
const struct uart_ops stm32_uart_ops = {
.init = stm32_uart_init,
.send = stm32_uart_send,
.recv = stm32_uart_recv,
.ioctl = stm32_uart_ioctl,
};
你看,操作函数表 stm32_uart_ops 是一个全局常量,里面填好了具体的函数地址。接下来,我们只需要把设备结构体和这个操作表关联起来:
/* 定义一个UART设备实例 */
struct uart_device uart1 = {
.name = "uart1",
.dev_id = 0,
.base_addr = 0x40011000, /* STM32 USART1基地址 */
.irq_num = 37,
.baud_rate = 115200,
.ops = &stm32_uart_ops, /* 关联操作表 */
.private_data = NULL,
};
/* 使用设备 */
void app_main(void) {
uint8_t tx_buf[] = "Hello, Device Model!\r\n";
/* 通过操作表调用初始化 */
uart1.ops->init(&uart1);
/* 通过操作表发送数据 */
uart1.ops->send(&uart1, tx_buf, sizeof(tx_buf) - 1);
}
看到没?上层应用调用时,根本不知道底层是STM32还是GD32,它只认 ops 这个接口。这就是多态的雏形。
3.4 设备对象模型的核心逻辑图
为了让你更直观地理解整个模型,我画了一张SVG图。这张图展示了设备对象、操作函数表、具体驱动实现三者之间的关系。
3.5 避坑指南:我踩过的几个坑
模型看起来简单,但实际用起来,有几个地方特别容易翻车。我把自己踩过的坑分享给你,希望能帮你省点调试时间。
坑一:函数指针表没有初始化
我曾经在一个项目里,定义了一个设备结构体,但忘了给 ops 指针赋值。结果一调用 dev->ops->init(),直接跑飞到HardFault。排查了半天,才发现是空指针。所以,建议在设备结构体初始化时,显式检查 ops 是否为 NULL。
坑二:操作函数表被放在了RAM里
有些朋友习惯把所有变量都定义在RAM里,包括操作函数表。但操作函数表一旦被意外修改(比如野指针写坏了),整个驱动就崩了。我建议把操作函数表定义为 const 并放在Flash里,这样既安全又省RAM。
坑三:多实例共享操作表时的数据竞争
如果两个设备实例共享同一个操作函数表,但操作函数里用到了全局变量或静态变量,就会出问题。比如两个UART实例都用同一个发送函数,但发送函数里有一个静态的“当前发送长度”变量,那这两个实例就会互相干扰。解决办法是:把需要保存的状态放到设备结构体的 private_data 里,每个实例一份。
3.6 小结:这个模型到底好在哪?
好了,这一章的内容就这些。我们来捋一捋:
- 我们用
struct实现了设备的“封装”,把属性和行为打包在一起。 - 我们用
const struct uart_ops实现了“多态”,不同的硬件实现只需要换一套操作函数表。 - 我们通过
private_data指针,让每个设备实例可以拥有自己的私有数据,实现了“实例化”。
你想想看,有了这个模型,以后你写一个新的UART驱动,只需要做三件事:
- 实现具体的操作函数(init、send、recv、ioctl)。
- 定义一个
const的操作函数表。 - 定义一个设备结构体实例,把资源信息和操作表填进去。