news 2026/9/11 13:01:44

Linux驱动开发系统路径:从内核模块到设备树与I2C/CAN实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux驱动开发系统路径:从内核模块到设备树与I2C/CAN实战

1. 我为什么坚持按“模块→字符设备→设备树→I2C/CAN”这个顺序带人入门

先说个背景。这几年我带过不少新人做嵌入式Linux驱动,也帮朋友的公司做过内训,发现一个普遍现象:很多人一上来就盯着RK3568、i.MX8M这类平台的BSP包死磕设备树,或者照着手册把I2C驱动、CAN驱动的代码抄一遍,板子上能跑通了就以为自己会了。结果一旦换平台、换内核版本、换一颗不熟悉的Sensor芯片,立刻卡死。问题出在哪儿?不是不够努力,而是学习路径错了——你跳过了驱动开发最底层的“地基”,直接去够最上层的“天花板”。

所谓“从内核模块到设备树、I2C/CAN的系统路径”,说白了就是要回答四个递进的问题:内核怎么把一个驱动代码片段加载进来?驱动怎么把设备能力暴露给用户态程序?硬件信息怎么从“写死在代码里”变成“由外部描述文件动态描述”?复杂的总线协议设备(I2C/CAN)又是怎么挂到这套框架下的?

这四个问题,恰好对应Linux驱动开发的四个台阶:内核模块、字符设备框架、设备树、总线驱动。我见过太多人死在第二级和第三级之间——字符设备还没吃透就去折腾设备树,导致后面看I2C的probe回调、看CAN的net_device注册时云里雾里。这篇文章就是按这条路径写的,适合刚接触驱动开发、或者已经能改设备树但想补系统知识的工程师,也适合准备Linux驱动相关面试的人。你不需要手里有一块真实板卡,大部分知识点都能在QEMU模拟的virt平台或者自己的x86开发机上复现。

这篇文章里出现的内核代码,我尽量按Linux 5.15/LTS的API来讲,因为这是目前各大厂商BSP里最主流的分支。不同内核版本API有细微差别,我会在关键位置标注出来。

2. 内核模块:驱动的最小生存单元,远不止一个hello world

2.1 一个模块到底长什么样

内核模块(Loadable Kernel Module,LKM)是驱动开发的第一步,也是最容易被低估的一步。很多人写个hello world就过了,觉得“不过如此”。但如果你真的把模块机制的本质吃透,后面理解驱动模型会顺非常多。

我见过的最精简、但五脏俱全的模块代码大概是这个样子的:

#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init demo_init(void) { pr_info("demo module loaded\n"); return 0; } static void __exit demo_exit(void) { pr_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 driver"); MODULE_VERSION("1.0");

这段代码虽然是入门级的,但它涵盖了模块的四个基本要素:初始化函数、退出函数、module_init/module_exit两个宏的注册动作、以及MODULE_系列元信息声明。注意__init__exit这两个宏,它们不只是装饰性的——__init告诉内核:这个函数只在初始化阶段使用,内存用完可以释放掉。这是一般模块代码里不讲的细节。

编译模块用的Makefile,很多人抄了一辈子也没弄明白为什么长这样:

obj-m += demo.o KDIR := /lib/modules/$(shell uname -r)/build all: make -C $(KDIR) M=$(PWD) modules clean: make -C $(KDIR) M=$(PWD) clean

KDIR指向内核源码树(或者内核头文件包),M=$(PWD)告诉kbuild系统“你要编译的模块源文件在我当前目录”。如果你是在板子上交叉编译,需要额外传入ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-,并且KDIR要指向你实际使用的内核源码目录,而不是uname -r对应的目录。这是我见过新手翻车率最高的地方——在x86主机上编译出的.ko,拿到ARM板子上insmod,直接报invalid module format

2.2 模块加载过程的实际链路

经常有人问我:insmod一条命令下去,内核到底做了什么?这个问题看似基础,但延伸出去能解释很多疑难杂症。

insmod demo.ko做完finit_module()系统调用后,内核会做这些事:检查模块ELF文件的格式和版本信息(vermagic),解析符号表并解决外部符号依赖,然后把模块的.text.data等段搬到内核地址空间,最后调用你注册的module_init函数。如果初始化函数返回非0值,模块会被自动卸载,这就是为什么init函数里一旦某个子步骤失败,要做好资源清理——要么用goto错误处理链,要么用内核提供的devm_系列托管函数。老手和新手的区别,从init函数是“一口气往下写”还是“每步留后路”就能看出来。

模块和模块之间还有依赖关系,日常调试中很常见的是:你insmod一个依赖其他模块的驱动,内核报Unknown symbol。解决这个问题有两个途径:一是按依赖顺序逐个insmod底层模块;二是把依赖关系写进depmod生成的modules.dep文件,然后用modprobe一键加载。modprobe本质就是insmod的智能封装,会解析.ko文件里的MODULE_SOFTDEPdepends=字段自动处理依赖。这也是为什么产品发布时,驱动安装脚本里几乎都是modprobe而不是insmod

2.3 模块参数:驱动和用户态最原始的交互方式

模块开发里还有一个常被低估的能力:module_param。它允许你在加载模块时像传命令行参数一样传递配置:

static int debug_level = 0; static char *server_addr = "192.168.1.100"; module_param(debug_level, int, 0644); module_param(server_addr, charp, 0644); MODULE_PARM_DESC(debug_level, "Debug level (0-3)");

加载时这样用:

