从内核模块到总线驱动:嵌入式Linux驱动开发完整路径解析
2026/9/14 15:08:28 网站建设 项目流程

最近在帮朋友调一块工业控制板卡,主控用的是瑞芯微方案,外设其实很简单:一颗I2C接口的温湿度传感器,一路CAN总线对接电机驱动器。需求听起来不算复杂,但真正动手之后才发现,要把这两个外设在Linux下跑稳,远不是"写个驱动"这么简单——你首先要理解内核模块的加载方式和字符设备框架,接着要过设备树这一关,把硬件拓扑清楚地告诉内核,然后还要分别走通I2C子系统和CAN子系统两条完全不同的技术路径。整条链路走完后回头看,这恰恰是一条从底层内核模块到总线驱动开发的完整学习路径,也是嵌入式Linux驱动开发最典型的一条主线。这篇文章就把这条路径完整拆开,说说每一步该怎么走、为什么这么走、以及实际调试中那些文档里不会写的坑。

1. 入门先搞清楚:内核模块和字符设备框架到底解决什么问题

很多初学者一上来就抱着《Linux设备驱动开发详解》啃,结果死磕在内核源码里出不来。我的建议是先跳出源码,想清楚一个更基础的问题:Linux为什么要搞驱动?说白了就是让用户态的程序能够安全、规范地访问硬件。而驱动开发的第一课,就是学会写一个内核模块。

1.1 模块机制:为什么驱动不能直接编译进内核

模块机制是Linux驱动开发的基石。所谓模块,就是一段可以动态加载到内核的代码,用insmod加载、rmmod卸载,不需要重启系统。这种机制带来的直接好处有两个:第一,开发和调试期间不用反复烧写内核镜像,编译一个.ko文件拷进板子就能测;第二,产品发布后,可以根据实际硬件配置按需加载驱动,减少内核体积和内存占用。

一个最简模块的骨架长这样:

#include <linux/module.h> #include <linux/init.h> static int __init my_driver_init(void) { printk(KERN_INFO "my_driver: module loaded\n"); return 0; } static void __exit my_driver_exit(void) { printk(KERN_INFO "my_driver: module unloaded\n"); } module_init(my_driver_init); module_exit(my_driver_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple example driver");

这里的__init__exit不是摆设。__init标记的初始化函数在调用完成后,内核会把这段内存释放掉,省一点是一点——在嵌入式设备上,这种细节就是实打实的资源节省。MODULE_LICENSE("GPL")也不是随便写的,如果不声明GPL,很多内核导出的符号(EXPORT_SYMBOL_GPL)你是用不了的,最常见的就是各种锁和内存管理接口。

编译模块也不复杂,前提是你有一份和板子上内核版本对应的内核源码树:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules_prepare

然后在驱动目录写一个Makefile:

obj-m := my_driver.o KDIR := /path/to/kernel/source all: $(MAKE) -C $(KDIR) M=$(PWD) modules

1.2 字符设备驱动:先从file_operations说起

模块本身只是载体,驱动真正的工作是向用户态提供操作接口。对于大多数没有复杂协议的外设,比如GPIO控制、简单的数据采集,字符设备是最常见的形式。它的核心就是file_operations结构体——你把硬件操作封装成openreadwriteioctl等函数,用户态的程序用open("/dev/xxx")read(fd, ...)就能访问硬件,内核帮你做了权限检查和资源管理。

注册字符设备的标准流程,我建议用新的cdev接口,而不是老式的register_chrdev。上点儿年代的书和教程还在用register_chrdev,但那东西会占用一整段设备号,不灵活。规范做法是:

