做嵌入式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层查找对应的inode
- inode里记录的主设备号和次设备号找到对应的cdev
- cdev调用led_fops结构体中的open函数
- echo命令写入字符'1',VFS调用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和device:
static 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 | 点亮LED |
| echo 0 > /dev/led0 | 熄灭LED |
| rmmod 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文件。用这个命令确认板子上实际生效的dtb:
cat /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_LOW |
| echo写入返回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属性就能控制LED:
echo 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虽小,但它串联起来的这套知识体系,足够一个初学者用几个月时间反复咀嚼了。