Linux设备驱动开发入门:从字符设备到设备树与I2C实战
2026/9/13 20:45:21 网站建设 项目流程

做Linux驱动开发这几年,被问得最多的一个问题不是“驱动怎么写”,而是“驱动到底是什么东西”。对刚入行的朋友来说,Linux设备驱动开发就像一座大山:字符设备、设备树、platform总线、I2C、中断、并发控制……每一个词拿出来都能写一本教材。但真正上手之后你会发现,绝大多数驱动开发工作并没有传说中那么玄乎,它更像是一门“给硬件写使用说明书”的手艺——内核是平台,总线是通道,设备树是契约,你的任务就是让这三者对齐。

这篇文章不是教材的浓缩版,而是我实际调板子、改驱动、被内核坑到凌晨之后攒下来的一套完整套路。我会从一个最简单的字符设备驱动讲起,然后引入设备树,再一路走到I2C和platform驱动,最后把调试手段和踩坑记录一并交底。如果你想入门Linux驱动开发,或者已经在写驱动但总感觉哪里没捋顺,这篇应该能帮你把整个链条串起来。

1. 先建立宏观认知:驱动、内核、设备和用户空间是怎么协作的

1.1 驱动到底做了一件什么事

很多新手学驱动,一上来就盯着file_operations里的readwrite函数看,结果越看越糊涂。我建议你换一个角度:把内核想象成一家酒店,用户空间的应用程序是房客,硬件设备是酒店里各种房间,而驱动就是客房服务手册。

应用程序想用某个硬件,它会通过open("/dev/xxx", ...)向内核发起请求。内核找到对应的驱动,调用驱动注册好的函数。驱动拿到请求后,去操作硬件寄存器,把结果拿回来,再通过copy_to_user传给用户空间。整个过程看起来像用户在直接操作硬件,实际上用户连设备文件长什么样都不知道,它只认识那个路径。

所以驱动开发的核心工作就两件事:一是把硬件能力“翻译”成用户空间可以调用的接口,二是让内核在合适的时机调用你的代码。第一件事靠file_operations,第二件事靠各种注册机制,比如字符设备注册、platform驱动注册、i2c_driver注册。

1.2 用户态与内核态的数据通路

我见过不少朋友写驱动时在read函数里直接访问用户传入的指针,然后系统崩溃。原因很简单:内核态不能直接读写用户态内存,必须用copy_from_usercopy_to_user。这两个函数会做地址合法性检查,也会处理缺页异常,是驱动和用户空间打交道的唯一正规通道。

一个典型的数据通路是这样的:

应用程序调用 read(fd, buf, count) → 系统调用进入内核 → VFS找到inode对应的file_operations → 驱动read函数被调用 → 读取硬件寄存器(ioremap后的地址) → copy_to_user(buf, kbuf, count) → 返回实际读取的字节数

凡是设备文件路径下能操作的东西,最后都会落到file_operations的实现上。这也是为什么我建议你先吃透字符设备框架,因为它把整个驱动机理用最少的代码量展示得清清楚楚。

1.3 驱动开发的两种形态:模块化与内核内置

驱动可以编成.ko模块动态加载,也可以直接编进内核镜像。开发阶段强烈推荐模块形式,修改代码后只需要重新编译模块、insmod加载、rmmod卸载,不用反复烧写整个内核。我自己的开发节奏是:先在PC上把逻辑调通,再交叉编译到板子上验证硬件相关的部分。

模块开发的Makefile也是新手最容易卡住的地方,先给一个模板:

obj-m := demo_dev.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

如果是嵌入式平台,KDIR要指向你的内核源码目录或已配置好的内核构建目录,同时需要设置交叉编译器前缀,比如ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-。很多新手编出来的模块在板子上加载报“version magic”不匹配,十有八九就是KDIR指错了地方,或者内核配置不一致。

2. 字符设备驱动的完整骨架:从零实现一个可用的demo

2.1 设备号分配与cdev的注册流程

字符设备驱动的地基是设备号。设备号分为主设备号和次设备号,主设备号对应驱动类型,次设备号对应同类型下的不同设备实例。分配设备号有两种方式:

  • 手动指定:register_chrdev_region,适合有固定设备号的场景。
  • 动态分配:alloc_chrdev_region,让内核自动分配主设备号,开发阶段强烈推荐。

