Linux设备驱动开发这条路,实操过的人都知道,难的不是语法,而是不知道从哪下手。很多人一上来就翻内核源码,对着设备树文件一脸懵,真正要用I2C和CAN总线驱动外部设备时更是手足无措。其实驱动开发有一套特别清晰的路径:先写内核模块搞懂驱动的装载和卸载,再用设备树把硬件信息和驱动代码拆开,最后进到I2C、CAN这类具体总线的子系统里,按规范读写寄存器、收发数据帧就行了。
这篇文章我就围绕这套路径,把从内核模块到设备树、再到I2C和CAN总线驱动的完整过程展开说清楚。不管你是刚入门的嵌入式新人,还是已经写过几个字符设备驱动但还没碰过总线类驱动的工程师,都可以照着这个思路去搭自己的驱动知识体系。我尽量用实际项目中能直接抄的代码、配置和命令来讲,少说空话。
1. 从内核模块开始:理解Linux驱动的最小单元
1.1 为什么内核模块是驱动开发的第一课
在Linux体系里,驱动本质上是运行在内核态的一段代码,用来管理特定硬件。内核模块(Kernel Module)是其中非常特殊的一种形式:它不用整体编译进内核,而是以 .ko 文件存在,系统运行时用 insmod 动态加载,用 rmmod 动态卸载。对开发调试来说,这太关键了。改一段驱动代码,重新编译一个 .ko 文件,加载进去就能测,不需要反复烧写整个内核镜像,开发周期会短非常多。
我见过不少初学者,第一步就去移植内核、裁剪系统,结果一个配置项没选对,整个内核起不来,折腾一下午还没碰到驱动代码。正确顺序是先把内核模块这一套流程跑通:写代码、编 .ko、加载、看日志、卸载,把"驱动是怎么被系统接管的"这个感觉找到,后面学设备树和总线子系统会特别顺。
类比一下,内核是公司的正式员工体系,模块就相当于外聘专家。专家不用参与公司全部日常流程,来了就能干活,干完活就可以走。设备驱动大多数情况下就是用这种"外聘"的方式来管理硬件资源的。
1.2 一个最小内核模块的骨架与编译
一个最基础的内核模块,代码量很小,核心就三句:加载函数、卸载函数、两个注册宏。下面这个例子我经常拿来当模板,不管后面驱动多复杂,骨架都是它。
#include <linux/module.h> #include <linux/init.h> #include <linux/kernel.h> static int __init demo_init(void) { printk(KERN_INFO "demo: module loaded\n"); return 0; } static void __exit demo_exit(void) { printk(KERN_INFO "demo: module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A demo kernel module");配套的Makefile也很有讲究,关键在于要使用内核源码树目录来做编译。以Ubuntu开发机为例:
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执行 make 之后如果没报错,目录下会生成 demo.ko。加载和验证流程如下:
sudo insmod demo.ko dmesg | tail -5 sudo rmmod demo dmesg | tail -5insmod 之后在 dmesg 里能看到 "demo: module loaded",rmmod 之后能看到 "demo: module unloaded",说明整个模块生命周期跑通了。这里有几个必须注意的细节:
- __init 和 __exit 不是装饰性写法。它们让内核在模块加载/卸载以后释放这段代码占用的内存,减少驻留内存的开销,正规驱动都这么写。
- MODULE_LICENSE("GPL") 非常关键。如果没有声明GPL,一些内核导出的符号(EXPORT_SYMBOL_GPL 导出的)你是无法使用的,比如很多内核 API 和辅助函数。
- 写 printk 时要用 KERN_INFO 或更高优先级,否则有些内核配置下 dmesg 里看不到,还容易被 level 过滤掉。
1.3 内核模块的生命周期与资源管理
模块加载后,内核会给它维护一个引用计数(refcnt)。如果模块中提供了文件操作接口,并且有进程正在使用这些接口,引用计数就不为0,这时 rmmod 会返回 EBUSY,模块卸不掉。手动处理时可以用 lsmod 查看模块被谁引用,也可以用 modprobe -r 去按依赖顺序卸载。
另一个容易踩坑的是模块的返回值语义。demo_init 返回 0 代表加载成功;返回负数,比如 -ENODEV、-EINVAL,内核会认为加载失败,并打印对应的错误码。很多新手习惯在 init 里做一堆资源申请,比如注册字符设备、申请GPIO、请求中断,只要有一步失败,都要记得把之前申请的资源全部释放掉再 return 负数,否则就会出现资源泄漏,下次 insmod 时可能报 "Device or resource busy"。
模块层面还有一个点,就是符号导出。如果 A 模块需要调用 B 模块里的函数,需要用 EXPORT_SYMBOL 或 EXPORT_SYMBOL_GPL 显式导出,同时 B 模块要先加载。这属于多模块协作的场景,后面写总线驱动时经常要拆分多个 .ko,提前知道这个机制能少走弯路。
2. 设备树:从"硬编码"到"配置化"的关键一跃
2.1 设备树解决了什么问题
在没有设备树的时代,ARM Linux 里每换一块板子,都要在 mach-xxx.c 这类板级文件里注册一堆 platform_device 结构体,把片内外设地址、中断号、GPIO 全部硬编码在 C 代码中。问题在于,上游内核每新增一种板子,就得多塞几百行板级代码,时间一长文件非常多,而且不同开发板之间的代码耦合严重,想复用内核版本特别痛苦。
设备树(Device Tree)把"硬件长什么样"从内核代码里抽离出来,变成一份独立的描述文件。内核编译时或启动时解析这份文件,根据节点和属性去匹配对应的驱动。这样同一份内核镜像,在 A 板子上启动时加载的是 A 的设备树,在 B 板子上加载的是 B 的设备树,硬件差异全在 .dts 文件里体现,代码层面一次编译、到处跑。
对驱动开发者来说,设备树带来的最大变化是:驱动代码里不再写死某块板子上的寄存器地址和中断号,而是通过标准 API 去解析设备树节点,拿到这些信息后再操作硬件。这个解耦思路一旦建立,后面做系统裁剪优化、平台移植都会轻松很多。
2.2 DTS/DTC/DTB:看懂设备树的三层结构
设备树的源文件是 .dts,公共部分通常放在 .dtsi 中,用 #include 方式引入。比如一个完整的板级 .dts 往往会包含 SoC 厂商提供的 .dtsi,里面定义了 CPU、内存控制器、中断控制器、各种总线节点,板级文件里只要覆盖(override)掉需要修改的节点即可。
DTC 是设备树编译器,把 .dts 编译成二进制的 .dtb。内核启动时由 bootloader 加载 .dtb 文件,然后在启动早期进行解压展开(unflatten),变成内核内部能检索的设备树结构体。所以设备树的三层结构可以理解为:DTS 是源码,DTB 是编译产物,内核解析后形成的内存树就是运行时使用的最终形态。
在开发机上手工编译设备树的命令:
dtc -I dts -O dtb -o myboard.dtb myboard.dts反编译已有 DTB 也非常实用:
dtc -I dtb -O dts -o output.dts myboard.dtb驱动代码里最常用的是这几个 API:
struct device_node *np = dev->of_node; of_property_read_u32(np, "clock-frequency", &freq); of_property_read_string(np, "compatible", &compatible); of_get_named_gpio(np, "enable-gpios", 0);这些 API 返回 -EINVAL 或 -ENODATA 时,说明节点里缺属性或类型不对,要回头查 .dts 里的写法。
2.3 I2C和CAN设备节点的编写实例
设备树的节点写法,对 I2C 从设备和 CAN 控制器最典型。
I2C 从设备节点,挂在对应 I2C 控制器节点下面,关键是 compatible 和 reg 两个属性。compatible 用于驱动匹配,reg 就是从设备的 7 位 I2C 地址:
&i2c2 { status = "okay"; clock-frequency = <400000>; eeprom@50 { compatible = "atmel,24c02"; reg = <0x50>; pagesize = <16>; }; temperature@48 { compatible = "ti,tmp117"; reg = <0x48>; }; };这里的 clock-frequency 是 I2C 控制器启动时配置总线速率用的,400000 就是快速模式 400kHz。内部子节点的 reg 直接决定了内核里 i2c_client 的 addr 字段,如果这个值写错,驱动用 i2c_transfer 发地址时永远匹配不上从设备。
CAN 控制器节点一般放在 SoC 片内外设区域下,需要配置 pinctrl 引脚复用、中断等信息。以常见的 RK3568 平台为例,设备树里往往是这样:
&can0 { pinctrl-names = "default"; pinctrl-0 = <&can0m0_pins>; status = "okay"; }; &can1 { pinctrl-names = "default"; pinctrl-0 = <&can1m0_pins>; status = "okay"; };pinctrl 的意思是告诉内核,CAN0 控制器的引脚复用被配置成 CAN 功能,而不是 GPIO 功能。这个配置如果和实际原理图不一致,板上总线怎么都拉不高电平,排查时特别容易忽略。
2.4 设备树与驱动匹配的完整链路
设备树驱动匹配,走的是 platform_driver 机制。驱动里声明一个 of_match_table,里面放好 compatible 字符串,内核在启动阶段和设备热插拔时,都会拿着设备树节点的 compatible 属性与驱动表中的项做比较,匹配成功就调用驱动的 probe 函数。
static const struct of_device_id demo_of_match[] = { { .compatible = "vendor,tmp117" }, { } }; MODULE_DEVICE_TABLE(of, demo_of_match); static struct platform_driver demo_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = "demo", .of_match_table = demo_of_match, }, }; module_platform_driver(demo_driver);probe 函数里做的事情,本质就是把设备树里解析到的信息拿出来,初始化硬件并注册设备。整个链路可以这样理解:DTS 里的 compatible 相当于身份证号,驱动的 of_match_table 相当于公安局的户籍系统,两者对上号了,probe 就被触发,硬件设备就算正式"接管"了。
我在实际项目中经常用 /sys/firmware/devicetree/base/ 目录来快速确认设备树是否生效。比如:
cat /sys/firmware/devicetree/base/i2c2/eeprom@50/compatible如果输出 atmel,24c02,说明节点已经进入内核的设备树了。再配合 /sys/bus/i2c/devices/ 下的设备列表,就能定位是设备树问题还是驱动匹配问题。
3. I2C子系统:从协议到驱动的全链路实践
3.1 I2C协议的核心要点
I2C 总线只有两根线:SCL(时钟线)和 SDA(数据线)。主机控制 SCL 产生时钟,SDA 用来传数据。通信开始前,主机先发出起始条件——SCL 为高电平期间 SDA 从高拉低;结束前发送停止条件——SCL 为高电平期间 SDA 从低拉高。这个起始和停止条件特别重要,很多从设备在识别到起始条件后才会重置内部状态机。
数据传输按字节进行,每字节8位,高位在前。每个字节之后,接收方需要回一个 ACK 位(SDA 拉低,表示收到),否则就是 NACK。地址帧也是一样,7位从设备地址左移一位,最低位为0表示写,为1表示读。比如 EEPROM 地址 0x50,写操作时字节是 0xA0,读操作时字节是 0xA1。
从设备如果处理不过来,可以拉低 SCL 进行时钟延展(clock stretching),主机需要等从设备释放 SCL 才能继续。不过很多小设备不支持这个特性,驱动里一般不会去处理,但总线丢 ACK 时心里要清楚有这种可能。
上拉电阻是 I2C 硬件设计的关键。SCL 和 SDA 都是开漏输出,必须通过上拉电阻接到电源。电阻太小会导致上升沿过快、功耗大,电阻太大则会导致上升沿过慢、通信频率上不去。常见经验值在 1kΩ 到 10kΩ 之间,400kHz 速率一般用 2.2kΩ 或 4.7kΩ。
3.2 Linux I2C子系统架构
Linux 的 I2C 子系统分为三层:适配器(adapter)、核心层(core)和设备驱动(client driver)。
- adapter:代表一条 I2C 总线控制器,里面包含 algorithm(收发函数实现)和资源信息。例如 SoC 自带的 I2C2、I2C3 控制器,内核里就是一个个 i2c_adapter。
- client:代表挂在某条总线上设备,包含地址和设备树信息。设备树解析后,每个 I2C 子节点都会生成一个 i2c_client。
- driver:i2c_driver 是驱动本身,通过 id_table 或者 of_match_table 与 client 匹配。
可以这么类比:adapter 是墙上的插座,client 是插头,driver 是电器说明书。匹配成功以后,probe 被调用,驱动就能通过 adapter 向 client 地址发起读写请求了。
内核提供的核心 API 有几个需要记牢:
int i2c_master_send(struct i2c_client *client, const char *buf, int count); int i2c_master_recv(struct i2c_client *client, char *buf, int count); int i2c_transfer(struct i2c_adapter *adap, struct i2c_msg *msgs, int num);i2c_master_send/recv 适合简单读写,i2c_transfer 适合组合操作。比如读 EEPROM 某个地址的数据,需要先写地址再读数据,两条消息要放同一个 i2c_msg 数组里,用 i2c_transfer 一次完成,中间是连续的重复起始条件(repeated start),这样就不会被其他主机打断。
3.3 I2C设备驱动编写实战
拿一个真实的温度传感器驱动来举例,从设备地址 0x48,内部寄存器 0x00 存储温度值。驱动核心是 i2c_driver 的注册和读写函数。
#include <linux/i2c.h> #include <linux/module.h> #include <linux/kernel.h> #define TMP117_TEMP_REG 0x00 static int tmp117_read_temperature(struct i2c_client *client, int *temp) { struct i2c_msg msgs[2]; unsigned char reg = TMP117_TEMP_REG; unsigned char data[2]; int ret; msgs[0].addr = client->addr; msgs[0].flags = 0; msgs[0].len = 1; msgs[0].buf = ® msgs[1].addr = client->addr; msgs[1].flags = I2C_M_RD; msgs[1].len = 2; msgs[1].buf = data; ret = i2c_transfer(client->adapter, msgs, 2); if (ret < 0) return ret; *temp = (data[0] << 8) | data[1]; return 0; } static int tmp117_probe(struct i2c_client *client) { int temp; dev_info(&client->dev, "tmp117 probed\n"); if (tmp117_read_temperature(client, &temp) == 0) dev_info(&client->dev, "temp raw = 0x%04x\n", temp); return 0; } static void tmp117_remove(struct i2c_client *client) { dev_info(&client->dev, "tmp117 removed\n"); } static const struct of_device_id tmp117_of_match[] = { { .compatible = "ti,tmp117" }, { } }; MODULE_DEVICE_TABLE(of, tmp117_of_match); static const struct i2c_device_id tmp117_id[] = { { "tmp117", 0 }, { } }; MODULE_DEVICE_TABLE(i2c, tmp117_id); static struct i2c_driver tmp117_driver = { .driver = { .name = "tmp117", .of_match_table = tmp117_of_match, }, .probe = tmp117_probe, .remove = tmp117_remove, .id_table = tmp117_id, }; module_i2c_driver(tmp117_driver); MODULE_LICENSE("GPL");注意几个容易忽略的地方:
- msgs[0].flags 不设置,表示写;msgs[1].flags 设置 I2C_M_RD,表示读。
- msg.len 是指字节数,不是寄存器地址位数。寄存器地址是1字节,len就是1;返回数据是2字节,len就是2。
- i2c_transfer 返回成功传输的消息条数,如果返回2表示两条消息都完成了;返回0或负数都说明传输失败。
编译和加载后,在 /sys/bus/i2c/devices/2-0048/ 下能看到这个设备节点,说明 client 和 driver 已经成对绑定了。
3.4 I2C调试与常见问题排查
I2C 调试工具我用得非常勤,在 i2c-tools 包里,几个命令分别是 i2cdetect、i2cdump、i2cget、i2cset、i2ctransfer。
先扫描总线上的设备:
i2cdetect -y 2这条命令会列出总线2上所有能 ACK 的7位地址。如果你确认设备挂在 I2C2,但这里扫不到,那基本可以断定是硬件层面的问题,排查方向是:设备供电是否正确、SDA/SCL 有没有接反、上拉电阻有没有贴、设备地址是不是真如手册所写。之前遇到过一块板子,原理图上地址是0x50,实际芯片的地址引脚被拉高变成0x51,i2cdetect 显示0x51,就是对不上的典型。
如果 i2cdetect 能扫到地址,但驱动读写异常,重点检查这两点:
- 字节序问题。很多传感器寄存器是16位,高字节在前还是低字节在前,要严格按 datasheet 来。我见过有工程师在小端处理器上把高低字节拼反了,温度读出直接变成一个不合理的负数。
- ACK 异常和时钟延展。用示波器一边抓 SCL、SDA,一边跑驱动。起始条件、地址字节、ACK 位每个都要核对。如果发现从设备一直 NACK,优先怀疑设备地址错误或设备处于复位状态。
还有一类问题特别隐蔽:I2C 总线被某个设备锁住,SDA 一直为低。原因可能是某次通信在停止条件前被打断,从设备状态机卡死。解决办法是给从设备断电复位,或者用 GPIO 模拟多个时钟周期让它恢复。做产品时,我习惯在硬件上给 I2C 设备的电源单独加软开关,遇到总线锁死可以通过 GPIO 复位设备,而不是只能整板断电。
4. CAN总线:面向工业现场的驱动开发
4.1 CAN协议与Linux SocketCAN
CAN 总线是工业控制和汽车领域最常见的现场总线。它用两根差分信号线(CANH、CANL)传输,抗干扰能力比普通UART强得多。通信时任何节点都可以主动发送,多个节点同时发送时,通过 ID 仲裁机制决定谁优先:ID 数值越小,优先级越高,发送时显性电平可以"覆盖"隐性电平,实现非破坏性仲裁。
协议层面,标准帧的 ID 是 11 位,扩展帧是 29 位。数据段最多8字节(CAN FD 可以到64字节,但这里先按经典CAN讲)。每帧还有 CRC、ACK 等字段,出错了节点会发出错误帧,网络里的节点可以根据错误计数(TEC/REC)判断自己是 error-active 还是 bus-off 状态。
Linux 内核把 CAN 设备抽象成了网络接口,也就是 SocketCAN,接口名是 can0、can1 这种。操作起来和网卡非常像,配置波特率、启停、收发数据都通过 socket 或 ip 命令完成。
启动一个 CAN 接口的完整流程:
sudo ip link set can0 down sudo ip link set can0 up type can bitrate 500000查看状态:
ip -details link show can0发送和接收数据,用 can-utils 里的命令:
cansend can0 123#DEADBEEF candump can0其中 123 是 11 位 ID,DEADBEEF 是 4 字节数据。用 cangen 可以随机发数据测试总线压力,用 candump 加上 -e 参数可以同时显示错误帧。
4.2 CAN设备驱动框架与中断处理
CAN 控制器在 Linux 里的驱动框架和普通网卡驱动有几分相似。核心数据结构有两个:net_device 描述网络接口,can_priv 描述CAN控制器私有属性,里面保存波特率、时钟、错误状态等。
驱动注册流程大致是:probe 函数里分配 net_device,初始化 can_priv,设置 netdev_ops,申请中断,最后 register_candev。中断处理函数里做接收和错误统计。接收时可以用 NAPI 或者 can_rx_offload 来减轻高负载下的 CPU 压力。
发送路径一般是:应用层调用 socket 发送 CAN 帧,内核协议栈最终调用驱动实现的 .ndo_start_xmit。驱动把帧写入控制器发送缓冲区,然后等待发送完成中断。这个过程需要处理好临界区,防止在发送中又来了新数据导致覆盖。
错误处理是 CAN 驱动里很容易被忽视的部分。总线短路、节点掉线、波特率不匹配都会导致错误帧。驱动里要监听控制器状态寄存器,如果是 bus-off 状态,通常需要按协议要求进行 11 个连续隐性位的总线恢复(bus-off recovery),这时也可以选择直接把接口 down/up 来手动复位。
我实际调试中常用的一个窍门是:丢掉逻辑分析仪之前,先看 can0 设备状态的 error 计数。如果一直在涨,说明物理层有问题;如果计数稳定但是收发不到数据,再去查帧ID和滤波配置。
4.3 基于设备树的CAN节点配置实例
CAN 控制器节点在设备树里的配置,和I2C节点风格一致,重点也是 pinctrl、中断、时钟。以 RK3568 平台为例,设备树中 CAN0 和 CAN1 节点通常这样使能:
&can0 { pinctrl-names = "default"; pinctrl-0 = <&can0m0_pins>; status = "okay"; }; &can1 { pinctrl-names = "default"; pinctrl-0 = <&can1m0_pins>; status = "okay"; };pinctrl 的作用前面说过,是把芯片引脚从 GPIO 模式切换成 CAN 功能。RK3568 的 CAN0 可能有 m0/m1 等多组引脚可选,设备树里选哪组必须和实际 PCB 原理图对应。如果 pinctrl 配置错误,CAN 控制器虽然注册成功了,但引脚完全没有信号,示波器量 CANH/CANL 都是平的,新手在这上面容易卡好久。
如果使用的是 SPI 转 CAN 的控制器,比如 MCP2515,设备树节点就会放在 SPI 总线下,写法类似:
&spi0 { mcp2515: can@0 { compatible = "microchip,mcp2515"; reg = <0>; spi-max-frequency = <10000000>; interrupt-parent = <&gpio3>; interrupts = <10 IRQ_TYPE_LEVEL_LOW>; clocks = <&cru CLK_MCP2515>; vdd-supply = <&vcc3v3>; xceiver-supply = <&vcc3v3>; }; };需要注意的是,MCP2515 这种控制器需要额外的晶振时钟,而且通常有一颗 CAN 收发器芯片(Transceiver)把控制器输出的逻辑信号变换成 CANH/CANL 差分信号。xceiver-supply 属性就是给收发器供电用的,设备树里不配,probe 阶段可能直接失败。
4.4 CAN调试工具与问题排查
CAN 调试中,我最常用的三个工具是 cansend、candump 和 canfdtest,前两个发数据和监听,后一个做 CAN FD 或环回压力测试。
排查双节点通信失败的顺序,我一般固定这样走:
- 第一步,查物理层。用万用表量 CANH 与 CANL 之间的静态电压,正常情况下隐性状态二者电压都接近 2.5V,差值接近 0;发送显性帧时差值会达到 2V 左右。然后再确认总线两端是否都有 120Ω 终端电阻,用万用表量总线两端之间的电阻,应该是 60Ω 左右(两个120Ω并联),如果量到120Ω说明有一端终端电阻没焊或者断了。
- 第二步,查波特率。两个节点必须用完全一样的波特率。ip link set can0 up type can bitrate 500000 这条命令里的 bitrate 必须一致。很多隐性故障都是这边 500K、那边 250K,双方都能起来,但一收发就全是错误帧。
- 第三步,抓错误帧。candump 加 -e 参数可以显示所有错误帧,看到 error passive 或 bus off 时,回头查物理层和波特率。
candump can0 -e如果手头有另一块正常工作的设备,可以用它做对比测试。我曾经遇到过一块板子 CAN 通信时好时坏,最后量出来是 CANL 线到连接器之间虚焊,仅靠排针的机械接触维持,摇晃一下线束就开始报错。这类问题,光看协议栈日志是定位不了的,必须回到硬件排查。
位定时参数(BS1、BS2、SJW)也是 CAN 驱动的重点之一。IP 命令里可以用 tq、prop-seg、phase-seg1、phase-seg2、sjw 这些参数微调位定时。BS1 和 BS2 的长短直接决定采样点位置,工业现场一般推荐采样点放在 75% 到 87.5% 左右,具体数值要看总线长度和节点数。如果链路长、节点多,可以适当增大 SJW 来增强对时钟偏差的容忍度。
5. 写在最后的一些实际经验
把这套路径走完,你会发现在 Linux 下做驱动开发,本质就是"按子系统规则办事情"。内核模块教会你资源是怎么申请和释放的,设备树教会你硬件信息是怎么传递到驱动里的,I2C 和 CAN 子系统则教你在具体总线上怎么收发数据、怎么处理中断和错误。掌握了这三层,再去碰 SPI、UART、USB、PCIe 驱动,大框架都是通的。
我自己带人的时候有个习惯:新同学先写内核模块,然后给他一块传感器小板子做 I2C 驱动,再给他装一套双节点 CAN 收发环境。这三步走完,他对"驱动到底在干什么"会有一个非常直观的认知。代码量并不大,过程却很锻炼人,尤其是出问题的时候,逼着他去读芯片手册、看时序图、量波形,这些能力后面用在哪都值钱。
调 I2C 和 CAN 的时候,有几个小习惯建议你从第一天就养成。第一,设备树改动一定用 git 管理,每个节点被谁改过、为什么改,全留记录;第二,调试信息先全部打开,内核的 dynamic debug 和网络的 dev_err、dev_info 都写上,别偷懒;第三,每次做硬件改动以后,先跑一遍环回测试再上网络,免得把总线上其他节点带崩。踩过几次坑之后你会发现,这些不起眼的习惯才是项目不烂尾的真正保障。