insmod demo.ko debug_level=2 server_addr="192.168.1.50"

第三个参数0644是sysfs权限位,这意味着模块运行期间,你还能通过/sys/module/demo/parameters/debug_level这个文件动态修改参数值。这个机制在调试驱动时非常好用:保留一个debug_level开关,现场不用重新编译,直接往sysfs里写数字就能打开/关闭驱动里的调试打印。我在做现场支持时,这一招帮我省了不知道多少回“重新编译来回折腾”的尴尬。

提示:在正式代码里,关键路径的调试打印建议用pr_debug()/dev_dbg()而不是pr_info(),因为前者在编译时可以通过DEBUG宏被优化掉,而且可以通过dynamic_debug机制在运行时按文件、函数、行号精确开关。这是老内核开发者非常依赖、但新人几乎不知道的机制。

3. 字符设备驱动框架:内核态和用户态对话的第一条正经通道

3.1 为什么字符设备是驱动开发的核心骨架

内核模块这一步相当于你写了一支可以插进内核的队伍,但它只能“自己跟自己玩”。用户态的App完全感知不到你的存在。要让用户态能访问你的硬件,最常见的手段就是注册一个字符设备:它通过/dev/xxx节点暴露给用户态,App对这个节点做open/read/write/ioctl,内核就回调到你驱动里对应的file_operations函数。

之所以说字符设备是所有驱动的地基,是因为连I2C、SPI、CAN这类复杂总线设备,最终也脱离不了字符设备的思想:I2C设备驱动的/dev/i2c-N就是字符设备,CAN的SocketCAN本质也依赖网络设备框架,而网络设备框架在Linux里也拥有和字符设备类似的设备模型根基。你把字符设备搞明白了,后面所有东西都是在这个框架上做加法。

裸写一个字符设备驱动大约需要四步:分配设备号、初始化cdev结构体并添加到内核、创建设备类、创建设备节点。

3.2 一个可用的字符设备骨架,直接照抄

下面这个模块是我在培训时最常用的模板,它没有对应任何真实硬件,但把字符设备的完整骨架搭了出来:

#include <linux/module.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> #include <linux/slab.h> #define DEV_NAME "chardemo" static dev_t demo_dev_num; static struct cdev demo_cdev; static struct class *demo_class; static char *demo_buf; static int demo_open(struct inode *inode, struct file *filp) { filp->private_data = (void *)demo_buf; return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t len = strlen((char *)filp->private_data); if (*offset >= len) return 0; if (count > len - *offset) count = len - *offset; if (copy_to_user(buf, (char *)filp->private_data + *offset, count)) return -EFAULT; *offset += count; return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { if (count > PAGE_SIZE) count = PAGE_SIZE; if (copy_from_user((char *)filp->private_data, buf, count)) return -EFAULT; ((char *)filp->private_data)[count] = '\0'; return count; } static long demo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { return 0; } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, .write = demo_write, .unlocked_ioctl = demo_ioctl, }; static int __init demo_init(void) { int ret; ret = alloc_chrdev_region(&demo_dev_num, 0, 1, DEV_NAME); if (ret) return ret; cdev_init(&demo_cdev, &demo_fops); ret = cdev_add(&demo_cdev, demo_dev_num, 1); if (ret) goto err_cdev_add; demo_class = class_create(THIS_MODULE, DEV_NAME); if (IS_ERR(demo_class)) { ret = PTR_ERR(demo_class); goto err_class_create; } device_create(demo_class, NULL, demo_dev_num, NULL, DEV_NAME); demo_buf = kzalloc(PAGE_SIZE, GFP_KERNEL); if (!demo_buf) { ret = -ENOMEM; goto err_kzalloc; } strcpy(demo_buf, "hello from kernel\n"); pr_info("chardemo: registered, major=%d, minor=%d\n", MAJOR(demo_dev_num), MINOR(demo_dev_num)); return 0; err_kzalloc: device_destroy(demo_class, demo_dev_num); class_destroy(demo_class); err_class_create: cdev_del(&demo_cdev); err_cdev_add: unregister_chrdev_region(demo_dev_num, 1); return ret; } static void __exit demo_exit(void) { kfree(demo_buf); device_destroy(demo_class, demo_dev_num); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(demo_dev_num, 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");

这段代码里有几个值得细品的地方:

  • alloc_chrdev_region()是“动态分配”设备号;如果你要固定设备号,用register_chrdev_region()。动态分配的好处是不冲突,但设备号不确定,需要靠udev根据/sys/class/chardemo/的信息自动创建节点。
  • cdev_add()执行完之后,这个设备立刻“活了”。所以它必须在所有资源就绪之后、而且device_create()之前完成,否则用户态可能打开一个有file_operations但没有数据缓冲区的设备。
  • class_createdevice_create这两步是“锦上添花但极其关键”的。没有这两步,你必须手动mknod /dev/chardemo c 240 0去创建设备节点,而且App根本不知道设备号是多少。有了device_create,用户态就可以看到/sys/class/chardemo/,udev通过这个目录自动生成/dev/chardemo

用户态的测试代码就很简单了:

#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main(void) { char buf[64] = {0}; int fd = open("/dev/chardemo", O_RDWR); if (fd < 0) { perror("open"); return -1; } read(fd, buf, sizeof(buf)); printf("read: %s\n", buf); write(fd, "hello user space", strlen("hello user space") + 1); lseek(fd, 0, SEEK_SET); memset(buf, 0, sizeof(buf)); read(fd, buf, sizeof(buf)); printf("read again: %s\n", buf); close(fd); return 0; }