我见过很多教程用register_chrdev老接口一条龙搞定注册,但那个接口在现代内核里已经逐步被cdev接口取代。新内核推荐的写法是“三步走”:

dev_t devno; int major; // 第一步:动态分配设备号 alloc_chrdev_region(&devno, 0, 1, "demo_dev"); major = MAJOR(devno); // 第二步:初始化并添加cdev cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; cdev_add(&demo_cdev, devno, 1); // 第三步:创建设备类与设备节点(配合udev/mdev自动生成/dev/下的文件) demo_class = class_create(THIS_MODULE, "demo_class"); device_create(demo_class, NULL, devno, NULL, "demo_dev");

第三步里的class_createdevice_create是让设备节点自动出现在/dev下的关键。如果没有这两步,你就得手动执行mknod /dev/demo_dev c major minor,太原始了。现代系统里的udev会监听内核发出的uevent,自动在/dev下创建设备节点,而device_create就是触发这个过程的源头。

2.2 file_operations的关键成员与实现要点

file_operations结构体是整个字符设备驱动的灵魂,它定义了你这个设备“能干什么”。我平时最常用的是这几个成员:

成员作用实现要点
.open打开设备,端口初始化或权限检查可以在这里递增模块引用计数,检查O_RDWR等标志
.read从设备读数据给用户注意count参数,一次读不完要自己处理“剩余数据”
.write从用户拿数据写入设备使用copy_from_user前先判断用户指针是否合法
.release关闭设备,释放资源open对应,引用计数递减
.unlocked_ioctl控制命令收发,非阻塞控制参数新内核里用这个,ioctl已被废弃
.llseek改变文件读写位置不实现的话默认不支持lseek操作

下面我给一个精简但功能完整的read实现,注意看它怎么处理“读不完”的情况:

static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct demo_dev *dev = filp->private_data; size_t available = strlen(dev->buffer); size_t to_read = min(count, available - *ppos); int ret; if (*ppos >= available) return 0; // EOF if (to_read == 0) return 0; ret = copy_to_user(buf, dev->buffer + *ppos, to_read); if (ret != 0) return -EFAULT; *ppos += to_read; return to_read; }

这套逻辑的核心就是*ppos偏移量管理。每次用户调用read,系统都会把上一次的位置带进来。你读完了返回0,应用层就知道到文件尾了。很多新手第一次写的时候直接忽略ppos,结果每次读都从头开始,程序陷入死循环。

2.3 open/release中的经典操作:private_data与并发控制

open是驱动和用户空间的握手环节,也是状态初始化的地方。我最常用的一个模式是把驱动内部维护的结构体指针挂到filp->private_data上,这样后续的readwriterelease都能通过filp->private_data访问到它,省去一堆全局变量:

static int demo_open(struct inode *inode, struct file *filp) { struct demo_dev *dev = container_of(inode->i_cdev, struct demo_dev, cdev); filp->private_data = dev; return 0; }

container_of是内核里非常高频的宏,作用是从结构体成员反推出整个结构体的起始地址。用熟了之后你会发现,整套内核驱动代码里到处是它的身影——bus、dri、device之间的互相转换全靠它。

如果你在驱动里管理了中断、定时器、DMA缓冲区等共享资源,一定要在open里处理并发问题。最简单的手段是用atomic_t或者mutex。举个实际场景:两个进程同时打开同一个设备文件,各自的filp不同,但访问的硬件寄存器是同一个。如果不加锁,后一个进程的操作就可能把前一个进程的状态覆盖掉。我一般习惯在write里用mutex_lock保护寄存器操作序列,宁可性能稍微降一点,也要保证数据一致性。

3. 设备树:驱动与硬件之间最重要的契约

3.1 设备树解决了什么问题

在设备树出现之前,Linux内核里充斥着大量的板级代码,每换一块开发板就要改一遍arch/arm/mach-xxx下的源码。设备树出现之后,硬件描述和驱动代码彻底分离:板子上有哪些设备、地址是多少、中断号是多少,都写在一个.dts文件里,内核通过设备树在启动时把硬件信息自动匹配给对应驱动。

