阳江市网站建设_网站建设公司_RESTful_seo优化
2025/12/29 1:36:52 网站建设 项目流程

从零开始写一个字符设备驱动:手把手带你走进内核开发大门

你有没有试过在 Linux 系统中读写/dev目录下的某个设备文件?比如用echo "hello" > /dev/ttyS0向串口发数据,或者通过/dev/input/event0获取键盘输入。这些看似普通的“文件”,其实背后连接的是真实的硬件——而让这一切成为可能的,正是设备驱动程序

对于刚接触内核开发的新手来说,第一个挑战往往不是复杂的中断或DMA,而是:如何让我的模块被系统识别为一个可读写的设备?

本文就以最基础也最重要的字符设备驱动为例,带你一步步实现从“空模块”到“可在用户空间操作的设备”的全过程。我们不堆术语、不讲虚概念,只聚焦一件事:把注册流程讲清楚,让你真正动手能跑起来。


字符设备到底是什么?

简单说,字符设备就是按字节流方式访问的设备。它不像块设备那样支持随机读写(如硬盘),而是像管道一样,一端写入、另一端顺序读出。

常见的字符设备包括:
- 串口(UART)
- 键盘、鼠标
- GPIO 控制接口
- 自定义的传感器驱动
- 调试用的虚拟设备

在 Linux 中,每个字符设备都有一个对应的主设备号 + 次设备号,并且会映射到/dev下的一个节点文件,比如/dev/mychardev。当用户程序调用open()read()write()时,内核就会找到这个设备,并执行你事先注册好的函数。

我们的目标是:写一个模块,加载后自动创建/dev/mychardev,并能实现基本的数据收发。


核心结构体:cdev到底怎么用?

要注册一个字符设备,绕不开的核心结构就是struct cdev。它是内核用来管理字符设备的“身份证”。

#include <linux/cdev.h> struct cdev { struct kobject kobj; struct module *owner; // 所属模块 const struct file_operations *ops; // 操作函数集合 dev_t dev; // 设备号 unsigned int count; // 设备数量 };

你可以把它理解为一个“设备容器”:
-owner告诉内核这个设备属于哪个模块(防止模块被卸载);
-ops是一组函数指针,定义了openreadwrite等行为;
-dev是设备号,相当于设备的唯一 ID;
-count表示连续注册几个设备(通常为1);

整个注册过程可以归纳为四个步骤:

  1. 申请设备号
  2. 初始化cdev并绑定操作函数
  3. cdev添加到内核
  4. 创建/dev下的设备节点

下面我们逐个拆解。


第一步:动态分配设备号 —— 别再硬编码了!

Linux 使用dev_t类型表示设备号,它是一个 32 位整数,高 12 位是主设备号(major),低 20 位是次设备号(minor)。主设备号标识设备类型,次设备号区分同类型的多个实例。

小知识:主设备号 4 是 tty,5 是 ttyS(串口),1 是内存设备(/dev/mem)

传统做法是静态指定主设备号,比如:

register_chrdev_region(MKDEV(200, 0), 1, "mydev");

但这样很容易冲突——万一别人也在用 200 号呢?

推荐做法:动态分配

static dev_t dev_num; alloc_chrdev_region(&dev_num, 0, 1, "mychardev");

这行代码的意思是:
- 让内核自动选一个可用的主设备号;
- 次设备号从 0 开始;
- 注册 1 个设备;
- 名字叫"mychardev"(出现在/proc/devices中);

成功后,dev_num就会被填入实际分配的设备号。你可以用MAJOR(dev_num)MINOR(dev_num)提取主次号。

⚠️ 注意:如果失败必须及时释放资源!否则会造成设备号泄漏。


第二步:设置文件操作接口 —— 用户能做什么由你定

所有对设备的操作最终都会落到file_operations结构体上。这是你和用户空间交互的“契约”。

static const struct file_operations fops = { .owner = THIS_MODULE, .open = my_open, .release = my_release, .read = my_read, .write = my_write, };

这几个函数的作用如下:

