news 2026/9/18 1:40:22

用LED驱动入门Linux设备开发:从字符设备到GPIO与设备树实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用LED驱动入门Linux设备开发:从字符设备到GPIO与设备树实践

1. 拆解问题:一个LED驱动背后到底藏着哪几层知识

我第一次接触Linux设备驱动时,心里其实很懵:驱动到底是个什么东西?网上的入门教程大多让你写个hello world,编译完insmod,dmesg里打出一行字就宣布“入门成功”。但真要让一个硬件动起来,比如点亮一颗LED,你会发现那行字什么忙都帮不上。后来我把目标换成“用Linux控制GPIO点亮一颗LED”,整个驱动的骨架才一点点清晰起来。

当你决定从LED切入Linux驱动开发时,表面上看只是控制一个引脚的电平高低,但内核把你和硬件之间隔了很多层。你必须回答四个问题:应用层怎么找到驱动?驱动怎么知道LED接在哪个引脚?内核用什么接口去操作GPIO?驱动代码又是在什么时候被执行起来的?

这四个问题分别对应着字符设备框架、设备树描述、GPIO子系统、平台驱动模型。把它们串起来,你就等于走完了一遍Linux设备驱动的完整开发流程。这也是我写这篇文章的目的:用一颗LED,把驱动开发的链路从头到尾捋一遍,而不是停在“点亮了,好耶”这个表面。

我用一块i.MX6ULL开发板做演示,Cortex-A7内核,跑Linux 5.4版本。你可以换成树莓派、全志H3或者任何能跑Linux的开发板,原理完全一致。区别只在设备树里的引脚编号和pinctrl配置不同。下面我们开始。

2. 硬件与开发环境:先把手里的牌理清楚

2.1 选型与接线:为什么选i.MX6ULL

做驱动开发,板子千万别太冷门,也别太复杂。我选择i.MX6ULL是因为它有三点好处:第一,资料多,遇到问题搜得到;第二,芯片内部有原生GPIO控制器,不涉及外部扩展芯片,点灯逻辑足够纯粹;第三,官方BSP和主线Linux都支持,设备树文件可以直接修改。

接线更简单。开发板上有一颗用户LED,原理图显示它连接到了GPIO1_IO03引脚,而且LED阴极接引脚、阳极通过电阻接3.3V,所以这颗LED是低电平点亮。这个“低电平点亮”细节很重要,后面写设备树和驱动时会直接体现出来。如果你用的是其他开发板,打开原理图搜“LED”,找到连接的GPIO编号即可,不需要额外搭电路。

2.2 交叉编译工具链:宿主和目标不是一回事

目标板跑的是ARM Linux,但你在PC上写代码、编译,所以要使用交叉编译工具链。我用的是arm-linux-gnueabihf-gcc8.3版本,对应Buildroot或Yocto生成的环境。驱动模块必须和板子内核使用同一把编译器、同一份内核源码编译,否则会出现版本字符串不匹配、无法加载的问题。

这一点是新手最容易踩的坑。如果你直接拿板子官方BSP里提供的arm-linux-gnueabihf-工具链,那基本配套。如果是自己装的通用工具链,内核源码版本和配置也要能对得上。反正记住一个原则:模块不是独立程序,它是内核的一部分,编译它必须使用内核源码树里的Makefile体系,而不是直接用gcc -c

2.3 内核源码准备:不用重新编译整个内核

有些同学一听“内核”就以为要花两小时编译完整镜像。这里可以放宽心:开发模块不需要全量编译内核,只要把内核源码下载好,先配置好.config,执行make modules_prepare生成模块编译所需头文件即可。

我习惯的做法是:从开发板厂商的Git仓库拉取对应版本的内核,使用厂商提供的默认配置make xxx_defconfig,然后make modules_prepare。这一步走完,就可以开始写驱动源码和Makefile了。整个过程不到十分钟,前提是PC上要有足够的内存,磁盘也要留出至少10GB空间。

3. 第一个版本:不碰硬件,先把字符设备框架搭出来

3.1 设备号、cdev和设备节点三件套

