ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式Linux驱动入门:从LED驱动拆解GPIO与设备树开发全流程

嵌入式Linux驱动入门:从LED驱动拆解GPIO与设备树开发全流程 带过不少刚转嵌入式Linux的朋友每次第一份任务我都会布置同一个项目写一个GPIO控制LED的驱动。很多人不理解点灯不是单片机玩的入门demo吗Linux驱动也拿它当起点是不是太没排面了但真把这个项目从头到尾跑通你才会发现字符设备框架、设备树匹配、GPIO子系统、模块加载、应用层访问这一整条驱动开发的主干链路全部被串起来了没有任何一个环节是可以跳过去蒙混过关的。这篇文章就把这条链路掰开揉碎讲给你听从驱动模型到硬件原理从设备树到实战代码从编译加载到常见坑点完整过一遍。准备入行嵌入式Linux驱动的朋友照着这个思路自己敲一遍会比只看书一族的效率高不少。1. 驱动开发前先把框架揉碎很多初学者拿到任务就急着找代码先看了两遍内核源码越看越晕。我建议反过来先搞明白Linux驱动到底在解决什么问题再动手写。1.1 Linux设备驱动到底在做什么从应用层视角看驱动就是让“操作文件”这件事变得有意义。你打开/dev/led0往里面写一个字符1LED亮了写一个0LED灭了。这个过程中驱动的作用是把“用户态写入的数据”翻译成“硬件引脚的翻转动作”。换句话说驱动是内核态里连接应用和硬件的那座桥。这和单片机裸机开发有本质区别。裸机里你直接操作寄存器地址比如*(volatile uint32_t *)0x1234 | (1 3);把某个位拉高。但在Linux里用户程序不能随便碰物理地址必须通过系统调用陷入内核再由驱动代码去访问硬件。同时驱动不能随手拿一个引脚号就开点因为引脚可能被其他驱动复用、被pinctrl配置成了别的功能甚至设备树里根本就没描述过这个外设。所以Linux驱动开发的核心是在正确的位置用正确的接口拿到资源然后完成硬件操作。理解了这一点你就明白为什么LED驱动虽然简单但必须具备完整的驱动框架——没有框架你连“设备树里描述的GPIO”都拿不到更别提安全地操作它了。1.2 设备、总线和驱动三件套Linux设备模型里最核心的三个概念是设备device、总线bus、驱动driver。设备是外部世界存在的硬件实体驱动是操作该硬件的代码总线则负责将两者关联起来。当内核发现一个设备和一个驱动的特征匹配时就会调用驱动里的probe函数这个函数就是驱动生命的起点。在SoC内部很多设备其实是“片上设备”Linux为这类设备抽象出了一条虚拟总线叫platform总线。GPIO控制器、UART、I2C控制器、LED这类GPIO外设通常都挂在platform总线上。我们写一个platform_driver然后在设备树里声明一个对应的platform_device内核会在启动时把两者匹配上然后执行我们注册的probe回调。设备树描述硬件驱动描述操作方式总线负责牵线这就是现代嵌入式Linux驱动的基本工作方式。1.3 字符设备驱动的骨架LED驱动属于字符设备因为它的数据交换方式是逐字节的、顺序的。字符设备驱动绕不开三个东西设备号、file_operations结构体、cdev对象。设备号分主设备号和次设备号主设备号标识驱动类型次设备号标识同类设备中的不同实例。向内核注册设备号有两种方式register_chrdev_region指定设备号和alloc_chrdev_region动态分配。不特殊需求就用后者避免设备号冲突。file_operations是驱动的操作表里面放着一堆函数指针比如open、read、write、ioctl、release。用户进程open(/dev/led0)时内核根据设备号找到对应的cdev再找到这个操作表调用其中的open函数。cdev结构体是字符设备在内核中的化身初始化后要用cdev_add将其添加到内核。为了不在应用层手动mknod创建设备节点还要用class_create创建一个类再用device_create在/dev下生成设备节点。这套“注册设备号→初始化cdev→添加cdev→创建设备节点”的流程就是字符设备驱动的骨架。后面所有的驱动无论控制LED、读取按键还是操作传感器都逃不出这个模板。2. 硬件的底细和GPIO模式写驱动之前不看硬件原理图就直接抄代码几乎必然踩坑。LED驱动虽然简单但“极性搞反”的情况我见得太多了先花几分钟把硬件搞清楚后面省一天时间。2.1 LED电路与GPIO电气特性LED本质上是一个二极管正向导通压降通常在1.8V到2.2V之间工作电流一般控制在5到10mA。GPIO引脚不能直接输出过大电流所以电路上必须串联限流电阻。比如一个3.3V供电的系统采用1kΩ限流电阻电流大约是(3.3 - 2.0) / 1000 1.3mA这个亮度偏小换成330Ω电流约4mA常见开发板就用这个范围。更关键的是LED的接法。如果是共阳接法LED的阳极接VCC阴极经过电阻接到GPIO此时GPIO输出低电平时LED亮这就是“低电平点亮”。如果是共阴接法LED阴极接地阳极经过电阻接GPIOGPIO输出高电平时LED亮也就是“高电平点亮”。Linux GPIO子系统提供了一个抽象机制设备树里用GPIO_ACTIVE_LOW或GPIO_ACTIVE_HIGH描述硬件逻辑。这样驱动代码里统一写gpiod_set_value(desc, 1)表示“点亮”GPIO子系统会根据active_low属性自动去反转物理电平。不注意这个极性你可能会看到逻辑上写1实际引脚输出0LED反而灭了。2.2 GPIO的输入输出模式与上下拉网上搜“gpio的8种工作模式”大部分内容是从STM32裸机开发角度讲的包括推挽输出、开漏输出、浮空输入、上拉输入、下拉输入、模拟输入等。Linux内核里没有这样直接叫“8种模式”但GPIO子系统配合pinctrl子系统同样能完成输入输出方向、内部上拉/下拉、驱动强度、开漏等配置。需要理解的是GPIO作为一个复用引脚在某个时刻只能承担一种功能。要么作为普通GPIO要么作为UART、I2C、PWM等外设引脚。这个选择由pinctrl完成设备树里的pinctrl-0属性指向一个pin controller节点内核会根据它把引脚配置为GPIO功能同时设置上下拉电阻。我们点LED时引脚一定要配置为GPIO输出模式。如果设备树里没有pinctrl配置而该引脚默认被复用成其他功能那么GPIO子系统在申请引脚时就会报“pin already requested”或者操作根本没反应。这也是嵌入式Linux点灯时最常见的坑之一。2.3 看原理图和芯片手册的姿势拿到一块开发板不要急着翻内核源码先从原理图里找到LED网络标号比如LED0、D1。追踪这个网络到主芯片的哪个引脚记录引脚的bank号和序号例如GPIO5_IO03、PB5、GPIO1_19等。再看LED和VCC/GND的连接方式判断是共阳还是共阴从而确定GPIO_ACTIVE_LOW还是GPIO_ACTIVE_HIGH。如果手头有芯片手册可以翻开GPIO章节确认这个引脚是否支持中断、内部上拉范围是多少、复用功能表长什么样。不同SoC的GPIO编号规则差异很大有的按bankbit有的按全局索引设备树里要写对。另外注意有些芯片引脚带模拟功能有些是电源域引脚不能直接当GPIO用。这些信息都藏在芯片手册和原理图里依赖“经验”不如依赖“图纸”。3. 用好GPIO子系统和设备树明白了硬件就可以开始写代码了。但在写之前我强烈建议不要再用十几年前的旧GPIO接口新代码一律用gpiod_*系列接口。3.1 老接口和新接口的对比早期Linux GPIO接口是整数型的gpio_request(19, led)gpio_direction_output(19, 0)gpio_set_value(19, 1)。用起来不算难但有几个明显问题GPIO编号在设备树上并不直观需要在代码里硬编码active-low的处理要自己写逻辑申请的资源容易泄露。而且拿编号的方式在不同平台不一致移植性较差。新接口是描述符型devm_gpiod_get(dev, led, GPIOD_OUT_LOW)返回一个struct gpio_desc *。它直接读取设备树节点里的led-gpios自动处理GPIO_ACTIVE_LOW带有资源管理遇到错误返回IS_ERR指针而不是裸的负数。代码更简洁也更不容易出错。内核文档和代码审查基本都要求新代码使用新接口。我在实际项目里还会用gpiod_set_value_cansleep当GPIO背后挂在I2C或SPI扩展芯片上时操作可能睡眠需要对应版本。虽然LED很少挂在I2C扩展上但如果你做1路UART转16路GPIO扩展这种方案就一定会遇到。3.2 设备树里的LED节点怎么写设备树是描述硬件的一张“表格”驱动通过它拿到GPIO等资源。最简单的点LED方式是直接使用内核自带的gpio-leds驱动它不需要你写任何设备驱动代码只要在设备树里加一个节点然后通过/sys/class/leds/下的属性控制LED。leds { compatible gpio-leds; led0 { label sys-led; gpios gpio5 3 GPIO_ACTIVE_LOW; default-state off; linux,default-trigger heartbeat; }; };但我们要演示字符设备驱动开发不能直接白嫖内核现成驱动。所以设备树写成自定义的compatible然后在驱动里匹配它。led_drv: led-driver { compatible my-led-driver; led-gpios gpio5 3 GPIO_ACTIVE_LOW; status okay; };这里有几个细节gpios属性名字不是固定的驱动里用devm_gpiod_get(dev, led, ...)它会去找led-gpios这个属性。所以属性名和获取函数是一一对应的别写岔了。GPIO_ACTIVE_LOW宏定义在dt-bindings/gpio/gpio.h中设备树源文件要#include dt-bindings/gpio/gpio.h。3.3 devm_前缀让资源自动释放现代内核驱动大量使用devm_managed device开头的接口例如devm_gpiod_get、devm_kzalloc、devm_request_irq。这类接口的生命周期和struct device绑定设备被移除或者驱动被解绑时内核自动释放对应的资源不需要你在remove回调里一个一个去free。这带来两个好处一是代码简洁不用维护一堆goto err的错误处理路径二是降低资源泄漏风险。写简单驱动时probe里只用devm_接口remove甚至可以只留空函数全靠内核自动清理。不过要注意gpio描述符如果用非devm接口申请就必须在remove里手动释放否则卸载模块会报警告。4. 完整驱动代码实战这一节是全文的重头戏。我会写一个完整的字符设备驱动使用platform驱动模型匹配设备树获得GPIO描述符然后提供write和ioctl两个操控接口。4.1 驱动代码实现程序设计思路设备树节点led-driver匹配驱动驱动probe时获取led-gpios注册字符设备创建/dev/led_drv节点。应用层往节点写字符1或0控制LED亮灭。#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/of.h #include linux/gpio/consumer.h #include linux/platform_device.h #include linux/uaccess.h #define LED_ON _IO(L, 0) #define LED_OFF _IO(L, 1) struct led_dev { struct gpio_desc *gpio; dev_t devno; struct cdev cdev; struct class *class; struct device *dev; }; static struct led_dev *led_data; static int led_open(struct inode *inode, struct file *filp) { 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) { char val; if (copy_from_user(val, buf, 1)) return -EFAULT; if (val 1) gpiod_set_value(led_data-gpio, 1); else if (val 0) gpiod_set_value(led_data-gpio, 0); else return -EINVAL; return 1; } static long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case LED_ON: gpiod_set_value(led_data-gpio, 1); break; case LED_OFF: gpiod_set_value(led_data-gpio, 0); break; default: return -ENOTTY; } 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, }; static int led_probe(struct platform_device *pdev) { int ret; led_data devm_kzalloc(pdev-dev, sizeof(*led_data), GFP_KERNEL); if (!led_data) return -ENOMEM; led_data-gpio devm_gpiod_get(pdev-dev, led, GPIOD_OUT_LOW); if (IS_ERR(led_data-gpio)) { dev_err(pdev-dev, Failed to get led gpio: %ld\n, PTR_ERR(led_data-gpio)); return PTR_ERR(led_data-gpio); } ret alloc_chrdev_region(led_data-devno, 0, 1, led_drv); if (ret 0) { dev_err(pdev-dev, Failed to alloc chrdev region\n); return ret; } cdev_init(led_data-cdev, led_fops); led_data-cdev.owner THIS_MODULE; ret cdev_add(led_data-cdev, led_data-devno, 1); if (ret 0) { unregister_chrdev_region(led_data-devno, 1); dev_err(pdev-dev, Failed to add cdev\n); return ret; } led_data-class class_create(THIS_MODULE, led_drv_class); if (IS_ERR(led_data-class)) { cdev_del(led_data-cdev); unregister_chrdev_region(led_data-devno, 1); return PTR_ERR(led_data-class); } led_data-dev device_create(led_data-class, pdev-dev, led_data-devno, NULL, led_drv); if (IS_ERR(led_data-dev)) { class_destroy(led_data-class); cdev_del(led_data-cdev); unregister_chrdev_region(led_data-devno, 1); return PTR_ERR(led_data-dev); } platform_set_drvdata(pdev, led_data); dev_info(pdev-dev, LED driver probed\n); return 0; } static void led_remove(struct platform_device *pdev) { struct led_dev *data platform_get_drvdata(pdev); device_destroy(data-class,>CROSS_COMPILE ? arm-linux-gnueabihf- KDIR ? /path/to/kernel-source PWD : $(shell pwd) obj-m : led_drv.o all: $(MAKE) -C $(KDIR) M$(PWD) ARCHarm CROSS_COMPILE$(CROSS_COMPILE) modules clean: $(MAKE) -C $(CROSS_COMPILE) M$(PWD) ARCHarm clean注意KDIR指向的是目标板的内核源码目录不能指到本机的/lib/modules/$(uname -r)/build除非你是在主机上加载模块。执行make后当前目录下会生成led_drv.ko。用file led_drv.ko可以确认模块架构是ARM还是ARM64吃不准的时候最喜欢用这个命令排查交叉编译配置错误。4.3 设备树编译与烧写设备树源文件修改后如果使用内核自带的build系统直接执行make dtbs生成的.dtb文件路径一般在arch/arm/boot/dts/下。也可以单独用dtc工具编译一个独立设备树源文件但用内核的counterpart更安全因为头文件路径和宏定义都能正确解析。拿到新的dtb后把开发板现有dtb备份然后把新dtb拷贝到/boot分区或者u-boot加载的fat分区。重启后在u-boot命令行也可以确认设备树是否加载成功比如用printenv查看fdtfile变量。4.4 加载模块并验证模块和设备树都准备好后使用insmod led_drv.ko加载驱动。这时候重点观察dmesg的输出insmod led_drv.ko dmesg | tail正常情况下会看到LED driver probed的日志并且/dev/led_drv节点已经生成。如果没有probe日志多半是设备树compatible对不上或者设备树根本没有加载新dtb。应用层测试程序很简单可以直接用shellecho 1 /dev/led_drv sleep 1 echo 0 /dev/led_drv也可以写一个C程序#include stdio.h #include fcntl.h #include unistd.h #include sys/ioctl.h #define LED_ON _IO(L, 0) #define LED_OFF _IO(L, 1) int main(int argc, char **argv) { int fd open(/dev/led_drv, O_RDWR); if (fd 0) { perror(open); return -1; } ioctl(fd, LED_ON); sleep(2); write(fd, 0, 1); close(fd); return 0; }建议把C程序用交叉编译器编成ARM可执行文件放到开发板上跑。调试阶段记得先chmod 666 /dev/led_drv免得一直被权限挡住。5. 调试与踩坑常见问题速查LED驱动看着简单但新手在实际跑通时会遇到一堆奇奇怪怪的问题。我把带新人时最常见的几类问题整理出来每个都附上排查思路。5.1 模块加载失败或设备节点不出现加载led_drv.ko后如果dmesg没有任何输出首先排查设备树是否被正确加载了。可以在开发板上执行ls /sys/firmware/devicetree/base/led-driver如果设备树节点存在说明新的dtb已经生效。再看看compatible是否匹配cat /sys/firmware/devicetree/base/led-driver/compatible正常会输出my-led-driver。如果没有这个路径说明设备树没改对或者没有被启动流程加载。如果probe执行了但设备节点没生成就看dmesg里有没有alloc_chrdev_region failed、device_create失败之类的日志。常见原因是设备号分配失败或者class_create和device_create之间某个环节返回错误。把dev_err打印信息加上一眼就能定位。5.2 GPIO被占用和引脚复用冲突错误信息类似gpio: pin 187 already requested表示这个GPIO已经被别的驱动申请了。先查占用者cat /sys/kernel/debug/gpio看输出里gpio-187后面挂着哪个label。如果是sys-led说明内核的gpio-leds驱动已经把它占用了你自己的自定义节点应该换个GPIO或者把原来的leds节点禁用掉。如果label是pinctrl则说明引脚被配置成了其他复用功能需要修改pinctrl配置把引脚切回GPIO模式。这种冲突在开发板上很常见尤其是同一个GPIO被多个设备树节点引用时。排查思路就是先用debugfs看清楚归属再去设备树里“让路”。5.3 电平逻辑反了或LED不亮驱动加载成功/dev/led_drv也有但写1灯不亮这种情况先不要怀疑代码多半是极性配置错了。gpiod_set_value(desc, 1)在内核里会根据GPIO_ACTIVE_LOW做逻辑反转物理电平可能是0但逻辑上是1。如果你的设备树里GPIO_ACTIVE_*写反了就会表现为写1灭、写0亮。先确认硬件原理图再检查设备树里的宏定义。用cat /sys/kernel/debug/gpio也能看到该GPIO当前的实际值和逻辑值辅助判断。如果极性没问题那就是硬件问题。用万用表量一下GPIO引脚电压写1时有没有跳变LED两端电压差够不够限流电阻是否虚焊。驱动的问题可以用软件查硬件的问题必须用仪器这是两个世界。5.4 模块卸载时崩溃或提示设备忙加载模块后如果应用层还在占用设备文件rmmod通常会失败因为模块引用计数不为0。先关掉所有打开设备文件的应用或者执行fuser -k /dev/led_drv杀进程再卸载。如果rmmod时内核崩溃多半是remove里释放资源的顺序不对或者你有手动申请的资源没有释放。现代devm_接口已经大幅降低这类风险只要所有资源都用devm_申请remove回调不写都能安全卸载。这里再次强调能用devm_就不要自己管理资源。5.5 设备节点权限问题刚生成的/dev/led_drv默认是root权限普通用户没法写。测试时可以直接chmod 666 /dev/led_drv在产品中则建议写udev规则把设备节点分配给指定用户组而不是粗暴开放权限。KERNELled_drv, MODE0666放到/etc/udev/rules.d/下重启后设备节点就能被普通用户访问。这也是一线开发里常说的“让非root用户也能操作硬件”的常规做法。6. 入门之后还能怎么走LED驱动不是终点而是通往Linux驱动开发的第一个里程碑。很多之前看资料看不明白的概念比如设备模型、设备树、平台驱动做完这个项目后都会有直观感受。接下来可以沿着几个方向继续深入。6.1 从LED驱动扩展到中断、定时器和I2CLED驱动里只用到了GPIO输出能力。下一步可以试着在同一个驱动里加一个按键输入按键按下触发中断中断里翻转LED。你会接触到request_threaded_irq、中断上下文、devm_request_irq等概念也会理解为什么不能在中断上下文里随便调用gpiod_set_value。如果板子里有I2C接口可以尝试挂一个I2C外设比如EEPROM或者I2C接口的RGB LED芯片。用i2c_driver结构体搭配设备树节点compatible就能把同样一套“设备树匹配probe字符设备”的思路复用到I2C设备场景中。很多做系统裁剪和BSP的朋友最后都在跟I2C/SPI这类接口打交道套路是一致的。6.2 系统裁剪与设备树优化当你掌握驱动开发的流程后就可以开始研究系统裁剪了。内核编译时可以去掉不需要的驱动、文件系统、网络协议栈减小内核镜像体积加快启动速度。设备树方面要清理掉用不到的外设节点避免GPIO和中断资源被无谓占用。启动时可以通过bootargs里的loglevel参数控制内核打印正式发布时降低log级别减少串口输出带来的启动延迟。还可以用设备树overlay做模块化调试同一份内核通过加载不同的dtb overlay适配不同硬件版本。这种方法在量产产品里很常见好处是不用为每个硬件版本单独编译内核只替换dtb就能适配。6.3 开发过程中高频Linux命令最后分享一份我在驱动调试中高频使用的Linux命令清单。做嵌入式开发这些东西几乎每天都会用到。dmesg # 内核日志驱动打印信息 lsmod # 查看已加载模块 modinfo led_drv.ko # 查看模块信息 insmod/rmmod/modprobe # 模块加载/卸载/自动管理 ls -l /dev/led_drv # 查看设备节点 cat /sys/kernel/debug/gpio # 查看GPIO分配和状态 cat /proc/device-tree/led-driver/compatible # 查看设备树compatible find /sys/bus/platform -name led* # 查看platform总线下的设备/驱动 file led_drv.ko # 查看模块架构 cat /proc/interrupts # 检查中断注册这些命令配合驱动代码里的dev_dbg、dev_info日志基本能覆盖90%的调试场景。遇到问题时先看dmesg再查debugfs最后定位代码效率最高。我个人带新人的体会是点灯这个项目真不是走过场。你把它从头到尾自己写一遍从设备树到驱动到应用层中间的每一个细节都亲手调过之后再去看网上的那些复杂驱动框架听起来就不会觉得隔着一层纱了。踩过几次坑之后你会发现所谓“Linux设备驱动开发”其实核心就是一套固定的骨架外设千变万化骨架不变。先把骨架刻进脑子里后面的一切都顺了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进