3. 第一个字符设备驱动:模块加载与卸载、注册字符设备、实现 open/release 操作
本章是字符设备驱动开发的起点。我们将从零构建一个完整的虚拟字符设备驱动,涵盖内核模块的加载/卸载框架、字符设备号的申请与注册,以及最基本的 open 和 release 操作实现。通过本章学习,你将掌握字符设备驱动的标准骨架,并能在 ARM 目标板上验证驱动的基本生命周期。
学习目标:
- 理解内核模块的
module_init/module_exit机制 - 掌握动态/静态分配设备号的方法
- 学会使用
cdev结构体注册字符设备 - 实现
open和release回调函数 - 在开发板上完成驱动加载、验证与卸载
3.1 内核模块框架:加载与卸载
Linux 驱动以内核模块的形式存在。模块使用 module_init 宏指定入口函数,使用 module_exit 宏指定出口函数。这两个函数是驱动与内核交互的第一道关口。
| 宏/函数 | 作用 | 调用时机 |
|---|---|---|
module_init(func) |
注册模块加载时调用的初始化函数 | insmod 时执行 |
module_exit(func) |
注册模块卸载时调用的清理函数 | rmmod 时执行 |
MODULE_LICENSE("GPL") |
声明模块许可证,避免内核污染 | 编译时静态声明 |
MODULE_AUTHOR("name") |
声明模块作者信息 | 编译时静态声明 |
关键点: 初始化函数必须返回 0 表示成功,返回负值表示失败。卸载函数负责释放所有已申请的资源,否则会导致资源泄漏。
下面是最简模块框架代码:
#include <linux/module.h>
#include <linux/kernel.h>
static int __init mydrv_init(void)
{
printk(KERN_INFO "mydrv: module loaded\n");
return 0;
}
static void __exit mydrv_exit(void)
{
printk(KERN_INFO "mydrv: module unloaded\n");
}
module_init(mydrv_init);
module_exit(mydrv_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Driver Expert");
MODULE_DESCRIPTION("A simple char device driver skeleton");
3.2 设备号分配:动态 vs 静态
每个字符设备在内核中由 设备号 唯一标识。设备号分为主设备号(major)和次设备号(minor)。主设备号对应驱动,次设备号对应同一驱动管理的不同设备实例。
| 分配方式 | 函数 | 优点 | 缺点 |
|---|---|---|---|
| 静态分配 | register_chrdev_region() |
设备号固定,便于用户空间 mknod | 需提前知道未使用的主设备号,容易冲突 |
| 动态分配 | alloc_chrdev_region() |
自动分配未使用的主设备号,无冲突 | 设备号不固定,需通过 /proc/devices 查看 |
实践建议: 在开发阶段优先使用动态分配,避免与现有驱动冲突。产品定型后,可申请固定主设备号或继续使用动态分配并配合 udev 自动创建设备节点。
动态分配设备号示例:
#include <linux/fs.h>
#define DEVICE_NAME "mydrv"
#define MINOR_COUNT 1
static dev_t dev_num; // 存储分配到的设备号
static int __init mydrv_init(void)
{
int ret;
// 动态分配设备号:第一个参数是输出,第二个是次设备号起始,第三个是数量,第四个是名称
ret = alloc_chrdev_region(&dev_num, 0, MINOR_COUNT, DEVICE_NAME);
if (ret < 0) {
printk(KERN_ERR "mydrv: failed to allocate device number\n");
return ret;
}
printk(KERN_INFO "mydrv: allocated major=%d minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
return 0;
}
static void __exit mydrv_exit(void)
{
unregister_chrdev_region(dev_num, MINOR_COUNT);
printk(KERN_INFO "mydrv: device number released\n");
}
3.3 注册字符设备:cdev 结构体
分配设备号后,需要将设备号与驱动操作函数绑定。内核使用 struct cdev 表示一个字符设备,通过 cdev_init() 和 cdev_add() 完成注册。
注册流程分为三步:
- 初始化 cdev:
cdev_init(&cdev, &fops)将文件操作结构体与 cdev 关联 - 设置所有者:
cdev.owner = THIS_MODULE防止模块被意外卸载 - 添加到内核:
cdev_add(&cdev, dev_num, count)将设备注册到系统
#include <linux/cdev.h>
static struct cdev my_cdev;
static int __init mydrv_init(void)
{
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) return ret;
// 2. 初始化 cdev
cdev_init(&my_cdev, &mydrv_fops);
my_cdev.owner = THIS_MODULE;
// 3. 添加 cdev 到内核
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
unregister_chrdev_region(dev_num, 1);
printk(KERN_ERR "mydrv: cdev_add failed\n");
return ret;
}
printk(KERN_INFO "mydrv: char device registered (major=%d)\n", MAJOR(dev_num));
return 0;
}
static void __exit mydrv_exit(void)
{
cdev_del(&my_cdev); // 从内核移除 cdev
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "mydrv: char device unregistered\n");
}
3.4 实现 open 和 release 操作
file_operations 结构体是驱动与 VFS 层的接口。本章我们实现最基本的两个回调:open 和 release。
| 回调函数 | 触发时机 | 典型实现 |
|---|---|---|
open |
用户调用 open() 系统调用时 |
递增使用计数、初始化硬件、分配私有数据 |
release |
用户调用 close() 系统调用时 |
递减使用计数、释放资源、关闭硬件 |
#include <linux/fs.h>
#include <linux/uaccess.h>
static int mydrv_open(struct inode *inode, struct file *filp)
{
// inode 包含设备号信息,filp 是文件描述符的内核表示
printk(KERN_INFO "mydrv: device opened (major=%d, minor=%d)\n",
imajor(inode), iminor(inode));
// 示例:将私有数据指针指向设备结构体(后续章节扩展)
// filp->private_data = my_dev;
return 0; // 0 表示成功
}
static int mydrv_release(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "mydrv: device released\n");
return 0;
}
// 定义文件操作结构体
static struct file_operations mydrv_fops = {
.owner = THIS_MODULE,
.open = mydrv_open,
.release = mydrv_release,
};
注意:
open 返回 0 表示成功,返回负值(如 -ENODEV、-EBUSY)表示打开失败。release 通常返回 0,内核忽略其返回值。
3.5 完整驱动代码与 Makefile
将以上所有片段整合为完整的驱动文件 mydrv.c:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "mydrv"
#define MINOR_COUNT 1
static dev_t dev_num;
static struct cdev my_cdev;
static int mydrv_open(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "mydrv: opened (major=%d, minor=%d)\n",
imajor(inode), iminor(inode));
return 0;
}
static int mydrv_release(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "mydrv: released\n");
return 0;
}
static struct file_operations mydrv_fops = {
.owner = THIS_MODULE,
.open = mydrv_open,
.release = mydrv_release,
};
static int __init mydrv_init(void)
{
int ret;
ret = alloc_chrdev_region(&dev_num, 0, MINOR_COUNT, DEVICE_NAME);
if (ret < 0) {
printk(KERN_ERR "mydrv: alloc_chrdev_region failed\n");
return ret;
}
cdev_init(&my_cdev, &mydrv_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, MINOR_COUNT);
if (ret < 0) {
unregister_chrdev_region(dev_num, MINOR_COUNT);
printk(KERN_ERR "mydrv: cdev_add failed\n");
return ret;
}
printk(KERN_INFO "mydrv: loaded, major=%d\n", MAJOR(dev_num));
return 0;
}
static void __exit mydrv_exit(void)
{
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, MINOR_COUNT);
printk(KERN_INFO "mydrv: unloaded\n");
}
module_init(mydrv_init);
module_exit(mydrv_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Driver Expert");
MODULE_DESCRIPTION("First char device driver with open/release");
配套 Makefile:
# Makefile for mydrv
obj-m := mydrv.o
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) clean
install:
sudo insmod mydrv.ko
remove:
sudo rmmod mydrv
info:
cat /proc/devices | grep mydrv
3.6 在 ARM 开发板上验证
将编译好的 mydrv.ko 传输到 ARM 开发板,按以下步骤验证:
- 加载模块:
insmod mydrv.ko - 查看设备号:
cat /proc/devices | grep mydrv记录主设备号 - 创建设备节点:
mknod /dev/mydrv c <major> 0 - 测试 open/release:
exec 3</dev/mydrv; echo "opened"; exec 3<&- - 查看内核日志:
dmesg | tail -10确认 open/release 打印 - 卸载模块:
rmmod mydrv
验证结果示例(dmesg 输出):
[ 1234.567890] mydrv: loaded, major=240
[ 1235.123456] mydrv: opened (major=240, minor=0)
[ 1235.234567] mydrv: released
[ 1236.345678] mydrv: unloaded
[ 1234.567890] mydrv: loaded, major=240
[ 1235.123456] mydrv: opened (major=240, minor=0)
[ 1235.234567] mydrv: released
[ 1236.345678] mydrv: unloaded
3.7 常见错误与调试
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
insmod: ERROR: could not insert module |
内核版本不匹配、符号缺失 | 检查 uname -r 与编译内核头文件版本一致 |
open(/dev/mydrv) failed: No such device |
设备节点主设备号与驱动不匹配 | 重新 cat /proc/devices 确认主设备号后重建节点 |
open(/dev/mydrv) failed: Permission denied |
设备节点权限不足 | chmod 666 /dev/mydrv 或使用 root 用户 |
| dmesg 无任何输出 | printk 日志级别被抑制 | 使用 printk(KERN_ALERT ...) 或调整 /proc/sys/kernel/printk |
rmmod: ERROR: Module mydrv is in use |
有进程持有设备文件描述符 | lsof /dev/mydrv 查找并关闭进程 |
内核调试黄金法则: 当驱动行为异常时,第一件事是检查
dmesg 输出。内核不会像用户空间程序那样崩溃打印段错误,而是通过 printk 输出诊断信息。在开发阶段,建议在 open、release 以及所有可能出错的路径上都加上 printk 日志。
3.8 本章小结
- 内核模块使用
module_init/module_exit管理加载和卸载生命周期 - 设备号通过
alloc_chrdev_region动态分配,避免冲突 cdev_init+cdev_add将字符设备注册到内核 VFS 层file_operations中的open和release是驱动与用户空间交互的入口- 完整的验证流程:insmod → 查看设备号 → mknod → open/close → dmesg → rmmod