3.3 file_operations里的两个容易被忽略的细节

第一是read/write必须用copy_to_user/copy_from_user,绝对不能用memcpy直接操作用户态指针。原因有两层:一是安全性——用户态传进来的指针可能是非法地址,直接操作会导致内核崩溃;二是功能需要——这两个API内部会做地址合法性校验、段越界检查、缺页处理。你需要关心的*offset移动也是驱动自己的职责,如果read里忘了递增*offset,App就只能永远读到第一段数据,这个bug我见过无数次。

第二是unlocked_ioctl的问题。老内核里还有个ioctl字段,2.6.36之后统一改成了unlocked_ioctl。如果你写驱动时没定义它,App的ioctl()调用会直接返回ENOTTY。很多从老代码抄驱动的新人,移植到新内核后发现ioctl失效,多半就是把这个字段名字写错了。

4. 设备树:驱动与硬件解耦的关键,Reset信号只是其中一个细节

4.1 为什么说设备树是驱动开发的“翻译层”

在设备树普及之前,驱动里想要知道硬件信息,比如寄存器基地址、中断号、GPIO引脚,最常见做法是:在驱动源码里直接硬编码。这种做法做单板可以,做产品就完蛋——你给A厂商的板子写的驱动,换到B厂商板子上就废了。内核开发者后来引入了Platform Device模型,把一个设备的“资源信息”抽象成struct resource,再通过platform_device注册到内核,驱动侧用platform_get_resource()去拿,这比硬编码好得多,但平台设备的注册仍然写在板级C文件里,代码耦合度还是高。

设备树(Device Tree)的突破在于:把硬件的描述完全从内核源码里挪到一份独立文本文件(.dts.dtsi)里。驱动代码只负责描述“我怎么操作这一类设备”,不关心它的物理位置和设备号。具体某个设备在哪个总线上、挂在哪个时钟域、用哪个GPIO做复位,全部由设备树描述。这个“驱动即方案、设备树即配置”的分离思想,是整个现代嵌入式Linux驱动开发的灵魂。

设备树的源代码是.dts文件,通过dtc编译器编译成.dtb二进制,引导时由Bootloader加载给内核。在运行时,你可以通过/sys/firmware/devicetree/base/查看设备树展开后的内容,这个目录对调试非常有用。比如你改了设备树之后到底生效没有,直接ls /proc/device-tree//sys/firmware/devicetree/base/,一眼就能看到。

4.2 看懂一个真实的设备树节点,并亲自动手写一个

以常见的SPI NOR Flash为例,设备树节点长这样:

&spi0 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&spi0_default>; cs-gpios = <&gpio4 3 GPIO_ACTIVE_LOW>; flash@0 { compatible = "jedec,spi-nor"; reg = <0>; spi-max-frequency = <50000000>; spi-tx-bus-width = <1>; spi-rx-bus-width = <1>; }; };

这里compatible = "jedec,spi-nor"就是设备树和驱动之间的“握手信号”。内核里SPI NOR驱动的of_match_table里如果包含这个字符串,驱动就会被匹配并probe。还有更重要的一点:compatible的命名规则是“厂商,型号”,顺序上先写特定型号、再写通用型号,这是Linux社区的约定,匹配时用的是“最前面匹配成功即停止”的逻辑。

如果你要为自己的I2C触摸屏、GPIO控制的Sensor写一个节点,基本上就是仿照这个结构。一个真实的GPIO控制复位信号的例子如下:

sensor@48 { compatible = "somevendor,pressure-sensor"; reg = <0x48>; interrupt-parent = <&gpio1>; interrupts = <13 IRQ_TYPE_EDGE_FALLING>; reset-gpios = <&gpio4 20 GPIO_ACTIVE_LOW>; vdd-supply = <&reg_3v3>; };

这里interrupt-parentinterrupts定义了这颗Sensor的中断脚接在主控的哪个GPIO上、什么边沿触发;reset-gpios就是热搜词里提到的“复位信号”——很多Sensor上电后需要拉低再拉高一次才能完成硬件复位,这个拉低保持的时间有些芯片要求最低10us,你可以在驱动里用gpiod_set_value()配合usleep_range()控制。设备树本身不负责时序,它只告诉驱动“这个复位脚是哪个GPIO、低有效”,时序是驱动代码里的活。

4.3 驱动侧怎么“接住”设备树:platform_driver与of_match_table

设备树节点只是“需求描述”,真正干活的还是驱动。挂在CPU片内外设总线上的设备,在Linux里统一抽象成platform_device,对应的驱动就是platform_driver。看一段典型的平台驱动注册代码:

static const struct of_device_id my_sensor_of_match[] = { { .compatible = "somevendor,pressure-sensor" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_sensor_of_match); static int my_sensor_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct gpio_desc *reset_gpio; reset_gpio = devm_gpiod_get(dev, "reset", GPIOD_OUT_LOW); if (IS_ERR(reset_gpio)) return PTR_ERR(reset_gpio); /* 拉高,完成一次硬件复位 */ gpiod_set_value(reset_gpio, 1); usleep_range(1000, 2000); gpiod_set_value(reset_gpio, 0); return 0; } static struct platform_driver my_sensor_driver = { .probe = my_sensor_probe, .remove = my_sensor_remove, .driver = { .name = "my_sensor", .of_match_table = my_sensor_of_match, }, }; module_platform_driver(my_sensor_driver);