#include <linux/cdev.h> #include <linux/fs.h> static int my_major = 0; static struct cdev my_cdev; static dev_t dev_num; static int my_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { /* 将内核缓冲区的数据拷贝到用户态,注意用 copy_to_user */ return count; } static struct file_operations my_fops = { .owner = THIS_MODULE, .open = my_open, .read = my_read, }; static int __init my_driver_init(void) { /* 动态分配设备号 */ alloc_chrdev_region(&dev_num, 0, 1, "my_dev"); my_major = MAJOR(dev_num); cdev_init(&my_cdev, &my_fops); my_cdev.owner = THIS_MODULE; cdev_add(&my_cdev, dev_num, 1); /* 创建/dev节点,这部分通常配合 class_create 使用 */ return 0; }

这里有个细节很多人会忽略:read/write回调里直接访问用户态指针是严重的bug,因为内核态和用户态的地址空间是隔离的,必须用copy_to_usercopy_from_user。曾经见过一个产品代码,直接在驱动里对用户态传进来的指针用memcpy,看似跑通了,但碰上CONFIG_STRICT_DEVMEMCONFIG_HARDENED_USERCOPY开启的内核就会随机崩溃,排查起来非常痛苦。

1.3 为什么还需要platform driver

字符设备框架解决的是"用户态怎么访问硬件"的问题,但还有一个问题没解决:硬件到底在哪里、用什么方式访问?传统做法是在驱动代码里硬编码寄存器地址和中断号,这在x86时代的PCI设备上勉强能用,但在ARM这种SoC百花齐放的世界里就完全行不通了——同样是I2C控制器,i.MX和RK3568的寄存器地址、中断号、时钟配置完全不一样,硬编码意味着每换一个平台就要改一遍驱动代码。

于是有了platform_driver,也就是平台驱动。它的思路是把"设备"和"驱动"分离:设备的信息(寄存器地址、中断号、引脚配置等)由板级描述提供,驱动只负责写操作逻辑。两者通过一个compatible字符串配对。这个"板级描述"在现代Linux内核里就是设备树(Device Tree)。

static const struct of_device_id my_platform_match[] = { { .compatible = "vendor,my-device" }, { } }; static struct platform_driver my_platform_driver = { .probe = my_platform_probe, .remove = my_platform_remove, .driver = { .name = "my_device", .of_match_table = my_platform_match, }, }; module_platform_driver(my_platform_driver);

module_platform_driver是一个封装好的宏,展开后就是把platform_driver_register放进init函数、platform_driver_unregister放进exit函数。probe函数是驱动最重要的入口,它负责获取硬件资源(寄存器地址、中断等)并完成初始化,后面I2C和CAN驱动的内容都围绕probe展开。

2. 设备树详解:硬件描述与驱动代码解耦的桥梁

设备树是ARM Linux开发绕不过去的一道坎。很多写驱动的人把设备树当成"配置文件",哪里不对改哪里,其实没有抓住本质。设备树本质是一种数据结构,用来描述硬件平台"长什么样":有哪些CPU、哪些内存、哪些外设挂在哪个总线地址上、使用哪个中断。它的诞生就是为了替代ARM Linux早期那种堆满板级文件的arch/arm/mach-xxx目录。

2.1 设备树的三个层级:dts、dtsi与dtb

设备树源文件有三种常见后缀,很多初学者搞不清楚它们的区别:

  • .dts:设备树源文件,描述一块具体板卡的全部硬件信息。
  • .dtsi:设备树包含文件,描述一个SoC或一个系列的公共部分,比如RK3568的rk3568.dtsi就定义了这个SoC所有内置控制器的节点,具体板卡只需要#include然后覆盖/追加自己需要的部分。
  • .dtb:编译生成的二进制文件,bootloader(U-Boot)启动内核时会把dtb的物理地址传给内核,内核解析并据此创建platform_device

在编译过程中,dtc(device tree compiler)工具负责将.dts编译成.dtb。如果是在内核源码树里编译,通常在arch/arm64/boot/dts/rockchip/等目录下执行:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs

生成的dtb文件还需要配合你使用的bootloader设置。以U-Boot为例,一般是通过fdt addrfdt move命令确认dtb加载到内存的地址,最终在启动内核时传参。如果你的板子是用自己的打包脚本把kerneldtb打包进一个镜像,同样需要保证dtb的加载地址和bootargs里指定的一致——这个过程我见过最多的问题就是地址对不上,内核启动到一半就死掉或者找不到外设。

2.2 从dtsi到dts:一个I2C节点的完整声明

我们以一个挂在I2C总线上的温湿度传感器为例。假设SoC内部已经有I2C控制器的节点,通常位于rk3568.dtsi里,类似这样:

i2c0: i2c@fe650000 { compatible = "rockchip,rk3568-i2c"; reg = <0x0 0xfe650000 0x0 0x1000>; interrupts = <GIC_SPI 72 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru CLK_I2C0>, <&cru PCLK_I2C0>; clock-names = "i2c", "pclk"; pinctrl-names = "default"; pinctrl-0 = <&i2c0_xfer>; status = "disabled"; #address-cells = <1>; #size-cells = <0>; /* 其它属性 */ };

status = "disabled"是SoC级dtsi里的默认状态,因为SoC集成了多路I2C,板卡具体用了哪几路,需要在板级dts里打开。板级myboard.dts里做两件事:打开I2C0控制器,然后声明挂在它下面的传感器子节点:

&i2c0 { status = "okay"; clock-frequency = <400000>; temp_sensor@48 { compatible = "sht30"; reg = <0x48>; interrupt-parent = <&gpio0>; interrupts = <RK_PB6 IRQ_TYPE_EDGE_FALLING>; }; };

@48是I2C设备节点的unit address,对应设备在I2C总线上的7位从机地址0x48。核内I2C子系统会读取子节点的compatible,和注册的i2c驱动中的of_device_id做匹配,匹配成功就调用驱动的probe函数。reg = <0x48>就是告诉内核,这个设备挂在I2C0上,地址是0x48。

2.3 compatible匹配机制与内核中的of_match_table

理解了设备树节点之后,驱动代码侧其实并不需要自己去解析设备树里的每一个属性——那部分工作由内核的of框架完成。你的驱动只需要提供一个of_device_id数组,声明自己支持哪些compatible

static const struct of_device_id sht30_of_match[] = { { .compatible = "sht30" }, { } }; MODULE_DEVICE_TABLE(of, sht30_of_match);

MODULE_DEVICE_TABLE是个容易被忽略的宏,它产生的别名信息可以让内核在设备树里出现"新设备"时,通过modprobe自动加载对应的驱动模块,而不是要求手动insmod。如果你写了一个设备驱动,烧到板子上却发现probe一直不执行,一种常见原因就是MODULE_DEVICE_TABLE没写,或者系统没有运行depmod生成模块依赖关系。

那probe函数里怎么拿到设备树里声明的资源?一般这么写:

static int sht30_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct device *dev = &client->dev; struct sht30_data *data; struct gpio_desc *reset_gpio; u32 freq; /* 读取时钟频率 */ device_property_read_u32(dev, "clock-frequency", &freq); /* 获取reset引脚,这是基于gpio descriptor的新接口 */ reset_gpio = devm_gpiod_get(dev, "reset", GPIOD_OUT_LOW); /* devm_系列接口在驱动卸载时自动释放资源,强烈建议使用 */ data = devm_kzalloc(dev, sizeof(*data), GFP_KERNEL); ... return 0; }

这里用到的devm_前缀非常关键。传统的驱动要在probe里手动kzalloc,在remove里手动kfree,一旦某个中间步骤出错忘记释放,就是一次内存泄漏。而devm_接口把资源的生命周期绑定到了struct device上,设备释放时自动清理,极大降低驱动出错概率。现代内核开发凡是新写的驱动,基本都推荐用devm_系列接口。

3. I2C子系统:从设备树节点到用户态读写的完整链路

