详细介绍:Linux字符设备驱动开发全攻略

目标与读者

这篇 面向小白、由浅入深的超详细教程,带你从最基础的概念到可以动手运行的 字符设备驱动(char driver)示例、调试与常见坑位。假如你是 Linux 驱动新手、刚接触内核/嵌入式开发,跟着本文走一遍,你能:

  • 理解内核空间 vs 用户空间;
  • 掌握内核与用户程序的常见通信方式(read/writeioctlmmapsysfsproc 等);
  • 能写出一个最小可运行的字符设备驱动,知道如何编译/加载/测试和清理;
  • 能进行基本调试与排错、理解内存/中断/同步等核心概念。

下面开始,慢慢来,每一部分都有实例与操作步骤,可直接照着做。


为什么要学驱动(从工程角度)

  • 驱动是操作系统与硬件/外设之间的桥梁。要读传感器、控制外设、处理 DMA、响应中断,都需要驱动。
  • 驱动比普通用户程序更接近硬件,出错可能导致系统崩溃(所以写驱动要格外小心)。
  • 学会驱动能帮助你深入理解操作系统(内存管理、进程/线程、同步、I/O 等)。

最基础的概念(必须搞清楚)

内核空间(Kernel Space)与用户空间(User Space)

  • 用户空间:普通程序运行区(浏览器、程序)。不能直接访问硬件或内核数据结构。
  • 内核空间:操作系统内核与驱动运行区,权限更高。错误会影响整个系统。

原则:永远不要在内核中直接使用用户空间指针或自行解引用用户指针 —— 一定要用内核提供的安全接口(copy_from_user / copy_to_user 等)。

设备节点(/dev)

  • 在 Linux 中,设备由设备节点表示(例如 /dev/ttyS0)。
  • 每个设备节点有 主设备号 (major)次设备号 (minor)。主号告诉内核使用哪个驱动,次号区分同驱动下的不同设备实例。

file_operations 与字符设备

驱动通过实现 struct file_operations(open/read/write/ioctl/mmap 等回调)与用户交互。内核在用户调用 open()/read() 时,会调用对应的驱动回调。

动态注册 vs 静态主设备号

  • 推荐用 alloc_chrdev_region 动态分配主设备号(避免冲突)。
  • 早期可用 register_chrdev(major, name, fops) 指定静态主号(不推荐)。

内核与用户空间通信方式概览

  • read / write + copy_to_user / copy_from_user:最常见的数据传输方式。
  • ioctl:控制命令接口(类似设备控制 API)。
  • mmap:将内核缓冲区映射到用户空间,适合大数据或高性能需求(零拷贝)。
  • sysfs:将设备属性暴露到 /sys,方便配置与读写小数据。
  • procfs:调试信息导出(/proc)。
  • netlink:复杂控制/事件通知,用户空间<->内核空间通讯。
  • 信号、轮询、异步通知(fasync)等:用于事件通知。

从零写一个最小、可运行的字符设备驱动(完整示例)

下面给出一个完整的可编译内核模块(字符设备),并配套用户态测试程序与 Makefile。代码已尽量注释以便小白理解。

驱动源码:mychardev.c