设备树对驱动的意义用一句话概括就是:驱动不关心板卡具体长什么样,只关心能不能匹配到自己关心的设备节点。驱动通过compatible属性说“我能驱动什么设备”,设备树节点通过compatible说“我就是那个设备”。两者一致,probe才会被执行。

3.2 一个标准设备树节点的写法

以一颗挂在某个内存地址上的自定义设备为例:

/ { soc { demo: demo@30000000 { compatible = "vendor,demo-device"; reg = <0x30000000 0x1000>; interrupts = <GIC_SPI 55 IRQ_TYPE_LEVEL_HIGH>; demo,param = <42>; demo,enable; clocks = <&clkc 15>; resets = <&rstc 3>; }; }; };

拆开看这些属性:

  • compatible:匹配驱动用,格式是“厂商,型号”。注意顺序不能乱,内核会从最后一个向第一个优先匹配。
  • reg:设备在总线上的地址范围,第一项是起始地址,第二项是长度。
  • interrupts:中断号与中断触发方式,具体写法依赖中断控制器的cell大小。
  • demo,param = <42>:自定义整型属性,厂商前缀可以避免和内核标准属性冲突。
  • demo,enable:无值布尔属性,表示“这个功能开着”。
  • clocksresets:时钟和复位控制,现代SoC集成驱动基本绕不开。

在驱动里读取这些属性用device_property_read_u32device_property_read_bool这些API。它们不收任何总线类型的限制,platform、I2C、SPI设备都能用。

3.3 probe函数里如何拿设备树资源

设备树解析和probe是强绑定的。一个典型的platform驱动probe函数长这样:

static int demo_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int irq, param, ret; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base); irq = platform_get_irq(pdev, 0); if (irq < 0) return irq; device_property_read_u32(&pdev->dev, "demo,param", &param); dev_info(&pdev->dev, "base=%px irq=%d param=%d\n", base, irq, param); return 0; }

注意这段代码里的资源管理方式:devm_ioremap_resource带了一个devm前缀。这意味着这块映射好的I/O内存不需要你手动iounmap,驱动卸载时内核会自动帮你释放。现代内核里几乎所有资源获取接口都有对应的devm_版本,优先使用它们能省掉一整套错误处理和清理路径。

3.4 设备树匹配的几种方式与优先级

驱动物理上挂在I2C总线上时,设备树匹配和platform匹配有些差异。I2C节点的compatible会匹配i2c_driver里的of_match_table;如果设备树节点里没有compatible而是只用reg地址,则需要匹配i2c_device_id。我把这两种方式做了一张对比表:

匹配方式关键属性/字段什么时候用
DT compatible设备树节点的compatible属性现在大多数嵌入式平台的首选
i2c_device_id驱动的id_table老代码、非设备树平台、同一芯片挂多个地址的场景
ACPI HID_HID对象x86平台居多
platform.nameplatform_device的name字段非设备树的旧式平台

这里有个很隐蔽的坑:如果不配置MODULE_DEVICE_TABLE(of, demo_of_match),设备树热拔插(或者modprobe时自动匹配)就会失灵。这个宏的作用是生成驱动与设备匹配的“目录”,供内核的模块加载系统查表。没有它,就算compatible完全一致,你可能还是要手动insmod,绑定的过程也会变得不干净。

4. 从字符设备进化到真实硬件驱动:platform驱动与I2C驱动

4.1 platform总线的本质:虚拟总线解决“孤儿设备”

很多入门者会被platform_driver_register搞懵——总线上明明没有叫“platform”的物理硬件,这到底是个什么总线?

platform总线是一种虚拟总线,专门用来收纳那些不挂在USB、PCI、I2C、SPI等真实总线上的设备。比如SoC内部集成的UART控制器、GPIO控制器、DMA控制器,它们直接挂在CPU总线上,没有标准的枚举机制,这时候就能用platform_device描述。

在内核里,platform驱动和设备通过match函数配对。如果用设备树,match的核心就是比对compatible属性;如果不用设备树,match比对的是platform_device.nameplatform_driver.driver.name。我建议你无论如何都走设备树方式,它能直接利用设备树的自动解析能力,代码量会少非常多。