I2C在嵌入式Linux里是使用频率最高的总线之一,几乎所有板载传感器都是I2C接口。Linux的I2C框架设计非常清晰,理解它之后写驱动基本就是套模板。

3.1 I2C框架的三层结构:adapter、client、driver

I2C子系统可以分为三层:

  • Adapter(适配器):对应SoC内部的I2C控制器,负责收发时钟和数据,就是前面设备树里的i2c0i2c1这些节点。每个adapter有一个编号,对应系统里的/dev/i2c-N/sys/bus/i2c/devices/i2c-N
  • Client(客户端):对应挂在I2C总线上的具体设备,比如temp_sensor@48。client描述设备挂在哪个adapter上、从机地址是多少。
  • Driver(驱动):对应我们的I2C驱动,通过i2c_add_driver注册到I2C总线上。内核会遍历该总线上所有的client,把compatibleid_table匹配的client和driver绑定起来,然后调用probe。

这个分层方式很像面向对象里的"接口与实现分离":adapter是谁来提供I2C时序,client是我要跟谁说话,driver是我怎么跟它说话,三者各司其职。

3.2 注册一个I2C驱动:先看probe和remove的职责

I2C驱动的注册代码有固定的套路:

#include <linux/i2c.h> static int sht30_probe(struct i2c_client *client) { struct sht30_data *data; struct device *dev = &client->dev; if (!i2c_check_functionality(client->adapter, I2C_FUNC_I2C)) { dev_err(dev, "I2C functionality not supported\n"); return -EOPNOTSUPP; } data = devm_kzalloc(dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; i2c_set_clientdata(client, data); dev_info(dev, "sht30 sensor probed, addr=0x%02x\n", client->addr); return 0; }

i2c_check_functionality是个值得单独拎出来解释的函数。I2C总线有SMBus、I2C原生读写、块读块写等多种能力,不同adapter实现的能力可能不同。你驱动里要用哪些能力,最好在probe里先检查,否则运行到一半发现硬件不支持,就是个很深的坑。比如老一点的SMBus adapter不支持I2C的快速读写,你的驱动一跑read就超时,查了几天才发现是能力问题。

驱动的核心数据操作无非是两条:i2c_transfer发一个i2c_msg数组,或者用更便捷的i2c_smbus_read/write_*系列函数。对于SHT30这类传感器,常用方式是先发一个命令寄存器地址,再读回数据:

static int sht30_read_data(struct i2c_client *client, u8 *buf, int len) { u8 cmd[2] = { 0x22, 0x36 }; /* SHT30 触发测量命令 */ struct i2c_msg msgs[2] = { { .addr = client->addr, .flags = 0, .len = 2, .buf = cmd }, { .addr = client->addr, .flags = I2C_M_RD, .len = len, .buf = buf }, }; int ret; ret = i2c_transfer(client->adapter, msgs, 2); if (ret != 2) { dev_err(&client->dev, "i2c transfer failed: %d\n", ret); return -EIO; } return 0; }

这段代码最关键的地方在于i2c_transfer的返回值。它返回的是成功传输的i2c_msg数量,不是字节数。如果你的驱动把返回值当成字节数去判断"读多少数据",很可能buf里的数据是空的还自以为成功了。类似的细节也存在于i2c_smbus_read_i2c_block_data,它的返回值才是实际读到的字节数。两者混在一起用的时候特别容易混淆,建议写完之后打印出来验一遍再继续往下写。

3.3 不要在用户态重复造轮子:i2c-dev与i2c-tools

实际调试阶段,我不建议一上来就写内核驱动。很多传感器芯片并不需要每时每刻都在内核态跑,尤其是产品原型验证阶段,用Linux自带的i2c-dev框架在用户态直接操作要快得多。

i2c-dev是一个通用的I2C用户态驱动,它会把adapter导出为/dev/i2c-N,然后你通过ioctl去访问总线上的设备:

#include <linux/i2c-dev.h> #include <sys/ioctl.h> #include <fcntl.h> int fd = open("/dev/i2c-0", O_RDWR); ioctl(fd, I2C_SLAVE, 0x48); /* 设置从机地址 */ write(fd, cmd, 2); read(fd, buf, 6);

命令行工具i2c-tools更是调试利器,几个高频命令:

i2cdetect -y 0 # 扫描I2C0总线上有哪些设备地址 i2cget -y 0 0x48 0x00 # 读取0x48设备寄存器0x00的值 i2cset -y 0 0x48 0x01 0x02 # 写值 i2ctransfer -y 0 w2@0x48 0x22 0x36 r6 # 一次完整的事务:写2字节命令再读6字节

i2cdetect扫描时,如果设备挂在0x48,但板子上另一个芯片的电源没上电或者地址冲突,你会看到奇怪的地址出现。这里一个小经验:扫描到的地址有一个范围标注,UU表示这个地址已经被内核驱动占用了。如果你驱动里注册的设备恰好显示UU但probe没执行,说明client创建成功但driver没匹配上,问题就出在compatibleid_table上。

3.4 I2C驱动的完整加载流程:从设备树到dev目录

走完上面的步骤,整条I2C驱动链路基本可以串起来了:

  1. 内核启动,解析dtb,找到i2c0节点和子节点temp_sensor@48
  2. I2C控制器对应的adapter驱动在drivers/i2c/busses/里(比如i2c-rk3x.c),probe后注册一个adapter。
  3. adapter注册时,内核会遍历它的子节点,为每个子节点分配i2c_client
  4. 我们的i2c-sht30驱动注册时,I2C总线尝试把client和driver匹配。
  5. 匹配成功,调用sht30_probe,驱动获得设备访问权。

这个流程里任何一个环节断了,现象都是/dev/i2c-0下看不到设备或者/sys/bus/i2c/devices/0-0048不存在。排查时按顺序检查:dtb是否烧写正确(/proc/device-tree/sys/firmware/devicetree/base里能看到原始节点)、i2c adapter是否注册(/sys/bus/i2c/devices/里有没有i2c-0)、client节点的compatible和驱动of_match_table是否一致。绝大多数probe不执行的问题,都能在这三步里定位。

4. CAN子系统:SocketCAN架构下的驱动与设备树配置

CAN总线是另一个高频外设,尤其汽车电子和工业控制领域。和I2C不同,Linux对CAN的抽象方式是把它当成一个网络接口,而不是字符设备——这可能是很多从单片机转过来的朋友最不适应的点。你接收CAN数据根本不需要打开什么设备文件,直接用socket就行,这背后的框架叫SocketCAN。

4.1 为什么是网络接口而不是字符设备

CAN在Linux里走网络协议栈,这个设计在刚接触时觉得绕,但用起来发现非常合理。涉及CAN的应用程序往往需要多路数据过滤、多进程同时接收、优先级管理,这些能力Linux网络子系统早就成熟了。CAN帧被封装成struct can_frame,通过PF_CAN协议族的socket收发,和UDP/TCP的收发模型极其相似。

收到一帧CAN数据的用户态代码长这样:

#include <linux/can.h> #include <sys/socket.h> #include <net/if.h> int s = socket(PF_CAN, SOCK_RAW, CAN_RAW); struct sockaddr_can addr = { .can_family = AF_CAN }; struct ifreq ifr; strcpy(ifr.ifr_name, "can0"); ioctl(s, SIOCGIFINDEX, &ifr); addr.can_ifindex = ifr.ifr_ifindex; bind(s, (struct sockaddr *)&addr, sizeof(addr)); struct can_frame frame; nread = read(s, &frame, sizeof(frame)); /* frame.can_id, frame.can_dlc, frame.data */

开发效率非常高一件事是你完全可以用tcpdump去抓CAN报文,这在调试的时候太方便了:

tcpdump -i can0 -X

