Linux 设备驱动模型:字符设备、块设备与 sysfs

Linux 设备驱动是连接硬件与内核的桥梁。本文从主设备号与次设备号的基础概念出发,深入讲解字符设备的注册与 file_operations 设计、ioctl 命令规范、块设备的 I/O 调度与请求队列,进而覆盖 sysfs 动态节点创建、udev 规则、platform driver 总线匹配、miscdevice 简化注册以及 DMA scatter-gather 等进阶主题。

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 介入。驱动需要:

  1. 分配 DMA 可用的内存(物理连续)
  2. 建立 DMA 映射(获取总线地址)
  3. 启动 DMA 传输
  4. 传输完成后解除映射
// 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 的现代存储栈全链路分析

延伸阅读

  1. 《Linux Device Drivers》第 3 版(Jonathan Corbet 等著)— 驱动开发圣经
  2. Linux Kernel Documentation: Documentation/driver-api/ 驱动 API 文档
  3. Linux 源码:drivers/base/platform.c(platform 驱动框架)
  4. Linux 源码:fs/char_dev.c(字符设备核心)
  5. Linux 源码:block/blk-core.c(块设备请求队列)
  6. 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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 内核调试:kgdb、kdump、crash 与动态追踪
  2. cgroups v2 与命名空间底层实现与资源隔离
  3. 系统调用实现:从 glibc 到内核态切换