4.2 I2C设备驱动的注册流程与probe实现

I2C设备驱动是嵌入式开发里最常见的驱动类型之一,一颗温度传感器、一片触摸屏控制芯片、一个EEPROM,基本都是I2C接口。I2C驱动和platform驱动结构上很像,核心是把i2c_driver注册到I2C总线上。

static const struct of_device_id demo_sensor_of_match[] = { { .compatible = "vendor,demo-sensor" }, { } }; MODULE_DEVICE_TABLE(of, demo_sensor_of_match); static const struct i2c_device_id demo_sensor_id[] = { { "demo,sensor", 0 }, { } }; MODULE_DEVICE_TABLE(i2c, demo_sensor_id); static struct i2c_driver demo_sensor_driver = { .driver = { .name = "demo_sensor", .of_match_table = demo_sensor_of_match, }, .probe = demo_sensor_probe, .remove = demo_sensor_remove, .id_table = demo_sensor_id, }; module_i2c_driver(demo_sensor_driver);

module_i2c_driver是一个便捷宏,展开后就是正常的module_initmodule_exit逻辑,帮你自动完成i2c_add_driveri2c_del_driver

在probe里,拿到i2c_client之后,你就可以通过smbus协议或i2c_transfer跟设备通信了。比如读取一个寄存器:

static int demo_sensor_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct demo_sensor *sensor; int ret; sensor = devm_kzalloc(&client->dev, sizeof(*sensor), GFP_KERNEL); if (!sensor) return -ENOMEM; sensor->client = client; // 读取0x0F寄存器,通常存的是芯片ID或版本号 ret = i2c_smbus_read_byte_data(client, 0x0F); if (ret < 0) { dev_err(&client->dev, "failed to read chip id: %d\n", ret); return ret; } dev_info(&client->dev, "chip id = 0x%02x\n", ret); return 0; }

i2c_smbus_read_byte_data是SMBus协议里的单字节读,适合大多数寄存器访问。如果遇到一次要读很多字节的场景,用i2c_master_recv或者i2c_transfer构造完整消息。

4.3 中断处理与下半部:从request_irq到threaded irq

真实硬件驱动里,中断是绕不开的。我最常用的注册接口是devm_request_threaded_irq,它能直接把中断处理做成线程化,省去在顶半部里做一堆麻烦的事情。

