3、Linux内核驱动基础:字符设备驱动框架、模块加载与卸载、file_operations结构体

好,咱们今天聊聊字符设备驱动。这是Linux驱动开发里最基础、也是最核心的一块。说白了,你只要搞懂了字符设备驱动,后面再去看块设备、网络设备,思路都会清晰很多。

我个人习惯,学任何新东西,先看它长什么样,再拆开看里面有什么。字符设备驱动也不例外。我们先搭一个最简单的框架出来。

3.1 字符设备驱动的基本框架

字符设备,顾名思义,就是按字节流来读写数据的设备。键盘、鼠标、串口,这些都是典型的字符设备。驱动的作用,就是让用户空间的程序能通过标准的文件操作——open、read、write——来和硬件打交道。

一个字符设备驱动,核心就三件事:

  • 注册设备号:告诉内核,我这个驱动管哪个设备。
  • 实现操作函数:告诉内核,用户读写时该干什么。
  • 注册设备:把设备和操作函数绑定起来。

嗯,这里要注意,设备号分主设备号和次设备号。主设备号标识驱动,次设备号标识具体的设备实例。我在项目中遇到过,两个驱动抢同一个主设备号,结果系统直接崩溃。所以设备号分配一定要小心。

3.2 模块加载与卸载

Linux驱动可以编译进内核,也可以编译成模块动态加载。我建议初学者先从模块开始,调试起来方便,不用每次改代码都重新编译整个内核。

模块的入口和出口,就两个宏:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>

// 模块加载函数
static int __init my_driver_init(void)
{
    printk(KERN_INFO "Hello, my driver loaded!\n");
    // 这里注册设备号、注册设备
    return 0;
}

// 模块卸载函数
static void __exit my_driver_exit(void)
{
    printk(KERN_INFO "Goodbye, my driver unloaded!\n");
    // 这里注销设备、释放资源
}

module_init(my_driver_init);
module_exit(my_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");

你想想看,这个结构是不是很清晰?加载时调用init,卸载时调用exit。我曾经犯过一个低级错误——在init里申请了内存,exit里忘了释放。结果模块卸载后,内存泄漏了。嗯,从那以后我每次写exit,都会对照init,确保成对释放。

注意:printk的日志级别很重要。KERN_INFO是普通信息,KERN_ERR是错误。调试时建议用KERN_DEBUG,生产环境记得关掉,否则dmesg会被刷爆。

3.3 file_operations结构体

这个结构体,是字符设备驱动的灵魂。它定义了用户空间程序能对设备做哪些操作。说白了,它就是驱动和用户态之间的协议。

我们来看一个典型的file_operations定义:

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
    .llseek  = my_llseek,
};

每个成员都是一个函数指针。你实现了哪个,用户就能用哪个。比如你只实现了open和read,那用户就只能打开和读取,不能写。

我刚开始做驱动时,总觉得这些函数要写得很复杂。其实不然。很多设备只需要简单的读写操作。举个例子,一个温度传感器驱动,read函数就是从硬件寄存器里读温度值,然后拷贝到用户空间。就这么简单。

下面是一个简单的实现示例:

static int my_open(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "Device opened\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "Device closed\n");
    return 0;
}

static ssize_t my_read(struct file *file, char __user *buf, 
                        size_t len, loff_t *offset)
{
    char data[] = "Hello from kernel!";
    size_t datalen = strlen(data);
    
    if (*offset >= datalen)
        return 0;  // 已经读到末尾了
    
    if (len > datalen - *offset)
        len = datalen - *offset;
    
    if (copy_to_user(buf, data + *offset, len))
        return -EFAULT;
    
    *offset += len;
    return len;
}

static ssize_t my_write(struct file *file, const char __user *buf,
                         size_t len, loff_t *offset)
{
    char kbuf[100];
    
    if (len > sizeof(kbuf) - 1)
        len = sizeof(kbuf) - 1;
    
    if (copy_from_user(kbuf, buf, len))
        return -EFAULT;
    
    kbuf[len] = '\0';
    printk(KERN_INFO "Received from user: %s\n", kbuf);
    
    return len;
}
关键点:copy_to_user和copy_from_user这两个函数,是用户空间和内核空间数据交换的桥梁。千万不要直接用memcpy!因为用户空间的指针可能是非法的,直接访问会导致内核崩溃。我见过太多新手在这里栽跟头了。

3.4 完整的注册流程

有了模块框架和file_operations,接下来就是注册了。流程如下:

  1. 分配设备号(动态或静态)
  2. 初始化cdev结构体
  3. 将cdev添加到内核
  4. 创建设备节点(或者用udev自动创建)

代码示例:

#include <linux/cdev.h>
#include <linux/device.h>

static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;

static int __init my_driver_init(void)
{
    int ret;
    
    // 1. 动态分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    if (ret < 0) {
        printk(KERN_ERR "Failed to allocate device number\n");
        return ret;
    }
    printk(KERN_INFO "Allocated major=%d minor=%d\n", 
           MAJOR(dev_num), MINOR(dev_num));
    
    // 2. 初始化cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    
    // 3. 添加cdev到内核
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        printk(KERN_ERR "Failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }
    
    // 4. 创建类,用于自动创建设备节点
    my_class = class_create(THIS_MODULE, "my_class");
    if (IS_ERR(my_class)) {
        printk(KERN_ERR "Failed to create class\n");
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_class);
    }
    
    device_create(my_class, NULL, dev_num, NULL, "my_device");
    
    printk(KERN_INFO "My driver loaded successfully\n");
    return 0;
}

static void __exit my_driver_exit(void)
{
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "My driver unloaded\n");
}
小技巧:动态分配设备号(alloc_chrdev_region)比静态指定好得多。静态指定容易冲突,动态分配由内核自动管理。我一般只在教学示例里用静态,实际项目全用动态。

3.5 避坑指南

最后,分享几个我踩过的坑:

  • 设备节点权限:默认创建的设备节点可能只有root能访问。记得在udev规则里设置权限,或者用chmod改一下。
  • 并发访问:多个进程同时读写同一个设备,不加锁的话数据会乱。我早期一个项目,两个线程同时写,结果数据交叉错乱,查了两天才发现是没加互斥锁。
  • 内存泄漏:每次open时分配的内存,一定要在release里释放。否则驱动跑几天,系统内存就被吃光了。
  • 错误处理:每一步都要检查返回值。alloc_chrdev_region失败、cdev_add失败,都要回滚之前的所有操作。否则系统会留下半残的状态。

好了,字符设备驱动的基础框架就这些。你把这个框架练熟了,后面学什么驱动都顺手。下一章我们聊聊如何用ioctl来控制设备,那才是真正让驱动“活”起来的关键。