module_platform_driver()这个宏是module_init + module_exit + platform_driver_register/unregister的语法糖,几乎所有总线驱动最后都是这么写的。probe函数一旦被调用,说明设备树里有一个compatibleof_match_table匹配的节点存在了。

这里推荐使用devm_(managed device resource)API,比如上面代码里的devm_gpiod_get()。原因是:如果后续某个资源获取失败,devm框架会自动帮你在probe返回前释放已经申请成功的资源,错误处理代码可以写得非常干净。这套API在整个现代Linux驱动里几乎是标配,凡是你看内核里比较新的驱动,几乎看不到裸gpio_request()不配gpio_free()的写法。

4.4 设备树调试里最容易踩的几个坑

设备树写错是日常,调试设备树需要方法论。我按频率排一下我踩过的坑:

第一,status = "disabled"。节点写得好好的,驱动就是probe不了,最后发现是SoC自带的dtsi里把某个控制器默认禁用了。这时你要在板级dts里显式改成status = "okay"。这个坑最隐蔽,因为你看dtsi和dts里都有这个节点,很难注意到一个状态位。

第二,reset-gpiosxxx-gpios这类属性的GPIO号写错。GPIO控制器有多个bank,不同SoC描述方式不一样,我们常在设备树里看到<&gpio4 20 GPIO_ACTIVE_LOW>这样的写法——其中gpio4是节点引用,20是bank内偏移,GPIO_ACTIVE_LOW是低有效。如果你不确定引脚号,优先在板卡原理图上把芯片引脚追到SoC的GPIO bank和pin index,再换算成设备树写法。别指望靠猜,我见过太多人把GPIO_ACTIVE_HIGHGPIO_ACTIVE_LOW搞反,导致设备永远处于复位状态。

第三,reg属性与地址空间。I2C设备节点里的reg = <0x48>是7位从机地址,注意不是8位写地址。很多人把数据手册里的“0x90(写地址)”直接填进来,驱动永远probe不到设备。这个坑在I2C驱动相关的热搜词里反复出现,我下一章会重点拆。

第四,想确认设备树是否生效,最快的方法是在内核里开CONFIG_OF相关选项、并在内核启动cmdline里加of_dev_dbg,或者直接在驱动probe函数里加dev_info(dev, "of_node name: %pOF\n", dev->of_node),内核会打印设备树节点路径,一眼就知道这个驱动是匹配到哪个节点了。比翻log快得多。

5. I2C设备驱动:一块Sensor芯片从设备树节点到probe回调的完整旅程

5.1 I2C子系统的三层结构

I2C子系统在Linux里分三层:控制器驱动(adapter)、核心层(I2C core)、设备驱动(client driver)。控制器驱动负责操作SoC上的I2C外设硬件寄存器,完成时序;设备驱动负责处理具体I2C从设备,比如读Sensor的寄存器、写DAC的配置。核心层负责两者之间的匹配和事务转发。

这个分层的理解至关重要:你在设备树里写的i2c@xxx节点下的子节点,驱动侧匹配到的就是i2c_driver,而你的i2c_driver在probe时拿到的参数是struct i2c_client *,它代表的就是设备树里的I2C从设备。你用i2c_transfer()发起的读写,最终会经由core调度到对应adapter的master_xfer()回调里,由它驱动硬件完成时序。

在设备树普及前的老代码里,你还会看到用i2c_board_info直接注册I2C设备的方式,比如:

static struct i2c_board_info i2c_devs[] __initdata = { { I2C_BOARD_INFO("pressure_sensor", 0x48) }, }; i2c_register_board_info(0, i2c_devs, ARRAY_SIZE(i2c_devs));

这个方式在设备树平台已经被替代了,但你在维护老内核BSP时会遇到,需要能看懂。

5.2 从零写一个I2C客户端驱动

假设我手上有一颗假的温度传感器芯片faketemp,I2C从机地址0x48,内部寄存器0x00是温度值(16位),0x01是配置寄存器。驱动代码核心部分如下:

#include <linux/i2c.h> #include <linux/module.h> #include <linux/err.h> #define FAKETEMP_TEMP_REG 0x00 #define FAKETEMP_CFG_REG 0x01 struct faketemp_data { struct i2c_client *client; }; static int faketemp_read_temp(struct i2c_client *client, s16 *temp) { u8 reg = FAKETEMP_TEMP_REG; u8 data[2] = {0}; int ret; struct i2c_msg msgs[2] = { { .addr = client->addr, .flags = 0, .len = 1, .buf = &reg, }, { .addr = client->addr, .flags = I2C_M_RD, .len = 2, .buf = data, }, }; ret = i2c_transfer(client->adapter, msgs, 2); if (ret != 2) { dev_err(&client->dev, "i2c_transfer failed, ret=%d\n", ret); return -EIO; } *temp = (s16)((data[0] << 8) | data[1]); return 0; } static int faketemp_probe(struct i2c_client *client) { s16 temp; int ret; dev_info(&client->dev, "faketemp probed, addr=0x%02x\n", client->addr); /* 读一次温度,顺便验证通信链路 */ ret = faketemp_read_temp(client, &temp); if (ret) { dev_err(&client->dev, "failed to read temp\n"); return ret; } dev_info(&client->dev, "temperature: %d.%02d\n", temp >> 8, temp & 0xff); return 0; } static const struct i2c_device_id faketemp_id[] = { { "faketemp", 0 }, { } }; MODULE_DEVICE_TABLE(i2c, faketemp_id); static const struct of_device_id faketemp_of_match[] = { { .compatible = "fake,faketemp" }, { } }; MODULE_DEVICE_TABLE(of, faketemp_of_match); static struct i2c_driver faketemp_driver = { .driver = { .name = "faketemp", .of_match_table = faketemp_of_match, }, .probe = faketemp_probe, .id_table = faketemp_id, }; module_i2c_driver(faketemp_driver); MODULE_LICENSE("GPL");

