3、设备驱动概述:驱动的作用、分类(字符/块/网络)、与内核和硬件的关系
设备驱动是操作系统与硬件设备之间的“翻译官”和“调度员”。在嵌入式项目中,无论是点亮一颗LED、读取传感器数据,还是通过网卡收发报文,应用程序都无法直接操作物理寄存器或总线时序,必须借助驱动来完成。驱动的作用可以概括为三点:屏蔽硬件差异(为上层提供统一接口)、管理硬件资源(中断、DMA、寄存器、电源)、保障数据通路(在用户态与内核态之间安全高效地搬运数据)。理解驱动的分类及其与内核、硬件的关系,是开发可靠嵌入式系统的第一道门槛。
上图展示了驱动在嵌入式系统中的核心位置。从纵向看,用户态应用程序通过系统调用进入内核,VFS 根据设备类型将请求分发到对应的驱动框架;驱动再通过硬件抽象层操作物理设备。从横向看,驱动分为字符、块、网络三大类,它们各自遵循不同的内核框架和接口约定。下面我们逐一深入剖析。
3.1 驱动的作用:为什么不能绕过驱动直接操作硬件?
在嵌入式开发初期,很多工程师习惯直接操作寄存器——比如通过 *(volatile unsigned int *)0x40021000 |= (1<<3); 来点亮LED。这种“裸奔”方式在裸机程序或极简RTOS中可行,但在Linux等现代操作系统中却行不通,原因有三:
- 安全与稳定性:用户态程序直接访问物理地址会破坏内核内存隔离机制,可能导致系统崩溃或被恶意利用。驱动运行在内核态,拥有硬件访问权限,但必须通过内核提供的API申请资源,避免冲突。
- 资源统一管理:多个进程可能同时访问同一设备。驱动负责实现互斥、同步、引用计数,确保每次只有一个进程独占设备(如串口),或允许多进程共享(如网卡)。
- 可移植性与抽象:不同厂商的芯片寄存器布局不同。驱动将硬件差异封装在内部,向上提供统一的
read()/write()/ioctl()接口,应用程序无需关心底层是STM32还是i.MX。
驱动还承担着中断处理、DMA传输管理、电源管理(如运行时挂起/恢复)、错误恢复等职责。以中断为例:当UART收到数据,硬件触发中断,驱动的中断处理函数被调用,它需要快速读取数据并放入缓冲区,然后唤醒等待数据的进程。这个过程涉及“上半部(快速处理)”和“下半部(延迟处理)”的拆分,是驱动开发的核心难点之一。
file_operations 结构体向VFS注册操作函数,通过 platform_driver 或 i2c_driver 等总线驱动模型与硬件设备绑定。驱动开发的核心工作就是实现这些操作函数,并正确处理并发、中断和电源状态。
3.2 驱动分类:字符、块、网络
Linux内核根据设备的数据访问特征,将驱动分为三大类。理解分类是选择驱动框架的第一步。
| 类型 | 数据访问方式 | 典型设备 | 内核接口 | 设备节点 | 关键特征 |
|---|---|---|---|---|---|
| 字符设备 | 按字节流顺序访问,类似文件读写 | 串口、GPIO、I2C、SPI、触摸屏、ADC | file_operations |
/dev/ttyS0, /dev/gpiochip0 |
支持 open/read/write/ioctl;数据量小;实时性要求高 |
| 块设备 | 按固定大小块(如512B/4KB)随机读写 | eMMC、SD卡、NAND Flash、SATA硬盘 | block_device_operations |
/dev/mmcblk0, /dev/sda1 |
支持缓存、读写合并、调度器;数据量大;通过页缓存访问 |
| 网络设备 | 以数据包(frame/packet)为单位收发 | 以太网MAC+PHY、WiFi、CAN、USB网卡 | net_device_ops |
无设备节点,使用 ifconfig/socket |
异步收包;支持协议栈;中断+NAPI;DMA环形队列 |
需要特别说明的是,字符设备并不等于“慢速设备”。例如,PCIe NVMe SSD在Linux中虽然也注册为块设备,但其内部命令提交机制更接近字符设备。分类的核心依据是数据访问模式,而非物理传输速度。另外,有些设备会同时注册为多种类型,例如USB摄像头既有V4L2字符设备接口,也有通过USB core管理的网络接口(UVC)。
3.3 与内核和硬件的关系:驱动如何“挂接”到系统
驱动不是孤立存在的,它必须融入内核的设备模型。现代Linux内核使用总线-设备-驱动(Bus-Device-Driver)模型来管理硬件。以I2C总线为例:
- 总线层:I2C core 维护两条链表——设备链表和驱动链表。每当有新的I2C设备或驱动注册,总线都会执行匹配操作。
- 设备节点:设备树(Device Tree)中描述“哪个I2C控制器上挂了哪个从设备,地址是多少”。内核启动时解析设备树,生成
struct i2c_client并挂到总线上。 - 驱动节点:驱动编写者实现
struct i2c_driver,其中包含id_table(支持的设备兼容性列表)和probe函数。 - 匹配与probe:当总线上设备的
compatible属性与驱动的id_table匹配时,内核调用驱动的probe函数。此时驱动获得设备实例,可以申请资源、注册字符设备、创建sysfs节点等。
硬件层面,驱动通过以下方式与物理设备交互:
- 寄存器访问:使用
ioremap()将物理地址映射到内核虚拟地址空间,然后通过readl()/writel()读写。注意不能直接解引用物理地址。 - 中断:通过
request_irq()注册中断处理函数,并指定触发方式(上升沿、电平等)。中断处理函数运行在中断上下文,不能睡眠。 - DMA:使用
dma_alloc_coherent()分配一致性DMA缓冲区,或使用dma_map_single()进行流式映射。需要处理Cache一致性。 - 时钟与电源:通过
clk_enable()/regulator_enable()管理外设时钟和供电,避免漏电或时序错误。
printf(),应使用 printk();不要用 delay() 忙等,应使用 msleep() 或 usleep_range()。
3.4 实战场景:从零编写一个字符设备驱动的骨架
为了将上述概念落地,我们以一个虚拟的“门控设备”为例,演示字符设备驱动的核心骨架。该设备模拟一个带锁的门,支持 open(开门)、ioctl(设置锁状态)、release(关门)。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <linux/uaccess.h>
#define CMD_LOCK _IO('G', 1)
#define CMD_UNLOCK _IO('G', 2)
static int door_open = 0; // 0: locked, 1: unlocked
static int door_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
switch (cmd) {
case CMD_LOCK:
door_open = 0;
pr_info("Door locked\n");
break;
case CMD_UNLOCK:
door_open = 1;
pr_info("Door unlocked\n");
break;
default:
return -EINVAL;
}
return 0;
}
static const struct file_operations door_fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = door_ioctl,
};
static struct miscdevice door_dev = {
.minor = MISC_DYNAMIC_MINOR,
.name = "door",
.fops = &door_fops,
};
static int __init door_init(void)
{
return misc_register(&door_dev);
}
static void __exit door_exit(void)
{
misc_deregister(&door_dev);
}
module_init(door_init);
module_exit(door_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Virtual door driver");
这个骨架展示了几个关键点:
- 使用
miscdevice框架简化字符设备注册,避免手动分配主设备号。 file_operations结构体是驱动与VFS的契约,这里只实现了ioctl。- 通过
_IO宏定义命令码,保证用户态与内核态命令一致。 - 实际项目中,
door_open应受并发保护(如mutex),且ioctl需要校验用户传入的参数指针。
编译后,使用 insmod door.ko 加载模块,系统会创建 /dev/door 节点。用户态程序通过 open("/dev/door", O_RDWR) 和 ioctl(fd, CMD_UNLOCK) 控制设备。这个例子虽然简单,但完整展示了驱动与内核(VFS、misc框架)、硬件(这里用变量模拟)的关系。
dev_info()/dev_err() 而不是 printk(),因为前者会打印设备名,便于过滤日志。
3.5 常见误区与复盘
在驱动开发的教学和实践中,以下误区反复出现,值得警惕:
| 误区 | 后果 | 正确做法 |
|---|---|---|
在中断处理函数中调用 msleep() 或 kmalloc(GFP_KERNEL) |
内核崩溃或死锁 | 中断上下文只能使用 GFP_ATOMIC,且不能睡眠;需要耗时操作时使用工作队列或tasklet |
| 直接使用物理地址访问寄存器 | MMU报错,内核oops | 必须 ioremap() 后访问虚拟地址 |
| 忽略并发控制 | 数据竞争,设备状态异常 | 使用 mutex/spinlock 保护共享数据;考虑中断与进程的并发 |
| 将块设备驱动写成字符设备 | 无法利用页缓存,性能极差 | 块设备必须实现 request 函数或使用 blk-mq 框架 |
| 网络驱动不使用NAPI | 高负载下丢包,CPU占用100% | 使用NAPI配合DMA环形队列,减少中断次数 |
复盘一个典型的调试案例:某工程师在I2C触摸屏驱动中,于 probe 函数里直接调用 i2c_transfer() 读取设备ID,但此时I2C控制器时钟尚未使能,导致总线超时。正确做法是在 probe 中先通过 clk_prepare_enable() 开启时钟,或者使用 devm_clk_get() 获取时钟并检查返回值。这个案例说明,驱动开发不仅要关注软件逻辑,还要理解硬件上电时序和时钟依赖。
总结本章:驱动是内核与硬件之间的桥梁,字符、块、网络三类驱动各有其设计范式。掌握驱动的作用、分类和与内核/硬件的关系,是后续学习具体总线驱动(I2C、SPI、PCIe)和复杂外设(Camera、GPU)的基础。在下一阶段,我们将深入字符设备驱动的完整实现,包括设备号管理、并发控制和阻塞IO。