RV1106平台LED驱动开发:从分层架构到设备树实战
最近在调试 RV1106 平台正好要写一个 LED 驱动。说实话LED 驱动在整个嵌入式 Linux 开发里算是入门级的活但入门不等于没坑。尤其是这类芯片平台如果你一开始没把整体架构想清楚后面加按键、加呼吸灯、加网络状态指示代码会越写越乱。这篇就是系列的第一篇先把整体架构讲透再往下走细节。文章适合刚接触 RV1106 平台、想快速上手内核驱动开发的开发者也适合想在自己的项目里把 LED 驱动从“能用”做到“好维护”的人。我会从架构设计的角度把这个驱动拆成几层说清楚每一层为什么这么划分、数据是怎么流的、代码该往哪里放、后面怎么扩展。1. 整体架构设计一个 LED 驱动为什么也值得认真分层1.1 你以为的 LED 驱动其实只有三行代码很多人写 LED 驱动第一反应是申请一个 GPIO然后写高写低完事。比如用gpio_direction_output(42, 1)点亮用gpio_set_value(42, 0)熄灭。实际做产品时需求远远不是“点亮”和“熄灭”这么简单。至少要考虑这些场景系统启动后默认处于什么状态亮、灭、还是保持上次状态开机时闪烁一次表示启动成功系统运行中LED 作为状态指示灯比如有网络时快闪、无网络时慢闪要和设备树绑定不同板子用不同引脚不能把引脚写死在代码里应用层需要通过标准接口控制比如echo 255 /sys/class/leds/xxx/brightness后续可能要支持呼吸灯效果那就不再是简单 GPIO 翻转了如果一上来就把这些逻辑全堆在一个驱动文件里代码会变成一个几百行的“大杂烩”。后面换引脚、换控制方式、加新功能都会非常痛苦。所以我给这个项目定的第一条规则是先分层再写码。1.2 整套驱动的分层思路我把整个 LED 控制功能拆成三层应用层用户态程序、脚本、服务通过标准接口控制 LED不用关心底层是 GPIO 还是 I2C 还是 PWM。内核驱动层负责和硬件直接打交道向上提供统一接口。这里面又分两块设备树解析读取当前板子的引脚配置、默认状态、触发方式LED class 驱动注册成标准 LED 设备通过/sys/class/leds/暴露接口硬件层芯片的 GPIO 控制器、时钟、中断等底层资源由内核 GPIO 子系统管理。为什么这样分层最核心的原因是隔离变化。硬件引脚可能会改驱动程序可能要适配不同产品应用软件最好不要跟着改。有了 LED class 这个标准接口应用层只需要写echo 1 /sys/class/leds/board_led/brightness就能控制完全不关心底层是不是换了引脚。这个思路和写业务系统是一样的接口稳定实现随便换。1.3 技术选型用 LED class 还是自己写字符设备这是写 LED 驱动第一个要做的决定。有两条路方案优点缺点LED class推荐内核自带接口统一自带 sysfs 节点支持触发器trigger很多细节被框架“包好”了可能让人忽略底层机制杂项字符设备完全自定义想怎么控制都行适合特殊需求要自己实现 open/read/ioctl应用层接口不标准还要处理并发、权限、错误码代码量大除非你的 LED 有特别复杂的私有协议比如通过某个串行总线、有特定时序否则都用 LED class。RV1106 平台跑的是标准 Linux 内核LED class 是现成支持的。你只要实现led_classdev结构体里的 ops框架就会帮你把 sysfs 里的 brightness、trigger、max_brightness 等属性全部建立好。用户空间操作这些属性文件内核框架会调用你写的 ops。后续如果想支持呼吸灯还可以用led_classdev的brightness_set_blocking配合 PWM或者用pattern触发器都不用动应用层的逻辑。所以这个选型是稳的。2. RV1106 平台驱动开发的基础认知2.1 RV1106 到底是什么平台RV1106 是一款面向 IPC网络摄像头、视觉识别等场景的 SoC 芯片芯片本身集成了 CPU、ISP、视频编解码模块、各种外设控制器。开发这种平台要注意的事情和普通 ARM 开发板差不多但有几个特点芯片的 SDK 里通常会带一套完整的内核和设备树源码外设的引脚复用pinmux、时钟、电源域一般都需要在设备树里配置。很多外设的控制方式和传统单片机不一样GPIO 的资源申请必须通过内核的gpiod接口不能直接操作物理地址。板级差异很大同一颗芯片在不同板子上LED 可能接在 GPIO0、GPIO1也可能是低电平点亮。这些差异都应该用设备树来描述。所以在 RV1106 上写 LED 驱动最重要的一个习惯是不要写死任何引脚号和寄存器地址。所有的硬件参数尽量从设备树拿。2.2 GPIO 控制 LED 的原理要点LED 本质上就是一个二极管给它两端加合适的电压它就会亮。在芯片平台上板子设计通常把 LED 接到某个 GPIO 引脚上另一端接电源或地。驱动里要做的事情就两件把 GPIO 配置成输出模式往寄存器里写 0 或 1让引脚输出高电平或低电平这里最容易踩的就是“极性”。有的板子 GPIO 输出高电平 LED 亮有的 GPIO 输出低电平 LED 亮。写驱动时必须支持设备树里的active-low属性。在旧的内核接口里你可能习惯用gpio_xxx函数带一个gpio_request的 int 号。但在新内核和 RV1106 这类新 SDK 里推荐直接用gpiod_xxx接口它和设备树结合得更紧密能自动处理active-low、GPIO_ACTIVE_LOWflag 等细节。比如struct gpio_desc *led_gpio; struct device_node *np dev-of_node; led_gpio devm_gpiod_get(dev, led, GPIOD_OUT_LOW); if (IS_ERR(led_gpio)) { return PTR_ERR(led_gpio); } gpiod_set_value(led_gpio, 1); // 点亮 gpiod_set_value(led_gpio, 0); // 熄灭devm_gpiod_get里的第二个参数是“属性名后缀”配合设备树的-gpios后缀属性来用。比如设备树里有led-gpios gpio0 15 GPIO_ACTIVE_LOW;驱动里就调devm_gpiod_get(dev, led, ...)。框架会自动找到led-gpios并处理好极性你在代码里gpiod_set_value(desc, 1)永远表示“语义上的打开”而不是“物理上的拉高”。这是非常省心的设计。2.3 设备树在驱动里的角色设备树是 Linux 用来描述硬件配置的机制。驱动通过“compatible”属性和设备树里的节点匹配。当匹配成功内核会调用驱动的probe函数并把节点的struct device_node *传给你。LED 驱动通常会定义一个这样的节点led_status: led-status { compatible rv1106-led; label status_led; led-gpios gpio0 15 GPIO_ACTIVE_LOW; default-state off; };驱动里用of_property_read_string读 label用devm_gpiod_get读 led-gpios。设备树的作用就是把这些硬件参数从代码里解耦出去。以后换了引脚只要改 DTS 重新编译设备树不用重新编译内核。这里要提醒一点设备树不是写完了就自动生效的。在 RV1106 的 SDK 里设备树会被编译成 dtb烧写到设备的分区里。修改 DTS 之后要重新编译整个设备树或者用 fastboot 单独烧写 dtb 分区分区名一般是resource或dtb不同 SDK 不一样最好先查一下 SDK 文档。如果只改了设备树别忘了在驱动加载前确认 dtb 已经更新。3. 整体架构实现模块划分与数据流3.1 代码文件怎么组织很多新手写驱动把所有代码放在一个.c文件里功能能跑但维护起来很头疼。我的习惯是以“可扩展”为前提来组织文件。对于这个 LED 驱动我把它分成下面几个文件rv1106-led/ ├── rv1106_led.c // 驱动主文件probe、remove、led ops 注册 ├── rv1106_led.h // 结构体、宏定义私有数据 ├── rv1106_led_platform.c // 平台相关的配置、引脚初始化如果以后要支持复杂时序可以拆开 ├── Kconfig // 内核配置项 └── Makefile // 编译规则对于一个简单的 LED 驱动其实rv1106_led.c和rv1106_led.h就够了rv1106_led_platform.c可以后面需要的时候再加。但是把“驱动核心逻辑”和“平台硬件适配”分开的思路一定要有这样万一以后要在一个驱动里支持多个 GPIO 控制多个 LED或者还要支持 PWM 模式大家不会互相打架。3.2 驱动对象的核心数据结构在设计架构阶段首先要定义一个结构体来承载“每个 LED 设备的所有实例信息”。建议这样定义struct rv1106_led_dev { struct led_classdev led_cdev; struct gpio_desc *gpio; struct device *dev; bool active_low; int default_state; };led_classdev是内核 LED 框架要求你必须嵌入的结构体它不能单独存在。通过container_of在 ops 回调里可以从传入的led_classdev*反推出你自己的rv1106_led_dev*然后拿到 GPIO 描述符和其他信息。这个模式在内核驱动里非常常见你要习惯。私有数据结构的设计就是整个驱动的灵魂。提前把所有必要字段想好probe里做的就是“把这个结构体填满”后面所有的 ops 都是围绕它转架构自然清晰。3.3 从用户层到底层的数据流把数据流画一遍你会对架构有更深的理解。当用户在终端敲下echo 1 /sys/class/leds/status_led/brightness内核里实际发生的事情是这样的VFS 层处理文件写入找到对应的 sysfs 文件对应的 inode。sysfs 的 store 回调被触发这个回调由 LED 框架注册。LED 框架把写入的字符串转换成brightness值然后调用led_cdev-brightness_set或led_cdev-brightness_set_blocking指针指向的函数。你的驱动 ops 里根据 brightness 值调用gpiod_set_value_cansleep(gpio, brightness ? 1 : 0)。GPIO 子系统把值设置到芯片 PWM/GPIO 控制器寄存器物理引脚电平变化LED 亮或灭。也就是说作为驱动作者你真正需要实现的回调函数只有static void rv1106_led_set_brightness(struct led_classdev *led_cdev, enum led_brightness brightness) { struct rv1106_led_dev *priv container_of(led_cdev, struct rv1106_led_dev, led_cdev); gpiod_set_value_cansleep(priv-gpio, brightness ? 1 : 0); }其它 sysfs 节点、触发器逻辑LED 框架全都帮你做好了。这也是我推荐用 LED class 的原因看似少写了很多代码实际上是框架帮你把复杂度和安全问题都解决了。3.4 触发器和多 LED 的架构扩展如果只是点亮和熄灭上面足够了。但实际项目里LED 往往要配合“事件”使用。比如网络断开时闪烁有数据读写时闪烁开机启动时先亮 1 秒然后熄灭Linux 的 LED 框架里有“触发器trigger”机制。最简单的做法是在驱动里注册一个 trigger比如heartbeat、netdev、timer用户可以在 sysfs 里随时切换。架构上如果你一开始就把 ops 分离出来后续加 trigger 只是新增一个文件或者配置项的事不需要改核心点亮逻辑。举个例子在设备树节点里可以加linux,default-trigger heartbeat;内核会自动把板级 LED 绑定到系统心跳闪烁上。不过这个需要内核配置开启相关的 triggerCONFIG_LEDS_TRIGGER_HEARTBEAT。在 RV1106 的 SDK 里多数 trigger 默认是开着的但你要确认一下。要是你要实现多个 LED比如一个电源灯、一个状态灯架构上建议就是“一个 probe 匹配多个子节点”在 probe 里遍历所有 compatible rv1106-led 的子节点为每个节点分配一个struct rv1106_led_dev分别注册到 LED 框架。这样的好处是所有 LED 驱动在同一模块里代码复用率高管理集中。4. 核心代码骨架与实现要点4.1 模块入口和出口直接给出我实现的模块入口文件骨架。#include linux/module.h #include linux/platform_device.h #include linux/of_device.h #include linux/leds.h #include linux/gpio/consumer.h #include linux/of_gpio.h static struct platform_driver rv1106_led_driver { .probe rv1106_led_probe, .remove rv1106_led_remove, .driver { .name rv1106-led, .of_match_table rv1106_led_of_match, }, }; module_platform_driver(rv1106_led_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Friendly Engineer); MODULE_DESCRIPTION(RV1106 LED driver);写驱动第一个要注意的细节模块加载宏。用module_platform_driver自动生成了module_init和module_exit不用自己手动写了。如果你用module_init(led_init)那也要相应地用module_exit(led_exit)千万别搞混不然打印错误信息可能显示符号找不到。of_match_table是设备树匹配的关键。定义如下static const struct of_device_id rv1106_led_of_match[] { { .compatible rv1106-led, }, { } }; MODULE_DEVICE_TABLE(of, rv1106_led_of_match);编译的时候MODULE_DEVICE_TABLE会把设备 ID 表生成加载时需要的模块符号。如果你只写了设备树节点却不写这个表insmod之后驱动可能一直不 probe这是新手最常见的坑。4.2 probe 函数怎么组织probe是整个驱动的“构造函数”它要做的事不外乎是解析设备树节点申请并填充priv结构体获取 GPIO注册 LED class 设备在 sysfs 里创建初始状态下面是一个完整度较高的实现static const struct of_device_id rv1106_led_of_match[] { { .compatible rv1106-led, }, { } }; MODULE_DEVICE_TABLE(of, rv1106_led_of_match); static int rv1106_led_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct device_node *np dev-of_node; struct rv1106_led_dev *led; const char *label; const char *default_state; int ret; led devm_kzalloc(dev, sizeof(*led), GFP_KERNEL); if (!led) return -ENOMEM; led-dev dev; /* 读 label如果没写则用 node 名字 */ ret of_property_read_string(np, label, label); if (ret) label np-name; /* 获取 GPIO这个函数会自动处理命名规则和极性 */ led-gpio devm_gpiod_get(dev, led, GPIOD_ASIS); if (IS_ERR(led-gpio)) { dev_err(dev, failed to get led gpio: %ld\n, PTR_ERR(led-gpio)); return PTR_ERR(led-gpio); } /* 设置初始方向 */ ret gpiod_direction_output(led-gpio, 0); if (ret) return ret; /* 读默认状态 */ default_state off; of_property_read_string(np, default-state, default_state); if (!strcmp(default_state, on)) { gpiod_set_value_cansleep(led-gpio, 1); led-led_cdev.brightness 1; } else { gpiod_set_value_cansleep(led-gpio, 0); led-led_cdev.brightness 0; } /* 填充 led_classdev */ led-led_cdev.name label; led-led_cdev.brightness_set rv1106_led_set_brightness; led-led_cdev.max_brightness 1; led-led_cdev.flags LED_CORE_SUSPENDRESUME; /* 注册到内核 LED 框架 */ ret devm_led_classdev_register(dev, led-led_cdev); if (ret) { dev_err(dev, failed to register led classdev: %d\n, ret); return ret; } dev_info(dev, rv1106 led driver registered: %s\n, label); return 0; }这里面有几个很关键的细节。第一devm_gpiod_get的第五个参数GPIOD_ASIS表示不改变引脚方向只拿描述符。然后自己调用gpiod_direction_output设置方向。这样更可控不会因为某个 SDK 版本里devm_gpiod_get自动设置方向和预想不一致。如果你确定要用可以用GPIOD_OUT_LOW或GPIOD_OUT_HIGH它会自动设置方向。第二label用在 LED 名字上确保不能为空。设备树里面的label如果没写我回退到节点名。因为后面 sysfs 路径会用到它空指针直接崩。第三max_brightness 1表示纯开关模式只有亮/灭两个状态。如果你以后要亮度调节比如 PWM 调光就需要把max_brightness设成 255并且brightness_set里根据输入值做 PWM 占空比映射。第四flags LED_CORE_SUSPENDRESUME这一个标志的作用是告诉框架系统 suspend/resume 的时候驱动会自动保存和恢复亮度。如果不需要可以不加。4.3 remove 函数和释放资源remove函数负责卸载时的清理。由于大量使用了devm_资源管理其实很多资源不用手动释放。但要注意两点GPIO 的电平状态和 sysfs 节点。static void rv1106_led_remove(struct platform_device *pdev) { struct rv1106_led_dev *led platform_get_drvdata(pdev); /* 把 LED 关掉避免卸载后引脚还是高电平导致灯常亮 */ gpiod_set_value_cansleep(led-gpio, 0); /* devm_ 管理的资源自动释放不需要你手动 gpio_free 或 kfree */ }platform_get_drvdata能拿到私有数据的默认机制前提是在 probe 里调用过platform_set_drvdata(pdev, led)。别漏了这句话。不过实际上devm_led_classdev_register会自动把led_classdev和设备的生命周期绑定卸载的时候框架会清理 sysfs 目录。所以remove里大部分情况下只需要处理“引脚状态恢复”这一类副作用。4.4 设备树的坑-gpios还是-gpio这是很多刚接触设备树的人会迷糊的地方。devm_gpiod_get在查找设备树属性时会自动把所有形如xxx-gpios的属性作为候选人然后根据conindex匹配参数。如果你写的是led-gpios ...在驱动里就传led。如果你的需求很特殊非要用一个单数属性名led-gpio那你需要知道旧的of_get_named_gpio函数是支持任意属性名的但使用gpiod接口时框架只会查找复数形式。所以设备树里统一写成led-gpios别写成led-gpio否则它拿不到描述符。还有一点如果你的 DTS 里引脚被别的驱动占用了比如被复用成 I2C 或者 UARTdevm_gpiod_get会返回-EBUSY这个看起来是“拿不到 GPIO”实际上可能是 pinmux 冲突。查这种问题别傻傻盯着驱动先去查 DTS 里有没有别的节点复用了同一个gpio。5. 调试技巧与常见问题排查5.1 驱动不 probe 怎么办这是 LED 驱动写完后第一个会碰到的状况。你insmod全加载了但probe就是不执行。先按这个顺序排查检查项具体操作of_match_table是否匹配在of_device_id里的compatible必须和设备树节点里的完全一致包括大小写设备树节点是否编译进 dtb打开生成的 dtb搜索节点名字或者用fdtdump确认设备树分区是否烧写成功启动 log 里看有没有Warning: unable to open an initial console之类或者确认启动加载的 dtb 分区模块是否被正确加载insmod后 dmesg最常用的排查方式是在 probe 函数入口加一个dev_info(dev, probe enter\n);。如果你看到函数进入了那就说明前面的匹配没问题。如果没进入优先检查compatible和设备树的对应关系。另外有些 SDK 在启动时不会自动加载模块所以即使写好了驱动模块也别忘了用insmod或把模块名加到启动脚本里。如果你用的是内核内建方式CONFIG_LEDS_RV1106y那驱动会随内核启动自动匹配并加载。5.2 引脚电平不对LED 亮灭反了这是最典型的极性坑。板子设计可能是“低电平点亮”。如果你的驱动里简单地用gpiod_set_value(desc, 0)熄灭默认的思维是“0 表示灭”但在低点亮板子上“0”反而是亮。解决这个问题的方法是永远不要自己在驱动代码里判断反正信号。应该这样做设备树里老老实实写GPIO_ACTIVE_LOW或GPIO_ACTIVE_HIGH。驱动代码里统一用逻辑值1表示点亮0表示熄灭。让 GPIO 子系统的devm_gpiod_get自动根据 flags 转换电平。比如板子实际是低电平点亮设备树写led-gpios gpio0 15 GPIO_ACTIVE_LOW;驱动里写gpiod_set_value_cansleep(led-gpio, 1); // 点亮 gpiod_set_value_cansleep(led-gpio, 0); // 熄灭GPIO 子系统会在中间做一层语义转换它在内部看到GPIO_ACTIVE_LOW当你的逻辑值是 1它会输出真正的低电平。如果你自己手动读引脚状态或者测试别用gpio_get_value看原始寄存器值来判断亮没亮那样很容易被低电平搞晕。要看实际现象不要看寄存器值。5.3 卸载模块的时候 kernel 报错或者 panic卸载模块常见问题有两个sysfs 还在被应用层引用。比如应用层打开了/sys/class/leds/xxx/brightness这个文件或者在某个 shell 脚本里循环读它这时卸载模块会导致释放的内存被 uevent 访问比较常见的是led_classdev回调到空指针。remove 顺序错乱。注册 LED class 设备的函数是devm_led_classdev_register驱动程序模块卸载的时候framework 会自动注销但是前提是你的remove函数不能先手动去释放 GPIO 描述符并且内核正在用led_classdev操作中断——所以 remove 里尽可能少做事除了熄灭 LED。其余交给devm的统一收尾。如果一定要手动注销led_classdev_unregister要放在gpio_free之前。static int rv1106_led_remove(struct platform_device *pdev) { struct rv1106_led_dev *led platform_get_drvdata(pdev); /* 先注销 LED 设备确保 sysfs 不再可访问 */ led_classdev_unregister(led-led_cdev); /* 再熄灭灯 */ gpiod_set_value_cansleep(led-gpio, 0); return 0; }但你说你要用devm_led_classdev_register吗那记得在remove里就不要调led_classdev_unregister。这是两条不同的资源管理路径你选一个用到底千万别混着写。混用会导致重复注销内核直接 warning。5.4 怎么快速验证驱动是否正常调试 LED 驱动我最常用这几个命令# 查看 LED 设备是否存在 ls /sys/class/leds/ # 手动点亮 echo 1 /sys/class/leds/status_led/brightness # 手动熄灭 echo 0 /sys/class/leds/status_led/brightness # 设置默认触发器如果支持 echo heartbeat /sys/class/leds/status_led/trigger如果ls /sys/class/leds/已经能看到设备名但echo没反应大概率是brightness_set回调没有正确设置或者 GPIO 描述符拿错引脚下。可以检查 dmesg 里驱动打印的任何错误。另外有一种很常见的迷惑现象写echo 1亮灯但是再写echo 0灭灯没反应。这时检查brightness的值是否被框架缓存了如果brightness_set里使用了brightness变量但没做 0/1 映射比如直接把 brightness 传给了gpiod_set_value而gpiod_set_value期待非 0 即 1那其实是没问题的。但如果你的max_brightness设了 255而板子只支持开关你需要自己判断brightness 0才点亮否则关掉否则在中间值的地方行为会很奇怪。6. 实测中积累的几个经验这个 LED 驱动虽然简单我在调试过程里还是踩了几个有意思的坑分享出来你可能也会遇到。第一个坑是“代码里明明写了gpiod_set_value(desc, 1)LED 就是一直灭”。后来发现设备树节点里写的是GPIO_ACTIVE_HIGH而板子实际是低电平亮。我把它改成了GPIO_ACTIVE_LOW驱动一行没动灯就正常了。所以遇到这种问题优先怀疑设备树极性不要怀疑代码。第二个坑是设备树里我同时写了老朋友gpios属性和新风格led-gpios结果驱动有时能匹配有时不能。原因是当一个节点同时有多个 gpio 相关属性时devm_gpiod_get会按属性名字的地址、配置顺序去选容易产生歧义。最佳实践是只保留一个属性。第三个坑是模块卸载后系统提示led_classdev相关的 workqueue 还在跑。我检查后发现是没有设置.flags里LED_CORE_SUSPENDRESUME导致的系统 suspend/resume 行为不一致。后来我在驱动初始化时把flags补充完整这个报警就没了。总的来说写 RV1106 的 LED 驱动不要因为功能简单就忽略架构。先用 LED class 把标准接口搭起来用设备树管理硬件差异用私有结构体串起 ops 和设备资源后面无论扩展呼吸灯、状态触发还是多 LED 支持都会非常从容。这一篇先把整体框架立住了下一篇我会继续拆解具体的设备树配置细节、怎么配合 sysfs 测试以及如果要加 PWM 呼吸灯效果驱动部分要改动哪几个点。