// mychardev.c
#include <linux/module.h>      // module init/exit macros
  #include <linux/init.h>
    #include <linux/fs.h>          // alloc_chrdev_region, struct file_operations
      #include <linux/cdev.h>        // cdev utilities
        #include <linux/uaccess.h>     // copy_to_user, copy_from_user
          #include <linux/slab.h>        // kmalloc, kfree
            #include <linux/device.h>      // class_create, device_create
              #define DEVICE_NAME "mychardev"   // /dev/mychardev
              #define BUF_SIZE    4096          // 内核缓冲区大小
              static dev_t dev_number;          // 保存分配到的设备号(包含主次号)
              static struct cdev my_cdev;       // cdev 结构
              static struct class *my_class;    // device class(用于自动创建设备节点)
              static char *kbuffer;             // 内核缓冲区
              static size_t data_len = 0;       // 缓冲区中有效数据长度
              // open 回调(每次用户 open() 时调用)
              static int my_open(struct inode *inode, struct file *file)
              {
              pr_info("mychardev: device opened\n");
              return 0;
              }
              // release/close 回调(每次用户 close() 时调用)
              static int my_release(struct inode *inode, struct file *file)
              {
              pr_info("mychardev: device closed\n");
              return 0;
              }
              // read 回调,将内核缓冲区数据拷贝到用户空间
              static ssize_t my_read(struct file *file, char __user *ubuf, size_t count, loff_t *ppos)
              {
              size_t available;
              size_t to_copy;
              int ret;
              // 如果偏移到文件末尾,返回0表示 EOF
              if (*ppos >= data_len)
              return 0;
              available = data_len - *ppos;
              to_copy = (count < available) ? count : available;
              // 从内核缓冲区复制到用户空间
              ret = copy_to_user(ubuf, kbuffer + *ppos, to_copy);
              if (ret != 0) { // ret 是未复制的字节数,非 0 表示失败
              pr_err("mychardev: copy_to_user failed, ret=%d\n", ret);
              return -EFAULT;
              }
              *ppos += to_copy; // 更新文件偏移
              pr_info("mychardev: read %zu bytes\n", to_copy);
              return to_copy;
              }
              // write 回调,将用户数据写入内核缓冲区(覆盖写)
              static ssize_t my_write(struct file *file, const char __user *ubuf, size_t count, loff_t *ppos)
              {
              int ret;
              size_t to_copy;
              // 限制写入长度(防止溢出)
              to_copy = (count < BUF_SIZE - 1) ? count : (BUF_SIZE - 1);
              // 将用户空间数据复制到内核缓冲区
              ret = copy_from_user(kbuffer, ubuf, to_copy);
              if (ret != 0) {
              pr_err("mychardev: copy_from_user failed, ret=%d\n", ret);
              return -EFAULT;
              }
              kbuffer[to_copy] = '\0'; // 保证以 '\0' 结尾,便于以字符串处理
              data_len = to_copy;      // 更新数据长度
              pr_info("mychardev: wrote %zu bytes\n", to_copy);
              return to_copy;
              }
              // 定义文件操作结构体,把我们的回调关联上来
              static const struct file_operations my_fops = {
              .owner = THIS_MODULE,
              .open = my_open,
              .release = my_release,
              .read = my_read,
              .write = my_write,
              };
              // module init:注册设备号、注册 cdev、创建类与设备节点、分配内存
              static int __init mychardev_init(void)
              {
              int ret;
              // 动态分配主设备号(0 表示内核分配)
              ret = alloc_chrdev_region(&dev_number, 0, 1, DEVICE_NAME);
              if (ret < 0) {
              pr_err("mychardev: alloc_chrdev_region failed\n");
              return ret;
              }
              pr_info("mychardev: alloc_chrdev_region ok, major=%d minor=%d\n",
              MAJOR(dev_number), MINOR(dev_number));
              // 初始化 cdev 并添加到内核
              cdev_init(&my_cdev, &my_fops);
              my_cdev.owner = THIS_MODULE;
              ret = cdev_add(&my_cdev, dev_number, 1);
              if (ret) {
              pr_err("mychardev: cdev_add failed\n");
              unregister_chrdev_region(dev_number, 1);
              return ret;
              }
              // 创建类,配合 udev 自动创建设备节点 /dev/mychardev
              my_class = class_create(THIS_MODULE, "mychardev_class");
              if (IS_ERR(my_class)) {
              pr_err("mychardev: class_create failed\n");
              cdev_del(&my_cdev);
              unregister_chrdev_region(dev_number, 1);
              return PTR_ERR(my_class);
              }
              device_create(my_class, NULL, dev_number, NULL, DEVICE_NAME);
              // 分配内核缓冲区
              kbuffer = kmalloc(BUF_SIZE, GFP_KERNEL);
              if (!kbuffer) {
              pr_err("mychardev: kmalloc failed\n");
              device_destroy(my_class, dev_number);
              class_destroy(my_class);
              cdev_del(&my_cdev);
              unregister_chrdev_region(dev_number, 1);
              return -ENOMEM;
              }
              data_len = 0;
              pr_info("mychardev: module loaded\n");
              return 0;
              }
              // module exit:释放资源
              static void __exit mychardev_exit(void)
              {
              kfree(kbuffer);
              device_destroy(my_class, dev_number);
              class_destroy(my_class);
              cdev_del(&my_cdev);
              unregister_chrdev_region(dev_number, 1);
              pr_info("mychardev: module unloaded\n");
              }
              MODULE_LICENSE("GPL");
              MODULE_AUTHOR("示例教程");
              MODULE_DESCRIPTION("简单字符设备驱动示例");
              module_init(mychardev_init);
              module_exit(mychardev_exit);

说明(高层):这个模块分配了设备号、注册了 cdev,创建了 /dev/mychardev(如果系统上有 udev 会自动创建),并分配了一个 4KB 的内核缓冲区用于 read/writewrite 会覆盖缓冲区,read 从缓冲区读取。


Makefile(用于编译内核模块)

