Linux驱动开发实战:从内核模块到设备树与I2C/CAN
2026/9/16 3:54:53 网站建设 项目流程

接手一块新板子的时候,我习惯先问一句:这上面的外设靠什么在Linux里跑起来?碰到国产ARM平台、RK3568、全志这类的板子,答案几乎是固定的——设备树描述硬件,内核驱动匹配设备,I2C/CAN这类总线负责搬数据。很多新人一上来就捧着驱动代码啃,结果卡在设备树语法、总线框架不熟上,越看越乱。这篇文章我就完全按自己实际走过的路径来写:先搞懂内核模块怎么存在,再看设备树怎么把硬件“说清楚”,最后落到I2C和CAN这两条总线的驱动开发与调试,全程用实战场景串起来。

1. 内核模块是入口:一个驱动到底怎么被系统加载起来的

1.1 从“Hello World”模块开始,先把生命周期跑通

很多人以为驱动开发很高深,其实第一步是写一个能在内核里跑起来的小模块。所谓内核模块,就是一段可以动态加载进内核的代码,不需要重新编译整个内核镜像,非常适合开发调试。我建议所有新人都先把这个最小模块跑通,再谈别的。

#include <linux/module.h> #include <linux/init.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_DESCRIPTION("Minimal driver module example");

配套的Makefile是这样的:

obj-m := demo.o KERNELDIR := /path/to/kernel-source PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

编译出来会得到demo.ko。在目标机器上执行insmod demo.ko,模块加载时demo_init执行;执行rmmod demodemo_exit执行。查看输出用dmesg,不要指望它出现在终端里,内核日志走的是内核环形缓冲区,不是标准输出。

这里有几个细节新手非常容易忽略:

  • __init__exit宏的作用是把初始化函数和退出函数放到专门的代码段里,模块加载成功后初始化函数占用的内存会被释放掉。这不是可有可无的修饰,而是内核模块的常规做法。
  • MODULE_LICENSE("GPL")不只是摆设。很多内核符号对非GPL模块不导出,尤其是调试、锁和部分子系统接口。不声明GPL,你在一些API上会踩到“未知符号”的坑。
  • insmod只做加载,不处理依赖。如果模块依赖其他模块,要用modprobe,它按依赖关系自动加载,这也是后面做驱动时更常用的命令。

1.2 模块机制只是“外壳”,设备驱动还差关键一步

把模块跑通之后,你会发现这和“驱动”还差得远。真正的驱动要实现具体的接口,让用户态程序能通过文件系统、网络栈或其他子系统访问你的硬件。比如一个字符设备驱动,必须实现file_operations结构体,再注册到内核的字符设备框架里,这样open/read/write/ioctl才会到达你的驱动函数。

我个人理解的内核模块到设备驱动的路程是这样的:

  • 模块机制解决的是“代码怎么塞进内核”的问题。
  • 设备模型解决的是“硬件设备怎么被描述、被找到”的问题。
  • 总线驱动解决的是“数据怎么在处理器和外设之间流动”的问题。

从实践来看,不理解设备模型就写驱动,很容易写出“一个模块把硬件寄存器全占了”的独享式代码。这种模块在开发板上能跑,一进正式项目就崩,因为内核的设备模型、电源管理、热插拔机制全都接不上。所以我的建议是:模块语法花两三天过一遍就行,重点放在理解设备模型上——也就是设备树、总线、驱动三者怎么铆合到一起。

1.3 交叉编译与内核源码版本:最容易卡住的隐藏门槛

做嵌入式Linux驱动,几乎不可能在目标板上直接编译,都是在宿主机上用交叉编译工具链编好.ko,再拷到板子上加载。这里最坑的就是内核源码版本必须和目标板运行的内核版本严格一致,同时编译配置也不能改花。

具体来说,注意三点:

  • KERNELDIR指向的内核源码树,最好就是板子当前内核同一个版本、同一个配置文件编出来的。否则模块加载时可能出现“版本魔术”不匹配。
  • 交叉编译需要指定架构和工具链,例如ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
  • 内核源码目录下得先make modules_prepare之类准备好部署文件,否则模块编译会报缺少Module.symversinclude文件的错。

这块的坑几乎所有人都会踩,解决办法也简单:别自己猜测,直接从板卡厂商的BSP里拿内核源码和设备树,比什么都省事。