static irqreturn_t demo_irq_handler(int irq, void *data) { struct demo_sensor *sensor = data; // 这里只做快速处理:读取状态寄存器,触发延迟工作 schedule_work(&sensor->work); return IRQ_HANDLED; } static irqreturn_t demo_irq_thread(int irq, void *data) { struct demo_sensor *sensor = data; // 在线程上下文里做耗时操作:I2C读取、数据处理 int val = i2c_smbus_read_byte_data(sensor->client, 0x10); ... return IRQ_HANDLED; } // probe中注册 ret = devm_request_threaded_irq(&client->dev, irq, demo_irq_handler, // 顶半部,可为NULL demo_irq_thread, // 线程化底半部 IRQF_TRIGGER_RISING, "demo_sensor", sensor);

中断上下文里不能调用i2c_smbus_read_byte_data,因为I2C传输可能睡眠。顶半部如果只是中断标志处理,就直接返回IRQ_HANDLED;如果要做数据读取,必须放到线程化底半部或者工作队列里。这个是最常见的死锁/睡眠出错源头,新手一踩一个准。

4.4 杂项设备:为什么我建议入门从misc开始

字符设备框架讲起来很完整,但实际项目里大量简单的驱动用杂项设备(miscdevice)会更省事。它本质上是字符设备的一种封装,主设备号统一固定为10,次设备号动态分配。当你只需要一个设备节点、几组文件操作的时候,misc是一把快刀:

static struct miscdevice demo_miscdev = { .minor = MISC_DYNAMIC_MINOR, .name = "demo_dev", .fops = &demo_fops, }; // module_init里: misc_register(&demo_miscdev);

misc_register内部会自动完成设备号分配、cdev注册、设备节点创建,比我前面写的那一大串精简太多。我现在做原型验证和简单外设驱动,基本都是直接上misc。只有设备量大、需要精细控制次设备号的场景,才会退回完整字符设备方案。

5. 调试三板斧:printk之外你还缺什么

5.1 日志级别:为什么你的printk有时候看不见

驱动里最常见的调试手段就是打印日志,但很多新手会困惑:printk打了,dmesg里却根本没有输出。原因多半是日志级别太低,被内核的console_loglevel过滤掉了。

内核里的printk有8个级别,从高到低分别是:

宏定义数值含义
KERN_EMERG0系统崩溃
KERN_ALERT1必须立即处理
KERN_CRIT2严重错误
KERN_ERR3一般错误
KERN_WARNING4警告
KERN_NOTICE5正常但重要
KERN_INFO6信息
KERN_DEBUG7调试信息

默认情况下,终端上只能看到级别小于console_loglevel的消息,而dmesg里能看到全部。开发阶段可以通过echo 8 > /proc/sys/kernel/printk让所有日志都打到串口上。不过我个人的习惯是用dev_info(&pdev->dev, ...)dev_err(...)这一类带设备信息的日志接口,它们会自动加上“设备是谁”这件事,排查多设备问题时效率高很多。

5.2 目录三件套:sysfs、debugfs、/proc/device-tree

比printk更进阶的调试手段是“看内核到底怎么看待你的设备”:

  • /proc/device-tree(新内核为/sys/firmware/devicetree/base):直接查看设备树解析结果,确认设备节点有没有被内核认到。
  • /sys/bus/platform/devices//sys/bus/i2c/devices/:查看总线上真实注册的设备。如果这里看不到你的设备,说明匹配环节出了问题。
  • /sys/kernel/debug/:需要内核配置CONFIG_DEBUG_FS,很多驱动和子系统的调试接口都在这里。

举个例子,你的I2C设备节点写好了但驱动probe不执行,第一件事先看/sys/bus/i2c/devices/下有没有对应的设备目录;如果没有,大概率是设备树节点没生效;如果有目录但没绑定驱动,问题基本就出在compatible匹配上。这套排查链路比盲改代码试错快十倍。

5.3 devmem2:暴力但有效验证硬件通路

调试新硬件时,我最喜欢用的工具是devmem2。它可以在用户态直接读写物理地址,非常适合验证寄存器的值是否符合预期。

# 读取0x30000000地址上的32位值 devmem2 0x30000000 w # 往0x30000004写入0x12345678 devmem2 0x30000004 w 0x12345678

拿到一块新板子,我会先查数据手册,确认要操作的寄存器地址,然后用devmem2直接写寄存器,观察硬件反应。如果devmem2能点亮LED,驱动的寄存器操作部分基本不会有大问题;如果devmem2也点不亮,先查硬件焊接和引脚复用,别急着改驱动。

5.4 设备树常用调试技巧:用status属性开关节点

如果你有多个版本的设备树,或者某个外设一时用不上,最优雅的处理方式是在设备树节点上留个status属性:

&demo { status = "okay"; // 使能 // status = "disabled"; // 禁用 };

内核在启动时如果看到status = "disabled",直接跳过这个节点,驱动probe根本不会被调用。这比注释代码、改动Makefile要安全得多,也方便硬件团队在不改驱动的情况下灵活配置产品形态。

6. 踩坑实录:驱动开发中那些让人血压升高的瞬间

6.1 模块加载“version magic”不匹配

编译好的.ko在板子上加载时报version magic '...' should be '...',这是开发环境最经典的坑。原因是模块编译用的内核版本/配置和运行内核不一致。排查思路:

  1. 确认KDIR是否指向运行内核对应源码。
  2. 在板子上执行uname -r,在编译机上确认源码根目录的include/config/kernel.release
  3. 如果嵌入式平台用了独立的内核源码树,先make modules_prepare一次。
  4. 关闭内核的CONFIG_MODVERSIONS(符号版本校验)也能绕过,但这个不推荐,它会掩盖掉真正的ABI匹配问题。

6.2 probe不执行,问题永远在匹配上

一个驱动加载了,module_init执行了,但probe就是不执行。我的排查顺序固定如下:

  1. dmesg里看驱动有没有注册成功。
  2. /sys/bus/xxx/devices下看设备有没有出现。
  3. 如果不设备树方式,确认节点compatible和驱动of_match_table完全一致,注意逗号、厂商前缀不能错。
  4. 看设备节点的status是不是被设置为disabled
  5. 看设备在总线上是否被其他驱动先占用,/sys/bus/xxx/devices下驱动的driver目录指向谁。

这个清单帮我解决了不下十次“驱动失效”的问题。多数情况下不是代码错了,而是匹配链条上有某个环节断了。

6.3 内存映射之后系统崩溃:ioremap与缓存一致性

在驱动里访问硬件寄存器,必须先通过ioremap把物理地址映射为内核虚拟地址。但如果你用普通的ioremap访问某些DMA缓冲区或需要强一致性的内存区域,可能会遇到数据错乱或系统卡死。这时需要评估是否改用ioremap_wc(write-combining)或者明确缓冲区属性。

我的经验是:寄存器操作老老实实用标准devm_ioremap_resource,不要试图用普通内存方式直接访问硬件地址。硬件寄存器访问要求访问宽度和次序都要符合数据手册,编译器优化可能把写操作乱序或合并,所以要用readl/writel这一类带内存屏障的访问接口。

6.4 并发竞争:两个进程同时open导致的混乱

驱动被多个用户程序同时使用,是非常常见的场景。如果你在open里初始化了硬件状态,在read里又依赖这个状态,两个进程交错访问时,数据就会错乱。最经典的解决手段是原子操作加锁:

static int demo_open(struct inode *inode, struct file *filp) { struct demo_dev *dev = container_of(inode->i_cdev, struct demo_dev, cdev); if (!atomic_dec_and_test(&dev->available)) { atomic_inc(&dev->available); return -EBUSY; } filp->private_data = dev; return 0; } static int demo_release(struct inode *inode, struct file *filp) { struct demo_dev *dev = filp->private_data; atomic_inc(&dev->available); return 0; }

atomic_dec_and_test保证同时只有一个进程能成功打开设备。如果你的设备本身支持多进程并发访问,那就要用mutexspinlock保护共享资源的临界区。记住一个原则:先在驱动里解决并发,再去谈性能优化。不分场景的无锁编程是驱动开发里最危险的傲慢。

6.5 用户态传参导致的内核异常

copy_from_user虽然会检查地址合法性,但你还是应该在write里仔细处理传入数据的长度和内容。比如:

static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { char kbuf[128]; if (count > sizeof(kbuf)) return -EINVAL; if (copy_from_user(kbuf, buf, count)) return -EFAULT; // 继续处理... return count; }

copy_from_user返回值是“未拷贝成功的字节数”,成功时是0。很多新手误以为它返回的是拷贝字节数,导致判断写反,明明成功了却返回-EFAULT

7. 最后再分享几个我一直在用的习惯

驱动开发做到后面,比的不是谁懂得多,而是谁踩过的坑少、谁的排查路径更高效。我自己始终保留这么几个习惯:

写驱动之前先花半小时把数据手册的寄存器列表理清楚,哪些是控制位、哪些是状态位、哪些寄存器需要读-改-写,都用表格列出来再动手。很多bug不是代码问题,是寄存器配错了。

编译模块之前先跑一遍make dtbs确认设备树编译通过,再insmod。设备树语法错误有时候dmesg只报一句含糊的invalid node,排查起来特别费劲。

驱动里每个devm_接口分配的资源,我都坚持不看释放路径。devm_系列就是为了让你省心,如果你发现自己写了一大堆goto err_x清理逻辑,多半是没用对资源管理接口。

模块卸载测试也是重头戏。加载卸载循环十几次,观察/proc/modules里引用计数有没有归零,设备节点有没有残留。有些驱动remove函数里忘了注销中断或删除定时器,卸载后一碰设备就又崩了,这类问题只能在反复卸载加载中暴露。

Linux设备驱动开发这条路,本质上就是在“硬件确定性”和“内核环境的复杂性”之间找到一个稳定交点。把字符设备、设备树、总线模型这三样东西吃透,再去碰具体的硬件类型,基本不会走太多弯路。希望这篇文章能帮你把散落的知识点串成一条完整的链路,少熬几个凌晨。

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

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

立即咨询