# Makefile
obj-m += mychardev.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

用户态测试程序:test.c

// test.c - 用于测试 /dev/mychardev
#include <stdio.h>
  #include <fcntl.h>
    #include <unistd.h>
      #include <string.h>
        #include <errno.h>
          int main(void)
          {
          int fd = open("/dev/mychardev", O_RDWR);
          if (fd < 0) {
          perror("open");
          return 1;
          }
          const char *msg = "Hello from user space!\n";
          ssize_t w = write(fd, msg, strlen(msg));
          if (w < 0) {
          perror("write");
          close(fd);
          return 1;
          }
          printf("wrote %zd bytes\n", w);
          // 从头开始读
          lseek(fd, 0, SEEK_SET);
          char buf[256];
          ssize_t r = read(fd, buf, sizeof(buf) - 1);
          if (r < 0) {
          perror("read");
          } else {
          buf[r] = '\0';
          printf("read %zd bytes: %s", r, buf);
          }
          close(fd);
          return 0;
          }

编译、加载、测试步骤(逐条可复制执行)

  1. mychardev.cMakefile 放到一个目录,运行:

    make
  2. 加载模块(需要 root):

    sudo insmod mychardev.ko
  3. 查看 dmesg(查看模块输出与分配到的主设备号):

    dmesg | tail -n 20
    # 你会看到类似:
    # mychardev: alloc_chrdev_region ok, major=249 minor=0
    # mychardev: module loaded
  4. 查看设备节点(如果 udev 自动创建):

    ls -l /dev/mychardev
    # 如果没有自动创建(较老系统),可以手动:
    # sudo mknod /dev/mychardev c <major> 0
      # sudo chmod 666 /dev/mychardev

    如果需要手动 mknod,用 dmesg 中的 major 替换 <major>

  5. 编译用户程序并运行:

    gcc -o test test.c
    sudo ./test
    # 期望输出:
    # wrote 21 bytes
    # read 21 bytes: Hello from user space!
  6. 卸载模块并清理:

    sudo rmmod mychardev
    dmesg | tail -n 10
    make clean

代码逐步讲解(重要点,面向小白)

  • alloc_chrdev_region(&dev_number, 0, 1, DEVICE_NAME);

    • 动态分配一个设备号,保存到 dev_number(包含主/次号)。1 表示注册 1 个连续设备编号。
  • cdev_init(&my_cdev, &my_fops); cdev_add(&my_cdev, dev_number, 1);

    • 初始化 cdev 结构并把它添加到内核中,使内核知道当访问该设备号时要调用哪个 file_operations
  • class_createdevice_create

    • 通过 sysfs / udev 创建 /dev 下的设备节点(如果系统运行 udev,会自动创建设备节点)。这样用户侧就可以通过 /dev/mychardev 打开驱动。
  • kmalloc(BUF_SIZE, GFP_KERNEL)

    • 在内核中分配一段内存作为缓冲区。GFP_KERNEL 表示可以睡眠等待内存分配(通常在进程上下文使用)。
  • copy_from_user / copy_to_user

    • 内核与用户数据拷贝的安全接口:永远不要直接访问用户指针,必须通过这些 API。
    • 这些函数返回未成功复制的字节数(0 表示成功)。检查返回值非常重要。
  • pr_info / pr_err

    • 内核日志打印(等价于 printk),便于 dmesg 查看调试信息。
  • 清理顺序:

    • 释放内存 kfreedevice_destroyclass_destroycdev_delunregister_chrdev_region

常见错误与排查(小白最容易踩的坑)

  • 直接使用用户指针:不要 char *p = (char *)ubuf; 然后读写 p —— 这样会导致内核 oops。必须使用 copy_from_user / copy_to_user
  • 忘记释放资源kmalloccdev_addalloc_chrdev_region 等出错后要做回滚清理,否则模块卸载或重复加载会报错。
  • 阻塞与中断上下文混用:在中断上下文不能睡眠、不能使用 GFP_KERNEL 分配内存,也不能拿 mutex。中断处理要用 spin_lock 或在工作队列中做耗时工作。
  • 权限问题/dev/mychardev 默认权限可能不能写。测试时使用 chmod 666 /dev/mychardev(仅测试用,生产不要这样)。
  • 没有 udev / device node 不存在:如果系统没有自动创建设备节点,需要 mknod 手工创建设备节点。
  • 没有检查 copy_ 返回值*:必须检查,否则读取不完全或出错时不会发现。

