字符设备驱动:file_operations 与读写实现

字符设备驱动: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 技巧。

我是黒漂技术佬。

相关推荐
阿拉斯攀登1 小时前
嵌入式 Linux 驱动概述:分类、内核模块、开发环境
linux·嵌入式硬件·linux内核·嵌入式·linux驱动
Uncertainty!!12 小时前
Ubuntu 22.04 安装超好用中文输入法rime-ice(雾凇拼音)过程记录
linux·ubuntu·中文输入法
ShineWinsu13 小时前
对于Linux:网络基础的解析
linux·网络·面试·udp·笔试·ip·tcp
↘"LYong14 小时前
旧版 CentOS 安装 Docker 完整指南(附国内镜像加速)
linux·运维·docker
Zhang~Ling14 小时前
Vim 多模式详解:命令、插入、底行与插件配置
linux·编辑器·vim
lularible16 小时前
HSM技术精讲(3.8):证书对象——数字世界的“身份证明“
安全·开源·嵌入式·汽车电子·hsm
想你依然心痛16 小时前
Linux用户空间与内核空间通信:netlink、ioctl、mmap 零拷贝与性能对比
linux·运维·服务器
FreakStudio17 小时前
WIZnet 开源方案分享:W55MH32 开发板 + 小智 AI实现以太网版本的小智机器人
python·单片机·嵌入式·大学生·面向对象·并行计算·电子diy·电子计算机
做个文艺程序员17 小时前
Linux第12篇:性能监控与瓶颈分析——CPU/内存/IO/网络全维度
linux·运维·网络