在Linux里,应用层操作一个硬件是通过设备文件完成的,比如/dev/gpio_led。驱动一侧则需要向内核注册一个“字符设备”,并分配设备号。设备号由主设备号和次设备号构成,主设备号标识驱动类型,次设备号用于区分同一个驱动管理的多个设备。

手动指定主设备号很容易冲突,更稳妥的方式是让内核动态分配。下面这段代码演示了申请设备号、初始化cdev、把设备添加到内核的全过程:

#include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> #define LED_CLASS_NAME "gpio_led" #define LED_DEVICE_NAME "gpio_led" static dev_t led_devno; static struct cdev led_cdev; static struct class *led_class; static char led_state = '0';

在入口函数里做初始化:

static int __init led_init(void) { int ret; ret = alloc_chrdev_region(&led_devno, 0, 1, LED_DEVICE_NAME); if (ret < 0) return ret; cdev_init(&led_cdev, &led_fops); led_cdev.owner = THIS_MODULE; ret = cdev_add(&led_cdev, led_devno, 1); if (ret < 0) goto out_unregister; led_class = class_create(THIS_MODULE, LED_CLASS_NAME); if (IS_ERR(led_class)) { ret = PTR_ERR(led_class); goto out_cdev_del; } device_create(led_class, NULL, led_devno, NULL, LED_DEVICE_NAME); return 0; out_cdev_del: cdev_del(&led_cdev); out_unregister: unregister_chrdev_region(led_devno, 1); return ret; } module_init(led_init);

这里最容易被忽略的是device_create这一行。很多教程只做cdev_add,然后告诉你手动执行mknod创建设备节点。现代Linux系统里udev会监听内核发出的uevent,自动在/dev下创建设备文件。而device_create就是触发uevent的关键。有了它,驱动一加载,/dev/gpio_led就会自动出现,省去手动mknod的麻烦。

3.2 file_operations:找对入口函数

字符设备驱动对外暴露的接口就是struct file_operations。它就像一张表格,回答内核“应用层open的时候该调谁,write的时候该调谁”。做LED点灯,核心只需要实现write和read:

static ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { if (copy_to_user(buf, &led_state, 1) != 0) return -EFAULT; return 1; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[4]; if (count > sizeof(kbuf)) count = sizeof(kbuf); if (copy_from_user(kbuf, buf, count)) return -EFAULT; if (kbuf[0] == '1') led_state = '1'; else if (kbuf[0] == '0') led_state = '0'; return count; } static struct file_operations led_fops = { .owner = THIS_MODULE, .read = led_read, .write = led_write, };

注意copy_from_usercopy_to_user这两个函数。内核空间不能直接访问用户空间的指针,因为用户空间的地址可能还没有映射到内核页表。这两个函数会做地址合法性检查和数据拷贝,返回非0表示拷贝失败,驱动应该返回-EFAULT。内核开发里不允许出现“先用了再说”的鲁莽做法。

3.3 入口出口要成对

模块出口函数负责释放入口函数申请的资源。顺序正好相反:先销毁设备,再销毁类,然后删除cdev,最后释放设备号:

static void __exit led_exit(void) { device_destroy(led_class, led_devno); class_destroy(led_class); cdev_del(&led_cdev); unregister_chrdev_region(led_devno, 1); } module_exit(led_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple gpio led driver");

这个版本不碰任何硬件,但你已经构建出一个“设备驱动”的壳。接下来要做的,是告诉内核这个驱动到底要控制哪颗引脚。

4. 设备树:把硬件描述从代码里抽离出去

4.1 为什么需要设备树

以前的驱动会把硬件地址、中断号、引脚号写在C代码里,换一块板子就要改源码重新编译。设备树改变了这种方式:它用一套树状数据结构描述“板子上有什么、接在哪”,驱动通过节点名称和属性去匹配、解析。换板子时通常只改设备树文件,不用动驱动代码。

正因如此,我们的LED驱动不应该在C文件里硬编码“GPIO1_IO03”。而是让设备树告诉驱动:这颗LED挂在GPIO1组的第3号引脚,低电平点亮。驱动里去读这个属性即可。

4.2 编写LED设备树节点

在根节点/下添加一个自定义节点:

/ { gpio-led { compatible = "myled,gpio-led"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_myled>; led-gpios = <&gpio1 3 GPIO_ACTIVE_LOW>; status = "okay"; }; };

同时要配置引脚复用。i.MX6ULL里GPIO1_IO03默认可能是普通GPIO,也可能被复用成其他功能,必须把引脚mux成GPIO模式:

&iomuxc { pinctrl_myled: myledgrp { fsl,pins = < MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x17059 >; }; };

0x17059是引脚配置寄存器值,包括上拉、驱动能力、压摆率等。别的平台写法不同,但思路一样:先让引脚变成GPIO,再给GPIO一个“名字”。

GPIO_ACTIVE_LOW这个宏来自include/dt-bindings/gpio/gpio.h,值是1。它表达了一个语义:GPIO逻辑1对应硬件“有效”,而这个LED是低电平有效,所以设备树里标记为GPIO_ACTIVE_LOW。驱动使用GPIO descriptor API读取这个flag后,对上层代码完全透明。你调用gpiod_set_value(desc, 1)时,内核底层自动输出低电平。

4.3 驱动如何拿到设备树里的GPIO信息

设备树写好后,驱动侧通过devm_gpiod_get函数获取GPIO desc:

struct gpio_desc *gpio; gpio = devm_gpiod_get(dev, "led", GPIOD_OUT_LOW); if (IS_ERR(gpio)) return PTR_ERR(gpio);

"led"对应节点里的led-gpios属性。内核解析时去掉-gpios后缀,前面的就是属性名。GPIOD_OUT_LOW表示获取GPIO后初始状态设置为低电平。由于我们标记了GPIO_ACTIVE_LOW,这里的“低”会再次被反转,实际引脚输出高电平,LED熄灭。这个细节非常烧脑,但理解了就一劳永逸:GPIO子系统帮你屏蔽了“物理电平”和“逻辑状态”的区别。

5. 内核里操作GPIO的两套API:sh还是新秀

5.1 旧式API:gpio_request和gpio_set_value

传统写法是先通过of_get_named_gpio从设备树拿到一个整数编号,再调用:

int gpio; int ret; gpio = of_get_named_gpio(node, "led-gpios", 0); ret = gpio_request(gpio, "led"); if (ret < 0) return ret; gpio_direction_output(gpio, 1); gpio_set_value(gpio, 0);

这套API在早期内核里非常流行,代码直观。但问题也很明显:of_get_named_gpio拿到的整数是“全局GPIO编号”,它依赖GPIO控制器注册顺序,一旦控制器注册顺序变化或设备树里有多个控制器,编号就可能改变。而且它没有自动处理GPIO_ACTIVE_LOW标志,驱动还要自己判断active才输出1还是0,容易出现“逻辑反了”的bug。

5.2 新式API:GPIO descriptor全家桶

新式API用struct gpio_desc *代替整数编号,把active-low标志、方向、输出值的语义全部封装进去。配合devm_前缀,生命周期也不用自己管。

获取GPIO并默认输出低电平:

devm_gpiod_get(dev, "led", GPIOD_OUT_LOW);

设置输出:

gpiod_set_value(desc, 1); /* 逻辑点亮,物理电平自动翻转 */

释放由devm机制处理。驱动卸载时,内核自动调用gpiod_put,不需要你操心。这套做法在Linux 4.8之后成为新驱动的主流写法。我建议新手直接从新式API入手,理由很简单:省心,且不容易写错。

5.3 为什么不要裸写寄存器

可能有人会问:既然最终都是操作寄存器,我直接ioremap物理地址,对着数据手册改GPIO数据寄存器不就行了?可以,但那是“裸机思维”,不是在写“Linux设备驱动”。

直接操作寄存器有四个风险:

  • 内核不知道你占用了哪个引脚,其他驱动也可能操作同一引脚,互相打架;
  • 引脚复用配置分散在pinctrl子系统和GPIO子系统里,绕开它们就会破坏一致性;
  • 低有效、上拉、中断等硬件语义都要自己处理,代码可移植性极差;
  • GPIO子系统附带完整的调试接口,比如/sys/kernel/debug/gpio,裸写寄存器就失去这些排查工具。

GPIO子系统的设计目标就是给各类GPIO控制器提供一个统一抽象,驱动作者只需要面向API编程。

6. 把驱动升级成platform驱动:让probe在合适的时机被调用

6.1 为什么字符设备驱动还不够

前面写的字符设备驱动能加载、能注册设备节点,但它有个致命问题:加载时没有与设备树建立关联。你也许在设备树里写了节点,驱动却视而不见。而真实场景中,内核需要知道“这个驱动对应哪个硬件节点”,等硬件设备就绪后再调用驱动的probe函数。这就是platform设备驱动模型。

平台驱动把工作放进probe和remove函数里。设备树解析到compatible = "myled,gpio-led"节点时,会创建平台设备;驱动注册时,内核通过of_match_table比对compatible字符串,匹配成功后调用probe。驱动核心初始化都在probe里完成,挂在设备树下,自然就明白了硬件连接信息。

6.2 完整代码:LED驱动的最终形态

把字符设备、GPIO、设备树串起来,完整代码如下:

#include <linux/module.h> #include <linux/kernel.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> #define LED_DEVICE_NAME "gpio_led" struct gpio_led_dev { struct gpio_desc *desc; struct cdev cdev; struct class *class; struct device *device; dev_t devno; }; static struct gpio_led_dev *g_led_dev; static const struct file_operations led_fops; static int led_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char val; if (!g_led_dev) return -ENODEV; val = gpiod_get_value(g_led_dev->desc) ? '1' : '0'; if (copy_to_user(buf, &val, 1)) return -EFAULT; return 1; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[4]; if (!g_led_dev) return -ENODEV; if (count > sizeof(kbuf)) count = sizeof(kbuf); if (copy_from_user(kbuf, buf, count)) return -EFAULT; if (kbuf[0] == '1') gpiod_set_value(g_led_dev->desc, 1); else if (kbuf[0] == '0') gpiod_set_value(g_led_dev->desc, 0); return count; } static int led_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; int ret; g_led_dev = devm_kzalloc(dev, sizeof(*g_led_dev), GFP_KERNEL); if (!g_led_dev) return -ENOMEM; g_led_dev->desc = devm_gpiod_get(dev, "led", GPIOD_OUT_LOW); if (IS_ERR(g_led_dev->desc)) { dev_err(dev, "failed to get led gpio\n"); return PTR_ERR(g_led_dev->desc); } ret = alloc_chrdev_region(&g_led_dev->devno, 0, 1, LED_DEVICE_NAME); if (ret < 0) return ret; cdev_init(&g_led_dev->cdev, &led_fops); g_led_dev->cdev.owner = THIS_MODULE; ret = cdev_add(&g_led_dev->cdev, g_led_dev->devno, 1); if (ret < 0) goto out_unregister; g_led_dev->class = class_create(THIS_MODULE, "gpio_led"); if (IS_ERR(g_led_dev->class)) { ret = PTR_ERR(g_led_dev->class); goto out_cdev_del; } g_led_dev->device = device_create(g_led_dev->class, dev, g_led_dev->devno, NULL, LED_DEVICE_NAME); if (IS_ERR(g_led_dev->device)) { ret = PTR_ERR(g_led_dev->device); goto out_class_destroy; } dev_info(dev, "gpio led driver probed\n"); return 0; out_class_destroy: class_destroy(g_led_dev->class); out_cdev_del: cdev_del(&g_led_dev->cdev); out_unregister: unregister_chrdev_region(g_led_dev->devno, 1); return ret; } static int led_remove(struct platform_device *pdev) { if (g_led_dev) { device_destroy(g_led_dev->class, g_led_dev->devno); class_destroy(g_led_dev->class); cdev_del(&g_led_dev->cdev); unregister_chrdev_region(g_led_dev->devno, 1); } return 0; } static const struct of_device_id led_of_match[] = { { .compatible = "myled,gpio-led" }, { } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_platform_driver = { .probe = led_probe, .remove = led_remove, .driver = { .name = "gpio_led", .of_match_table = led_of_match, }, }; module_platform_driver(led_platform_driver); static const struct file_operations led_fops = { .owner = THIS_MODULE, .open = led_open, .read = led_read, .write = led_write, }; MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("platform gpio led driver");

有一点需要说明:不同内核版本里class_create的参数数量有变化。Linux 5.x及以前常见的是class_create(THIS_MODULE, "name"),到6.x以后简化成class_create("name")platform_driver的remove回调在6.1版本以后返回值也从int改成了void。如果你用的是新内核,照着这个差异微调即可,逻辑不变。

module_platform_driver这个宏很关键。它展开后注册了driver又注册了module init/exit,比手动调用platform_driver_register省事,也避免遗漏。

7. Makefile与模块装载:从源码到板子上的最后一步

7.1 这段Makefile新手必须背下来

驱动模块的编译不同于普通C程序,必须借助内核源码树里的Kbuild体系。Makefile核心内容就这几行:

obj-m := gpio_led.o KDIR := /home/user/linux-imx CROSS_COMPILE := arm-linux-gnueabihf- all: $(MAKE) -C $(KDIR) ARCH=arm M=$(PWD) CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KDIR) ARCH=arm M=$(PWD) clean

