Linux驱动开发实战:从内核模块到设备树、I2C与CAN 📅 发布时间:2026/9/9 8:59:53 👁 浏览次数: 做Linux驱动开发这几年我见过很多从单片机和裸机转过身的同行最典型的一个场景是这样芯片手册上写着I2C控制器寄存器地址你对着寄存器一顿操作传感器能读出数据、电机也能转起来。但等板子真正跑起Linux你会发现“代码逻辑全对”设备却怎么都枚举不出来。问题往往不在你的C语言功底而在于Linux驱动从来不是“对着寄存器写地址”这么简单它有一套完整的体系内核模块是载体设备树是硬件描述语言而I2C/CAN这类总线驱动则是跑在这套描述之上的通信程序。这篇文章想把这条完整的系统路径梳理清楚——从内核模块怎么写到设备树怎么描述硬件再到I2C和CAN驱动具体怎么落地顺带把我踩过的坑、觉得好用的调试手段都摊开来讲。适合正准备入门Linux驱动、或者从裸机开发转Linux的朋友也适合那些项目里需要用到I2C/CAN却总在“驱动层”卡住的人。1. 先理清一条主线驱动开发的四个层级1.1 为什么“能点灯”不等于“会写驱动”很多人第一次接触Linux驱动都会拿裸机开发的思路往上套知道设备挂在哪条总线上、知道寄存器地址就直接ioremap然后读写。这种思路在裸机上完全正确但在Linux里会被内核的安全模型和管理框架“拦下来”。内核不希望驱动程序直接乱碰物理地址而是希望驱动通过platform_get_resource、of_iomap、ioremap这些接口向内核“申请”资源再由内核统一管理映射关系。好处是不同的驱动之间不会因为地址冲突互相踩踏资源也能按需加载、释放。我记得自己第一次把一个字符设备模板编译成.ko加载进内核的时候内心其实是懵的为什么我明明在主程序里写了硬件初始化加载完模块以后设备节点却没出现后来才意识到Linux的设备节点是由driver在probe里调用device_create创建的probe没被调用说明驱动和硬件描述还没对上。这种“代码写了但系统不认”的挫败感几乎是每一个Linux驱动初学者都会撞上的墙。1.2 从内核模块到设备树再到总线的完整链路这里想先帮大家把整条路径拆开。我习惯把Linux驱动开发分成四个阶段每个阶段解决一个层面的问题内核模块解决“代码以什么形式存在于内核”的问题。驱动不是编译进内核的main函数而是一个可以被动态加载/卸载的独立模块所以要先搞懂模块框架、生命周期、符号导入导出这些机制。设备树解决“内核怎么知道硬件长什么样”的问题。板子上有哪些外设、挂在哪个控制器下面、中断在哪个引脚上这些信息不再写在内核源码里而是写到设备树源文件里由内核在启动时解析。I2C/总线驱动解决“数据如何在设备间流动”的问题。I2C这类总线有自己的一套子系统驱动要遵循adapter、client、driver的框架而不是自己造轮子。CAN网络驱动解决“多节点实时通信”的问题。CAN在Linux里被抽象成网络接口驱动和网络协议栈衔接属于总线驱动里的进阶方向。这四个阶段是层层递进的。如果不理解内核模块的组织形式驱动写得再好也加载不进去如果设备树不匹配probe永远不会被调用如果不懂总线的匹配机制光有一堆寄存器的读写代码在Linux里就是一堆“死代码”。这篇文章会按照这条链路往下走每一层都会给出可落地的代码和调试手段。2. 内核模块驱动代码的最小容器2.1 一个最小可加载模块的完整模板先给一个最朴素的内核模块代码别嫌它简单很多驱动工程最后都会回到这个结构上// hello.c #include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO hello module loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello module unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A minimal hello module);配套的Makefile是很多人第一次写错的地方直接给一个能用的obj-m : hello.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如果你是交叉编译比如给瑞芯微RK3568那种ARM64平台做驱动Makefile里要加上ARCH和CROSS_COMPILEobj-m : hello.o KDIR : /path/to/kernel/source PWD : $(shell pwd) ARCH : arm64 CROSS_COMPILE : aarch64-linux-gnu- all: $(MAKE) -C $(KDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) modules编译完会在当前目录生成hello.ko然后insmod hello.ko加载、rmmod hello卸载用dmesg看内核日志。这个流程看似简单但很多初学者会卡在“为什么insmod没反应”上——实际上printk默认级别可能不输出到终端要用dmesg或者journalctl -k才能看到。2.2 从模板到字符设备注册一个真实节点模块只是容器真实驱动还需要让用户态“看得见”。字符设备驱动是最常见的一种核心三件套是alloc_chrdev_region分配设备号、cdev_init初始化cdev、cdev_add把设备加进内核。为了让/dev下出现节点还需要class_create和device_create。我常用的字符设备模板是这样#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DEVICE_NAME demo_dev static int major; static struct class *demo_class; static struct cdev demo_cdev; static int demo_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char kernel_buf[64] hello from kernel\n; size_t len strlen(kernel_buf); if (copy_to_user(buf, kernel_buf, len)) return -EFAULT; return len; } static struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .read demo_read, }; static int __init demo_init(void) { dev_t devno; alloc_chrdev_region(devno, 0, 1, DEVICE_NAME); major MAJOR(devno); cdev_init(demo_cdev, demo_fops); demo_cdev.owner THIS_MODULE; cdev_add(demo_cdev, devno, 1); demo_class class_create(DEVICE_NAME); device_create(demo_class, NULL, devno, NULL, DEVICE_NAME); printk(KERN_INFO demo device registered, major%d\n, major); return 0; } static void __exit demo_exit(void) { dev_t devno MKDEV(major, 0); device_destroy(demo_class, devno); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(devno, 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);编译加载后/dev/demo_dev就会出现用户可以cat /dev/demo_dev读到内核返回的字符串。这里有个很容易踩的坑注册和注销的顺序必须严格对称。如果你在exit里先cdev_del再unregister_chrdev_region而class还没销毁那设备节点虽然被删了但/sys目录下会残留僵尸入口下一次加载时可能出现设备号冲突或者创建失败。2.3 模块加载失败几个容易忽略的底层原因insmod以后什么提示都没有或者直接报错是最常见的事故现场。我遇到过几种高频情况先记下来方便你对号入座Unknown symbol说明驱动里用了内核没有导出的函数或者变量需要检查内核是否配置了相关选项或者用EXPORT_SYMBOL导出。vermagic mismatch内核模块和当前内核版本不一致最常见于交叉编译时把KDIR指错了比如x86主机上用主机内核目录去编arm64模块。模块已存在或者设备号冲突加载时提示“Device or resource busy”这时需要用lsmod查一下模块是否已经加载。模块顺序依赖比如你的模块依赖另一个模块导出的符号必须先加载被依赖的模块用modprobe会比insmod更省心因为modprobe会处理依赖关系但需要先用depmod生成依赖索引。我自己的习惯是先写一个最小模块验证编译环境和加载通路再往里填业务逻辑。这样一旦出问题能很清楚地知道是环境问题还是驱动逻辑问题。3. 设备树让内核知道硬件长什么样3.1 设备树解决了什么问题设备树Device Tree本质上是把“板级硬件描述”从内核源码里抽出来变成一份独立的数据文件。早期内核里每来一块新开发板都要在内核源码里新增一个board文件里面塞满了GPIO定义、I2C设备列表、中断信息这种把硬件信息写死在代码里的做法导致内核里平台代码越来越多、维护成本极高。设备树的思路是硬件是什么样就用树状节点描述出来内核启动时解析这些描述自动生成platform设备、I2C设备和中断映射。这里有三个文件名你需要分清DTS是设备树源文件文本DTSI是公共头文件也是文本经常被includeDTB是编译后的二进制文件。内核运行时解析的是DTB。在ARM64平台比如RK3568上你会在内核源码的arch/arm64/boot/dts/rockchip/目录下看到rk3568-evb.dts、rk3568.dtsi这些文件rk3568.dtsi是SoC级公共描述evb.dts这种是具体板级描述它们在编译时会合到一起。手动编译设备树的命令是dtc -I dts -O dtb -o test.dtb test.dts反过来想从DTB里查看源文件内容也很简单dtc -I dtb -O dts -o dumped.dts test.dtb这个反编译操作在调试时特别有用很多板子给的是编译好的dtb你想知道某个节点究竟写没写进去直接反编译出来看。3.2 一个典型外设节点的字段拆解以I2C外设节点为例我们看一个完整写法i2c0 { status okay; clock-frequency 400000; bq769520b { compatible ti,bq76952; reg 0x0b; interrupt-parent gpio1; interrupts 3 IRQ_TYPE_LEVEL_LOW; ti,batt-monitor-enabled; }; };这个节点拆开来看每一行都对应驱动里的一段逻辑i2c0是引用I2C控制器节点表示我要往这个控制器下面挂从设备。status okay是使能标志很多SoC默认把用不到的外设节点设成disabled驱动probe不执行的时候先查这个字段。clock-frequency是I2C控制器的时钟频率设备树里经常能看到400k、100k这种值它最终会体现在adapter的timeout和比特率配置里。子节点名写成bq769520b后面跟的是I2C从设备地址这个地址是7位地址单位是0x0b。这里有个特别容易踩的坑芯片手册里如果写的是8位地址比如0x16你要右移一位变成7位的0x0b否则I2C扫描器根本找不到设备。compatible是驱动匹配的关键字符串驱动中的of_match_table里必须有一个完全相同的compatible才能触发probe。reg 0x0b表示设备在I2C总线上的地址这个值会被内核填充到i2c_client结构体的addr字段。interrupt-parent和interrupts用来映射中断驱动里用irq_of_parse_and_map或者直接使用client-irq就能拿到中断号。3.3 驱动如何找到设备compatible匹配机制设备树写好了驱动里还得有对应的匹配描述。platform_driver的匹配逻辑会先查of_match_table所以驱动里通常是这样static const struct of_device_id bq76952_of_match[] { { .compatible ti,bq76952 }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, bq76952_of_match); static struct platform_driver bq76952_driver { .driver { .name bq76952, .of_match_table bq76952_of_match, }, .probe bq76952_probe, .remove bq76952_remove, }; module_platform_driver(bq76952_driver);匹配流程大概是内核在注册platform设备时会把这个设备和平台上已经注册的driver做比对。匹配优先级里of_match_table里的compatible字符串优先级很高接下来才会去看id_table和driver.name的兼容。很多情况下probe不执行不是驱动代码写错了而是设备树里的compatible和驱动里的of_match_table字符串不一致——注意连大小写和拼写都要一模一样。调试设备树匹配问题我常用的手段是看/sys/bus/platform/devices/目录里面列出了所有枚举到的平台设备如果设备树节点解析成功应该能看到对应的设备目录。另外内核还支持在/sys/firmware/devicetree/base下查看运行时设备树结构节点展开情况一目了然。4. I2C驱动从看得见到控得住4.1 I2C子系统里的三个角色I2C子系统是Linux驱动里非常典型的“三件套”模型adapter、client、driver。adapter就是硬件上的I2C控制器它负责物理层的时序、起始信号、停止信号你可以把它理解成“快递站”client是指挂在总线上的从设备设备树里写的bq769520b就是一个client对比你要寄件的“收件人地址”driver则是驱动逻辑它知道怎么跟这个client打交道相当于“处理包裹的流程”。这三者在内核里的关系是adapter由I2C控制器驱动注册client由设备树或者ACPI、板级代码枚举出来driver通过i2c_add_driver注册进内核。当内核发现某个client的compatible匹配到一个driver时就会调用driver的probe函数。每一次I2C读写从用户态到设备链路是用户态open/read/write → 字符设备驱动 → I2C driver的处理函数 → adapter的传输函数 → 物理I2C控制器 → 从设备。4.2 驱动代码的核心骨架一个I2C驱动的基本骨架是i2c_driver#include linux/i2c.h #include linux/module.h #include linux/init.h static int bq76952_probe(struct i2c_client *client) { dev_info(client-dev, probe bq76952, addr0x%02x, irq%d\n, client-addr, client-irq); return 0; } static void bq76952_remove(struct i2c_client *client) { dev_info(client-dev, bq76952 removed\n); } static const struct i2c_device_id bq76952_id[] { { bq76952, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, bq76952_id); static const struct of_device_id bq76952_of_match[] { { .compatible ti,bq76952 }, { } }; MODULE_DEVICE_TABLE(of, bq76952_of_match); static struct i2c_driver bq76952_driver { .driver { .name bq76952, .of_match_table bq76952_of_match, }, .probe bq76952_probe, .remove bq76952_remove, .id_table bq76952_id, }; module_i2c_driver(bq76952_driver); MODULE_LICENSE(GPL);probe里最关键的是拿到client结构体然后通过i2c_transfer或i2c_smbus_read/write和芯片通信。这里面有个很重要的细节i2c_transfer的msg数组里每一条msg由一个start信号开始最后一条msg结束后才有stop信号。如果你要“写地址再读数据”中间不能拆成两个独立transfer否则中间会产生stop和start很多芯片会因此通信失败。正确写法是把写寄存器地址和读数据放在同一个transfer里连续两个msg这样总线上表现为START写地址寄存器地址REPEAT START读数据STOP。在调试阶段我倾向先用i2c-tools在用户态验证通信通路是否正常再回到驱动里写代码。尤其对于BQ76952这种电池管理芯片它本身有很多寄存器组和校验机制用户态验证能极大缩小问题范围。4.3 用i2c-tools快速定位通信失败i2c-tools几乎是I2C调试的标配常用四件套是i2cdetect、i2cget、i2cset、i2cdump。扫描总线能看到哪些地址上有设备应答i2cdetect -l # 列出所有I2C总线 i2cdetect -y -r 0 # 扫描bus 0-r表示用read方式如果设备地址是0x0bi2cdetect应该能在0b位置看到一坨数字比如UU表示地址被驱动占了其他数字表示有设备应答。接下来可以直接读寄存器i2cget -y 0 0x0b 0x00 # 读地址0x0b的0x00寄存器 i2cset -y 0 0x0b 0x01 0x55 # 写地址0x0b的0x01寄存器为0x55 i2cdump -y 0 0x0b # dump整个寄存器空间一条经验如果i2cdetect扫得到地址但i2cget提示“Remote I/O error”多半不是地址问题而是时序问题比如上拉电阻过小导致上升沿太慢或者I2C速率太高超出芯片规格。反过来如果i2cdetect扫不到先检查7位/8位地址换算再检查设备树里status是否为okay最后用示波器看SDA/SCL波形。软件I2C也是个常用后备方案。有些板子的某个I2C控制器被复用了或者引脚不支持硬件I2C功能Linux内核里可以直接启用i2c-gpio这个驱动用两个GPIO模拟I2C时序。设备树里通常这样描述i2c-gpio0 { compatible i2c-gpio; gpios gpio0 5 GPIO_ACTIVE_HIGH, /* SDA */ gpio0 6 GPIO_ACTIVE_HIGH; /* SCL */ i2c-gpio,delay-us 5; /* 约100kHz */ };我刚开始在STM32F407这类MCU上做软件I2C时最大的教训是IO口一定要配置成开漏输出并外加合适的上拉电阻同时每个时钟周期的高电平和低电平时间要稳定不要一边翻转一边做耗时的计算。这个经验放到Linux的i2c-gpio上同样适用。4.4 从设备树到用户态读写的完整链路实际项目里我会按下面这个顺序把I2C从设备完整“跑通”在设备树里添加I2C从设备节点确认compatible、reg、interrupt-parent都正确。重新编译设备树并烧录启动后在/sys/bus/i2c/devices/下看到新设备目录。用i2cdetect扫一下地址确认设备在总线上有应答。用i2cget/i2cset在用户态读写几个关键寄存器验证芯片手册上的寄存器地址和预期一致。再写内核驱动的probe、read、write注册成一个misc设备或接入regmap框架让用户态应用能通过/dev/xxx访问。这套顺序最大的好处是每一层验证通过后再往下一层走问题出现时能立刻定位到是硬件、设备树、用户态通信还是驱动逻辑的问题。我见过太多人一上来就写一整套驱动最后用户态测试read返回值不对查了一整天结果是芯片手册上的寄存器地址记错了如果先做第4步五分钟就能发现。5. CAN驱动当总线不再是一对一5.1 CAN在Linux里为什么是网络接口CAN总线和I2C最大的不同是I2C通常是一主多从CAN则是对等式多主通信数据以帧为单位广播所有节点都能收到同一帧靠标识符决定是否处理。这种“多个节点都能发言”的模型和网络接口的模型天然贴合所以Linux把CAN做成了网络设备叫SocketCAN。SocketCAN带来的好处是巨大的你不需要自己写一套消息队列和超时机制直接用socket(AF_CAN, SOCK_RAW, CAN_RAW)然后像收发UDP包一样收发CAN帧。驱动层面也复用网络驱动的框架CAN控制器注册成一个net_device这个设备的驱动只需要实现open、close、start_xmit和中断处理函数剩下的调度、缓冲、过滤都交给内核网络栈。5.2 设备树里如何描述CAN控制器CAN控制器有两种常见形态一种是SoC内置的CAN控制器比如STM32的bxCAN/FDCAN、NXP的FlexCAN或者瑞芯微平台上的CAN控制器另一种是外挂的CAN控制器芯片比如MCP2515这种SPI转CAN的芯片。两者在设备树里的写法不一样。外挂MCP2515的节点通常是挂在SPI总线下面ecspi2 { mcp2515: can1 { compatible microchip,mcp2515; reg 1; clocks clk20m; interrupt-parent gpio1; interrupts 17 IRQ_TYPE_LEVEL_LOW; spi-max-frequency 10000000; }; };SoC内置的CAN控制器节点则要重点配置引脚复用和时钟比如can0 { status okay; pinctrl-names default; pinctrl-0 can0_pins; clock-frequency 30000000; /* 根据实际时钟配置 */ };这里pinctrl特别容易被忽略。很多CAN控制器在硬件上已经连接了收发器但引脚复用没有配置到CAN功能上或者配置到的GPIO功能被其他驱动占用导致CAN口始终拉不起来。排查这种问题先看pinctrl-0对应的引脚是否被其他节点抢占。5.3 用户态把CAN“拉起来”驱动注册成功之后CAN在用户态的命令非常直观。先把接口拉起来ip link set can0 up type can bitrate 500000然后就能用candump监听总线上所有帧candump can0发送一帧数据cansend can0 123#DEADBEEF这条命令表示往标识符0x123发一帧数据内容是0xDE 0xAD 0xBE 0xEF。如果你想要循环发送可以用cangen。排查CAN问题的第一步是查看接口状态ip -details link show can0这时能看到波特率、采样点、状态等参数。如果状态显示ERROR-ACTIVE或者BUS-OFF说明总线上有错误。最常见的坑是整个总线上只有你一个节点你没有接终端电阻和对端设备然后你往总线上发帧因为没有ACK应答控制器会不停重发最终进入bus-off状态。这种时候不要慌先接上另一个CAN节点或者终端电阻然后ip link set can0 down ip link set can0 up type can bitrate 500000把接口恢复回来就好。5.4 SocketCAN编程要点和位时序参数如果你要在应用层直接发CAN帧代码很简单#include linux/can.h #include linux/can/raw.h #include sys/socket.h #include sys/ioctl.h #include net/if.h int s socket(AF_CAN, SOCK_RAW, CAN_RAW); struct sockaddr_can addr; struct ifreq ifr; strcpy(ifr.ifr_name, can0); ioctl(s, SIOCGIFINDEX, ifr); addr.can_family AF_CAN; addr.can_ifindex ifr.ifr_ifindex; bind(s, (struct sockaddr *)addr, sizeof(addr)); struct can_frame frame; frame.can_id 0x123; frame.can_dlc 4; frame.data[0] 0xDE; frame.data[1] 0xAD; frame.data[2] 0xBE; frame.data[3] 0xEF; write(s, frame, sizeof(frame));位时序参数虽然大多数时候用默认值就行但真要手工调的时候容易一头雾水。经典CAN的位时间由SYNC_SEG、PROP_SEG、PHASE_SEG1、PHASE_SEG2和SJW组成采样点通常在75%到87.5%之间。有些控制器驱动允许在设备树里覆盖这些参数比如can0 { /* 500kbps, 采样点75% */ bosch,mram-cfg ...; bitrate 500000; sample-point 0.75; };如果总线上某个节点采样点不合适高速率下会出现偶发错误帧。排查这类问题建议先用默认参数跑通再用CAN分析仪对比总线波形来确定最佳采样点不要一上来就改。6. 常见问题与排查技巧实录6.1 高频问题速查表把驱动开发中容易遇到的高频问题整理成一张表按“症状-可能原因-解决办法”的顺序查会快很多症状可能原因排查/解决办法insmod报Unknown symbol依赖的模块未加载或符号未导出先加载被依赖模块代码里加EXPORT_SYMBOLmodprobe找不到模块依赖索引未更新执行depmod后重试probe函数不被调用compatible不匹配、驱动没编进去、statusdisabled检查of_match_table和设备树compatible字符串i2cdetect扫不到设备7位/8位地址混淆、上拉电阻、status状态换算地址、示波器看波形、检查设备树I2C读写偶发失败速率太高、线长、电容过大降低clock-frequency、缩短引线、检查上拉CAN发送后进入bus-off总线上只有单节点、无ACK、波特率不一致接入对端节点/终端电阻设置相同波特率设备节点没生成device_create未调用或udev规则问题检查probe是否执行、设备类和device_create参数中断不触发IRQ_TYPE不匹配、上下拉、中断被共享用cat /proc/interrupts查中断计数核对触发方式6.2 我的独门排查顺序这些年驱动没调通的时候我摸索出一套固定的排查顺序比东一榔头西一棒子高效得多先看dmesg。不是随便翻而是启动后无脑dmesg | grep -E failed|error|timeout|can|i2c先把日志里的异常信号捞出来。很多问题其实早就有蛛丝马迹只是你没看。再看设备是否被内核枚举到。I2C设备看/sys/bus/i2c/devices/平台设备看/sys/bus/platform/devices/CAN设备用ip link。设备一旦在sysfs里出现了说明硬件描述和驱动匹配已经成功问题只会在数据传输层。然后用用户态工具打辅助。i2c直接上i2c-toolsCAN直接上candump/cansend这些工具比自己写测试代码快得多而且它们绕过了复杂驱动逻辑能直接验证底层通路。最后才回到驱动代码。排查I2C就检查msg的start/stop组合排查CAN就检查中断处理函数里是否及时调用can_rx_offload排查设备树就把反编译后的节点和驱动里的of_match_table对着看。6.3 避坑心得有些坑是反复踩过以后才长记性的笔记式地分享几条。设备树的statusdisabled是个大坑。很多SoC的出厂设备树会把用不到的I2C/CAN控制器disable掉你往里加子节点之前必须先把父节点状态改成okay否则子节点写了也白写。我见过有人在一棵I2C节点下面写了三个外设结果一个probe都不跑最后发现父节点的status还是disabled。内核驱动里不要做太长时间的忙等待。在probe里直接mdelay几百毫秒、或者在I2C没有应答时写一个while(1)死循环等待这些操作在裸机上是常态在Linux里会直接拖垮整个系统。正确做法是用内核的延迟机制、超时重试和错误码返回处理不了就返回-ETIMEDOUT让应用层去决定重试策略。看芯片手册时寄存器的字节序和位段定义一定要对着datasheet仔细核对。I2C读回来的数据到底是big-endian还是little-endian不同芯片千差万别。我踩过最痛的坑是在BQ76952上手册说两个寄存器拼成一个16位电压值结果我把高低字节顺序弄反了调了三天才发现其实只是字节序问题。最后也是最重要的一条先看内核现有代码再自己造轮子。内核里已经有大量成熟的I2C客户端驱动、CAN控制器驱动、GPIO驱动很多你想实现的功能都已经有现成实现。读一个最接近的驱动比从零开始写顺畅得多而且代码风格、资源申请释放的规范都能直接学到。做驱动开发这行真正难的其实不是语法而是建立“Linux怎么管理硬件”的心智模型。裸机开发时你直接操作寄存器Linux驱动则多了一层“框架”的约束这个约束不是限制而是保护。我自己从裸机转Linux的时候前两周几乎天天卡在模块加载和设备树匹配上后来踏踏实实按“内核模块 → 设备树 → I2C → CAN”这条路径一路走下来每一步都亲手写一个小模块、加一个节点、读一个寄存器慢慢就通了。写一个小模块看它加载改一个设备树看它匹配模拟一个I2C从设备看它通信把CAN拉起来看它和别的节点对话这种从无到有的过程带来的理解远比看十篇文档深刻。希望这篇文章能让你少走一些我走过的弯路。