字符设备驱动:file_operations 与读写实现
字符设备是 Linux 驱动最基础、最常用的一类。这篇讲字符设备驱动的核心框架:设备号、cdev 结构体、file_operations 操作集、open/read/write 实现,以及用户空间和内核空间的数据拷贝。
大家好,我是黒漂技术佬。
内核模块搞明白了,接下来正式写驱动。字符设备驱动是最基础的一类,也是学习其他驱动的基础。串口、GPIO、LED、按键、I2C 设备......底层都是字符设备。
这篇讲字符设备的完整框架:设备号、cdev 注册、file_operations、读写实现、用户态和内核态的数据交互。
一、字符设备是什么?
特点
- 按字节流顺序访问的设备
- 应用层通过
/dev/xxx设备文件操作 - 用 open / read / write / close / ioctl 等标准接口
- 大多数简单外设都是字符设备
设备号
每个字符设备有一个唯一的设备号,由两部分组成:
主设备号:标识是哪一类驱动(比如都是串口驱动)
次设备号:标识同类中的第几个设备
设备号 = 主设备号 << 20 | 次设备号
用 dev_t 类型存。
操作宏
c
#include <linux/kdev_t.h>
MAJOR(dev) // 提取主设备号
MINOR(dev) // 提取次设备号
MKDEV(ma, mi) // 合成设备号
查看设备号
bash
cat /proc/devices
# Character devices:
# 1 mem
# 4 tty
# ...
ls -l /dev/
# crw-rw---- 1 root dialout 4, 64 ... ttyS0
# ↑ ↑
# 主设备号 次设备号
c 开头表示字符设备(character)。
二、设备号的申请和释放
静态申请(指定主设备号)
c
#include <linux/fs.h>
dev_t devid = MKDEV(200, 0); // 主设备号200,次设备号0
int register_chrdev_region(devid, count, "mydev");
- 优点:固定,方便
- 缺点:可能跟已有设备冲突
动态分配(推荐)
c
dev_t devid;
int alloc_chrdev_region(&devid, 0, 1, "mydev");
// 第一个参数:返回的设备号
// 第二个参数:次设备号起始
// 第三个参数:设备数量
// 第四个参数:设备名(/proc/devices里显示的)
- 优点:自动分配空闲的主设备号,不会冲突
- 缺点:每次加载主设备号可能不一样
释放
c
unregister_chrdev_region(devid, count);
模块卸载时一定要释放。
三、cdev 结构体
是什么
内核里用 struct cdev 表示一个字符设备。
关键成员
c
struct cdev {
struct kobject kobj;
struct module *owner; // 所属模块,一般填 THIS_MODULE
const struct file_operations *ops; // 操作函数集合(核心)
struct list_head list;
dev_t dev; // 设备号
unsigned int count; // 设备数量
};
初始化和注册
两步:
c
// 1. 初始化 cdev,绑定 file_operations
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
// 2. 加入内核,让系统知道这个设备
int cdev_add(struct cdev *p, dev_t dev, unsigned count);
注销
c
void cdev_del(struct cdev *p);
完整流程
申请设备号 → cdev_init 绑定操作集 → cdev_add 注册 → 设备可用
↓
卸载时:cdev_del 注销 → 释放设备号
四、file_operations:操作函数集合
是什么
一个函数指针结构体,把应用层的系统调用映射到驱动里的具体函数。
常用成员
c
struct file_operations {
struct module *owner;
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
loff_t (*llseek) (struct file *, loff_t, int);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*mmap) (struct file *, struct vm_area_struct *);
// ...
};
对应关系
| 应用层 | 驱动层 |
|---|---|
| open() | .open |
| close() | .release |
| read() | .read |
| write() | .write |
| ioctl() | .unlocked_ioctl |
| lseek() | .llseek |
| select/poll | .poll |
| mmap() | .mmap |
定义示例
c
static int my_open(struct inode *inode, struct file *filp)
{
printk("设备打开了\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp)
{
printk("设备关闭了\n");
return 0;
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.unlocked_ioctl = my_ioctl,
};
.owner 必须填 THIS_MODULE,防止驱动被使用时卸载。
五、用户空间和内核空间的数据拷贝
为什么不能直接访问?
- 用户空间的指针不能直接在内核里读写
- 地址空间不一样,而且要做合法性检查
- 直接访问可能会导致内核崩溃
拷贝函数
c
#include <linux/uaccess.h>
// 从用户空间拷贝到内核空间(read 的反方向,write 用这个)
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
// 从内核空间拷贝到用户空间(read 用这个)
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
返回值:成功返回 0,失败返回没拷贝成功的字节数。
简单数据的存取
单个变量可以用更简单的:
c
get_user(x, ptr) // 从用户空间读一个变量到 x
put_user(x, ptr) // 把 x 写到用户空间
比 copy_*_user 快,适合 char/int/long 这种小数据。
六、read 和 write 的实现
read 函数
c
static char buf[1024]; // 内核缓冲区
static ssize_t my_read(struct file *filp, char __user *buf_user,
size_t count, loff_t *f_pos)
{
int len = strlen(buf); // 实际有多少数据
if (count > len)
count = len;
if (copy_to_user(buf_user, buf, count)) {
return -EFAULT;
}
*f_pos += count;
return count; // 返回实际读到的字节数
}
参数说明:
filp:文件结构体buf_user:用户空间缓冲区指针(__user 标记)count:用户想读多少字节f_pos:文件读写位置- 返回值:实际读到的字节数,负数表示错误
write 函数
c
static ssize_t my_write(struct file *filp, const char __user *buf_user,
size_t count, loff_t *f_pos)
{
if (count > sizeof(buf))
count = sizeof(buf);
if (copy_from_user(buf, buf_user, count)) {
return -EFAULT;
}
buf[count] = '\0'; // 字符串结尾
*f_pos += count;
printk("收到数据: %s\n", buf);
return count;
}
错误码
返回负数表示错误,用标准错误码:
-EINVAL:参数无效-EFAULT:地址错误(copy 失败)-ENOMEM:内存不足-EBUSY:设备忙-ENODEV:设备不存在
七、open 和 release
open
打开设备时调用,可以做:
- 初始化硬件
- 分配缓冲区
- 检查设备状态
- 私有数据初始化
c
static int my_open(struct inode *inode, struct file *filp)
{
// 从 inode 里拿到 cdev,再拿到我们的私有数据
// 后面讲容器_of的时候细说
printk("设备打开\n");
return 0;
}
release
关闭设备时调用,做清理:
- 释放 open 里分配的资源
- 复位硬件
c
static int my_release(struct inode *inode, struct file *filp)
{
printk("设备关闭\n");
return 0;
}
八、ioctl:控制命令
作用
read/write 是数据流,ioctl 是「控制命令」:
- 设置参数
- 读取状态
- 执行特定操作(复位、校准......)
命令码构造
c
#include <linux/ioctl.h>
// 方向:_IO(无数据) _IOW(写) _IOR(读) _IOWR(读写)
// type:8位魔数,每个驱动一个
// nr:8位命令序号
// size:数据大小
#define MY_IOC_MAGIC 'x'
#define MY_IOC_RESET _IO(MY_IOC_MAGIC, 0)
#define MY_IOC_SET_BAUD _IOW(MY_IOC_MAGIC, 1, int)
#define MY_IOC_GET_STATUS _IOR(MY_IOC_MAGIC, 2, int)
实现
c
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
int val;
switch (cmd) {
case MY_IOC_RESET:
printk("复位设备\n");
break;
case MY_IOC_SET_BAUD:
if (copy_from_user(&val, (int __user *)arg, sizeof(int)))
return -EFAULT;
printk("设置波特率: %d\n", val);
break;
case MY_IOC_GET_STATUS:
val = 0x1234; // 假设的状态值
if (copy_to_user((int __user *)arg, &val, sizeof(int)))
return -EFAULT;
break;
default:
return -EINVAL;
}
return 0;
}
应用层调用
c
int baud = 115200;
ioctl(fd, MY_IOC_SET_BAUD, &baud);
int status;
ioctl(fd, MY_IOC_GET_STATUS, &status);
九、完整的字符设备驱动示例
c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#define BUF_SIZE 1024
#define DEV_NAME "mychar"
static dev_t devid;
static struct cdev my_cdev;
static char kbuf[BUF_SIZE];
static int my_open(struct inode *inode, struct file *filp)
{
pr_info("mychar: open\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp)
{
pr_info("mychar: release\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
int len = strlen(kbuf);
if (count > len) count = len;
if (copy_to_user(buf, kbuf, count))
return -EFAULT;
*f_pos += count;
return count;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
if (count >= BUF_SIZE) count = BUF_SIZE - 1;
if (copy_from_user(kbuf, buf, count))
return -EFAULT;
kbuf[count] = '\0';
*f_pos += count;
pr_info("mychar: write '%s'\n", kbuf);
return count;
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int __init mychar_init(void)
{
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&devid, 0, 1, DEV_NAME);
if (ret < 0) {
pr_err("alloc_chrdev_region failed\n");
return ret;
}
// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 注册 cdev
ret = cdev_add(&my_cdev, devid, 1);
if (ret < 0) {
pr_err("cdev_add failed\n");
goto err_cdev;
}
strcpy(kbuf, "hello from kernel\n");
pr_info("mychar: loaded, major=%d\n", MAJOR(devid));
return 0;
err_cdev:
unregister_chrdev_region(devid, 1);
return ret;
}
static void __exit mychar_exit(void)
{
cdev_del(&my_cdev);
unregister_chrdev_region(devid, 1);
pr_info("mychar: unloaded\n");
}
module_init(mychar_init);
module_exit(mychar_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Heipiao");
MODULE_DESCRIPTION("简单字符设备驱动");
应用层测试程序
c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd = open("/dev/mychar", O_RDWR);
if (fd < 0) {
perror("open");
return 1;
}
// 写
write(fd, "hello driver", 12);
// 读
char buf[64];
lseek(fd, 0, SEEK_SET); // 回到开头
int n = read(fd, buf, sizeof(buf));
buf[n] = '\0';
printf("读到: %s\n", buf);
close(fd);
return 0;
}
十、手动创建设备节点
加载驱动后,要手动创建设备文件才能用:
bash
# 查看主设备号
cat /proc/devices | grep mychar
# 创建设备节点
mknod /dev/mychar c 主设备号 0
每次手动 mknod 很麻烦,下一篇讲怎么自动创建(udev + class/device_create)。
十一、常见坑
坑 1:直接访问用户空间指针
不用 copy_from_user / copy_to_user,直接解引用 __user 指针,轻则数据错乱,重则内核 panic。
坑 2:read/write 返回值不对
- 成功返回实际读写的字节数(>=0)
- 失败返回负的错误码
- 不要返回 0 表示错误,应用层会以为读到文件末尾了
坑 3:忘了检查 copy 的返回值
copy_to_user / copy_from_user 可能失败(用户地址非法),一定要检查返回值。
坑 4:cdev_add 失败没回滚
初始化过程中某一步失败,前面申请的资源要释放。用 goto 做错误处理是内核的标准写法。
坑 5:file_operations 里的函数没加 static
不加 static 会污染内核命名空间,可能跟别的驱动重名。
坑 6:owner 没设 THIS_MODULE
.owner 不设的话,模块使用中也能被卸载,卸载了再调用驱动函数直接 panic。
十二、本篇小结
- 字符设备用设备号标识:主设备号(驱动类别)+ 次设备号(具体设备)
- 设备号申请:alloc_chrdev_region(动态,推荐)/ register_chrdev_region(静态)
- struct cdev 表示字符设备,cdev_init 绑定操作集,cdev_add 注册到内核
- file_operations 是核心,把系统调用映射到驱动函数
- open / release:打开关闭时的初始化和清理
- read / write:数据读写,必须用 copy_to_user / copy_from_user 拷贝数据
- ioctl:控制命令,用来设置参数、读状态、执行特殊操作
- 用户空间指针不能直接访问,必须用 copy_*_user 或 get_user / put_user
- 加载驱动后用 mknod 创建设备节点,下一篇讲自动创建
下一篇讲设备节点自动创建:class、device_create、udev 机制,以及私有数据的 container_of 技巧。
我是黒漂技术佬。