驱动模型概述:Linux 内核驱动框架与字符设备基础结构

好,咱们正式开始聊驱动。很多初学者一上来就盯着 GPIO、I2C、SPI 这些外设操作,觉得把寄存器配通了就算会写驱动了。其实不然。你想想看,如果没有一个清晰的驱动模型做骨架,你写的代码就是一盘散沙,换个内核版本就崩,换个平台就废。

我个人习惯,在动任何硬件寄存器之前,先把驱动框架搭好。框架对了,后面的事情就是填空。今天这一讲,咱们就把 Linux 内核的驱动模型和字符设备驱动的基础结构彻底捋清楚。

Linux 内核驱动框架:三层架构

Linux 内核的驱动体系,说白了就是三层:设备驱动层、内核核心层、硬件抽象层。我画了张图,你看一眼就明白了。

Linux 内核驱动三层架构 用户空间(应用程序 / 系统调用) 内核核心层(VFS / 设备模型 / 驱动核心) file_operations · 设备号管理 · 总线/设备/驱动模型 设备驱动层(具体驱动实现) 字符设备 · 块设备 · 网络设备 · 其他驱动 硬件抽象层(寄存器 / 总线 / 外设) 用户态不可直接 访问硬件 驱动开发者 主要工作区

从上往下看:用户空间的应用程序通过系统调用(open、read、write、ioctl)进入内核。内核核心层负责解析这些调用,找到对应的驱动。设备驱动层才是咱们写代码的地方,它负责和硬件打交道。最底下是硬件抽象层,说白了就是芯片手册里那些寄存器。

我在项目中遇到过不少新手,一上来就想着怎么操作寄存器,结果连驱动怎么注册进内核都不知道。嗯,这里要注意:驱动首先是内核的一个模块,然后才是硬件的控制者。

字符设备驱动:最基础的驱动类型

Linux 把设备分成三大类:字符设备、块设备、网络设备。咱们做嵌入式开发,打交道最多的就是字符设备。为什么?因为串口、GPIO、I2C、SPI、PWM、ADC……这些全是字符设备。

字符设备的特点很简单:数据按字节流访问,顺序读写。你往串口发一个字节,它就是一个字节;你从 ADC 读一个值,它就是一个值。没有缓冲区,没有随机访问,就是这么直来直去。

设备类型 访问方式 典型设备 驱动核心结构
字符设备 字节流,顺序访问 串口、GPIO、I2C、SPI file_operations
块设备 块为单位,随机访问 eMMC、NAND Flash、SD 卡 block_device_operations
网络设备 数据包,异步收发 以太网、WiFi、CAN net_device_ops

字符设备驱动的核心骨架

写一个字符设备驱动,你只需要做四件事。我把它总结成四个步骤,记牢了,后面所有字符设备驱动都是这个套路。

  1. 分配设备号 —— 告诉内核:我要用这个编号
  2. 初始化 cdev 结构体 —— 把 file_operations 挂上去
  3. 注册设备 —— 把驱动加入内核设备链表
  4. 实现 file_operations —— 写 open、read、write、ioctl 等函数

来看一段最精简的代码。这是我自己项目里常用的模板,去掉了错误处理,只留骨架。

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

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

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

/* 打开设备 */
static int my_open(struct inode *inode, struct file *file)
{
    pr_info("Device opened\n");
    return 0;
}

/* 读取设备 */
static ssize_t my_read(struct file *file, char __user *buf,
                       size_t count, loff_t *offset)
{
    pr_info("Read called\n");
    return 0;
}

/* 写入设备 */
static ssize_t my_write(struct file *file, const char __user *buf,
                        size_t count, loff_t *offset)
{
    pr_info("Write called\n");
    return count;
}

/* 文件操作结构体 —— 驱动的核心接口 */
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .read    = my_read,
    .write   = my_write,
};

