Linux驱动开发入门:从内核模块到设备树与I2C/CAN驱动实战
发布时间:2026/9/14 15:06:53来源:尧图网络
干嵌入式Linux驱动这行经常有人问我同一个问题新手到底该从哪学起是先把内核模块写好还是先啃设备树又或者直接上手I2C、CAN这类具体总线设备的驱动我的答案一直是——这不是选A还是选B的问题这三者本来就是一整条链路。内核模块解决代码怎么进内核设备树解决硬件长什么样I2C/CAN驱动解决数据怎么和设备交换。你把这三级台阶依次踩实再看任何一款芯片的SDK、任何一块板子的BSP都会觉得脉络清晰很多。这篇文章就把这条路径完整走一遍模块、设备树、I2C驱动、CAN驱动到最后落到工程化时要注意的系统层面问题。想入门Linux驱动或者想把RK3568、IMX6ULL这类板子的外设真正跑起来的朋友可以把这条主线当个参照。1. 先把内核模块这关过了驱动代码是怎么进内核的1.1 为什么要从模块而不是直接啃驱动框架入手很多教材喜欢一上来就让你写一个字符设备再叠加各种中断、锁、等待队列结果新手往往被一堆并发机制劝退。我建议反过来先透彻理解模块这个东西本身。模块本质是一段可以被内核动态装载和卸载的代码.ko文件就是编译产物。驱动、文件系统、网络协议、安全模块底层形态都是模块所以它是所有内核功能的最小载体。更关键的是模块机制回答了一个基础问题你的代码是怎么跑到内核地址空间里去的。insmod命令最终会走sys_init_module系统调用内核把模块的二进制段搬进内存做重定位再执行它的module_init函数。理解了这个过程后面probe函数为什么会被调用、设备树节点又是怎么和驱动挂钩的你都不会觉得玄学。1.2 一个真正能跑的字符设备模块长什么样我用一个最简单但真实可用的miscdevice模块来演示。选miscdevice而不是完整字符设备是因为它省掉了主设备号分配的麻烦适合整个生命周期只有几十行代码的测试场景。#include linux/module.h #include linux/fs.h #include linux/miscdevice.h #include linux/uaccess.h #define DRV_NAME demo_dev static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char msg[] hello from driver\n; if (*ppos sizeof(msg)) return 0; if (count sizeof(msg) - *ppos) count sizeof(msg) - *ppos; if (copy_to_user(buf, msg *ppos, count)) return -EFAULT; *ppos count; return count; } static const struct file_operations demo_fops { .owner THIS_MODULE, .read demo_read, }; static struct miscdevice demo_dev { .minor MISC_DYNAMIC_MINOR, .name DRV_NAME, .fops demo_fops, }; static int __init demo_init(void) { return misc_register(demo_dev); } static void __exit demo_exit(void) { misc_deregister(demo_dev); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name);配套的MakefileKbuild风格的写法是固定的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编出来之后sudo insmod demo_dev.ko cat /proc/misc | grep demo echo test /dev/demo_dev cat /dev/demo_dev sudo rmmod demo_dev整个过程走通你就算亲手完成了一次代码进入内核的闭环。注意copy_to_user到用户空间的这段逻辑它是驱动和用户态数据的边界不能直接memcpy这是内核开发和普通应用开发在思维上一个很重要的区别。1.3 模块机制里的几条潜规则关于模块经验上有几个点经常让新手翻车MODULE_LICENSE(GPL)不是可选项。如果你不声明GPL很多导出符号尤其EXPORT_SYMBOL_GPL修饰的你就用不了而且加载时内核会打印module license unspecified taints kernel虽然能跑但调试时一切异常都会被怀疑是它引起的。__init和__exit不是装饰。标记了__init的函数在内核完成初始化后它占用的内存块会被释放掉这也是内核启动后内存占用看起来比镜像文件小的原因之一。如果你的__init函数被中断上下文或异步路径持有引用迟早会崩出UNUSED_SYMBOL类的诡异问题。insmod和modprobe职责不同。modprobe会做依赖解析自动加载宏模块依赖链上的其他模块insmod不会。所以正式项目里部署驱动建议写进/etc/modules-load.d配合modprobe加载而不是靠insmod一条条手动怼。模块这一级说到底解决的是代码如何在内核里驻留的问题它还不关心硬件长什么样。接下来设备树要解决的就是后者。2. 设备树来了硬件信息不再藏在代码里2.1 为什么要有设备树在老一代ARM Linux里每块板子的硬件信息是写死在arch/arm/mach-xxx/board-xxx.c里的比如注册一个platform_device、填一个i2c_board_info结构体、在代码里拼接platform_data。问题在于随着SoC型号和板级配置的组合爆炸内核里板级文件越来越臃肿而且每次换几个引脚都要改C文件重新编译内核。设备树Device Tree把这个逻辑反转了内核代码不再关心这块板子上有什么只管在compatible名字匹配上时进入probe。硬件怎么组织由一份文本化的树形描述文件决定。dts是源码dtsi是公共头文件式的包含文件dtc负责把它们编译成dtb二进制bootloader把dtb传给内核后内核解析成一个内存中的设备树。它的本质不是配置文件而是关于硬件的数据。这句话理解到位了你就不会天真地以为改设备树是在调参。2.2 解剖一个设备树节点拿一个真实场景说吧。手头一块RK3568板子想外挂一块SSD1306 OLED屏I2C1总线上。设备树里要定义一个子节点i2c1 { status okay; clock-frequency 400000; ssd1306: ssd13063c { compatible solomon,ssd1306; reg 0x3c; reset-gpios gpio0 5 GPIO_ACTIVE_LOW; }; };几个字段拆开看compatible是驱动匹配的钥匙格式一般为厂商,型号。它前面允许有多个字符串内核会按顺序匹配第一个被驱动的of_match_table命中就生效。reg在I2C子节点语境下就是7位从机地址。0x3c对应ssd1306在I2C地址引脚接法下的常见值注意这个地址不含读写位。status置okay才生效如果原dtsi里定义为disabled这一步就是死是活的分水岭。reset-gpios这种是外设私有属性由对应的驱动解析内核不会替你校验合法性。驱动侧的匹配表要写对这个字符串static const struct of_device_id ssd1306_of_match[] { { .compatible solomon,ssd1306 }, { } }; MODULE_DEVICE_TABLE(of, ssd1306_of_match);这里有个细节MODULE_DEVICE_TABLE宏把of_device_id导出到一个特殊段里modpost阶段会根据它生成modules.alias文件modprobe才能在没有显式指定模块名的情况下通过设备树的compatible字符串自动加载驱动。实验环境里你可能觉得无所谓但要做产品镜像这个表绝对不能漏。2.3 改完设备树之后怎么验证、踩过哪些坑编译设备树通常用内核的dtc工具链make dtbs # 单文件编译也可以 scripts/dtc/dtc -I dts -O dtb -o rk3568-evb1-v10.dtb rk3568-evb1-v10.dts启动后确认设备树是否生效两个入口最常用/proc/device-tree以目录结构展示当前运行时设备树。/sys/firmware/devicetree/base和上面的内容基本同源。更多时候你遇到的问题不是看不到节点而是节点存在但驱动没跑。我总结下来排前三的坑有compatible对不上。设备树里写的字符串和驱动of_match_table里的任何一个字符串必须完全一致包括大小写和厂商前缀分隔符。reg和实际总线地址不符。I2C设备把自己的地址设成了0x50你节点里写0x3cprobe永远不会被调用。父节点被status disabled禁用了。子节点写成okay也没用总线不使能节点连挂载的机会都没有。我自己调试RK3568时碰到过一个典型问题I2C1明明在设备树里使能了但用i2cdetect扫描时总线一片--最后发现是复用的引脚在另一个pinctrl节点里被同方向配置盖掉了。设备树里pinctrl-0覆盖的是同一pin的复用状态重复配置不会报错只会悄悄覆盖。排查这类问题用cat /sys/kernel/debug/gpio看引脚的claim情况往往比看设备树更快。3. I2C设备驱动走一遍从节点到寄存器的完整链路3.1 i2c_driver的骨架里藏着设备树匹配的全流程当设备树里有了一个I2C子节点且I2C控制器驱动已经注册好之后内核的I2C核心会在总线枚举时创建一个i2c_client。这个client的来源完全由设备树节点决定。所以编写一个I2C设备驱动的核心就是注册一个i2c_driver并保证它的匹配表能命中节点。我以一个温度传感器驱动来演示它骨架紧凑足够说明问题#include linux/module.h #include linux/i2c.h static int tmp117_probe(struct i2c_client *client) { int raw; raw i2c_smbus_read_word_data(client, 0x00); if (raw 0) return raw; /* 这里省略数据换算 */ dev_info(client-dev, register value: 0x%04x\n, raw); return 0; } static const struct i2c_device_id tmp117_id[] { { tmp117, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, tmp117_id); static const struct of_device_id tmp117_of_match[] { { .compatible ti,tmp117 }, { } }; MODULE_DEVICE_TABLE(of, tmp117_of_match); static struct i2c_driver tmp117_driver { .probe tmp117_probe, .id_table tmp117_id, .driver { .name tmp117, .of_match_table tmp117_of_match, }, }; module_i2c_driver(tmp117_driver); MODULE_LICENSE(GPL);module_i2c_driver这个宏等价于i2c_add_driver加i2c_del_driver的组合省几行样板代码。probe触发的路径有两条设备树节点里的compatible命中of_match_table或者在老式的i2c_board_info机制里通过i2c_device_id命中。现代内核基本走前一条但id_table建议保留它同时也是modprobe生成i2c别名所需的依据。3.2 寄存器读写i2c_smbus和regmap要怎么选拿到i2c_client之后最底层的读写手段是i2c_transfer它不关心具体格式你想发什么字节就发什么字节。但大多数传感器寄存器访问格式是有套路的——一个寄存器地址字节后面跟数据。于是就有了i2c_smbus_read_byte_data和i2c_smbus_write_byte_data这类封装多字节场景还有i2c_smbus_read_block_data。这些封装函数用起来直观但有个问题如果有PMBus、SMBus时序要求严格的设备某些SMbus快命令时序和普通I2C有细微差异需要确认设备是否兼容。而regmap抽象则可以遮蔽掉这些细节它把寄存器的读写操作变成统一的map接口static const struct regmap_config tmp117_regmap { .reg_bits 8, .val_bits 16, .max_register 0x0F, };然后通过devm_regmap_init_i2c(client, tmp117_regmap)获得regmap实例后续regmap_read、regmap_write、regmap_update_bits。regmap带来的额外好处是cache和bus同步逻辑都比较成熟还省掉了不少手动加锁的功夫。我个人的选型原则单一寄存器的离散传感器直接i2c_smbus带多个寄存器块、可能要开cache的无脑regmap。3.3 I2C驱动调试三板斧i2cdetect、i2cdump、逻辑分析仪拿到一块新板子最怕的是驱动加载失败。失败前的第一道检查不是看驱动日志而是确认I2C总线上到底有没有这个设备。i2c-tools里这套命令是无脑起手式i2cdetect -l # 列出系统中的I2C总线 i2cdetect -y 0 # 扫描总线0上的所有7位地址 i2cdump -y 0 0x3c # dump 从机0x3c 的寄存器i2cdetect扫出UU表示设备已被内核驱动占用扫出3c表示总线上有设备但还没有驱动绑定。两者都不是你也别急着怀疑寄存器地址先用示波器或逻辑分析仪挂在SCL/SDA上看波形。没有ack信号多半是地址不对或设备没上电看不到时钟多半是总线被某个节点禁用了。在这类问题上逻辑分析仪一锤定音的效率比反复改设备树高得多。另一个容易忽略的点I2C上拉电阻。几十k欧姆级别的上拉配大电容负载时边沿会变得很钝高速模式下一旦握手失败通讯会进入时好时坏、偶尔NACK的状态。板上已经坐实了I2C设备却没反应优先检查这两件事从机地址及供电、SCL/SDA上拉。4. CAN设备驱动SocketCAN把总线演进成了socket4.1 为什么单独把CAN拎出来讲在很多人的印象里CAN是汽车总线离通用嵌入式有点远。但近几年工业控制、机器人、储能设备里CAN接口越来越常见Linux内核里CAN设备的驱动形态和I2C/SPI这类谁主谁从的总线有很大差异值得单独走一遍。CAN是广播式多主总线节点间通过报文ID仲裁不需要片选线也没有传统意义上的主从设备。Linux从很早起就做了个漂亮的设计把CAN抽象成网络设备用户空间用socket去收发包。这个设计叫SocketCAN它彻底统一了不同CAN控制器厂商的接口差异。你在应用层写代码时不用关心底层是瑞萨的R-Car CAN还是Microchip的MCP2515外挂SPI它们对应用来说就是一个can0网络接口。4.2 CAN驱动在内核里的框架与设备树描述以一颗很经典的外挂控制器MCP2515为例它在SPI总线上设备树长这样spi0 { status okay; mcp2515: can0 { compatible microchip,mcp2515; reg 0; clocks mcp251x_clk; interrupt-parent gpio0; interrupts 4 IRQ_TYPE_LEVEL_LOW; spi-max-frequency 10000000; vdd-supply vcc3v3; }; };MCP2515走SPI所以它的父节点是SPI控制器但它在Linux驱动模型里是一个net_device驱动注册的接口是alloc_candev加register_candev。驱动内部要维护struct can_priv里面包含位时序参数、设备时钟、TX/RX队列、错误状态统计。当控制器收到一帧报文中断处理函数里先把数据从控制器FIFO读出来组成struct can_frame最后通过netif_rx把skb交给网络协议栈。用户空间的操作就会变成很自然的socket流程ip link set can0 up type can bitrate 500000 sample-point 0.875 candump can0 cansend can0 123#DEADBEEFint s socket(PF_CAN, SOCK_RAW, CAN_RAW); struct sockaddr_can addr { .can_family AF_CAN, .can_ifindex if_nametoindex(can0) }; bind(s, (struct sockaddr *)addr, sizeof(addr));使用CAN_RAW协议收到的报文是struct can_frame格式头部带ID和数据长度不经过IP协议栈。这套接口背后就是Linux网络驱动的通用收发路径所以CAN驱动开发者和网卡驱动开发者共用了同一套技能树。4.3 CAN调试最容易踩的三个工程坑CAN驱动的坑很多不在驱动代码里而在总线物理层和时序配置上。位时序和波特率。ip link set can0 up type can bitrate 500000这一句背后是can_bittiming的计算它要根据控制器时钟和你要的波特率算出同步段、传播段、相位缓冲段各是多少。很多控制器驱动还支持sample-point参数采样点太靠前或太靠后都会导致在总线较长时误码率上升。你两端的CAN设备如果波特率设置不一致总线上的ACK位会出错表现是发送方不断重发dmesg里错误计数器蹭蹭涨。终端电阻。CAN总线两端需要120欧姆终端电阻。只接一个节点调试时没接终端电阻往往也能跑通短距离但一旦线长超过半米波形反射会导致CRC错误。这个坑我见过很多人排查半天最后还是示波器一看波形过冲才发现。收发器使能脚。不少CAN收发器有STBY或EN引脚拉低才进入工作模式。设备树里如果没把对应的GPIO配置对驱动能正常注册、can0也能up但报文就是发不出去、也收不到。排查顺序建议是ip -details link show can0看控制器状态、candump -e can0看错误帧、最终回到收发器的物理层检查。5. 驱动写完只是第一步把这条路径装进真实产品5.1 模块与内核的版本匹配vermagic是硬门槛开发环境能用不代表产品环境能跑。一个很常见的现象PC上编出来的.ko放到开发板上insmod直接报version magic 6.1.0-generic should be 6.1.0-imx。版本魔法字符串必须完全一致包含内核版本和SMP、PREEMPT这类配置。所以交叉编译驱动时KDIR指向的必须是目标板上同款内核版本的构建目录不能拿主机/lib/modules下的build目录凑合。如果内核源码是用make modules_prepare配置的编出来的模块内部符号版本也和实际运行内核不同。稳妥做法是要么整树编译、要么在目标板的SDK环境里编译别混合。5.2 动态debug、tracepoints和debugfs把黑盒变成灰盒驱动跑起来后最需要的是观察力。printk当然是起点但产品代码里不能到处留打印。更现代的做法是dynamic_debug它允许你在运行时开关某个文件的某个打印点echo module tmp117 p /sys/kernel/debug/dynamic_debug/control echo func tmp117_probe p /sys/kernel/debug/dynamic_debug/control然后是tracepoints内核里已经布满了大量预置的跟踪点I2C、CAN、网络协议栈都能通过trace-cmd或perf观察。比如排查I2C传输问题时/sys/kernel/debug/tracing/events/i2c下的tracepoint会记录每次i2c_transfer的地址和长度这比在驱动里加打印更省事也不会引入时序改变。驱动发布之后debugfs里暴露寄存器状态、错误计数、内部队列长度这类信息对现场支持极有价值。写驱动阶段就想着把调试接口留好比出了问题再打补丁体面得多。5.3 一条能救命的工作习惯先看内核绑定文档再动设备树Linux内核源码树的Documentation/devicetree/bindings/目录是设备树书写的最终裁判。比如i2c子目录下会写明某某设备有哪些必填属性、哪些可选属性、compatible的厂商前缀是否合法。内核维护者对设备树节点与驱动的对应关系抠得非常细不看文档直接照葫芦画瓢很容易写出一份运行时能跑但违规范的设备树。这类问题在长时间维护时会反噬升内核版本后某个原本忽略的警告变成错误节点就不被识别了。另外一条经验拿到原厂SDK时尽量用i2c1 { ... }这种方式往既有节点里补充子节点别去直接改原厂dtsi主文件。一来方便后续同步上游代码二来出问题时容易对比基线。我在产品项目里维护设备树默认分两层原厂dtsi一层不改板级dts一层加定制将来无论追内核还是换板子都有退路。这几年带人写驱动我最大的体会是驱动开发最终拼的不是API背得多熟而是对数据从哪里来、到哪里去的链路感。模块是入口设备树是地图I2C/CAN是路径两侧的交通工具出问题时不要凭空猜顺着链路一层层看往往比把所有打印堆进probe里高效得多。最后分享一个压箱底的小习惯我每接触一个新平台会给它的设备树单独建一个git分支所有调试经验都记录在commit信息里包括这次改节点是为了解决什么现象。半年后再回来维护你会发现这些commit信息比文档还好用。
网站建设高端定制企业官网