Linux 内核中超过一半的代码是设备驱动。驱动程序是硬件与操作系统交互的唯一桥梁——无论是键盘、网卡、NVMe SSD 还是图形处理器,都需要对应的驱动让内核识别并控制它们。
本文从 Linux 设备模型的基础概念出发,系统地讲解字符设备的注册与 file_operations、块设备的 I/O 调度、sysfs 与动态设备节点、udev 规则、platform driver 总线匹配机制、miscdevice 简化注册以及 DMA scatter-gather,帮助读者建立从入门到进阶的完整驱动开发知识体系。
一、主设备号与次设备号
1.1 设备号的组成
在传统 Linux 设备模型中,每个设备由一个 32 位的设备号(dev_t)标识,其中高 12 位为主设备号(Major)、低 20 位为次设备号(Minor)。主设备号标识驱动程序,次设备号标识该驱动管理的具体设备实例。
// include/linux/kdev_t.h
#define MINORBITS 20
#define MINORMASK ((1U << MINORBITS) - 1)
#define MAJOR(dev) ((unsigned int)((dev) >> MINORBITS))
#define MINOR(dev) ((unsigned int)((dev) & MINORMASK))
#define MKDEV(ma, mi) (((ma) << MINORBITS) | (mi))
$ ls -l /dev/sda /dev/tty /dev/zero
brw-rw---- 1 root disk 8, 0 Jan 1 00:00 /dev/sda # 块设备, major=8
brw-rw---- 1 root disk 8, 1 Jan 1 00:00 /dev/sda1
brw-rw---- 1 root disk 8, 2 Jan 1 00:00 /dev/sda2
crw-rw-rw- 1 root tty 5, 0 Jan 1 00:00 /dev/tty # 字符设备, major=5
crw-rw-rw- 1 root root 1, 5 Jan 1 00:00 /dev/zero # 字符设备, major=1
sda的 major=8 对应 SCSI/SATA 磁盘驱动,minor 区分分区tty的 major=5 对应 TTY 驱动子系统zero的 major=1 对应内存字符设备(mem 驱动)
1.2 静态与动态分配
现代内核强烈推荐使用动态分配主设备号,避免手动分配冲突:
#include <linux/fs.h>
#include <linux/cdev.h>
static dev_t dev_num; // 设备号
static int major;
// 1. 动态分配设备号(alloc_chrdev_region)
static int __init mycdev_init(void)
{
int ret;
// 请求分配 1 个连续的设备号,驱动名 "mycdev"
ret = alloc_chrdev_region(&dev_num, 0, 1, "mycdev");
if (ret < 0) {
pr_err("Failed to allocate device number\n");
return ret;
}
major = MAJOR(dev_num);
pr_info("mycdev: allocated major=%d\n", major);
return 0;
}
static void __exit mycdev_exit(void)
{
unregister_chrdev_region(dev_num, 1);
}
module_init(mycdev_init);
module_exit(mycdev_exit);
MODULE_LICENSE("GPL");
加载模块后,/proc/devices 会自动追加一行:253 mycdev。用户空间程序可据此调用 mknod 创建设备节点。
二、字符设备注册与 file_operations
2.1 cdev 结构体
字符设备在内核中由 struct cdev 表示,需要通过 cdev_init 和 cdev_add 注册到系统:
#include <linux/cdev.h>
#include <linux/fs.h>
static struct cdev my_cdev;
static int my_open(struct inode *inode, struct file *filp)
{
pr_info("my_open called\n");
// filp->private_data 可存放驱动私有数据
return 0;
}
static int my_release(struct inode *inode, struct file *filp)
{
pr_info("my_release called\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *off)
{
char msg[] = "Hello from mycdev!\n";
int len = min(count, sizeof(msg));
if (copy_to_user(buf, msg, len))
return -EFAULT;
*off += len;
return len;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *off)
{
char kbuf[256];
int len = min(count, sizeof(kbuf) - 1);
if (copy_from_user(kbuf, buf, len))
return -EFAULT;
kbuf[len] = '\0';
pr_info("my_write received: %s\n", kbuf);
*off += len;
return len;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int __init mycdev_init(void)
{
int ret;
ret = alloc_chrdev_region(&dev_num, 0, 1, "mycdev");
if (ret < 0) return ret;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
unregister_chrdev_region(dev_num, 1);
return ret;
}
pr_info("mycdev: registered\n");
return 0;
}
2.2 用户空间创建设备节点
# 查看分配到的 major 号
$ cat /proc/devices | grep mycdev
253 mycdev
# 创建设备节点
$ sudo mknod /dev/mycdev c 253 0
$ sudo chmod 666 /dev/mycdev
# 测试读写
$ echo "test data" > /dev/mycdev
$ cat /dev/mycdev
Hello from mycdev!
2.3 自动创建设备节点:class_create + device_create
手动 mknod 不方便,内核提供了 class_create 和 device_create 来自动在 /dev 下创建节点(需配合 udev):
static struct class *my_class;
static int __init mycdev_init(void)
{
// ...alloc_chrdev_region, cdev_init, cdev_add...
// 创建 sysfs class
my_class = class_create(THIS_MODULE, "mycdev");
if (IS_ERR(my_class))
return PTR_ERR(my_class);
// 创建 /dev/mycdev 节点
device_create(my_class, NULL, dev_num, NULL, "mycdev");
return 0;
}
static void __exit mycdev_exit(void)
{
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
}
三、ioctl 命令设计
3.1 unlocked_ioctl
内核 2.6.36 之后推荐使用 unlocked_ioctl 替代 ioctl,因为它不持有全局 BKL(Big Kernel Lock):
#include <linux/ioctl.h>
// ioctl 命令定义(遵循内核规范)
#define MYDEV_IOC_MAGIC 'k'
#define MYDEV_IOC_RESET _IO(MYDEV_IOC_MAGIC, 0)
#define MYDEV_IOC_SETVAL _IOW(MYDEV_IOC_MAGIC, 1, int)
#define MYDEV_IOC_GETVAL _IOR(MYDEV_IOC_MAGIC, 2, int)
static long my_unlocked_ioctl(struct file *filp,
unsigned int cmd, unsigned long arg)
{
int value;
switch (cmd) {
case MYDEV_IOC_RESET:
pr_info("RESET command\n");
break;
case MYDEV_IOC_SETVAL:
if (copy_from_user(&value, (int __user *)arg, sizeof(int)))
return -EFAULT;
pr_info("SETVAL: %d\n", value);
break;
case MYDEV_IOC_GETVAL:
value = 42;
if (copy_to_user((int __user *)arg, &value, sizeof(int)))
return -EFAULT;
break;
default:
return -ENOTTY; // 不支持的 ioctl
}
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = my_unlocked_ioctl,
// ...open, read, write...
};
3.2 ioctl 命令编码规范
ioctl 命令的编码由 _IO、_IOR、_IOW、_IOWR 宏生成,包含四个字段:
- 幻数(Magic):8 位,标识驱动
- 序号(Number):8 位,命令编号
- 方向(Direction):2 位,无数据/读/写/双向
- 大小(Size):14 位,参数结构体大小
这种编码方式让内核可以验证用户传入的命令是否合法。
四、块设备 I/O 调度
4.1 块设备与字符设备的区别
| 特性 | 字符设备 | 块设备 |
|---|---|---|
| 数据粒度 | 字节流 | 块(通常 512B/4KB) |
| 寻址 | 不支持随机访问 | 支持随机访问 |
| 缓存 | 通常无 | 有页缓存(Page Cache) |
| 典型驱动 | 串口、键盘 | 磁盘、SSD |
| I/O 调度 | 无 | 有电梯调度等策略 |
4.2 块 I/O 请求队列
块设备驱动通过**请求队列(Request Queue)**接收来自文件系统的 I/O 请求。Linux 块层将相邻或相近的读写请求合并排序,减少磁盘寻道时间。
// 简化的块设备注册框架
#include <linux/blkdev.h>
#include <linux/genhd.h>
static struct request_queue *queue;
static struct gendisk *disk;
static char *block_data; // 模拟磁盘数据
static void my_block_request(struct request_queue *q)
{
struct request *req;
while ((req = blk_fetch_request(q)) != NULL) {
// 获取请求的起始扇区和方向
sector_t sector = blk_rq_pos(req);
unsigned int nr_sectors = blk_rq_sectors(req);
int dir = rq_data_dir(req); // READ or WRITE
// 将扇区转换为字节偏移
unsigned long offset = sector * 512;
unsigned long nbytes = nr_sectors * 512;
struct bio_vec bvec;
struct req_iterator iter;
__rq_for_each_bio(iter, req) {
bvec = iter.bvec;
char *buffer = page_address(bvec.bv_page) + bvec.bv_offset;
if (dir == READ) {
memcpy(buffer, block_data + offset, bvec.bv_len);
} else {
memcpy(block_data + offset, buffer, bvec.bv_len);
}
offset += bvec.bv_len;
}
__blk_end_request_all(req, 0); // 完成请求
}
}
4.3 I/O 调度器
Linux 内核支持多种 I/O 调度策略:
- None:完全绕过调度(NVMe 等高性能设备的默认选择)
- MQ-Deadline:多队列 Deadline,兼顾吞吐与延迟(SSD 推荐)
- BFQ(Budget Fair Queueing):按进程公平分配 I/O 带宽(交互式场景推荐)
- Kyber:面向低延迟的新型调度器
# 查看当前块设备的 I/O 调度器
$ cat /sys/block/nvme0n1/queue/scheduler
[mq-deadline] kyber bfq none
# 临时切换
$ echo bfq | sudo tee /sys/block/sda/queue/scheduler
五、sysfs 与 /dev 节点动态创建
5.1 sysfs 的作用
sysfs 是 Linux 内核导出的虚拟文件系统,挂载于 /sys。它将内核中的设备、总线、驱动数据结构以文件树的形式暴露给用户空间,用于:
- 查看设备属性(如
/sys/class/net/eth0/address) - 动态控制设备(如
echo 1 > /sys/class/leds/.../brightness) - udev 规则依据(设备热插拔时,udev 读取 sysfs 属性创建设备节点)
5.2 在驱动中创建 sysfs 属性
static int my_value = 0;
static ssize_t my_value_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
return sprintf(buf, "%d\n", my_value);
}
static ssize_t my_value_store(struct device *dev,
struct device_attribute *attr,
const char *buf, size_t count)
{
if (kstrtoint(buf, 10, &my_value) < 0)
return -EINVAL;
return count;
}
static DEVICE_ATTR(my_value, 0644, my_value_show, my_value_store);
static int __init mydev_init(void)
{
// ...device_create...
sysfs_create_file(&my_dev->kobj, &dev_attr_my_value.attr);
return 0;
}
加载模块后,用户空间可通过 /sys/class/mycdev/mycdev/my_value 读写该属性。
六、udev 规则
6.1 udev 的工作方式
udev 是 Linux 的设备管理器,监听内核通过 netlink 发送的 uevent,根据规则文件在 /dev 下创建或删除设备节点。
# 示例 udev 规则:/etc/udev/rules.d/99-mycdev.rules
# 当 major=253 的字符设备出现时,创建 /dev/my-special-dev 并赋予权限
KERNEL=="mycdev", SUBSYSTEM=="misc", MODE="0666", SYMLINK+="my-special-dev"
# 重载规则
$ sudo udevadm control --reload-rules
$ sudo udevadm trigger
udev 规则匹配条件包括 KERNEL、SUBSYSTEM、ATTRS、ENV 等,动作包括修改权限、创建符号链接、运行脚本等。
七、Platform Driver 总线匹配
7.1 平台总线模型
许多嵌入式设备并不挂在 PCI、USB 等传统总线上,而是直接通过内存映射寄存器与 CPU 通信。Linux 使用**平台总线(Platform Bus)**模型来管理这类设备。
平台设备(platform_device)在设备树(Device Tree)中描述,平台驱动(platform_driver)通过 compatible 字符串匹配:
// platform driver 示例
#include <linux/platform_device.h>
static int my_platform_probe(struct platform_device *pdev)
{
struct resource *res;
void __iomem *regs;
// 从设备树获取内存资源
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
regs = devm_ioremap_resource(&pdev->dev, res);
if (IS_ERR(regs))
return PTR_ERR(regs);
// 从设备树获取中断
int irq = platform_get_irq(pdev, 0);
if (irq < 0) return irq;
pr_info("my_platform: probed, regs@%p, irq=%d\n", regs, irq);
return 0;
}
static int my_platform_remove(struct platform_device *pdev)
{
pr_info("my_platform: removed\n");
return 0;
}
static const struct of_device_id my_of_match[] = {
{ .compatible = "vendor,my-device", },
{ },
};
MODULE_DEVICE_TABLE(of, my_of_match);
static struct platform_driver my_platform_driver = {
.probe = my_platform_probe,
.remove = my_platform_remove,
.driver = {
.name = "my-platform-dev",
.of_match_table = my_of_match,
},
};
module_platform_driver(my_platform_driver);
MODULE_LICENSE("GPL");
对应的设备树片段:
my_device@10000000 {
compatible = "vendor,my-device";
reg = <0x10000000 0x1000>;
interrupts = <0 42 4>;
};
当内核解析设备树发现 compatible = "vendor,my-device" 的设备节点时,会自动调用 my_platform_probe。
八、miscdevice 简化注册
对于简单的字符设备,使用完整的 cdev + class_create + device_create 流程过于繁琐。miscdevice 框架提供了快速注册通道:
#include <linux/miscdevice.h>
static ssize_t misc_read(struct file *filp, char __user *buf,
size_t count, loff_t *off)
{
char msg[] = "miscdevice demo\n";
int len = min(count, sizeof(msg));
if (copy_to_user(buf, msg, len))
return -EFAULT;
return len;
}
static struct file_operations misc_fops = {
.owner = THIS_MODULE,
.read = misc_read,
};
static struct miscdevice my_misc = {
.minor = MISC_DYNAMIC_MINOR, // 动态分配 minor
.name = "mymisc",
.fops = &misc_fops,
.mode = 0666, // 设备节点权限
};
static int __init misc_init(void)
{
return misc_register(&my_misc); // 自动创建设备节点
}
static void __exit misc_exit(void)
{
misc_deregister(&my_misc);
}
module_init(misc_init);
module_exit(misc_exit);
MODULE_LICENSE("GPL");
加载后自动创建 /dev/mymisc,无需手动调用 mknod。
九、DMA 与 Scatter-Gather
9.1 DMA 基础
DMA(Direct Memory Access)允许外设直接读写内存而无需 CPU 介入。驱动需要:
- 分配 DMA 可用的内存(物理连续)
- 建立 DMA 映射(获取总线地址)
- 启动 DMA 传输
- 传输完成后解除映射
// DMA 一致性映射示例
void *cpu_addr;
dma_addr_t dma_handle;
// 分配并映射 DMA 内存(物理连续)
cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);
if (!cpu_addr) return -ENOMEM;
// cpu_addr: 驱动使用的虚拟地址
// dma_handle: 设备使用的总线地址(物理地址经 IOMMU 转换后的地址)
// 填充数据
cpu_addr[0] = 0xAB;
// 启动 DMA(设备用 dma_handle 作为源/目的地址)
start_dma_transfer(dma_handle, size);
// 等待完成后释放
dma_free_coherent(dev, size, cpu_addr, dma_handle);
9.2 Scatter-Gather DMA
当驱动需要传输的数据分散在多个不连续的物理页上时,使用 Scatter-Gather(SG)DMA:
struct scatterlist sg[3];
struct sg_table sgt;
// 准备 scatterlist
sg_init_table(sg, 3);
sg_set_page(&sg[0], page1, 4096, 0);
sg_set_page(&sg[1], page2, 4096, 0);
sg_set_page(&sg[2], page3, 1024, 0); // 最后一段可能不足一页
// 映射 scatterlist
int nents = dma_map_sg(dev, sg, 3, DMA_TO_DEVICE);
if (nents == 0) return -ENOMEM;
// 遍历映射后的每个 entry
for (int i = 0; i < nents; i++) {
dma_addr_t addr = sg_dma_address(&sg[i]);
unsigned int len = sg_dma_len(&sg[i]);
// 将 addr 和 len 填入 DMA 描述符
setup_dma_descriptor(i, addr, len);
}
// 传输完成后解除映射
dma_unmap_sg(dev, sg, 3, DMA_TO_DEVICE);
现代 ARM64 和 x86-64 服务器大多配备 IOMMU,使得设备看到的地址空间与物理地址空间解耦,进一步增强了安全性与灵活性。
相关阅读
- https://plumephp.com/os-kernel-module/ —— Linux 内核编译与模块开发入门,驱动开发的前置基础
- https://plumephp.com/os-interrupts/ —— 中断处理机制详解,理解设备中断注册与 handler 的关键背景
- https://plumephp.com/os-nvme-storage-stack/ —— 从块设备到 NVMe 的现代存储栈全链路分析
延伸阅读
- 《Linux Device Drivers》第 3 版(Jonathan Corbet 等著)— 驱动开发圣经
- Linux Kernel Documentation:
Documentation/driver-api/驱动 API 文档 - Linux 源码:
drivers/base/platform.c(platform 驱动框架) - Linux 源码:
fs/char_dev.c(字符设备核心) - Linux 源码:
block/blk-core.c(块设备请求队列) - IOMMU 与 VFIO:
Documentation/vfio.txt
// ============================================================
// 完整可运行示例:极简字符设备驱动 + ioctl + sysfs 属性
// 编译: make -C /lib/modules/$(uname -r)/build M=$(pwd) modules
// ============================================================
// === mycdev.c ===
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/ioctl.h>
#include <linux/uaccess.h>
#define MYDEV_IOC_MAGIC 'k'
#define MYDEV_IOC_RESET _IO(MYDEV_IOC_MAGIC, 0)
#define MYDEV_IOC_SETVAL _IOW(MYDEV_IOC_MAGIC, 1, int)
#define MYDEV_IOC_GETVAL _IOR(MYDEV_IOC_MAGIC, 2, int)
static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;
static int my_value = 0;
static long my_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
int v;
switch (cmd) {
case MYDEV_IOC_RESET: my_value = 0; break;
case MYDEV_IOC_SETVAL:
if (copy_from_user(&v, (int __user *)arg, sizeof(int)))
return -EFAULT;
my_value = v;
break;
case MYDEV_IOC_GETVAL:
if (copy_to_user((int __user *)arg, &my_value, sizeof(int)))
return -EFAULT;
break;
default: return -ENOTTY;
}
return 0;
}
static ssize_t my_read(struct file *f, char __user *buf, size_t c, loff_t *o)
{
char msg[64];
int n = snprintf(msg, sizeof(msg), "value=%d\n", my_value);
if (*o >= n) return 0;
int len = min((int)c, n - (int)*o);
if (copy_to_user(buf, msg + *o, len)) return -EFAULT;
*o += len;
return len;
}
static struct file_operations fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = my_ioctl,
.read = my_read,
};
static ssize_t val_show(struct device *d, struct device_attribute *a, char *b)
{
return sprintf(b, "%d\n", my_value);
}
static ssize_t val_store(struct device *d, struct device_attribute *a,
const char *b, size_t c)
{
if (kstrtoint(b, 10, &my_value) < 0) return -EINVAL;
return c;
}
static DEVICE_ATTR(my_val, 0644, val_show, val_store);
static int __init m_init(void)
{
alloc_chrdev_region(&dev_num, 0, 1, "mycdev");
cdev_init(&my_cdev, &fops); cdev_add(&my_cdev, dev_num, 1);
my_class = class_create(THIS_MODULE, "mycdev");
struct device *dev = device_create(my_class, NULL, dev_num, NULL, "mycdev");
sysfs_create_file(&dev->kobj, &dev_attr_my_val.attr);
return 0;
}
static void __exit m_exit(void)
{
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
}
module_init(m_init); module_exit(m_exit);
MODULE_LICENSE("GPL");
// === Makefile ===
// obj-m += mycdev.o
// KDIR ?= /lib/modules/$(shell uname -r)/build
// all:
// $(MAKE) -C $(KDIR) M=$(PWD) modules
// clean:
// $(MAKE) -C $(KDIR) M=$(PWD) clean
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。