/* 模块初始化 */
static int __init my_init(void)
{
    /* 1. 分配设备号 */
    major = register_chrdev(0, DEVICE_NAME, &my_fops);
    if (major < 0) {
        pr_err("Failed to register device\n");
        return major;
    }

    /* 2. 初始化 cdev */
    cdev_init(&my_cdev, &my_fops);

    /* 3. 注册 cdev 到内核 */
    cdev_add(&my_cdev, MKDEV(major, 0), 1);

    /* 4. 创建设备类(自动生成 /dev 节点) */
    my_class = class_create(THIS_MODULE, CLASS_NAME);
    my_device = device_create(my_class, NULL,
                              MKDEV(major, 0), NULL, DEVICE_NAME);

    pr_info("Module loaded, major=%d\n", major);
    return 0;
}

/* 模块卸载 */
static void __exit my_exit(void)
{
    device_destroy(my_class, MKDEV(major, 0));
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(MKDEV(major, 0), 1);
    pr_info("Module unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A minimal char device driver");
我的习惯: 每次写新驱动,我都从这段模板开始改。先把框架跑通,再往里填硬件操作。这样调试起来心里有底,出了问题也知道是框架问题还是硬件问题。

file_operations:驱动与用户态的桥梁

这个结构体太重要了。它定义了用户程序能对设备做什么操作。说白了,用户态调 open,内核就调你的 my_open;用户态调 read,内核就调你的 my_read。

我列几个最常用的回调函数,你写驱动时至少要实现其中两三个。

回调函数 触发时机 典型用途
open 用户调用 open("/dev/xxx") 初始化硬件、申请资源、自增引用计数
release 用户调用 close() 释放资源、关闭硬件、自减引用计数
read 用户调用 read(fd, buf, count) 从硬件读取数据并拷贝到用户空间
write 用户调用 write(fd, buf, count) 从用户空间拷贝数据并写入硬件
ioctl 用户调用 ioctl(fd, cmd, arg) 控制硬件、设置参数、读取状态
llseek 用户调用 lseek(fd, offset, whence) 调整文件读写位置(很少用)
我曾经踩过的坑: 写第一个驱动时,我忘了在 open 里做互斥保护。结果两个用户程序同时打开设备,同时操作硬件寄存器,直接把板子搞挂了。从那以后,我每个驱动都会在 open 里加一个原子变量做引用计数,或者在 ioctl 里加互斥锁。你也要养成这个习惯。

设备号:驱动的身份证

每个字符设备在内核里都有一个唯一的编号,叫设备号。它由两部分组成:主设备号和次设备号。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。

举个例子:你的板子上有两个串口,它们都用同一个驱动(主设备号相同),但一个是 ttyS0,一个是 ttyS1(次设备号不同)。

概念 说明 示例
主设备号 标识驱动,一个驱动一个主设备号 4(串口)、89(I2C)
次设备号 标识具体设备实例 0(ttyS0)、1(ttyS1)
MKDEV(major, minor) 将主次设备号合并为一个 dev_t MKDEV(4, 0) → 0x00000400

分配设备号有两种方式:

  • 静态分配: 你自己指定一个主设备号,用 register_chrdev_region() 注册。适合你知道这个号没被占用的场景。
  • 动态分配: 传 0 给 register_chrdev(),让内核自动分配。我建议你一直用这种方式,省心,不会冲突。

驱动模块的加载与卸载

驱动写好了,怎么跑起来?Linux 把驱动编译成内核模块(.ko 文件),可以动态加载和卸载。这是嵌入式开发最常用的方式,不用每次都重新编译整个内核。

常用的命令就三个:

# 加载驱动
insmod my_driver.ko

# 查看已加载的驱动
lsmod | grep my_driver

# 卸载驱动
rmmod my_driver

加载时内核会调用 module_init 指定的函数(my_init),卸载时调用 module_exit 指定的函数(my_exit)。你想想看,这其实就是驱动生命周期管理的入口和出口。

核心要点: 字符设备驱动的本质,就是向内核注册一组 file_operations 回调函数。用户态的任何操作,最终都会通过 VFS 层映射到你的回调函数里。框架是固定的,你只需要填业务逻辑。

好了,这一讲的内容就这些。驱动模型的三层架构、字符设备的核心结构、file_operations 的用法、设备号的分配,这些都是后面所有章节的基础。你先把这段模板跑通,后面咱们再往里加硬件操作、加中断、加 DMA,一步步把驱动写扎实。