obj-m告诉Kbuild把gpio_led.c编译成内核模块。KDIR指向已配置好的内核源码目录。M=$(PWD)表示要编译“外部模块”,源码在Makefile所在目录。ARCH=armCROSS_COMPILE用于交叉编译。如果忘了指定ARCH和CROSS_COMPILE,编译出来的模块是你的x86宿主机架构,放到ARM板子上根本加载不了。

这里我踩过一个低级的坑:Makefile里必须用Tab键缩进命令,不能用四个空格。如果报错“missing separator”,多半就是缩进有问题。

7.2 拷贝与装载:insmod只是其中一环

编译成功后目录下会生成gpio_led.ko。把它拷贝到开发板上,方式随意:NFS挂载、scp、U盘都行。我常用scp:

scp gpio_led.ko root@192.168.1.100:/root/

上板后先加载:

insmod gpio_led.ko

如果一切正常,dmesg | tail应该能看到:

gpio led driver probed

同时/dev/gpio_led节点会创建,默认属主是root。用普通用户操作前,可能需要chmod 666 /dev/gpio_led,或者配置一条udev规则。开发阶段图省事,我一般直接root测试。

卸载模块用rmmod gpio_led。注意rmmod只能卸载“没有被占用”的模块。如果有应用层程序正打开着设备文件,卸载会失败,提示Device or resource busy。这是内核保护机制,很合理。

