Linux驱动开发:从内核模块到设备树与I2C/CAN的完整实践 📅 发布时间:2026/9/13 22:10:52 👁 浏览次数: 我入职第三年的时候公司接了一块 RK3568 板子的项目适配我拿到的第一个任务是让一颗挂在 I2C 总线上的触摸芯片正常工作。当时我的理解很天真写个驱动编译insmod完事。但真正动手才发现驱动代码本身几个小时就写完了真正折腾了我两天的是设备树里一个中断引脚的配置跟另一路 GPIO 的复用冲突。更气人的是那个驱动用 modprobe 加载时明明显示成功系统里却就是找不到设备。这就是 Linux 设备驱动开发最真实的状态搞不清楚内核模块、设备树、I2C/CAN 总线这几层之间的关系你会在各种看似莫名其妙的问题里打转。这篇文章我就把从零跑通这条完整链路的经验整理出来。不管你是在 RK3568、i.MX 还是树莓派上做嵌入式 Linux 开发只要碰到过内核模块加载失败、设备树没生效、I2C 读不到数据、CAN 接口起不来这类问题这篇内容应该对你有用。1. 为什么很多驱动开发者在明明会写C语言的情况下依然被卡住1.1 这条链路里的三个层次各自解决什么问题先把整条链路拆开看。Linux 设备驱动开发这条模块 → 设备树 → I2C/CAN的路径实际上由三个既独立又耦合的层次组成。第一个层次是内核模块。它解决的是代码形态和动态插拔的问题。你不用为了加一个外设驱动就把整个内核重新编译一遍而是可以写一个 .ko 文件在系统运行时加载、卸载。这相当于给内核开了插槽需要时插上去不需要时拔下来。第二个层次是设备树。它解决的是硬件描述与平台适配的问题。同一份内核镜像要能跑在 A 公司的板子上也能跑在 B 公司的板子上靠的就是设备树来描述我这块板子上有哪些外设、接在哪个控制器上、中断是几号、GPIO 怎么用。换句话说设备树不是驱动代码而是硬件资源的说明书。第三个层次是 I2C/CAN 这类总线驱动。它们解决的是 CPU 与外设之间的通信问题。像 I2C 这种总线有它的时序、地址、读写协议CAN 总线则有仲裁、报文、波特率这些概念。当你的外设挂在某种总线上时驱动要做的其实是两件事一是通过设备树知道自己控制的是哪个设备、资源在哪二是按照总线协议把数据正确发出去、收回来。如果你非要打个比方我一般跟新人这么说设备树是产品的图纸驱动是看懂图纸并执行操作的老师傅而 I2C/CAN 总线是老师傅走得那条路——图纸告诉他目的地路决定了他怎么过去。1.2 三个最常见的认知误区我见过不少同事和社区里的开发者在这条链路上踩坑往往不是因为代码能力不行而是因为下面几个误区。误区一把设备树当成普通配置文件改。有人觉得 dts 文件跟 ini 差不多随便加一个节点系统就能识别。实际上设备树有严格的格式、address-cells/size-cells 这样的语法要求而且节点里的每个属性最终必须被某个驱动代码主动去解析才会有意义。设备树里的属性如果对应的驱动不读取它就是死数据。误区二以为 insmod 成功就万事大吉。模块加载成功只能说明内核接受了这段代码并不代表驱动和你的设备成功匹配了。你在 dmesg 里看到 driver registered 和看到 probe called 是两码事后者才是真正开始接触硬件。误区三把 I2C/CAN 读写当成普通的库函数调用。很多从单片机和应用层转过来的开发者会觉得操作 I2C 不就是调用一个读写函数吗。实际上一旦走进设备驱动你会遇到内核态上下文、中断、原子操作这些限制一个在用户态跑得好好的读函数搬到内核态可能就会导致系统卡死。把这三个误区想明白之后整条学习路线就会清晰很多先把模块写好确认能加载再学会写设备树节点让驱动能找到设备最后才是深入总线协议把数据通路彻底打通。2. 内核模块驱动的最小可运行单元与实际加载中的暗坑2.1 一个最小模块的骨架以及每个关键字的作用不管驱动多复杂起点永远是那个最小的模块骨架。#include linux/module.h #include linux/init.h static int __init demo_init(void) { pr_info(demo module loaded\n); return 0; } static void __exit demo_exit(void) { pr_info(demo module unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(A minimal Linux kernel module);解释一下关键点。__init这个宏表示这个函数占用的内存在模块加载完成后会被释放掉内核启动时代的初始化函数也是这个套路节约内存。module_init和module_exit是模块的入口/出口注册点加载模块时执行 demo_init卸载时执行 demo_exit。MODULE_LICENSE(GPL)不只是个声明它直接关系到你能使用多少内核导出的符号很多只允许 GPL 模块调用的函数如果你不声明 GPL 就链接不上。实际项目中这套骨架可以是字符设备、平台驱动、I2C 驱动、网络驱动的基础但入口、出口的注册结构永远是这么个味道。我习惯先用一个只有 pr_info 的空模块验证编译工具链和加载环境确认没问题了再往里面填实际功能这个习惯帮我排掉了不少环境问题。2.2 Makefile 的常见错误与交叉编译细节模块编译不是用 gcc 直接编而是要借助内核源码树里的 Kbuild 系统。obj-m : demo.o KERNELDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean最关键的是 KERNELDIR 这一行。如果你在开发板上做原生编译它指向本机的内核构建目录如果你在 PC 上做交叉编译这里要改成目标平台内核源码树的路径并且需要在环境变量里指定交叉编译工具链比如export ARCHarm64 CROSS_COMPILEaarch64-linux-gnu-。新手在 Makefile 上报错最常见的有三类一是 KERNELDIR 指向的内核源码没有先完成配置没有 .config 和编译生成的头文件编到一半各种 linux/xxx.h: No such file or directory二是架构没指定x86 的工具链去编 ARM 内核模块直接报无法识别的指令三是 PWD 用了相对路径make 切换目录时找不到源文件。这些错误信息本身可能很长但根因基本都是这三个。2.3 加载失败时的典型报错和排查路径模块写好了编译过了但 insmod 的时候可能直接给你甩个错误。我整理了一个高频问题对照表。报错信息实际原因处理方式Unknown symbol模块依赖的内核符号没导出或依赖的模块没先加载用 modinfo 查看依赖按顺序加载依赖模块确认内核配置选项Operation not permitted权限不足或者内核开启了模块签名强制校验确认 root 权限若开了签名需关闭 CONFIG_MODULE_SIG_FORCE 或做签名Invalid module format模块 vermagic 与当前内核不一致用当前内核版本对应的源码重新编译注意 GCC 版本差异Module xxx not foundmodprobe 在模块目录里找不到该模块先执行 depmod -a更新 modules.dep 索引insmod: ERROR: could not insert module: File exists同名模块已经加载先 lsmod 确认或改模块名这里面最折磨人的是 Unknown symbol。我之前调一个 SPI 转 CAN 的驱动时一直报Unknown symbol mcp251x_can_probe查了半天发现是内核里把芯片的主驱动编成了模块而我的板级代码引用它时依赖模块还没加载。用modinfo xxx.ko看depends字段再用modprobe而不是 insmod 去加载能自动处理依赖关系比手动 insmod 省心很多。2.4 驱动里最容易忽略的生命周期问题加载和卸载还牵涉一个新手很容易踩的雷资源生命周期管理。我打过一个比方内核模块就像租房子init 的时候申请了内存、注册了中断、创建了工作队列那么 exit 的时候就必须把每一笔账都还清。很多驱动表面上加载正常一旦rmmod就死机或者卸载再加载就出错基本都是因为 exit 路径做得不干净。举个例子。有个同事写了一个用 workqueue 定期采集数据的驱动采集函数里访问硬件寄存器他没有在 exit 时调用cancel_work_sync。平时跑着没事但一旦卸载模块workqueue 可能刚好在回调执行到一半回调里访问的代码段已经被卸载内核直接 panic。这不是小概率事件而是大概率事件。所以在模块设计之初就应该先列一个资源清单内存有没有释放、中断有没有 free、定时器有没有 del、工作队列有没有 cancel、proc/sysfs 文件有没有 remove。卸载函数不是随便写几行代码而是和 init 一一对应的逆操作。这块做扎实了后面集成到设备树、I2C/CAN 驱动里才不需要反复在内核 panic 里找原因。3. 设备树硬件描述的核心和 RK3568 项目的实战配置3.1 设备树在设计上解决的根本问题设备树这个机制本质上是把硬件长什么样和驱动怎么写这两件事解耦。在设备树普及之前驱动里普遍用 board file 或者平台代码硬编码硬件资源比如某平台驱动里直接写gpio 123换一块板子 GPIO 变了就得改驱动源码。设备树出现后驱动不再关心具体引脚号是多少而是通过gpio gpio3 5 GPIO_ACTIVE_LOW这种描述性属性由设备树把这个设备用哪个引脚告诉驱动。所以看设备树源码应该把它理解成一张资源表驱动的工作是查这张表而不是自己编造硬件信息。这个观念转变过来之后再看那些各种外设节点就不会觉得是一堆莫名其妙的语法了。RK3568 这类 ARM64 平台尤其依赖设备树因为核心频率、DDR 参数、外设控制器都通过设备树描述改错一个status disabled可能整个外设都起不来。3.2 实际项目中必须掌握的设备树节点写法看一个典型的 I2C 外设节点顺便搭上 GPIO 复位控制。i2c3 { status okay; clock-frequency 400000; touch38 { compatible goodix,gt911; reg 0x38; interrupt-parent gpio3; interrupts 5 IRQ_TYPE_LEVEL_LOW; reset-gpios gpio3 20 GPIO_ACTIVE_LOW; reset-delay-ms 20; }; };这里每个属性都有明确作用。compatible是驱动和设备匹配的暗号驱动里 of_match_table 会用它做比对reg是设备在 I2C 总线上的地址interrupt-parent和interrupts指定中断控制器和引脚reset-gpios是复位引脚描述reset-delay-ms这种带单位后缀的属性一般是驱动自定义的私有属性需要在驱动里显式解析。热词里有人搜spidev设备树配置原理完全一样。SPI 总线下加一个 spidev 节点spi1 { status okay; spidev0 { compatible rohm,dh2228fv; reg 0; spi-max-frequency 1000000; }; };这里有个实际教训某些内核版本对 spidev 的兼容字符串有白名单限制写成比较通用的字符串可能直接报 SPI device /dev/spidev 相关错误加载不到。解决方法是优先参考内核文档里推荐使用的方式或者在内核配置里打开对应选项。还有热词里提到的 disp 设备树本质也是显示控制器节点里面包含pinctrl、clock等资源描述只是属性更多更复杂。思路仍然是节点提供资源驱动解析资源。3.3 设备树里设置复位信号时间这类细节怎么处理搜设备树相关热词时我注意到很多人卡在复位信号时间这个问题上——也就是驱动初始化时复位引脚拉低/拉高的持续时间。这个时间到底配在哪很多人一上来就懵。要理解清楚设备树本身不产生任何时序行为它只提供数据。你在 dts 里写reset-delay-ms 20;不会自动让系统在复位时等 20 毫秒必须由驱动里的代码读取这个属性然后在操作 GPIO 时实际执行延时。驱动侧大概是这么用的struct device_node *np client-dev.of_node; u32 reset_delay; of_property_read_u32(np, reset-delay-ms, reset_delay); gpiod_set_value(reset_gpio, 0); msleep(reset_delay ? reset_delay : 1); gpiod_set_value(reset_gpio, 1);实际项目中还有一个细节复位时序通常要求拉低后等一段时间再拉高所以 dts 属性定义好之后驱动里解析属性的代码必须和 dts 里定义的属性名完全一致拼写错一个字符驱动读到的就是一个 undefined 值。内核不会帮你报错这个我踩过两次。除了设备树配延时也可以直接在驱动代码里用usleep_range写死时序但那样就不方便板级适配了。一般情况下凡是跟硬件时序强相关的参数我都会倾向放到设备树里方便硬件改版后只改 dts 不动驱动。3.4 改完设备树没生效的排查顺序设备树改完之后系统没反应这是高频问题。我总结了一套排查顺序从到底加载没加载开始查效率能提升不少。第一步确认内核实际加载的是哪个 dtb 文件。很多开发板 boot 分区里有多个 dtb或者 uboot 根据环境变量选择 dtb你以为改了 dts 并编译出了新的 dtb结果系统加载的还是旧的。用ls /boot或看 uboot 环境变量先确保加载路径正确。第二步反编译 dtb确认你的修改确实进到了二进制里。用dtc -I dtb -O dts -o output.dts 你的.dtb然后 grep 一下你加的节点名或者属性名。这一步能快速排除编译没编进去的问题。第三步在运行时检查设备树节点是否存在。设备树被内核解析后可以在/sys/firmware/devicetree/base路径下看到对应的目录结构节点里的属性会以文件形式存在。如果能在这个目录下找到你加的节点至少说明设备树解析层没有问题。第四步看dmesg。很多驱动匹配失败、资源申请失败都会打印错误信息。dmesg | grep -E i2c|gpio|probe|fail这样的组合搜索能帮你快速定位是哪个环节断掉了。这一套流程走下来大部分改了没生效的问题其实都出在第一步和第二步——要么加载的不是这个 dtb要么编译流程根本没把你的修改生成到目标 dtb 里。4. I2C 从设备驱动的完整链路从设备树节点到 probe 再到数据读写4.1 设备树侧给 I2C 从设备发一张身份证在设备树里I2C 外设不是独立根节点而是挂在某个 I2C 控制器节点下面的子节点。前面那个 touch 节点就是典型写法。有一点很多人会忽略reg里填的 I2C 地址必须是设备实际的 7 位地址不能带读写位。有些芯片 datasheet 上写的是 8 位地址比如 0xD0但设备树里reg实际上要求填 0x68 这样的 7 位地址。如果这里填错了你 probe 时看到的client-addr就是错的读写自然全部失败。另外I2C 节点里的clock-frequency决定控制器主频400KHz 对应 fast mode。有些传感器对时序要求苛刻你一味调高频可能反而导致通信不稳定。设备侧如果觉得 I2C 偶发通信错误第一步先降频率试试比查代码更真实地验证硬件稳定性。4.2 驱动侧i2c_driver 的匹配逻辑与 probe 参数I2C 从设备驱动的核心是i2c_driver结构体配合of_match_table实现和设备树节点的匹配。#include linux/i2c.h #include linux/of.h static const struct of_device_id mydevice_of_match[] { { .compatible mycompany,mydevice }, { } }; MODULE_DEVICE_TABLE(of, mydevice_of_match); static int mydevice_probe(struct i2c_client *client) { u32 reset_delay; dev_info(client-dev, probe addr0x%02x\n, client-addr); of_property_read_u32(client-dev.of_node, reset-delay-ms, reset_delay); return 0; } static void mydevice_remove(struct i2c_client *client) { /* 释放资源 */ } static const struct i2c_device_id mydevice_id_table[] { { mydevice, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, mydevice_id_table); static struct i2c_driver mydevice_driver { .driver { .name mydevice, .of_match_table mydevice_of_match, }, .probe mydevice_probe, .remove mydevice_remove, .id_table mydevice_id_table, }; module_i2c_driver(mydevice_driver);probe函数是整个驱动真正开始工作的入口它拿到的struct i2c_client *client核心就是设备树节点和 I2C 地址。在这个函数里你可以通过client-dev.of_node读取设备树里的各种自定义属性。module_i2c_driver宏会帮你注册和注销比自己手动调i2c_add_driver清爽得多。有个细节需要注意probe 过程会跟设备树匹配多次吗不会一个节点匹配成功后一般只 probe 一次。但如果 probe 失败返回了错误码内核会记录 failed 状态不会一直重试。所以你在改驱动时如果改了 probe 里的逻辑记得重新加载模块而不是等着系统自动重新 probe。4.3 走上总线后的读写细节与时序问题驱动匹配成功后真正和传感器打交道的方式主要有两种i2c_transfer和i2c_smbus_*系列函数。i2c_transfer更底层直接构造i2c_msg数组支持一次传输多个消息比如先写寄存器地址再读数据这种复合操作。i2c_smbus_read_byte_data(client, reg)这类适合简单外设。我个人的选择是简单传感器用 smbus 系列功能复杂、需要打包读多个寄存器的用 i2c_transfer后者在驱动里更灵活也更容易复用整理成寄存器读缓存函数。实际试过很多 I2C 外设之后我的经验是不要假定一次读多个字节一定成功。某些芯片对 I2C 连续读有页边界限制超过页大小会回绕导致数据错乱。读寄存器前要不要先写寄存器地址不同芯片不一样。有些芯片上电时寄存器地址默认 0但状态不定安全起见还是先显式写地址。错误处理不能省。I2C 传输返回负的 errno 时驱动里应该考虑重试但重试前要适当延时让总线状态恢复。还有个大坑I2C 访问在原子上下文中断、自旋锁保护区间里执行不能直接调用可能睡眠的函数。新手在中断里往下调用i2c_smbus_read_byte_data可能导致系统调度异常或者并发访问问题。如果确实要处理中断上报的数据应该先把数据标记在 workqueue 里然后让内核线程去执行 I2C 读写。这也是为什么很多传感器的驱动里都能看到i2c_client和struct work_struct同时出现。5. CAN 控制器的设备树接法与驱动侧的配合5.1 CAN 控制器在驱动模型里的双重身份CAN 和 I2C 的驱动模型有一个非常明显的差异CAN 控制器驱动最终是以网络接口的形式呈现的。你写一个 I2C 触摸驱动最后对应用层暴露的是 input 子系统或者字符设备而 CAN 控制器驱动不管是内部集成还是外部芯片最终都要注册成can0这样的网络设备应用层通过 SocketCAN 访问它。这意味着驱动要遵守 Linux 网络设备驱动框架有struct net_device_ops、有发送/接收回调、有 NAPI 或中断收包。很多人一开始会把 CAN 驱动当成普通字符设备驱动来写写到一半发现问题了——收发报文的语义其实应该落到网络子系统里。方向错了后面再改就很痛苦。所以在看 CAN 相关设备树节点时要带着这是一个网络设备控制器的视角去理解中断负责收包时钟负责波特率基准而最终它需要注册成网络接口。5.2 mcp251x 外挂控制器的真实配置经验CAN 控制器里很有代表性的一类是 SPI 转 CAN 芯片mcp251x 系列是绝对的劳模。它通过 SPI 接口和主控通信设备树节点就挂在 SPI 总线下。mcp2515_osc: mcp2515_osc { compatible fixed-clock; #clock-cells 0; clock-frequency 16000000; }; spi1 { status okay; mcp25150 { compatible microchip,mcp2515; reg 0; spi-max-frequency 10000000; clocks mcp2515_osc; interrupt-parent gpio1; interrupts 19 IRQ_TYPE_LEVEL_LOW; vdd-supply reg_3v3; xceiver-supply reg_5v0; }; };一个容易被忽略的细节是那个 fixed-clock 节点。mcp2515 的工作时钟来自外部晶振设备树驱动必须通过clock属性拿到晶振频率才能正确计算 CAN 波特率对应的寄存器分频值。如果你的板子晶振是 16MHz设备树里写成了 8MHz现象会非常诡异连接能建立但一收发报文就报总线错误。spi-max-frequency也很关键SPI 时钟太高线缆稍长一点就可能出现 SPI 通信错误表现为主控读不到芯片的寄存器驱动 probe 失败。降低 SPI 频率到 1MHz 往往就能解决问题。这是硬件稳定性的问题不能靠改软件硬扛。5.3 起网卡、配置波特率、Canopen 调测时的常用命令设备树配好、驱动 probe 成功之后CAN 接口还需要手动配置波特率并启用。ip link set can0 up type can bitrate 500000 candump can0 cansend can0 123#DEADBEEFip link这条命令加载了 SocketCAN 并配置 500Kbps 波特率然后可以用candump监听总线上所有报文用cansend发送测试帧。这套组合拳是 CAN 驱动调测的第一步。我踩过的一个错误是直接ip link set can0 up不带 type 参数系统默认把 can0 当成 Ethernet 设备来配置报一堆 VLAN/LRO 的错但其实是波特率根本没设置。正确的做法是始终把type can bitrate一起写或者用ip link set can0 type can restart-ms 100设置错误恢复时间。candump收不到数据的时候先用示波器或者逻辑分析仪看 CAN_H/CAN_L 上到底有没有差分信号这是判断芯片没配好还是总线根本没数据的最快方法。软件层确认无误、物理层却没有波形多半就是时钟或波特率不匹配。5.4 时钟与中断是最容易出错的两关CAN 驱动调通之后我复盘过最容易出错的其实就是两关时钟和中断。时钟的问题是控制器驱动计算波特率全靠节拍晶振频率和设备树clock-frequency不一致几乎必然导致同步错误。所以调试 CAN 的第一件事不是看软件代码而是确认晶振实际频率跟设备树描述一致。中断的问题更隐蔽。很多 CAN 控制器的中断是电平触发设备工作时间长了如果中断里没做处理和清标志中断信号一直拉低CPU 一直被中断打断表现为系统卡顿但又不完全死机。另一个常见问题是 GPIO 或 SPI 控制器本身使用的中断号配置错误interrupts属性写成另一个引脚导致驱动一直等不到收包中断。我那次踩坑就是因为interrupts 19 IRQ_TYPE_LEVEL_LOW里的引脚号和硬件实际连接对不上白白排查了很久。所以任何 CAN 驱动调测都建议把dmesg里该驱动的日志打开确认 probe 时读到的中断号、时钟频率和设备树器件一致再往下定位。6. 用系统化视角做驱动开发一套实用的定位方法6.1 自顶向下的分层排查法驱动开发最怕的不是某个点不会而是出了问题不知道从哪儿开始找。我后来总结了一套分层定位法驱动起不来时按层往下查很少空转。第一层硬件层。先确认供电、地线、时钟信号、总线电平。用万用表量电源轨用示波器看晶振是否起振用逻辑分析仪看 I2C/SPI 总线上有没有波形。很多驱动问题其实是硬件没焊好或者引脚短路。这一层不查清楚后面所有软件排查都白费。第二层设备树层。确认节点是否被系统解析到status是不是okay使用的引脚有没有被其他节点占用。Linux 下 GPIO/中断/引脚冲突会在注册时打印冲突信息dmesg里多留个心眼。第三层总线层。对 I2C用i2cdetect -y看总线上是不是能扫描到设备对 SPI检查对应 spidev 设备是否出现对 CAN看接口can0是否存在。这一层能区分内核还没看到设备和设备看到了但驱动没跑起来。第四层驱动层。确认 probe 有没有被调用打印的日志信息是否正常资源申请是否成功。如果 probe 根本没被调用优先检查 compatible 字符串是否匹配、模块是否加载、设备有没有出现在总线上。第五层应用层。接口层测试比如 CAN 的 candump 能不能收到数据I2C 的/dev/i2c-x读写正不正常。很多时候应用层通了就是驱动已经正常工作了。这五层从上到下逐层排查每一层都有自己的确认命令和验证方法。我见过很多人一上来就翻驱动源码结果问题出在硬件供电上这属于方向没搞对。6.2 一份可以直接抄的驱动开发自检清单最后把我每次做驱动移植都会过一遍的自检清单整理出来可以直接收藏。检查项验证方法最常见的失败原因模块能否加载insmod / modprobe lsmod符号依赖缺失、签名校验失败加载后有没有注册成功dmesg 搜驱动名注册函数未调用或返回错误设备树节点是否存在/sys/firmware/devicetree/base/...dtb 没编译进去或加载错文件compatible 是否匹配对比驱动 of_match_table 和 dts拼写不一致或厂商前缀不完整总线地址是否正确i2cdetect / 读客户端地址reg 地址写错或带了读写位时钟/中断是否正常dmesg 日志 示波器晶振频率不符、中断号配置错误数据收发是否正常应用层接口测试时序不匹配、错误处理缺失卸载是否干净rmmod dmesg 无报错资源没释放、并发回调未取消这张表看起来简单但每一条背后都是实打实踩过的坑。比如时钟/中断是否正常这一行我当时花了一个下午最后是示波器看到晶振根本没起振才找到原因——板子上的晶振焊的是 8MHz设备树里却写 16MHz。如果提前按这张表过一遍能省太多时间。驱动开发到后期越来越像用系统的方法验证系统而不是单纯靠代码能力硬解。把这条模块 → 设备树 → I2C/CAN的链路彻底理解透不管以后换什么芯片平台这套方法论都能直接迁移。我现在的习惯是拿到一块新板子先花半天时间把设备树文件完整看一遍把每个节点的资源归属理清楚再动手写第一行驱动代码。这个习惯帮我避掉了很多低级错误也让后续的调试路径变得非常清晰。