嵌入式Linux驱动开发——一口气了结老字符设备——字符设备驱动完整指南 - 从硬件抽象到部署实战
仓库已经开源!所有教程,主线内核移植,跑新版本imx-linux/uboot都在这里!欢迎各位大佬观摩!喜欢的话点个⭐!
如果你看过一些简单的驱动教程代码,可能会发现它们把所有东西都塞在一个文件里:寄存器定义、硬件操作、字符设备接口、ioctl 命令…… 一两千行代码混在一起,初学者能跑通就行,根本不考虑代码组织。
说实话,这种写法对于教学 demo 来说或许可以接受,但对于实际工程来说是灾难。想象一下,当你需要把驱动移植到另一块板子上,或者硬件稍微改动了一下,你需要在一个几千行的文件里找出所有需要修改的地方。更糟糕的是,如果硬件操作代码和字符设备代码混在一起,改硬件时很可能不小心破坏了设备接口的逻辑。
所以我们采用分层设计。把硬件相关操作封装在一个独立的层里,向上提供清晰的接口。这样字符设备层只关注"用户想要什么"(开灯、关灯、查询状态),而不用关心"具体怎么操作寄存器"。硬件抽象层则专注于"怎么和硬件打交道",提供初始化、控制、查询等基础操作。这种设计带来的好处显而易见:代码职责清晰、易于维护、便于移植。当需要更换硬件时,只需要重写硬件抽象层,上层的字符设备代码可以完全不动。
我们来看看硬件抽象层的接口定义(led_hw.h):
void led_hw_init(void);
void led_hw_deinit(void);
void led_set_status(bool status);
bool led_get_status(void);
四个函数,两个用于生命周期管理,两个用于功能操作。接口非常简洁,而且名字就能说明用途。注意我们用 bool 类型表示状态,true 是亮,false 是灭。至于底层怎么实现——是写寄存器还是操作 GPIO 子系统——调用者完全不需要关心。有些严谨的工程师可能会问,这里为什么没有参数校验?比如 led_set_status() 传入一个非 0/1 的值怎么办?这个问题提得好。在我们的实现里,参数是 bool 类型,C 语言里任何非零值都会被当成 true,所以实际上不会有"非法值"这个概念。但如果接口设计成 int 类型,那确实需要考虑参数校验的问题。
现在我们深入到 led_hw.c 的实现,看看初始化过程是怎么做的。整个初始化分为三个步骤,这在代码注释里也有明确标注。
首先是寄存器映射。我们需要把所有用到的寄存器物理地址都映射到虚拟地址空间:
static void __iomem* IMX6U_CCM_CCGR1 = NULL;
static void __iomem* SW_MUX_GPIO1_IO03 = NULL;
static void __iomem* SW_PAD_GPIO1_IO03 = NULL;
static void __iomem* GPIO1_DR = NULL;
static void __iomem* GPIO1_GDIR = NULL;
static void ioremapping_registers(void) {
#define IOREMAP(BASE_ADDR) ioremap(BASE_ADDR, kRegSize)
IMX6U_CCM_CCGR1 = IOREMAP(kCCM_CCGR1_BASE);
SW_MUX_GPIO1_IO03 = IOREMAP(kSW_MUX_GPIO1_IO03_BASE);
SW_PAD_GPIO1_IO03 = IOREMAP(kSW_PAD_GPIO1_IO03_BASE);
GPIO1_DR = IOREMAP(kGPIO1_DR_BASE);
GPIO1_GDIR = IOREMAP(kGPIO1_GDIR_BASE);
#undef IOREMAP
pr_info("IMX6U_CCM_CCGR1 = 0x%p (phys: 0x%x)\n", IMX6U_CCM_CCGR1, kCCM_CCGR1_BASE);
pr_info("SW_MUX_GPIO1_IO03 = 0x%p (phys: 0x%x)\n", SW_MUX_GPIO1_IO03, kSW_MUX_GPIO1_IO03_BASE);
pr_info("SW_PAD_GPIO1_IO03 = 0x%p (phys: 0x%x)\n", SW_PAD_GPIO1_IO03, kSW_PAD_GPIO1_IO03_BASE);
pr_info("GPIO1_DR = 0x%p (phys: 0x%x)\n", GPIO1_DR, kGPIO1_DR_BASE);
pr_info("GPIO1_GDIR = 0x%p (phys: 0x%x)\n", GPIO1_GDIR, kGPIO1_GDIR_BASE);
}
这里用了一个小技巧:宏定义 IOREMAP 来简化代码。这样每行寄存器映射的代码都有统一的格式,而且方便调整映射大小(只需要改 kRegSize 的定义)。打印映射前后的地址对比,这在调试时非常有用——你可以一眼确认内核把物理地址映射到了哪个虚拟地址。
我们这里的实现为了教学简洁,省略了错误检查。但在实际工程中,每个 ioremap() 调用后都应该检查返回值。如果映射失败,应该打印错误信息并返回错误码,而不是继续执行导致后续崩溃。
第二步是使能时钟:
static void enable_gpio_clock(void) {
u32 clock_settings = readl(IMX6U_CCM_CCGR1);
pr_info("CCGR1 raw value: 0x%08x\n Bits: ", clock_settings);
pr_info("\n");
pr_bin_u32(clock_settings);
clock_settings &= ~(0b11 << 26); // 清除原来的值
clock_settings |= 0b11 << 26; // 设置为 11
pr_info("CCGR1 new raw value: 0x%08x \nBits: ", clock_settings);
pr_bin_u32(clock_settings);
pr_info("\n");
writel(clock_settings, IMX6U_CCM_CCGR1);
}
这里我们用了标准的"读-改-写"模式。先读取当前值,修改需要改的位,然后写回。注意我们用的是 &= ~ 和 |= 组合,这样可以保证只修改目标位,不影响其他位。打印二进制位的函数 pr_bin_u32() 虽然只有几行,但在调试时非常有用。你可以直观地看到修改前后的寄存器状态,确认时钟位确实被设置成了 11。
第三步是配置 GPIO 功能:
static void gpio_func_init(void) {
// 配置引脚复用为 ALT5 (GPIO 模式)
const u32 kGPIO_MUX_SETTINGS = 0b101;
pr_info("Setting SW_MUX_GPIO1_IO03 = 0x%x\n", kGPIO_MUX_SETTINGS);
writel(kGPIO_MUX_SETTINGS, SW_MUX_GPIO1_IO03);
// 配置电气特性
const u32 kGPIO_PAD_SETTINGS = 0x10B0;
writel(kGPIO_PAD_SETTINGS, SW_PAD_GPIO1_IO03);
// 配置为输出模式
const u32 kGPIO_DR_OUTPUT = (1 << 3);
u32 gpio_direction = readl(GPIO1_GDIR);
gpio_direction &= ~kGPIO_DR_OUTPUT; // 清除 bit 3
gpio_direction |= kGPIO_DR_OUTPUT; // 设置 bit 3 为 1
writel(gpio_direction, GPIO1_GDIR);
pr_info("GPIO1_GDIR set to 0x%08x\n", gpio_direction);
// 初始化为高电平(LED 熄灭)
u32 gpio_val = readl(GPIO1_DR);
gpio_val |= (1 << 3);
writel(gpio_val, GPIO1_DR);
pr_info("GPIO1_DR init set to 0x%08x (LED OFF)\n", gpio_val);
}
这一步做了三件事:配置引脚复用、配置电气特性、配置 GPIO 方向。每个配置我们都打印了调试信息,方便验证。注意 PAD 设置值 0x10B0,这是一个根据硬件手册计算出来的值,具体每一位的含义在硬件文档里有详细说明。如果你使用的是不同的开发板或不同的 LED 引脚,这个值可能需要调整。最后我们把 LED 初始化为熄灭状态(输出高电平)。这看起来有点多余,但考虑到系统启动时 LED 可能处于随机状态,明确设置一个初始状态是个好习惯。
控制函数的实现非常直接:
void led_set_status(bool status) {
const u32 led_bits = (1 << 3);
u32 gpio_val = readl(GPIO1_DR);
pr_info("led_set_status: status=%d, GPIO1_DR before=0x%08x\n", status, gpio_val);
if (status) {
gpio_val &= ~led_bits; // 清零 -> 点亮
} else {
gpio_val |= led_bits; // 置一 -> 熄灭
}
writel(gpio_val, GPIO1_DR);
pr_info("led_set_status: GPIO1_DR after=0x%08x, bit3=%d\n", gpio_val, !status);
}
bool led_get_status(void) {
u32 gpio_val = readl(GPIO1_DR);
return (gpio_val & (1 << 3)) == 0; // 低电平为亮
}
因为我们的 LED 是低电平有效,所以控制逻辑稍微有点"反直觉":写 0 点亮,写 1 熄灭。但我们在接口层做了抽象,调用者用 true 表示点亮、false 表示熄灭,不用关心底层电平。led_get_status() 的实现也体现了这一点——它读回寄存器的值,如果 bit 3 是 0(低电平),返回 true(点亮);否则返回 false。
这里有个细节需要特别注意:我们在 led_set_status() 里用的是 &= ~led_bits 和 |= led_bits,而不是直接赋值。这很重要,因为 GPIO1_DR 寄存器控制了 32 个 GPIO 引脚,我们只关心 bit 3,其他位不能动。如果直接赋值,会影响其他引脚的状态。
清理函数的实现很简单:
void led_hw_deinit(void) {
pr_info("Deinit the LED Hardware\n");
iounmap(IMX6U_CCM_CCGR1);
iounmap(SW_MUX_GPIO1_IO03);
iounmap(SW_PAD_GPIO1_IO03);
iounmap(GPIO1_DR);
iounmap(GPIO1_GDIR);
}
就是解除所有映射。注意这里没有把硬件恢复到初始状态(比如关闭时钟、恢复引脚复用)。这其实是个设计选择:如果这个硬件之后不会再被使用,恢复初始状态是礼貌的;但如果系统马上就要关机或重启,这些操作就没什么意义了。和初始化时必须按顺序来不同,清理时 iounmap() 的顺序不重要。每个映射都是独立的,谁先解映射都可以。
硬件抽象层已经完成。代码不长,但涵盖了驱动开发中最核心的概念:寄存器映射、时钟控制、引脚配置、位操作。现在我们来看看字符设备层是如何实现用户接口的。
第二章:字符设备驱动实现 - 让用户空间能玩硬件
前面我们把硬件相关的操作都封装到了硬件抽象层里。现在要做的事情,就是把这些硬件能力通过字符设备的接口暴露给用户空间。用户程序可以通过 open() 打开设备文件,通过 write() 发送控制命令,通过 read() 查询 LED 状态,最后通过 close() 关闭设备。这一章我们会分析 chardev_led_v1_01_main.c 的完整实现,看看字符设备驱动是怎么组织的,以及如何和硬件抽象层集成。
我们的主驱动文件结构大致是这样的:
// 头文件包含
#include "led_hw.h" // 硬件抽象层接口
#include "linux/fs.h" // 文件操作相关
#include "linux/printk.h" // 内核打印
#include "linux/uaccess.h" // 用户空间访问
// ... 其他头文件
// 设备标识
static const char* CHARDEV_NAME = "AES_LED";
static const int CHARDEV_MAJOR = 200;
// 文件操作函数
static int aes_chardev_open(struct inode* inode, struct file* filp);
static ssize_t aes_chardev_read(struct file* filp, char __user* buf, size_t cnt, loff_t* offt);
static ssize_t aes_chardev_write(struct file* filp, const char __user* buf, size_t cnt, loff_t* offt);
static int aes_chardev_release(struct inode* inode, struct file* filp);
// 文件操作结构
static struct file_operations fops = { ... };
// 模块初始化/退出
static int __init chardev_led_v1_01_init(void);
static void __exit chardev_led_v1_01_exit(void);
这是一个非常标准的字符设备驱动模板。如果你以后要写其他字符设备驱动,基本也是这个结构。
我们先来看最简单的 open() 函数:
static int aes_chardev_open(struct inode* inode, struct file* filp) {
pr_info("Device: %s called open!\n", CHARDEV_NAME);
return 0;
}
这是最简单的一个函数。它的作用是在用户打开设备文件时被调用,做一些必要的初始化工作。但对于我们的 LED 驱动来说,硬件初始化已经在模块加载时完成了(通过 led_hw_init()),所以这里不需要做任何额外的事情,只是打印一条日志记录一下。
你可能想知道 inode 和 filp 这两个参数是什么。inode 包含了文件的元数据信息,从这里可以获取设备号。filp(file pointer)代表了这次打开操作的文件实例,我们可以用它来存储一些私有数据。但我们的驱动很简单,不需要这些,所以这两个参数都没有使用。open() 的返回值是 int 类型,0 表示成功,负数表示错误。如果你返回一个负数,用户空间的 open() 调用会失败,并设置 errno。这里的 0 表示成功打开设备。
接下来是 read() 函数:
static ssize_t aes_chardev_read(struct file* filp, char __user* buf, size_t cnt, loff_t* offt) {
if (*offt > 0) {
return 0; // EOF,防止 cat 无限读取
}
if (cnt > 1) {
cnt = 1; // 我们只提供一个字节的状态
}
*offt += cnt;
const bool led_status = led_get_status();
const char user_indication = led_status ? '1' : '0';
const auto kResult = copy_to_user(buf, &user_indication, cnt);
if (kResult != 0) {
pr_warn("Failed to pass the led status to user! code: %ld\n", kResult);
return -EFAULT;
}
return cnt;
}
这个函数的工作是:当用户读取设备文件时,返回 LED 的当前状态。我们用字符 '1' 表示点亮,'0' 表示熄灭。有几个细节值得注意。
*offt > 0 的判断是为了防止 cat 这样的程序无限循环读取。cat 每次读到数据后会继续读,直到遇到 EOF(返回 0)。我们的设备只提供一个字节的状态,读一次就够了,所以当偏移量大于 0 时直接返回 0,告诉用户空间"没数据了"。
如果用户请求读取的数据量大于 1,我们只返回 1 个字节。这是因为 LED 状态只有一个字节,多返回没有意义。
你可能会想,为什么不用 memcpy(buf, &user_indication, cnt)?因为 buf 指向的是用户空间内存,而内核代码不能直接访问用户空间内存。原因有很多:用户空间指针可能是无效的、用户空间内存可能被换出、直接访问可能绕过安全检查等等。正确的做法是使用 copy_to_user(),这个函数会做所有的安全检查,并且在出错时返回未拷贝的字节数。如果返回值不是 0,说明拷贝失败了,我们返回 -EFAULT 表示错误。
然后是 write() 函数:
static ssize_t aes_chardev_write(struct file* filp, const char __user* buf, size_t cnt,
loff_t* offt) {
pr_info("aes_chardev_write: cnt=%zu\n", cnt);
if (cnt > 2) {
pr_warn("Get the unexpected data, that's too much!\n");
return -EINVAL;
}
char user_led_new_status = 0;
const auto kResult = copy_from_user(&user_led_new_status, buf, 1);
if (kResult != 0) {
pr_warn("Failed to set the led status from user! code: %ld\n", kResult);
return -EFAULT;
}
const bool led_new_status = (user_led_new_status == '1');
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。我们只接受 1-2 个字节的数据,如果用户写了太多数据,返回 -EINVAL 表示参数错误。这其实是一个简化的处理,严格来说应该更灵活一点,但对于 LED 控制这个场景来说足够了。
和 copy_to_user() 类似,从用户空间拷贝数据也必须使用专门的函数。copy_from_user() 会做安全检查,返回未拷贝的字节数。如果返回值不是 0,说明拷贝失败。我们设计的协议很简单:用户写入字符 '1' 点亮 LED,写入其他任何字符(通常用 '0')熄灭 LED。这个协议虽然简单,但很实用,而且容易扩展——如果将来需要支持亮度调节,可以增加 '2'、'3' 等命令。
release() 函数更简单:
static int aes_chardev_release(struct inode* inode, struct file* filp) {
pr_info("Device: %s called close!\n", CHARDEV_NAME);
return 0;
}
当用户关闭设备文件时这个函数被调用。我们的驱动不需要做任何清理工作,因为硬件资源是在模块卸载时统一释放的,而不是每次关闭设备时释放。所以这里只是打印一条日志。
我们把这些函数组织成 file_operations 结构:
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = aes_chardev_open,
.read = aes_chardev_read,
.write = aes_chardev_write,
.release = aes_chardev_release,
};
这个结构告诉内核:当用户对我们的设备文件执行各种操作时,应该调用哪些函数。.owner = THIS_MODULE 这一行很重要,它告诉内核这个结构属于哪个模块,用于模块引用计数管理。你可能注意到了,我们没有实现 llseek()、ioctl()、mmap() 等操作。这对于 LED 驱动来说没关系,因为我们不需要这些功能。如果用户尝试这些操作,内核会返回默认的错误代码(通常是 -ENOSYS 或 -EINVAL)。
模块初始化和退出函数:
static int __init chardev_led_v1_01_init(void) {
led_hw_init(); // 先初始化硬件
const int kResult = register_chrdev(CHARDEV_MAJOR, CHARDEV_NAME, &fops);
if (kResult != 0) {
pr_warn("Failed to register the chardev region! kResult=%d\n", kResult);
return kResult;
}
pr_info("%s load successfully!\n", CHARDEV_NAME);
return kResult;
}
static void __exit chardev_led_v1_01_exit(void) {
pr_info("=== chardev_led_v1_01 rmmod progress ===\n");
led_hw_deinit(); // 先清理硬件
unregister_chrdev(CHARDEV_MAJOR, CHARDEV_NAME); // 再注销设备
pr_info("========================\n");
}
初始化时我们做两件事:先初始化硬件,然后注册字符设备。这里用的是 register_chrdev() 这个 legacy API,所以只需要一行代码就完成了注册。如果注册失败,打印错误信息并返回错误码,这样模块加载会失败。一定要先初始化硬件,再注册设备。如果顺序反了,可能出现这种情况:设备注册成功了,用户空间马上开始操作,但硬件还没初始化好,就会出问题。虽然在这个简单的驱动里不太可能发生,但养成正确的顺序习惯很重要。
退出时我们做的和初始化相反:先清理硬件资源,再注销设备。顺序和初始化时相反,这是一个通用的原则——先建立的资源后释放,后建立的资源先释放。
你可能已经注意到了,整个字符设备层对硬件的访问只有三个地方:
led_hw_init(); // 初始化时调用
led_set_status(); // write() 里调用
led_get_status(); // read() 里调用
这就是分层设计的好处。字符设备层完全不需要知道寄存器地址、时钟配置、引脚复用这些细节,只需要调用硬件抽象层提供的接口。如果将来硬件改了,只需要修改硬件抽象层,字符设备层的代码一行都不用动。
驱动写好之后,用户空间怎么使用呢?首先需要创建设备节点:
mknod /dev/led c 200 0
这条命令创建了一个字符设备文件(c),主设备号是 200,次设备号是 0。主设备号 200 必须和我们在驱动里注册的一致(CHARDEV_MAJOR = 200)。然后就可以用普通文件操作来控制 LED 了:
# 点亮 LED
printf '1' > /dev/led
# 熄灭 LED
printf '0' > /dev/led
# 查询状态
cat /dev/led
还可以用 C 程序来操作,就像操作普通文件一样:
int fd = open("/dev/led", O_RDWR);
write(fd, "1", 1); // 点亮
char status;
read(fd, &status, 1); // 读取状态
close(fd);
到这里,我们的 LED 驱动代码实现就分析完了。从硬件抽象层到字符设备层,我们完成了驱动代码的编写。下一步,我们会看看如何编译这个驱动,如何把它部署到开发板上,以及如何调试常见问题。
第三章:构建和部署实战指南
前言:代码写完了,该让它跑起来了
前面我们花了很大篇幅讲硬件抽象层设计和字符设备实现。说实话,光看这些理论真的很虚,代码不跑起来,永远不知道哪里会炸。现在到了最激动人心的时刻——把代码编译成模块,部署到开发板上,看着 LED 听从我们的指挥亮灭。
第一步:理解 Makefile 的结构
在开始编译之前,我们先看看 Makefile 是怎么组织的。说实话,很多人(包括我们以前)都是直接复制粘贴 Makefile,改个文件名就开始编译,对里面到底发生了什么一知半解。这种做法在简单项目里可能没问题,但一旦出问题就两眼一抹黑。
我们先看第一部分:
# Kernel module definition
obj-m := chardev_led_v1_01_driver.o
chardev_led_v1_01_driver-y := chardev_led_v1_01_main.o led_hw.o
这两行告诉内核构建系统:我们要编译一个模块叫 chardev_led_v1_01_driver,它由两个目标文件组成:chardev_led_v1_01_main.o 和 led_hw.o。注意这里 .o 后缀的文件名不要加 .c,构建系统会自动找到对应的 .c 文件。
这里有个细节很多人容易搞错:obj-m 里的 m 代表 module,意思是这是一个可加载模块。如果你写的是 obj-y,那这个代码会被直接编译进内核镜像,不能作为单独的 .ko 文件加载。对于我们这种开发阶段经常需要修改代码的场景,obj-m 是更合适的选择。
接下来是路径配置:
# ── 项目配置 ────────────────────────────────────────
PROJECT_ROOT := $(shell realpath $(CURDIR)/../..)
ARCH := arm
CROSS_COMPILE := arm-none-linux-gnueabihf-
# 内核源码路径
KDIR := $(PROJECT_ROOT)/third_party/linux-${KERNEL_TYPE}
KOBJ := $(PROJECT_ROOT)/out/${KERNEL_TYPE}
# 输出目录
OUTPUT_DIR := $(PROJECT_ROOT)/out/driver_artifacts/chardev_led_v1_01/alpha-board
这里定义了一些路径和架构配置。ARCH 指定目标架构是 ARM,CROSS_COMPILE 指定交叉编译工具链前缀。这两个参数非常重要,如果填错了,编译出来的模块要么跑不起来,要么直接炸掉。
KDIR 指向内核源码目录,KOBJ 是内核编译输出目录。为什么要分开这两个?因为内核编译会在源码目录下生成大量中间文件,如果你直接在源码目录里编译,会把源码目录弄得很乱。通过 O 参数指定一个独立的输出目录,可以保持源码目录的干净。
最后是编译规则:
modules:
@mkdir -p $(OUTPUT_DIR)
$(MAKE) -C $(KDIR) M=$(CURDIR) O=$(KOBJ) \
ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules
@cp *.ko $(OUTPUT_DIR)/ 2>/dev/null || true
这一段是核心。-C $(KDIR) 切换到内核源码目录,M=$(CURDIR) 告诉内核构建系统我们的模块源码在哪里,O=$(KOBJ) 指定输出目录。最后把生成的 .ko 文件拷贝到我们的输出目录。
理解了这些,当编译出问题的时候,你就知道该去哪里找原因了。
第二步:编译驱动模块
我们的项目提供了构建脚本,使用起来比直接调用 make 更方便。你可能会问,为什么不直接用 make?因为脚本帮我们处理了一些繁琐的细节,比如检测内核类型、创建输出目录、拷贝文件等。
cd /home/charliechen/imx-forge
scripts/driver_helper/build_driver.sh chardev_led_v1_01 alpha-board
这条脚本会自动处理内核类型检测、编译、拷贝等操作。如果一切顺利,你会看到类似这样的输出:
🔨 编译chardev_led_v1_01驱动...
make[1]: Entering directory '/home/charliechen/imx-forge/third_party/linux-mainline'
CC [M] /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/chardev_led_v1_01_main.o
CC [M] /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/led_hw.o
MODPOST /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/Module.symvers
CC [M] /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/chardev_led_v1_01_driver.mod.o
LD [M] /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/chardev_led_v1_01_driver.ko
make[1]: Leaving directory '/home/charliechen/imx-forge/third_party/linux-mainline'
✓ 驱动编译完成: out/driver_artifacts/chardev_led_v1_01/alpha-board/chardev_led_v1_01_driver.ko
但说实话,第一次编译很少能这么顺利。我们总结了一些常见的坑,希望能帮你节省点时间。
第一个坑是找不到头文件。 这通常有几个原因:要么是 #include 路径写错了,要么是内核源码目录不完整,要么是你用的内核版本和目标板子不匹配。内核构建系统默认只会搜索内核源码目录下的 include 和架构相关的 include,如果你需要额外的头文件,需要在 Makefile 里用 ccflags-y 指定。
第二个坑是内核版本不匹配。 内核模块对版本依赖非常严格,必须用和目标内核完全匹配的源码编译。如果你在板子上跑的是 5.15.0,但你编译模块用的是 5.14.0 的源码,加载的时候会提示 "Invalid module format"。这种情况下,唯一的解决办法就是找到匹配的内核源码重新编译。
第三个坑是交叉编译工具链问题。 如果你的 CROSS_COMPILE 前缀不对,或者工具链不在 PATH 里,编译就会失败。一个简单的验证方法是直接运行 arm-none-linux-gnueabihf-gcc -v,看看能不能找到这个命令。
第三步:部署到开发板
编译成功后,.ko 文件位于 out/driver_artifacts/chardev_led_v1_01/alpha-board/ 目录下。现在需要把它部署到开发板。
我们的项目提供了部署脚本:
scripts/driver_helper/deploy_driver.sh chardev_led_v1_01 alpha-board
对于现在咱们是NFS调试阶段,那就部署到NFS上就好。
第四步:加载和测试驱动
加载驱动
登录到开发板,进入存放 .ko 文件的目录,执行:
insmod chardev_led_v1_01_driver.ko
如果成功,不会有任何输出。这是 Linux 的哲学:没有消息就是好消息。但如果你是个强迫症患者,可以用 lsmod 检查:
lsmod | grep chardev
你应该能看到 chardev_led_v1_01_driver 在列表里。
查看内核日志
驱动加载时会打印一些初始化信息,我们可以用 dmesg 查看:
dmesg | tail -20
你应该能看到类似这样的输出:
[ 1234.567890] IMX6U_CCM_CCGR1 = 0xf5d1000 (phys: 0x20c406c)
[ 1234.567891] SW_MUX_GPIO1_IO03 = 0xf5d2000 (phys: 0x20e0068)
[ 1234.567892] SW_PAD_GPIO1_IO03 = 0xf5d3000 (phys: 0x20e02f4)
[ 1234.567893] GPIO1_DR = 0xf5d4000 (phys: 0x209c000)
[ 1234.567894] GPIO1_GDIR = 0xf5d5000 (phys: 0x209c004)
[ 1234.567895] CCGR1 raw value: 0x00000000
[ 1234.567896] CCGR1 new raw value: 0x0c000000
[ 1234.567897] Setting SW_MUX_GPIO1_IO03 = 0x5
[ 1234.567898] GPIO1_GDIR set to 0x00000008
[ 1234.567899] GPIO1_DR init set to 0x00000008 (LED OFF)
[ 1234.567900] LED Init OK!
[ 1234.567901] AES_LED load successfully!
这些日志告诉我们硬件初始化的每一步都成功了。从 CCM 时钟寄存器到 GPIO 复用、PAD 配置、方向设置,每一步都有对应的打印。这种调试信息在开发阶段非常宝贵,能帮你快速定位问题。
创建设备节点
驱动加载成功后,还需要创建设备节点才能被用户空间访问。对于使用老 API 的驱动,这一步是必须的:
mknod /dev/led c 200 0
这条命令创建一个字符设备文件(c),主设备号 200,次设备号 0。主设备号必须和驱动里注册的一致(CHARDEV_MAJOR = 200)。
你还可以调整权限,让非 root 用户也能访问:
chmod 666 /dev/led
说实话,每次加载驱动后都要手动执行这些命令真的很烦。这也是为什么新 API 引入了自动创建设备节点的机制,但我们后面再讲这个。
测试 LED 控制
现在可以开始测试了:
# 点亮 LED
printf '1' > /dev/led
# 熄灭 LED
printf '0' > /dev/led
# 查询状态
cat /dev/led
如果一切正常,你会看到 LED 随着命令亮灭,cat /dev/led 会输出 '1' 或 '0' 表示当前状态。到这一刻,恭喜你,你的第一个字符设备驱动成功运行了!
第五步:排查常见问题
我们在折腾过程中遇到过各种问题,这里挑一些比较有代表性的分享一下。
insmod 提示 "Invalid module format"
这通常意味着模块版本和内核版本不匹配。内核模块对版本依赖非常严格,必须用和目标内核完全匹配的源码编译。解决方法:确认 KDIR 指向的内核源码和开发板运行的内核版本一致。你可以用 uname -r 在板子上查看内核版本,然后在主机上确认源码目录的版本。
insmod 时出现 "Unknown symbol"
这是因为驱动依赖的某个符号(函数或变量)在当前内核配置下没有被编译进去。检查 dmesg 输出,看具体是哪个符号找不到。如果是你自己在代码里调用了一个不存在的函数,需要换一个实现方式。
我们遇到过一次,驱动里用了 gpio_set_value,但内核配置没开启 GPIO 支持,结果加载时就报这个错。这种情况下,要么改内核配置重新编译内核,要么换一种方式控制 GPIO。
LED 不亮也不灭
这个问题可能的原因就多了。首先确认硬件连接没问题(LED 正确接到 GPIO1_IO03),然后用 dmesg 查看驱动日志,看初始化是否成功。检查寄存器地址是否正确,时钟是否开启,引脚复用是否配置正确。
一个有用的调试技巧是在 led_set_status() 里多加一些打印,看看函数是否被调用,寄存器读写是否成功。有时候你以为代码没问题,但实际跑起来跟你想象的不一样。
rmmod 时卡住或报错
这通常意味着有进程还在使用设备。检查是否有程序打开着 /dev/led 没有关闭,或者是否有后台进程在访问设备。用 lsof /dev/led 可以查看哪些进程在占用设备。
我们遇到过一次,测试程序异常退出了,但没有正确关闭文件描述符,导致 rmmod 一直卡住。最后只能 kill -9 杀掉那个进程,然后才能卸载驱动。
第六步:性能考虑(暂时不用太在意)
对于 LED 控制这种简单功能,性能基本不是问题。但如果你的驱动需要频繁操作寄存器,有一些优化技巧值得了解。
第一个是减少寄存器访问次数。如果可能,把多次读写合并成一次。比如配置多个位时,先构造好完整的值,一次性写进去,而不是每配置一个位就写一次。
第二个是考虑使用缓存。如果频繁读取同一个寄存器,可以在驱动里缓存它的值,避免重复读取。但要注意缓存一致性——如果寄存器值可能被硬件改变,缓存就会失效。
第三个是避免频繁的用户态/内核态切换。如果需要连续执行多个操作,考虑实现一个 ioctl 接口,一次性完成所有操作,而不是多次 write() 调用。
对于我们的 LED 驱动,这些优化都不必要,因为控制频率很低。但了解这些原则对以后开发更复杂的驱动有帮助。
卸载驱动
测试完成后,记得清理:
rmmod chardev_led_v1_01_driver
rm /dev/led
rmmod 会调用驱动的 exit 函数,清理硬件资源和注销设备。你应该能在 dmesg 里看到类似这样的输出:
[ 2345.678901] === chardev_led_v1_01 rmmod progress ===
[ 2345.678902] Deinit the LED Hardware
[ 2345.678903] ========================
本章小结
到这里,我们已经完成了从硬件理解到驱动实现,再到编译部署的完整流程。你掌握的不仅仅是一个 LED 驱动,更是一套可以复用到其他驱动的开发流程和方法论。
回顾一下,我们学到了什么:
第一,硬件抽象层设计的重要性。通过分层设计,我们让硬件操作和用户接口完全解耦,代码职责清晰,易于维护和移植。
第二,字符设备驱动的标准结构。从 file_operations 结构到各个回调函数的实现,这是一个可以复用的模板,适用于各种字符设备驱动开发。
第三,理解构建和部署流程很重要。不要只是复制粘贴 Makefile,知道每个参数的作用,出问题的时候才能快速定位。
第四,编译过程可能会遇到各种坑,大多数是路径、版本、工具链相关的问题。耐心排查,这些问题都有明确的解决方案。
第五,部署和测试是验证代码正确性的关键步骤。内核日志是你的好朋友,多打印信息能帮你快速定位问题。
下一步,你可以尝试修改代码,实现更复杂的功能。比如支持亮度调节(PWM)、支持闪烁模式、通过 ioctl 实现更多控制命令。或者,你可以去看看新 API 的实现,了解现代字符设备驱动的标准写法。
驱动开发的大门已经打开了,接下来就是不断实践和探索了。祝你在内核驱动的世界里玩得开心!
相关阅读
- 深入理解Linux模块——模块参数与内核调试:让模块"活"起来的魔法 - 相似度 100%
- 深入理解Linux模块——内核模块编译与加载详解:从 Makefile 到 insmod 的完整旅程 - 相似度 100%
- 嵌入式Linux驱动开发(3)——内核模块机制 - Linux 的插件系统 - 相似度 100%