ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Linux GPIO LED驱动开发实战:从字符设备到设备树完整解析

Linux GPIO LED驱动开发实战:从字符设备到设备树完整解析 1. 为什么驱动开发入门的第一课几乎都是LED做Linux驱动开发的这几年我接触过不少刚入行的同事和学生大家聊起第一个能跑通的驱动项目十有八九都是控制一个LED灯。这还真不是偷懒或者俗套LED驱动正好卡在一个微妙的平衡点上代码量不大知识点却覆盖了Linux设备驱动的整个主干。你只要把LED驱动从头到尾自己写一遍字符设备框架、设备号申请、file_operations操作集、GPIO子系统、设备树节点、模块编译加载、应用层测试这一整套流程就全打通了。很多初学者喜欢一上来就啃PCIe驱动、USB驱动或者网卡驱动我的建议是别这么干。那些复杂驱动本身就包含大量子系统交互、中断处理、DMA、并发同步机制一旦跑不起来你根本分不清是框架问题、硬件问题还是协议问题。LED驱动没有这些干扰项它就是一个纯粹的“用户写一个值硬件就亮/灭”的闭环非常适合把“驱动到底是怎么工作的”这件事看清楚。这篇内容的主线就是围绕“Linux GPIO LED灯驱动”把设备驱动开发的完整链路讲透。我会先带你看懂驱动框架怎么选型再手写一个可运行的标准字符设备驱动然后升级到设备树配合平台驱动的方式最后聊聊工程里更常用的leds-gpio子系统。适合有一定C语言基础、接触过Linux基本命令想知道“驱动代码是怎么和硬件扯上关系”的读者。就算你之前只玩过单片机这篇文章也吃得住。1.1 从MCU跳转Linux先把GPIO的概念对齐如果你从STM32这类MCU过来那么理解Linux GPIO会非常顺。STM32的GPIO一共有8种工作模式浮空输入、上拉输入、下拉输入、模拟输入、开漏输出、推挽输出、开漏复用功能、推挽复用功能。你在MCU上要操心引脚模式、速度、上下拉但在Linux驱动里这些底层细节大部分已经被芯片厂商的pinctrl子系统消化掉了。驱动工程师通常只需要决定三件事这个引脚用作输入还是输出、默认电平是多少、有没有上拉/下拉需求。我在带新人时经常看到有人写出这样的注释“这里把GPIO配置为输出模式”。但真正调用gpio_direction_output之后引脚到底是推挽还是开漏其实是由设备树里的pinctrl设置决定的。换句话说Linux把“引脚复用选择”和“GPIO方向/电平控制”分成了两层前者是硬件相关的pinmux配置后者是通用GPIO子系统的标准接口。LED驱动只需要关心后面这一层。把GPIO的概念对齐之后你会发现面向Linux写LED驱动真正要啃的核心并不是GPIO本身而是“驱动如何注册成为文件、应用层如何通过文件操作硬件”这一整套机制。1.2 驱动框架三选一字符设备、平台设备、Led子系统做事之前先选型这条路在工程项目里怎么走决定了你写的代码长什么样。实际开发中控制一个LED有3种常见选择。第一种是自己写一个标准字符设备驱动。字符设备驱动的特点是一个字节一个字节地读写逻辑简单直接非常适合用来学习设备驱动框架。“/dev/led”这种节点就是字符设备。几乎所有入门教程都是这条路我也建议你先把这条跑通。第二种是平台设备驱动platform_driver。平台设备是Linux内核为了管理那些“挂在CPU总线上的片上设备”而抽象出来的概念。LED虽然简单但在现代嵌入式Linux中它往往挂在SoC的GPIO控制器下面所以很多驱动会写成platform_driverprobe函数里再解析设备树获取GPIO号。这种写法更贴近工程实际。第三种是用内核现成的led子系统也就是“leds-gpio”驱动。严格来说这种情况下你基本不用写逻辑代码只需要在设备树里声明led节点内核就会自动创建对应的led设备再通过/sys/class/leds/目录来操作。工程里80%以上的LED控制需求可以用这个方案搞定。很多人会纠结“我到底该学哪一种”我的建议是为了理解内核机制把第一种和第二种都亲手写一遍为了项目交付优先用第三种。这篇后面会把这三种全部过一遍你可以对照着体会它们各自的定位。2. 动手之前开发环境、内核源码和Makefile怎么准备开始写代码之前先把环境摸清楚。很多新手驱动代码写得没问题结果卡在编译环境上要么内核头文件对不上要么Makefile里的路径写错白白浪费时间。这部分我先说清楚环境搭建的关键点顺便把交叉编译和模块编译的基本逻辑梳理一下。2.1 开发板、工具链、内核源码三件套做Linux驱动开发通常有宿主机和目标机之分。宿主机是你写代码、编译代码的PC目标机是运行Linux的开发板。如果你用的是树莓派、香橙派、RK系列开发板这类环境可以直接在板子上完成编译省去交叉编译这层麻烦。但如果你做的是商业产品目标板性能往往不足以跑编译器这时候就必须用交叉编译工具链在x86的PC上编译出ARM架构能运行的.ko文件。三件套里最重要的是工具链和内核源码的匹配。你编译驱动模块时必须使用的是目标板运行内核对应的源码树。如果目标板内核版本是6.1你拿着5.10的内核源码去编译大概率加载不进去系统会报“invalid module format”或者版本号不匹配。为什么呢因为.ko文件里记录了编译时内核的vermagic信息包括版本号、编译选项等insmod时会做校验有一项对不上模块就会被拒绝。我见过太多人栽在这个地方所以第一条建议就是先到开发板里执行uname -r再找到对应版本的内核源码别想当然。如果你是在普通PC上做实验事情更简单。安装好linux-headers包之后/lib/modules/$(uname -r)/build这个软链接会指向当前内核的构建目录Makefile里直接引用它就行。2.2 一个最简Makefile背后的逻辑驱动模块的编译和普通C程序完全不同。普通C程序用gcc直接编译链接驱动模块则必须使用内核的构建系统kbuild来编译。kbuild是一个庞大而复杂的Makefile体系你不需要全部掌握但得知道最基本的模板长什么样。下面这5行Makefile我沿用了很多年obj-m : led_drv.o KERN_DIR : /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERN_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERN_DIR) M$(PWD) cleanobj-m的含义是把led_drv.o编译成一个内核模块最终生成led_drv.ko。-C $(KERN_DIR)表示切换到内核源码目录去执行MakefileM$(PWD)告诉kbuild当前模块源码在哪个目录。这套逻辑大概可以这样理解你不是在用编译器编译代码而是在“借用”内核的编译规则把一段代码编译成内核能识别的模块格式。如果你用交叉编译需要在Makefile里额外指定ARCH和CROSS_COMPILE比如ARCH ? arm CROSS_COMPILE ? arm-linux-gnueabihf-这样make的时候kbuild就会用交叉编译工具链里的gcc代替本机的gcc。工具链前缀要和你开发板体系结构对应起来aarch64的板子就换成aarch64-linux-gnu-。2.3 怎么确定你的LED接在哪个GPIO上这是很多初学者最容易卡住的一个环节。你拿到了驱动代码也知道GPIO API怎么调用但不清楚自己板子上的LED到底对应哪个GPIO编号。这就要靠原理图和芯片手册来定位。现在的嵌入式Linux内核中GPIO编号已经被GPIO子系统统一管理起来芯片厂商在pinctrl驱动里注册了gpio_chip每个chip包含一组gpio引脚编号通常是连续分配的。你可以在目标板上执行cat /sys/kernel/debug/gpio查看当前系统里所有gpio_chip的基地址和占用情况。有些板子为了方便用户还会在/sys/class/leds或者设备树里保留led的节点定义这时候就可以直接对照节点里的gpios属性查看引脚。例如设备树中写gpios gpio1 20 GPIO_ACTIVE_LOW含义是使用gpio1这个控制器下的第20号引脚低电平有效。结合原理图看LED的负极是否接到这个引脚整条链路就串起来了。如果开发板上有现成的设备树led节点我的建议是直接沿用不要自己另起炉灶造一套编号因为内核里已经有led驱动在管了你独立再申请同一个GPIO会冲突。3. 手写一个标准LED字符设备驱动代码逐段拆解现在进入正题。这一节的代码是整篇文章的核心我把它拆成几段来讲所有代码组合在一起就是一个完整可编译的LED字符设备驱动。它做的事情是注册一个名为“led_dev”的字符设备应用层往/dev/led_dev写入字符‘1’开灯、字符‘0’关灯读取它则返回当前灯的状态。同时提供一个ioctl接口方便应用程序用命令字控制。完整源码我平时在编写的工具里存了一份思路如下。这个驱动虽然不大但结构上五脏俱全适合作为模板反复使用。3.1 头文件、设备结构体和file_operations操作集#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/gpio.h #include linux/of_gpio.h #include linux/platform_device.h #include linux/of.h #define LED_NAME led_dev #define LED_CLASS_NAME led_class #define LED_DEVICE_NAME led_dev这些头文件里面fs.h和cdev.h是字符设备驱动的核心依赖device.h提供自动创建设备节点的class/device相关接口gpio.h提供GPIO操作APIof_gpio.h和of.h用于设备树解析。你没看错这里已经包含了后续讲设备树时要用的头文件。因为在工程实践中哪怕是“一只LED”驱动也经常会从设备树里获取GPIO信息所以我现在就一步到位后面的代码里会用到of_get_named_gpio。对于暂时没有设备树平台的读者可以先把这个调用对应的注册函数看明白甚至可以用gpio_request直接申请固定编号来替代。接下来是file_operations结构体。这个结构体是字符设备驱动和应用层之间的接口契约。应用层每执行一次open、read、write、ioctl最终都会通过这个结构体绕到驱动里你实现的对应函数。没有它应用层的调用就无处落地。static struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .read led_read, .write led_write, .unlocked_ioctl led_ioctl, .release led_release, };注意这里用的是unlocked_ioctl而不是ioctl。老内核里用.ioctl但在2.6.36之后内核就强制要求驱动实现unlocked_ioctl了。这个字段在设计上是为了避免内核大锁带来的性能问题驱动里在使用unlocked_ioctl的情况下要自己在并发场景下加锁LED这种简单场景不涉及共享资源直接用全局GPIO操作问题不大。3.2 实现open、read、write、ioctl和releaseopen和release函数在这里都可以做成空操作或者打印一条调试信息。真实的项目里open常常用于增加使用计数或者初始化私有数据release用于释放资源。LED驱动简单但保留这两个入口能让你看清楚整个调用流程。static int led_open(struct inode *inode, struct file *filp) { pr_info(led device opened\n); return 0; } static int led_release(struct inode *inode, struct file *filp) { pr_info(led device released\n); return 0; }read函数返回当前LED状态。这里有个细节如果count参数小于1直接返回0表示没有读取到数据这是符合Linux驱动的读写语义的。static ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char val; if (count 1) return 0; val gpio_get_value(led_gpio) ? 1 : 0; if (copy_to_user(buf, val, 1)) return -EFAULT; return 1; }gpio_get_value返回的是0或非0但应用层缓冲区里放的是ASCII字符所以要转换成‘0’或‘1’。copy_to_user是内核态向用户态拷贝数据的接口不能直接memcpy因为用户态指针在内核态不能直接解引用。write函数接收用户写入的字符遇到‘1’开灯‘0’关灯。这里同样要用copy_from_user把用户数据搬进内核态。static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char val; if (count 1) return 0; if (copy_from_user(val, buf, 1)) return -EFAULT; if (val 1) gpio_set_value(led_gpio, 1); else if (val 0) gpio_set_value(led_gpio, 0); else return -EINVAL; return 1; }这里有个坑值得说一下应用层如果用printf之类的方式写入多个字符比如写入“1\n”因为换行符的存在write会收到2个字节。常见的测试写法是echo 1 /dev/led_dev这是通过shell的重定向先调用open然后写入两个字节“1\n”。上面代码里只处理了第一个字符换行符虽然被忽略了但也不会报错。比较严谨的驱动可以逐字节解析输入或者干脆让应用层用ioctl把控制命令走专门的命令通道。ioctl实现如下static long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case 0: gpio_set_value(led_gpio, 0); break; case 1: gpio_set_value(led_gpio, 1); break; default: return -EINVAL; } return 0; }cmd等于0代表关灯cmd等于1代表开灯。实际项目中ioctl命令通常会用_IOW这类宏来定义以避免命令号冲突和类型错误。这里为了简洁我直接用了裸数字但你在正式项目里一定要学会使用内核提供的_IO/_IOR/_IOW宏。3.3 设备号的申请、字符设备注册、自动创建设备节点这三个步骤是字符设备驱动的“标准动作”。设备号由主设备号和次设备号组成主设备号标识设备类型次设备号标识同类型下的不同设备。你可以手动指定一个主设备号也可以用alloc_chrdev_region让内核帮你动态分配。动态分配的好处是不会和已有设备冲突坏处是你得看内核日志才能知道分到了什么号。不过在自动创建设备节点的机制下这个号对用户是不可见的所以动态分配反而是更好的选择。#define LED_MAJOR 0 static int __init led_drv_init(void) { int ret; dev_t devno; if (LED_MAJOR) { devno MKDEV(LED_MAJOR, 0); ret register_chrdev_region(devno, 1, LED_NAME); } else { ret alloc_chrdev_region(devno, 0, 1, LED_NAME); LED_MAJOR 宏通过宏定义方式配合MKDEV来使用 } if (ret 0) { pr_err(failed to alloc devno\n); return ret; } led_devno devno; ... }这里其实有个小设计值在头文件里defineLED_MAJOR 0然后代码里判断#if LED_MAJOR而不是运行时的if会更符合惯例。不过为了可读性我在代码里用运行时判断也是可以的只是要记得LED_MAJOR为0时走动态分配。你还得把分配到的devno存到一个全局变量里后面cdev_add和device_create都要用它。接下来初始化cdev结构体并添加到内核cdev_init(led_cdev, led_fops); led_cdev.owner THIS_MODULE; ret cdev_add(led_cdev, led_devno, 1); if (ret 0) goto err_cdev_add;cdev_init负责把cdev结构和file_operations关联起来owner字段通常设成THIS_MODULE这样内核在通过该设备调用操作函数时能防止模块被意外卸载。cdev_add把设备对象挂到内核里第三个参数1表示只注册一个次设备号。然后是自动创建设备节点。以前没有自动创建机制的时候驱动加载后要手动执行mknod /dev/led_dev c 主号 0。有了class_create和device_create之后配合用户空间的udev/mdev守护进程驱动只要注册好class和设备/dev/led_dev节点就会自动出现。led_class class_create(THIS_MODULE, LED_CLASS_NAME); if (IS_ERR(led_class)) { ret PTR_ERR(led_class); goto err_class; } led_device device_create(led_class, NULL, led_devno, NULL, LED_DEVICE_NAME); if (IS_ERR(led_device)) { ret PTR_ERR(led_device); goto err_device; }这里有个和内核版本相关的坑。在6.4版本以前class_create接收两个参数第一个是THIS_MODULE第二个是class名6.4之后THIS_MODULE参数被移除变成class_create(name)单参数。我建议你在自己的开发环境上先确认内核版本再决定用哪种写法。很多从旧教程复制来的代码在这条上编译报错也是正常的不是你的问题。3.4 GPIO的申请获取与模块生命周期GPIO在Linux内核里使用前必须先“申请”一下目的是防止别的驱动或模块重复占用同一个引脚。申请之后还要设置方向。LED默认高电平点亮还是低电平点亮要看硬件原理图。我这里先假设GPIO号为板子上的某个引脚用of_get_named_gpio从设备树读取。如果纯粹是不带设备树的实验环境也可以直接填固定GPIO号再用gpio_request申请。led_gpio of_get_named_gpio(pdev-dev.of_node, led-gpios, 0); if (!gpio_is_valid(led_gpio)) { pr_err(invalid gpio\n); ret -EINVAL; goto err_gpio; } ret gpio_request(led_gpio, led); if (ret 0) { pr_err(gpio request failed\n); goto err_gpio; } ret gpio_direction_output(led_gpio, 1); if (ret 0) { pr_err(gpio direction failed\n); gpio_free(led_gpio); goto err_gpio; }上面这段代码里的pdev是指platform_device指针说明这个驱动本身要按平台驱动的方式来写。为了让大家理解两种写法之间的差异我先把这段谷取出来看驱动入口点变成probe函数注册驱动用platform_driver_register而不是直接在init函数里完成一切。模块退出时一定要按注册的逆序释放资源static void __exit led_drv_exit(void) { gpio_free(led_gpio); device_destroy(led_class, led_devno); class_destroy(led_class); cdev_del(led_cdev); unregister_chrdev_region(led_devno, 1); platform_driver_unregister(led_platform_driver); } module_init(led_drv_init); module_exit(led_drv_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple LED driver);很多人会忽略MODULE_LICENSE不写或者写成非GPL会导致一些内核API不可用还会在你加载模块时看到一个“module license unspecified taints kernel”的警告。如果驱动使用了EXPORT_SYMBOL_GPL导出的内核符号非GPL模块甚至直接无法链接。所以别嫌麻烦正规驱动必须声明GPL。4. 编译、加载、测试让LED亮起来代码写完了怎么让它跑起来这里面一样有小讲究。很多新人自己写的第一个驱动编译通过了insmod也返回成功但LED就是没反应反复查代码查不出问题。这时候先别急着怀疑自己的GPIO操作先把完整链路检查一遍。4.1 编译成.ko模块注意模块版本校验如果你是PC上实验直接在源码目录执行make。如果是开发板先把Makefile里KERN_DIR改成开发板内核源码路径ARCH和CROSS_COMPILE按需设置再执行make。编译成功后会生成led_drv.ko文件。把.ko拷贝到目标板执行insmod led_drv.ko。这里我特别想强调一件事驱动加载不进去多半是版本不匹配而不是代码写错。报错信息会明确告诉你“disagrees about version of symbol”或者“Invalid module format”。遇到这种问题第一步不是重写代码而是去核对目标板上运行的内核源码和编译内核时用的源码是否同一个版本并且.config配置是否一致。如果你的内核是别人定制过的直接解压一个官版内核源码来编译模块加载时基本都会失败。加载成功后执行dmesg查看内核日志能看到“led device opened”这类调试信息说明驱动已经运行。4.2 三种应用层测试方式对比操作LED有三种非常直观的方式。第一种直接用echo写字符echo 1 /dev/led_dev echo 0 /dev/led_dev这个方式依赖write接口方便快速验证但要注意刚才提到过的换行符问题驱动端只读取第一个字符所以实际效果不受影响。第二种用cat读取状态cat /dev/led_dev这个方式依赖read接口会返回当前LED的亮灭状态用来排查驱动有没有正确执行GPIO操作很管用。第三种写一个小C程序使用ioctl#include stdio.h #include fcntl.h #include unistd.h #include sys/ioctl.h int main(int argc, char *argv[]) { int fd open(/dev/led_dev, O_RDWR); if (fd 0) { perror(open); return -1; } if (argc 1 argv[1][0] 1) ioctl(fd, 1, 0); else ioctl(fd, 0, 0); close(fd); return 0; }编译gcc led_test.c -o led_test。运行./led_test 1开灯./led_test 0关灯。三种方式本质都是在调用file_operations里的对应接口。这也是驱动开发最迷人的地方内核里再复杂的东西到用户态就是文件操作。4.3 加载后设备节点不出现先把udev和class的关系搞清楚我在实际调试中遇到过不少次insmod成功dmesg也没有报错但/dev/led_dev就是不存在。这个问题和驱动代码没关系而是用户空间的udev守护进程在管理设备节点创建。自动创建设备节点的条件是驱动调用class_create创建了class再调用device_create创建了device。udev会监听到内核通过netlink发出的uevent事件然后根据device的name和class信息在/dev下创建设备节点。如果你的系统是精简裁剪过的可能压根没跑udev那设备节点就没人创建。解决办法是手动执行mknod /dev/led_dev c $(grep led_dev /proc/devices | awk {print $1}) 0然后就能看到节点了。所以遇到节点不出现先确认两件事系统有没有udev/mdev服务在跑驱动有没有确实执行到device_create5. 企业里的标准做法设备树配合平台驱动如果你只学字符设备驱动等到真正参与项目时会发现很多新平台的驱动早就不是在init函数里把一切写死了而是通过设备树来描述硬件资源驱动通过匹配compatible字符串找到自己对应的设备。这也是为什么几乎所有招聘JD里都写着“熟悉设备树”。5.1 为什么设备树风格现在是主流设备树英文叫Device Tree它的本质是一种描述硬件资源的数据结构。以前每个板子都在内核源码里放一堆平台设备代码和板级文件硬件稍有变化就要重新编译内核。设备树把这种“硬件配置”从内核中剥离出来变成一份独立的.dts/.dtsi文件修改LED引脚时只需要改动设备树并重新编译设备树内核本身不用动。这对商业产品开发太重要了。同一个内核镜像可以适配多款硬件只要设备树不同就行。所以现在的ARM Linux平台驱动里大量使用of_match_table通过compatible属性匹配设备。LED驱动也顺势变成平台驱动从设备树里解析GPIO号。5.2 设备树里LED节点的写法设备树中声明LED节点非常简单下面是一个常见例子/ { led_drv { compatible myled,led-drv; led-gpios gpio1 20 GPIO_ACTIVE_LOW; status okay; }; };compatible字符串是驱动的“身份证”驱动声明自己兼容“myled,led-drv”设备树里有这个字符串两者就能配对。led-gpios属性里的GPIO_ACTIVE_LOW表示低电平有效如果你的硬件上LED负极接到GPIO引脚那么GPIO输出0时灯亮输出1时灯灭。这一点如果弄反了LED就会呈现“反着亮”的效果排查时要留意。至于刚才在字符设备驱动里出现的of_get_named_gpio调用就是用来解析这个属性的。probe函数在接到匹配通知后从设备节点里取出GPIO号然后完成申请和初始化。5.3 probe函数和platform_driver的完整配合平台驱动的标准套路是定义一个platform_driver结构体设置driver.name或者driver.of_match_table然后在init函数里注册在exit函数里注销。当设备树和设备匹配成功后内核会自动调用probeprobe里做硬件初始化当设备移除时调用remove做反初始化。static const struct of_device_id led_of_match[] { { .compatible myled,led-drv, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_platform_driver { .probe led_probe, .remove led_remove, .driver { .name led_platform, .of_match_table led_of_match, }, }; module_platform_driver(led_platform_driver);注意module_platform_driver宏会替你生成module_init和module_exit的调用所以上面3.4节里我们手动写的module_init(led_drv_init)在平台驱动场景下可以省略。这也是为什么很多同事看新驱动觉得“入口在哪里”找不到其实入口被宏封装了。probe函数的写法是很多教程没讲清楚的地方static int led_probe(struct platform_device *pdev) { struct device_node *np pdev-dev.of_node; enum of_gpio_flags flags; led_gpio of_get_named_gpio_flags(np, led-gpios, 0, flags); if (!gpio_is_valid(led_gpio)) { dev_err(pdev-dev, invalid gpio\n); return -EINVAL; } ret devm_gpio_request(pdev-dev, led_gpio, led); if (ret 0) return ret; ret gpiod_direction_output(gpio_to_desc(led_gpio), 1); if (ret 0) return ret; return 0; }我在这个版本里故意用了devm_gpio_request和gpiod_direction_output。devm前缀API的好处是资源跟随设备生命周期自动释放probe失败或者设备移除时不需要手动调用gpio_free省去一大片错误处理代码。gpiod系列API则是新一代GPIO接口配合设备树更自然底层自动处理了active-low和active-high的差异。你如果只看老教程仍然会用gpio_request那并不是错只是在新代码里不推荐了。6. 更省事的工程解法内核leds-gpio子系统的使用相信你看完前几节已经能写出一个完整的LED驱动了。但接下来我要说点更实际的东西如果项目里只是控制几个LED做状态指示绝大多数情况下你不需要自己写驱动。6.1 一个设备树节点搞定一个LEDleds-gpio实战Linux内核的drivers/leds目录下有一个非常成熟的gpio-led驱动它的compatible字符串是“gpio-leds”。只要设备树里声明了相应的节点内核就会自动加载这个驱动把你的LED设备注册到/sys/class/leds下面。设备树配置方式如下/ { leds { compatible gpio-leds; status_led { label status; gpios gpio1 20 GPIO_ACTIVE_LOW; default-state on; }; heartbeat_led { label heartbeat; gpios gpio1 21 GPIO_ACTIVE_LOW; linux,default-trigger heartbeat; }; }; };节点名可以随便起真正决定驱动行为的是compatible gpio-leds。每个子节点对应一个LEDlabel是它在sysfs里的名称gpios指定引脚default-state定义默认状态linux,default-trigger可以设置内核自带的触发器比如heartbeat心跳闪烁、mmc0表示SD卡活动时亮、timer表示周期性闪烁。配置好后系统起来就能看到ls /sys/class/leds/ status heartbeat控制LED亮灭只需写sysfs节点echo 1 /sys/class/leds/status/brightness echo 0 /sys/class/leds/status/brightness这个方案的好处是驱动代码完全不用写设备树描述好硬件关系内核自动把LED和sysfs关联起来。裁剪系统时如果需要LED功能只要内核打开CONFIG_LEDS_GPIO设备树加上节点就能交付。6.2 什么时候应该写自己的LED驱动看到这里你可能会有疑问既然leds-gpio这么方便那前面写的一大堆代码是不是白学了不是。leds-gpio好用但它的功能边界很明确它只能提供最基本的“亮/灭/触发”控制。如果你需要自定义闪烁节奏、多灯联动、呼吸效果或者LED和业务逻辑强耦合比如网络数据流量到达时闪烁直接用现成驱动反而别扭因为这些逻辑写在应用层更合理或者你要基于led-class写一个自定义触发器的驱动。这时候你就需要真正理解驱动框架把内核提供的led_class API和字符设备机制结合起来。更重要的是LED驱动是你理解Linux设备驱动开发的最佳“最小系统”。以后写按键驱动、PWM驱动、SPI驱动走的都是这套思路分配设备注册设备实现操作接口描述硬件资源。你把这套思路练熟了遇到任何新硬件心里都有底。leds-gpio只是内核替你做完了这套流程但你自己走了这套流程才算真正懂了它。7. 调驱动必须掌握的排错思路和实操心得最后把我在过去这些年里踩过的坑、以及带新人时反复强调的排查思路整理一遍。这里每一条都是真实遇到过的如果你在这个方向上卡住了按顺序查下来能省很多时间。7.1 常见问题速查表现象最可能的原因怎么处理insmod报Invalid module format内核版本或.config不匹配切换对应内核源码重新编译驱动模块确认vermagic一致insmod报Unknown symbol模块依赖的内核符号未导出或依赖模块未加载先加载依赖模块用modprobe代替insmod尝试自动解决依赖/dev/led_dev不存在系统没有udev/mdev或device_create没执行成功执行mknod手动创建节点同时用dmesg确认驱动是否进入device_createecho写入没反应GPIO号不对或GPIO被其他驱动占用查看/sys/kernel/debug/gpio确认占用情况核对设备树和原理图LED反着亮有效电平配置错误检查设备树GPIO_ACTIVE_HIGH/GPIO_ACTIVE_LOW是否和原理图匹配class_create编译报错内核版本太新API参数变了查看内核源码中class_create定义按版本调整参数所有字符设备都正常但LED偶尔闪一下又不亮GPIO被复用为其他功能引脚配置冲突检查pinctrl配置确认该引脚没有被设置为i2c/uart等复用功能驱动卸载后重新加载报设备号被占用上次异常退出设备号没释放重启板子或者检查退出函数是否确实调用了unregister_chrdev_region其中GPIO被复用这个坑经常出现在调试板上。芯片引脚大多是多功能复用引脚设备树里的pinctrl-0配置可能把这根引脚设置成了别的功能比如串口或I2C那么你在GPIO子系统里就算申请成功实际引脚也不受GPIO控制器控制。排查方式是看设备树里该引脚对应的pinctrl配置确保它的function和groups是gpio相关。7.2 几条写在最后的经验第一内核日志是你的第一排查工具。代码和配置出了问题先看dmesg。建议在驱动每一段关键操作后都加上pr_info打印比如“设备号分配成功”“cdev添加成功”“GPIO申请成功”“LED亮”。上线前可以删掉调试期别省。不要靠猜日志会告诉你到底走到了哪一步。第二从“异步书写的驱动”变成“能用的驱动”往往差的不是代码逻辑而是硬件匹配。GPIO编号、有效电平、引脚复用这三样任何一样错了代码再对都没有意义。拿到一块新板子第一件事是看原理图和设备树确定这三个信息。第三当你开始写复杂驱动时一定要重视资源释放路径。probe函数里可能出现多个失败点每个失败点都需要考虑之前申请的资源要不要释放。devm系列API能在很大程度上减轻这种负担所以新代码我强烈推荐优先使用devm_gpio_request、devm_kzalloc这类资源管理接口。第四一个驱动从编译到能在应用层操作链路是“驱动代码 - 模块加载 - 设备号分配 - 设备节点创建 - 文件操作接口被调用”。链路很长每一环都可能出问题。我的排查习惯是自底向上先确认模块加载成功再确认设备号存在再确认节点存在再确认读写接口被调用。这样一步一步能快速缩小问题范围。这款LED驱动我给很多同事和新人作为模板讲过上面这些经验也都是在一次次反复调试中提炼出来的。每次有人问我“我写的驱动怎么不行”我都会先反问一句dmesg和debugfs看了吗如果没看先看再聊。大多数疑难杂症内核早就把答案写在日志里了。你把这条基本功练扎实后面不管写什么驱动都会顺畅得多。
返回列表