函数触发时机典型用途
.openopen()系统调用初始化设备状态、计数器加一
.releaseclose()系统调用清理资源、引用计数减一
.readread()系统调用把数据从内核传给用户
.writewrite()系统调用接收用户发来的数据

⚠️ 特别注意:这些函数运行在内核态,但接收的缓冲区指针来自用户空间,不能直接访问


第三步:注册cdev—— 把设备交给内核管理

有了设备号和操作函数,就可以组装cdev并注册了:

static struct cdev my_cdev; // 初始化 cdev_init(&my_cdev, &fops); my_cdev.owner = THIS_MODULE; // 添加到内核 cdev_add(&my_cdev, dev_num, 1);

这里有两个关键点:

  1. cdev_init()fops绑定到cdev
  2. cdev_add()告诉内核:“我现在有一个设备,设备号是dev_num,长度为 1(即只有一个设备)”。

如果失败(返回非0),一定要调用unregister_chrdev_region()回收设备号,避免下次加载失败。


第四步:自动生成/dev/mychardev—— 告别手动 mknod

很多人卡在这一步:明明注册成功了,为什么/dev/mychardev没出现?

因为cdev_add()只完成了内核侧注册,并不会自动创建设备文件。你需要借助udev + sysfs机制来触发节点生成。

方法是使用class_createdevice_create

static struct class *my_class; static struct device *my_device; // 创建设备类(会在 /sys/class 下生成目录) my_class = class_create(THIS_MODULE, "myclass"); // 创建设备节点(触发 udev 创建 /dev/mychardev) my_device = device_create(my_class, NULL, dev_num, NULL, "mychardev");

这两步完成后:
-/sys/class/myclass/mychardev出现;
- udev 监听到事件,自动创建/dev/mychardev
- 权限默认为 664,组为 dialout;

💡 提示:如果不希望暴露太多属性,也可以不用 class,改用手动mknod,但在生产环境中不推荐。


完整代码实战:一个可运行的模板

下面是你可以直接编译测试的完整驱动代码:

#include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/uaccess.h> #include <linux/device.h> #include <linux/slab.h> #define DEVICE_NAME "mychardev" #define CLASS_NAME "myclass" static dev_t dev_num; static struct cdev my_cdev; static struct class *my_class; static struct device *my_device; static char *kernel_buffer; #define BUFFER_SIZE 1024 // open: 设备打开时调用 static int my_open(struct inode *inode, struct file *file) { printk(KERN_INFO "Device opened\n"); return 0; } // release: 设备关闭时调用 static int my_release(struct inode *inode, struct file *file) { printk(KERN_INFO "Device closed\n"); return 0; } // read: 向用户返回数据 static ssize_t my_read(struct file *file, char __user *buf, size_t len, loff_t *offset) { int ret; if (*offset >= BUFFER_SIZE) return 0; // EOF if (copy_to_user(buf, kernel_buffer + *offset, len)) return -EFAULT; *offset += len; return len; } // write: 接收用户数据 static ssize_t my_write(struct file *file, const char __user *buf, size_t len, loff_t *offset) { if (*offset + len > BUFFER_SIZE) return -ENOSPC; if (copy_from_user(kernel_buffer + *offset, buf, len)) return -EFAULT; *offset += len; kernel_buffer[*offset] = '\0'; // 保证字符串结尾 printk(KERN_INFO "Received: %s\n", kernel_buffer); return len; } // 模块初始化 static int __init char_driver_init(void) { int ret; // 1. 分配设备号 ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret) { printk(KERN_ERR "Failed to allocate device number\n"); return ret; } // 2. 初始化 cdev cdev_init(&my_cdev, &fops); my_cdev.owner = THIS_MODULE; ret = cdev_add(&my_cdev, dev_num, 1); if (ret) { printk(KERN_ERR "Failed to add cdev\n"); unregister_chrdev_region(dev_num, 1); return ret; } // 3. 创建设备类和设备节点 my_class = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(my_class)) { cdev_del(&my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_class); } my_device = device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(my_device)) { class_destroy(my_class); cdev_del(&my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_device); } // 4. 分配内核缓冲区 kernel_buffer = kzalloc(BUFFER_SIZE, GFP_KERNEL); if (!kernel_buffer) { device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(&my_cdev); unregister_chrdev_region(dev_num, 1); return -ENOMEM; } printk(KERN_INFO "Character device '%s' registered, major=%d\n", DEVICE_NAME, MAJOR(dev_num)); return 0; } // 模块退出 static void __exit char_driver_exit(void) { kfree(kernel_buffer); device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(&my_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO "Character device unregistered\n"); } // 文件操作结构 static const struct file_operations fops = { .owner = THIS_MODULE, .open = my_open, .release = my_release, .read = my_read, .write = my_write, }; module_init(char_driver_init); module_exit(char_driver_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple character device driver"); MODULE_VERSION("1.0");

