尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Linux驱动开发全路径:内核模块、设备树与I2C/CAN实战

Linux驱动开发全路径:内核模块、设备树与I2C/CAN实战 做Linux设备驱动开发这些年我经常被新手问同一个问题学了内核模块也看了设备树文档真到板子上调I2C、CAN还是两眼一抹黑问题到底出在哪这个问题背后恰恰是驱动开发最核心的门道——它不是某一个孤立的知识点能解决的而是一条从“内核加载机制”到“硬件描述语言”再到“总线协议栈”的系统路径。这篇文章我就沿着一条真实可行的路径来拆解从内核模块的加载与字符设备实现到设备树的节点配置与匹配机制再到I2C和CAN两大典型总线的驱动编写与调试把整条链路串起来讲。适合刚入门嵌入式Linux的工程师也适合写过几个模块但始终没打通“板级外设”的开发者。1. 先摸清系统路径驱动开发的三层认知1.1 驱动不是写代码是打通三层栈很多人一上来就找“驱动怎么写”这其实是个伪问题。驱动开发真正难的地方在于你得同时维护三套心智模型硬件手册上的寄存器时序、内核里的设备驱动框架、以及应用层看到的接口行为。这三层就像快递链路——硬件是发货仓库内核驱动是中转站应用层是收货人。任何一环没对好包裹就到不了用户手里。举个我早年踩过的例子。那时我在一块ARM板子上调一个I2C触摸屏驱动代码明明load进去了probe也执行了可应用层就是读不到坐标。折腾了两天最后发现根本问题不在驱动而在设备树里把I2C设备地址写错了驱动里用的是8位地址设备树里填的却是7位地址差了整整一位I2C控制器压根没找到那个触摸IC。所以你看单看某一个环节都是“对的”合起来整个链路就是断的。所以这篇文章一开始我想先给你一个总览。驱动开发本质上是在走一条固定的“系统路径”内核模块机制解决“代码怎么进内核、怎么跟内核对话”的问题字符设备/网络设备框架解决“应用层拿什么接口来访问硬件”的问题设备树解决“同一份内核怎么适配不同板卡”的问题I2C/CAN子系统解决“设备挂在总线上驱动怎么与硬件会话”的问题把这四层打通不管以后是写SPI驱动、USB驱动还是PCIe驱动底层思路都是相通的。只是总线不一样子系统的API不一样而已。1.2 为什么用I2C和CAN作为解剖样本I2C和CAN这两条总线恰好覆盖了驱动开发的两种典型形态选它们做样本性价比极高。I2C是典型的板内总线。它引脚少SCLSDA两根线、速率低标准模式100kbps快速模式400kbps、通信逻辑简单非常适合用来学习“总线设备驱动”的完整套路。我们日常用的触摸屏、温湿度传感器、OLED显示屏、EEPROM绝大多数都是I2C设备。你学会了I2C驱动就等于掌握了一大票传感器驱动的写法。CAN则是典型的板间总线也是工业控制和车载领域的老大哥。它用差分信号传输抗干扰能力强速率能上到1Mbps甚至更高CAN FD能到5Mbps以上最特别的是它有完整的错误检测和仲裁机制节点之间不需要主机统一调度。从驱动形态上看CAN控制器在Linux里不是以字符设备呈现的而是以网络设备net_device的方式接入SocketCAN协议栈。也就是说写CAN驱动你接触的是另一套完全不用的内核框架。一个I2C一个CAN一个字符设备型驱动一个网络设备型驱动正好把Linux驱动开发最核心的两种模型都覆盖了。再加上设备树把硬件描述统一起来这套组合拳打完你对Linux驱动应该会有一种“通”了的感觉。2. 内核模块起步从hello world到字符设备2.1 模块的骨架生命周期先搞清楚内核模块Kernel Module是Linux提供的一种动态加载代码的机制。它最大的价值是不用每次改代码都重新编译整个内核、重新烧写整个镜像而是像给系统“打补丁”一样把一段代码塞进运行中的内核里。模块加载之后它运行在内核态拥有最高权限同时也意味着一个野指针就可能让整个系统panic。这点务必时刻记着。一个最简模块长这样#include linux/init.h #include linux/module.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 minimal demo module);这段代码里有两处容易被忽略的细节。第一个是__init和__exit这两个宏它们不只是装饰。带__init的函数在加载完成后它占用的内存会被释放掉因为初始化代码之后不会再用了带__exit的函数在模块编进内核而不是作为模块加载时会被直接扔掉因为无法卸载。这种细节看似微小但能体现你对内核内存管理的理解。第二个是MODULE_LICENSE(GPL)。如果你声明的许可不是GPL那么内核里所有导出的GPL符号EXPORT_SYMBOL_GPL导出的函数你就都用不了。很多第三方驱动一开始加载报“Unknown symbol”就是因为许可证没配对。实际项目中这个声明也能省掉很多内核维护者对你的“合规性”质疑。对应的Makefile也非常固定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这里M$(PWD)是告诉内核源码树的构建系统要单独编译一个外部模块这个模块的源码在当前目录。KERNELDIR指向内核源码编译目录在x86主机上直接指向系统自带的内核头文件即可交叉编译时则需要指向你板子对应的内核源码树这一点后面第6章展开讲。模块的生命周期简单说就是insmod加载→ module_init → 业务逻辑 → rmmod卸载 → module_exit。加载和卸载的日志默认在dmesg里用tail -f /var/log/kern.log或者直接看dmesg都能跟踪。2.2 模块参数与内核版本匹配模块不是死的。很多时候我们需要在加载时动态调整行为比如指定IO引脚号、修改轮询间隔。内核提供了现成的参数机制static int irq_num 11; static char *dev_name mydemo; module_param(irq_num, int, 0644); module_param(dev_name, charp, 0644); MODULE_PARM_DESC(irq_num, IRQ number); MODULE_PARM_DESC(dev_name, device name);加载时就能这样传参insmod demo.ko irq_num17 dev_namemydemo1参数文件的权限位0644指的是/sys/module/demo/parameters/下对应文件的操作权限设成0644就表示允许运行时通过sysfs修改。驱动里建议加上MODULE_PARM_DESC把每个参数的用途写清楚。这个习惯在团队协作时特别重要否则几个月后你自己都得靠猜。再强调一个新手几乎必踩的坑模块的“版本魔术”。内核模块在编译时会记录自己对应的内核版本、编译器版本、内核配置比如是否强制模块签名、是否开启SMP这些信息合起来构成“vermagic”。如果你用A内核的源码编出的模块强行insmod到B内核大概率会得到insmod: ERROR: could not insert module demo.ko: Invalid module format回到dmesg里看通常是version magic不匹配。这种情况下别硬扛老老实实把模块放到对应内核源码树下重新编译一遍。做嵌入式开发时这一步尤其容易搞错因为你主机上的内核和你板子上的内核往往不是同一个版本。2.3 把模块变成真正的设备驱动字符设备实战光能加载卸载还不够驱动最终要给应用层使用。最基础的方式就是注册一个字符设备在/dev下生成一个节点应用层通过open/read/write来访问硬件。一个带字符设备的模块核心工作有三件分配设备号、初始化cdev并添加、创建device节点。下面是我在实际项目中常用的骨架代码#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DEVICE_NAME mydemo #define CLASS_NAME mydemo_class static dev_t dev_num; static struct cdev my_cdev; static struct class *my_class; static struct device *my_device; static char kernel_buf[128] hello from kernel module\n; static int my_open(struct inode *inode, struct file *filp) { printk(KERN_INFO mydemo: open called\n); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { size_t msg_len strlen(kernel_buf); if (*off msg_len) return 0; if (len msg_len - *off) len msg_len - *off; if (copy_to_user(buf, kernel_buf *off, len)) return -EFAULT; *off len; return len; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { if (len sizeof(kernel_buf)) len sizeof(kernel_buf) - 1; if (copy_from_user(kernel_buf, buf, len)) return -EFAULT; kernel_buf[len] \0; return len; } static struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, }; static int __init my_init(void) { int ret; ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(mydemo: alloc_chrdev_region failed\n); return ret; } cdev_init(my_cdev, my_fops); ret cdev_add(my_cdev, dev_num, 1); if (ret 0) goto err_cdev_add; my_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(my_class)) { ret PTR_ERR(my_class); goto err_class_create; } my_device device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(my_device)) { ret PTR_ERR(my_device); goto err_device_create; } pr_info(mydemo: loaded, major%d minor%d\n, MAJOR(dev_num), MINOR(dev_num)); return 0; err_device_create: class_destroy(my_class); err_class_create: cdev_del(my_cdev); err_cdev_add: unregister_chrdev_region(dev_num, 1); return ret; } static void __exit my_exit(void) { device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); pr_info(mydemo: unloaded\n); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple char device demo);这里最值得强调的是copy_to_user和copy_from_user千万不要图省事直接对用户态指针解引用。内核态和用户态的地址空间天然隔离直接访问轻则返回垃圾数据重则触发oops。内核里对这种跨态拷贝有严格的安全检查copy_*_user系列函数内部会做access_ok验证是标准做法。加载这个模块后你会在/dev/mydemo看到一个设备节点。用echo hello /dev/mydemo往里面写数据再用cat /dev/mydemo读出来就能验证整条通路。如果你在实际工作中发现加载之后/dev下没有自动生成节点大概率是因为没有使用class_createdevice_create这套组合。老旧的写法里有人手动mknod但mknod需要你手工计算主次设备号而且开机流程里很容易丢失所有主流方案早就改用自动创建设备节点的机制了。2.4 模块开发阶段的三个起步误区第一个误区是printk刷屏式调试。在内核里printk不是不可以但要控制等级。开发期可以用pr_info、pr_debug出货前一定要清理或降级否则控制台会被内核日志刷爆拖慢系统。更重要的是打印本身不能替代对代码逻辑的推演尤其是并发场景下多打几行日志可能就改变了时序。第二个误区是不检查返回值。alloc_chrdev_region失败要继续用cdev_addcdev_add失败还要继续device_create——这种“侥幸式”写法在应用开发里可能只是bug在内核开发里就是系统崩溃的前兆。内核API的每个错误路径都要认真处理该goto goto该释放释放。第三个误区是忽略并发。你的驱动可能会被多个进程同时打开中断和底半部也随时可能触发。如果不加锁竞态条件是必然会来的。新手阶段最容易犯的错误是以为“我的设备只有一个用户”但内核不会替你做这个假设。初学可以先从简单的原子操作、mutex、spinlock用起形成“共享数据必须加锁”的肌肉记忆。3. 设备树配置让驱动知道硬件长什么样3.1 为什么要引入设备树设备树Device Tree对新手来说是整个链路里最抽象的环节。它不像是代码因为它没有执行逻辑但它是代码的“输入数据”影响驱动能不能被正确加载和初始化。设备树解决的核心问题是“硬件描述”和“内核代码”的解耦。在ARM Linux早期每换一块板子内核源码的arch/arm/mach-xxx目录里就要新增一个board文件用C语言代码描述板子上有哪些外设、中断怎么接、GPIO怎么配置。这种方式的坏处很明显代码越积越多内核越来越臃肿每家芯片厂商的板文件还互相打架。设备树出来后硬件描述变成了纯数据。同一份内核镜像只要配不同的设备树二进制文件dtb就能适配不同板卡。硬件变了改dts文件重新编译dtb就行内核代码完全不用动。这套思路和前后端分离有点像——内核是逻辑设备树是配置两边的改动互相隔离。设备树的源文件是dtsdevice tree source经过dtc编译器生成dtbdevice tree blobbootloader启动内核时把dtb加载进内存并传给内核。芯片厂商通常会提供基础dtsisoc级描述板卡厂商再通过#include方式叠加板级dts这种分层方式让不同开发层次的人能各改各的。3.2 设备树基础语法节点、属性、寻址设备树最外层是一个根节点/下面挂子节点节点下面还能挂孙节点整棵树的结构跟文件系统非常像。每个节点有若干属性属性就是key-value对。/dts-v1/; / { compatible myvendor,myboard, myvendor,myboard-v2; model My Board V2; soc { #address-cells 1; #size-cells 1; uart0: serial10000000 { compatible myvendor,uart; reg 0x10000000 0x1000; interrupts 0 32 4; status okay; }; }; };这里有几个概念新手容易绕晕compatible是最关键的匹配属性。格式一般是“厂商,型号”它的核心作用是和驱动里的of_match_table做匹配。系统启动时内核遍历设备的节点根据compatible值找到对应的驱动然后调用驱动的probe函数。reg表示地址和长度。它的取值取决于父节点里#address-cells和#size-cells的设定。address-cells为1说明地址用1个u32表示size-cells为1说明长度用1个u32表示。这种成套的约定在I2C总线下也成立只是I2C地址里地址-cells通常是1而且只填地址不填长度。interrupts描述中断。里面的数字含义由父节点的interrupt-controller决定最常见的是中断域 中断号 触发类型。status取值一般是“okay”或“disabled”。工程中特别常见的一种“设备树改了没生效”的情况就是忘记把一个节点从disabled改成okay。芯片原厂提供的默认dtsi里很多外设节点默认都是关闭的为的是省电和避免资源冲突板级dts里必须显式打开。3.3 compatible匹配机制驱动和设备树怎么对上设备树只是数据真正让它“活”起来的是内核的platform驱动和ofopen firmware匹配机制。我们在驱动里写的of_match_table和设备树节点里的compatible二者内容一致时驱动就会被找到。举个例子设备树里写i2c0: i2c10020000 { compatible myvendor,i2c; reg 0x10020000 0x1000; ... };驱动里就需要定义static const struct of_device_id my_i2c_of_match[] { { .compatible myvendor,i2c, }, { } }; MODULE_DEVICE_TABLE(of, my_i2c_of_match); static struct platform_driver my_i2c_driver { .driver { .name my_i2c, .of_match_table my_i2c_of_match, }, .probe my_i2c_probe, .remove my_i2c_remove, }; module_platform_driver(my_i2c_driver);在匹配时内核会有多个优先级ACPI信息、设备树compatible、platform总线上的id_table、最后才是driver name里的字符串。ARM平台现在绝大多数走的是设备树compatible这条路。所以调试的基本逻辑是设备树里改了compatible驱动里也要同步改两边不一致的时候probe不会被触发但系统不会给你明显报错只会在/sys/bus/platform/devices下看到一个没有driver绑定的设备而已。还有一个容易忽视的点compatible的厂商前缀最好和真实的厂家保持一致。比如你用的是Solomon的SSD1306屏幕驱动芯片设备树里写solomon,ssd1306驱动of_match_table里也写solomon,ssd1306。这种规范看起来是小事但涉及后续内核上游合入、社区维护、文档索引时前缀不统一会造成很大的麻烦。3.4 手把手配置一个I2C设备的设备树节点下面用场景说话。假设板子上有一颗SSD1306的OLED屏挂在I2C1总线上从设备地址是0x3C还有一根复位引脚接到GPIO1组的A5脚低电平有效。完整的设备树节点大概长这样i2c1 { status okay; clock-frequency 100000; ssd1306: oled3c { compatible solomon,ssd1306; reg 0x3c; reset-gpios gpio1 5 GPIO_ACTIVE_LOW; width 128; height 64; }; };这里有三个点值得展开说。第一i2c1是引用语法表示往已经定义好的i2c1节点里追加内容。这种写法在板级dts中非常常见比全部重写一遍友好得多。不过要确认一点i2c1控制器本身的status如果是disabled你在外面把它挂上的子节点也是无效的必须先把控制器打开。第二reg 0x3c。这里填的是7位I2C地址不带读写位。很多新手拿着芯片手册上的“0x78”或者“0x7A”往里填那其实是8位地址包含R/W位需要右移一位得到7位地址。这是I2C地址最容易搞混的点后面调试章节还会再强调。第三reset-gpios不是I2C协议的一部分它是板级私有属性定义这个GPIO用于复位屏幕。驱动里通过devm_gpiod_get来获取这个GPIO操作它的时机在芯片手册里有明确要求——上电时序里必须先拉低复位延时若干毫秒后再释放让屏幕跑内部上电流程。类似这种“非总线”信息设备树的作用就是把它们也统一收编进节点描述里。3.5 设备树调试三板斧第一板斧是启动时看内核是否解析到了你的节点。在bootloader参数里加上earlycon和printk.devkmsgon内核启动日志会打印设备树注册过程。找不到想要的节点时优先查dtb到底烧对了没有很多时候你改了一下午dts结果bootloader加载的还是老dtb。第二板斧是系统起来后查/proc/device-tree。这个目录是设备树在运行时展开的虚拟文件系统每个节点对应一个目录属性对应文件。比如ls /proc/device-tree/ cat /proc/device-tree/aliases/ethernet0如果这里看不到你加的节点说明dtb本身就没更新成功跟驱动代码无关。第三板斧是用dtc反向编译。你手头拿到别人编好的dtb想知道里面到底有什么可以用dtc -I dtb -O dts -o out.dts board.dtb反编译出来的dts可读性很高用来确认实际生效的硬件配置非常方便。这块建议内化为习惯——凡是“设备树不生效”先反编译再看compatible再查status大概率能定位问题。4. I2C驱动实战从设备树节点到probe回调4.1 I2C子系统结构adapter、client、driverI2C总线在Linux里被抽象成三个角色i2c_adapter代表I2C控制器本身也就是硬件上的I2C主机负责产生SCL时钟管理SDA数据线。i2c_client代表挂在总线上的一颗从设备比如一片EEPROM、一颗传感器、一块OLED屏。它通常由设备树自动生成包含地址、compatible等信息。i2c_driver代表驱动代码描述这个设备如何被初始化、如何通信。它通过i2c_driver结构体向系统注册。三者的关系可以用一句话概括adapter是路client是路边的门牌号driver是门后面住的“人”。系统启动时会扫描每一条总线上已经注册的client然后拿client里的compatible和id_table去和所有i2c_driver做匹配匹配成功就调用probe。在设备树里I2C子节点描述的是client信息。你在I2C总线下写一个子节点内核启动时自动为它创建对应的i2c_client。这是整个“设备树到驱动”衔接的关键一环——没有设备树节点驱动就算加载了probe也不会被调用。4.2 从零编写一个I2C客户驱动还是用SSD1306 OLED当例子。这个驱动框架非常典型可以作为绝大多数I2C从设备驱动的模板#include linux/module.h #include linux/i2c.h #include linux/delay.h #include linux/of_gpio.h #include linux/gpio/consumer.h #define SSD1306_CMD 0x00 #define SSD1306_DATA 0x40 struct ssd1306_dev { struct i2c_client *client; struct gpio_desc *reset_gpio; u16 width; u16 height; }; static int ssd1306_write_cmd(struct ssd1306_dev *dev, u8 cmd) { u8 buf[2] { SSD1306_CMD, cmd }; struct i2c_msg msg; msg.addr dev-client-addr; msg.flags 0; msg.len sizeof(buf); msg.buf buf; return i2c_transfer(dev-client-adapter, msg, 1); } static int ssd1306_write_data(struct ssd1306_dev *dev, u8 *data, size_t len) { u8 *buf; struct i2c_msg msg; int ret; buf kzalloc(len 1, GFP_KERNEL); if (!buf) return -ENOMEM; buf[0] SSD1306_DATA; memcpy(buf 1, data, len); msg.addr dev-client-addr; msg.flags 0; msg.len len 1; msg.buf buf; ret i2c_transfer(dev-client-adapter, msg, 1); kfree(buf); return ret; } static int ssd1306_init_display(struct ssd1306_dev *dev) { /* 典型SSD1306初始化序列按芯片手册配置 */ ssd1306_write_cmd(dev, 0xAE); // display off ssd1306_write_cmd(dev, 0xD5); // clock divide ssd1306_write_cmd(dev, 0x80); ssd1306_write_cmd(dev, 0xA8); // multiplex ratio ssd1306_write_cmd(dev, dev-height - 1); ssd1306_write_cmd(dev, 0xD3); // display offset ssd1306_write_cmd(dev, 0x00); ssd1306_write_cmd(dev, 0x40); // start line ssd1306_write_cmd(dev, 0x8D); // charge pump ssd1306_write_cmd(dev, 0x14); ssd1306_write_cmd(dev, 0x20); // memory mode ssd1306_write_cmd(dev, 0x00); ssd1306_write_cmd(dev, 0xA1); // segment remap ssd1306_write_cmd(dev, 0xC8); // COM scan direction ssd1306_write_cmd(dev, 0xDA); // COM pins ssd1306_write_cmd(dev, 0x12); ssd1306_write_cmd(dev, 0x81); // contrast ssd1306_write_cmd(dev, 0xCF); ssd1306_write_cmd(dev, 0xD9); // precharge ssd1306_write_cmd(dev, 0xF1); ssd1306_write_cmd(dev, 0xDB); // vcomh deselect ssd1306_write_cmd(dev, 0x40); ssd1306_write_cmd(dev, 0xA4); // resume to RAM content ssd1306_write_cmd(dev, 0xA6); // normal display (not inverted) ssd1306_write_cmd(dev, 0xAF); // display on return 0; } static int ssd1306_probe(struct i2c_client *client) { struct ssd1306_dev *dev; struct device *dev_ptr client-dev; int ret; dev devm_kzalloc(dev_ptr, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-client client; i2c_set_clientdata(client, dev); ret device_property_read_u16(dev_ptr, width, dev-width); if (ret) dev-width 128; ret device_property_read_u16(dev_ptr, height, dev-height); if (ret) dev-height 64; dev-reset_gpio devm_gpiod_get_optional(dev_ptr, reset, GPIOD_OUT_LOW); if (!IS_ERR(dev-reset_gpio)) { gpiod_set_value(dev-reset_gpio, 1); msleep(10); gpiod_set_value(dev-reset_gpio, 0); msleep(30); } ret ssd1306_init_display(dev); if (ret 0) { dev_err(dev_ptr, failed to init display\n); return ret; } dev_info(dev_ptr, ssd1306 probed, %ux%u\n, dev-width, dev-height); return 0; } static void ssd1306_remove(struct i2c_client *client) { dev_info(client-dev, ssd1306 removed\n); } static const struct of_device_id ssd1306_of_match[] { { .compatible solomon,ssd1306 }, { } }; MODULE_DEVICE_TABLE(of, ssd1306_of_match); static struct i2c_driver ssd1306_driver { .driver { .name ssd1306, .of_match_table ssd1306_of_match, }, .probe ssd1306_probe, .remove ssd1306_remove, }; module_i2c_driver(ssd1306_driver); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(SSD1306 OLED driver);这个驱动里涉及几个重点API值得逐个说清楚i2c_transfer是最底层的I2C传输函数。它接受一个i2c_msg数组数组里包含要发往从机的数据。一次i2c_transfer可以完成一个完整的“写地址-写数据”或者“写地址-读数据”序列在时序上能确保原子性。对于简单的寄存器读写你也可以用更上层的i2c_smbus_read_byte_data和i2c_smbus_write_byte_data它们把命令、地址、数据封装得更规整。但对于像SSD1306这样需要先发控制字节0x00命令/0x40数据再接数据的设备我更推荐手工构造msg因为控制字节是设备自定义的SMBus接口处理不了这种私有协议。devm_gpiod_get_optional和device_property_read_u16这两组接口一个取GPIO一个取设备树自定义属性。它们都是“设备资源管理”风格的API好处是probe失败或remove时框架自动帮你释放资源不需要手工清理省去了一堆错误处理代码。这套devm_前缀的API在内核里越来越普遍新驱动尽量遵循这个习惯。4.3 I2C通信的细节与踩坑记录第一个坑地址不匹配。芯片手册上写的从机地址如果是0x78通常是8位地址含读写位换算成7位地址就是0x3C而Linux的i2c_client-addr保存的是7位地址对应到设备树里就是reg 0x3C。搞混这个一切通信都会失败。调试时优先用i2cdetect工具扫描总线看到哪个地址有回应基本上就是真实从机地址。第二个坑总线挂死。I2C总线是开漏输出没有设备拉低时SCL、SDA应该都被上拉到高电平。如果设备树里i2c控制器没配置外设时钟或者板子上上拉电阻没焊总线会残留在低电平或波形异常任何通信都做不了。遇到这种情况先量波形再查配置不要急着改代码。第三个坑NACK信息很隐晦。I2C读不到数据时i2c_transfer的返回值可能是负的错误码也可能返回1表示成功发送一个消息但数据全是0xFF。我看到过不少人在那空转半天最后发现从机芯片压根没上电或者供电电压不对。硬件问题永远排在软件调试之前——先量VCC再查GND最后再聊协议。第四个坑时钟频率和上升时间。I2C标准模式100kbps和快速模式400kbps对上拉电阻的阻值是有要求的。板级上拉电阻取太大上升沿太缓时钟损伤会导致传输错误取太小灌电流又偏大影响寿命。这个在原理图设计阶段就该考虑好到了驱动阶段能做的只是选对clock-frequency属性。第五个坑别忘了总线锁死后的恢复。I2C设备偶发异常时SDA可能被从机拉死不放常见的恢复手段是让主机在SCL上先发送9个时钟脉冲从机状态机就会复位。Linux的i2c子系统不一定自动帮你做这件事所以当你看到“bus is stuck”之类的日志时要有意识地考虑硬件恢复机制。5. CAN总线驱动从控制器到SocketCAN的完整链路5.1 CAN协议最核心的机制与Linux的方案CANController Area Network相比I2C最大的不同在于两点。第一它是多主总线任何节点在总线空闲时都能发起发送通过标识符仲裁来决定谁有发送权。第二它自带复杂的错误检测和恢复机制单节点故障不会拖垮整条总线。CAN的帧结构里最常用的是数据帧。经典CAN数据帧最多携带8字节数据标识符可以是11位标准帧或29位扩展帧。发送节点发出SOF位后所有节点同时向总线写标识符标识符数值越小优先级越高仲裁获胜的节点继续发送其他节点自动转为接收。这套机制意味着总线上不需要主节点仲裁非常适合汽车、工控这类实时性要求高的场景。Linux对CAN的支持方式非常巧妙——它把CAN控制器抽象成了网络设备。你没有看错就是和以太网卡类似的net_device。应用层通过PF_CAN协议族、SOCK_RAW类型创建socket绑定到某个CAN接口比如can0然后就能像收发UDP报文一样收发CAN frame。这种设计的好处是CAN驱动的开发可以直接复用网络子系统庞大而成熟的框架包括NAPI、中断下半部、netlink配置等。5.2 CAN控制器的驱动框架与核心流程编写一个CAN控制器驱动的思路和写一个网卡驱动非常接近。主要做这几件事分配并注册一个net_device用alloc_candev或alloc_canfd_netdev填充struct can_priv配置位时序bit timing、控制器模式、状态等实现net_device_ops里的ndo_open、ndo_stop、ndo_start_xmit在ndo_open里申请中断、配置硬件、启动控制器在ndo_start_xmit里把struct can_frame中的数据写入硬件发送缓冲区在中断处理函数里读取接收到的帧通过netif_rx或can_rx_offload上交协议栈关注错误状态变化尤其是bus-off后的恢复流程以SoC内置CAN控制器为例比如IMX6ULL、STM32MP1、RK3568这类芯片都自带CAN外设设备树节点大概长这样can0 { status okay; pinctrl-names default; pinctrl-0 can0_pins; };然后在板级dts里定义引脚复用can0_pins: can0-pins { /* 具体字段按SoC的pinctrl格式来这里示意 */ u-boot,dm-pre-reloc; };驱动侧注册CAN设备也类似static int can0_probe(struct platform_device *pdev) { struct net_device *netdev; struct my_can_priv *priv; netdev alloc_candev(sizeof(*priv), TX_ECHO_SKB_MAX); if (!netdev) return -ENOMEM; priv netdev_priv(netdev); priv-dev pdev-dev; /* 解析platform资源寄存器地址、中断号、时钟等 */ netdev-netdev_ops my_can_netdev_ops; netdev-flags | IFF_ECHO; ret register_candev(netdev); if (ret) goto err_register; return 0; }alloc_candev会额外分配一个struct can_priv和一个struct can_berr_counter它们在netdev_priv里可以拿到。这个结构体里有大量CAN控制器相关的配置字段比如bittiming、data_bittiming、ctrlmode、state。驱动里要根据硬件手册初始化这些参数尤其是位时序换算。5.3 SocketCAN应用层调试工具链驱动注册成功接口起来之后验证工作通常在应用层完成。SocketCAN提供了一套非常完整的命令行工具日常调试基本靠它们就够了。先把接口配置起来ip link set can0 type can bitrate 500000 ip link set up can0第一条命令设置CAN接口的波特率为500kbps。第二条命令把接口up起来这时驱动里的ndo_open会被调用控制器进入工作状态。监听总线上的所有报文candump can0主动发送一帧数据cansend can0 123#DEADBEEF123是11位标准帧的ID#后面是数据字节用十六进制表示。如果要用扩展帧可以这样cansend can0 1A2B3C4D#0102030405060708ID超过0x7FF工具会自动按扩展帧处理。如果想做压力测试可以用cangen按规则随机生成报文或者用cansequence检查是否有丢帧、乱序cangen can0 -g 10 -I 42A -L 8 -n 10000 cansend can0 1#AAAAAAAA另外ip -details link show can0可以查看当前CAN接口的详细参数包括波特率、采样点、状态等这个命令在排查“双方都配了但通信不上”的问题时非常有用因为它能直接看到两个节点是否真的配置在同一个速率上。5.4 CAN驱动调试的重难点CAN驱动调试里最让人头疼的往往是这几个问题。第一是波特率不一致。两个节点一个配500k一个配250k总线上会出现大量错误帧通信完全瘫痪。排查时先看ip -details link show can0再检查总线上的所有节点配置。第二是总线终端电阻。CAN总线两端都必须接120欧姆终端电阻中间节点不接。很多实验桌上出现信号反射、波形畸变、误码率升高都是因为有人忘了在总线末尾接电阻。第三是bus-off恢复。CAN节点在发送错误超过255次后会进入bus-off状态主动脱离总线。恢复策略可以和具体应用相关有自动恢复也有需要重新初始化控制器的。内核的can子系统和驱动里都要能处理CAN_STATE_BUS_OFF事件否则节点在总线上可能一掉就回不来。第四是发送超时和错误帧计数。调试时打开canfdconfig或者查看ip -stats link show can0的收发统计如果错误帧数量持续增长要怀疑物理层问题终端电阻、线缆质量、节点数而不是驱动逻辑问题。我曾经因为测试时用了两根过长的杜邦线导致CAN信号边沿恶化错误帧飙升到几十万换屏蔽双绞线后立刻恢复正常。6. 完整实操串讲模块、设备树、总线的协同调通6.1 开发环境准备与交叉编译前面的章节把各个部件拆开讲了这一节把它们串起来。不管你是用QEMU模拟还是真实板卡开发环境大致是一样的。我习惯的基本步骤是准备一台Ubuntu主机安装交叉编译工具链准备好板子对应的内核源码树并且至少编译过一次因为外部模块编译需要用到内核的生成头文件和符号表。比如RK3568平台常用的交叉工具链是aarch64-linux-gnu-系列内核源码目录假设在~/kernel-rk3568。编译外部模块的Makefile可以这样改obj-m : mydemo.o ssd1306.o ARCH : arm64 CROSS_COMPILE : aarch64-linux-gnu- KERNELDIR : /home/user/kernel-rk3568 PWD : $(shell pwd) all: $(MAKE) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) -C $(KERNELDIR) M$(PWD) modules注意这里的ARCH和CROSS_COMPILE缺一不可。如果只写-C而不指定架构make会默认用x86的配置编出来的模块在板子上几乎肯定加载不了。交叉编译和本地编译的区别新手最容易忽略的就是这两个变量。6.2 一次完整的驱动启动调试实录假设我们现在要在RK3568板子上驱动一颗I2C接口的SSD1306 OLED屏完整的流程是这样的第一步查原理图确认OLED挂在哪个I2C控制器上。RK3568有多组I2C通常I2C1、I2C2等分布在不同的引脚组上。确认好之后在内核dts里找到对应的i2c节点。第二步在dts里追加子节点i2c1 { status okay; clock-frequency 100000; ssd1306: oled3c { compatible solomon,ssd1306; reg 0x3c; reset-gpios gpio1 RK_PA5 GPIO_ACTIVE_LOW; width 128; height 64; }; };这里RK_PA5是Rockchip体系里的引脚宏相当于之前章节里的gpio1 5表达方式不同实质一致。第三步编译dtb。RK3568的dts编译一般通过内核的make命令make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- dtbs生成的dtb在arch/arm64/boot/dts/rockchip/目录下找到对应你板子的文件名比如rk3568-evb.dtb把它替换到板子的boot分区里。第四步编译驱动模块。把前面第4.2节那个ssd1306.c放到一个干净目录用上面带交叉编译参数的Makefile编译出ssd1306.ko。第五步启动板子加载模块insmod ssd1306.ko如果设备树里compatible匹配正确dmesg里应该能看到probe打印。我习惯在probe函数里加上一行dev_info(client-dev, ssd1306 probed\n)这是最快确认“设备树与驱动是否成功对接”的方法。没有这行打印先怀疑设备树再怀疑驱动注册。第六步应用层验证。写一个小程序或者在板子上用i2c-tools直接验证i2cdetect -y 1看看0x3c地址是否有回应。如果有回应说明总线通信正常没有回应检查上拉、供电和地址。6.3 系统裁剪优化与性能提示驱动调通只是第一步产品化还要考虑系统裁剪和性能。嵌入式Linux里镜像体积和启动时间都是稀缺资源裁剪优化和驱动开发是相辅相成的。内核裁剪用make menuconfig把不需要的驱动、文件系统、网络协议尽量去掉。这里要留个心眼裁剪要分层做先裁掉确定没用的再逐个验证。我曾经为了省体积把某个I2C控制器的驱动直接编成了模块结果系统起来后没自动load导致触摸屏瘫痪。裁剪不是越狠越好而是“可用的最小集”。模块化的驱动要配合init脚本或者udev规则确保真正用到时能加载起来。rootfs的裁剪用Buildroot或者Yocto会更高效。Buildroot上手快配置文件里选好需要的包一条命令就能出镜像。Yocto灵活性强但学习曲线陡峭适合团队化持续集成。个人经验是如果只是产品验证Buildroot足够如果要长期维护、复杂定制Yocto值得投入。性能调优方面驱动开发常关注的几点中断是否频繁触发、是否可以用NAPI合并收包、是否该启用DMA、锁的粒度是否需要优化。CAN和I2C这类总线数据量不算大瓶颈往往不在带宽而在中断频率和调度延迟。用perf工具抓一下软中断和硬中断的分布一般能快速定位热点。比如CAN接收中断频繁时可以考虑用can_rx_offload机制把处理推到NAPI的软中断里减少对应用进程的抢占。7. 高频问题与排查技巧速查7.1 模块加载与编译问题现象可能原因处理建议insmod报Invalid module format版本magic不匹配用板子对应的内核源码重新编译模块检查uname -rUnknown symbol xxx符号未导出或模块未加载依赖确认符号用EXPORT_SYMBOL导出先插依赖模块加载后无/dev节点缺少class_create/device_create确认驱动里是否调用了这两组API编译报缺少头文件内核源码没编过、或ARCH未设先在内核目录执行make prepare确认交叉编译参数probe不执行compatible不匹配或statusdisabled反编译dtb确认节点与驱动of_match_table完全一致7.2 设备树与总线通信问题现象可能原因处理建议/proc/device-tree里找不到节点dtb没更新或节点被裁剪确认bootloader加载的是哪个dtb重新编译并烧写i2cdetect全部无回应上拉电阻/供电/地址问题先量SCL/SDA波形确认供电再用i2cdetect扫描i2cdetect看到地址少了一位7位/8位地址混淆核对芯片手册设备树里统一填7位地址CAN接口起不来位时序配置错误或控制器不支持检查ip -details link show can0核对硬件手册CAN通信错误帧暴增波特率不一致或缺少终端电阻统一波特率总线两端各接120欧电阻偶发通信超时中断丢失或锁竞争用perf看中断分布检查驱动加锁粒度多调试缓冲7.3 几个值得记住的排查习惯遇到驱动异常先看dmesg别急着改代码。内核在很多环节都会留下日志线索比如“i2c transfer error”“can bus off”“unable to handle kernel paging request”。这些日志字少但往往一针见血。遇到设备和驱动“看似匹配成功了但功能不正常”的情况优先确认硬件基础条件供电、时钟、复位引脚、中断是否进得来。驱动开发里我见过最多的“灵异事件”一半以上最终都定位到“芯片没上电”“复位脚悬空”这类硬件问题。所以示波器和万用表是驱动工程师最可靠的调试伙伴不比gdb逊色。遇到设备树改动后不生效养成“先反编译dtb确认内容”的反射。常常有人在源码里改了一堆dts烧到板子上的却是旧dtb排查很久才发现源头就没对上。遇到内核panic或oops学会看懂栈回溯。PC is at xxx、LR is at xxx这两行能直接告诉你崩溃发生在哪个函数。配合addr2line和内核符号表把地址翻译成源码行号定位速度会快非常多。写在最后的经验驱动开发这条路我摸索了很久才逐渐建立起“系统路径”的概念。回头总结真正让我觉得豁然开朗的节点不是学会了某个函数的用法而是把“模块加载—设备树匹配—总线通信—应用验证”这整条链路的每一步都用实际项目串起来的那一刻。从那以后面对任何新的外设、新的总线我知道该先看什么、再查什么、在哪里找突破口。如果你正在学习或转型做嵌入式Linux驱动我给的建议很朴素不要沉迷看PDF尽早找一块真实板子哪怕只是跑通一个LED驱动也比在文档里泡一个月更有价值。然后沿着“内核模块→设备树→I2C/CAN”这条路径一个节点一个节点地推进。遇到问题不慌按照本文里整理的排查思路一步步来多半都能定位到症结。等这条链路在你手里完整跑通过一遍你对Linux驱动开发的整体理解就真正立起来了。
返回列表