7.3 为什么insmod加载后没有probe

新手遇到最多的问题是模块加载成功,但dmesg里没有probe打印。这时候第一反应不要怀疑设备树没生效,先检查compatible:

grep -n "myled,gpio-led" /sys/firmware/devicetree/base/gpio-led/compatible

如果输出为空,说明设备树没编进去或者节点路径不对。第二件要检查的是当前系统里有没有另一个驱动的compatible恰好也是myled,gpio-led,导致平台设备被别的驱动先绑定了。可以用ls /sys/bus/platform/drivers/gpio_led/查看驱动绑定情况。这两个点最基础,也最容易被忽略。

8. 用户态测试:赶紧让LED动起来

驱动装好,设备节点出现,就该准备测试。最简单的测试方式直接在命令行敲:

echo 1 > /dev/gpio_led echo 0 > /dev/gpio_led

如果LED没反应,别急,先看内核日志。如果驱动write里copy_from_user正常,这行命令应该能控制LED亮灭。但为了把read和write两个接口都测透,我更推荐写一个几十行的小程序:

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #define DEV_PATH "/dev/gpio_led" int main(int argc, char *argv[]) { int fd; char buf[1]; if (argc != 2) { fprintf(stderr, "usage: %s [0|1]\n", argv[0]); return 1; } fd = open(DEV_PATH, O_RDWR); if (fd < 0) { perror("open"); return 1; } buf[0] = argv[1][0]; if (write(fd, buf, 1) != 1) { perror("write"); close(fd); return 1; } lseek(fd, 0, SEEK_SET); if (read(fd, buf, 1) == 1) printf("led state: %c\n", buf[0]); close(fd); return 0; }

这个小程序验证了三件事:设备文件能不能正常open,数据能不能从用户态传到内核态,内核态还能不能把状态读回用户态。如果你熟悉Linux文件编程,会发现这跟操作普通文件没什么两样。这正是字符设备驱动的精髓所在:对外暴露文件接口,内部各自精彩。