注意两点:i2c_transfer里我用了“write-then-read”的两段式消息数组。这是I2C读寄存器的标准做法——先写寄存器的地址,然后带I2C_M_RD标志再读。很多新手图省事,直接用i2c_smbus_read_word_data(),那个函数读16位数据时字节序的坑很多,不同传感器微妙地不一样,我现在写新驱动都倾向于显式用i2c_msg数组控制每个字节。

设备树侧对应节点:

&i2c2 { status = "okay"; clock-frequency = <400000>; faketemp@48 { compatible = "fake,faketemp"; reg = <0x48>; }; };

需要注意的是clock-frequency这个属性是挂在I2C控制器节点上的,表示总线速率,常见值是100000(标准模式)或400000(快速模式)。但它只是尽量去配置,实际能否达到400k取决于硬件pull-up电阻和走线,不是设备树写了就一定跑得动。

5.3 关于0x48与0x90:7位地址和8位地址的世纪混淆

这是I2C调试里最经典的坑。芯片数据手册上经常写“写地址0x90、读地址0x91”,但你的设备树reg属性值必须是0x48——因为0x90是把7位地址0x48左移一位再加上读写位得到的8位形式。Linux I2C子系统的client->addr保存的是7位地址(新内核也支持10位地址,但那是另一个话题),i2c_transfer里框架会根据读写标志自动帮你移位、附加读写位。

我见过不止一个工程师在这里卡一整天:设备树填0x90,用i2cdetect却能在0x48扫到设备,驱动probe不到,两者对不上。所以,看到数据手册上的地址大于等于0x80时,先做一次“除以2”运算,得到的才是reg属性该填的值。这个经验我在培训里讲了至少五十次,依然有新人反复踩。

5.4 I2C驱动的调试手段:i2cdetect、i2cdump、i2cget

设备树写好了,驱动probe不到,怎么排查?我的第一反应永远是先用i2cdetect扫总线:

i2cdetect -l i2cdetect -y 2

-l列出所有I2C适配器,-y 2扫总线2上的设备。如果0x48这个地址出现在扫描结果里,说明硬件通路通着,问题大概率出在设备树匹配或者驱动注册上;如果扫描不出来,说明是硬件接线、电压、上拉电阻、地址线设置的问题,你花多少时间在软件上都是白费。

确认设备存在后,i2cget可以手动读寄存器:

i2cget -y 2 0x48 0x00

这个命令直接用系统I2C框架发起一次读操作,能把寄存器值裸读出来。如果这条命令能读出来、驱动却工作不正常,说明问题在驱动的读写时序/缓存处理上;如果这里都失败,就是硬件链路的问题。这种“先排除物理层,再定位逻辑层”的思路,能让I2C调试效率翻倍。

6. CAN设备驱动:从字符设备思维切换到网络接口思维的十字路口

6.1 为什么把CAN单独拿出来讲

很多人学驱动,学完I2C之后顺手就去搞SPI、搞PCIe,然后突然在一个地方卡住了——CAN。因为CAN在Linux里的实现思路跟前面那些字符设备完全不一样:CAN压根不是字符设备,而是网络设备。它的驱动框架挂在net_device之下,用户态用socket去访问,而不是open/read/write。

这是一个思维模式的转换点:同样是总线设备,I2C设备的用户接口是/dev/i2c-N,而CAN设备的用户接口是can0这样的网络接口,并且遵循SocketCAN协议族。SocketCAN是Linux内核里一套完整的CAN实现,包括协议栈(CAN_RAWCAN_BCMCAN_ISOTP)、网络设备层和底层控制器驱动。

从驱动开发现场角度来说,这就意味着:你写CAN驱动时,不是在实现struct file_operations,而是在实现struct net_device_opsndo_openndo_closendo_start_xmit这些回调。套接字发出的一帧CAN数据,会经过网络协议栈、到达你的驱动接口,最后由你控制CAN控制器硬件把它发到总线上。

6.2 驱动侧的核心骨架:net_device + can_priv

Linux内核为CAN设备驱动准备了专门的辅助框架:struct can_priv(定义在include/linux/can/dev.h)封装了波特率、时钟、状态、中断控制等通用逻辑,你只需要实现具体的控制器操作函数。

驱动骨架大概是这样的:

#include <linux/can.h> #include <linux/can/dev.h> #include <linux/netdevice.h> struct fakecan_priv { struct can_priv can; void __iomem *base; struct napi_struct napi; struct net_device *dev; }; static netdev_tx_t fakecan_start_xmit(struct sk_buff *skb, struct net_device *dev) { struct fakecan_priv *priv = netdev_priv(dev); struct can_frame *frame = (struct can_frame *)skb->data; int i; /* 把frame数据写入硬件控制器的发送FIFO */ for (i = 0; i < frame->can_dlc; i++) { writeb(frame->data[i], priv->base + FAKECAN_TX_BUF + i); } /* 触发发送 */ writel(1, priv->base + FAKECAN_TX_CTRL); /* 网络设备层需要统计和管理skb生命周期 */ dev->stats.tx_packets++; dev->stats.tx_bytes += frame->can_dlc; /* SocketCAN要求驱动把skb交给上层释放,不加NETDEV_TX_BUSY就成功 */ dev_kfree_skb(skb); return NETDEV_TX_OK; } static int fakecan_open(struct net_device *dev) { /* 设置波特率、申请中断、启动控制器 */ return open_candev(dev); } static int fakecan_stop(struct net_device *dev) { /* 关闭控制器、释放中断 */ return close_candev(dev); } static const struct net_device_ops fakecan_netdev_ops = { .ndo_open = fakecan_open, .ndo_stop = fakecan_stop, .ndo_start_xmit = fakecan_start_xmit, }; static int fakecan_probe(struct platform_device *pdev) { struct net_device *dev; struct fakecan_priv *priv; void __iomem *base; base = devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(base)) return PTR_ERR(base); dev = alloc_candev(sizeof(struct fakecan_priv), 1); if (!dev) return -ENOMEM; dev->netdev_ops = &fakecan_netdev_ops; priv = netdev_priv(dev); priv->base = base; priv->dev = dev; /* 时钟频率:硬件控制器输入时钟 */ priv->can.clock.freq = 50000000; platform_set_drvdata(pdev, dev); return register_candev(dev); } static struct platform_driver fakecan_platform_driver = { .probe = fakecan_probe, .remove = fakecan_remove, .driver = { .name = "fakecan", .of_match_table = fakecan_of_match, }, }; module_platform_driver(fakecan_platform_driver);

alloc_candevregister_candev这两个API把CAN控制器的生命周期管理打包好了。实际上你在具体SoC的驱动里看到的代码会比这个复杂得多,比如FlexCAN控制器有邮箱(Mailbox)机制,MCP2515是把CAN控制器封装在SPI后面的外置芯片,底层变成SPI读写,但上层的net_device骨架是统一的。你在drivers/net/can/下面翻驱动时,会发现无论什么控制器,切换到net_device_ops的思路都是一样的。

6.3 设备树侧的CAN节点

还是以最常见的RK3568平台为例,它内部集成的是FlexCAN(继承自NXP IP),设备树节点大概长这样:

&can1 { status = "okay"; assigned-clocks = <&cru CLK_CAN1>; assigned-clock-rates = <50000000>; };

如果主控没有内置CAN控制器,用的是MCP2515这类外置SPI转CAN芯片,在设备树里是这样写的:

&spi1 { status = "okay"; mcp2515: can@0 { compatible = "microchip,mcp2515"; reg = <0>; spi-max-frequency = <10000000>; clocks = <&mcp251x_osc>; interrupt-parent = <&gpio4>; interrupts = <20 IRQ_TYPE_EDGE_FALLING>; }; };

clocks属性指向的是一个振荡器节点,MCP2515需要外接晶振,波特率精度很大程度依赖这个晶振的精度;interrupts是控制器中断脚,内核收中断后从它的接收FIFO里把报文搬出来,再交给SocketCAN协议栈。如果你配置完发现ip link set can0 up时报failed to bring up can0,有一半概率是clocks没给对,另一半概率是中断号出了偏差。

6.4 用户态怎么验证CAN驱动调通了

设备树配好、驱动加载成功之后,验证手段比普通字符设备直观得多。

# 打开can0接口,设置500kbps波特率,启动 ip link set can0 up type can bitrate 500000 # 查看接口状态:应该出现NOARP、UP、RUNNING字样 ip -details link show can0 # 监听总线上所有报文 candump can0 # 发送一帧标准帧,ID=0x123,数据为0x11 0x22 0x33 0x44 cansend can0 123#11223344 # 统计错误帧和总线错误 ip -details -statistics link show can0

如果ip link set can0 up ...成功,说明驱动open回调走通了;再用cansend主动发一帧、candump能收到自己发的帧,基本就说明发送路径OK。但要注意总线没有其他节点应答时,CAN控制器通常会报ACK错误,这是正常现象——CAN协议要求发送方必须收到至少一个其他节点的ACK位。所以自己一个人调CAN时,回环测试(ip link set can0 up type can bitrate 500000 loopback on)才是真正验证驱动的方式。等整个链路通了,再把它切回正常模式接真实总线。这个细节对那种“发送总报错但驱动明明没问题”的场景是决定性的。

7. 调试Linux驱动最容易卡住的几个瞬间(附我的排查路径)

7.1 内核崩溃后,如何快速定位是不是自己的驱动干的

驱动开发最刺激的瞬间,就是insmod下去,终端刷了一屏Oops(内核崩溃),然后系统完全卡死。很多新人的第一反应是慌,甚至直接断电重启,这其实完全没必要——Oops信息本身就是最好的调试数据。只要你能把终端上滚动的信息抄下来,大概率能定位问题。

拿到Oops信息后,先看三件事:

第一,看“Unable to handle kernel NULL pointer dereference”这类描述,它告诉你崩溃类型。第二,看PC指针:PC is at demo_write+0x10/0x50,这个表达式里的demo_write+0x10意思是崩溃发生在demo_write函数偏移0x10字节处,后面的/0x50是函数总大小。如果你编译时开了CONFIG_DEBUG_INFO,用addr2line反查一下就能准确定位到源码行号。第三,看调用栈(Call trace),它告诉你这个函数是被谁调进来的——大多数驱动崩溃都是因为一个空指针、野指针被传到了下一层。