内存分配与 I/O 映射(常见函数对比)

  • kmalloc(size, GFP_KERNEL):分配内核连续的虚拟内存(物理上可能不连续),用于小块内存(几 KB ~ 数百 KB)。
  • vmalloc(size):分配虚拟连续但物理不连续的大块内存(用于很大内存)。
  • ioremap(phys, size):把设备物理寄存器地址映射到内核虚拟地址,供 CPU 读写外设寄存器。
  • dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL):分配用于 DMA 的内存(既保证物理连续又可映射到设备)。用于驱动需要 DMA 的场景。

中断处理(简介,配合设备需要了解)

  • 注册中断:

    ret = request_irq(irq_number, my_isr, IRQF_SHARED, "mydev_irq", dev_id);
  • 中断处理函数(ISR)示例:

    irqreturn_t my_isr(int irq, void *dev_id) {
    // 快速处理:读取寄存器、清中断标志
    // 如果需要较长处理,使用 tasklet / workqueue
    return IRQ_HANDLED;
    }
  • 卸载中断:

    free_irq(irq_number, dev_id);

注意:ISR 中不能睡眠,不能调用会睡的 API(例如 kmallocGFP_KERNEL 可能睡,尽量使用 GFP_ATOMIC,并尽量少做重工作)。


并发/同步(核心概念,小白常迷糊)

  • mutex:用于可睡眠上下文(普通进程上下文),当持有者做 I/O 或会休眠时选择 mutex。
  • spinlock:用于中断或不可睡眠上下文,获取锁不会睡眠(自旋),持有时间要尽量短。
  • atomic_t:对单一整数的原子操作(计数器等)。
  • semaphoresrw_semaphore:更复杂的同步原语。

原则:在 IRQ 或死忙场景不能睡眠时用 spinlock;在可以睡眠时用 mutex。


调试技巧(必会)

  • dmesg -w:实时查看内核日志,打印 pr_info/printk 输出。
  • modinfo mychardev:查看模块信息。
  • lsmod | grep mychardev:查看模块是否存在。
  • cat /proc/devices:列出字符/块设备主设备号映射。
  • strace ./userprog:调试用户程序系统调用。
  • echo 1 > /sys/kernel/debug/dynamic_debug/controlpr_debug:用于动态调试(需要开启内核支持)。
  • 若系统挂了(kernel panic),建议在虚拟机中实验(如 QEMU 或 VMware),避免主机被影响。

安全与最佳实践(写驱动的约定俗成)

  • 总是检查返回值(每个内核 API 都可能失败)。
  • 出错时按申请资源的逆序释放。
  • 不要在中断上下文睡眠;不要在可睡眠上下文使用 spin_lock 导致死锁。
  • 使用 dev_*ptrIS_ERR() 等内核辅助宏规范化代码。
  • 测试环境请使用虚拟机会更安全,避免主机宕机损失。

推荐学习路线与资料(按难度递进)

  • 从基础读:Linux 内核模块编程入门教程(网上大量入门文章与示例)。

  • 书籍(经典):

    • 《Linux Device Drivers》(LDD,第三版)—— 理论 + 示例(虽然有点旧,但基础概念极好)
    • 《Linux Kernel Development》—— 更深入内核机制
  • 在线资料:

    • kernel.org 的 Documentation(驱动相关章节)
    • Kernelnewbies(面向新手的解释)
    • 各种博客与 CSDN 教程(动手实验为主)
  • 实践建议:在虚拟机上练习,从 char driver → sysfs → mmap → irq → DMA 逐步深入。


小练习(建议做 5 个小练手项目)

  • 改造示例驱动,使写入数据追加(不覆盖)并支持文件偏移。
  • 添加 ioctl 实现设备控制命令(比如获取/设置缓冲区大小)。
  • 实现 mmap,把内核缓冲区映射给用户程序,比较性能差异(有无拷贝)。
  • 在驱动中添加一个 sysfs 属性(device_create_file),在用户空间通过 echo/ cat 操作查看与设置。
  • 为虚拟设备实现简单中断模拟(使用 tasklet / workqueue 完成较耗时工作)。

总结(简明版)

  • 驱动程序是用户空间与硬件的桥梁,写驱动要注意安全、同步、资源管理
  • 学会 alloc_chrdev_regioncdevfile_operationscopy_from_user/copy_to_user 是写字符设备驱动的入门要点。
  • 通过上面的完整示例,你能搭建起一个能被用户程序 open/read/write 的内核模块,并掌握编译/加载/测试流程。
  • 多看 dmesg、一步步释放资源、在虚拟机中反复实践,是成为合格驱动工程师的正确路径。

posted on 2025-10-04 17:13  ljbguanli  阅读(3)  评论(0)    收藏  举报