嵌入式Linux驱动入门:从LED驱动拆解GPIO与设备树开发全流程
2026/9/16 10:00:17 网站建设 项目流程

带过不少刚转嵌入式Linux的朋友,每次第一份任务我都会布置同一个项目:写一个GPIO控制LED的驱动。很多人不理解,点灯不是单片机玩的入门demo吗?Linux驱动也拿它当起点,是不是太没排面了?但真把这个项目从头到尾跑通,你才会发现,字符设备框架、设备树匹配、GPIO子系统、模块加载、应用层访问,这一整条驱动开发的主干链路全部被串起来了,没有任何一个环节是可以跳过去蒙混过关的。这篇文章就把这条链路掰开揉碎讲给你听,从驱动模型到硬件原理,从设备树到实战代码,从编译加载到常见坑点,完整过一遍。准备入行嵌入式Linux驱动的朋友,照着这个思路自己敲一遍,会比只看书一族的效率高不少。

1. 驱动开发前,先把框架揉碎

很多初学者拿到任务就急着找代码,先看了两遍内核源码,越看越晕。我建议反过来,先搞明白Linux驱动到底在解决什么问题,再动手写。

1.1 Linux设备驱动到底在做什么

从应用层视角看,驱动就是让“操作文件”这件事变得有意义。你打开/dev/led0,往里面写一个字符1,LED亮了;写一个0,LED灭了。这个过程中,驱动的作用是把“用户态写入的数据”翻译成“硬件引脚的翻转动作”。换句话说,驱动是内核态里连接应用和硬件的那座桥。

这和单片机裸机开发有本质区别。裸机里你直接操作寄存器地址,比如*(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是驱动的操作表,里面放着一堆函数指针,比如openreadwriteioctlrelease。用户进程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阴极接地,阳极经过电阻接GPIO,GPIO输出高电平时LED亮,也就是“高电平点亮”。

Linux GPIO子系统提供了一个抽象机制:设备树里用GPIO_ACTIVE_LOWGPIO_ACTIVE_HIGH描述硬件逻辑。这样驱动代码里统一写gpiod_set_value(desc, 1)表示“点亮”,GPIO子系统会根据active_low属性自动去反转物理电平。不注意这个极性,你可能会看到逻辑上写1,实际引脚输出0,LED反而灭了。

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网络标号,比如LED0D1。追踪这个网络到主芯片的哪个引脚,记录引脚的bank号和序号,例如GPIO5_IO03PB5GPIO1_19等。

再看LED和VCC/GND的连接方式,判断是共阳还是共阴,从而确定GPIO_ACTIVE_LOW还是GPIO_ACTIVE_HIGH。如果手头有芯片手册,可以翻开GPIO章节,确认这个引脚是否支持中断、内部上拉范围是多少、复用功能表长什么样。不同SoC的GPIO编号规则差异很大,有的按bank+bit,有的按全局索引,设备树里要写对。

另外注意,有些芯片引脚带模拟功能,有些是电源域引脚,不能直接当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_getdevm_kzallocdevm_request_irq。这类接口的生命周期和struct device绑定,设备被移除或者驱动被解绑时,内核自动释放对应的资源,不需要你在remove回调里一个一个去free。

这带来两个好处:一是代码简洁,不用维护一堆goto err的错误处理路径;二是降低资源泄漏风险。写简单驱动时,probe里只用devm_接口,remove甚至可以只留空函数,全靠内核自动清理。不过要注意,gpio描述符如果用非devm接口申请,就必须在remove里手动释放,否则卸载模块会报警告。

4. 完整驱动代码实战

这一节是全文的重头戏。我会写一个完整的字符设备驱动,使用platform驱动模型匹配设备树,获得GPIO描述符,然后提供writeioctl两个操控接口。

4.1 驱动代码实现

程序设计思路:设备树节点led-driver匹配驱动,驱动probe时获取led-gpios,注册字符设备,创建/dev/led_drv节点。应用层往节点写字符10控制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) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(CROSS_COMPILE) M=$(PWD) ARCH=arm 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。

应用层测试程序很简单,可以直接用shell:

echo 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 faileddevice_create失败之类的日志。常见原因是设备号分配失败,或者class_createdevice_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规则,把设备节点分配给指定用户组,而不是粗暴开放权限。

KERNEL=="led_drv", MODE="0666"

放到/etc/udev/rules.d/下,重启后设备节点就能被普通用户访问。这也是一线开发里常说的“让非root用户也能操作硬件”的常规做法。

6. 入门之后还能怎么走

LED驱动不是终点,而是通往Linux驱动开发的第一个里程碑。很多之前看资料看不明白的概念,比如设备模型、设备树、平台驱动,做完这个项目后都会有直观感受。接下来可以沿着几个方向继续深入。

6.1 从LED驱动扩展到中断、定时器和I2C

LED驱动里只用到了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_dbgdev_info日志,基本能覆盖90%的调试场景。遇到问题时,先看dmesg,再查debugfs,最后定位代码,效率最高。

我个人带新人的体会是,点灯这个项目真不是走过场。你把它从头到尾自己写一遍,从设备树到驱动到应用层,中间的每一个细节都亲手调过,之后再去看网上的那些复杂驱动框架,听起来就不会觉得隔着一层纱了。踩过几次坑之后你会发现,所谓“Linux设备驱动开发”,其实核心就是一套固定的骨架,外设千变万化,骨架不变。先把骨架刻进脑子里,后面的一切都顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询