提示:我调试时一定会确保开发板用的是编译时开启了CONFIG_DEBUG_INFOCONFIG_KALLSYMS的内核,否则Oops信息里的符号名全变成十六进制地址,定位效率下降一大半。这个选项在你编译内核时开一次,用到天荒地老。

7.2 内核日志里什么都没有,驱动就是没反应

insmod成功,但dmesg里看不到驱动自己的打印,比报错还让人抓狂。我遇到过的情况主要分四种:一是你的打印用了pr_debug(),而内核没有开DEBUG宏,这些打印被编译期优化掉了。二是dmesg的缓冲区被刷掉,或者控制台的loglevel太低,pr_info()都没有显示到串口上。三是驱动确实没被加载,用lsmod检查一下别靠猜。四是驱动被加载了但probe函数没被调用,原因通常是设备树节点没匹配上。

排查顺序我一般这样走:先lsmod | grep your_dev确认模块在不在;再看/sys/bus/platform/devices/下有没有对应设备;最后在驱动的.of_match_table里临时加一个非常宽松的匹配项,用dev_info()打印“probe called”。从外到内一层层排除,比盯着代码发呆有效得多。

关于printk控制台级别,很多人调试时发现串口只有部分打印,这其实是/proc/sys/kernel/printk四个数字在起作用。它代表:控制台日志级别、默认消息日志级别、最小控制台级别、默认控制台级别。调试时把第一项改成8能放行几乎所有printk:

echo "8 4 1 7" > /proc/sys/kernel/printk

7.3 驱动能编译,但加载时提示版本magic不匹配

insmod报错:version magic '5.15.0-91-generic SMP mod_unload modversions' should be '5.15.0-91-generic SMP mod_unload'——这种问题解决路径很清楚:模块编译环境的内核头文件版本和运行内核版本不一致。要么把/lib/modules/$(uname -r)/build这个链接指到对的内核源码树,要么重新跑到板子对应的内核源码目录下编译。

内核模块对版本匹配极其严格,因为它要防止二进制接口不兼容的模块被强行加载进内核。在给量产产品做驱动时,一定要用最终固件同版本的内核源码来编模块,否则就是给自己埋雷。

7.4 GPIO请求失败:设备树里写的是GPIO,驱动里却请求不到

这是设备树驱动的另一个高频坑。用了devm_gpiod_get(),结果返回-EPROBE_DEFER-EPROBE_DEFER是一个特殊错误码,它的含义是:我依赖的资源还没准备好了,请内核过一段时间再调用我的probe一次。这个机制在设备树驱动里非常重要——如果节点的GPIO控制器驱动还没加载,devm_gpiod_get就会返回-EPROBE_DEFER,内核会在依赖的驱动加载完成后自动重新probe。

所以,遇到-EPROBE_DEFER不是错误,而是正常流程,你只需要确认两件事:一是GPIO引脚的pinctrl配置是否正确,它有没有被其他外设占用;二是gpio-controller节点的#gpio-cells属性是否与你的<&gpio4 20 GPIO_ACTIVE_LOW>的写法匹配。很多SoC的引脚是mux复用功能,同一个引脚可能被I2C、SPI、GPIO多个功能占用,设备树里没有配置pinctrl或者pinctrl配置了两个外设共用同一引脚,都会导致GPIO申请失败。

7.5 中断处理函数里不能做的事

写驱动一定会碰到中断。我见过最普遍的中断问题,是在中断上下文里调用msleep()mutex_lock(),甚至printk()用多了。中断上下文是原子上下文,不能睡眠,这是Linux内核开发最基本的红线。实际操作中,如果遇到必须在中断里去等待硬件完成某个操作的情况,解决方案是采用底半部(bottom half)机制:用taskletworkqueuethreaded IRQ把耗时操作挪到进程上下文执行。现代驱动里最推荐的是request_threaded_irq(),它能直接把中断处理放到一个内核线程上下文里,函数里能用mutex,也能做更多事情,代码可读性也更好。

还有一个细节:中断服务函数里记录时间后要尽快返回,不要在中断里做I2C读写——I2C时序是阻塞的,在atomic上下文里你去搞I2C,轻则timeout,重则挂死总线。真要读数据,就disable_irq之后把工作交给workqueue,在workqueue里再访问I2C,处理完重新enable_irq

7.6 从Sysfs到Debugfs:驱动调试的几个“免费”侦察工具

驱动开发不止是写代码,更是一个信息获取的过程。/sys/proc/sys/kernel/debug/dev这些虚拟文件系统就是你的侦察工具。我最常用的是:

  • /sys/kernel/debug/下的内核调试接口,比如/sys/kernel/debug/gpio能看到所有GPIO的状态和占用情况,/sys/kernel/debug/clk能看到时钟树,/sys/kernel/debug/pinctrl看看引脚mux状态。这些比拿万用表捅引脚快得多。
  • /proc/interrupts能看每个中断号触发次数,驱动装上之后第一件事就是确认中断有没有在触发。如果中断没触发,多半是设备树中断配置有问题,或者设备根本没工作。
  • perftracepoint,在定位驱动性能瓶颈时特别好用。比如查CAN报文接收是不是出现了丢包,可以用perf trace或者trace-cmd跟踪net:netif_receive_skbcan:can_rx_skb相关事件。这些工具的使用经验一般不是语法问题,而是“要先想清楚自己要看什么事件”的思维问题。

7.7 一次现场排查的完整复盘:CAN驱动在量产工位间歇性丢帧

最后分享一个我这几年印象最深的案例,它几乎把所有模块踩坑点串到了一起。

