Linux系统架构基础(五):设备驱动与I/O子系统——字符设备、块设备与Linux设备模型全解
目录
博主智算菩萨,专注于人工智能、Python编程、音视频处理及UI窗体程序设计等方向。致力于以通俗易懂的方式拆解前沿技术,从零基础入门到高阶实战,陪伴开发者共同成长。目前已开设五大技术专栏,累计发布多篇原创技术文章,深受读者好评。
📌 专栏导航
- 人工智能前沿知识(已更144篇):深度剖析Transformer架构、生成式AI、强化学习、具身智能、神经符号系统、大模型及智能体(Agent)技术,系统性解析AI核心技术体系与前沿趋势。
- Python基础小白编程(已更232篇):从零开始,以保姆式教程讲解变量、数据类型、流程控制、函数等核心语法,配有大量实战代码与避坑指南,真正做到学以致用。
- 机器学习与深度学习(125篇):系统化拆解线性模型、决策树、随机森林、梯度提升树、神经网络等算法原理与工程实践,覆盖从公式推导到代码实现的全链路内容。
- 音频、图像与视频处理理论与实战(81篇):涵盖FFmpeg多媒体处理、audio_shop开源工具、ComfyUI-WanVideoWrapper视频生成等实用技术,从基础操作到高级应用一应俱全。
- UI窗体程序设计实战(78篇):深入讲解UI设计、动态窗体生成、游戏UI框架设计等实战技巧,提供从配置到编码的完整解决方案。
智算菩萨,以代码为经,以算法为纬,在人工智能的星辰大海中,做你前行路上最可靠的导航者。本人最常用的AI对话工具是AIGCBAR。
设备驱动是操作系统内核与硬件设备之间的桥梁,它将内核的通用I/O请求转换为特定硬件设备能理解的操作命令。Linux内核的设备驱动框架经历了从早期简单字符/块设备接口到现代统一设备模型的演进,形成了一套层次分明、扩展性强的架构体系。I/O子系统则负责管理所有输入输出操作的调度和执行,是连接设备驱动和VFS的中间层。本讲将深入剖析Linux设备驱动的分类与架构、字符设备和块设备的实现机制、Linux设备模型的核心数据结构、以及I/O子系统的调度策略,帮助读者建立对Linux设备驱动和I/O子系统的系统性认知。
1 设备驱动概述与分类
在计算机系统中,硬件设备的种类繁多、特性各异——从简单的LED指示灯到复杂的高速网络适配器,从低速的I2C传感器到高速的NVMe固态硬盘,每种设备都有其独特的控制方式和数据传输模式。设备驱动的核心任务就是屏蔽这些硬件差异,为内核和用户空间程序提供统一的设备访问接口。
Linux将设备分为三大类:字符设备(Character Device)、块设备(Block Device)和网络设备(Network Device)。这种分类基于设备的数据传输方式和访问模式,不同类型的设备使用不同的驱动框架和注册接口。
| 设备类型 | 数据传输方式 | 访问模式 | 典型设备 | 设备节点 |
|---|---|---|---|---|
| 字符设备 | 字节流 | 顺序访问 | 串口、键盘、鼠标、LED | /dev/ttyS0, /dev/input/event0 |
| 块设备 | 固定大小块 | 随机访问 | 硬盘、SSD、U盘 | /dev/sda, /dev/nvme0n1 |
| 网络设备 | 数据包 | 包传输 | 网卡、回环设备 | 无设备节点 |
字符设备是最简单的设备类型,它以字节流的方式传输数据,不支持随机寻址。字符设备的访问类似于文件的读写——应用程序通过open()打开设备节点,通过read()和write()进行数据传输,通过ioctl()发送控制命令。字符设备的典型例子包括串口终端(/dev/ttyS0)、控制台(/dev/console)、音频设备(/dev/dsp)、鼠标(/dev/input/mouse0)等。字符设备驱动需要实现file_operations结构体中的操作函数,如read()、write()、open()、release()、ioctl()等。
块设备以固定大小的块(通常为512字节或4KB)为单位传输数据,支持随机访问——应用程序可以读写设备上任意位置的数据块。块设备的访问通过文件系统间接进行——应用程序通过文件系统的系统调用(如read()、write())访问文件,文件系统将文件操作转换为块设备的I/O请求。块设备也可以通过设备节点直接访问(如dd命令),但这种访问方式绕过了文件系统,需要用户自行管理数据的组织。块设备驱动需要实现block_device_operations结构体中的操作函数,以及处理来自I/O子系统的请求。
网络设备与其他两类设备有本质区别——网络设备没有设备节点,不通过文件操作接口访问,而是通过套接字(Socket)接口进行数据包的收发。网络设备驱动需要实现net_device_ops结构体中的操作函数,如ndo_open()、ndo_start_xmit()、ndo_set_rx_mode()等。网络设备的数据传输以数据包(sk_buff)为单位,驱动负责将数据包发送到物理网络和从物理网络接收数据包。
除了上述三大类设备,Linux还支持一些特殊设备类型,如:
| 特殊设备 | 描述 | 设备节点 |
|---|---|---|
| /dev/null | 空设备,读取返回EOF,写入丢弃数据 | /dev/null |
| /dev/zero | 零设备,读取返回无限零字节 | /dev/zero |
| /dev/random | 随机数设备,阻塞式读取 | /dev/random |
| /dev/urandom | 随机数设备,非阻塞式读取 | /dev/urandom |
| /dev/full | 满设备,读取返回零字节,写入返回ENOSPC | /dev/full |
| /dev/mem | 物理内存映像 | /dev/mem |
| /dev/kmem | 内核虚拟内存映像 | /dev/kmem |
设备节点(Device Node)是用户空间程序访问设备的入口点,它是一个特殊的文件,位于/dev目录下。设备节点通过主设备号(Major Number)和次设备号(Minor Number)来标识对应的设备驱动和设备实例。主设备号标识设备驱动程序——同一类型的设备共享同一个主设备号和同一个驱动程序。次设备号标识同一驱动程序管理的不同设备实例——例如,系统中有多个串口设备,它们共享同一个串口驱动(主设备号相同),但通过不同的次设备号区分具体的串口端口。
Linux内核维护了一个全局的字符设备哈希表(chrdevs)和块设备哈希表(bdev),以主设备号为键,记录了每个主设备号对应的驱动程序信息。当用户空间程序打开设备节点时,内核根据设备节点的类型(字符或块)和主设备号,在对应的哈希表中查找驱动程序,然后调用驱动程序的open()函数。
设备号的分配有静态分配和动态分配两种方式。静态分配由驱动开发者在代码中指定主设备号,需要在Documentation/devices.txt中注册以避免冲突。动态分配由内核在驱动注册时自动分配一个未使用的主设备号,避免了冲突问题,但用户空间程序需要通过sysfs或udev机制获取动态分配的设备号。
2 字符设备驱动框架
字符设备是Linux中最常见的设备类型,其驱动框架相对简单,是学习Linux设备驱动开发的最佳起点。字符设备驱动的核心是实现file_operations结构体中的操作函数,然后通过注册接口将驱动与设备号关联。
字符设备驱动的开发流程通常包括以下步骤:
第一步,分配和初始化字符设备结构体。Linux内核使用cdev结构体来表示一个字符设备,cdev包含了设备号、file_operations指针和所属模块等信息。cdev的分配通过cdev_alloc()函数完成,初始化通过cdev_init()函数完成。
cdev结构体的关键字段包括:
| 字段 | 类型 | 含义 |
|---|---|---|
| owner | module * | 所属内核模块 |
| ops | file_operations * | 文件操作函数表 |
| list | list_head | 同一主设备号的cdev链表 |
| dev | dev_t | 设备号(主+次) |
| count | unsigned int | 连续次设备号数量 |
第二步,注册设备号。设备号的注册通过register_chrdev_region()或alloc_chrdev_region()函数完成。register_chrdev_region()用于静态分配设备号,需要指定主设备号和次设备号范围;alloc_chrdev_region()用于动态分配设备号,内核自动选择一个未使用的主设备号。
设备号(dev_t)是一个32位无符号整数,其中高12位为主设备号,低20位为次设备号。内核提供了MAJOR(dev)、MINOR(dev)和MKDEV(ma, mi)宏来操作设备号。
第三步,将cdev添加到系统。通过cdev_add()函数将初始化好的cdev结构体添加到内核的字符设备哈希表中,使其可以被用户空间程序访问。cdev_add()的原型为:
int cdev_add(struct cdev *p, dev_t dev, unsigned count) \text{int cdev\_add(struct cdev *p, dev\_t dev, unsigned count)} int cdev_add(struct cdev *p, dev_t dev, unsigned count)
其中p是cdev结构体指针,dev是起始设备号,count是连续的次设备号数量。
第四步,创建设备节点。在早期的Linux系统中,设备节点通过mknod命令手动创建。现代Linux系统使用udev(或devtmpfs)自动创建设备节点——当设备注册时,内核发送uevent事件,udev守护进程根据规则文件自动创建设备节点。
第五步,实现文件操作函数。字符设备驱动需要实现file_operations结构体中的操作函数,最常用的包括:
| 操作函数 | 调用时机 | 功能描述 |
|---|---|---|
| open() | 用户调用open() | 初始化设备,分配资源 |
| release() | 用户调用close() | 释放资源,关闭设备 |
| read() | 用户调用read() | 从设备读取数据 |
| write() | 用户调用write() | 向设备写入数据 |
| ioctl() | 用户调用ioctl() | 设备控制命令 |
| mmap() | 用户调用mmap() | 将设备内存映射到用户空间 |
| poll() | 用户调用poll()/select() | 查询设备是否可读/可写 |
| llseek() | 用户调用lseek() | 修改文件读写位置 |
read()和write()操作是字符设备驱动最核心的功能。read()函数的原型为:
ssize_t read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) \text{ssize\_t read(struct file *filp, char \_\_user *buf, size\_t count, loff\_t *f\_pos)} ssize_t read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
其中filp是文件对象指针,buf是用户空间缓冲区指针,count是请求读取的字节数,f_pos是文件偏移量指针。read()函数需要将设备数据复制到用户空间缓冲区,使用copy_to_user()函数确保安全的内存访问。write()函数类似,使用copy_from_user()将用户空间数据复制到内核空间。
ioctl()操作是字符设备驱动中最灵活的接口,用于实现设备特定的控制命令。ioctl()的原型为:
long ioctl(struct file *filp, unsigned int cmd, unsigned long arg) \text{long ioctl(struct file *filp, unsigned int cmd, unsigned long arg)} long ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
其中cmd是命令码,arg是命令参数。命令码的编码遵循一定的规则,使用_IOR、_IOW、_IOWR和_IO宏来定义,这些宏将方向(读/写)、类型、序号和参数大小编码到一个32位整数中,避免不同设备驱动之间的命令冲突。
poll()操作用于实现非阻塞I/O和多路复用。当用户空间程序调用poll()或select()时,内核调用驱动的poll()函数,驱动检查设备是否可读或可写,并将当前进程添加到设备的等待队列中。当设备状态变化时(如数据到达),驱动唤醒等待队列中的进程。
字符设备驱动的一个典型例子是LED驱动。LED设备通过GPIO(通用输入输出)引脚控制,驱动需要实现打开LED、关闭LED和查询LED状态的功能。LED驱动的file_operations可能只包含open()、release()、write()和ioctl()四个操作——write()控制LED的亮灭,ioctl()设置LED的闪烁模式。
3 块设备驱动与I/O请求处理
块设备驱动比字符设备驱动复杂得多,因为块设备需要处理随机访问、请求排队、I/O调度等复杂问题。块设备驱动不直接处理用户空间的read()/write()请求,而是接收来自I/O子系统的块I/O请求(bio),将请求转换为具体的硬件操作。
块设备驱动的核心数据结构包括:
gendisk结构体:表示一个通用磁盘(Generic Disk),是块设备在内核中的表示。gendisk包含了磁盘的名称、主设备号、块设备操作函数表、请求队列等信息。
| 字段 | 类型 | 含义 |
|---|---|---|
| major | int | 主设备号 |
| first_minor | int | 起始次设备号 |
| minors | int | 次设备号数量(分区数+1) |
| disk_name | char * | 磁盘名称(如"sda") |
| fops | block_device_operations * | 块设备操作函数表 |
| queue | request_queue * | I/O请求队列 |
| capacity | sector_t | 磁盘容量(扇区数) |
| part_tbl | struct blk_part_tbl * | 分区表 |
block_device_operations结构体:定义了块设备的操作函数,类似于字符设备的file_operations,但操作集合不同。
| 操作函数 | 功能描述 |
|---|---|
| open() | 打开块设备 |
| release() | 关闭块设备 |
| ioctl() | 块设备控制命令 |
| compat_ioctl() | 32位兼容ioctl |
| direct_access() | 直接访问块设备内存(DAX) |
| check_events() | 检查媒体变化事件 |
| revalidate_disk() | 重新验证磁盘 |
| getgeo() | 获取磁盘几何信息 |
bio结构体:块I/O(Block I/O)是内核中块设备I/O请求的表示。bio结构体描述了一次块设备I/O操作的完整信息,包括目标设备、起始扇区、数据缓冲区、I/O方向等。
| 字段 | 类型 | 含义 |
|---|---|---|
| bi_bdev | block_device * | 目标块设备 |
| bi_iter | bvec_iter | 迭代器(扇区位置、字节计数) |
| bi_end_io | bio_end_io_t * | I/O完成回调 |
| bi_private | void * | 私有数据 |
| bi_status | blk_status_t | I/O完成状态 |
| bi_opf | unsigned int | 操作标志(READ/WRITE/FLUSH等) |
| bi_vcnt | unsigned short | bio_vec数量 |
| bi_io_vec | bio_vec * | bio_vec数组 |
bio_vec结构体:描述bio中的一个数据段,即一个连续的内存区域。
| 字段 | 类型 | 含义 |
|---|---|---|
| bv_page | struct page * | 数据页面 |
| bv_len | unsigned int | 数据长度 |
| bv_offset | unsigned int | 页内偏移 |
一个bio可以包含多个bio_vec,每个bio_vec描述一段连续的内存区域。这种设计支持分散-聚集I/O(Scatter-Gather I/O),即一次I/O操作可以涉及多个不连续的内存区域,硬件的DMA引擎可以将这些内存区域的数据合并传输,减少了内存拷贝和I/O次数。
块设备I/O请求的处理流程如下:
第一步,VFS层发起I/O请求。当用户空间程序调用read()或write()系统调用时,VFS层通过页面缓存(Page Cache)将文件操作转换为页面级的I/O请求。
第二步,页面缓存层将页面I/O请求提交给块I/O子系统。如果页面缓存中未命中(读操作)或需要回写脏页面(写操作),页面缓存层调用submit_bio()函数将I/O请求提交给块I/O子系统。
第三步,块I/O子系统将bio请求放入请求队列。submit_bio()函数将bio请求放入块设备的请求队列(request_queue),I/O调度器根据调度策略对请求进行排序和合并。
第四步,I/O调度器处理请求队列。I/O调度器将请求队列中的bio请求按照调度策略排序,合并相邻的请求,生成优化后的I/O调度请求。
第五步,块设备驱动执行I/O操作。块设备驱动从请求队列中取出调度后的请求,将请求转换为具体的硬件操作(如DMA传输),启动硬件执行I/O。
第六步,硬件完成I/O操作后产生中断。块设备驱动的中断处理函数检查I/O完成状态,调用bio的完成回调函数通知上层I/O完成。
第七步,上层处理I/O完成。页面缓存层标记页面为最新(读操作)或清除脏标志(写操作),唤醒等待I/O完成的进程。
4 I/O调度器
I/O调度器(I/O Scheduler)是块I/O子系统中至关重要的组件,它决定了块设备I/O请求的执行顺序。I/O调度器的目标是在保证公平性的前提下,最小化磁盘寻道时间,最大化I/O吞吐量。对于机械硬盘,磁盘寻道时间是I/O性能的主要瓶颈——磁头移动到目标磁道需要数毫秒的时间,而数据传输只需要微秒级。因此,I/O调度器需要将随机I/O请求重新排序为顺序I/O请求,减少磁头移动距离。
Linux内核历史上实现了多种I/O调度器,每种调度器针对不同的工作负载和设备类型进行了优化:
| 调度器 | 引入版本 | 核心策略 | 适用场景 |
|---|---|---|---|
| Linus Elevator | 2.4 | 简单合并和排序 | 通用 |
| Deadline | 2.6 | 请求截止时间 | 数据库 |
| CFQ | 2.6.18 | 完全公平排队 | 桌面/通用 |
| NOOP | 2.6 | 不排序,直接FIFO | SSD/虚拟设备 |
| BFQ | 4.12 | 预算公平排队 | 桌面/移动 |
| mq-deadline | 5.0 | 多队列截止时间 | 通用 |
| Kyber | 4.12 | 令牌桶限流 | NVMe |
Deadline调度器为每个I/O请求设置一个截止时间(读请求500ms,写请求5秒),在截止时间之前,调度器按照扇区顺序处理请求以减少寻道;当请求即将超时,调度器优先处理即将超时的请求,避免请求饥饿。Deadline调度器维护三个队列:读FIFO队列、写FIFO队列和排序队列。正常情况下,调度器从排序队列中按扇区顺序取出请求;当某个FIFO队列中的请求即将超时,优先处理该请求。
CFQ(Completely Fair Queueing)调度器为每个发起I/O请求的进程维护一个独立的请求队列,按照时间片轮转的方式为每个进程分配I/O带宽。CFQ的目标是公平分配I/O带宽,避免某个进程独占I/O资源。CFQ还支持进程I/O优先级(类似于CPU的nice值),高优先级进程获得更多的I/O时间。CFQ在桌面系统中表现良好,因为桌面系统的I/O模式多样(应用程序启动、文件复制、后台同步等),公平性比吞吐量更重要。
NOOP调度器是最简单的I/O调度器——它不进行任何排序或合并,只是将请求按照FIFO顺序传递给设备。NOOP调度器适用于SSD和虚拟设备,因为这些设备没有机械寻道开销,随机I/O和顺序I/O的性能差异很小。对SSD使用Deadline或CFQ调度器反而会增加不必要的排序开销。
BFQ(Budget Fair Queueing)调度器是CFQ的改进版本,它使用预算公平排队算法为每个进程分配I/O带宽。BFQ的核心思想是将磁盘的I/O带宽视为一种预算资源,每个进程获得一定的I/O预算(以扇区数或时间为单位),进程用完预算后切换到下一个进程。BFQ相比CFQ的主要改进是更精确的带宽分配和更好的延迟控制——BFQ可以保证交互式应用(如视频播放、文件管理器)的低延迟响应,同时后台任务(如文件同步)不会饿死。
Kyber调度器是专为NVMe等高速存储设备设计的调度器。NVMe设备的I/O队列深度可达65535,传统的I/O调度器无法有效处理如此高的队列深度。Kyber使用令牌桶算法限制同步和异步请求的并发数量,防止设备队列过载。Kyber调度器维护两个令牌桶——一个用于同步请求(读),一个用于异步请求(写),通过调整令牌发放速率来控制I/O并发度。
从Linux 5.0开始,内核引入了多队列块I/O(blk-mq)框架,替代了传统的单队列框架。blk-mq框架为每个CPU核心维护一个软件队列,为每个硬件队列维护一个硬件上下文,支持多核并行提交I/O请求。blk-mq框架下的I/O调度器(mq-deadline、BFQ、Kyber)都是基于多队列设计的,能够充分利用NVMe设备的多队列能力。
blk-mq框架的核心数据结构包括:
| 数据结构 | 含义 |
|---|---|
| blk_mq_tag_set | 标签集,管理硬件队列的请求标签 |
| blk_mq_hw_ctx | 硬件队列上下文,对应一个硬件提交队列 |
| blk_mq_ctx | 软件队列上下文,对应一个CPU核心 |
| blk_mq_tags | 标签位图,管理请求的分配和释放 |
blk-mq的I/O提交流程如下:上层通过submit_bio()提交bio请求,blk-mq根据当前CPU选择对应的软件队列(blk_mq_ctx),将bio请求添加到软件队列中。然后,blk-mq将软件队列中的请求刷新到对应的硬件队列(blk_mq_hw_ctx),I/O调度器对硬件队列中的请求进行排序和合并。最后,blk-mq调用驱动的queue_rq()函数将请求提交给硬件执行。
5 Linux设备模型
Linux设备模型(Linux Device Model)是2.6内核引入的一套统一的设备管理框架,它为系统中的所有设备、驱动和总线提供了一个层次化的组织结构。设备模型的核心目标是:提供统一的设备表示和管理方式、支持设备的自动发现和绑定、提供sysfs接口供用户空间查看设备信息、支持电源管理和热插拔。
设备模型的核心数据结构包括kobject、kset、ktype、device、device_driver和bus_type,它们之间的关系如下:
kobject是设备模型的基本单元,所有设备模型中的对象都嵌入了一个kobject。kobject提供了引用计数、sysfs表示、父子关系和热插拔事件等基本功能。
| 字段 | 类型 | 含义 |
|---|---|---|
| name | const char * | 对象名称 |
| entry | list_head | 链表节点 |
| parent | kobject * | 父对象 |
| kset | kset * | 所属的kset |
| ktype | kobj_type * | 对象类型 |
| sd | kernfs_node * | sysfs目录项 |
| kref | kref | 引用计数 |
kobject的引用计数通过kref管理,kref内部是一个atomic_t计数器。kobject_get()增加引用计数,kobject_put()减少引用计数,当计数降为零时调用ktype的release()函数释放对象。这种引用计数机制确保了对象在使用期间不会被意外释放。
kset是一组相关kobject的集合,对应sysfs中的一个目录。kset除了包含kobject外,还包含一个链表头(用于链接所有属于该kset的kobject)和一个uevent操作函数表(用于发送热插拔事件)。kset是kobject的容器,kobject通过kset组织成层次结构。
ktype定义了kobject的类型信息,包括默认属性、sysfs操作和释放函数。同一类型的kobject共享相同的ktype。
device结构体是设备模型中表示设备的最高层抽象,它嵌入了一个kobject,并添加了设备特有的信息:
| 字段 | 类型 | 含义 |
|---|---|---|
| parent | device * | 父设备 |
| p | device_private * | 私有数据 |
| bus | bus_type * | 所属总线 |
| driver | device_driver * | 绑定的驱动 |
| platform_data | void * | 平台特定数据 |
| driver_data | void * | 驱动私有数据 |
| of_node | device_node * | 设备树节点 |
| id | u32 | 设备ID |
| init_name | const char * | 初始名称 |
| dma_ops | dma_map_ops * | DMA操作 |
device_driver结构体表示设备驱动程序,它也嵌入了一个kobject,并包含驱动特有的信息:
| 字段 | 类型 | 含义 |
|---|---|---|
| name | const char * | 驱动名称 |
| bus | bus_type * | 所属总线 |
| module | module * | 所属内核模块 |
| probe() | 函数指针 | 设备探测和初始化 |
| remove() | 函数指针 | 设备移除 |
| shutdown() | 函数指针 | 系统关机 |
| suspend() | 函数指针 | 设备挂起 |
| resume() | 函数指针 | 设备恢复 |
bus_type结构体表示总线类型,它定义了总线的匹配规则和操作函数:
| 字段 | 类型 | 含义 |
|---|---|---|
| name | const char * | 总线名称 |
| match() | 函数指针 | 设备-驱动匹配函数 |
| probe() | 函数指针 | 设备探测 |
| remove() | 函数指针 | 设备移除 |
| suspend() | 函数指针 | 设备挂起 |
| resume() | 函数指针 | 设备恢复 |
| p | subsys_private * | 私有数据 |
设备模型的设备-驱动绑定过程如下:
第一步,设备注册。当新设备添加到系统时(如热插拔事件或内核初始化),内核调用device_add()函数将设备添加到设备模型中。device_add()在sysfs中创建设备的目录和属性文件,然后调用bus_type的match()函数查找匹配的驱动。
第二步,驱动匹配。bus_type的match()函数根据总线的匹配规则判断设备和驱动是否匹配。匹配规则因总线类型而异——PCI总线使用厂商ID和设备ID匹配,USB总线使用接口类和协议匹配,平台总线使用设备名称匹配,设备树使用兼容字符串匹配。
第三步,驱动探测。如果match()函数找到匹配的驱动,内核调用驱动的probe()函数。probe()函数负责初始化设备、分配资源、注册中断处理函数等。如果probe()成功,设备和驱动的绑定关系建立;如果probe()失败,内核继续查找其他匹配的驱动。
第四步,驱动注册。当新驱动加载到系统时(如模块加载),内核调用driver_register()函数将驱动添加到设备模型中。driver_register()在sysfs中创建驱动的目录和属性文件,然后遍历总线上的所有未绑定设备,调用match()函数查找匹配的设备。
这种双向匹配机制确保了设备和驱动可以以任意顺序注册——先注册设备后加载驱动,或先加载驱动后添加设备,都能正确地建立绑定关系。
6 设备树与平台设备驱动
设备树(Device Tree)是一种描述硬件设备信息的数据结构,最初由Open Firmware规范定义,后来被Linux内核广泛采用。设备树的主要目的是将硬件描述信息从内核代码中分离出来,使同一个内核映像能够支持不同的硬件平台,无需为每个平台编译不同的内核。
在设备树引入之前,Linux内核使用平台数据(Platform Data)来描述硬件信息。平台数据以C语言结构体的形式硬编码在内核源码的板级文件中,每增加一个硬件平台就需要修改内核代码。这种方式在嵌入式领域造成了严重的代码膨胀——ARM架构的板级文件一度超过数百万行代码。设备树的引入彻底改变了这种状况——硬件信息以文本格式(.dts文件)描述,编译为二进制格式(.dtb文件),由引导加载程序传递给内核,内核在启动时解析设备树,动态创建设备。
设备树源文件(.dts)的语法类似于C语言的结构体定义,使用花括号和分号组织层次结构。一个典型的设备树节点如下:
/ {
model = "Example Board";
compatible = "vendor,example-board";
cpus {
cpu@0 {
device_type = "cpu";
compatible = "arm,cortex-a53";
reg = <0x0>;
clock-frequency = <1200000000>;
};
};
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x40000000>;
};
uart@fe201000 {
compatible = "brcm,bcm2835-uart";
reg = <0xfe201000 0x200>;
interrupts = <57>;
clock-frequency = <48000000>;
status = "okay";
};
};
设备树的关键概念包括:
| 概念 | 含义 | 示例 |
|---|---|---|
| 节点(Node) | 设备或总线的描述 | uart@fe201000 |
| 属性(Property) | 设备的参数 | compatible, reg, interrupts |
| compatible | 兼容性字符串,用于驱动匹配 | “brcm,bcm2835-uart” |
| reg | 设备寄存器地址和大小 | <0xfe201000 0x200> |
| #address-cells | 子节点reg属性中地址的cell数 | <2> |
| #size-cells | 子节点reg属性中大小的cell数 | <1> |
| ranges | 地址映射(子地址到父地址) | <0x0 0xe0000000 0x100000> |
| status | 设备状态 | “okay"或"disabled” |
| phandle | 节点的唯一引用标识 | <5> |
compatible属性是设备树中最重要的属性,它用于设备和驱动的匹配。compatible属性的值是一个或多个字符串,按优先级从高到低排列。驱动程序通过of_device_id结构体声明自己支持的compatible值,内核在匹配时从左到右扫描compatible列表,找到第一个匹配的驱动。
平台设备驱动(Platform Driver)是Linux内核中用于不可发现设备(即无法通过总线枚举自动发现的设备)的驱动框架。平台设备通常包括SoC内部的控制器(如UART、I2C、SPI、GPIO控制器)、片上总线设备、以及通过设备树描述的自定义硬件。
平台设备驱动的核心数据结构是platform_driver和platform_device:
platform_device结构体表示一个平台设备:
| 字段 | 类型 | 含义 |
|---|---|---|
| name | const char * | 设备名称 |
| id | int | 设备ID |
| dev | device * | 内嵌的device结构体 |
| num_resources | unsigned int | 资源数量 |
| resource | resource * | 资源数组 |
| id_entry | platform_device_id * | ID匹配入口 |
platform_driver结构体表示平台设备驱动:
| 字段 | 类型 | 含义 |
|---|---|---|
| probe() | 函数指针 | 设备探测和初始化 |
| remove() | 函数指针 | 设备移除 |
| driver | device_driver | 内嵌的device_driver |
| id_table | platform_device_id * | ID匹配表 |
| of_match_table | of_device_id * | 设备树匹配表 |
平台设备驱动的匹配过程如下:如果设备由设备树创建,内核使用of_match_table中的compatible字符串与设备树节点的compatible属性匹配;如果设备由代码创建,内核使用id_table中的名称与设备的name字段匹配;如果没有id_table,则使用driver.name与设备名称匹配。
7 DMA与零拷贝技术
DMA(Direct Memory Access,直接内存访问)是现代计算机系统中提高I/O性能的关键技术。DMA允许硬件设备直接与系统内存交换数据,无需CPU参与数据传输过程。在没有DMA的情况下,CPU需要将数据从设备缓冲区逐字节复制到系统内存(或反向),这既浪费CPU时间,又限制了数据传输速率。使用DMA后,CPU只需设置DMA传输的参数(源地址、目标地址、传输长度),然后启动DMA传输,DMA控制器独立完成数据传输,传输完成后通过中断通知CPU。
Linux内核提供了DMA映射API来管理DMA缓冲区。DMA映射需要解决两个核心问题:一是DMA缓冲区的物理地址连续性——某些DMA控制器要求缓冲区在物理内存中连续;二是缓存一致性——CPU缓存中的数据可能与DMA传输的数据不一致。
DMA映射分为两种类型:
| 类型 | 物理连续性 | 缓存一致性 | 适用场景 |
|---|---|---|---|
| 一致性DMA映射 | 物理连续 | 硬件保证一致性 | 长期使用的缓冲区 |
| 流式DMA映射 | 可不连续 | 软件管理一致性 | 短期使用的缓冲区 |
一致性DMA映射(Coherent DMA Mapping)通过dma_alloc_coherent()函数分配,返回的缓冲区在物理内存中连续,且CPU和DMA控制器之间的缓存一致性由硬件保证(通常通过设置缓冲区为不可缓存或使用总线监听协议)。一致性DMA映射适用于长期使用的缓冲区,如设备的控制环、描述符表等。
流式DMA映射(Streaming DMA Mapping)通过dma_map_single()或dma_map_page()函数创建,它将已有的内存区域映射为DMA可访问的地址。流式DMA映射不保证物理连续性(使用IOMMU/SWIOTLB进行地址转换),缓存一致性需要软件管理——在DMA传输前调用dma_sync_single_for_device()刷新CPU缓存,在DMA传输后调用dma_sync_single_for_cpu()使CPU缓存失效。
零拷贝(Zero-Copy)技术是提高I/O性能的另一种重要手段。传统的数据传输路径涉及多次内存拷贝——例如,从文件读取数据发送到网络需要经过以下步骤:磁盘→内核缓冲区→用户缓冲区→内核套接字缓冲区→网卡。零拷贝技术通过消除不必要的内存拷贝,减少CPU开销和内存带宽消耗。
Linux内核支持多种零拷贝技术:
| 技术 | 系统调用 | 数据拷贝次数 | 适用场景 |
|---|---|---|---|
| 传统read/write | read() + write() | 4次 | 通用 |
| sendfile | sendfile() | 2次(或0次) | 文件到网络 |
| mmap + write | mmap() + write() | 2次 | 文件到网络 |
| splice | splice() | 0次 | 管道中转 |
| tee | tee() | 0次 | 数据复制到管道 |
sendfile()系统调用是最常用的零拷贝接口,它将文件数据直接传输到套接字,无需经过用户空间。在内核2.6.33之前,sendfile()仍需将数据从页面缓存拷贝到套接字缓冲区;从2.6.33开始,如果硬件支持scatter-gather DMA,sendfile()可以完全消除内存拷贝——页面缓存中的页面直接作为DMA传输的源,数据从磁盘缓存直接传输到网卡。
splice()系统调用在两个文件描述符之间移动数据,至少有一个描述符必须是管道。splice()的实现利用了管道的缓冲区机制——数据从源文件描述符拷贝到管道缓冲区,然后从管道缓冲区拷贝到目标文件描述符。由于管道缓冲区是内核空间中的页面,splice()避免了数据在内核空间和用户空间之间的拷贝。
8 嵌入式Linux设备驱动开发
嵌入式系统是Linux设备驱动开发的重要领域。与x86服务器不同,嵌入式设备通常使用ARM、RISC-V、MIPS等架构的SoC(System on Chip),集成了CPU、内存控制器、中断控制器、定时器、UART、I2C、SPI、GPIO等各种外设控制器。嵌入式Linux设备驱动开发需要理解这些硬件接口和内核的驱动框架。
I2C(Inter-Integrated Circuit)总线是嵌入式系统中最常用的低速串行总线之一,它使用两根信号线(SCL时钟线和SDA数据线)实现半双工通信,支持多主多从模式。Linux内核的I2C子系统分为I2C核心层、I2C总线驱动层和I2C设备驱动层。I2C核心层提供了I2C适配器注册/注销、I2C设备探测、I2C数据传输等通用接口。I2C总线驱动(也称为I2C适配器驱动)实现了特定SoC的I2C控制器操作,包括发送START条件、发送字节、接收字节、发送STOP条件等。I2C设备驱动实现了特定I2C从设备(如传感器、EEPROM、RTC等)的操作逻辑。
SPI(Serial Peripheral Interface)总线是另一种常用的嵌入式串行总线,它使用四根信号线(SCLK时钟、MOSI主出从入、MISO主入从出、CS片选)实现全双工通信,速度比I2C快得多(可达数十MHz)。Linux内核的SPI子系统与I2C子系统结构类似,分为SPI核心层、SPI主控制器驱动和SPI设备驱动。SPI设备通过board_info结构体或设备树描述其连接信息(片选引脚、最大时钟频率、工作模式等),内核在启动时根据这些信息创建SPI设备并匹配对应的驱动。
GPIO(General Purpose Input/Output)是嵌入式系统中最基本的I/O接口,每个GPIO引脚可以配置为输入或输出,用于控制LED、读取按键、驱动继电器等。Linux内核的GPIO子系统经历了从旧式全局编号接口到基于描述符的gpiod接口的演进。gpiod接口使用gpio_desc描述符代替全局编号,通过devm_gpiod_get()获取GPIO描述符,通过gpiod_direction_input()/gpiod_direction_output()设置方向,通过gpiod_get_value()/gpiod_set_value()读写值。gpiod接口还支持从设备树自动获取GPIO配置(通过gpios属性),简化了驱动代码。
设备树(Device Tree)是嵌入式Linux中描述硬件信息的主要方式。设备树将硬件描述从内核代码中分离出来,使得同一个内核映像可以在不同的硬件平台上运行。设备树源文件(.dts)编译为设备树二进制文件(.dtb),由bootloader传递给内核。内核在启动时解析设备树,创建对应的platform_device、i2c_client、spi_device等设备,然后与已注册的驱动进行匹配。
9 Linux设备模型与sysfs深度解析
Linux设备模型是内核中管理设备和驱动的框架,它通过kobject、kset、ktype、subsystem等数据结构,建立了设备之间的层次关系,并通过sysfs文件系统将设备信息导出到用户空间。理解Linux设备模型对于理解设备驱动的注册和匹配机制至关重要。
kobject是设备模型的基本单元,它类似于面向对象编程中的基类——所有设备模型中的对象都"继承"自kobject。kobject结构体包含以下关键字段:name(对象名称)、entry(链表节点,用于将kobject链接到kset中)、parent(父kobject指针)、kset(所属kset指针)、ktype(ktype指针)、kref(引用计数)。kobject本身不执行任何有意义的工作,它的作用是提供引用计数、sysfs表示和父子关系等基础设施。
kset是一组相关kobject的集合,它在sysfs中对应一个目录。kset不仅是一个容器,还管理着其内部kobject的uevent(用户空间事件)——当kobject被创建、删除或修改时,kset向用户空间发送uevent通知,udev守护进程根据uevent执行相应的操作(如创建设备节点、加载固件等)。kset结构体包含list(链表头,链接所有属于该kset的kobject)、kobj(kset自身的kobject)、uevent_ops(uevent操作函数表)等字段。
ktype定义了kobject的类型信息,包括default_attrs(默认属性数组)和release(释放函数)。当kobject的引用计数降为零时,调用ktype的release()函数释放包含该kobject的上层对象(如device、cdev等)。default_attrs定义了该类型kobject在sysfs中的默认属性文件——每个属性对应一个文件,读取文件调用属性的show()方法,写入文件调用属性的store()方法。
设备模型中最重要的数据结构是struct device和struct device_driver。device结构体代表系统中的一个设备,包含设备的信息(名称、类型、总线、驱动、电源状态等)和一个嵌入的kobject。device_driver结构体代表一个设备驱动,包含驱动的信息(名称、总线类型、probe/remove回调等)和一个嵌入的driver_private结构体。设备和驱动通过总线(bus_type)进行匹配——总线定义了match()回调函数,当新设备或新驱动注册时,总线遍历已注册的驱动或设备列表,调用match()函数查找匹配项。
设备注册和匹配的流程如下:驱动调用driver_register()注册到总线,内核遍历总线上已注册的设备列表,对每个设备调用bus_type.match();如果匹配成功,调用driver.probe()函数初始化设备。同样,设备调用device_register()注册到总线,内核遍历已注册的驱动列表,对每个驱动调用match();如果匹配成功,调用driver.probe()。这种双向匹配机制确保了设备和驱动可以以任意顺序注册。
10 电源管理与休眠机制
电源管理是现代Linux系统的重要功能,特别是在笔记本电脑和移动设备上,有效的电源管理可以显著延长电池续航时间。Linux内核的电源管理子系统包括CPU频率调节(CPUFreq)、CPU空闲管理(CPUIdle)、系统休眠(Suspend/Resume)和运行时电源管理(Runtime PM)等多个组件。
CPUFreq子系统通过动态调整CPU的运行频率和电压来节省功耗。CPUFreq框架由调频驱动(cpufreq_driver)和调频策略(cpufreq_governor)两部分组成。调频驱动负责与硬件交互,实际执行频率切换操作;调频策略决定何时切换到什么频率。Linux内核提供了多种调频策略:performance(始终运行在最高频率)、powersave(始终运行在最低频率)、ondemand(根据CPU利用率动态调整频率)、conservative(类似ondemand但调整更保守)、schedutil(由调度器驱动的调频策略,利用调度器的CPU利用率信息做更精确的频率决策)。
系统休眠(Suspend)将系统置于低功耗状态,分为S1(Power On Suspend,CPU停止但内存和设备保持供电)、S3(Suspend to RAM,只有内存保持供电)、S4(Suspend to Disk,所有状态保存到磁盘后断电)和S5(Soft Off,完全关机)几种状态。Linux最常用的是S3(STR,挂起到内存)和S4(STD,挂起到磁盘)。S3的恢复速度最快(通常几秒),因为内存中的数据保持不变;S4的恢复速度较慢(需要从磁盘读取数据),但功耗最低。
休眠的流程涉及多个阶段:冻结用户空间进程(发送PM事件通知);同步文件系统(sync);禁用内核抢占;依次挂起各个设备(调用driver.suspend());禁用非启动CPU;挂起中断控制器和系统时钟;CPU进入低功耗状态。恢复流程是休眠的逆序:CPU恢复运行;恢复系统时钟和中断控制器;启用非启动CPU;依次恢复各个设备(调用driver.resume());解冻用户空间进程。
运行时电源管理(Runtime PM)允许设备在不使用时自动进入低功耗状态,而不需要整个系统进入休眠。Runtime PM为每个设备维护一个引用计数和使用计数——当引用计数降为零时,设备自动进入低功耗状态;当需要使用设备时,先增加引用计数唤醒设备。Runtime PM的API包括pm_runtime_get()/pm_runtime_put()(增加/减少引用计数)和pm_runtime_suspend()/pm_runtime_resume()(手动挂起/恢复设备)。Runtime PM与系统休眠可以协同工作——在系统休眠前,Runtime PM确保所有设备都已挂起;在系统恢复后,Runtime PM恢复设备的运行时状态。
11 设备树与平台驱动模型
设备树(Device Tree)是嵌入式Linux中描述硬件信息的主要方式,它将硬件描述从内核代码中分离出来,使得同一个内核映像可以在不同的硬件平台上运行。设备树最初由Open Firmware(IEEE 1275)标准定义,被PowerPC和SPARC架构采用,后来被ARM架构广泛使用,现在已成为嵌入式Linux的标准硬件描述格式。
设备树源文件(.dts)使用层次化的树形结构描述硬件,每个节点代表一个设备或总线,属性描述设备的配置信息。设备树的根节点包含所有顶层设备的描述,子节点描述总线上的从设备。设备树支持include机制(.dtsi文件),可以将公共部分提取到单独的文件中,避免重复描述。
设备树中常用的属性包括:compatible(兼容性字符串,用于驱动匹配)、reg(寄存器地址和大小)、interrupts(中断号和类型)、clocks(时钟源引用)、gpio(GPIO引脚引用)、status(设备状态,okay或disabled)等。compatible属性是最重要的属性,它是一个或多个字符串,驱动程序通过of_device_id结构体声明自己支持的compatible值,内核在匹配时扫描compatible列表找到匹配的驱动。
设备树的编译和加载流程如下:设备树源文件(.dts)通过dtc编译器编译为设备树二进制文件(.dtb);bootloader(如U-Boot)将内核映像和.dtb文件加载到内存;bootloader将.dtb文件的地址通过启动参数传递给内核;内核在启动时解析.dtb文件,创建对应的设备节点。内核的设备树解析代码在drivers/of/目录中,核心函数是unflatten_dt_node()——它递归地遍历设备树节点,为每个节点创建device_node结构体。
平台驱动(Platform Driver)是嵌入式Linux中最常用的驱动模型。平台驱动通过platform_driver_register()注册,与platform_device通过of_match_table(设备树匹配表)或id_table(ID匹配表)进行匹配。当匹配成功时,内核调用驱动的probe()函数,传入platform_device指针,驱动从中获取设备树中的配置信息(如寄存器地址、中断号、时钟等),初始化设备。
平台驱动的probe()函数通常执行以下操作:通过platform_get_resource()获取寄存器资源,通过devm_ioremap_resource()映射寄存器地址;通过platform_get_irq()获取中断号,通过devm_request_irq()注册中断处理程序;通过devm_clk_get()获取时钟,通过clk_prepare_enable()启用时钟;初始化硬件设备,注册到相应的内核子系统(如misc_register()、input_register_device()等)。devm_前缀的函数是设备资源管理(Device Resource Management, devres)接口——它们在设备移除时自动释放资源,避免了资源泄漏。
12 Linux内核模块机制
Linux内核模块(Loadable Kernel Module, LKM)是Linux内核实现动态扩展的核心机制。内核模块允许在系统运行时动态地添加和移除功能,而无需重新编译内核或重启系统。这种机制使得Linux内核既保持了宏内核的高性能,又具备了类似微内核的灵活性。
内核模块的本质是一个经过特殊编译的目标文件(.ko文件),它包含可以在内核空间执行的代码和数据。模块的编译使用内核构建系统(Kbuild),需要包含正确的头文件路径和编译选项。一个最简单的内核模块包含两个入口函数——init函数(模块加载时调用)和exit函数(模块卸载时调用),通过module_init()和module_exit()宏注册。
内核模块的生命周期管理涉及以下关键步骤。加载阶段:insmod或modprobe命令触发sys_init_module()系统调用;内核将模块的ELF文件读入内核空间;解析模块的符号表,解析未定义的符号引用;执行模块的重定位操作;调用模块的init函数;将模块标记为LIVE状态。卸载阶段:rmmod命令触发sys_delete_module()系统调用;检查模块的引用计数是否为零;检查是否有其他模块依赖此模块;调用模块的exit函数;释放模块占用的内核内存。
模块之间的依赖关系通过符号导出和引用来管理。内核模块通过EXPORT_SYMBOL()和EXPORT_SYMBOL_GPL()宏导出符号(函数或变量),其他模块可以使用这些导出的符号。EXPORT_SYMBOL_GPL()导出的符号只对以GPL兼容许可证声明的模块可见,这是内核社区鼓励开源的一种方式。模块的依赖信息存储在modules.dep文件中(由depmod命令生成),modprobe命令在加载模块时自动加载其依赖模块。
内核模块版本控制是模块兼容性的重要保障。当内核配置启用CONFIG_MODVERSIONS时,每个导出符号都会计算一个CRC(Cyclic Redundancy Check)校验值,作为符号的版本标识。模块加载时,内核比较模块中记录的符号CRC与当前内核的符号CRC,如果不匹配则拒绝加载。这种机制防止了模块与内核之间的ABI不兼容问题——当内核的数据结构或函数签名发生变化时,CRC也会变化,旧模块无法加载到新内核上。
13 虚拟化与半虚拟化I/O
虚拟化技术是现代云计算的基础,而I/O虚拟化是虚拟化技术中最复杂的部分之一。Linux内核作为宿主机(Host)和虚拟机(Guest)的操作系统,需要支持多种I/O虚拟化方案,从全虚拟化到半虚拟化再到硬件辅助虚拟化。
全虚拟化(Full Virtualization)不需要修改Guest操作系统,通过模拟完整的硬件设备来提供I/O支持。QEMU的设备模拟是全虚拟化的典型实现——QEMU在用户空间模拟各种硬件设备(如e1000网卡、IDE控制器、VGA显卡等),Guest操作系统使用标准的设备驱动与模拟设备交互。每次I/O操作都需要从Guest切换到Host的KVM内核模块,再切换到QEMU用户空间进程,经过多次上下文切换,性能开销很大。
半虚拟化(Paravirtualization)通过修改Guest操作系统,使用专门为虚拟化环境设计的驱动程序(Virtio驱动)来提高I/O性能。Virtio是Rusty Russell在2008年提出的半虚拟化I/O框架,它定义了一组简化的设备接口,Guest和Host之间通过共享内存环形缓冲区(Virtqueue)进行高效的数据传输。
Virtio框架的核心组件包括:Virtio设备(在Host侧实现,如QEMU中的virtio-net、virtio-blk)、Virtio驱动(在Guest内核中实现,如virtio_net、virtio_blk驱动)、Virtqueue(数据传输的共享内存环形缓冲区)。Virtio设备通过Virtqueue与驱动通信,每个Virtqueue包含三个部分:描述符表(Descriptor Table,描述数据缓冲区的地址和长度)、可用环(Available Ring,驱动写入,指示可用的描述符)、已用环(Used Ring,设备写入,指示已处理的描述符)。
Virtio的数据传输流程如下:Guest驱动将数据缓冲区的地址和长度写入描述符表;将描述符索引写入可用环;通过写Virtio设备的"通知寄存器"通知Host有新数据;Host从可用环读取描述符索引,根据描述符表中的地址和长度访问Guest的内存(通过KVM的内存映射);Host处理完数据后,将描述符索引写入已用环;Host通过VIRQ(虚拟中断)通知Guest数据处理完成;Guest在中断处理程序中从已用环读取已处理的描述符,回收数据缓冲区。
Virtio的优势在于减少了上下文切换和内存拷贝——数据直接在Guest和Host的共享内存中传输,无需经过多次拷贝。Virtio的局限在于需要修改Guest操作系统(安装Virtio驱动),对于无法修改的闭源操作系统(如Windows),需要额外安装Virtio驱动程序。
Vhost是Virtio的进一步优化。在标准Virtio实现中,I/O请求需要从Guest内核→KVM→QEMU用户空间→Host内核,经过多次上下文切换。Vhost将Virtio设备的数据面处理逻辑移到Host内核中(vhost_net、vhost_scsi等内核模块),I/O请求只需要从Guest内核→KVM→Host内核,减少了一次用户空间切换。Vhost-net可以将网络吞吐量提高30%-50%。
SR-IOV(Single Root I/O Virtualization)是硬件辅助的I/O虚拟化技术,它允许一个物理PCIe设备(Physical Function, PF)创建多个虚拟功能(Virtual Function, VF),每个VF可以独立分配给虚拟机使用。VF拥有独立的配置空间、中断和DMA引擎,虚拟机可以直接通过VF与物理设备通信,完全绕过Host内核的软件交换路径。SR-IOV的性能接近裸机,但灵活性较低——VF的数量有限(通常64-256个),且不支持虚拟机的在线迁移。
14 固件与设备初始化流程
设备固件(Firmware)是运行在设备内部微控制器上的程序,它控制设备的硬件操作。许多现代设备(如网卡、GPU、无线适配器)需要加载固件后才能正常工作。Linux内核提供了固件加载框架,允许驱动程序在初始化设备时从用户空间加载固件映像。
固件加载的流程如下:驱动程序调用request_firmware()(或其变体request_firmware_nowait()、request_firmware_direct()),传入固件文件名和设备信息;内核向用户空间的udev/uevent守护进程发送固件加载请求;udev从/lib/firmware/目录中读取固件文件,通过sysfs接口将固件数据传回内核;内核将固件数据映射到内核地址空间,返回给驱动程序;驱动程序通过设备特定的方式(如通过MMIO寄存器或DMA)将固件下载到设备中;下载完成后,调用release_firmware()释放固件数据。
固件加载的异步变体request_firmware_nowait()适用于不能阻塞的上下文(如中断处理程序或内核线程)。它注册一个回调函数,固件加载完成后内核调用回调函数,传入固件数据。这种异步方式避免了固件加载过程中的阻塞等待。
Direct Access Firmware(request_firmware_direct())跳过udev的参与,直接从内核内置固件或文件系统加载固件。这种方式更快,但要求固件文件在内核编译时通过CONFIG_EXTRA_FIRMWARE选项嵌入到内核映像中,或者固件文件已经存在于文件系统中。嵌入式系统通常使用这种方式,因为它们没有用户空间的udev守护进程。
Linux内核还支持EFI嵌入式固件(EFI Embedded Firmware)——某些设备的固件存储在UEFI固件中,内核通过EFI接口获取。这种方式避免了在文件系统中维护固件文件,但需要UEFI固件的支持。
设备初始化的完整流程通常包括以下步骤:电源管理初始化(启用设备时钟、解除复位状态);总线枚举(PCI/USB/平台总线扫描发现设备);驱动匹配和probe()调用;固件加载和下载;设备寄存器初始化;中断注册;DMA映射初始化;注册到内核子系统(网络子系统、输入子系统、音频子系统等)。每个步骤都可能失败,驱动程序需要正确处理失败情况,释放已分配的资源。devm_前缀的资源管理接口可以自动处理资源释放,大大简化了错误处理代码。
15 Linux设备驱动的安全考虑
设备驱动的安全性是Linux内核安全的重要组成部分。由于设备驱动运行在内核空间,驱动中的安全漏洞可能导致整个系统被攻破。近年来,内核漏洞统计显示,设备驱动是安全漏洞最集中的区域——约占所有内核漏洞的70%以上。因此,编写安全的设备驱动代码至关重要。
设备驱动的常见安全漏洞包括:缓冲区溢出(由于未正确验证用户空间传入的数据长度)、整数溢出(由于未检查算术运算的溢出)、空指针解引用(由于未检查kmalloc等分配函数的返回值)、信息泄漏(由于未初始化的栈变量或结构体填充字节被复制到用户空间)、竞态条件(由于缺少适当的同步保护)等。
设备驱动的安全编码实践包括:始终验证用户空间传入的参数(特别是长度和偏移量),使用copy_from_user()和copy_to_user()而不是直接访问用户空间指针,使用access_ok()验证用户空间指针的合法性,使用size_t等无符号类型表示大小和长度,检查kmalloc()等分配函数的返回值,使用memset()初始化分配的内存,使用适当的锁保护共享数据等。
内核还提供了多种安全机制来限制设备驱动的权限:seccomp(Secure Computing Mode)限制进程可以使用的系统调用集合;AppArmor和SELinux限制进程可以访问的文件和设备;内核锁定(Kernel Lockdown)模式在UEFI Secure Boot启用的系统上限制内核模块的加载和/dev/mem等接口的访问。这些机制为设备驱动的安全提供了额外的防御层。
例题
- Linux将设备分为三大类,其中网络设备与其他两类设备的本质区别是:
A. 网络设备传输速度更快
B. 网络设备没有设备节点,不通过文件操作接口访问
C. 网络设备不支持DMA
D. 网络设备只能读取不能写入
答案:B。网络设备与字符设备和块设备的本质区别在于:网络设备没有设备节点(不位于/dev目录下),不通过open()/read()/write()等文件操作接口访问,而是通过套接字(Socket)接口进行数据包的收发。网络设备使用sk_buff结构体来组织数据,而不是文件I/O的缓冲区。
- 在字符设备驱动中,copy_to_user()和copy_from_user()函数的主要作用是:
A. 在内核空间的不同地址之间复制数据
B. 在用户空间和内核空间之间安全地复制数据
C. 在DMA缓冲区和设备之间复制数据
D. 在不同进程的地址空间之间复制数据
答案:B。copy_to_user()和copy_from_user()是Linux内核提供的安全内存访问函数,用于在用户空间和内核空间之间复制数据。这些函数会验证用户空间地址的合法性(是否在用户空间范围内、是否可读/可写),防止内核因为无效的用户空间指针而崩溃。直接使用memcpy()访问用户空间地址是危险的,因为用户可能传入非法地址。
- 关于I/O调度器,以下描述正确的是:
A. NOOP调度器不进行任何排序,适用于所有存储设备
B. Deadline调度器只按截止时间排序,不考虑寻道优化
C. CFQ调度器为每个进程维护独立的请求队列,公平分配I/O带宽
D. BFQ调度器是CFQ的简化版本,性能更差
答案:C。CFQ(Completely Fair Queueing)调度器为每个发起I/O请求的进程维护一个独立的请求队列,按照时间片轮转的方式为每个进程分配I/O带宽,实现了I/O带宽的公平分配。NOOP调度器适用于SSD和虚拟设备,但不适用于机械硬盘。Deadline调度器在截止时间之前按扇区顺序排序以优化寻道,只在请求即将超时时才优先处理超时请求。BFQ是CFQ的改进版本,使用更精确的预算公平排队算法,性能更好。
- Linux设备模型中,kobject的引用计数机制的作用是:
A. 统计系统中kobject的数量
B. 确保对象在使用期间不会被意外释放
C. 限制kobject的创建数量
D. 记录kobject被访问的次数
答案:B。kobject的引用计数通过kref管理,kobject_get()增加引用计数,kobject_put()减少引用计数。当引用计数降为零时,调用ktype的release()函数释放对象。这种机制确保了对象在还有引用者时不会被释放,避免了使用已释放内存导致的内核崩溃。
- 设备树中compatible属性的主要作用是:
A. 描述设备的物理地址
B. 标识设备的兼容性,用于设备和驱动的匹配
C. 描述设备的中断号
D. 标识设备的状态
答案:B。compatible属性是设备树中最重要的属性,它用于设备和驱动的匹配。compatible属性的值是一个或多个字符串,驱动程序通过of_device_id结构体声明自己支持的compatible值,内核在匹配时扫描compatible列表找到匹配的驱动。设备的物理地址通过reg属性描述,中断号通过interrupts属性描述,设备状态通过status属性描述。
- 关于DMA映射,一致性DMA映射和流式DMA映射的主要区别是:
A. 一致性映射不需要物理连续,流式映射需要物理连续
B. 一致性映射的缓存一致性由硬件保证,流式映射需要软件管理缓存一致性
C. 一致性映射只能用于读操作,流式映射只能用于写操作
D. 一致性映射性能更高,流式映射性能更低
答案:B。一致性DMA映射的缓存一致性由硬件保证(通过不可缓存属性或总线监听协议),软件无需手动管理缓存。流式DMA映射的缓存一致性需要软件管理——DMA传输前调用dma_sync_single_for_device()刷新CPU缓存,DMA传输后调用dma_sync_single_for_cpu()使CPU缓存失效。一致性映射需要物理连续,流式映射可以不连续(使用IOMMU进行地址转换)。
- sendfile()零拷贝技术相比传统read()+write()的优势是:
A. 支持任意两个文件描述符之间的数据传输
B. 数据不经过用户空间,减少了2次内存拷贝
C. 数据不经过内核空间
D. 支持数据修改后再传输
答案:B。sendfile()将文件数据直接从页面缓存传输到套接字缓冲区,无需经过用户空间,相比传统的read()+write()减少了2次内存拷贝(从内核缓冲区到用户缓冲区,从用户缓冲区到套接字缓冲区)。sendfile()的限制是输出端必须是套接字,不支持任意两个文件描述符之间的传输,也不支持在传输过程中修改数据。
- blk-mq(多队列块I/O)框架相比传统单队列框架的主要改进是:
A. 支持更多的I/O调度器
B. 为每个CPU核心维护独立的软件队列,支持多核并行提交I/O请求
C. 减少了内存使用
D. 简化了驱动开发
答案:B。blk-mq框架为每个CPU核心维护一个软件队列(blk_mq_ctx),为每个硬件队列维护一个硬件上下文(blk_mq_hw_ctx),支持多核并行提交I/O请求,消除了传统单队列的全局锁竞争。blk-mq框架特别适合NVMe等支持多硬件队列的高速存储设备,能够充分利用设备的并行处理能力。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)