Linux设备驱动基础:从模块到字符设备

大家好,我是老张。做嵌入式Linux驱动开发这么多年,我始终觉得——理解设备驱动的基础,就像盖楼打地基。地基不稳,后面全是坑。今天咱们就来聊聊Linux内核模块结构、字符设备驱动框架,还有那个绕不开的file_operations结构体。

1. Linux内核模块:驱动的最小单元

说白了,内核模块就是一段可以动态加载到内核的代码。你想想看,如果每次加个驱动都要重新编译整个内核,那得多痛苦?模块机制就是为了解决这个问题的。

一个典型的内核模块,至少包含两个函数:

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

static int __init my_driver_init(void)
{
    printk(KERN_INFO "Hello, driver loaded!\n");
    return 0;
}

static void __exit my_driver_exit(void)
{
    printk(KERN_INFO "Goodbye, driver unloaded!\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Zhang");
MODULE_DESCRIPTION("A simple demo driver");

嗯,这里要注意——__init__exit这两个宏,很多人刚开始会忽略。它们的作用是把初始化代码放到特定段,模块加载完后内核可以回收这部分内存。我在项目中遇到过,有人没加这个宏,结果模块占用的内存一直释放不掉。

核心要点:

  • module_init():指定模块加载入口
  • module_exit():指定模块卸载出口
  • MODULE_LICENSE:必须声明,否则内核会报"tainted"

2. 字符设备驱动框架:最经典的驱动模型

字符设备,就是那些像串口、GPIO、I2C设备一样,按字节流读写的外设。Linux里大部分简单设备都属于这一类。

我个人习惯把字符设备驱动的开发流程总结为三步:

  1. 注册设备号——告诉内核"我要用这个设备号"
  2. 初始化cdev结构体——把操作函数绑定到设备
  3. 创建设备节点——让用户空间能访问到

来看一个完整的框架代码:

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

#define DEVICE_NAME "my_char_dev"
#define CLASS_NAME  "my_class"

static int major_num;
static struct class *my_class;
static struct device *my_device;
static struct cdev my_cdev;

static int __init char_dev_init(void)
{
    dev_t dev_num;
    
    // 第一步:动态分配设备号
    alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    major_num = MAJOR(dev_num);
    
    // 第二步:初始化cdev
    cdev_init(&my_cdev, &my_fops);
    cdev_add(&my_cdev, dev_num, 1);
    
    // 第三步:创建类和设备节点
    my_class = class_create(THIS_MODULE, CLASS_NAME);
    my_device = device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);
    
    printk(KERN_INFO "Device registered, major=%d\n", major_num);
    return 0;
}

我的经验:动态分配设备号(alloc_chrdev_region)比静态指定好得多。静态指定容易跟其他驱动冲突,我曾经在调试一个项目时,就因为设备号冲突,折腾了两天才找到原因。

3. file_operations结构体:驱动与用户空间的桥梁

这个结构体,说白了就是一张函数指针表。用户空间调用open()read()write()时,内核会通过这张表找到对应的驱动函数。

我见过很多新手,一上来就想把所有函数都实现。其实没必要——你只需要实现设备真正需要的操作就行。

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)
{
    // 注意:这里要用copy_to_user,不能直接memcpy
    printk(KERN_INFO "Read request, len=%zu\n", len);
    return 0;
}

static ssize_t my_write(struct file *file, const char __user *buf,
                        size_t len, loff_t *offset)
{
    printk(KERN_INFO "Write request, len=%zu\n", len);
    return len;
}

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

避坑指南:我曾经在read函数里直接用了memcpy往用户空间拷贝数据,结果内核直接崩溃。记住——用户空间和内核空间不能直接传指针,必须用copy_to_usercopy_from_user。这是新手最容易犯的错误之一。

4. 模块加载与卸载流程

加载和卸载,看起来就是insmodrmmod两个命令的事。但背后的流程,你想想看,其实挺讲究的。

阶段 用户操作 内核行为
加载 insmod my_driver.ko 调用module_init指定的函数
使用 open/read/write 通过file_operations调用驱动函数
卸载 rmmod my_driver 调用module_exit指定的函数

卸载时要注意顺序——先删除设备节点,再销毁cdev,最后释放设备号。顺序反了,系统可能会崩溃。我建议你养成一个习惯:加载和卸载的代码写成对称的,这样不容易漏掉。

卸载函数的正确写法:

static void __exit char_dev_exit(void)
{
    dev_t dev_num = MKDEV(major_num, 0);
    
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    
    printk(KERN_INFO "Device unloaded\n");
}

5. 知识体系总览

为了让你更直观地理解整个框架,我画了一张图:

Linux字符设备驱动框架 用户空间 应用程序 open() / read() / write() 设备节点 /dev/my_char_dev 系统调用 内核空间 VFS(虚拟文件系统层) struct file_operations .open → my_open | .read → my_read | .write → my_write | .release → my_release 字符设备驱动(硬件操作逻辑) 图1:字符设备驱动调用链路

这张图清晰地展示了从用户空间到硬件驱动的完整调用链。你写驱动时,心里要始终装着这个框架——每个函数、每个结构体,都在这个链条上有自己的位置。

我的建议:刚开始学驱动开发,不要急着写复杂的逻辑。先把module_initmodule_exitfile_operations这三个核心要素跑通。能加载、能卸载、能读写,你就已经掌握了80%的基础。

好了,这一章的内容就到这里。记住——驱动开发没有捷径,但有了扎实的基础,后面的路会越走越顺。


公众号:蓝海资料掘金营,微信deep3321