
如果你问我Linux驱动开发从哪里上手最划算我一定会回答写一个GPIO控制LED灯的驱动。不是因为它简单而是因为这颗小小的灯珠背后几乎串起了Linux设备驱动开发的全部主线——设备树怎么描述硬件platform驱动怎么和设备匹配字符设备怎么向用户空间暴露读写接口gpiod接口怎么统一控制GPIO。把这些链路吃透了再去看I2C、SPI、PCI这类复杂驱动你会发现骨架其实都一样。这篇内容不是给你贴一段能在网上抄到的“点灯代码”就完事而是把一套完整的Linux设备驱动开发流程拆开讲清楚从硬件引脚到设备树节点从驱动模型到文件操作接口从Makefile编译到应用层验证最后再把我实际踩过的几个坑摆出来。不管你是刚开始学嵌入式Linux还是在做STM32MP1、i.MX、瑞芯微这类平台的系统移植这篇都能当一份实操参考。1. 为什么是GPIO LED一颗灯珠串起驱动开发主线1.1 看似简单的LED其实覆盖了驱动开发的全部核心环节很多人觉得LED驱动太简单不值得专门写文章。实际上恰恰相反GPIO控制LED是Linux驱动开发里“性价比”最高的入门案例。它不需要复杂的外设协议不像I2C要处理地址和寄存器也不需要CPU密集型的数据通路不像网卡、GPU那么复杂但它覆盖了一个真实设备驱动必须面对的所有问题硬件资源怎么描述GPIO引脚属于哪个控制器、第几号引脚、是否低电平有效驱动和设备怎么绑定内核怎么知道这段驱动代码该服务于哪块硬件设备节点怎么生成应用层的/dev/xxx文件是哪里来的用户空间怎么控制open、write、ioctl这些系统调用如何穿越VFS到达驱动模块怎么编译和装载写好的代码怎么进入Linux内核并安全退出。这些问题在任何一个设备驱动里都存在LED驱动把它们浓缩到了一个尽量小的体量里。所以我会推荐刚入门的人直接拿GPIO LED做第一个练手项目而不是一上来就去啃复杂的SDIO或者Display驱动。1.2 一次“点灯”背后接近真实的内核调用链从应用层执行一条echo 1 /dev/gpio_led0到LED真正亮起来中间经过的层次大概是这样的应用层发起write();VFS根据文件描述符找到对应的struct file进而找到驱动注册的file_operations.write;驱动函数解析用户数据比如把字符“1”转成数字1;调用gpiod_set_value()操作GPIO描述符;gpiolib层根据描述符对应的GPIO控制器操作底层寄存器;电平输出到引脚LED亮起。所以我平时跟同事讲驱动架构时喜欢用一句话概括驱动就是“硬件能力”和“系统调用”之间的翻译官。GPIO LED这个例子把翻译官的工作流程完整演示了一遍。2. 驱动模型三件套字符设备、platform驱动、设备树如何分工在动手写代码前得先把三个基础概念理清楚。很多人第一次看驱动源码觉得乱就是因为没搞明白这三者各管哪一段。2.1 字符设备应用访问外设的Unix通道LED这种设备天然适合用字符设备框架。它的特点是数据按字节顺序读写像文件一样有open、read、write、close操作。内核里用struct cdev表示一个字符设备用struct file_operations描述它能支持哪些操作。写一个字符设备驱动核心就是实现file_operations里的函数然后调用cdev_add()把设备注册进内核。注册成功后内核会分配一个设备号用户空间就多出一个可以访问的设备节点。2.2 platform驱动让“设备”主动找上“驱动”在老派开发方式里板级代码会在arch/arm/mach-xxx/board-xxx.c里静态注册一个platform_device然后驱动通过platform_driver_register()匹配。现在的做法更现代设备树里写节点驱动只注册一个platform_driver内核的匹配机制会自动把设备树节点和驱动绑定起来然后调用驱动的probe()函数。这里有个关键点probe()不是驱动自己随便调用的而是内核匹配成功后回调的。设备树里写一个节点驱动里声明一个compatible字符串两者一致时内核才会执行驱动的初始化逻辑。所以很多调试问题最终都归结到“设备树节点和驱动的compatible没对上”。2.3 设备树取代board文件的新硬件描述方式设备树Device Tree解决的是“硬件描述和驱动代码分离”的问题。以前每换一块板子就要改一次内核源码里的board文件后来社区搞出了设备树用.dts文件描述CPU、内存、GPIO、时钟、外设等硬件信息编译成.dtb后交给内核解析。对驱动开发者来说设备树里最关键的就是你要把外设占用的资源描述清楚驱动再去获取这些资源。GPIO引脚就是这么一种资源。这三者的配合关系可以简单理解为角色类比职责设备树硬件配置单告诉内核有哪些硬件资源可用platform驱动业务逻辑代码内核匹配到设备后执行初始化字符设备对外服务窗口把驱动能力暴露给用户空间3. 设备树里描述一颗LED从硬件引脚到软件资源的映射我的开发环境以5.x内核为例SoC选型不限整个思路在i.MX、瑞芯微、全志这类平台上都通用。下面先看设备树节点怎么定义。3.1 一个适用于自研驱动的LED节点写法如果你要写的不是内核自带的leds-gpio驱动而是我们自己的字符设备驱动那设备树节点可以这么写/ { gpio_led: gpio-led { compatible myvendor,gpio-led; led-gpios gpio1 17 GPIO_ACTIVE_HIGH; status okay; }; };这里几个字段需要注意compatible驱动里of_device_id要匹配的字符串必须完全一致led-gpios这是GPIO描述符属性名字里带了“led”对应驱动中要用devm_gpiod_get(dev, led, ...)去取GPIO_ACTIVE_HIGH表示高电平有效如果硬件上是低电平点亮要改成GPIO_ACTIVE_LOWstatus一般调试阶段可以显式写okay如果某个型号的板子没贴这个LED可以改成disabled。设备树编译没你想的那么神秘就是在内核源码目录下执行make dtbs或者单独编译某个dtb。拿到新的.dtb后替换到boot分区里即可。3.2 从DTS到GPIO描述符的解析过程驱动里调用devm_gpiod_get()时内核会做这几件事根据设备节点的led-gpios属性找到对应的GPIO控制器解析出引脚号和有效电平标志在gpiolib框架里申请这个GPIO返回一个struct gpio_descGPIO描述符给驱动。这里要特别强调一点led-gpios属于GPIO描述符APIgpiod而不是老的gpio_request()那套gpios纯数字接口。gpiod接口是现在的主力它把“第几个控制器第几号脚”这种底层细节抽象掉了还自动处理了GPIO_ACTIVE_LOW的取反逻辑。如果你的平台还需要配置引脚复用pin mux比如某个引脚默认不是GPIO功能而是UART功能那设备树里通常还要加pinctrl-0和pinctrl-names属性指向SoC的pinctrl节点。这个和具体芯片关系很大但只要记住一条排查思路引脚不工作先看引脚复用配置对不对再看GPIO有没有被别的驱动占用。4. 驱动代码拆解请求GPIO、注册字符设备、实现读写4.1 驱动数据结构与入口出口设备驱动一般会定义一个私有数据结构把运行时需要的资源打包在一起。我的LED驱动结构体长这样#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/gpio/consumer.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/slab.h #define LED_ON 1 #define LED_OFF 0 struct gpio_led_dev { struct gpio_desc *led_gpio; /* GPIO描述符 */ struct cdev cdev; /* 字符设备 */ dev_t devno; /* 设备号 */ struct class *class; /* 设备类 */ struct device *device; /* 设备实例 */ };然后把平台驱动的声明放在文件最下面static const struct of_device_id gpio_led_of_match[] { { .compatible myvendor,gpio-led }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, gpio_led_of_match); static struct platform_driver gpio_led_driver { .probe gpio_led_probe, .remove gpio_led_remove, .driver { .name gpio_led, .of_match_table gpio_led_of_match, }, }; module_platform_driver(gpio_led_driver); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(A simple GPIO LED char driver);module_platform_driver()是个简化宏展开后会自动处理module_init和module_exit的注册少写一堆样板代码。4.2 probe函数里都干了些啥probe()是整个驱动最重要的函数它完成资源申请和初始化。我按依赖顺序一步步写在注释里static int gpio_led_probe(struct platform_device *pdev) { struct gpio_led_dev *priv; int ret; priv devm_kzalloc(pdev-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; /* 1. 申请GPIO并初始化为低电平 */ priv-led_gpio devm_gpiod_get(pdev-dev, led, GPIOD_OUT_LOW); if (IS_ERR(priv-led_gpio)) { dev_err(pdev-dev, failed to request led gpio: %ld\n, PTR_ERR(priv-led_gpio)); return PTR_ERR(priv-led_gpio); } /* 2. 动态分配设备号 */ ret alloc_chrdev_region(priv-devno, 0, 1, gpio_led); if (ret 0) return ret; /* 3. 初始化并添加字符设备 */ cdev_init(priv-cdev, led_fops); priv-cdev.owner THIS_MODULE; ret cdev_add(priv-cdev, priv-devno, 1); if (ret 0) { unregister_chrdev_region(priv-devno, 1); return ret; } /* 4. 创建设备类和设备节点 */ priv-class class_create(gpio_led); if (IS_ERR(priv-class)) { cdev_del(priv-cdev); unregister_chrdev_region(priv-devno, 1); return PTR_ERR(priv-class); } priv-device device_create(priv-class, pdev-dev, priv-devno, NULL, gpio_led%d, 0); if (IS_ERR(priv-device)) { class_destroy(priv-class); cdev_del(priv-cdev); unregister_chrdev_region(priv-devno, 1); return PTR_ERR(priv-device); } platform_set_drvdata(pdev, priv); dev_info(pdev-dev, gpio_led driver probed\n); return 0; }有几个细节值得展开devm_gpiod_get()里的led对应设备树属性的led-gpiosGPIOD_OUT_LOW表示在驱动加载时就把这个GPIO设置为低电平避免LED在上电瞬间乱亮。alloc_chrdev_region()是动态分配设备号主设备号由内核分配不用自己手工指定。device_create()的最后一个参数用了可变参数所以会在/dev下生成gpio_led0这个节点。4.3 file_operations用户空间操作LED的通道接下来是实现file_operations。对一个LED驱动来说write和ioctl是最常用的两个入口。这里给出一个完整的实现static int led_open(struct inode *inode, struct file *filp) { struct gpio_led_dev *dev container_of(inode-i_cdev, struct gpio_led_dev, cdev); filp-private_data dev; 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 *ppos) { struct gpio_led_dev *dev filp-private_data; char kbuf[16]; int value; if (count sizeof(kbuf) - 1) count sizeof(kbuf) - 1; if (copy_from_user(kbuf, buf, count)) return -EFAULT; kbuf[count] \0; if (kstrtoint(kbuf, 10, value) ! 0) return -EINVAL; gpiod_set_value(dev-led_gpio, value ? 1 : 0); return count; } static long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct gpio_led_dev *dev filp-private_data; switch (cmd) { case LED_ON: gpiod_set_value(dev-led_gpio, 1); break; case LED_OFF: gpiod_set_value(dev-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, };注意两点第一用户空间的buf不能直接在内核态访问必须用copy_from_user()拷贝否则可能会因为用户指针非法导致内核崩溃。第二gpiod_set_value()传的是逻辑值。什么意思就是说设备树里如果定义的是GPIO_ACTIVE_LOW你传1进去gpiolib会自动把物理引脚拉低来点亮LED。你不需要关心硬件电平方向这是gpiod接口相对老接口最舒服的地方。5. 把驱动编进内核Makefile、模块装载与内核内建对比驱动写完之后整个工程里最容易被忽略的部分是构建和部署。很多新手在PC上编了个hello.ko能在开发板insmod但一换到真实项目就懵了。这里把标准玩法写清楚。5.1 模块编译的标准Makefile如果按模块方式编译Makefile藏不下三行核心内容obj-m : gpio_led.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如果给开发板交叉编译改成这样obj-m : gpio_led.o KERNELDIR ? /path/to/kernel/source CROSS_COMPILE ? arm-linux-gnueabihf- CC : $(CROSS_COMPILE)gcc all: $(MAKE) ARCHarm CROSS_COMPILE$(CROSS_COMPILE) -C $(KERNELDIR) M$(PWD) modules编译完之后把gpio_led.ko拷到开发板执行insmod gpio_led.ko。如果一切正常dmesg里会看到“gpio_led driver probed”。5.2 装载、卸载与自动创建设备节点模块装载后/dev/gpio_led0这个节点一般情况下会自动出现。为什么能自动出现因为驱动里做了class_create和device_create内核的devtmpfs机制会同步在/dev下生成节点。如果有些板子默认没开devtmpfs就得靠mdev或者udev扫描class来创建设备节点。卸载时执行rmmod gpio_led驱动的remove函数会清理字符设备、设备类和设备号。需要提醒的是如果此时应用层还开着这个设备的文件描述符卸载通常不会成功或者会触发警告。所以调试阶段先杀掉占用节点进程再rmmod。5.3 内建进内核镜像的差异如果你的LED驱动属于基础硬件比如板子上的状态灯希望系统启动时自动初始化不适合等文件系统起来后再insmod那可以直接编进内核。做法很简单把驱动源码放进内核源码树的drivers/xxx/目录下Kconfig里加一个配置项或者临时直接把Makefile里的obj-m改成obj-y。内建方式的区别主要有两点不用再手动insmod系统启动时在do_initcalls()阶段就会执行驱动初始化设备树匹配成功后probe()会在启动早期被调用LED初始化更早。代价是每次改代码都要重新编译整个内核镜像调试迭代慢。所以我的习惯是功能开发阶段全部用模块完全稳定后再决定是否内建。6. 应用层验证与调试点灯之外还要会看日志驱动写完只是第一步怎么验证驱动真的正常工作这往往是考察一个人嵌入式基本功的地方。6.1 一句话点灯echo写入验证模块装载成功、设备节点也出现了就可以直接用命令行验证echo 1 /dev/gpio_led0 echo 0 /dev/gpio_led0如果LED能随命令亮灭说明整条链路是通的设备树匹配OKGPIO申请OK字符设备注册OKwrite回调执行OK。这里有个小技巧如果想验证write驱动函数是否真的被调用可以在led_write里加一句dev_info打印然后执行echo 1 /dev/gpio_led0看内核日志是否输出对应信息。这种方式比一遍遍猜问题出在哪要高效得多。6.2 自己写应用验证ioctlecho只能验证writeioctl得用一个小C程序测#include stdio.h #include fcntl.h #include sys/ioctl.h #include unistd.h #ifndef LED_ON #define LED_ON 1 #define LED_OFF 0 #endif int main(void) { int fd open(/dev/gpio_led0, O_RDWR); if (fd 0) { perror(open); return -1; } ioctl(fd, LED_ON, 0); sleep(1); ioctl(fd, LED_OFF, 0); close(fd); return 0; }编译时注意头文件里的宏定义要和驱动里保持一致否则cmd数值对不上驱动会返回-ENOTTY。6.3 节点与日志的常规诊断手段遇到驱动不工作先按下面顺序排查现象排查命令/方法设备节点不存在查看dmesg有没有probe失败ls /sys/bus/platform/drivers/gpio_led/确认驱动是否注册设备节点存在但操作报错cat /proc/devices看设备号是否注册ls -l /dev/gpio_led0看设备号和主次设备号GPIO状态不对在板子上用cat /sys/kernel/debug/gpio查看引脚状态确认GPIO是否被占用设备树没生效检查/proc/device-tree/gpio-led/目录下是否有compatible和led-gpios属性我自己调试时最常用的是/sys/kernel/debug/gpio它能把当前SoC所有GPIO的状态列出来包括哪个驱动占用了某个引脚、当前是输入还是输出、电平是高还是低。这个文件是硬件调试的照妖镜强烈推荐先用它。7. 实战中容易踩的四个坑匹配失败、GPIO占用、属性解析、权限这部分是我希望有人在入门时直接告诉我的内容。四个坑我带过的徒弟基本都踩过写出来给大家省时间。7.1 compatible字符串不一致导致probe不执行最常见的问题模块insmod进去了但dmesg里什么都没打印probe根本没被调用。原因基本都是设备树里的compatible和驱动of_device_id里的字符串没对上。排查时把设备树里节点导出来看真实值cat /proc/device-tree/gpio-led/compatible再看驱动里的定义strings gpio_led.ko | grep myvendor两个字符串必须一字不差包括大小写和标点。我建议在设备树和驱动里都用同一份宏定义或者固定写死尽量避免手抄造成不一致。7.2 GPIO被占用或pin mux没配置GPIO申请失败时dmesg会打印类似failed to request led gpio的错误。这时候用/sys/kernel/debug/gpio看一眼大概率会发现引脚被别的驱动占用了或者引脚复用根本没切到GPIO模式。遇到过最蹊跷的是引脚在设备树里被两个节点同时引用前一个驱动先probe把GPIO申请走了后一个驱动只能拿到-EBUSY。所以规划设备树时所有节点用到的GPIO一定要标注清楚别图省事重复用。7.3 active-low属性与实际硬件无法对应LED的硬件接法有两种一种是GPIO输出高电平LED正极接GPIO、负极接地另一种是GPIO输出低电平LED负极接GPIO、正极接VCC常见于板上电源指示灯。第二种接法就必须在设备树里写GPIO_ACTIVE_LOW。这里最大的坑是当你用gpiod_set_value(dev, 1)想要点亮LED时逻辑值是1但物理电平实际是0。如果你用示波器或者万用表去量发现引脚是低电平别慌这是对的行为。gpiod API已经把有效电平转换掉了。如果用老的gpio_set_value()接口就得自己在驱动里判断这也是我推荐gpiod接口的原因。7.4 设备节点权限不足和udev规则默认创建的/dev/gpio_led0经常是root权限普通用户执行echo 1 /dev/gpio_led0会报Permission denied。开发调试图省事可以直接chmod 666 /dev/gpio_led0但系统重启后又会恢复原样。正规做法是写一条udev规则添加/etc/udev/rules.d/99-gpio-led.rulesKERNELgpio_led0, MODE0666然后执行udevadm control --reload-rules udevadm trigger权限问题虽然不在内核驱动的职责范围里但它直接决定应用层能不能正常访问节点。很多项目联调阶段卡住不是驱动问题而是这种看似不起眼的系统配置问题。最后再分享一个我的个人习惯写这类驱动时每个资源申请都尽量用devm_前缀的版本。devm_gpiod_get、devm_kzalloc这些都是资源管理接口驱动卸载或者probe失败时内核会自动帮我们释放资源。少写不少错误处理代码也避免漏释放导致的问题。GPIO LED这个例子虽小但如果你能从设备树、probe、字符设备、模块编译一路捋下来Linux设备驱动开发的整体脉络就基本掌握在手里了。