前言
嵌入式Linux开发中,字符设备驱动是最基础、最常见的驱动类型。韦东山老师的课程从这一框架切入,让我们快速建立对Linux驱动模型的认知。本文基于课程思路,整理出一个完整、可编译运行 的字符设备驱动框架,并带你一步步理解 cdev、file_operations、设备号自动分配、自动创建设备节点等核心知识点。
读完这篇文章,你将收获:
- 一个真正能
insmod后生成/dev节点的驱动模板 - 对字符设备驱动注册/注销流程的透彻理解
- 可直接复用的代码和 Makefile

一、字符设备驱动核心概念
在写代码之前,先梳理三个关键的数据结构和概念。
1.1 设备号(dev_t)
Linux 内核通过 主设备号(major) 和 次设备号(minor) 来唯一标识一个设备。
c
typedef u32 dev_t; // 高12位主设备号,低20位次设备号
- 静态分配:
register_chrdev_region(),需手动指定主设备号(可能冲突) - 动态分配:
alloc_chrdev_region(),由内核分配一个未使用的主设备号(推荐)
本驱动采用动态分配,避免手选设备号带来的冲突问题。
1.2 cdev 结构体
c
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
};
cdev 是字符设备的抽象,内核用它将设备号和文件操作 file_operations 绑定在一起。
1.3 file_operations 结构体
这是驱动与用户程序的接口 。每一个系统调用(open、read、write 等)都会对应到这个结构体中的某个函数指针。
c
struct file_operations {
struct module *owner;
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
// ...还有很多其他成员
};
我们首先实现 open、release、read、write 四个最基本的接口。
二、驱动设计思路
整个驱动的生命周期可以概括为:
-
模块加载:
- 动态申请一个可用的主设备号
- 初始化
cdev,绑定file_operations - 向内核注册
cdev - 创建
/sys/class/下的类(为了自动创建/dev/节点) - 根据类创建设备节点
/dev/chrdev_test
-
用户访问:
- 用户程序调用
open("/dev/chrdev_test", ...)→ 触发chrdev_open read/write触发对应函数(本示例仅打印内核日志)
- 用户程序调用
-
模块卸载:
- 严格按照"先创建的后销毁"顺序:
- 销毁
/dev节点 - 销毁 class
- 删除
cdev - 释放设备号
- 销毁
- 严格按照"先创建的后销毁"顺序:
三、代码实现与详细解析
3.1 头文件和全局变量
c
#include <linux/module.h>
#include <linux/fs.h> // file_operations, alloc_chrdev_region...
#include <linux/cdev.h> // cdev_init, cdev_add...
#include <linux/device.h> // class_create, device_create
#include <linux/uaccess.h> // copy_to_user, copy_from_user (备用)
#include <linux/slab.h> // kmalloc, kfree (备用)
#define DEVICE_NAME "chrdev_test" // 设备名称,最终出现在 /dev/chrdev_test
#define CLASS_NAME "chrdev_class" // 在 /sys/class/ 下创建的同名类
static dev_t dev_num; // 设备号
static struct cdev my_cdev; // 字符设备
static struct class *my_class; // 设备类指针
static struct device *my_device; // 设备指针
dev_num保存动态分配的设备号,MAJOR(dev_num)获取主设备号。my_class和my_device用于自动创建设备节点,这样就不用自己mknod。
3.2 文件操作函数实现
这四个函数目前只是打印日志,不操作实际硬件或数据,目的是验证框架能跑通。
c
static int chrdev_open(struct inode *inode, struct file *file)
{
pr_info("chrdev: device opened\n");
return 0;
}
static int chrdev_release(struct inode *inode, struct file *file)
{
pr_info("chrdev: device closed\n");
return 0;
}
static ssize_t chrdev_read(struct file *file, char __user *buf,
size_t count, loff_t *f_pos)
{
pr_info("chrdev: read called, count = %zu\n", count);
return 0; // 返回0表示文件末尾(EOF)
}
static ssize_t chrdev_write(struct file *file, const char __user *buf,
size_t count, loff_t *f_pos)
{
pr_info("chrdev: write called, count = %zu\n", count);
return count; // 假装全部写入
}
- 返回值含义:
chrdev_read返回 0 会让cat认为文件已结束; chrdev_write返回count告诉上层所有数据都已"写入"。
3.3 绑定 file_operations 结构体
c
static struct file_operations chrdev_fops = {
.owner = THIS_MODULE,
.open = chrdev_open,
.release = chrdev_release,
.read = chrdev_read,
.write = chrdev_write,
};
.owner = THIS_MODULE 用于防止模块在使用中被卸载,是必须的。
3.4 模块初始化函数(重点)
c
static int __init chrdev_init(void)
{
int ret;
// 1. 动态申请设备号(主设备号可能为0,由内核分配)
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) {
pr_err("chrdev: failed to allocate device number\n");
return ret;
}
pr_info("chrdev: allocated major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
// 2. 初始化 cdev 并添加到内核
cdev_init(&my_cdev, &chrdev_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret) {
pr_err("chrdev: cdev_add failed\n");
goto err_cdev_add;
}
// 3. 创建 class(自动设备节点需要)
my_class = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(my_class)) {
pr_err("chrdev: class_create failed\n");
ret = PTR_ERR(my_class);
goto err_class_create;
}
// 4. 在 /dev/ 下创建设备节点
my_device = device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);
if (IS_ERR(my_device)) {
pr_err("chrdev: device_create failed\n");
ret = PTR_ERR(my_device);
goto err_device_create;
}
pr_info("chrdev: module loaded successfully, /dev/%s created\n", DEVICE_NAME);
return 0;
// 错误回滚路径(goto链式处理)
err_device_create:
class_destroy(my_class);
err_class_create:
cdev_del(&my_cdev);
err_cdev_add:
unregister_chrdev_region(dev_num, 1);
return ret;
}
关键解释:
alloc_chrdev_region:次设备号从 0 开始,连续申请 1 个。成功后dev_num会被填上分配的主次设备号。cdev_init将file_operations与cdev绑定。cdev_add告诉内核该cdev代表的设备号范围,只有调用后设备才真正可用。- 创建 class 和 device 是借助 udev 机制,加载驱动后会自动在
/dev下生成设备文件,无需手动mknod。 - 错误处理使用了经典的 goto 链式回滚,确保中间任何一步出错都会正确清理已申请的资源。
3.5 模块卸载函数
c
static void __exit chrdev_exit(void)
{
// 严格遵守"先创建的后销毁"
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("chrdev: module unloaded\n");
}
3.6 完整的驱动代码(可直接复制)
将上面的片段合并到 chrdev_base.c 中(再加上模块入口/出口宏):
c
/*
* chrdev_base.c
* 一个完整的字符设备驱动框架,加载后自动生成 /dev/chrdev_test
* 可直接编译运行,适用于 Linux 5.x(4.x 也可)
* 作者:[你的ID]
*/
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/slab.h>
#define DEVICE_NAME "chrdev_test"
#define CLASS_NAME "chrdev_class"
static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;
static struct device *my_device;
static int chrdev_open(struct inode *inode, struct file *file)
{
pr_info("chrdev: device opened\n");
return 0;
}
static int chrdev_release(struct inode *inode, struct file *file)
{
pr_info("chrdev: device closed\n");
return 0;
}
static ssize_t chrdev_read(struct file *file, char __user *buf,
size_t count, loff_t *f_pos)
{
pr_info("chrdev: read called, count = %zu\n", count);
return 0;
}
static ssize_t chrdev_write(struct file *file, const char __user *buf,
size_t count, loff_t *f_pos)
{
pr_info("chrdev: write called, count = %zu\n", count);
return count;
}
static struct file_operations chrdev_fops = {
.owner = THIS_MODULE,
.open = chrdev_open,
.release = chrdev_release,
.read = chrdev_read,
.write = chrdev_write,
};
static int __init chrdev_init(void)
{
int ret;
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) {
pr_err("chrdev: failed to allocate device number\n");
return ret;
}
pr_info("chrdev: allocated major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
cdev_init(&my_cdev, &chrdev_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret) {
pr_err("chrdev: cdev_add failed\n");
goto err_cdev_add;
}
my_class = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(my_class)) {
pr_err("chrdev: class_create failed\n");
ret = PTR_ERR(my_class);
goto err_class_create;
}
my_device = device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);
if (IS_ERR(my_device)) {
pr_err("chrdev: device_create failed\n");
ret = PTR_ERR(my_device);
goto err_device_create;
}
pr_info("chrdev: module loaded successfully, /dev/%s created\n", DEVICE_NAME);
return 0;
err_device_create:
class_destroy(my_class);
err_class_create:
cdev_del(&my_cdev);
err_cdev_add:
unregister_chrdev_region(dev_num, 1);
return ret;
}
static void __exit chrdev_exit(void)
{
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("chrdev: module unloaded\n");
}
module_init(chrdev_init);
module_exit(chrdev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A complete character device driver template");
MODULE_VERSION("1.0");
四、编写 Makefile
在内核模块开发中,Makefile 的写法非常固定,只需指定内核源码路径即可。这里提供一份可直接在 x86 Ubuntu 或 树莓派 上使用的 Makefile。
makefile
# Makefile for chrdev_base
# 内核源码目录(使用当前运行内核的构建目录)
KERNEL_DIR := /lib/modules/$(shell uname -r)/build
# 当前目录
PWD := $(shell pwd)
# 编译目标
obj-m := chrdev_base.o
all:
make -C $(KERNEL_DIR) M=$(PWD) modules
clean:
make -C $(KERNEL_DIR) M=$(PWD) clean
交叉编译说明(编译给 ARM 开发板用):
-
将
KERNEL_DIR修改为开发板内核源码的路径(已配置好的) -
并设置环境变量,例如:
bashexport ARCH=arm export CROSS_COMPILE=arm-linux-gnueabihf- make
五、编译与测试
5.1 编译
将 chrdev_base.c 和 Makefile 放在同一目录下,执行:
bash
make
成功后目录下会生成 chrdev_base.ko。
5.2 加载驱动
bash
sudo insmod chrdev_base.ko
查看内核输出:
bash
dmesg | tail -n 5
预期输出类似:
chrdev: allocated major=239, minor=0
chrdev: module loaded successfully, /dev/chrdev_test created
检查设备节点是否生成:
bash
ls -l /dev/chrdev_test
预期看到:
crw------- 1 root root 239, 0 May 27 10:00 /dev/chrdev_test
其中 239 是你这次得到的主设备号(每次可能不同)。
5.3 测试设备
用 cat 读取设备,会触发 open → read → release:
bash
sudo cat /dev/chrdev_test
再用 dmesg 查看:
chrdev: device opened
chrdev: read called, count = 131072
chrdev: device closed
用 echo 写入:
bash
sudo sh -c "echo 'hello' > /dev/chrdev_test"
日志中会增加:
chrdev: device opened
chrdev: write called, count = 6
chrdev: device closed
如果普通用户无法访问,可用 sudo chmod 666 /dev/chrdev_test 临时赋权。
5.4 卸载驱动
bash
sudo rmmod chrdev_base
dmesg 会显示:
chrdev: module unloaded
此时 /dev/chrdev_test 会自动消失。
六、常见问题与解决方法
-
insmod 报错:
Required key not available原因:当前系统开启了 Secure Boot,且模块未签名。
解决:暂时关闭 Secure Boot,或用
mokutil --disable-validation禁用验证后重启。 -
insmod 报错:
Unknown symbol in module原因:可能是内核版本不匹配,或依赖的其他模块未加载。
解决:确认编译时的内核头版本与运行内核一致(
uname -r)。 -
加载成功但没有
/dev/chrdev_test- 检查
dmesg是否有device_create失败的 log。 - 确认系统有 udev 且运行正常(
ps aux | grep udev)。
- 检查
-
设备节点权限不足
解决方法:编写 udev 规则,或在测试时直接
chmod。
七、总结与下篇预告
本文从零搭建了一个标准的字符设备驱动框架 ,涵盖了设备号申请、cdev 注册、自动节点创建等全部流程,加载后即可在 /dev 下看到设备,并能通过最简的系统调用触发内核打印。
这个框架是后续一切字符驱动开发的基石。下一篇文章,我们将:
- 在
read/write中实现内核空间与用户空间的数据拷贝 (copy_to_user/copy_from_user) - 演示一个简单的内存缓冲区设备,真正做到"打开、读写、关闭"一个字符设备。
参考与推荐
- 韦东山《嵌入式Linux应用开发完全手册》
- Linux 内核文档:
Documentation/driver-model/
如果本文对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续输出的动力!有任何疑问也欢迎在评论区留言交流~