
做嵌入式Linux开发的朋友应该都有这种感觉驱动开发这事儿说难也难说简单也简单。难的是概念太多——设备树、platform总线、字符设备框架、gpio子系统、pinctrl子系统……层层嵌套一上来就容易懵。简单的是只要找到一个足够小的切入点把一条链路完整走通后面再接触I2C、SPI、USB这类复杂驱动都是换汤不换药。GPIO点灯就是那个最理想的切入点。一颗LED、一个限流电阻、一根杜邦线外加一个Linux开发板就能把字符设备驱动的完整生命线走一遍硬件原理、驱动框架、设备树匹配、模块编译、加载卸载、应用层交互。这套流程跑通之后你对Linux设备驱动到底在干什么会有一个非常清晰的体感。这篇文章我就以GPIO LED灯驱动为例子把我做驱动开发时的完整流程和踩坑经验整理出来希望对正在入门的朋友有些帮助。1. 为什么拿一颗LED灯来讲Linux驱动开发1.1 LED灯是驱动开发的最小闭环很多人觉得点灯太简单不屑于做。但实际上LED灯驱动是一个麻雀虽小、五脏俱全的项目。它涉及到的知识点几乎覆盖了Linux驱动开发的所有核心环节硬件上LED接到某个GPIO引脚高电平亮还是低电平亮由硬件原理图决定。驱动里需要申请GPIO、设置方向、控制输出电平。框架上要实现字符设备的file_operations接口让应用层能够通过open/write/ioctl来操作硬件。设备树里要配置compatible和GPIO属性让驱动能够匹配到具体设备。编译加载上要编写Makefile交叉编译生成.ko模块加载到目标板上。调试上要用dmesg、/proc、/sys等工具确认驱动状态。这哪是点灯这是在用一个最小系统验证你对整个Linux驱动开发流程的理解。等你把这个闭环跑通了再去学PCIe、USB、触摸屏这些大型驱动心里就有底了因为它们的骨架都是一样的差别只是业务逻辑更复杂。1.2 驱动、内核、硬件三层各自管什么在Linux系统里一次最简单的点灯操作应用层发出的数据要经过好几层才能到达硬件。我用一个生活化的类比来拆解一下。想象你去酒店入住要往房间送一份文件。你的角色是客人应用层前台的服务员是虚拟文件系统VFS内核的通用接口层客房服务人员是驱动设备驱动而房间里的设施LED就是最终执行你指令的硬件。你到前台说我要送文件去808房间前台不会自己去敲门而是打电话通知负责808房间的客房主管让他去办。客房主管带你到房间把文件放到桌上然后回个话搞定了。对应到Linux系统里就是应用层程序调用write(fd, buf, len)VFS根据文件描述符找到对应的驱动接口驱动拿到数据解析出指令和参数驱动调用gpio_set_value等接口操作GPIO控制器寄存器GPIO控制器输出高低电平点亮或熄灭LED。这个链条里驱动要做的事就是接到上层指令翻译成硬件能听懂的电平操作。开发者的注意力也就集中在怎么写好这个翻译官上面。2. 动手之前环境、平台与内核源码准备2.1 硬件平台选型与工具链做驱动开发硬件平台的选择很重要。我自己最早是在X86虚拟机上练手后来转到ARM开发板才真正跑通设备树。如果你是零基础给你两个路线参考。第一个是树莓派路线。树莓派有成熟的GPIO库和内核文档社区资料多遇到问题好查。缺点是官方内核的模块编译需要自己配置对新手来说环境搭建稍微绕一点。第二个是IMX6ULL、全志V3s这类入门级ARM板。这类板子通常出厂就带好用的交叉编译工具链和内核源码教程也多很适合做设备树相关的实验。我当时用的是全志的一块小板子交叉编译器是gcc-arm-linux-gnueabihf内核版本是5.4配置好之后编译模块特别顺。无论用哪个平台核心记住一条铁律模块编译所用内核源码的版本必须和板子上运行的内核版本完全一致。否则加载.ko时极大概率报Invalid module format错误。先查版本uname -r然后再去下载对应的内核源码解压后先配置、编译出基础产物后面模块构建才可能成功。这里我多说一句很多新手卡在make menuconfig怎么配置这一步其实对于编译外部模块来说你只需要让内核源码处于已经配置过且准备就绪的状态就行不需要完整编译整个内核。具体做法是在内核源码目录执行make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- menuconfig make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- modules_preparemodules_prepare这个目标就是专门用来准备模块编译环境的执行完会在源码目录生成Module.symvers等文件外部的模块代码就能基于这套内核源码来编译了。2.2 交叉编译工具链和主机环境如果你的目标平台是ARM板那么主机上的工具链是绕不开的。一般的交叉编译工具链命名会带一个前缀比如arm-linux-gnueabihf-编译的时候通过CROSS_COMPILE变量指定。安装工具链的方法因发行版而异比如在Ubuntu上可以这样搜索和安装apt search arm-linux-gnueabihf sudo apt install gcc-arm-linux-gnueabihf如果你用的是厂商提供的SDK通常里面已经包含了配套工具链设置好PATH环境变量就行。判断工具链能不能用的标准很简单执行arm-linux-gnueabihf-gcc -v能输出版本信息就说明OK。主机上还需要一个文件传输方式常见的包括scp、nfs、或者用SD卡拷贝。我强烈建议配置一个网络文件系统或至少能通过局域网传文件否则每次编译完模块都要拔卡插卡效率低得你想哭。scp命令大概长这样scp led_driver.ko root192.168.1.100:/root/然后在板子上执行insmod加载。这条流程看似不起眼但在实际开发中整个编辑-编译-传输-测试的循环效率往往决定了你能在一天里迭代多少个版本。3. 完整驱动源码拆解从骨架到血肉3.1 字符设备的基础骨架Linux设备驱动三大类字符设备、块设备、网络设备。我们控制LED本质就是读写字节所以字符设备是最合适的。字符设备驱动的核心是file_operations结构体它定义了open、release、read、write、ioctl等操作方法。你把这些方法实现好了注册给内核应用层再加一个打开对应设备节点的操作整个通路就打通了。先看一个最基本的驱动骨架我用的是经典的主设备号注册方式#include linux/module.h #include linux/fs.h #include linux/gpio.h #include linux/of_gpio.h #include linux/platform_device.h #include linux/uaccess.h #include linux/slab.h #define LED_DRIVER_NAME led-gpio-demo struct led_dev { int gpio; struct cdev cdev; struct class *class; dev_t devno; }; static struct led_dev *led_devp; static int led_open(struct inode *inode, struct file *filp) { filp-private_data led_devp; return 0; } static int led_release(struct inode *inode, struct file *filp) { return 0; } static ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct led_dev *dev filp-private_data; int val; char kbuf[4]; if (dev-gpio 0) return -EINVAL; val gpio_get_value(dev-gpio); kbuf[0] val ? 1 : 0; kbuf[1] \n; kbuf[2] \0; if (copy_to_user(buf, kbuf, 3)) return -EFAULT; return 3; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { struct led_dev *dev filp-private_data; char kbuf[4]; if (count 2) count 2; if (copy_from_user(kbuf, buf, count)) return -EFAULT; if (kbuf[0] 1) gpio_set_value(dev-gpio, 1); else if (kbuf[0] 0) gpio_set_value(dev-gpio, 0); else return -EINVAL; return count; } static const struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .release led_release, .read led_read, .write led_write, };这里最关键的是两点。一是filp-private_data这个指针用来在open之后把设备结构体传给read/write等函数省去全局变量的尴尬。二是copy_from_user和copy_to_user处理用户态和内核态之间的数据拷贝必须用这两个接口不能直接访问用户指针。很多新手在这里掉坑直接解引用用户指针结果内核崩溃。3.2 设备树匹配与platform_driver的现代写法传统的驱动写法里GPIO号是硬编码在代码里的比如s3c2410_gpio那种老掉牙的方式。现在内核推荐的做法是设备树Device Tree驱动通过compatible属性来匹配设备节点GPIO号也从设备树里动态解析可移植性大大增强。我用的设备树片段大概长这样led-gpio-demo { compatible vendor,led-gpio-demo; led-gpio gpio0 13 GPIO_ACTIVE_LOW; status okay; };对应的驱动里我们要解析这个节点static int led_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct led_dev *pdata; struct device_node *np dev-of_node; enum of_gpio_flags flags; int ret 0; if (!np) return -ENODEV; pdata devm_kzalloc(dev, sizeof(*pdata), GFP_KERNEL); if (!pdata) return -ENOMEM; pdata-gpio of_get_named_gpio_flags(np, led-gpio, 0, flags); if (pdata-gpio 0) { dev_err(dev, failed to get LED gpio\n); return -ENODEV; } dev_info(dev, LED GPIO %d\n, pdata-gpio); ret devm_gpio_request(dev, pdata-gpio, led-gpio-demo); if (ret 0) { dev_err(dev, failed to request GPIO %d: %d\n, pdata-gpio, ret); return ret; } ret gpio_direction_output(pdata-gpio, 0); if (ret 0) { dev_err(dev, failed to set GPIO direction output\n); return ret; } led_devp pdata; platform_set_drvdata(pdev, pdata); /* 注册字符设备 */ return 0; } static int led_remove(struct platform_device *pdev) { struct led_dev *pdata platform_get_drvdata(pdev); gpio_set_value(pdata-gpio, 0); /* 注销字符设备 */ return 0; } static const struct of_device_id led_of_match[] { { .compatible vendor,led-gpio-demo }, { } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_platform_driver { .probe led_probe, .remove led_remove, .driver { .name LED_DRIVER_NAME, .of_match_table led_of_match, }, }; module_platform_driver(led_platform_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple GPIO LED driver);这里有一个容易忽略的知识点devm_gpio_request和gpio_request的差别。带devm_前缀的接口是内核的设备资源管理device resource management机制它会在设备解除绑定时自动释放GPIO。也就是说即使你的remove函数忘了调gpio_free内耗也会帮你回收。这种写法在现代内核里是主流能少写很多错误处理路径推荐优先使用。3.3 字符设备注册的两种方式在内核里注册字符设备有两种常用方式。一种是老式的直接register_chrdev(major, name, fops)优点是一行代码搞定缺点是如果你指定了主设备号容易冲突而且不支持大于255的设备数量。另一种是推荐的使用alloc_chrdev_region动态分配设备号再用cdev_init和cdev_add来注册。动态分配设备号的好处是可以避免主设备号冲突。内核会从空闲的设备号中给你分配一个然后你通过/proc/devices查看具体拿到了哪个号再手动创建设备节点。完整代码片段如下static int led_setup_cdev(struct led_dev *pdata) { int ret; ret alloc_chrdev_region(pdata-devno, 0, 1, LED_DRIVER_NAME); if (ret 0) return ret; cdev_init(pdata-cdev, led_fops); pdata-cdev.owner THIS_MODULE; ret cdev_add(pdata-cdev, pdata-devno, 1); if (ret) { unregister_chrdev_region(pdata-devno, 1); return ret; } pdata-class class_create(THIS_MODULE, led_class); if (IS_ERR(pdata-class)) { ret PTR_ERR(pdata-class); cdev_del(pdata-cdev); unregister_chrdev_region(pdata-devno, 1); return ret; } device_create(pdata-class, NULL, pdata-devno, NULL, led_demo); return 0; }应用层的程序open这个设备节点然后往里面写1或0int fd open(/dev/led_demo, O_RDWR); write(fd, 1, 1); // 亮 write(fd, 0, 1); // 灭到这里一个完整的字符设备驱动闭环就已经跑通了。不过这只是从功能实现的角度距离产品级驱动还有一些距离。下面我要讲的是开发和调试过程中最容易踩的坑。4. 从源码到内核模块构建、加载与验证4.1 内核模块的构建脚本写好了C文件还需要一个Makefile才能编译成内核模块。内核的构建系统kbuild有一套固定的外部模块编译模板不需要你手动敲gcc命令那是不现实的因为模块编译要链接内核对导出的符号单独用gcc根本编不出来。我的习惯是建一个工程目录里面放led_driver.c和Makefileifneq ($(KERNELRELEASE),) obj-m : led_driver.o else KDIR : /path/to/kernel/source all: $(MAKE) -C $(KDIR) M$(PWD) ARCHarm CROSS_COMPILEarm-linux-gnueabihf- modules clean: $(MAKE) -C $(KDIR) M$(PWD) ARCHarm CROSS_COMPILEarm-linux-gnueabihf- clean endif这段Makefile有点trick的地方在于以ifneq ($(KERNELRELEASE),)分成了两部分。第一次执行make时因为没有定义KERNELRELEASE所以走else分支调用make -C $(KDIR)进入内核源码目录内核构建系统会把外部模块M$(PWD)作为目标重新调用make这时候KERNELRELEASE已经有值了就会执行第一行的obj-m赋值真正编译出led_driver.ko。如果你是在本机做实验KDIR可以设为/lib/modules/$(shell uname -r)/build同时去掉ARCH和CROSS_COMPILE因为本机架构一致直接用gcc就行。但是在ARM板子上必须指定交叉编译工具链不然编译出来的模块架构不对加载时会报wrong architecture之类的错误。编译完之后目录下会生成led_driver.ko。我先通过file led_driver.ko确认架构$ file led_driver.ko led_driver.ko: ELF 32-bit LSB relocatable, ARM, EABI5 version 1 (SYSV), BuildID[sha1]..., not stripped看到ARM字样说明架构正确。4.2 加载模块与创建设备节点模块传到板子上之后加载的顺序很关键。如果设备树里已经有匹配的节点那么platform_driver在注册的瞬间probe函数就会被调用设备节点也会自动创建。但前提是device_create时指定了正确的类名和设备名。如果一切顺利加载过程应该是这样insmod led_driver.ko然后查看内核日志dmesg | tail如果你看到类似led-gpio-demo: LED GPIO 13的输出说明probe函数跑到了GPIO号也正确解析出来了。然后查看设备节点ls -l /dev/led_demo如果设备节点存在就可以直接测试了echo 1 /dev/led_demo echo 0 /dev/led_demo注意这里有个细节。echo 1 /dev/led_demo和write(fd, 1, 1)是有区别的。echo默认会在字符串后面加一个换行符所以实际写到驱动的数据是1 \n两个字节。我们的write实现里对后面的换行符没有做特殊处理直接忽略所以功能上没问题。但如果是严谨的产品代码这里要处理用户传入任意数据的情况比如只取第一个字符其他全部忽略甚至可以按固定协议解析比如on和off。如果你没有用device_create自动创建设备节点而是在probe里只做了cdev_add那你就得手动创建设备节点先查看主设备号cat /proc/devices | grep led然后mknod /dev/led_demo c 240 0240就是分配到的动态主设备号不同环境可能不一样以/proc/devices显示为准。手动mknod这种方式在调试早期很常用但产品化时基本都用device_create自动建节点了配合udev应用层直接用/dev下的固定路径即可。4.3 应用层联调与常见测试方法驱动写完不测试等于白写。测试应用层有两种方式一种是我前面写的那种简易C程序另一种更简单直接用shell的echo/cat来验证。可以在板子终端逐个执行# 测试写 echo 1 /dev/led_demo sleep 1 echo 0 /dev/led_demo # 测试读 cat /dev/led_demoread实现里返回的是1或0加换行符cat会把它打印到终端。如果你看到输出和LED的实际状态一致说明read/write两个方向都正常。我还习惯用循环延时做一个简单的闪烁测试确认高频率操作不会出问题while true; do echo 1 /dev/led_demo; usleep 100000; echo 0 /dev/led_demo; usleep 100000; done这个脚本跑起来之后LED会以大约5Hz的频率闪烁。如果系统负载正常、dmesg里没有错误记录说明驱动在高频调用下是稳定的。另外我也测过用多个进程同时打开设备节点Linux的cdev是支持多进程打开的这种情况下只要你的read/write函数没有使用静态变量一般不会有大问题。5. GPIO LED驱动开发中的高频坑与排查思路做驱动开发有一个现实十个编译错误里七个是配置问题两个是设备树问题真正代码逻辑写错的反而不多。我把自己踩过的一些坑按频率从高到低列在下面。5.1 加载模块报Invalid module format这个问题90%是因为内核版本不一致。确认三件事编译模块时用的内核源码版本、目标板上运行的内核版本、以及配置是否一致。如果源码是从板子供货商给的一般不会出问题。如果你是网上随便下载了一个相近版本的内核源码那么大概率modules_prepare生成的一些头文件宏定义与板子内核不一致加载时就会报错。排查方法很简单modinfo led_driver.ko输出里有vermagic信息比如vermagic: 5.4.0-rc1 SMP preempt mod_unload modversions ARMv7 p2v8。再看板子内核cat /proc/version两者如果不匹配就只能换内核源码或者让它匹配。没有捷径。5.2 gpio_request成功但GPIO点不亮这种问题往往不是代码问题而是GPIO被复用了。ARM平台每个引脚往往有多个功能GPIO只是其中之一。如果同一个引脚被pinctrl设置成了UART的TX、I2C的SCL等功能那你request GPI0也可能成功但电平输出始终没反应。排查方法先看设备树里有没有其他节点占用了同样的GPIO再看pinctrl配置。你可以在板子的/sys/kernel/debug/gpio或/sys/class/gpio下查看GPIO占用情况和当前状态。使用cat /sys/kernel/debug/gpio可以看到当前所有GPIO的占用者、方向和电平值这是调试GPIO问题的神器。还有一个常见原因是硬件上LED接了高电平点亮但你gpio_set_value输出的是0LED自然不亮。这就是设备树里GPIO_ACTIVE_LOW和GPIO_ACTIVE_HIGH标志的意思一定要跟硬件原理图对清楚。5.3 设备节点存在但open失败如果/dev/led_demo存在但应用层open报No such device或No such file or directory说明设备节点和设备号对不上。检查设备节点的主/次设备号与cdev注册的是否一致ls -l /dev/led_demo cat /proc/devices | grep led如果不一致删掉重新mknod或者卸载模块后重新加载让device_create重新生成节点。还有一种情况是cdev_add虽然成功了但你在led_probe里又提前返回了错误导致drvdata没设置好其他函数访问不到设备。这种错误不太明显建议probe里每个步骤都加上dev_err打印逐步排查。5.4 高频调用时系统崩溃如果应用层频繁读写时系统崩溃大概率是竞态问题。比如read/write里访问了全局变量又没有加锁两个进程同时操作就会出问题。解决的思路通常是尽量使用filp-private_data每个打开的文件实例有自己的数据如果中间件有共享资源使用mutex保护确保copy_from_user/copy_to_user返回值被检查内核里严禁进行可能休眠的占用比如在自旋锁里调用gpio_set_value这类接口要小心。我把常见问题整理成一个速查表方便你对照排查现象大概率原因排查手段insmod失败Invalid module format内核版本不匹配对比modinfo和/proc/versioninsmod失败Unknown symbol模块引用了未导出的内核符号查看dmesg中的symbol名称检查内核配置GPIO点不亮方向设置错误、硬件接法反了、引脚复用冲突检查gpio_direction_output参数查看debugfs的gpio占用设备节点open失败设备号不匹配、驱动未加载ls -l /devcat /proc/deviceswrite没反应应用层数据格式不对用hexdump查看应用层实际发送的字节模块卸载死机卸载顺序有误、GPIO未释放remove里加打印检查是否有进程还持有fd5.5 调试驱动的几个实用技巧调试驱动不比调试用户态程序你没法用gdb直接断点。我常用的调试手段有这几个printk加上KERN_INFO/KERN_ERR级别在probe、open、write等关键路径打点。用dmesg -w实时监控日志能快速定位执行流程。使用/sys/kernel/debug/下的各种调试接口尤其是gpio、pinctrl这类子系统能看到内核当前对硬件资源的管理状态。用LED闪烁本身作为调试信号。比如在驱动的某个状态变化时让LED闪烁一次这比打日志更直观尤其在没有串口控制台的场景下非常管用。编译时开启动态调试CONFIG_DYNAMIC_DEBUG配合pr_debug可以在运行时通过echo file led_driver.c p /sys/kernel/debug/dynamic_debug/control来控制某个文件的调试输出不用重新编译。不要小看这些土办法在没有仿真器的嵌入式环境里它们就是最高效的调试工具。6. 进阶从自定义驱动到内核标准LED子系统6.1 led_class框架与leds-gpio写自己的驱动固然能学到东西但等你理解了整个流程之后再看内核现有的LED驱动会发现一个更优雅的世界。内核为LED设备提供了一个完整的子系统led-class。leds-gpio.c驱动是内核自带的GPIO LED驱动它通过设备树就能直接控制LED完全不需要你自己写字符设备。设备树里声明一个LED节点leds { compatible gpio-leds; led0 { label status-led; gpios gpio0 13 GPIO_ACTIVE_LOW; default-state off; }; };然后系统就会在/sys/class/leds/status-led/下自动生成控制接口你只需要往brightness文件里写值echo 255 /sys/class/leds/status-led/brightness echo 0 /sys/class/leds/status-led/brightness底层同样是GPIO操作但led-class框架帮你处理了设备节点、亮灭状态管理、甚至闪烁节拍等所有细节。这也是Linux驱动开发的乐趣所在系统已经帮你搭好了一大半的积木你要做的是理解积木的接口然后把它们拼成自己的产品功能。6.2 pinctrl子系统与GPIO的协作前面提到驱动里申请GPIO其实在更复杂的平台上GPIO的控制还牵扯到一个叫pinctrl的子系统。pinctrl负责引脚功能的复用pinmux和引脚电气属性的配置比如上拉、下拉、驱动强度。在设备树里一个GPIO要正常工作往往还需要pinctrl节点的配合。比如pio { led_pin: led_pin { pins PA13; function gpio_out; }; };内核的gpiolib在devm_gpio_request申请GPIO时会自动关联设备树里配置好的pinctrl状态把引脚切换为GPIO功能。这就是为什么用设备树驱动比硬编码GPIO号要省心那么多你只需要正确描述硬件连接内核的各个子系统会互相协作把这个引脚配置到位。如果你在新平台上搞GPIO点灯电平总是出不来首先就要检查pinctrl配置是否正确。很多BSP提供的参考设备树里同一个引脚可能被UART、I2C等外设复用了最终生效的配置取决于哪个节点先被解析、哪个驱动先probe这些都要靠pinctrl的配置来保证。6.3 我建议你继续做的几个练习写完GPIO LED驱动你可以沿着这条主线继续拓展写一个按键驱动用GPIO中断读到按键事件。这会触及Linux中断子系统包括request_irq、中断上下文、bottom half等概念。把LED驱动改成使用led-trigger定时器功能让驱动在内核态自动闪烁不需要应用层参与。增加platform_driver的电源管理回调在系统休眠时关闭LED唤醒时恢复状态。这会让你接触到Linux电源管理架构。如果把IOCTL也加上用ioctl命令来控制LED你还会接触到解锁和复制的经典问题这是很多面试官喜欢问的点。每完成一个练习你对Linux设备模型和内核抽象的理解都会深一层。等到有一天你发现一个驱动看起来不再是一堆看不懂的宏和回调而是一个由各种子系统按照清晰接口组装起来的模块时你就算是真正入了驱动开发的门。根据我个人的体会学习Linux设备驱动最关键的不是背接口、记函数而是把应用层-内核-硬件这个分层协作的模型吃透。当你脑子里有了这张大图驱动的每一行代码都不再是孤立的它们都在这个大图里有着明确的位置。GPIO LED驱动就是帮你建立这张大图的最好起点。