这样一条命令就能实时看到总线上所有帧的内容,配合时间戳定位问题比自己在驱动里加打印直观得多。传统的单片机CAN开发根本没有这种排错手段。

4.2 设备树里声明CAN控制器:以M_CAN为例

SoC里的CAN控制器通常也是在dtsi里定义好的。以常见的M_CAN(Bosch M_CAN)为例,它的设备树节点大概是:

can1: can@fe5c0000 { compatible = "bosch,m_can"; reg = <0x0 0xfe5c0000 0x0 0x1000>, <0x0 0xfe5c1000 0x0 0x1000>; reg-names = "m_can", "message_ram"; interrupts = <GIC_SPI 49 IRQ_TYPE_LEVEL_HIGH>; interrupt-names = "int0"; clocks = <&cru CLK_CAN1>, <&cru PCLK_CAN1>; clock-names = "cclk", "hclk"; status = "disabled"; /* 其它 */ };

message_ram是M_CAN特有的,它需要一段共享内存来存放发送/接收FIFO,所以reg有两段,在驱动里用devm_platform_ioremap_resource_byname分别映射:

struct resource *res; void __iomem *mcan_base; void __iomem *msg_ram_base; res = platform_get_resource_byname(pdev, IORESOURCE_MEM, "m_can"); mcan_base = devm_ioremap_resource(dev, res); res = platform_get_resource_byname(pdev, IORESOURCE_MEM, "message_ram"); msg_ram_base = devm_ioremap_resource(dev, res);

板级文件里操作和I2C类似,只需要把status改成okay,然后根据实际硬件连接调整引脚复用:

&can1 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&can1_gpios>; };

如果控制器支持CAN-FD,可以额外在节点里加bosch,mram-cfg之类的属性配置FIFO大小,具体看控制器datasheet和驱动代码的解析逻辑。

4.3 从启用can0到实际收发:ip命令与can-utils

内核驱动加载成功后,CAN接口默认是down的,需要手动拉起来。这也是新手经常卡住的地方——ip link set can0 up之后立刻去读socket,结果什么都读不到,因为在bring up之前你没有配置波特率:

ip link set can0 type can bitrate 500000 ip link set can0 up

不想每次手动敲可以写到启动脚本里。调试期间can-utils提供了一组很实用的工具:

cansend can0 123#DEADBEEF # 发送一帧标准ID为0x123的CAN数据 candump can0 # 接收所有CAN报文 candump can0 -n 10 # 收10帧就退出 cangen can0 -v # 随机发生器,压测用

4.4 CAN驱动的probe与net_device注册

写CAN驱动时,驱动框架和I2C不同。你需要分配一个net_device,然后设置一套struct can_priv下的操作函数,包括do_set_modedo_set_bittiming等。简易模板:

#include <linux/can/dev.h> static int mycan_probe(struct platform_device *pdev) { struct net_device *ndev; struct mycan_priv *priv; ndev = alloc_candev(sizeof(*priv), 0); if (!ndev) return -ENOMEM; priv = netdev_priv(ndev); priv->dev = &pdev->dev; /* 设置位时序 */ priv->can.bittiming_const = &mycan_bittiming_const; priv->can.do_set_bittiming = mycan_set_bittiming; priv->can.do_set_mode = mycan_set_mode; netif_napi_add(ndev, &priv->napi, mycan_poll, NAPI_POLL_WEIGHT); register_candev(ndev); platform_set_drvdata(pdev, ndev); return 0; }

这套写起来环节比字符设备多,但结构很固定。要注意的是中断处理函数里别做太多耗时操作,收帧之后用napi_schedule唤醒NAPI的poll来处理,否则高负载下丢帧率会很难看。还有一点和I2C很像:register_candev之前要先保证中断号已经成功申请,request_irq失败直接返回并清理前面分配的net_device,不然一个半初始化状态的接口出现在系统里,后续排查更痛苦。

5. 整条链路联调:我在实际项目中排过的那些坑

