嵌入式Linux GPIO LED驱动开发:从字符设备到设备树全流程解析
发布时间:2026/9/13 13:15:15来源:尧图网络
做嵌入式Linux这一行十个工程师里面估计有九个写过的第一个驱动是点灯。这个题目听起来不起眼但一个完整的GPIO LED灯驱动实际上把Linux设备驱动开发的主干全部串起来了字符设备框架、设备树、GPIO子系统、platform驱动模型、用户态交互接口、模块编译加载。我刚入行时踩了不少坑回头再看这套流程就是嵌入式Linux驱动开发的基本盘。这篇文章就从我实际写这个驱动的过程出发把每一步为什么这么做、怎么做、遇到问题怎么查全部摊开来讲。适合正在入门嵌入式Linux驱动开发、准备找相关岗位、或者被设备树和platform框架绕晕的朋友。1. 项目拆解一个LED驱动背后藏着Linux设备驱动的全部骨架1.1 为什么我选LED驱动来做入门主线LED驱动是典型的“外设简单但框架完整”的项目。硬件上就是一个GPIO引脚控制一个灯逻辑上就是输出高或低电平没有任何协议解析、时序要求、DMA中断之类的高难度内容。但正因为硬件足够简单你才能把精力全部集中在Linux驱动本身的结构上而不是被复杂的硬件逻辑带偏。通过写这个驱动我至少把下面这些核心概念全部过了一遍字符设备驱动的file_operations结构体如何工作cdev设备号分配、注册、注销的完整生命周期设备树节点如何描述硬件信息驱动如何从设备树获取资源platform_driver和设备树节点的匹配机制GPIO子系统的标准API用法以及传统接口和描述符接口的区别内核模块的编译、加载、卸载以及设备节点的自动生成这些内容看起来多但集中在LD LED这一条主线上时逻辑非常清晰。学完这个再去看I2C、SPI、中断、DMA等驱动你会发现它们的骨架都是一样的区别只是硬件控制逻辑的复杂度不同。1.2 驱动架构选型字符设备、misc设备还是内核LED子系统开始动手之前有一个方案选择要先想明白。Linux内核里能点灯的驱动写法不止一种我见过有人直接写misc设备驱动有人推荐用内核自带的led子系统还有人在应用层直接操作/dev/mem。简单梳理一下方案优点缺点适合场景手写字符设备驱动学习价值高能掌握完整驱动框架代码量大需要自己处理设备号、class、device等学习驱动开发原理misc设备驱动代码简洁自动分配主设备号掩盖了字符设备注册的细节不利于理解底层快速实现小型外设驱动内核LED子系统(gpio-leds)内核现成框架无需写C代码设备树配置即可无法学习到底层驱动实现产品落地、快速开发应用层操作/dev/mem调试方便不被产品接受绕过内核管理有安全风险软硬件联调阶段临时验证我在这个项目里选择的是第一种手写字符设备驱动。原因很简单如果只是为了让灯亮起来设备树里配几行属性就够了根本不需要写驱动。但这样就永远搞不清楚设备树、platform驱动、cdev注册、GPIO操作这些概念是怎么串起来的。既然题目是“阐述Linux设备驱动完整开发”那就老老实实从最底层走一遍。1.3 整体数据流从echo 1到引脚翻转动手写代码之前先把数据的流通路径想清楚。用户态执行一条命令echo 1 /dev/led0这条命令背后的完整链路是shell打开/dev/led0这个设备节点触发内核VFS层查找对应的inodeinode里记录的主设备号和次设备号找到对应的cdevcdev调用led_fops结构体中的open函数echo命令写入字符1VFS调用led_fops中的write函数write函数解析用户态传入的数据决定点亮还是熄灭通过GPIO子系统API设置对应引脚的电平引脚电平变化LED灯亮或灭把这条链路画在脑子里整个驱动需要什么代码就自然而然清楚了。用户态接口需要open、write硬件控制需要GPIO操作中间的关键是设备号、cdev、设备树资源的关联。2. 环境准备与工程骨架搭建2.1 硬件平台与交叉编译环境我用的开发板是Cortex-A7内核的入门级Linux板卡主控型号是NXP i.MX6ULL这颗芯片在嵌入式Linux学习圈很常见资料多BSP完整。实际上你用什么芯片都可以只要内核版本在4.x以上设备树机制和GPIO子系统的用法基本一致。编译环境需要准备的东西如下组件说明开发服务器任意Linux发行版Ubuntu 18.04/20.04均可交叉编译工具链arm-linux-gnueabihf-gcc与目标芯片架构对应内核源码与板卡BSP匹配的内核版本需要提前配置编译通过设备树源码板级dts/dtsi文件通常在arch/arm/boot/dts/目录下开发板支持网络启动或SD卡烧录方便快速测试交叉编译工具链的安装这里不展开你的BSP文档里一般都有。重点提醒一句驱动编译依赖的内核源码必须和你板子上正在运行的内核版本一致而且最好用同一份.config配置编译过。否则insmod的时候会报version magic不匹配这是很多新手第一个遇到的坎。2.2 字符设备驱动骨架file_operations与模块入口不管硬件是什么字符设备驱动的骨架是固定的。先创建驱动文件led_drv.c包含必要头文件定义file_operations结构体#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/platform_device.h #include linux/of.h #include linux/gpio/consumer.h #include linux/uaccess.h #include linux/slab.h #define LED_DRV_NAME myled struct led_device { struct cdev cdev; struct class *cls; struct device *dev; struct gpio_desc *led_gpio; int open_count; }; static struct led_device *g_led; static int led_open(struct inode *inode, struct file *filp) { struct led_device *led container_of(inode-i_cdev, struct led_device, cdev); filp-private_data led; if (led-open_count 0) { return -EBUSY; } led-open_count; try_module_get(THIS_MODULE); return 0; } static int led_release(struct inode *inode, struct file *filp) { struct led_device *led filp-private_data; led-open_count--; module_put(THIS_MODULE); return 0; } static const struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .release led_release, .write led_write, .unlocked_ioctl led_ioctl, };这个骨架里有一点值得注意open函数里我用container_of从inode-i_cdev反推出整个led_device结构体再把指针存到filp-private_data。这样后面的write、release函数只需要从private_data取结构体不需要再查全局变量。这是字符设备驱动里的标准做法。open_count计数是个很小的细节但很实用。它防止两个进程同时打开设备节点导致资源竞争。实际产品里用原子变量atomic_t更严谨这里用int是因为没有并发写的问题但思路是一致的。2.3 设备节点自动生成class/device机制很多新手用mknod手动创建节点这在测试阶段没问题但真实项目里必须让设备节点自动生成。做法是在probe函数里创建class和devicestatic int led_create_device_node(struct led_device *led) { dev_t devno; alloc_chrdev_region(devno, 0, 1, LED_DRV_NAME); led-devno devno; cdev_init(led-cdev, led_fops); led-cdev.owner THIS_MODULE; cdev_add(led-cdev, devno, 1); led-cls class_create(THIS_MODULE, LED_DRV_NAME); if (IS_ERR(led-cls)) { pr_err(Failed to create class\n); cdev_del(led-cdev); unregister_chrdev_region(devno, 1); return PTR_ERR(led-cls); } led-dev device_create(led-cls, NULL, devno, NULL, led0); return 0; }class_create之后内核会在/sys/class/下创建一个目录。device_create时传入的名字“led0”最终会被uevent机制发送给用户态的udev或mdev由它们自动在/dev/下生成led0节点。所以板子上必须跑udev或mdev否则还得手动mknod。很多同学看到驱动加载成功但/dev/led0不存在第一反应是驱动问题其实很多时候是文件系统里没有设备管理守护进程。3. 设备树绑定与GPIO操作细节3.1 设备树节点与pinctrl引脚复用设备树是Linux驱动开发的必备技能。我的板子上设备树里对应的节点长这样/ { myled { compatible myled,led0; label user-led; pinctrl-names default; pinctrl-0 pinctrl_led; gpios gpio1 3 GPIO_ACTIVE_LOW; status okay; }; };pinctrl_led这个节点在iomuxc里定义作用是把这个引脚复用为GPIO功能并设置上下拉、驱动能力等电气属性iomuxc { pinctrl_led: ledgrp { fsl,pins MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x10b0 ; }; };第二行的0x10b0是PAD配置值包括上下拉、速度、驱动强度等。这个值怎么来的芯片手册里每个pad都有对应的配置位说明0x10b0是常见的默认推荐值。不同芯片差异很大用的时候一定要查你手上芯片的参考手册不能照抄。这里要注意一个常见误区设备树里描述硬件连接关系pinctrl描述引脚复用状态。两者缺一不可。如果pinctrl没配即使GPIO寄存器操作正确引脚也可能没有连接到GPIO模块上灯自然不会亮。3.2 GPIO操作API选型从gpio_xxx到gpiod_xxx内核操作GPIO的API有两代老的gpio_request/gpio_direction_output/gpio_set_value这套基于整数编号的接口和新的gpiod_get/gpiod_set_value这套基于描述符的接口。新代码强烈建议用gpiod接口原因有几个gpiod接口天然与设备树绑定不需要手工传GPIO编号设备树里GPIO_ACTIVE_LOW标志能被gpiod接口自动处理gpiod_set_value(desc, 1)在硬件上自动输出低电平来点亮LED驱动代码不需要关心电平反转devm_开头的gpiod接口支持资源自动释放probe失败或remove时不需要手动free还有一个初学者常有的困惑STM32资料里经常说GPIO有8种工作模式什么输入浮空、上拉、下拉、推挽输出、开漏输出在Linux里去哪配置答案是通过pinctrl子系统。芯片厂家的pinctrl驱动会解析设备树里的fsl,pins属性把引脚的复用功能、上下拉、驱动能力配置好。驱动代码里的gpiod接口只负责设置输出电平和读取输入电平不直接配置电气模式。所以“8种模式”的配置工作在设备树层面已经完成了这一点和裸机开发有本质区别。3.3 probe函数中获取与申请GPIO资源当设备树节点和platform驱动匹配成功后probe函数会被调用。这里做的事情很明确从设备树拿GPIO资源初始化字符设备创建节点static int led_probe(struct platform_device *pdev) { struct led_device *led; struct device *dev pdev-dev; led devm_kzalloc(dev, sizeof(*led), GFP_KERNEL); if (!led) return -ENOMEM; led-led_gpio devm_gpiod_get(dev, NULL, GPIOD_OUT_LOW); if (IS_ERR(led-led_gpio)) { dev_err(dev, Failed to get gpio: %ld\n, PTR_ERR(led-led_gpio)); return PTR_ERR(led-led_gpio); } gpiod_set_consumer_name(led-led_gpio, myled); g_led led; led_create_device_node(led); dev_info(dev, LED driver probed\n); return 0; } static int led_remove(struct platform_device *pdev) { struct led_device *led g_led; if (led) { device_destroy(led-cls, led-devno); class_destroy(led-cls); cdev_del(led-cdev); unregister_chrdev_region(led-devno, 1); g_led NULL; } return 0; } static const struct of_device_id led_of_match[] { { .compatible myled,led0 }, { } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_platform_driver { .probe led_probe, .remove led_remove, .driver { .name LED_DRV_NAME, .of_match_table led_of_match, }, }; module_platform_driver(led_platform_driver); MODULE_LICENSE(GPL);devm_gpiod_get(dev, NULL, GPIOD_OUT_LOW)这一句里的第二个参数是NULL对应设备树里的“gpios”属性。如果设备树写的是“led-gpios”这里就要传“led”。这个对应关系经常让人迷糊建议直接记住gpiod_get的con_id去掉“-gpios”后缀对应设备树属性名。con_id为NULL就默认取“gpios”。probe返回非0值就表示驱动初始化失败内核会打印错误。devm系列的接口好处是后面任何一个步骤失败前面已经申请的资源会被自动释放不需要手动写一堆goto error处理。4. 驱动核心实现用户态接口与硬件控制的桥梁4.1 open/release与引用计数管理open/release我在骨架里已经写了基本的逻辑。对于LED这种简单设备open里做的最重要的事情就是把设备私有数据结构挂到filp-private_data上。这样write和ioctl函数拿不到全局变量也能操作对应设备。try_module_get和module_put是防止设备被打开时驱动模块被rmmod卸载。虽然现在的内核有cdev引用计数保护但对于入门学习来说养成这个习惯对以后写复杂驱动有帮助。4.2 write接口解析用户输入并控制LED开关用户态最简单的交互方式是echo和cat。echo写入字符驱动解析字符决定开关状态。实现如下static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { struct led_device *led filp-private_data; char kbuf[4] {0}; if (copy_from_user(kbuf, buf, count 3 ? 3 : count)) return -EFAULT; if (kbuf[0] 1) { gpiod_set_value(led-led_gpio, 1); } else if (kbuf[0] 0) { gpiod_set_value(led-led_gpio, 0); } else { return -EINVAL; } return count; }copy_from_user是必须用的内核不能直接解引用用户空间指针。如果用户传递的地址非法直接访问会导致内核崩溃。内核开发的第一条铁律就是用户传入的任何东西都不能轻易相信。字符‘1’被解释为点亮‘0’为熄灭。因为设备树里gpios属性标了GPIO_ACTIVE_LOW所以gpiod_set_value(desc, 1)输出到物理引脚上是低电平。如果硬件上LED负极接GPIO低电平正好点亮。这就是为什么我说gpiod接口方便驱动里完全不用关心电平反转的事。4.3 ioctl接口把驱动做成能干活的样子write接口能控制开关但真实产品通常需要更丰富的控制比如设置闪烁频率、读取当前状态。这时候就要上ioctl了#define LED_IOC_MAGIC L #define LED_IOCTL_SET_ON _IO(LED_IOC_MAGIC, 1) #define LED_IOCTL_SET_OFF _IO(LED_IOC_MAGIC, 2) #define LED_IOCTL_SET_BLINK _IOW(LED_IOC_MAGIC, 3, int) static long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct led_device *led filp-private_data; int blink_ms; switch (cmd) { case LED_IOCTL_SET_ON: gpiod_set_value(led-led_gpio, 1); break; case LED_IOCTL_SET_OFF: gpiod_set_value(led-led_gpio, 0); break; case LED_IOCTL_SET_BLINK: if (copy_from_user(blink_ms, (void __user *)arg, sizeof(blink_ms))) return -EFAULT; dev_info(led-dev-dev, blink not implemented, ms%d\n, blink_ms); break; default: return -ENOTTY; } return 0; }_IO、_IOW这些宏是内核提供的ioctl命令编码方式。它们把命令打包成一个整数包含方向、大小、魔数和序号。用户态和内核态必须用同一套宏定义否则cmd不匹配返回-ENOTTY。有人可能会问blink功能为什么不实现因为真正要做闪烁需要内核定时器或高精度定时器。我建议你在学完这个基础驱动之后自己加一个hrtimer或者timer_list实现闪烁这是一个非常好的进阶练习。4.4 用户态测试程序完整闭环驱动写完写个简单的用户态测试程序验证功能。测试代码很短#include stdio.h #include fcntl.h #include unistd.h #include sys/ioctl.h #define LED_IOCTL_SET_ON _IO(L, 1) #define LED_IOCTL_SET_OFF _IO(L, 2) int main(int argc, char *argv[]) { int fd open(/dev/led0, O_RDWR); if (fd 0) { perror(open); return -1; } ioctl(fd, LED_IOCTL_SET_ON, 0); sleep(1); ioctl(fd, LED_IOCTL_SET_OFF, 0); close(fd); return 0; }这套用户态程序和驱动之间的交互方式和真实产品里应用层控制硬件的流程一模一样。很多同学以为写驱动是纯内核的事其实一个完整功能的验证必须打通应用层。5. 编译、部署与实测记录5.1 Makefile与内核模块编译驱动和用户态程序的Makefile要分开。内核模块的Makefile长这样obj-m : led_drv.o KERNELDIR ? /home/workspace/linux-imx CROSS_COMPILE ? arm-linux-gnueabihf- ARCH ? arm PWD : $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) cleanKERNELDIR必须指向你已经配置编译过的内核源码目录。M$(PWD)表示只编译当前目录下的模块。ARCH和CROSS_COMPILE指定目标架构和交叉工具链。编译可能在最后链接阶段报这几种错误内核源码没有被编译过缺少Module.symvers交叉工具链没安装或者路径不对内核源码的.config里没有开启module支持编译成功后生成的led_drv.ko就是要加载的目标文件。用file命令看一下是不是ARM架构的file led_drv.ko如果显示ELF 32-bit LSB relocatable, ARM说明编译正确。5.2 加载、节点生成与点灯操作把led_drv.ko拷贝到板子上执行下面这个操作序列命令作用insmod led_drv.ko加载驱动模块dmesg | tail查看内核打印信息确认probe是否执行ls /dev/led0确认设备节点是否自动生成echo 1 /dev/led0点亮LEDecho 0 /dev/led0熄灭LEDrmmod led_drv卸载驱动我实测时的输出大致是myled myled: LED driver probed看到这行说明probe函数正常执行。然后echo 1灯亮echo 0灯灭。如果一切正常恭喜你已经历了一个Linux设备驱动从编译到运行的完整生命周期。5.3 实测现象与GPIO电平极性分析这里必须提一个非常容易踩坑的点电平极性。我的板子上LED灯一端接3.3V电源另一端经过限流电阻接到GPIO1_IO03。这意味着GPIO输出低电平时LED才导通点亮输出高电平时灭。设备树里gpios属性描述的是硬件连接逻辑还是物理电平答案是逻辑电平。GPIO_ACTIVE_LOW表示“逻辑有效状态是低电平”。所以驱动里调用gpiod_set_value(desc, 1)gpiod子系统知道active-low会反转成物理低电平输出。这时候LED亮。如果不用gpiod用老接口gpio_set_value就直接输出物理电平驱动里就必须判断active-low并手动反转。这就是gpiod接口更受欢迎的原因把硬件细节隔离在设备树层驱动代码和用户态接口保持一致1就是亮0就是灭。6. 常见问题与排查技巧实录6.1 probe函数没执行的三个高频原因这个是我见过最多人栽跟头的地方insmod成功但dmesg里没有probe打印。原因基本分三类第一compatible不匹配。设备树节点里的compatible必须和驱动of_match_table里的字符串完全一致一个字符都不能差。检查方法strings /boot/xxx.dtb | grep myled如果设备树编译进去了但查不到说明dts没改对或者没重新编译dtb并烧录到板子。第二设备树没有真正生效。有些板卡使用u-boot传入的dtb你改了源码dts但没重新编译打包或者u-boot环境变量里指定了其他的dtb文件。用这个命令确认板子上实际生效的dtbcat /proc/device-tree/myled/compatible如果文件不存在说明设备树节点没加载。第三pinctrl配置冲突或错误。如果引脚被其他外设复用probe可能失败。设备树里status disabled也要检查。6.2 GPIO操作报错与占用排查如果gpiod_get返回错误最常见的是-EBUSY表示GPIO已经被其他驱动申请。内核提供了GPIO调试接口非常有用的排查工具mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/gpio这个命令会列出所有GPIO的状态包括被哪个驱动占用、当前电平等。找到你的引脚看它是不是被其他pinctrl节点或驱动占用了。如果是把冲突的节点禁用或修改。另一种情况是在probe函数里gpiod_get直接返回-EPROBE_DEFER。这是内核特有的机制表示该GPIO对应的GPIO控制器驱动还没加载完成。这种情况通常不需要你处理只要保证GPIO controller驱动正常probe会自动重试。6.3 设备节点和权限问题设备节点没生成先看dmesg里class_create和device_create有没有报错。如果没报错但/dev/led0不存在极大概率是文件系统里没有udev或mdev。可以用mknod手动验证mknod /dev/led0 c 主设备号 0主设备号可以通过cat /proc/devices | grep myled拿到。手动mknod成功后可以操作说明驱动本身没问题回去配udev规则即可。另外日常使用会碰到权限问题非root用户echo不到设备节点。最简单的办法是修改设备节点权限或者写一个udev规则让系统自动设置权限。这在实际产品中很重要但在学习阶段直接sudo或root操作就行。6.4 大概率踩坑点总结现象可能原因排查方向insmod失败报version magic不匹配内核源码版本和运行内核不一致用板子同版本内核源码重新编译insmod成功但灯不亮pinctrl未配置或LED极性配反检查dts的pinctrl和GPIO_ACTIVE_LOWecho写入返回No such device设备号/节点创建失败dmesg确认cdev注册结果卸载时卡死或崩溃open的设备没有正常关闭确认release里计数逻辑正确gpiod_get返回NULL设备树gpios属性名不对检查是gpios还是xx-gpios这些坑我基本都踩过一遍。其中version magic不匹配是新手期的重灾区经常有人拿着A版本内核编译的模块往B版本内核的板子上一insmod就报错。解决办法只有一个明确板子上运行的内核版本然后下载对应版本的源码重新编译。7. 一个真实的建议要不要自己写LED驱动7.1 内核自带的gpio-leds怎么用如果你做的是实际产品不是学习驱动框架那强烈不建议自己写LED驱动。内核里已经有现成的gpio-leds驱动你只需要在设备树里这样配置leds { compatible gpio-leds; led0 { label status; gpios gpio1 3 GPIO_ACTIVE_LOW; default-state off; }; };内核会自动创建/sys/class/leds/status/目录通过操作brightness属性就能控制LEDecho 1 /sys/class/leds/status/brightness echo 0 /sys/class/leds/status/brightness这个框架还自带trigger机制可以把LED关联到心跳、网络活动等事件上。实际产品里90%的LED需求用这个方案就够了而且经过大量用户验证稳定性和代码质量远比你自己写的好。正是因为这个原因我在做这个项目时明确了自己的目标学习完整驱动开发流程时手写一份产品落地时优先用内核现成框架。两条路都走通了才算真正掌握。7.2 学习驱动开发更重要的东西写完这个LED驱动我的体会是驱动开发真正的难点不在硬件操作而在理解内核的抽象层次。字符设备是用户态和内核态的桥梁platform驱动是设备和驱动代码的粘合层设备树是硬件信息的描述语言GPIO子系统是通用的硬件操作接口。每一个层次的目的都是为了让驱动代码更通用、可维护、可移植。当时我有个很大的顿悟为什么同样一份led_drv.c换一块芯片的板子只要重新编译设备树权重定向驱动代码几乎不用改因为芯片差异被pinctrl、GPIO controller、设备树这些层次吸收掉了。驱动代码面对的是一个抽象的GPIO描述符而不是某个寄存器地址。理解了这一点再去学I2C、SPI、DMA、中断你会觉得万变不离其宗底层套路都是一样的。这个项目做完之后我建议你继续做的几个延伸方向用内核定时器实现闪烁功能理解timer机制改成中断触发方式比如按键点灯理解中断子系统把设备树里加两个LED分别用不同GPIO理解多个实例的管理试试把驱动编进内核而不是模块理解两者的区别和适用场景每一个延伸方向都是在为后面更复杂的驱动打基础。LED虽小但它串联起来的这套知识体系足够一个初学者用几个月时间反复咀嚼了。
网站建设高端定制企业官网