嵌入式Linux驱动开发------新 API 字符设备驱动完整教程 - 从设备结构体到应用测试
仓库已经开源!所有教程,主线内核移植,跑新版本imx-linux/uboot都在这里!欢迎各位大佬观摩!喜欢的话点个⭐!
仓库地址:https://github.com/Awesome-Embedded-Learning-Studio/imx-forge
静态网页:https://awesome-embedded-learning-studio.github.io/imx-forge/
前言:从老 API 到新 API
前面我们已经把老 API 驱动跑起来了,说实话,虽然能用,但代码写起来真的挺别扭的。硬编码主设备号、手动创建设备节点、资源浪费严重,这些问题在写简单驱动的时候还能忍受,但一旦项目复杂起来,这些坑会让你踩到怀疑人生。
所以我们决定用新 API 重写驱动。新 API 虽然代码量多一些,但每个步骤都清清楚楚,该申请什么资源、该释放什么资源,明明白白。更重要的是,它解决了老 API 的那些硬伤:动态分配设备号避免冲突,按需申请资源避免浪费,还能自动创建设备节点不用每次手动 mknod。
第一部分:设备结构体设计
全局变量的混乱时代
还记得我们最早写那个简单驱动的时候吗?代码里到处都是全局变量。一个设备需要设备号、cdev 结构、class 指针、device 指针,我们就定义了一堆全局变量。当时觉得挺简单的,反正只有一个设备,全局变量就全局变量吧。
但后来问题来了。假设板子上有 3 个 LED,每个 LED 都需要独立的控制。我们怎么办?定义 devid1, devid2, devid3?定义 cdev1, cdev2, cdev3?定义 cls1, cls2, cls3?代码量直接翻三倍,维护成本也是三倍。更糟糕的是,如果需要支持 8 个 LED 呢?16 个呢?难道要定义 16 组变量?
这时候我们就意识到,需要一种更好的组织方式。把描述一个设备所需的所有信息打包到一个结构体中,这样要支持多个设备只需要定义一个数组或者动态分配多个结构体实例。这就是面向对象思想在 C 语言中的应用。
设备结构体的基本思想
虽然 C 语言不是面向对象语言,但这不妨碍我们用面向对象的思想来组织代码。面向对象的核心是把数据(属性)和操作数据的方法(行为)打包在一起。在内核驱动开发中,"方法"的部分由 file_operations 回调函数实现,"属性"的部分则由设备结构体来实现。
一个简单的设备结构体可能是这样的:
c
struct led_device {
dev_t devid; /* 设备号 */
struct cdev cdev; /* 字符设备 */
struct class *cls; /* 设备类 */
struct device *dev; /* 设备 */
int major; /* 主设备号 */
int minor; /* 次设备号 */
};
现在如果需要支持多个设备,代码就简单多了:
c
struct led_device led1;
struct led_device led2;
struct led_device led3;
/* 或者用数组 */
struct led_device leds[8];
这样无论有多少设备,代码量都不会线性增长。操作设备的函数只需要接受一个设备结构体指针,就能访问这个设备的所有信息。
struct IMXAesLED 的设计
我们的 LED 驱动中定义了这样一个结构体:
c
struct IMXAesLED {
dev_t devid; /* 设备号 */
struct cdev char_device_handle; /* 字符设备 */
struct class* char_device_class; /* 设备类 */
struct device* char_device_device; /* 设备 */
};
我们来逐个分析这些成员。devid 存储分配给此设备的设备号,这在设备创建和销毁时都需要用到。
char_device_handle 是字符设备的核心结构体。这里我们使用完整的 struct cdev 而不是指针,这是有意的设计。使用完整结构体意味着不需要动态内存分配,简化了内存管理。在内核中,能避免动态分配就避免,因为内存分配可能失败,失败后还需要错误处理。对于固定大小的结构体,直接嵌入在父结构体中更安全。
char_device_class 是指向设备类的指针。这里用指针是因为 struct class 通常由 class_create() 返回,而且多个设备可以共享同一个 class。没必要在每个设备结构体中都嵌入一个完整的 class 结构体。
char_device_device 是指向设备结构体的指针,用于 sysfs 表示。和 class 一样,这也是指针,由 device_create() 返回。
你可能注意到这里的命名有点特别:char_device_handle 而不是简单的 cdev,char_device_class 而不是 class。这种长命名虽然敲起来麻烦,但在复杂代码中能减少混淆。当你几个月后再看这段代码,或者别人来维护你的代码时,能一眼看出这个变量是做什么的。
与老驱动的对比
老驱动(v1)使用的是静态主设备号,没有设备结构体封装:
c
/* chardev_led_v1_01_driver_main.c */
static const char* CHARDEV_NAME = "AES_LED";
static const int CHARDEV_MAJOR = 200; // 静态指定主设备号
static struct cdev aes_cdev;
static struct class *aes_class;
static struct device *aes_device;
// 分散的全局变量
这种方式简单直接,但有明显的缺点。静态主设备号可能导致冲突,如果 200 号已经被占用,驱动注册就会失败。而且所有变量都是全局的,没有组织在一起,代码可读性差。
新驱动(v2)改用动态分配设备号,设备信息封装在结构体中:
c
/* chardev_led_v2_02_driver_main.c */
static const char* CHARDEV_NAME = "AES_LED";
static const int LED_CNT = 1;
struct IMXAesLED {
dev_t devid;
struct cdev char_device_handle;
struct class* char_device_class;
struct device* char_device_device;
} led_handle; // 单一实例
// 封装的设备结构体
这种方式虽然代码量多了一些,但好处是明显的。设备号动态分配避免冲突,设备信息封装在一起易于理解,而且扩展到多设备非常方便。
private_data 模式
设备结构体解决了数据组织的问题,但还有一个问题:当用户程序调用 open()、read()、write() 时,驱动如何知道用户操作的是哪个设备?在多设备场景下,这个问题尤其重要。
内核提供的解决方案是 struct file 中的 private_data 字段。这是一个 void * 指针,驱动可以自由使用。通常的做法是在 open() 时把设备结构体指针存进去,在其他函数中取出来用。
c
static int aes_chardev_open(struct inode* inode, struct file* filp)
{
pr_info("Device: %s called open!\n", CHARDEV_NAME);
/* 设置私有数据 */
filp->private_data = &led_handle;
return 0;
}
这样在 read()、write() 等函数中就能访问设备结构体了:
c
static ssize_t aes_chardev_read(struct file* filp, char __user* buf,
size_t cnt, loff_t* offt)
{
/* 从 filp 获取设备结构体 */
struct IMXAesLED *dev = filp->private_data;
/* 现在可以访问设备的所有信息 */
// dev->devid, dev->char_device_handle, etc.
/* ... */
}
在 release() 中可以清理这个指针:
c
static int aes_chardev_release(struct inode* inode, struct file* filp)
{
pr_info("Device: %s called close!\n", CHARDEV_NAME);
/* 释放私有数据 */
filp->private_data = NULL;
return 0;
}
private_data 模式的真正威力在多设备场景下体现出来。假设我们有 3 个 LED,可以通过次设备号判断用户打开的是哪个:
c
/* 假设有 3 个 LED */
struct IMXAesLED led1, led2, led3;
static int led_open(struct inode *inode, struct file *filp)
{
int minor = MINOR(inode->i_rdev); // 通过次设备号判断
struct IMXAesLED *dev;
switch (minor) {
case 0: dev = &led1; break;
case 1: dev = &led2; break;
case 2: dev = &led3; break;
default: return -ENODEV;
}
/* 关键:把设备结构体保存到 filp->private_data */
filp->private_data = dev;
return 0;
}
这样 read() 和 write() 就不需要知道是哪个设备,它们直接从 private_data 取出设备结构体指针即可。
第二部分:驱动代码深度解析
驱动结构:文件是怎么组织的
我们先看看新驱动的文件组织,和 v1 老驱动相比,现在的结构清晰多了:
driver/chardev_led_v2_02/alpha-board/
├── chardev_led_v2_02_driver_main.c # 主驱动文件
├── led_hw.c # 硬件抽象层实现
├── led_hw.h # 硬件抽象层接口
├── led_reg.h # 寄存器定义
└── Makefile # 构建配置
以前我们把所有代码都塞在一个文件里,后来发现这根本不是个好主意。硬件操作、设备管理、文件操作,全部混在一起,改一个地方得在几百行代码里翻半天。现在我们把这些职责分开了,led_hw 负责硬件操作,主驱动负责设备管理和用户接口,各司其职。
新驱动和老驱动最大的区别,其实是设计思想的变化。老驱动就是"能跑就行",代码怎么写都无所谓;新驱动我们开始关注可维护性,用结构体封装设备信息,用函数抽象硬件操作,虽然代码量多了不少,但以后要改功能或者调试问题,会轻松很多。
init_led_handle():新 API 的三步走
初始化函数是新驱动的核心,完整展示了新 API 的"三步走"流程。我们把这个函数拆开来看,每一步都做得很清楚。
第一步:动态申请设备号
c
ret = alloc_chrdev_region(&led_handle->devid, 0, LED_CNT, CHARDEV_NAME);
if (ret < 0) {
return ret;
}
这里我们用 alloc_chrdev_region 动态申请设备号,而不是像老 API 那样硬编码一个主设备号。函数的第一个参数 &led_handle->devid 是传出参数,内核会把分配到的设备号写到这里;第二个参数 0 是次设备号的起始值;第三个参数 LED_CNT 是我们要申请的设备数量;第四个参数 CHARDEV_NAME 是设备名称,会出现在 /proc/devices 里。
动态分配的好处是,我们不需要猜哪个设备号是空闲的,内核会自动找一个给我们用。老驱动硬编码主设备号 200,如果系统里已经有驱动占用了这个号,我们的驱动注册就会失败。这个问题真的坑了我们好几次,换了个开发板或者加载了其他驱动,突然就起不来了,查半天才发现是设备号冲突。
分配成功后,我们打印一下分配到的设备号,方便调试:
c
const auto led_major_number = MAJOR(led_handle->devid);
const auto led_minor_number = MINOR(led_handle->devid);
pr_info("LED handle get the device number: major: %u, minor: %u\n",
led_major_number, led_minor_number);
第二步:初始化并注册 cdev
拿到设备号之后,下一步就是初始化并注册 cdev 结构体:
c
led_handle->char_device_handle.owner = THIS_MODULE;
cdev_init(&led_handle->char_device_handle, &fops);
ret = cdev_add(&led_handle->char_device_handle,
led_handle->devid, LED_CNT);
if (ret < 0) {
pr_warn("Error when trying to make a cdev in kernel: %d\n", ret);
return ret;
}
这里的 THIS_MODULE 宏很重要,它告诉内核这个 cdev 属于当前模块,防止模块在使用时被卸载。说实话,这个细节很容易被忽略,但如果你忘记设置这个字段,在某些情况下可能会遇到奇怪的问题,比如模块被卸载了但还有进程在使用设备,然后内核就炸了。
cdev_init 初始化 cdev 结构体,并把我们的 file_operations 结构体关联上去。cdev_add 把 cdev 添加到内核,这时候设备就正式注册了。
第三步:创建类和设备
最后一步是创建类和设备,这也是新 API 相比老 API 最大的改进:
c
led_handle->char_device_class = class_create(CHARDEV_NAME);
if (IS_ERR(led_handle->char_device_class)) {
const auto error_code = PTR_ERR(led_handle->char_device_class);
pr_warn("Failed to create a class, code: %ld", error_code);
return error_code;
}
led_handle->char_device_device =
device_create(led_handle->char_device_class, NULL,
led_handle->devid, NULL, CHARDEV_NAME);
if (IS_ERR(led_handle->char_device_device)) {
const auto error_code = PTR_ERR(led_handle->char_device_device);
pr_warn("Failed to create a device, code: %ld", error_code);
return error_code;
}
class_create 创建一个设备类,会出现在 /sys/class 目录下。device_create 创建具体的设备,这一步会自动在 /dev 目录下创建设备节点。也就是说,我们再也不用手动执行 mknod 命令了,驱动加载完设备节点就自动出现在 /dev 目录下。
说实话,这个改进真的太赞了。老驱动每次加载后都要手动创建设备节点,忘记这步的话用户程序就访问不了设备,而且用户必须知道正确的主设备号和次设备号,对新手来说很不友好。现在好了,insmod 完就能直接用,体验完全不一样。
这里要注意 IS_ERR 和 PTR_ERR 的用法。内核里很多函数用指针返回值,成功时返回有效指针,失败时返回错误码编码的指针。IS_ERR 判断是否是错误指针,PTR_ERR 把错误指针转换成错误码。这个模式和普通的返回值判断不太一样,一开始用的时候真的搞混了好几次。
错误处理:别让错误悄无声息地溜走
上面的代码为了简洁省略了错误处理,但在实际代码里,每一步都可能失败,我们需要妥善处理。正确的做法是用 goto 模式进行逆序清理:
c
static int init_led_handle(struct IMXAesLED* led_handle)
{
int ret;
ret = alloc_chrdev_region(&led_handle->devid, 0, LED_CNT, CHARDEV_NAME);
if (ret < 0) {
return ret;
}
led_handle->char_device_handle.owner = THIS_MODULE;
cdev_init(&led_handle->char_device_handle, &fops);
ret = cdev_add(&led_handle->char_device_handle,
led_handle->devid, LED_CNT);
if (ret < 0) {
goto failed_cdev;
}
led_handle->char_device_class = class_create(CHARDEV_NAME);
if (IS_ERR(led_handle->char_device_class)) {
ret = PTR_ERR(led_handle->char_device_class);
goto failed_class;
}
led_handle->char_device_device =
device_create(led_handle->char_device_class, NULL,
led_handle->devid, NULL, CHARDEV_NAME);
if (IS_ERR(led_handle->char_device_device)) {
ret = PTR_ERR(led_handle->char_device_device);
goto failed_device;
}
return 0;
failed_device:
class_destroy(led_handle->char_device_class);
failed_class:
cdev_del(&led_handle->char_device_handle);
failed_cdev:
unregister_chrdev_region(led_handle->devid, LED_CNT);
return ret;
}
错误处理的思路是,如果某一步失败了,就往前清理已经分配的资源。创建顺序是 alloc_chrdev_region → cdev_add → class_create → device_create,清理顺序就是反过来的 device_destroy → class_destroy → cdev_del → unregister_chrdev_region。
说实话,这种 goto 模式一开始看着有点怪,但写多了你会发现它确实是处理多步初始化的最佳实践。每一步失败都有一个对应的清理标签,资源不会泄漏,代码逻辑也清晰。别一听到 goto 就觉得是坏习惯,在内核代码里,这是标准写法。
release_led_handle():逆序清理的艺术
卸载函数比初始化函数简单得多,核心就是逆序清理资源:
c
static void release_led_handle(struct IMXAesLED* led_handle)
{
device_destroy(led_handle->char_device_class,
led_handle->devid);
class_destroy(led_handle->char_device_class);
cdev_del(&led_handle->char_device_handle);
unregister_chrdev_region(led_handle->devid, LED_CNT);
}
清理顺序一定要对,最后创建的最先销毁,最先创建的最后销毁。如果顺序乱了,可能会出现内核试图访问已经被释放的资源,然后就 panic 了。这种问题真的很难调试,因为不是每次都会复现,但一出现就是致命的。
与硬件抽象层的集成:分层设计的好处
新驱动的另一个重要改进是引入了硬件抽象层。硬件相关的操作全部封装在 led_hw.c 里,主驱动通过简洁的接口调用:
c
void led_hw_init(void);
void led_hw_deinit(void);
void led_set_status(bool status);
bool led_get_status(void);
这样的设计有什么好处呢?首先是代码复用,硬件操作可能被多处调用,封装成函数就不用重复写了。其次是隔离变化,硬件相关的代码集中在一个地方,以后换硬件或者改寄存器操作,只需要修改硬件抽象层,主驱动代码不用动。
主驱动的 write 函数就是这样调用硬件抽象层的:
c
static ssize_t aes_chardev_write(struct file* filp, const char __user* buf,
size_t cnt, loff_t* offt)
{
/* ... 参数验证 ... */
const bool led_new_status = (user_led_new_status == '1') ? true : false;
pr_info("LED status: %d (user_led_new_status='%c')\n",
led_new_status, user_led_new_status);
led_set_status(led_new_status); /* 调用硬件抽象层 */
return 1;
}
主驱动根本不关心硬件是怎么操作的,它只知道调用 led_set_status 就能设置 LED 状态。这种抽象让代码更易读,也更容易测试。你可以在硬件抽象层下面模拟硬件,单独测试主驱动的逻辑。
第三部分:应用开发与真实测试
前言:驱动写完了,但故事还没结束
前面我们已经把新 API 驱动写完了,代码编译也没报错,但说实话,这时候我们心里还是没底的。驱动这东西,不到开发板上跑一遍,你永远不知道哪里会炸。内核代码和用户空间程序不一样,一个指针错误就能让整个系统崩溃,连个调试信息都留不下。
所以接下来我们要做两件事:写一个用户空间的测试程序,然后把整个东西部署到真实开发板上,看看它到底能不能正常工作。这个过程其实比写驱动本身还要重要,因为只有通过真实测试,你才能确认代码不是在自嗨,而是真的能解决问题。
应用层开发:用户空间怎么和驱动对话
驱动在内核空间,应用程序在用户空间,它们之间通过系统调用和设备文件通信。我们的应用程序要做的事情很简单:打开设备文件,写入控制命令,读取设备状态。但说起来简单,实际写的时候还是有不少细节要注意的。
应用程序位于 driver/application/chardev_led_control/led_control.c,我们先看一下完整的代码:
c
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
void print_help(const char* app_name) {
printf("Usage: %s /path/to/chardev_file <0/1>\n", app_name);
printf(" - /path/to/chardev_file: char dev file in /dev/\n");
printf(" - <0/1>: 0 for off, 1 for on\n");
printf("@note: make sure the protocols match!\n");
}
int main(int argc, char* argv[])
{
if (argc != 3) {
print_help(argv[0]);
return 1;
}
const char* dev_file = argv[1];
const char* user_indication = argv[2];
/* 参数验证 */
if (strcmp(user_indication, "1") != 0 &&
strcmp(user_indication, "0") != 0) {
printf("Expected only 1 and 0, but get %s\n", user_indication);
return 1;
}
/* 打开设备文件 */
const int dev_file_fd = open(dev_file, O_RDWR);
if (dev_file_fd < 0) {
printf("Failed to open the file: %s, code: %d\n", dev_file, errno);
return 1;
}
/* 写入控制命令 */
write(dev_file_fd, user_indication, 1);
/* 读取设备状态 */
char buffer[2] = {0};
const int bytes = read(dev_file_fd, buffer, 1);
if (bytes < 0) {
printf("Failed to read the file: %s, code: %d\n", dev_file, errno);
return 1;
}
/* 打印状态 */
if (buffer[0] == '1') {
printf("LED is on now, status from the dev file!\n");
} else if (buffer[0] == '0') {
printf("LED is off now, status from the dev file!\n");
} else {
printf("Unknown value: %s", buffer);
return -1;
}
return 0;
}
这个程序虽然简单,但包含了用户空间和驱动通信的完整流程。我们先解析一下参数,用户需要提供设备文件路径和控制命令。设备文件一般是 /dev/AES_LED,控制命令是 0 或 1,0 关灯,1 开灯。
参数验证这一步真的不能省,用户输入千奇百怪,你不验证的话,什么奇怪的东西都能传进来。我们只接受 0 和 1 两个值,其他的直接拒绝。说实话,这种防御性编程在驱动开发里特别重要,你永远不知道用户会干什么。
编译与部署:从开发机到开发板
代码写完了,下一步是编译和部署。我们的开发环境是在 PC 上,代码要在 ARM 开发板上跑,所以需要交叉编译。
bash
cd /home/charliechen/imx-forge
./scripts/driver_helper/build_driver.sh chardev_led_v2_02 alpha-board
这个脚本会帮我们处理交叉编译的细节,包括设置交叉编译器、指定架构参数、编译驱动和应用程序。
编译完成后,我们把文件部署到开发板:
bash
./scripts/driver_helper/deploy_driver.sh chardev_led_v2_02 alpha-board
部署脚本会通过网络把驱动和应用程序拷贝到开发板的正确位置。我们的开发板配置要求内核版本 6.12.49 或更高,支持 mdev(BusyBox 的设备管理器),设备文件 /dev/AES_LED 会在驱动加载后自动创建。
真实测试输出:第一次跑起来是什么样
第一次在开发板上运行自己写的驱动,心情真的很复杂。既期待它正常工作,又怕哪里出问题炸板。我们先加载驱动:
bash
/lib/modules # insmod chardev_led_v2_02_driver.ko
然后盯着串口看日志输出,每一行都代表一个步骤的成功:
[ 84.386824] chardev_led_v2_02_driver: loading out-of-tree module taints kernel.
[ 84.387622] === led driver using new api ===
[ 84.387644] Step 0: Request MMU Mappings by ioremap
[ 84.387710] IMX6U_CCM_CCGR1 = 0xc59d421e (phys: 0x20c406c)
[ 84.387744] SW_MUX_GPIO1_IO03 = 0x17f27ee4 (phys: 0x20e0068)
[ 84.387759] SW_PAD_GPIO1_IO03 = 0xbb8efdcf (phys: 0x20e02f4)
[ 84.387775] GPIO1_DR = 0x3e65fd70 (phys: 0x209c000)
[ 84.387790] GPIO1_GDIR = 0x69125586 (phys: 0x209c004)
第一行是内核警告我们加载了一个树外模块,会污染内核。这在开发阶段很正常,不用管。接下来是驱动打印的初始化信息,ioremap 成功映射了 5 个寄存器。
然后是时钟使能:
[ 84.387806] Step 1: GPIO Enable Clock
[ 84.387813] CCGR1 raw value: 0xfcfc0000
[ 84.387813] Bits:
[ 84.387826] 11111100111111000000000000000000
[ 84.387835] CCGR1 new raw value: 0xfcfc0000
[ 84.387847] Bits: 11111100111111000000000000000000
CCGR1 寄存器的 bit 26-27 控制着 GPIO1 的时钟,这里已经被设置为 11,表示时钟已使能。
接下来是 GPIO 功能配置:
[ 84.387870]
[ 84.387880] Step 2: GPIO Functional Settings
[ 84.387888] Setting SW_MUX_GPIO1_IO03 = 0x5
[ 84.387901] GPIO1_GDIR set to 0x00000108
[ 84.387911] GPIO1_DR init set to 0xf004031c (LED OFF)
[ 84.387922] LED Init OK!
MUX 寄存器设置为 0x5(ALT5,GPIO 模式),GDIR 设置为 0x108(bit 3 为 1,输出模式),DR 初始化为 0xf004031c(bit 3 为 1,LED 关闭)。
最后是设备注册:
[ 84.387931] Init the User Interfaces and driver handles
[ 84.387944] LED handle get the device number: major: 241, minor: 0
[ 84.387966] cdev series api called success!
[ 84.388037] class create success!
[ 84.388557] device create success!
[ 84.388580] ========================
内核动态分配了主设备号 241,cdev 注册成功,class 创建成功,device 创建成功。到这里,驱动加载就完成了。
LED 控制测试:看看灯是不是真的能亮
接下来就是激动人心的时刻了,我们要看看 LED 到底能不能控制。先用 printf 命令测试一下:
bash
/lib/modules # printf '1' > /dev/AES_LED
然后看内核日志:
[ 125.236855] Device: AES_LED called open!
[ 125.237085] aes_chardev_write: cnt=1
[ 125.237114] LED status: 1 (user_led_new_status='1')
[ 125.237131] led_set_status: status=1, GPIO1_DR before=0xf004031c
[ 125.237147] led_set_status: GPIO1_DR after=0xf0040314, bit3=0
[ 125.237183] Device: AES_LED called close!
open() 被调用,write() 接收到 1 字节数据 1,led_set_status() 被调用,GPIO1_DR 从 0xf004031c 变成 0xf0040314。注意 bit 3 从 1 变成了 0,LED 点亮(低电平有效)。
我们再关掉 LED:
bash
/lib/modules # printf '0' > /dev/AES_LED
内核日志:
[ 130.690963] Device: AES_LED called open!
[ 130.691063] aes_chardev_write: cnt=1
[ 130.691082] LED status: 0 (user_led_new_status='0')
[ 130.691097] led_set_status: status=0, GPIO1_DR before=0xf0040314
[ 130.691114] led_set_status: GPIO1_DR after=0xf004031c, bit3=1
[ 130.691147] Device: AES_LED called close!
GPIO1_DR 从 0xf0040314 变回 0xf004031c,bit 3 从 0 变成 1,LED 熄灭。一切符合预期。
应用程序测试:完整的工作流
printf 命令测试通过后,我们用真正的应用程序测试一下:
bash
~ # /usr/local/bin/led_control /dev/AES_LED 1
LED is on now, status from the dev file!
应用程序成功执行,打印出 LED 已经点亮。
bash
~ # /usr/local/bin/led_control /dev/AES_LED 0
LED is off now, status from the dev file!
LED 成功熄灭。到这里,我们就可以确认驱动和应用程序都工作正常了。
驱动卸载测试:资源清理是否完整
最后我们测试一下驱动的卸载,确保资源能被正确释放:
bash
/lib/modules # rmmod chardev_led_v2_02_driver.ko
内核日志:
[ 155.317898] === chardev_led_v2_02驱动卸载成功 ===
[ 155.318567] Deinit the LED Hardware
[ 155.318623] ========================
我们验证一下设备节点是否被删除:
bash
/lib/modules # ls /dev/AES_LED
ls: /dev/AES_LED: No such file or directory
设备文件已经被删除,说明 device_destroy 工作正常。我们再检查一下设备号是否被释放:
bash
$ cat /proc/devices | grep AES_LED
# (无输出,设备号已释放)
设备号已经被释放,说明 unregister_chrdev_region 工作正常。资源清理完整,没有泄漏。
故障排查:遇到问题怎么办
虽然我们的测试很顺利,但实际开发中难免会遇到各种问题。这里我们总结一些常见的故障和排查方法。
如果设备节点没有创建,首先检查驱动是否真的加载了:
bash
$ lsmod | grep aes_led
chardev_led_v2_02_driver 2048 0
如果没有输出,说明驱动没加载成功,查看内核日志找原因。如果驱动加载了,检查设备号是否分配:
bash
$ cat /proc/devices | grep aes_led
241 aes_led
如果没有输出,说明设备号分配失败。再检查 class 是否创建:
bash
$ ls /sys/class/aes_led/
aes_led/
权限问题是另一个常见问题。如果你遇到这样的错误:
bash
$ ./led_control /dev/AES_LED 1
Failed to open the file: /dev/AES_LED, code: 13
# code: 13 = EACCES (Permission denied)
最简单的解决方案是用 sudo,或者修改设备文件权限:
bash
$ chmod 666 /dev/AES_LED
还有一种情况是程序执行成功了,但 LED 就是不亮。这时候需要确认驱动真的收到了命令:
bash
# 检查 write 是否被调用
$ dmesg | grep "aes_chardev_write"
# 检查 GPIO 操作
$ dmesg | grep "led_set_status"
如果这些日志都有,说明驱动工作正常,问题可能在硬件上。用万用表测量一下 GPIO 电平,确认硬件连接。
本章小结
这一章我们完成了从设备结构体设计到驱动实现,再到应用开发和测试的完整流程。设备结构体封装是从"能跑"到"专业"的关键一步。虽然简单的驱动用全局变量也能工作,但当系统变复杂时,缺乏组织的数据会变成维护噩梦。
新 API 的"三步走"流程清晰明了:alloc_chrdev_region 动态申请设备号,cdev_init + cdev_add 初始化并注册 cdev,class_create + device_create 创建类和设备。释放的时候要逆序清理,device_destroy → class_destroy → cdev_del → unregister_chrdev_region。
与老驱动相比,新驱动在架构上有很多改进。设备结构体封装了所有设备相关信息,硬件抽象层分离了硬件操作和业务逻辑,动态设备号分配避免了冲突,自动创建设备节点改善了用户体验。这些改进让代码更易维护、更易扩展。
驱动开发就是这样,代码写完了不算完,必须真实环境跑过才算数。只有通过测试,你才能发现那些在代码审查里看不到的问题,才能确认驱动真的能解决实际问题。
相关文档:
相关阅读
- 驱动错误处理模式 - 当资源分配失败时该怎么办 - 相似度 100%
- 深入理解Linux模块------第1章 Hello World内核模块:内核编程的第一步 - 相似度 60%
- 深入理解Linux模块------模块参数与内核调试:让模块"活"起来的魔法 - 相似度 60%