
1. 什么是驱动
驱动本质上是对底层硬件设备的操作进行封装,并向上层应用提供统一的函数接口。
设备分类:Linux内核将设备主要分为三类:字符设备、块设备和网络设备。
- 字符设备:指以字节流形式进行顺序读写的设备,数据需要按照先后顺序访问,不支持随机读取。常见的字符设备包括鼠标、键盘、串口、控制台和LED等。其驱动程序通常需要实现
open、close、read 和 write 等基本系统调用。
- 块设备:指可以从设备任意位置读取一定长度数据的设备,支持随机访问。典型的块设备有硬盘、U盘和SD卡。
- 网络设备:负责数据报文的发送和接收,它既可以是一个物理硬件设备(如网卡),也可以是一个纯粹的软件抽象(如回环接口
lo)。
下面通过一个具体例子说明整个调用过程:
- 应用程序调用C库的
open 函数,例如 open("/dev/pin4", O_RDWR),意图以可读写方式打开 /dev 目录下的 pin4 设备。
- 该调用会触发一次软中断(中断号为
0x80),使CPU从用户空间切换到内核空间,并进入 system_call 函数。
system_call 根据传递的系统调用号(对应 open)和参数(设备名),通过虚拟文件系统层(VFS)的 sys_open 函数进行处理。
sys_open 根据设备名在内核维护的驱动链表中,查找对应的设备驱动。驱动链表中的每个节点都包含设备号和设备驱动函数。
- 找到驱动后,最终会执行驱动中定义的
open 函数,该函数内部包含操作硬件寄存器以控制特定IO口的代码。
编写驱动的主要任务就是向内核添加这样一个驱动节点,需要明确三项内容:
- 设备名
- 设备号(主设备号+次设备号)
- 设备驱动函数(包含操作寄存器的代码)
综上所述,从用户态 open 调用到最终操控硬件的完整路径是:用户调用触发软中断进入内核态 -> system_call -> VFS的 sys_open -> 在驱动链表中根据设备名/设备号查找驱动 -> 执行驱动中对应的操作函数(如 pin4_open)-> 函数内操作寄存器控制硬件。
2. 用户态、内核态与驱动链表详解
用户态:
- 指应用程序运行的环境。开发者基于C标准库进行编程,库函数(如
open, read, write, fork, socket)封装了与内核交互的接口,应用程序通过调用这些API来“支配”内核完成具体工作。这属于系统编程的范畴。
内核态:
- 当用户需要操作硬件时,必须通过内核态的驱动程序。驱动程序是内核的一部分,直接与硬件寄存器打交道。例如,树莓派的
wiringPi 库在用户态提供的接口,其底层就是由对应的内核驱动实现的。理解内核态工作机制是深入Linux系统的关键。
- Linux遵循“一切皆文件”的哲学,所有设备在
/dev 目录下都有一个对应的设备文件。应用程序可以像操作普通文件一样,使用 open、read、write 等函数操作这些设备文件。
- 内核通过设备号来唯一标识和管理设备。设备号由主设备号和次设备号组成。主设备号标识设备类型(对应特定的驱动程序),次设备号标识同一驱动程序下的不同设备实例。例如,可以为两个LED灯编写同一个字符设备驱动,分配相同的主设备号(如5),但赋予不同的次设备号(如1和2)来区分它们。
驱动链表:
- 内核通过一个链表来管理所有已加载的设备驱动。添加驱动发生在我们将编译好的驱动模块(
.ko 文件)加载到内核时;查找驱动则发生在用户空间调用 open 等函数时。
- 驱动程序在链表中的位置由设备号来索引。因此,设备号不仅用于区分设备,也决定了驱动在内核数据结构中的组织方式。
- 当
system_call 收到系统调用请求时,它会读取寄存器(如 eax 存放系统调用号)中的参数,通过系统调用表(sys_call_table)找到对应的内核处理函数(如 sys_open),进而完成后续的驱动查找与调用。
3. 字符设备驱动工作原理
在Linux中,对硬件的访问最终被抽象为对文件的操作。字符设备驱动的工作原理围绕几个关键数据结构展开:
- struct inode:描述文件系统中的一个文件(包括设备文件)的元信息,如文件类型、权限、设备号等。
- struct file:每次打开文件时,VFS层都会创建一个该结构体,代表一个“打开的文件”,记录本次打开的操作状态、位置偏移等。
- struct cdev:内核中描述一个字符设备的核心结构体,其中最重要的成员是
ops 指针,指向设备的具体操作函数集合(file_operations)。