理论部分讲完,说说实际联调中踩过的坑。这些坑你要是遇到了,按我的排查思路走,基本能省下半天到一天的排查时间。

5.1 设备树编译与打包:最常见的第一道坎

设备树编译本身不难,难在打包和加载环节。我遇到过两次特别典型的故障:

第一次是改了dts里某个节点的status,但是烧到板子上发现改动完全没生效。排查到最后发现是U-Boot在加载dtb之前偷偷覆盖了某个fdt环境变量,导致实际传给内核的dtb不是我们编译出来的那份。解决办法是启动时打印fdtcontroladdr和实际加载地址,对比一下mddump出来的内容是不是我们想要的那份。

第二次是make dtbs编译通过,但在内核启动日志里出现Error: invalid dtb magic,说明dtc编译出来的dtb版本和内核解包器不兼容。这种问题基本是把旧内核的dtc工具用在新的dts上,或者反过来。建议直接在内核源码树里用:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs

别用系统自带的dtc,版本偏差很容易埋雷。

5.2 I2C probe不执行的完整排查链路

遇到I2C设备probe不执行,我的排查顺序固定是这样:

  1. 确认设备树节点是否被解析到。执行ls /sys/firmware/devicetree/base/i2c0/temp_sensor@48,如果目录不存在,说明设备树没生效或节点路径不对,问题在设备树侧。
  2. 确认i2c_client是否创建。执行ls /sys/bus/i2c/devices/,看到0-0048目录说明client创建成功。
  3. 确认driver是否注册成功。执行ls /sys/bus/i2c/drivers/sht30/,看目录下绑定的是哪一个设备。如果驱动目录存在但没有设备,用cat /sys/bus/i2c/drivers/sht30/uevent查看驱动的MODULE_ALIAS和设备的MODALIAS是否一致。这一步基本能定位90%的问题。

有一次排查了很久,最后发现是驱动的compatible字符串写了个大写的"SH30",而设备树里是"sh30"——字符串匹配是精确的,大小写差一点都不行。这种错真是眼睛瞪出血都看不出来,建议直接把两个字符串用hexdump对一下最稳妥。

5.3 CAN总线起不来和数据异常的排查

CAN接口ip link set can0 up之后一直报RTNETLINK answers: Operation not permitted,这个报错很误导人,实际原因往往是设备树里中断号配错了或者时钟没使能。用dmesg | grep -i can看一下内核打印,如果出现failed to get clock或者failed to request irq,按图索骥修改对应的资源节点即可。

还遇到过一种非常隐蔽的情况:板卡上CAN收发器供电没接,总线侧怎么都收不到数据。candump一直接收超时,但用示波器量CAN_H和CAN_L之间有没有2.5V左右的共模电压——如果完全没有差分信号,问题根本不在Linux驱动,而在硬件链路。这时候用回环模式测一下:

ip link set can0 down ip link set can0 up type can bitrate 500000 loopback on cansend can0 123#AABBCCDD candump can0

如果回环能收到自己的帧,说明控制器和驱动没问题,问题在收发器、线路或对端节点;如果回环也收不到,那问题出在控制器配置层次,需要回看bittiming和时钟配置。

CAN总线的终端电阻也是一个老生常谈的坑。协议规定总线两端各需要120欧姆电阻,很多工程师在实验室用一根短导线接两个节点,忘了终端电阻,结果波特率不高的时候一切正常,一提到1Mbps就随机丢帧。这种随机故障最费时间。如果你的板子没有焊接终端电阻,调试时至少要在总线两端临时并联120欧姆,别信"距离近不需要"这种说法。

5.4 性能与稳定性:从驱动写得好不好看这几个细节

