3、设备驱动概述:驱动的作用、分类(字符/块/网络)、与内核和硬件的关系

设备驱动是操作系统与硬件设备之间的“翻译官”和“调度员”。在嵌入式项目中,无论是点亮一颗LED、读取传感器数据,还是通过网卡收发报文,应用程序都无法直接操作物理寄存器或总线时序,必须借助驱动来完成。驱动的作用可以概括为三点:屏蔽硬件差异(为上层提供统一接口)、管理硬件资源(中断、DMA、寄存器、电源)、保障数据通路(在用户态与内核态之间安全高效地搬运数据)。理解驱动的分类及其与内核、硬件的关系,是开发可靠嵌入式系统的第一道门槛。

驱动在嵌入式系统中的位置与分类 用户态 (User Space) 应用程序 (App) / 库 (Libc) / 系统调用接口 (open, read, write, ioctl, mmap) 内核态 (Kernel Space) VFS (虚拟文件系统) 字符设备驱动 串口 / GPIO / I2C / SPI 按字节流访问 块设备驱动 eMMC / SD / NAND / SSD 按块读写,支持缓存 网络设备驱动 以太网 / WiFi / CAN 以数据包为单位收发 内核核心服务:进程调度 / 内存管理 / 中断管理 / 时间管理 / 并发与同步 硬件抽象层 (HAL) / 板级支持包 (BSP) 寄存器映射 / 时钟使能 / 引脚复用 / 中断控制器 / DMA 控制器 物理硬件 (Hardware) SoC / MCU / 外设控制器 / 传感器 / 存储芯片 / 网络PHY 系统调用接口 file_operations block_device_operations net_device_ops ioremap / readl / writel

上图展示了驱动在嵌入式系统中的核心位置。从纵向看,用户态应用程序通过系统调用进入内核,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总线为例:

  1. 总线层:I2C core 维护两条链表——设备链表和驱动链表。每当有新的I2C设备或驱动注册,总线都会执行匹配操作。
  2. 设备节点:设备树(Device Tree)中描述“哪个I2C控制器上挂了哪个从设备,地址是多少”。内核启动时解析设备树,生成 struct i2c_client 并挂到总线上。
  3. 驱动节点:驱动编写者实现 struct i2c_driver,其中包含 id_table(支持的设备兼容性列表)和 probe 函数。
  4. 匹配与probe:当总线上设备的 compatible 属性与驱动的 id_table 匹配时,内核调用驱动的 probe 函数。此时驱动获得设备实例,可以申请资源、注册字符设备、创建sysfs节点等。

硬件层面,驱动通过以下方式与物理设备交互:

  • 寄存器访问:使用 ioremap() 将物理地址映射到内核虚拟地址空间,然后通过 readl()/writel() 读写。注意不能直接解引用物理地址。
  • 中断:通过 request_irq() 注册中断处理函数,并指定触发方式(上升沿、电平等)。中断处理函数运行在中断上下文,不能睡眠。
  • DMA:使用 dma_alloc_coherent() 分配一致性DMA缓冲区,或使用 dma_map_single() 进行流式映射。需要处理Cache一致性。
  • 时钟与电源:通过 clk_enable()/regulator_enable() 管理外设时钟和供电,避免漏电或时序错误。
常见误区:初学者容易混淆“驱动”和“BSP”。BSP(板级支持包)包含启动代码、时钟初始化、引脚复用配置等,通常在系统启动早期执行;而驱动是内核模块,依赖BSP已经初始化好的基础环境。另外,不要试图在驱动中直接调用 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框架)、硬件(这里用变量模拟)的关系。

经验提醒:在真实项目中,驱动开发前务必阅读芯片参考手册的“外设寄存器描述”章节,确认时钟门控、引脚复用、中断号等关键信息。同时,利用设备树(Device Tree)描述硬件拓扑,避免在驱动中硬编码地址。调试时,优先使用 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。