在 Linux 驱动的广阔世界里,字符设备(Character Device) 可以说是最基础、也最常见的“用户可交互”接口。你很可能在日常使用中接触过它们,例如:
/dev/ttyS0 (串口)
/dev/input/event1 (输入设备)
/dev/gpiochip0 (GPIO控制器)
这些设备节点的共同特征是:都以 /dev/xxx 的文件形式存在;用户程序可以通过标准的 open()、read()、write()、ioctl() 等系统调用来操作它们。本质上,它们都是由字符设备驱动在背后提供支持。
本文我们将一起动手,从零编写一个完整的字符设备驱动,打通从用户态(User Space) 到内核态(Kernel Space) 的交互全链路。
字符设备架构解析
简单来说,字符设备是一种以字节流方式进行读写的设备。它与块设备(例如硬盘)不同,没有固定的块大小限制,访问通常是按字节顺序进行的。
交互架构
为了清晰地理解用户程序的一条 write 指令是如何一步步传递到底层驱动的,我们来看下面这张交互架构图:

上图清晰地展示了数据从用户应用程序,经过 C 库和系统调用接口,穿越虚拟文件系统(VFS),最终由我们的驱动函数处理的完整路径。理解这个流程,是掌握驱动开发的关键。
驱动开发步骤
开发一个基础的字符设备驱动,可以概括为以下四个核心步骤:
| 步骤 |
关键动作 |
作用 |
| 1. 分配设备号 |
alloc_chrdev_region |
向内核申请合法的主设备号(Major)和次设备号(Minor) |
| 2. 初始化 cdev |
cdev_init |
初始化核心的 cdev 结构体,并绑定具体的操作函数集 |
| 3. 实现接口 |
file_operations |
实现 open、read、write 等函数,定义驱动的具体行为 |
| 4. 注册设备 |
cdev_add & device_create |
将驱动注册到内核,并自动在 /dev 目录下创建设备节点 |
代码实现
接下来,我们实战编写一个名为 /dev/hello_chrdev 的简易“回显”驱动。它的功能很简单:用户写入什么数据,读取时就会原样返回什么。
驱动初始化流程
在深入代码细节之前,我们先通过一张流程图来理清驱动初始化函数(init)的核心执行流与错误处理逻辑:

这个流程为我们编写健壮的初始化代码提供了清晰的蓝图。
完整代码实现
头文件与全局变量
首先,我们需要包含必要的内核头文件,并定义一些全局变量。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h> // 包含 copy_to/from_user
#define DEVICE_NAME "hello_chrdev"
static dev_t dev_num; // 存放设备号
static struct cdev hello_cdev; // 字符设备核心结构
static struct class *hello_class; // 用于自动创建设备节点
static char kernel_buffer[128]; // 内核侧数据缓冲区
file_operations 接口实现
file_operations 结构体是字符设备驱动的“灵魂”。它定义了当用户层进行文件操作时,内核应该执行的具体函数。
// 对应用户层的 open()
static int hello_open(struct inode *inode, struct file *file)
{
pr_info("hello_chrdev: device opened\n");
return 0;
}
// 对应用户层的 read()
static ssize_t hello_read(struct file *file, char __user *buf, size_t len, loff_t *offset)
{
// 使用内核帮助函数,自动处理偏移量和缓冲区边界
return simple_read_from_buffer(buf, len, offset, kernel_buffer, strlen(kernel_buffer));
}
// 对应用户层的 write()
static ssize_t hello_write(struct file *file, const char __user *buf, size_t len, loff_t *offset)
{
size_t to_copy = min(len, sizeof(kernel_buffer) - 1);
// 关键:必须使用 copy_from_user 安全地从用户空间拷贝数据
if (copy_from_user(kernel_buffer, buf, to_copy))
return -EFAULT;
kernel_buffer[to_copy] = '\0'; // 确保字符串结束符
pr_info("hello_chrdev: received \"%s\"\n", kernel_buffer);
return to_copy;
}
驱动入口与出口
这里是驱动的模块初始化(加载)和清理(卸载)函数。
// 定义操作函数集
static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.open = hello_open,
.read = hello_read,
.write = hello_write,
};
static int __init hello_init(void)
{
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0)
return ret;
// 2. 初始化 cdev 并与 fops 绑定
cdev_init(&hello_cdev, &hello_fops);
// 3. 添加 cdev 到内核
ret = cdev_add(&hello_cdev, dev_num, 1);
if (ret < 0)
return ret;
// 4. 自动创建设备节点 /dev/hello_chrdev
// 先创建类
hello_class = class_create(THIS_MODULE, "hello_class");
if (IS_ERR(hello_class))
return PTR_ERR(hello_class);
// 再创建设备
device_create(hello_class, NULL, dev_num, NULL, DEVICE_NAME);
pr_info("hello_chrdev: initialized successfully\n");
return 0;
}
static void __exit hello_exit(void)
{
// 销毁顺序与注册顺序相反
device_destroy(hello_class, dev_num);
class_destroy(hello_class);
cdev_del(&hello_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("hello_chrdev: unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("dump linux");
编译与验证
编译加载
假设你已经准备好了对应的 Makefile,编译和加载驱动模块的命令如下:
make
sudo insmod hello_chrdev.ko
# 查看内核日志,确认加载成功
dmesg | tail
你应该能看到类似 hello_chrdev: initialized successfully 的输出。
检查设备节点
驱动加载成功后,系统会自动在 /dev 目录下创建设备节点。
# 输出示例:crw------- 1 root root 240, 0 ... (开头的‘c’代表 character device)
ls -l /dev/hello_chrdev
读写测试
现在,我们可以像操作普通文件一样来测试这个驱动了。
# 向设备写入数据
echo "hello from user" > /dev/hello_chrdev
# 从设备读取数据,将会回显我们刚才写入的内容
cat /dev/hello_chrdev
如果一切正常,cat 命令的输出应该是 hello from user。你也可以用 dmesg 查看驱动打印的接收信息。
常见问题
在开发过程中,你可能会遇到以下典型问题:
| 现象 |
可能原因 |
排查建议 |
没有生成 /dev/xxx 节点 |
udev/mdev 机制未触发或代码漏写 |
检查 class_create 和 device_create 是否执行成功,查看内核日志是否有错误 |
| 写入数据后读取乱码 |
缓冲区溢出或字符串未以 \0 结尾 |
检查 kernel_buffer 的边界处理(使用 min 函数)及是否添加了结束符 |
copy_from_user 失败返回 -EFAULT |
用户空间指针非法 |
确保不要直接解引用用户指针,始终使用 copy_from/to_user 系列函数 |
read() 总是返回0或陷入死循环 |
文件偏移量 *offset 未更新 |
read 函数必须正确更新 loff_t *offset 参数,否则用户程序会认为始终在读取文件开头 |
驱动类型对比
为了更清晰地定位字符设备在 Linux 驱动体系中的角色,我们将其与常见的平台或总线驱动进行简要对比:
| 特征 |
平台/总线驱动 (Platform/I2C/SPI) |
字符设备驱动 (Char Device) |
| 关注点 |
如何挂载到具体的硬件总线 |
如何被访问,提供什么文件操作 |
| 驱动入口 |
probe() (匹配设备树或总线ID后触发) |
init() (模块加载时直接运行) |
| 主要通信对象 |
硬件芯片(操作GPIO、寄存器) |
用户空间程序(通过文件IO) |
| 暴露方式 |
通常在 /sys/bus/... 下生成属性文件 |
/dev/xxx 字符设备节点 |
| 典型应用 |
传感器、控制器等具体硬件的底层驱动 |
虚拟设备、自定义通信接口、硬件驱动的上层封装 |
总结
字符设备驱动是连接用户空间与内核空间最直接、最经典的桥梁。对内,它可以调用 GPIO、I2C 等底层硬件接口;对外,它提供标准的 open/read/write 文件接口,使得应用程序可以像操作普通文件一样与硬件或内核模块交互。
掌握字符设备驱动的开发,意味着你获得了在 Linux 系统中自定义 /dev 接口的能力。这不仅是理解更复杂驱动模型(如平台驱动、设备树)的重要基础,也是实现内核与用户态灵活通信的关键技能。希望这篇实战指南能帮助你顺利踏入 Linux 驱动开发的大门。如果你想深入探讨更多内核或底层开发话题,欢迎来 云栈社区 交流分享。