2. 设备树不是配置文件,是驱动与硬件之间的“接线契约”

2.1 内核为什么非要设备树不可

早年ARM Linux上每换一款板子,都要在内核里写一大堆board-xxx.c文件,把外设地址、中断号、GPIO复用一个一个硬编码到C结构体里。树莓派、手机和平板这种海量变体一出来,这种模式直接失控,代码满是平台相关的东西,没法维护。设备树的作用,就是把这些硬件信息从代码里剥出来,变成一份内核启动时读入的结构化数据。

你可以把设备树理解成一张“接线表”:哪个设备的寄存器在哪、占几号中断、连在哪条I2C总线上、要用哪个引脚,都由设备树描述。内核拿到这张表之后,把对应设备和驱动匹配上,然后调用驱动的probe函数。编写驱动的人不需要知道所有板卡细节,只需要处理协议和寄存器逻辑。

2.2 dts/dtsi/dtb/dtc的关系先理顺

  • .dts是设备树源文件,扩展名是文件后缀,实际就是文本。
  • .dtsi是公共片段,类似C语言的.h,常放SoC公共部分,板级的.dts再通过#include引入。
  • .dtb是编译出来的二进制文件。
  • dtc是设备树编译器。
  • 反编译工具也很有用:dtc -I dtb -O dts -o output.dts input.dtb,拿到别人编好的固件,反编译看它怎么配置的,这在调板子时特别常见。

一个基本节点长这样:

&i2c3 { status = "okay"; clock-frequency = <400000>; touchscreen@5d { compatible = "goodix,gt911"; reg = <0x5d>; interrupt-parent = <&gpio1>; interrupts = <13 IRQ_TYPE_LEVEL_LOW>; reset-gpios = <&gpio3 9 GPIO_ACTIVE_LOW>; touchscreen-inverted-x = <0>; touchscreen-inverted-y = <1>; }; };

这里&i2c3是引用SoC基础dtsi里已经定义好的I2C控制器的节点,把它“打开”并添加子节点。这里的关键词:status决定这个控制器是否启用;compatible就是驱动匹配的字符串钥匙;reg是该设备在I2C总线上的从机地址;interruptsreset-gpios告诉驱动中断引脚和复位引脚。

2.3 compatible匹配规则:驱动和设备靠什么“对暗号”

设备树里写compatible = "goodix,gt911",驱动的结构体里也要有对应的匹配条目:

static const struct of_device_id gt911_of_match[] = { { .compatible = "goodix,gt911" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, gt911_of_match); static struct i2c_driver gt911_driver = { .probe = gt911_probe, .remove = gt911_remove, .driver = { .name = "gt911", .of_match_table = of_match_ptr(gt911_of_match), }, };

匹配顺序是:设备树遍历时,拿节点的compatible值和驱动注册到总线上的of_match_table比对,匹配成功就调用驱动的probe,并把struct device、中断、寄存器地址、引脚资源等通过struct platform_devicestruct i2c_client传进驱动。也就是说,probe不是你自己调用的,是内核框架匹配后自动触发的。很多新手在驱动里写一堆初始化代码却发现根本没执行,先查这个匹配关系,八成是compatible写错或者驱动根本没注册上。

2.4 实战:RK3568上给I2C触摸屏配置并修改横竖屏

RK3568是不少国产板卡常用的主控,它的BSP里设备树路径基本都在arch/arm64/boot/dts/rockchip/下。拿到一块带I2C触摸屏的板子,第一步是看核心板的原理图,确定触摸屏挂在哪组I2C控制器上、从机地址是多少、复位和中断接了哪个GPIO,然后在对应的板级dtsi里打开控制器并添加子节点,像上面那段代码那样。

如果是竖屏硬件,想改成横屏,除了显示方向要改,触摸坐标轴也要对应翻转。不少电容触摸屏驱动支持设备树属性,比如touchscreen-inverted-ytouchscreen-swapped-x-y,直接改dts比改驱动代码好维护得多。比如把竖屏变横屏:

&i2c3 { touchscreen@5d { compatible = "goodix,gt911"; reg = <0x5d>; interrupt-parent = <&gpio1>; interrupts = <13 IRQ_TYPE_LEVEL_LOW>; reset-gpios = <&gpio3 9 GPIO_ACTIVE_LOW>; touchscreen-inverted-x = <1>; touchscreen-inverted-y = <0>; touchscreen-swapped-x-y = <1>; }; };

这几个属性不同驱动实现不同,有的用touchscreen-swapped-x-y,有的用touchscreen-x-flip之类的名字,具体要看驱动源码对设备树属性的解析。我的习惯是改完先不重新编译整个内核,只编译设备树并单独替换启动分区里的.dtb,板子起不来还能快速回退。这个工作流比每次重刷kernel快太多了。

3. I2C子系统:把时序细节交给内核,驱动只关心设备逻辑

3.1 I2C协议的关键点,用一句话先过一遍

I2C就是两根线:SDA(数据)和SCL(时钟)。主机发起传输,先拉低SCL时机发送起始位,再发送7位地址加一位方向位。地址匹配的从机会拉低SDA应答(ACK)。后面读写字节按位传输,每8位数据跟着一个应答位。读完或写完发停止位收尾。关于多字节连续读写,本质就是主设备先把要操作的寄存器地址写进去,再切换方向连续读多个字节,或者连续写多个字节,从设备内部寄存器地址自动增加。

不理解协议细节能不能写Linux驱动?能。因为Linux I2C核心层已经封装好了,但你在排查故障的时候,必须回来看时序图,不然分不清“设备没响应”和“时序不对”到底哪个原因。

3.2 Linux的I2C框架:adapter、client、driver三个角色

Linux对I2C的抽象分两层:

  • adapter:I2C控制器本身,也就是硬件上一组SCL/SDA引脚的对应驱动逻辑,负责发送起始位、地址、读写字节、ACK/NACK这些底层动作。
  • client:挂在I2C总线上的从设备,和一条总线上的一对地址绑定。
  • driver:从设备对应的驱动,实现具体的寄存器操作逻辑。

一个从设备要工作,必须先有一个I2C控制器驱动注册了adapter,然后设备树里的子节点被解析出来注册成client,再通过compatible匹配到i2c_driver,触发probe。简单记:adapter是高速公路,client是入口匝道,driver是出口。

3.3 手写一个I2C从设备驱动的骨架

下面是一个常见的I2C设备驱动的核心框架,以读取传感器ID为例:

static int mydev_probe(struct i2c_client *client) { struct i2c_adapter *adapter = client->adapter; u8 reg = 0x00; s32 val; if (!i2c_check_functionality(adapter, I2C_FUNC_I2C)) { dev_err(&client->dev, "adapter does not support I2C\n"); return -EOPNOTSUPP; } /* 通过smbus函数读一个寄存器,适用于EEPROM、传感器等简单从设备 */ val = i2c_smbus_read_byte_data(client, reg); if (val < 0) { dev_err(&client->dev, "read failed: %d\n", val); return val; } dev_info(&client->dev, "chip id: 0x%02x\n", val); return 0; } static int mydev_remove(struct i2c_client *client) { return 0; } static const struct of_device_id mydev_of_match[] = { { .compatible = "vendor,mydev" }, { } }; MODULE_DEVICE_TABLE(of, mydev_of_match); static struct i2c_driver mydev_driver = { .probe = mydev_probe, .remove = mydev_remove, .driver = { .name = "mydev", .of_match_table = mydev_of_match, }, }; module_i2c_driver(mydev_driver); MODULE_LICENSE("GPL");

注意probe拿到的是struct i2c_client *,里面已经包含设备树里的reg地址、中断号、GPIO等信息,直接读取就行。需要读一字节以上的寄存器数据时,可以用i2c_transfer填充struct i2c_msg数组,先写寄存器地址,再读数据,这个更接近原生协议。很多EEPROM驱动就是用i2c_transfer实现多字节读写的。

3.4 排错实战:I2C通信失败到底从哪查起

I2C调试有一个固定的排查链条,按顺序来能省一多半时间:

  • 先看设备树节点有没有被正确解析,启动日志里有i2c相关的报错没有,或者去/sys/bus/i2c/devices/下看0-005d这类目录是否存在,0-005d对应I2C控制器编号和从机地址。
  • i2cdetect -y <bus号>扫描总线上有哪些地址响应。注意,有些传感器在初始化前不响应地址,这种扫描不出来,但总线本身是通的。
  • 用逻辑分析仪抓SCL/SDA时序。看起始位、地址、ACK位。地址不对、上拉电阻没装、设备供电没起来,都能在时序上看出来。
  • 检查引脚复用。I2C引脚经常和别的外设共用Pinctrl,设备树里GPIO被其他节点占用,或者pinctrl-0没设置正确,信号根本到不了控制器。

我遇到最多的问题就是设备树里忘了status = "okay",或者clock-frequency配了从机不支持的速率,导致偶发通信失败。还有一个隐藏很深的坑:I2C总线挂了很多设备时,某个设备的SDA线故障可能把整条总线拖死,表现为所有设备都响应不了地址。这时候挂个逻辑分析仪最直观,比反复改驱动高效多了。

4. CAN总线在Linux下的形态:从SocketCAN理解驱动对接和排错

4.1 CAN和I2C/SPI完全不是一路的协议

I2C是典型的“一主多从”,CPU永远是主机。CAN不一样,总线上所有节点地位平等,任意节点都能主动发帧。消息靠帧ID来决定优先级,多个节点同时发送时,ID小的获胜,低优先级的节点自动退避重发。这个“多主机+仲裁”的特性,决定了它在汽车、工控、机器人里不可替代——任何一个控制器都能随时上报故障,而不是等中央控制器来轮询。

CAN帧分标准帧(CAN 2.0A,11位ID)和扩展帧(CAN 2.0B,29位ID),数据场最多8字节。CAN FD在后面又扩到最多64字节数据场,但对Linux驱动的框架来说,差异都被封装了起来,不需要改驱动逻辑。

4.2 SocketCAN:把CAN接口当成普通的网络接口来用

Linux对CAN的抽象非常妙——直接用PF_CAN套接字,把每个CAN控制器变成一个网络接口,叫can0can1。这么设计的好处是,你可以用网络栈那一整套工具和思路来操作CAN:配置IP不适用,但可以用ip link配置波特率,用抓包工具分析报文。

典型用法:

ip link set can0 type can bitrate 500000 ip link set can0 up candump can0 cansend can0 123#DEADBEEF

我见过很多老式项目把CAN驱动做成字符设备,自己定ioctl收发。一旦车厂需要记录总线日志、模拟多个节点,这种方案简直要命。而SocketCAN天然支持candump -l记录pcap文件、canplayer回放历史报文、cansend模拟单帧,调试效率不是一个量级。

4.3 Linux CAN驱动要实现的几个关键部分

在Linux内核里,CAN设备驱动主要是实现:

  • alloc_candev分配并初始化struct can_priv
  • 填充struct can_ops,包括do_set_modedo_get_berr_counter等。
  • 发送和接收路径都是基于网络的net_device_ops。发送通过ndo_start_xmit,接收通过netif_rx将CAN帧上送到网络协议栈。
  • 处理总线错误状态、重启等通过内核提供的can_bus_offcan_restart等辅助函数。

如果用的是典型SPI接口外挂CAN控制器,比如MCP2515,设备树里会这样配:

&spi0 { status = "okay"; mcp2515: mcp2515@0 { compatible = "microchip,mcp2515"; reg = <0>; clocks = <&mcp2515_clk>; interrupt-parent = <&gpio0>; interrupts = <1 IRQ_TYPE_EDGE_FALLING>; spi-max-frequency = <10000000>; }; };

驱动方面,MCP2515在主线内核有mcp251x驱动。它本质上是“SPI从设备 + 中断通知”,收到CAN中断后再通过SPI把整个报文读出来。内核框架需要处理的事情很多,比如中断下半部、发送队列、错误状态上报,但这些都被SocketCAN封装成标准接口了。

4.4 波特率、采样点和总线错误处理

CAN调试里最容易被忽略的是波特率和采样点。两个参数不光要“一样”,还要看具体位时序。ip link set can0 type can bitrate 500000这种方式用的是内核默认的采样点,有些场合需要精确控制:

ip link set can0 type can bitrate 500000 sample-point 0.8

采样点决定什么时候去读电平,工控链路易受干扰时,适当调整能让误码率明显下降。另外,CAN节点出现持续错误后会进入bus-off状态,驱动要能检测到并恢复。用ip -details link show can0能看到错误状态和统计信息,这是排查物理层问题的第一步。

5. 把整套路径串起来:一块板子的驱动开发与调试流程

5.1 先画一张“设备地图”,再去看代码

拿到一块新板子,不要急着翻内核源码,先画一张表把外设信息列全。这张表就是驱动开发的起点,也是设备树配置的“需求文档”。

外设总线从机地址/ID中断引脚复位引脚供电典型速率
GT911触摸屏I2C30x5dGPIO1_13GPIO3_093.3V400kHz
MCP2515SPI0CS0GPIO0_013.3V10MHz
OLED屏I2C10x3c3.3V100kHz
CAN收发外接5V500kbps

有了这张表,写设备树就只是“翻译”工作,不用看一段代码查一下原理图。我在正规项目里都会要求硬件工程师先提供GPIO分配表和外设连接表,这些信息不齐,驱动开发就是大海捞针。

5.2 配置设备树后,怎么确定驱动真的“上电开工”了

设备树编译打包后,启动进入系统,在/sys/firmware/devicetree/base/下能看到对应节点;在/sys/bus/i2c/devices//sys/bus/spi/devices/下能看到设备目录。如果设备目录存在但驱动没有成功probe,一般是compatible没匹配上,或者驱动内probe执行失败,比如中断申请失败、GPIO请求冲突。

dmesg | grep -i i2cdmesg | grep -i probe能快速看到内核在枚举设备时打印的信息。有的驱动设计不好,probe失败不打印任何日志,这时候我会在probe入口加dev_info,从第一行开始逐段确认卡在哪,或者用dev_erri2c_check_functionalitydevm_gpiod_getrequest_threaded_irq每一步的返回值都打出来。

5.3 重点排查:复位信号、引脚复用、中断配置

热搜里经常有“设备树设置复位信号时间”的问题,这其实是一个典型细节。很多触摸屏、NFC芯片、音频Codec在上电后需要一定的复位时间,设备树里一般通过reset-gpiosreset-deassert-us这类属性控制复位释放和延时。

reset-gpios = <&gpio3 9 GPIO_ACTIVE_LOW>; reset-deassert-us = <10000>;

如果你发现外设时不时“初次访问失败”,但重启后又好了,很大概率就是这个复位时序不对。另一个高发问题是中断号配置错误。设备树里的interrupts不只是写个GPIO号那么简单,还要指定触发类型,比如IRQ_TYPE_LEVEL_LOWIRQ_TYPE_EDGE_FALLING。如果驱动和物理设备触发方式不匹配,中断会丢失或者狂触发。我的经验是先用cat /proc/interrupts看中断是否注册,再用示波器或逻辑分析仪抓一下引脚电平变化,对比触发类型是不是一致。

5.4 内核裁剪与部署时的注意事项

驱动开发稳定之后,第二个任务是系统裁剪优化。嵌入式设备的flash容量有限,总不能把整个内核源码和所有驱动都塞进去。用make menuconfig把不用的驱动关掉,把不再需要的内核调试选项停掉,可以显著缩小内核体积、加快启动速度。

裁剪的原则是“最小可用集”:先用全功能配置跑通业务,再备份一个可启动的内核和设备树,然后一版一版往下裁剪。我见过有人一上来就拼命关驱动,结果关掉了文件系统或根总线相关的模块,板子直接起不来,又不知道是哪项配置出问题,白白浪费几天。正确的做法是:每次只做一组裁剪,保留一个能启动的备份,把“内核镜像+设备树+Buildroot/rootfs”作为一个版本整体记录,这样回溯问题时效率高得多。

最后聊几句实在的

驱动开发这条路,真正重要的不是背出某个API,而是把“硬件 — 设备树 — 内核驱动 — 用户态工具”这条链路上的数据流和错误流理清楚。我最开始写I2C驱动时,遇到通信失败就反复改代码,后来发现十有八九是设备树没配对或者硬件上拉了错引脚。从那以后,我调试驱动一律先查设备树、再抓时序、最后才动驱动代码。如果你正准备入这行,建议一定准备一块带逻辑分析仪的开发环境,成本不高,却能让你少走太多弯路。等这条链路走熟了,哪怕换一款主控、换一套外设,你也能用同一套方法论快速定位问题,这才是“从内核模块到设备树、I2C/CAN系统路径”给你的真正底气。

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

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

立即咨询