调用流程:
- 应用层调用
open(“/dev/pin4”, …)。
- VFS层根据
/dev/pin4 对应的 inode,获知这是一个字符设备,并获取其设备号。
- 根据设备号,在内核中找到对应的
struct cdev 结构体。
- 将
cdev 的地址记录在 inode->i_cdev,并将 cdev->ops(即 file_operations)的地址记录在新创建的 struct file->f_op 中。
- 将
file 结构体与一个文件描述符(fd)关联,并返回给应用层。
- 此后,应用层通过
fd 进行的 read、write 等操作,最终都会通过 file->f_op 找到驱动中具体的函数来执行。
驱动开发者的任务,就是在驱动模块初始化时(通常通过 module_init 指定的函数),完成 cdev 结构的初始化(cdev_init)并将其添加到内核(cdev_add),从而建立设备号与操作函数集的绑定。
基于框架编写驱动代码
上层应用测试代码 (pin4test.c)
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
void main()
{
int fd;
fd = open("/dev/pin4", O_RDWR);
if(fd < 0){
printf("open fail\n");
perror("reason:");
} else {
printf("open successful\n");
}
write(fd, ‘1‘, 1); // 尝试向设备写入一个字符‘1‘
}
内核驱动框架代码 (以字符设备为例)
这是一个最简化的字符设备驱动框架:
#include <linux/fs.h> // file_operations声明
#include <linux/module.h> // module_init, module_exit声明
#include <linux/init.h> // __init, __exit宏定义声明
#include <linux/device.h> // class, device声明
#include <linux/uaccess.h> // copy_from_user头文件
#include <linux/types.h> // 设备号 dev_t 类型声明
#include <asm/io.h> // ioremap, iounmap头文件
static struct class *pin4_class;
static struct device *pin4_class_dev;
static dev_t devno; // 设备号
static int major = 231; // 主设备号
static int minor = 0; // 次设备号
static char *module_name = “pin4”; // 模块名/设备名
// 驱动操作函数
static int pin4_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO “pin4_open\n”);
return 0;
}
static ssize_t pin4_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
printk(KERN_INFO “pin4_write\n”);
// 此处应添加实际的硬件操作代码,例如:写寄存器控制GPIO
return 0;
}
// file_operations 结构体,向上层提供操作接口
static struct file_operations pin4_fops = {
.owner = THIS_MODULE,
.open = pin4_open,
.write = pin4_write,
};
// 驱动模块初始化函数
int __init pin4_drv_init(void)
{
int ret;
devno = MKDEV(major, minor); // 1. 创建设备号
ret = register_chrdev(major, module_name, &pin4_fops); // 2. 注册字符设备驱动
if (ret < 0) {
printk(KERN_ERR “register chrdev failed!\n”);
return ret;
}
pin4_class = class_create(THIS_MODULE, “myfirstdemo”); // 3. 创建一个设备类
if (IS_ERR(pin4_class)) {
unregister_chrdev(major, module_name);
return PTR_ERR(pin4_class);
}
// 4. 在 /dev 下自动创建设备节点
pin4_class_dev = device_create(pin4_class, NULL, devno, NULL, module_name);
if (IS_ERR(pin4_class_dev)) {
class_destroy(pin4_class);
unregister_chrdev(major, module_name);
return PTR_ERR(pin4_class_dev);
}
printk(KERN_INFO “pin4 driver init OK\n”);
return 0;
}
// 驱动模块退出函数
void __exit pin4_drv_exit(void)
{
device_destroy(pin4_class, devno); // 销毁设备
class_destroy(pin4_class); // 销毁类
unregister_chrdev(major, module_name); // 注销驱动
printk(KERN_INFO “pin4 driver exit\n”);
}
module_init(pin4_drv_init); // 指定模块入口
module_exit(pin4_drv_exit); // 指定模块出口
MODULE_LICENSE(“GPL v2”); // 声明开源协议
驱动框架执行流程:
module_init(pin4_drv_init) 宏指定驱动加载时的初始化入口。
- 在
pin4_drv_init 函数中,依次完成:创建设备号 -> 向内核注册驱动(关联 file_operations)-> 创建设备类 -> 在 /dev 下自动创建设备节点。
- 应用层
open 调用时,内核根据设备名/号找到此驱动,并调用驱动中定义的 pin4_open 函数。
- 卸载驱动时,
module_exit(pin4_drv_exit) 指定的函数被调用,执行销毁设备节点、注销驱动等清理工作。
手动创建设备节点:
除了代码自动创建,也可以手动创建:sudo mknod /dev/pin4 c 231 0。其中 c 表示字符设备,231 是主设备号,0 是次设备号。删除使用 rm 命令即可。
驱动模块代码编译与加载
驱动模块编译
驱动模块的编译需要配置好的内核源码。以交叉编译为例,基本步骤如下:
- 将上述驱动代码(如命名为
pin4driver.c)放入内核源码树的驱动目录下,例如 /drivers/char/。
- 修改该目录下的
Makefile,添加一行:obj-m += pin4driver.o,告诉编译系统将此文件编译为模块。
- 在内核源码根目录下执行编译命令,指定架构和交叉编译器:
ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make modules
- 编译成功后,会在对应目录生成
pin4driver.ko 文件,这就是可加载的内核模块。
在目标板(如树莓派)上加载驱动
- 将编译好的
pin4driver.ko 和交叉编译后的上层测试程序 pin4test 拷贝到目标板。
- 加载驱动模块:
sudo insmod pin4driver.ko
- 使用
lsmod 命令可查看已加载的模块。
- 使用
ls /dev/pin4 检查设备节点是否成功创建。
- 为设备节点设置合适权限,以便普通用户访问:
sudo chmod 666 /dev/pin4
- 运行测试程序:
./pin4test
- 查看内核日志,确认驱动函数被调用:
dmesg | grep pin4,应该能看到 “pin4_open” 和 “pin4_write” 的输出。
- 卸载驱动模块:
sudo rmmod pin4driver
关于编译环境的说明:驱动模块需要与运行的内核版本严格匹配,因此通常需要在拥有完整内核源码和交叉编译工具链的主机(如虚拟机)上进行编译,而不是在资源有限的嵌入式目标板上直接编译,这属于嵌入式开发与运维部署中的常见实践。