9. 调试三板斧:dmesg、debugfs、查看GPIO状态

驱动写完不等于不出问题。我总结了三套最常用的调试手段,按顺序用基本能覆盖绝大多数情况。

9.1 dmesg永远第一个出场

驱动里的dev_errdev_infoprintk都会输出到内核日志。我在probe和write路径里加了打印信息,方便观察调用顺序。调试时可以直接:

dmesg | tail -20

如果嫌日志太多,按模块过滤:

dmesg | grep "gpio_led"

一个建议是:开发阶段打印要“有选择地加”。打印太多会刷屏,尤其注意不要在中断上下文或者高频调用路径里加printk,否则严重影响性能。点灯这种低频操作就没问题。

9.2 debugfs里看GPIO全貌

内核GPIO子系统提供了debugfs接口,位置在/sys/kernel/debug/gpio。如果挂载了debugfs,直接查看:

cat /sys/kernel/debug/gpio

输出里能看到每个GPIO控制器的使用情况,哪些引脚被占用了,方向是什么,当前值是几。例如我们使用的GPIO1_IO03,会出现在GPIO控制器下面的记录里,标记为gpio-3,方向和value也能直接看到。这比盲目猜寄存器状态靠谱得多。

9.3 用devmem验证物理层

如果GPIO子系统显示正常,但LED还是不亮,那问题可能出在硬件层面:引脚复用错了、上拉配置不对、或者LED本身就坏了。这时候可以用devmem直接读寄存器,比如i.MX6ULL上GPIO1的数据寄存器地址,读出来看对应bit有没有变化。这属于最后手段,因为绕过驱动直接看寄存器,能确认到底是“内核软件问题”还是“硬件连接问题”。正常开发流程里,用到这步说明问题已经非常底层了。

10. 从一颗LED到完整驱动的进阶路线

写完这个驱动之后,你手里已经握着一份“麻雀虽小,五脏俱全”的代码。它包含了设备模型、设备树、字符设备、GPIO子系统、file_operations、模块编译全套知识点。接下来想继续深入,我建议按下面顺序走,每一步都在原基础上加新东西:

  • 加一个按钮按键,用GPIO中断上报按键事件,理解中断下半部和阻塞IO;
  • 把read接口改成poll机制,应用层用select等待设备事件,理解内核的异步通知机制;
  • ioctl扩展控制命令,比如设置闪烁频率,理解应用层与驱动之间的命令协议;
  • 改用内核的led-class框架,把设备注册到/sys/class/leds,让标准工具也能操作,理解内核“类”抽象的作用;
  • 尝试把同一份驱动跑在不同板子上,只需要改设备树里的gpio引脚描述,理解设备树带来的可移植性。

我个人在实际写驱动时的习惯是:每写一个功能都先确认“这个函数能不能在5.x和6.x内核里通用”,一旦发现API有变化就顺手记录下来。内核接口变动很频繁,能拿出手的经验往往不在于背住某个函数,而在于知道去哪里查、怎么快速适配新版本。

开发Linux驱动,本质上是在一个约束很多的环境里干活:要考虑并发访问、要考虑资源释放顺序、要考虑内核态和用户态的安全边界。一颗LED把这些约束全部暴露了一遍,却又不至于让人失去信心。花一个下午把这篇文章里的代码敲一遍,再亲手把设备树、Makefile、测试程序从头到尾完整跑通,你对Linux设备驱动的理解,会远超屏幕上那行“Hello world”。

希望这份实操记录能帮你在Linux驱动开发里顺利点亮第一盏灯。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 1:39:37

TeXLive2020安装避坑:报错、镜像源与宏包配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 1:36:38

Unity适配鸿蒙:重构级NDK桥接实战指南

1. 项目概述&#xff1a;Unity构建鸿蒙环境不是“移植”&#xff0c;而是重构级适配 Unity构建鸿蒙环境和直接发布鸿蒙应用——这句话乍看像一句技术宣传语&#xff0c;实则藏着一个被大量开发者误读的底层事实&#xff1a; Unity官方至今&#xff08;2024年中&#xff09;并…

作者头像 李华