Linux设备驱动开发实践:从内核模块、设备树到I2C/CAN总线

发布时间:2026/9/14 4:25:23
Linux设备驱动开发实践:从内核模块、设备树到I2C/CAN总线
做个事儿先说清楚这篇文章不是“Linux驱动从入门到放弃”的劝退帖也不是哪儿都能搜到的hello world教程。我打算用一条完整可复制的路径把Linux设备驱动开发里的几座大山——内核模块、设备树、I2C、CAN——串起来说。你按这条路径走一遍能把“写驱动”这件事从玄学变成工程学。说下背景。我这些年带过不少做单片机的工程师转Linux也带过刚毕业的学生入职嵌入式岗大家最容易卡住的地方其实不在写代码而在不知道一整条链路长什么样。比如很多人上来就啃I2C子系统源码结果被platform_driver、of_match_table、i2c_client这些概念缠住根本读不下去原因是缺了设备树和驱动模型的基础。反过来如果只会在板子上点个灯跑个模块加载又完全够不到真实项目的门槛。所以这篇文章我会按“先跑通内核模块再搞懂设备树最后打通I2C/CAN总线驱动”的顺序往下讲。中间会穿插我实际项目中踩过的坑、排查问题的思路还有可以直接抄作业的代码片段和命令。适合正在学Linux驱动、准备做嵌入式开发或者在真实板子上调外设的同学参考。1. 为什么建议按“模块→设备树→总线驱动”这条路径走1.1 路径设计背后的思考先说结论我把这条路径设计成三段是因为每一段解决的是不同层次的问题缺一个后面都会别扭。第一段是内核模块。它解决的是“驱动跑在什么环境里”的问题。你要理解内核态和用户态的区别、模块的加载卸载机制、printk怎么用、字符设备怎么注册。这些是地基同时也是成就感来得最快的东西——写好一个模块insmod进去dmesg里看到自己的日志打出来你就算迈过第一道门槛了。第二段是设备树。它解决的是“驱动怎么知道硬件长什么样”的问题。真实板卡上每一颗芯片挂在哪个总线、基地址是多少、中断脚是哪个、引脚复用到什么功能这些信息不能写死在驱动源码里否则换一块板子就要改一遍驱动。设备树就是把这些硬件描述从驱动代码中剥离开形成一个独立描述层。第三段才是I2C和CAN这类具体总线设备驱动。有了模块基础你看得懂驱动的生命周期有了设备树基础你才知道i2c_client是怎么自动生成并绑定到驱动的。这时再去看i2c-core、can-dev这些子系统代码思路会清晰得多。1.2 常见学习误区与定位这条路看起来很长但真正让你浪费时间的往往不是知识量而是路线选错。我在面试和带人过程中经常见到这么几种情况只玩过树莓派和Arduino用python调过I2C就以为Linux驱动也是两三行库函数的事上来就碰到“extern”符号找不到、内核头文件不匹配这类问题心态直接崩。一上来就去读Linux内核源码里最复杂的网卡驱动被一堆NAPI、DMA、offload概念劝退。网卡驱动确实是Linux驱动里最顶级的复杂度不适合新手开荒。只会在QEMU里做纯软件实验从不接触真实板子和示波器结果一到现场就不知道怎么定位硬件还是软件的问题。你走完这条路径后应该能对着任何一颗SoC的手册和板子原理图完成从“看原理图”到“写驱动”再到“上板验证”的完整闭环。这基本就是多数嵌入式驱动岗位的日常工作模型了。2. 第一站内核模块——先把最小闭环跑起来2.1 模块框架与编译环境内核模块本质上还是一个C程序但它不依赖libc入口函数是module_init指定的那个函数出口是module_exit指定的函数。它跑在内核空间访问的是内核的符号表。所以第一个模块不用写功能能加载、卸载、打印日志就说明环境没问题。#include linux/init.h #include linux/module.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);编译这个模块不能直接用gcc要用内核提供的Makefile体系obj-m : demo.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules这里最大的坑是内核版本和配置必须匹配。如果你板子上的内核是5.10但编译时用了PC的/lib/modules/5.15加载时会直接报invalid module format。所以交叉编译时要明确指定KDIR指向板卡对应的内核源码树和编译配置。顺带提一句内核源码树必须提前编译过至少要把scripts和Module.symvers生成出来。我之前用Buildroot编系统时有时候图快只编了内核镜像没跑make modules结果后面编驱动就是各种implicit declaration这是编译环境没搭完整的典型症状。2.2 字符设备与自动创建设备节点模块只打印日志肯定不够。驱动最基础的功能是向用户空间提供访问硬件的接口而最常见的接口就是字符设备。这里我很推荐用miscdevice也就是杂项设备。它的好处是内核帮你管理主设备号统一用10号次设备号自动分配省去手动register_chrdev_region的麻烦。#include linux/miscdevice.h #include linux/fs.h #include linux/uaccess.h static ssize_t demo_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { char data[] hello from kernel\n; size_t len strlen(data); if (copy_to_user(buf, data, len)) return -EFAULT; return len; } static long demo_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { /* 实际项目中可以在这里处理控制命令 */ return 0; } static const struct file_operations demo_fops { .owner THIS_MODULE, .read demo_read, .unlocked_ioctl demo_ioctl, }; static struct miscdevice demo_misc { .minor MISC_DYNAMIC_MINOR, .name demo_dev, .fops demo_fops, }; static int __init demo_init(void) { return misc_register(demo_misc); } static void __exit demo_exit(void) { misc_deregister(demo_misc); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);insmod之后/dev/demo_dev这个节点会被自动创建出来。如果你看到设备节点没有出现多半是udev/mdev没有正确处理misc设备的uevent。Buildroot默认用mdev的话要确认/etc/mdev.conf没把它过滤掉。这个阶段的“最小闭环”是指用户空间open/read/write/ioctl一个设备节点内核空间驱动响应这些操作。这样你后续学平台驱动、总线驱动时核心关注点就只剩“设备怎么找到驱动”而不用再花心思在文件操作层的骨架搭建上。2.3 内核调试三板斧内核调试不像用户空间能用gdb直接跟多数情况下第一板斧就是printk。printk的日志级别从KERN_EMERG到KERN_DEBUG一共8级默认控制台只会显示比console_loglevel高的信息。如果你printk(KERN_DEBUG ...)但dmesg和串口都不显示不要慌先看看/proc/sys/kernel/printkcat /proc/sys/kernel/printk # 默认通常是 7 4 1 7第一个数字7表示控制台能显示优先级小于7的日志也就是KERN_DEBUG级别的8被过滤掉了。临时调试可以把该值改成8echo 8 4 1 8 /proc/sys/kernel/printk第二板斧是动态调试。内核很多子系统和驱动都带了pr_debug/dev_dbg这类调试打印默认编译时不输出但运行时可以用dynamic_debug控制。前提是内核配置了CONFIG_DYNAMIC_DEBUG然后用下面的命令打开echo file drivers/i2c/* p /sys/kernel/debug/dynamic_debug/control第三板斧才是正经的调试工具比如/proc/interrupts查看中断统计、/proc/iomem查看设备内存映射这些到调具体设备时会很有用。而遇到内核崩溃也就是oops不要光看最后一行panic要往上翻找到PC指针和Call trace先用addr2line把PC地址翻译成源码行号。这一步能帮你定位到具体是哪一行触发了空指针不然你只能在代码里瞎猜。3. 第二站设备树——硬件的“身份证”就该独立存在3.1 设备树到底解决了什么问题在没有设备树的年代ARM Linux对板级硬件的描述是硬编码在一堆board-*.c文件里的。你换了颗Flash、改了颗PMIC都要改内核代码重新编译一颗新SoC要想支持多块评估板代码里得维护大量板级补丁。这就导致社区里流传一句戏言ARM Linux的板级文件快比机器还多了。设备树Device Tree的引入把硬件描述变成了一种松耦合的文本结构。它本质上是一棵描述硬件拓扑的树每个硬件外设对应一个节点节点里有compatible、reg、interrupts、pinctrl等属性。内核启动时解析设备树生成platform_device或i2c_client等设备对象再和驱动注册的id_table/of_match_table做匹配匹配成功后调用probe。驱动只关心“我兼容哪些硬件”不再关心“硬件具体长什么样”。常有人问我设备树语法是不是另一种编程语言。严格说不是它没有执行逻辑只是数据描述类似JSON/XML的角色。语法简单归简单但坑都在细节里最常见的是改了设备树文件忘了重新编译dtb、dtb烧错分区、或者节点里compatible写法和驱动对不上结果probe就是不被调用还没有任何编译报错。3.2 一个LED节点的完整拆解讲语法不如直接看例子。以最常见的GPIO LED为例设备树节点长这样/ { leds { compatible gpio-leds; status okay; work_led: led0 { label work; gpios gpio4 0 GPIO_ACTIVE_HIGH; default-state on; }; }; };驱动侧内核自带的gpio-leds驱动通过compatible gpio-leds匹配到这个节点然后读取gpios属性拿到gpio4的第0号引脚再根据default-state把引脚拉到高电平或低电平。这里面看起来就五行配置实际牵扯到三层gpio控制器节点gpio4、引脚号0、有效电平GPIO_ACTIVE_HIGH。实际项目中GPIO往往要复用。SoC的同一个引脚可能有UART、I2C、GPIO多种功能到底哪个功能生效由pinctrl子系统决定。设备树里经常看到pinctrl { led_gpio: led_gpio { rockchip,pins 4 RK_PA0 RK_FUNC_GPIO pcfg_pull_none; }; };然后在LED节点里加pinctrl-0 led_gpio。这里有个初学者必踩的坑pinctrl设置如果和别的外设冲突后加载的驱动不会报错但功能就是不对。我之前调I2C时发现SDA引脚波形异常最后查出来是某个GPIO节点把同一引脚复用成了普通输出导致I2C总线一直被拉低。这种问题用示波器看波形会很明显SCL正常SDA永久低电平。3.3 看懂SoC厂商的dtsi继承关系设备树文件分为dtsiSoC级和dts板级。dtsi是芯片原厂提供的描述SoC内部外设例如UART、I2C控制器、DMA默认状态往往是disabled因为同一颗SoC在不同板卡上未必全部引出。板级dts需要include对应的dtsi然后决定哪些外设使能怎么配置引脚。以瑞芯微RK3568这类常见方案为例你的板级设备树通常长这样/dts-v1/; #include rk3568.dtsi i2c0 { status okay; clock-frequency 400000; sensor48 { compatible some,vendor-sensor; reg 0x48; interrupt-parent gpio3; interrupts RK_PA5 IRQ_TYPE_EDGE_RISING; reset-gpios gpio3 RK_PA6 GPIO_ACTIVE_LOW; }; };看懂这个结构后你会明白一个事拿到一块新板子第一件事不应该是急着写代码而是去读它的设备树看看这颗SoC的哪些控制器被使能了、挂在哪个地址上、中断引脚是哪个。很多时候所谓“驱动适配”其实就是根据原理图在设备树里补节点、调引脚、配时钟一行C代码都不用改。调试设备树也有几个实用命令。板子上直接看当前生效的设备树可以挂载debugfs或者用/proc/device-treels /proc/device-tree/ cat /proc/device-tree/model这个目录下的结构就是运行时设备树展开后的样子。如果你想反编译dtb文件PC上装dtc工具用dtc -I dtb -O dts xxx.dtb就能还原出dts源码排查“烧进去的dtb到底改没改对”特别好用。我调试时习惯在Makefile里加一条target每次编完dtb自动反编译一份存下来对比检查比肉眼盯十六进制靠谱得多。4. 第三站I2C设备驱动——把总线通信吃透4.1 I2C协议速记与一个完整读写时序I2C是嵌入式里出场率最高的低速总线之一一颗MCU板子上挂个eeprom、温湿度传感器、加速度计、OLED屏几乎全是I2C。它只有两根线SCL时钟线、SDA数据线都是开漏结构加上拉电阻所以空闲时两根线都是高电平。我习惯用一个不太严谨但很好记的类比I2C像一个带门禁的小区。主机是物业每个从设备是一栋楼7位地址就是楼栋号。SCL是节拍器所有通信都跟着它跳动SDA是对话内容但只有在SCL为高时才能被采样。通信开始前主机把SDA从高拉低这期间SCL保持高就是START条件相当于敲了门。结束后主机让SDA从低变高也是SCL为高时发生就是STOP条件相当于关门。一次完整的寄存器读取时序大概是这样的主机发START。主机发送7位从机地址1位写标志0等待从机ACK。主机发送要读取的寄存器地址等待ACK。主机再次发送START也就是repeated START。主机发送7位从机地址1位读标志1等待ACK。从机输出该寄存器的数据主机在每字节后回ACK最后一个字节要回NACK。主机发STOP。为什么中间要repeated START因为很多从机芯片在连续操作时必须保持总线占用否则可能丢失状态。内核里i2c_transfer的msgs数组里放两条消息第一条是写寄存器地址第二条是读数据驱动框架会自动处理这个时序。如果你在用户空间用i2c-tools模拟也能看到相同的效果。选择i2c-tools还是内核驱动取决于应用场景和性能要求。可参考下表场景推荐方案原因调试、产测、手动验证i2c-tools命令式操作最快确认硬件链路产品功能低速率且无并发i2c-dev 用户程序开发快不涉及内核编译产品功能需要中断/并发/DMA内核驱动内核API提供原子性和调度保证4.2 驱动框架与关键API对照真正到了内核驱动层面I2C设备驱动核心是i2c_driver结构体#include linux/i2c.h static int sensor_probe(struct i2c_client *client) { struct device *dev client-dev; u8 id; /* 读取芯片ID做确认 */ id i2c_smbus_read_byte_data(client, 0x0f); dev_info(dev, chip id: 0x%02x\n, id); return 0; } static void sensor_remove(struct i2c_client *client) { /* 释放资源 */ } static const struct of_device_id sensor_of_match[] { { .compatible some,vendor-sensor }, { } }; MODULE_DEVICE_TABLE(of, sensor_of_match); static struct i2c_driver sensor_driver { .probe sensor_probe, .remove sensor_remove, .id_table NULL, .driver { .name some_sensor, .of_match_table sensor_of_match, }, }; module_i2c_driver(sensor_driver);这个驱动里的probe函数什么时候被调用就是第3.3节设备树里那棵sensor48节点所描述的i2c_client生成并匹配成功后。整个链条是设备树节点 → i2c-core解析生成i2c_client → 根据compatible匹配i2c_driver → 调用probe。所以compatible必须两边一致少了哪一个probe都不会执行。I2C通信API有四个层次我整理过自己的选用规律i2c_master_send/i2c_master_recv适合只发或只收的简单场景。i2c_transfer可以一次传多条消息适合需要repeated START的场景比如读寄存器。i2c_smbus_read_byte_data/i2c_smbus_write_byte_data适合8位寄存器地址的器件背后帮你封装成两条消息代码最简洁。i2c_mux专用API如果总线上挂了多路选择器比如PCA9548要用i2c_mux框架不能手动改地址。调试I2C设备驱动时我有个固定动作先不看驱动代码用i2c-tools确认硬件链路。因为如果芯片地址写错或虚焊驱动写得再对也白搭。4.3 用户空间i2c-tools硬件问题先在这里验明正身i2c-tools是我调试I2C外设最依赖的命令集合它能在不写驱动的情况下直接访问I2C总线# 列出系统里所有I2C总线 i2cdetect -l # 扫描0号总线上的从设备-y跳过确认 i2cdetect -y 0 # 读取0x48地址0x0f寄存器的值 i2cget -y 0 0x48 0x0fi2cdetect扫不到设备但原理图上明确画了这颗芯片时我总结过排查顺序先量SDA和SCL对地电压正常空闲状态应该是上拉电平大概3.3V或1.8V取决于上拉电源。如果SDA一直是低电平优先怀疑有设备把总线拉死了常见原因是某个从设备地址冲突或虚焊。用示波器抓START条件看SCL拉高时SDA是不是有下降沿。没有起始条件说明总线根本没被驱动。确认地址对不对。7位地址常见表示法是0x48但有些芯片手册用8位地址写0x90不转换就会差一位。我印象最深的一次是调一颗EEPROM怎么都读不到ACKi2cdetect超时但示波器却看到有波形。折腾了大半天最后拿放大镜才看出是芯片的SDA引脚虚焊视觉上像焊上了实际上没吃锡。从那以后我调I2C必带上放大镜和示波器也建议大家先信硬件再怀疑软件。如果你用的是SSD1306这类OLED屏也可以用i2cset直接往显存里写数据先确认屏幕和总线没问题再写驱动。这样能有效区分“屏幕没点亮”是因为驱动没写好还是硬件链路的问题。5. 第四站CAN控制器驱动与SocketCAN5.1 为什么Linux CAN外设走SocketCANCAN总线和I2C/UART最大的区别在于它不是传统的“主从读写”模型而是一种多主、广播式的报文协议。总线上每个节点都能随时发帧帧ID决定了优先级多个节点同时发送时通过ID仲裁决定谁先发非破坏性仲裁是CAN的招牌特性。这种模型天然类似于网络通信所以Linux社区很早就定了一个方向把CAN做成一个网络协议族叫SocketCAN。对用户空间来说CAN设备不是/dev/ttyCAN0而是网络接口can0。你用socket(AF_CAN, SOCK_RAW, CAN_RAW)就能收发原始报文。这么做的好处是复用了一整套网络工具链例如ifconfig/ip、tcpdump风格的分析工具以及网络命名空间、防火墙等特性。我之前带过一个人他在别的RTOS上做CAN一直用字符设备模型每收一帧都要读一次驱动用户空间还得自己解析ID和长度写得特别痛苦。切换到SocketCAN后他第一次看到ip link set can0 up type can bitrate 500000就把控制器起来了直接用candump抓包半天就上手了。5.2 设备树中的CAN节点与驱动骨架CAN控制器在设备树里的节点和I2C控制器类似不同SoC的寄存器名和属性不同。以常见的M_CAN或FlexCAN为例节点大致长这样can1 { status okay; pinctrl-0 can1_rx can1_tx; pinctrl-names default; /* 有些SoC还需要配置时钟和中断 */ };CAN的引脚复用也得靠pinctrl完成所以我在第3.2节讲的pinctrl概念在这里直接就会用到。调CAN时如果发现发送超时或者看不到报文先查引脚复用确认CAN_TX和CAN_RX对应引脚确实被配置成了CAN功能而不是GPIO。驱动侧CAN控制器驱动要注册一个net_device核心结构体是can_priv。必要实现包括open/stop、do_set_bittiming、do_start/do_stop这些net_device_ops里的回调同时还有中断处理、错误状态上报等。因为CAN控制器一般都有硬件发送邮箱和接收FIFO驱动还要配合can_rx_offload来做NAPI式收包减轻中断风暴。这部分代码量不小新人不建议上来就自己从零写控制器驱动除非你手里芯片没有任何官方或社区支持否则先把主线内核自带的驱动裁剪适配好才是正路。实际项目里更多的情况是驱动已经在BSP里了你只需要配置设备树、确认引脚、调bitrate。这也是我更推荐先跑通SocketCAN再回看源码的原因。全流程跑通后再去读drivers/net/can里对应芯片的驱动理解接收路径、错误处理收益会高得多。5.3 上板实测从ip link到candump上了真实双节点之后验证CAN链路最快的方式如下# 一台设备配置bitrate并启动 ip link set can0 down ip link set can0 type can bitrate 500000 ip link set can0 up # 另一台同样配置后监听 candump can0 # 第一台发送一帧ID123的数据 cansend can0 123#DEADBEEF如果candump里看不到数据先不要查驱动重点查物理层确认两个节点都接了120欧姆终端电阻或者使用带终端电阻的CAN分析仪。高速CAN总线两端必须有终端电阻否则信号反射会导致错误帧。用万用表量CAN_H和CAN_L之间的电压静态时应该在2V左右因为CAN_H约2.5VCAN_L约2.5V差分电压接近0。发送时差分电压会拉大。用示波器分别抓CAN_H和CAN_L波形看是不是一高一低互补差分信号明显。如果两个引脚量出来完全一样可能是CAN收发器坏了或接线接反。还有一个高频坑是bitrate不一致。两边都是500k但实际晶振误差大会出现大量错误帧。可以用ip -details link show can0查看当前错误计数或直接看ip -s -d link show can0这个命令会输出bus error、RX overrun等统计。错误计数不断增长基本就是波特率不匹配或物理层问题而不是驱动逻辑问题。6. 系统裁剪、性能调优与高频问题排查6.1 从“能跑”到“跑得好”驱动打通只是第一步真实产品里还得考虑系统裁剪和性能调优。我见过不少项目功能都调好了但上电启动要十几秒或者CPU占用率莫名高查下来都是设备树里一堆用不到的外设还开着或者某颗传感器的轮询线程在空转。裁剪的第一步是审视设备树。把所有没有用到的外设节点全部status disabled包括调试串口外的多余UART、未用的I2C控制器、未使用的SDIO/PCIe接口这样可以减少内核启动时对它们的初始化降低中断注册和电源管理负担。注意禁用节点不是删除节点因为SoC级dtsi里的节点可能会被其他节点引用。第二步是裁剪内核配置。通过menuconfig关掉不需要的文件系统、驱动、网络协议栈特性。这一步需要结合产品实际需求比如纯数据采集设备用不到NFS、无线网卡和蓝牙直接关掉能省不少内存和启动时间。裁剪前强烈建议先用内核自带的savedefconfig导出当前有效配置备份好现状再动手。性能调优方面比较有效的动作有查看中断分布cat /proc/interrupts确认外设中断均衡到多核或者绑定到指定CPU。用perf top或ftrace查看内核热点。CAN总线压力大时重点看软中断ksoftirqd的占用必要时用NAPI、threaded IRQ或硬中断绑核来优化。根据实时性要求考虑preempt_rt或内核RT补丁但这是另一个大话题尽可能先不引入除非产品对实时性有硬性指标。6.2 高频问题速查表我把这些年Linux驱动开发中高频出现的问题汇总成一张表你可以直接截图存着用现象可能原因排查思路insmod报Invalid module format内核版本不匹配、编译器版本差异、内核配置不一致确认KDIR指向板卡对应内核树查看uname -r重新make modules_preparemodprobe找不到模块模块没安装到/lib/modules下、depmod未更新make modules_install后执行depmod -a设备节点不出现udev/mdev未处理、misc device注册失败dmesg看注册日志确认CONFIG_DEVTMPFS且已挂载probe没被调用compatible不匹配、设备树未生效、status disabled确认设备树被正确解析用ues dtc反编译dtb检查of_match_tableI2C读数据恒为0xFF设备地址不对、上拉电阻异常、从机未上电i2cdetect扫描示波器抓SDA/SCL电平CAN大量错误帧波特率不匹配、终端电阻缺失、CAN_H/L接反ip -s link show can0看错误计数量CAN_H对CAN_L电压dmesg一直刷类似timeout中断配置不对、设备忙、pinctrl冲突cat /proc/interrupts看中断计数确认设备树interrupts属性排查问题时我有一条原则先从外部到内部从硬件到软件。量电压、看波形、确认设备树、加载模块、用工具验证最后才是怀疑驱动代码。倒过来查经常会把一个虚焊问题查成一部“驱动被迫背锅”的连续剧。写在最后的一点个人体会这条路走完一遍后我有一个很深的感触Linux驱动开发其实是一门“系统解释学”。你在设备树里看懂硬件描述在总线框架里理解设备与驱动的匹配在中断和定时器里感知整个系统的节拍。每一步都不是孤立的节点和节点之间通过compatible、通过pinctrl、通过中断号紧密咬合。带项目这几年我越来越觉得对新手最有帮助的不是收藏多少篇帖子而是手边有一块能反复折腾的板子再加上一条完整的路径图。按“内核模块→设备树→I2C/CAN”这条路走完哪怕中间多踩几个坑花的时间也是值得的因为你会建立起属于自己的调试直觉。最后分享一个小技巧把常用的调试命令写成shell函数放进~/.bashrc比如dmesg -w、cat /proc/interrupts、i2cdetect -y 0这三个我一天能敲几十遍。省下来的时间够你多抓几次示波器多读两页芯片手册了。