驱动模型概述:Linux 内核驱动框架与字符设备基础结构
好,咱们正式开始聊驱动。很多初学者一上来就盯着 GPIO、I2C、SPI 这些外设操作,觉得把寄存器配通了就算会写驱动了。其实不然。你想想看,如果没有一个清晰的驱动模型做骨架,你写的代码就是一盘散沙,换个内核版本就崩,换个平台就废。
我个人习惯,在动任何硬件寄存器之前,先把驱动框架搭好。框架对了,后面的事情就是填空。今天这一讲,咱们就把 Linux 内核的驱动模型和字符设备驱动的基础结构彻底捋清楚。
Linux 内核驱动框架:三层架构
Linux 内核的驱动体系,说白了就是三层:设备驱动层、内核核心层、硬件抽象层。我画了张图,你看一眼就明白了。
从上往下看:用户空间的应用程序通过系统调用(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 |
字符设备驱动的核心骨架
写一个字符设备驱动,你只需要做四件事。我把它总结成四个步骤,记牢了,后面所有字符设备驱动都是这个套路。
- 分配设备号 —— 告诉内核:我要用这个编号
- 初始化 cdev 结构体 —— 把 file_operations 挂上去
- 注册设备 —— 把驱动加入内核设备链表
- 实现 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) | 调整文件读写位置(很少用) |
设备号:驱动的身份证
每个字符设备在内核里都有一个唯一的编号,叫设备号。它由两部分组成:主设备号和次设备号。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。
举个例子:你的板子上有两个串口,它们都用同一个驱动(主设备号相同),但一个是 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 的用法、设备号的分配,这些都是后面所有章节的基础。你先把这段模板跑通,后面咱们再往里加硬件操作、加中断、加 DMA,一步步把驱动写扎实。