编译与测试:让它跑起来!

新建一个Makefile

obj-m += mychardev.o KDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean install: sudo insmod mychardev.ko remove: sudo rmmod mychardev

然后依次执行:

make sudo insmod mychardev.ko dmesg | tail

你应该看到类似输出:

[ 1234.567890] Character device 'mychardev' registered, major=240

再检查:

ls /dev/mychardev cat /proc/devices | grep mychardev

最后试试读写:

echo "Hello Kernel" > /dev/mychardev cat /dev/mychardev

查看内核日志确认是否收到数据:

dmesg | tail

新手常踩的坑与避坑指南

❌ 问题1:卸载模块时报 “Device or resource busy”

原因:还有进程正在打开该设备(比如 shell 脚本没退出,或忘记close())。

✅ 解决方案:
- 确保所有测试程序已结束;
- 在.open中增加引用计数,在.release中判断是否允许卸载;
- 或重启后再卸载。


❌ 问题2:write()返回-EFAULT

原因:你在.write函数里直接用了*buf,试图访问用户空间地址。

✅ 正确做法:必须使用copy_from_user()

if (copy_from_user(kernel_buf, user_buf, len)) { return -EFAULT; // 复制失败,可能是无效指针 }

同理,read必须用copy_to_user()


❌ 问题3:/dev/mychardev没有自动生成

原因:缺少class_createdevice_create

✅ 解决方案:确保两步都完成,且没有错误返回。

提示:可以用sudo mdev -s或重启 udev 强制刷新设备节点。


❌ 问题4:多次加载模块导致设备号冲突

原因:上次卸载时没有正确释放资源。

✅ 防御性编程建议:
- 所有资源分配都要有对应的释放路径;
- 使用 goto 统一错误处理(进阶技巧);
- 加载前先rmmod一次。


进阶思考:这个驱动还能怎么改进?

虽然我们现在实现了基本功能,但离工业级驱动还有距离。你可以继续优化:

  1. 添加互斥锁:防止多进程同时读写造成数据混乱;
    c static DEFINE_MUTEX(device_mutex);
  2. 支持 ioctl:扩展控制命令;
  3. 加入等待队列:实现阻塞式读写;
  4. 使用 miscdevice:简化注册流程(适合单设备场景);
  5. 对接设备树:用于真实硬件匹配;

这些内容我们后续可以单独展开。


写在最后:你已经迈出了关键一步

恭喜你!当你成功用echo向自己写的驱动发送第一条消息时,你就已经越过了内核开发最大的心理门槛。

字符设备注册机制看似繁琐,实则逻辑清晰:
-设备号 → 唯一身份
-cdev → 内核登记簿
-file_operations → 用户接口
-class/device → 自动化节点管理

掌握这套流程,不仅是写一个 LED 驱动的基础,更是深入 platform driver、I2C/SPI 子系统、中断处理等高级主题的起点。

下一步,不妨尝试:
- 改造驱动支持多个次设备号;
- 连接真实的 GPIO 控制 LED;
- 实现一个简单的环形缓冲区用于异步通信;

如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。驱动开发的路上,我们一起前行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询