客户产品是个带CAN总线的运动控制器,量产工位反馈:偶尔会出现控制器掉线,但重新上电又好了。我去现场时,先candump挂着抓帧,抓到几帧后发现报文在时间戳上有明显的间隙,有一瞬间卡了20ms没帧。这20ms足够让我怀疑驱动侧的问题。

排查思路分两步:先看驱动在哪一步丢了时间,再看是什么占了这20ms。把中断号对应的触发次数打到板卡上,发现CAN接收中断触发正常,但在另一个GPIO中断触发时CAN中断响应被明显拖慢了——两个中断共享同一个中断控制器,GPIO中断处理函数里有一段msleep(5)循环了几次,把中断处理拖到了20ms。更糟的是,那个中断里还做了I2C读写,I2C总线正好又被同一颗SoC的另一个功能占着,时序冲突。

定位到之后,修复方案是:把GPIO中断改成request_threaded_irq,把耗时操作挪到线程上下文,中断服务函数只做数据搬运。改完再测,CAN报文间隙从20ms降到了0.5ms以内。这个案例最典型的教训是:中断上下文里任何可能阻塞的操作,都是隐形的系统级地雷——它不只影响你那颗中断,还会拖累整个板子上其他外设。

8. 最后送你一条驱动开发的“系统路径”心法

这篇文章从内核模块讲到字符设备、设备树、I2C、CAN,一路铺下来,可能信息量不小。但你回头再看,会发现它们不是五个孤立的知识点,而是一条完整的能力链:理解模块机制 → 掌握设备注册与文件操作 → 学会用设备树描述硬件 → 在具体总线上落地。每往前一步,都对前一步提出了新的要求。

我带人的实际经验是,按这个顺序把每一层都吃透,一个零基础的人大概需要三到四个月能够独立接一个简单的I2C或GPIO驱动开发任务。如果跳过层级直接去抄CAN驱动,哪怕当时跑通了,后面一旦遇到硬件改版、内核升级、驱动并发问题,你会发现自己根本无从下手。

还有一个很重要的习惯:一定要自己动手把每个模块从空文件开始敲一遍,哪怕和教程完全一样,也要亲手敲。因为驱动开发的很多坑不在阅读代码阶段暴露,而是在编译、加载、运行、调试的每一个环节里。我写了这么多年驱动,依然会时不时在insmod这步翻车,但那不就是这一行的乐趣吗——每次翻车,都能多攒一个“这个坑我下次绝对绕过去”的经验。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 13:01:03

从一段氨基酸序列到三维结构:AlphaFold蛋白质结构预测上手实战

从一段氨基酸序列到三维结构&#xff1a;AlphaFold蛋白质结构预测上手实战 【免费下载链接】alphafold Open source code for AlphaFold 2. 项目地址: https://gitcode.com/GitHub_Trending/al/alphafold 当你手里只有一段氨基酸序列&#xff0c;却需要知道它在空间里怎…

作者头像 李华
网站建设 2026/9/11 13:01:02

敏捷思维:提升团队效率与项目成功率的关键

1. 为什么我们需要敏捷思维&#xff1f;我清楚地记得2018年带领的第一个项目团队&#xff0c;那是一个典型的瀑布式开发项目。我们花了三个月做需求分析&#xff0c;两个月做设计&#xff0c;等到真正开始编码时&#xff0c;才发现前期很多假设都不成立。团队成员互相指责&…

作者头像 李华
网站建设 2026/9/11 12:59:07

RISC-V设备树中断绑定详解:中断控制器、PLIC与多父节点路由实战

直接说结论&#xff1a;RISC-V 设备树里的中断绑定&#xff0c;最核心的坑不是手册读不懂&#xff0c;而是你根本不知道中断号应该填几、 parent 该指向谁、多个父节点出现时到底走哪条路由。很多工程师照着 ARM 平台的习惯写 dts&#xff0c;结果在 RISC-V 上要么中断不触发&a…

作者头像 李华
网站建设 2026/9/11 12:58:20

RISC-V中断控制器实战:PLIC与APLIC调通指南

1. 为什么RISC-V工程师必须亲手调通PLIC和APLIC——不是讲概念&#xff0c;是调通它 你手头有一块基于RISC-V的SoC开发板&#xff0c;跑着Linux或裸机固件&#xff0c;突然发现&#xff1a;UART收发正常&#xff0c;但定时器中断一来&#xff0c;ADC采样就丢点&#xff1b;或者…

作者头像 李华
网站建设 2026/9/11 12:58:08

基于MovieLens的推荐系统实战:Flask+Spark+ALS全流程实现

简介&#xff1a;一份面向毕业设计场景的电影智能推荐系统完整实现&#xff0c;整合了Flask Web框架、Spark分布式计算、ALS协同过滤算法与MovieLens公开评分数据集。项目覆盖数据清洗、特征处理、推荐模型训练与网页交互展示等关键环节&#xff0c;适合计算机相关专业的学生用…

作者头像 李华
网站建设 2026/9/11 12:58:00

RISC-V设备树中断绑定:多父节点路由实战与避坑指南

做 RISC-V 设备树开发&#xff0c;最绕不开的一个坎就是中断绑定。很多人第一次看 OpenSBI 或 Linux 里的 .dts 文件&#xff0c;对着 interrupt-parent 、 interrupts-extended 、 #interrupt-cells 这些字段一头雾水&#xff0c;尤其当板子上不止一个中断控制器、同一…

作者头像 李华