整条链路跑通之后,还有几个和驱动质量直接相关的细节:

  • 原子上下文:中断下半部、spinlock保护的区域里不能调用msleepi2c_transfer这类可能睡眠的函数,否则内核会报BUG: sleeping function called from invalid context。用schedule_worktasklet把耗时操作推迟到进程上下文。
  • 并发访问:用户态的read/write可能多线程同时调用,驱动里要加锁保护共享数据。用mutex就够的场景别用自旋锁,别把简单的事情搞复杂。
  • 内存屏障:外设DMA缓冲区和CPU之间的数据同步需要合理使用dma_map_single/dma_unmap_single,直接访问物理地址往往读到的是缓存里的旧值。这个坑在CAN驱动里特别常见,因为M_CAN的message_ram就是这么一段和CPU共享的内存。
子系统设备树要点驱动注册方式用户态访问方式
内核模块module_init + platform_driver_registerinsmod/rmmod
字符设备无(或platform适配)cdev_add + class_createopen/read/write/ioctl
I2C clienti2c控制器节点 + 子节点compatible/regi2c_add_driver + of_match_table/dev/i2c-N 或 i2c-tools
CANCAN控制器节点 + pinmux/中断register_candev + net_device_opssocket(PF_CAN) 或 can-utils

6. 从模块到子系统:驱动开发的系统化思维

前面把内核模块、设备树、I2C、CAN四条线全部走了一遍。回头看整条路径,我认为最有价值的不只是某个具体驱动的写法,而是那条一以贯之的"解耦"思路:设备树把硬件描述和驱动逻辑解耦,总线子系统把控制器和客户端设备解耦,devm_接口把资源生命周期和设备绑定解耦,然后compatibleof_match_tableid_table这些机制再把这些解耦后的部分按规则重新组合。

这种系统化思维直接决定了你面对一个新外设时的上手速度。比如你现在手里有一块SPI接口的ADC芯片,路径几乎是复制粘贴的:设备树里找到一个SPI控制器节点,声明子节点和compatible,驱动里注册一个spi_driver,probe里用spi_read/write收发数据。又比如你要接一个USB转串口芯片,走的是USB子系统,但"注册驱动→匹配设备→probe→提供接口"这条路线依然成立。Linux的设备驱动生态之所以庞大,就是因为这种模型在所有总线上是统一、可复用的。

调试方法同样可以迁移。在I2C上习惯用i2cdetect验证物理连接,在CAN上用candump验证总线状态,换成SPI之后就先用spidev_test把收发打通再碰驱动——先确认硬件链路,再确认内核资源,最后才是业务逻辑。把这条排查顺序固化下来,你会发现自己写驱动的调试时间会明显缩短。

平时维护驱动代码,我习惯在开发板阶段就把CONFIG_DEBUG_FSCONFIG_DYNAMIC_DEBUG打开。这两个配置在驱动开发中价值极高。前者可以把驱动内部寄存器状态、ring buffer使用情况导出到/sys/kernel/debug/,后者允许在运行时动态打开某个函数里的dev_dbg打印,不用重新编译内核和模块:

echo 'file drivers/net/can/m_can/* +p' > /sys/kernel/debug/dynamic_debug/control

这一行命令打开的调试信息,比你在代码里到处撒printk再重新编译要快得多。内核的ftraceperf也值得花时间了解一下——当驱动看起来"一切正常"但系统就是偶发卡顿时,这些工具才能帮你定位到底是中断风暴、调度延迟还是锁竞争。

最后再分享一个个人心得:做Linux驱动开发,一定要维护一份自己的"最小可用配置清单"。以我常用的RK3568平台为例,每次拿到新板卡,我会在第一时间确认四件事:串口调试输出是否正常(无日志万事休提)、网络接口能否ping通(文件传输依赖它)、设备树能否正确加载(外设调试的地基)、以及一个简单的GPIO翻转驱动能否编译安装(排除工具链和构建环境问题)。这四件事全部通过之后,再开始按需开发I2C或CAN驱动——80%的"驱动调不通"最后都能回溯到这个阶段某个环节没打通。这个习惯帮我避开了无数无效联调,也算是这些年做Linux驱动开发最深的一点体会。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询