
自己动手写一个字符设备驱动然后在开发板上把 LED 点亮这个项目我前前后后带过不少新人重新做每次做都还能挖出点新东西。原因很简单它把内核模块、设备注册、文件操作、GPIO 控制、应用层调用这几个最关键的环节一次性串起来而且成果是物理世界里的灯在亮比单纯打印日志直观太多了。这篇文章就从零讲一遍我自己实现点 LED 的整个过程包含驱动代码、Makefile、应用层测试程序以及只有实际动手才会踩到的几个坑。想搞清楚“字符设备”四个字背后到底是什么、想补 Linux 驱动入门这块短板的可以直接照着做。1. 项目拆解字符设备驱动到底在做什么1.1 字符设备是什么和 LED 有什么关系Linux 把设备大致分成三类字符设备、块设备、网络设备。块设备按块读写比如硬盘网络设备走协议栈比如网卡而字符设备是一条字节流你 open 一个设备节点后可以用 read、write 像操作文件一样去操作硬件。LED 这个东西本质上就是一根引脚上的高低电平按字节控制就够了不需要随机访问也不需要缓冲管理所以它天然适合用字符设备来封装。这个项目真正的难点从来不是“点灯”本身而是把一套完整的字符设备驱动框架跑通。等你把框架跑通了以后写按键、写传感器、写调速风扇都是同一套套路换汤不换药。所以我一直推荐新手拿 LED 做第一个驱动因为你把一半精力放在搞懂框架上剩下一半才是在调硬件。1.2 用户态怎么碰到硬件file_operations 这一层新手最容易困惑的一点是我在用户态写了一个open(/dev/led_driver, O_RDWR)为什么就能操作到 GPIO中间隔着一道内核隔离不是直接访问地址就能行的。真正干活的是驱动里注册的file_operations结构体。用户态调用驱动里的回调在本项目里的职责openled_open打开设备节点可以在这里做占用标记writeled_write接收 “0” 或 “1”控制 LED 亮灭ioctlled_ioctl接收命令字更适合后续功能扩展closeled_release关闭设备节点释放资源用户程序发起的系统调用经过虚拟文件系统 VFS 之后最终会落到这个结构体上。也就是说把file_operations里对应的函数指针填对用户态就能通过文件接口控制硬件。这是一条完整的链路应用层 → VFS → 驱动回调 → GPIO 控制器 → LED。1.3 方案取舍为什么用内核 GPIO 接口而不是裸寄存器有人可能会问GPIO 控制说到底不就是往某个寄存器写值吗我直接用ioremap映射物理地址再用writel写寄存器是不是更“底层”更底层是没错但在实际工程里非常不推荐。第一你得翻芯片手册搞清楚 GPIO 控制器的基地址、引脚偏移、方向寄存器、数据寄存器一旦配置错了轻则点不亮重则把整片区域搞挂。第二裸写寄存器没有“该引脚是否被其他驱动占用”的概念很容易出现两个驱动同时操作同一个引脚互相打架。第三内核里已经有 GPIO 子系统帮你做了引脚申请、方向设置、输出电平这些基础操作还带了调试信息直接用框架是更稳妥的做法。我用的是传统gpio_*接口而不是新的gpiod_*。原因是传统接口参数简单gpio_request传引脚编号gpio_set_value传电平适合把框架讲透。等你理解了字符设备这一层再去补设备树和gpiod那套不迟。2. 开发环境与驱动框架搭建2.1 环境准备先确定你要在哪里编译写内核模块第一件事是搞清楚编译环境和运行环境是不是匹配。最常见情况是两种一种是直接在目标开发板上做原生编译板子上得要装好内核头文件和 build 工具链另一种是在 PC 上交叉编译然后再把.ko文件拷到板子里。我这边用的是 PC 交叉编译的方式环境清单大致是这样Linux 主机一台用来跑 make交叉编译工具链目标架构对应 ARM 就装 ARM 的工具链目标开发板的内核源码或者内核头文件路径用KDIR指定开发板原理图确认 LED 接在哪个 GPIO 上是不是高电平点亮。如果你暂时没有板子也可以在一个标准发行版上编译模块并insmod只是没法真正看到 LED 动作。我建议还是找个带 LED 的开发板哪怕是在某个老平台上都行硬件反馈对理解驱动很有帮助。2.2 Makefile 应该怎么写内核模块的构建方式和普通 C 程序不一样不能直接gcc。你得借用内核的构建系统告诉它“我要把哪个.c文件编成.ko”。obj-m : led_driver.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) cleanobj-m表示以模块方式编译M$(PWD)告诉内核构建系统到当前目录找源码最后生成led_driver.ko。交叉编译时只要把架构和工具链前缀传进去make ARCHarm CROSS_COMPILEarm-linux-gnueabihf-这个命令只改编译环境不改代码。如果你想确认编出来的模块是什么架构直接file led_driver.ko就能看到。注意内核模块必须和运行它的内核严格匹配不是随便编出来就能 insmod 成功的。如果你在 PC 上编完直接拷到开发板十有八九会报 Invalid module format后面我会专门讲这个问题。2.3 模块的基本骨架驱动没有 main 函数入口由module_init指定。模块加载时内核调用初始化函数模块卸载时调用退出函数。最简单的骨架长这样static int __init led_init(void) { return 0; } static void __exit led_exit(void) { } module_init(led_init); module_exit(led_exit); MODULE_LICENSE(GPL);__init标记的代码在初始化完成后可以释放内存__exit同理只对模块生效。MODULE_LICENSE(GPL)不是可有可无的装饰很多内核函数只对 GPL 声明的模块开放GPIO 相关接口就属于这一类所以一定要写。3. 核心实现从设备号申请到 GPIO 点亮3.1 设备号申请与 cdev 注册字符设备在内核里靠“设备号”区分。设备号分成主设备号和次设备号主设备号决定使用哪个驱动次设备号通常用来区分同一驱动的不同实例。老式写法是自己指定一个主设备号然后用register_chrdev_region注册但主设备号很容易冲突现在更推荐用alloc_chrdev_region动态申请。static dev_t led_dev; static struct cdev led_cdev; ret alloc_chrdev_region(led_dev, 0, 1, led_driver); if (ret 0) { pr_err(alloc_chrdev_region failed\n); return ret; } cdev_init(led_cdev, led_fops); led_cdev.owner THIS_MODULE; ret cdev_add(led_cdev, led_dev, 1); if (ret 0) { pr_err(cdev_add failed\n); goto err_unregister; }alloc_chrdev_region会把分配到的设备号写入led_devMAJOR(led_dev)可以拿到主设备号MINOR(led_dev)可以拿到次设备号。接下来cdev_init把file_operations关联起来cdev_add把字符设备正式添加到内核。加载后执行cat /proc/devices就能看到led_driver和一个动态分配的主设备号。3.2 class 和 device让 /dev 节点自动出现光有 cdev 还不够你大概率会发现/proc/devices里有驱动但/dev/led_driver没有自动出现。以前的做法是手动mknod /dev/led_driver c 主设备号 0很麻烦。现代 Linux 通常用 udev 或 mdev 监听内核事件自动创建设备节点而这一套机制依赖驱动创建 class 和 deviceled_class class_create(THIS_MODULE, led_class); if (IS_ERR(led_class)) { ret PTR_ERR(led_class); goto err_cdev_del; } led_device device_create(led_class, NULL, led_dev, NULL, led_driver); if (IS_ERR(led_device)) { ret PTR_ERR(led_device); goto err_class_destroy; }device_create会触发 uevent用户态的 udev/mdev 接到消息后根据 class 目录里的信息在/dev下创建设备节点。这样你的世界里就有/dev/led_driver这个文件了。这里埋了一个小坑不同内核版本class_create的签名不一样。早期写法是class_create(THIS_MODULE, led_class)较新内核改成了只传名字。编译报参数个数不对时看一眼内核头文件就行别硬记 API记思路更重要。提示如果设备节点一直没出现先查 dmesg再看板子的用户态工具。有的精简文件系统只有 mdev需要先运行mdev -s扫描一次设备节点才会补出来。3.3 打通 GPIO申请、方向、输出设备节点有了驱动也注册好了接下来就是真正操作 LED 的部分。GPIO 编号在不同平台上含义不同我习惯把它做成模块参数这样换板子不用改代码加载时直接传参就行。static int led_gpio 19; module_param(led_gpio, int, 0644);模块参数的好处很明显比如insmod led_driver.ko led_gpio61。如果你的板子 LED 引脚编号是 61就不需要重新编译源码只需要换加载参数。驱动的初始化里面要按顺序做三件事if (!gpio_is_valid(led_gpio)) { ret -EINVAL; goto err_device_destroy; } ret gpio_request(led_gpio, led_driver); if (ret 0) { pr_err(gpio_request failed: %d\n, ret); goto err_device_destroy; } gpio_direction_output(led_gpio, 0);gpio_request相当于向内核登记“这根线我用了”如果已经有人占用会返回-EBUSY。gpio_direction_output一次性把引脚设置为输出模式并输出一个初始电平。这里要注意一定要先设方向再写值否则有些控制器在输入模式下写输出寄存器是无效的。还有一个所有新手都会忽略的问题LED 是低电平点亮还是高电平点亮取决于电路设计。如果板子上的 LED 是低电平点亮那你写 1 反而灭灯写 0 才是亮灯。代码逻辑里我默认了高电平点亮实际抄作业的时候先翻原理图确认极性。3.4 两种控制方式write 和 ioctl用户要控制 LED最简单的方式是往设备节点写字符。我做了 ASCII 判断写1亮写0灭。static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { char val; if (count 1) return -EINVAL; if (copy_from_user(val, buf, 1)) return -EFAULT; if (val 0) gpio_set_value(led_gpio, 0); else gpio_set_value(led_gpio, 1); return count; }这里必须用copy_from_user从用户态拷贝数据不能直接解引用用户空间指针。内核不能信任用户传进来的地址直接访问轻则 oops重则引入安全漏洞。这个习惯从一开始就要养好。除了 write我还会实现 ioctl。ioctl 适合传“命令”而不是“流数据”以后要加延时、加闪烁模式扩展起来比 write 舒服得多。#define LED_CMD_ON _IO(L, 1) #define LED_CMD_OFF _IO(L, 0) static long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case LED_CMD_ON: gpio_set_value(led_gpio, 1); break; case LED_CMD_OFF: gpio_set_value(led_gpio, 0); break; default: return -ENOTTY; } return 0; }_IO(L, 1)这类宏是 ioctl 命令字生成器L是魔数后面的数字是子命令号。驱动和应用各定义一份编译后命令字是一样的所以两头都能识别。这里我用了.unlocked_ioctl不是老教程里常见的.ioctl因为现代内核已经改用前一个回调了。file_operations里把这两条通道都接上static struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .release led_release, .write led_write, .unlocked_ioctl led_ioctl, };open和release现在只是空实现但必须提供。以后你要做“单开”限制、计数、资源释放都要在这两个函数里补逻辑。3.5 完整驱动源码整合版把上面所有片段拼起来就是一个可以直接编译运行的led_driver.c#include linux/module.h #include linux/init.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/ioctl.h #include linux/gpio.h #define DEVICE_NAME led_driver #define LED_CLASS_NAME led_class #define LED_CMD_ON _IO(L, 1) #define LED_CMD_OFF _IO(L, 0) static int led_gpio 19; module_param(led_gpio, int, 0644); MODULE_PARM_DESC(led_gpio, GPIO number used by LED); static dev_t led_dev; static struct cdev led_cdev; static struct class *led_class; static struct device *led_device; static int led_open(struct inode *inode, struct file *filp) { return 0; } static int led_release(struct inode *inode, struct file *filp) { return 0; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { char val; if (count 1) return -EINVAL; if (copy_from_user(val, buf, 1)) return -EFAULT; if (val 0) gpio_set_value(led_gpio, 0); else gpio_set_value(led_gpio, 1); return count; } static long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case LED_CMD_ON: gpio_set_value(led_gpio, 1); break; case LED_CMD_OFF: gpio_set_value(led_gpio, 0); break; default: return -ENOTTY; } return 0; } static struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .release led_release, .write led_write, .unlocked_ioctl led_ioctl, }; static int __init led_init(void) { int ret; ret alloc_chrdev_region(led_dev, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(alloc_chrdev_region failed\n); return ret; } cdev_init(led_cdev, led_fops); led_cdev.owner THIS_MODULE; ret cdev_add(led_cdev, led_dev, 1); if (ret 0) { pr_err(cdev_add failed\n); goto err_unregister; } led_class class_create(THIS_MODULE, LED_CLASS_NAME); if (IS_ERR(led_class)) { ret PTR_ERR(led_class); goto err_cdev_del; } led_device device_create(led_class, NULL, led_dev, NULL, DEVICE_NAME); if (IS_ERR(led_device)) { ret PTR_ERR(led_device); goto err_class_destroy; } if (!gpio_is_valid(led_gpio)) { ret -EINVAL; goto err_device_destroy; } ret gpio_request(led_gpio, DEVICE_NAME); if (ret 0) { pr_err(gpio_request failed: %d\n, ret); goto err_device_destroy; } gpio_direction_output(led_gpio, 0); pr_info(led_driver registered, gpio%d\n, led_gpio); return 0; err_device_destroy: device_destroy(led_class, led_dev); err_class_destroy: class_destroy(led_class); err_cdev_del: cdev_del(led_cdev); err_unregister: unregister_chrdev_region(led_dev, 1); return ret; } static void __exit led_exit(void) { gpio_free(led_gpio); device_destroy(led_class, led_dev); class_destroy(led_class); cdev_del(led_cdev); unregister_chrdev_region(led_dev, 1); pr_info(led_driver removed\n); } module_init(led_init); module_exit(led_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(A Developer); MODULE_DESCRIPTION(A simple char device driver for LED control);这个版本用的是传统 GPIO 接口简单直白。真正放到产品上我会建议改成gpiod接口配合设备树描述引脚代码更干净资源也能自动释放。不过从学习字符设备框架的角度这一版是最适合起步的。4. 应用层测试与联调让 LED 真正闪起来4.1 用户态测试程序驱动写完了得有一个用户态程序去喂它。最简单的测试程序就是用 write 按固定节奏写1和0让 LED 闪烁#include stdio.h #include fcntl.h #include unistd.h #include string.h #define DEVICE_PATH /dev/led_driver int main(void) { int fd, i; fd open(DEVICE_PATH, O_RDWR); if (fd 0) { perror(open /dev/led_driver); return 1; } for (i 0; i 5; i) { write(fd, 1, 1); usleep(500000); write(fd, 0, 1); usleep(500000); } close(fd); return 0; }编译方式和驱动不同用户态程序只需要普通编译器arm-linux-gnueabihf-gcc -o led_test led_test.c如果你在板子上原生编译直接用gcc就行。4.2 闪烁节奏的设计与观察我这边用的是 500ms 亮、500ms 灭肉眼看起来很明显又不会让人觉得太快。实际测试时有个很常见的错觉你把延时写成了usleep(1000)也就是 1ms 一次翻转人眼看到的不是闪烁而是灯变暗了一点于是有人会误以为驱动没生效。这时候最好用示波器观察 GPIO 引脚波形或者把翻转周期调到 200ms 以上再确认。没有示波器的话也可以在驱动代码里加一句打印比如每次 write 都输出当前电平。还有一个细节要注意用户态调用write(fd, 0, 1)时驱动只读取了第一个字节所以0能正确灭灯。但如果直接在 shell 里用echo 0 /dev/led_driver实际写入的是0和\n两个字节我的驱动只看第一个字节仍然能正确认出0。如果你的驱动实现是if (val ! 0) gpio_set_value(1)那0的 ASCII 码是 0x30不是 0就会出现只亮不灭的诡异现象。这个坑我见过不止一次。4.3 加载与联调完整流程拿到编译好的led_driver.ko和led_test之后按照下面的流程走一遍# 1. 加载驱动如果有需要就顺手指定 GPIO 编号 sudo insmod led_driver.ko led_gpio61 # 2. 确认驱动已经注册 cat /proc/devices | grep led_driver # 3. 确认设备节点已经创建 ls -l /dev/led_driver # 4. 跑测试程序 ./led_test如果/dev/led_driver没有出现在开发板上可以先手动创建MAJOR$(awk $2led_driver {print $1} /proc/devices) sudo mknod /dev/led_driver c $MAJOR 0注意mknod的次设备号要跟alloc_chrdev_region里申请的范围一致我这里是 0。测试完之后卸载驱动sudo rmmod led_driver dmesg | tail -20dmesg里应该能看到加载和卸载的打印信息这是驱动开发最基础的调试手段之一。5. 踩坑实录常见问题与排查流程5.1 模块加载失败提示 Invalid module format这是新手遇到最多的报错原因几乎都是“编译用的内核版本”和“运行时的内核版本”不一致。解决办法就是让KDIR指向目标板子实际运行的内核。排查命令uname -r modinfo led_driver.ko | grep vermagic如果modinfo显示的 vermagic 和uname -r对不上重新用正确的内核源码编译。还有一种情况是目标内核开了模块签名校验无签名模块会被拒绝。临时做法是重新编译内核时关掉CONFIG_MODULE_SIG或者把签名配好。5.2 GPIO 申请失败返回 EBUSY错误表现是insmod过程里打印gpio_request failed: -16-16 就是EBUSY。说明这个 GPIO 编号已经被其他驱动或者 pinmux 子系统占用了。排查路径# 挂载 debugfs sudo mount -t debugfs none /sys/kernel/debug # 查看当前系统 GPIO 占用情况 cat /sys/kernel/debug/gpio输出里会列出每个 gpiochip 的编号范围以及已经 claim 的 GPIO。看到某个引脚被占就换一个空引脚或者确认占用的驱动是不是你那块板子本来就不该加载的。还有一些板子的引脚需要先在设备树里做 pinmux 配置GPIO 编号只是其中一环光改模块参数是不够的。5.3 设备节点没出现或者打开时 Permission denied设备节点没出现先回看dmesg。如果初始化函数在执行device_create之前就报错节点自然不会创建。如果初始化成功了但用户态程序open时提示Permission denied多半是权限问题。内核创建的设备节点默认属主是 root权限可能是 0600普通用户访问不了。临时测试可以直接sudo chmod 666 /dev/led_driver正式一点的做法是写 udev rule在设备节点创建时自动设置为 0666。开发板如果不是用 udev 而是用 mdev记得先执行mdev -s扫描。5.4 驱动加载成功写值没反应驱动注册成功open也正常write 也正常但灯就是不亮。这种问题通常不是字符设备框架的问题而是 GPIO 控制或者硬件本身的问题。按顺序排查先确认gpio_request和gpio_direction_output都成功再确认模块参数led_gpio是否等于原理图上 LED 实际连接的引脚编号然后看极性LED 如果是低电平点亮驱动里输出 1 反而灭灯最后看硬件电路LED 有没有串限流电阻正负极是否接反。我碰到过一次最磨人的情况引脚编号完全正确电平也翻转了但灯不亮。最后发现是这颗 GPIO 复用成了别的功能要在设备树里先把 pinmux 切回 GPIO 模式。这种问题纯靠阅读电路原理图和板卡手册才能定位。5.5 一套快速排查模板我自己在调试时习惯用固定模板能把排查效率提高不少# 1. 内核日志永远先看 dmesg | tail -30 # 2. 看驱动是否注册成功 cat /proc/devices | grep led_driver # 3. 看设备节点是否存在 ls -l /dev/led_driver # 4. 看 GPIO 占用和输入输出电平 cat /sys/kernel/debug/gpio这套模板能覆盖掉 80% 的入门问题。剩下 20% 是芯片手册、原理图、设备树这些需要你静下心来读的硬资料不是看日志能解决的。6. 从点 LED 到正经驱动的几个写码习惯做完这个项目之后我养成了几个习惯后来写其他驱动也一直在用。第一个习惯是初始化函数里资源申请和释放必须对称。从alloc_chrdev_region开始每成功一步就要在错误路径上有对应的释放操作。我代码里用的是 goto label 的写法这是内核源码里最常见的错误处理风格。新手一开始会觉得 goto 很丑但看多了会发现它在“多级资源初始化”场景下反而是最清晰的。第二个习惯是绝不省略返回值检查。cdev_add可能失败device_create可能失败gpio_request可能失败。任何一步失败都要打断初始化返回错误码而不是继续往下跑。驱动跑在内核态一个错误处理不当轻则资源泄漏重则整个系统崩溃。第三个习惯是在动手写驱动前先把原理图和内核文档看明白。LED 接在哪个引脚是高有效还是低有效引脚有没有复用冲突这些事情写在代码里只需要一行但问题排查起来可能要半天。前期花十分钟读资料永远比后期花一小时调 bug 划算。最后一个想说的是这个项目后面可以扩展的方向很多给设备加mutex做并发保护用atomic_t限制只允许一个进程打开在 ioctl 里加更多控制命令或者把驱动改成platform_driver配合设备树自动匹配。做完基础版之后选其中一个方向继续改你会发现自己对 Linux 驱动的理解又上了一个台阶。最后说一个我自己的小习惯。每次写完一个驱动 demo我都会把 dmesg 的输出、用过的命令和出错时的解决过程整理成一张小抄贴在板子旁边。这个习惯看着不起眼但你隔两周再捡起同一块板子时就会发现它比任何教程都管用。