0. 前言
在嵌入式 Linux 驱动开发中,如何驱动一个传感器往往是迈向进阶的一道门槛,尤其是对于学习过单片机裸机开发的人来说,还需要进行思维的转变。因为相对于单片机裸机开发,Linux 驱动开发不仅涉及到软件与硬件之间的通信,还涉及到内核态与用户态的数据交互,以及字符设备的框架搭建等操作。
本文以经典的六轴姿态传感器 MPU6050 为例,开发板使用 LubanCat 2N,搭载 RK3568 芯片,从零开始记录一个完整的驱动开发过程。
我将会写一个标准的 I2C 字符设备驱动,构建从设备树配置,到内核驱动,再到用户空间的应用的完整链路。
对于已经熟练裸机 I2C ,了解 Linux 驱动的基本概念,比如file_operations、probe初始化函数等,但从未独立完成过一个从设备树,到内核驱动,再到用户空间的完整传感器的朋友。阅读完本篇文章,你将学会 RK3568 设备树中 I2C 设备的正确写法,以及现代 Linux 内核 I2C 客户端驱动的标准写法,除此之外,你将会对字符设备的完整注册流程有一个清晰的认知。
考虑到可能有不了解 Linux 驱动基本概念的朋友,本文在讲到相关内容时,也会对这些概念进行简单的介绍。
好了,话不多说,下面进入正文。
1. 硬件连接与环境介绍
1.1 硬件方面
首先,我们需要先搞清楚物理连接,并确认硬件处于正常工作的状态。
1.1.1 硬件介绍
我们使用的开发板是 LubanCat 2N ,基于 Rockchip RK3568 SoC。
传感器模块使用的是 MPU6050 ,该模块默认通过 I2C 接口通信,典型工作电压为 3.3V~5V,I2C 从机地址通常为 0x68(AD0 引脚接地时)或 0x69(AD0 接高电平时)。
市面上很多廉价模块实际焊接的芯片并非原装的 MPU6050,而是 MPU6500,它们的 I2C 地址一样,并且寄存器大部分兼容,但 WHO_AM_I 寄存器(寄存器地址为0x75)的值是不同的。正宗的 MPU6050 的 WHO_AM_I 寄存器的值为 0x68,而 MPU6500 的 WHO_AM_I 寄存器值为 0x70。
这里需要提一下,当芯片是 MPU6050 且 AD0 引脚接地时,会出现 WHO_AM_I 寄存器的值和从机地址的值都为 0x68 的情况,但是实际上,这并不是说他们之间有什么关系,只是一个巧合罢了。
I2C 设备的从机地址通常用于 I2C 总线进行寻址,而 WHO_AM_I 通常用于确认设备类型,两者数值可能相同,但代表完全不同的概念,不能混淆。
1.1.2 硬件连接
在进行硬件连接之前,我们需要看看板子的引脚图。下面是我使用的板子的引脚图:
MPU6050 使用的是 I2C 接口,我们将其连接到 RK3568 的 40-Pin 扩展接口上面。这里我们使用 I2C3 ,也就是物理编号分别为 3 号和 5 号的两个引脚。
在连接时,我们将 MPU6050 芯片的 VCC 引脚接到板子上的物理引脚 1 号上面,也就是 3.3V 电源引脚。
将 MPU6050 芯片的 GND 引脚接到板子上的物理引脚 9 号,也就是 GND。
芯片上面的 SCL 引脚接到板子上面的 5 号引脚,SDA 接到板子上的 3 号引脚。
对于芯片上的 AD0 引脚,单个 MPU6050 芯片的 AD0 引脚如果悬空,地址是不稳定的(可能随机 0x68 或 0x69)。但在整个 GY-521 模块上(包含 MPU6050 芯片以及外接电路),通常有一个 4.7kΩ 下拉电阻把 AD0 连接到 GND,所以不接任何线时默认是低电平,此时I2C 地址为 0x68。这里我们不管这个引脚,让它默认低电平。
连接好的图片如下:
1.1.3 验证硬件连接是否成功
接线完成后,我们可以先使用 Linux 自带的 i2c-tools 工具包进行验证,看看硬件的连接是否有问题。
在板子的命令行执行下面命令:
sudo i2cdetect -y 3
结果如下:
先讲一下这个命令是干什么的。这条命令的作用是扫描 I2C 总线 3 上的所有设备,并显示哪些地址上有设备响应。
如图可以看到,在地址 0x68(行 60,列 8)显示了十六进制的数 68,这表示 I2C 总线 3 上有一个从设备成功应答了地址 0x68。现在几乎可以百分之百确定 MPU6050 已经正确连接并通上电了,并且 AD0 引脚处于低电平状态(默认低电平),地址就是标准的 0x68。
如果用一根杜邦线将 AD0 与 3.3V 的 VCC 连接起来,结果如下:
可以看到,我们的 MPU6050 又成功应答了地址 0x69 。
我们去掉这根杜邦线,依然使用 0x68 作为从机地址。
然后,我们继续确认一下芯片型号,看看到底是 MPU6050 还是 MPU6500 ,在命令行执行下面命令:
sudo i2cget -y 3 0x68 0x75
这条命令会读取编号为 3 的 I2C 总线上从机地址为 0x68 的设备中地址为 0x75 的寄存器 ,其中 0x75 是 WHO_AM_I 寄存器的地址,下面是命令执行后的结果:
运行该命令后结果是 0x70 ,这说明我手中的芯片其实是 MPU6500 ,它和 MPU6050 的寄存器大部分是兼容的。
到此,我们已经验证了硬件连接成功,并且 I2C 的总线是正常工作的。
1.2 软件方面
在嵌入式 Linux 开发中,搭建一个稳定且兼容的交叉编译环境往往比写代码更折磨人,但好在环境的搭建只需要一次,这是一本万利的。为了彻底解决兼容性问题并实现高效开发,我搭建了一套 Docker + NFS 的开发环境。
1.2.1 编译环境
我的宿主机使用的是 Ubuntu 24.04,这是一个比较新的发行版。但是 RK3568 的官方 SDK 和交叉编译工具链通常在 Ubuntu 20.04 上经过验证。如果直接在 24.04 上强行搭建环境,极易出现兼容性问题。
因此,我选择引入 Docker。
我的宿主机:Ubuntu 24.04,负责代码编辑,日常操作,作为 NFS 的服务端。
Docker 容器:Ubuntu 20.04,负责运行 make、交叉编译器。
此外,在创建 Docker 时,可以使用 -v 参数将宿主机的代码目录挂载到容器内部,这样在宿主机上编写代码,切个终端就能在 Docker 里直接编译,非常方便,在保证了兼容性的同时又保留了宿主机的操作体验。
1.2.2 文件传输
传统的开发中,编译好 .ko 驱动模块后,往往需要用 scp 或者 U 盘拷贝到开发板上,每次修改代码都要重复 编译 -> 拷贝 -> 加载 的繁琐过程,尤其是在代码调试过程中效率极低。
为了图个方便,我搭建了 NFS (Network File System) 服务,打通了 宿主机、Docker、开发板 三者之间的文件共享。
在这个环境下,开发流程如下:
- 在宿主机写代码
- 进入 Docker 终端的对应目录,编译写好的代码,编译完成后 .ko 文件已经出现在共享文件夹中了。
- 在板子的终端,直接执行
insmod /mnt/nfs/my_driver.ko,模块就加载完成了。
这套环境一旦配好,开发效率就能提升一个档次,完全省去了反复传输文件的过程。
2. 核心概念与前置知识
在正式敲代码之前,我们需要先转变一下观念,在单片机裸机开发中,驱动往往就是直接操作寄存器(比如用 GPIO 翻转来模拟 I2C 时序)。但在 Linux 这个庞大的操作系统中,分层 和 抽象 才是核心哲学。
我们要写的 MPU6050 驱动,本质上是 I2C Client 驱动 与 字符设备驱动 的结合体。
2.1 Linux 中 I2C 子系统的分层架构
Linux 将 I2C 驱动体系设计成了三层结构,理解了这个结构,可以帮助我们知道什么是我们要做的,什么 Linux 已经帮我们做好了。
2.1.1 三层模型
先简单介绍一下三层模型的构造。
-
底层:I2C 总线驱动。
它负责操作 SoC 内部的 I2C 控制器硬件,产生 SCL 时钟信号和 SDA 数据信号。这是芯片原厂已经编写好的,在 RK3568 的内核里,这部分已经写好并编译进去了。
我们不需要碰这里,只需要知道板子上面确实有 I2C3 这条路可以使用。
-
中间层:I2C 核心
它提供了一套通用的 C 语言接口,并且屏蔽了底层的硬件差异。无论你用的是瑞芯微的芯片,还是树莓派,又或者是 STM32MP1,只要调用这套标准的接口,代码就能通用。
这也是不需要我们编写的,我们只需要会调用就可以了。
-
上层:I2C 设备驱动
这是我们需要编写的内容。我们不关心波形怎么翻转,我们只关心具体的业务逻辑。
比如,我要向 MPU6050 的 0x6B 寄存器写入 0x00 来唤醒它。
为了让大家更直观地理解,我们可以把这三层结构映射到 Linux 内核代码的数据结构 上,这与我们后面写的代码息息相关。
-
struct i2c_adapter(适配器)这个结构体对应底层的 I2C 总线驱动。在 Linux 内核眼里,RK3568 内部的每一个 I2C 控制器(I2C0, I2C1, I2C3 等)都是一个 adapter。它掌握着 I2C 总线通信的时序控制权。
-
struct i2c_client(客户端)对应我们板子上的那个 MPU6050 硬件。这个结构体记录了硬件信息,比如挂在哪条总线上面(
&i2c3),从机地址是多少(0x68)等等。这个结构体通常不需要我们自己去构造,它是根据设备树文件中的节点信息描述自动生成的。
-
struct i2c_driver(驱动程序)这是我们将要编写的软件代码。它包含了一堆函数指针,比如发现设备时我们应该干什么(
probe),设备移除时我们应该干什么(remove)等等。它还包含了
id_table或of_match_table,用来告诉内核该驱动可以和哪些设备进行匹配。
三层模型的结构到这里应该比较清楚了,但是他们之间是怎么协同工作的呢?其实还是比较模糊的,下面来讲一下他们的协同工作。
这实际上也是 Linux 驱动开发中最核心的 总线-设备-驱动 模型。这个模型的相关内容我之前的文章已经描述的很清晰了,这里只结合 I2C 的三层模型简单概括。当然,要描述他们的协同工作过程,这就要涉及到后面要讲的内容了,有些不理解没关系,可以看完整篇文章再回头看一下这部分,你会恍然大悟。
首先,当系统启动并解析设备树时,会发现 I2C3 节点下有一个mpu6050@68,然后内核就会生成一个 struct i2c_client,这是我们上面提到的客户端结构体,它会被挂载到 I2C 总线链表上。此外,这个结构体内有一个象征着它身份的成员:compatible,这是一个字符串。
然后,当我们使用 insmod 加载模块时,内核会注册一个struct i2c_driver,正如上面提到的,这个结构体内有一个 of_match_table,而 of_match_table内也有一个 compatible 成员,作为辨识这个驱动结构体的一个属性。
这时,I2C 核心会不断遍历总线链表上的 Driver 和 Client ,一旦发现两者的 compatible 字符串一模一样,这就代表匹配成功。内核自动调用驱动里的 probe() 函数,并将那个 struct i2c_client 作为参数传进去。这也就是为什么我们在写驱动时,probe 函数的第一个参数是 struct i2c_client *client, 拿到了这个指针,我们就拿到了操作硬件的权限,从而能对硬件进行各种操作。
2.1.2 两个容易混淆的地址
在编写 I2C 驱动时,有两个地址是很容易搞混淆的,即 I2C 设备地址和设备内部的寄存器地址。
I2C 设备地址可以理解为硬件设备在 I2C 总线上的一个编号,对于 MPU6050,通常是 0x68,这个地址用于让 I2C 控制器找到设备,通常在设备树(DTS)文件 中指定,后面添加设备树节点时会详细讲解。
设备内部寄存器地址可以理解为挂载在 I2C 总线上的一个具体设备内部的地址编号。比如在 MPU6050 中,0x75 是 WHO_AM_I 寄存器的地址,这个寄存器地址是我们在驱动代码中操作的对象。
2.2 设备树
在 ARM Linux 中,内核不会自动扫描板子上接了什么传感器,我们需要通过设备树文件告诉内核:在 I2C3 总线上挂载了一个 MPU6050 。
2.2.1 为什么要修改设备树
正如我们在 2.1.1 节最后讲到的,系统解析设备树后才会生成struct i2c_client结构体,而我们在使用insmod加载驱动时,总线需要在总线链表中寻找与这个驱动匹配的设备。
假如我们不去修改这个设备树,也就不存在与我们的 MPU6050 对应的struct i2c_client结构体,那么总线根本就无法找到与我们的驱动匹配的设备,从而无法调用 probe 初始化函数。
2.2.2 怎么修改设备树
现在我们已经知道了为什么要修改设备树,下面我们要弄懂怎么修改设备树。
修改设备树通常有两种方法,分别为修改设备树源码法和插件法,为了从根源上理解设备树的编译流程,本文选择了第一种直接修改源码的方式。
无论使用哪种方式修改,我们在 I2C 控制器节点下添加设备时,都必须要提供以下三个核心属性,缺一不可:
-
compatible:这是最重要的属性,它是驱动和设备匹配的暗号,它的值通常是一个字符串。前面提到过内核启动时会解析设备树节点,当内核启动时,会拿这个字符串去遍历驱动链表,看有没有哪个驱动的
of_match_table里的compatible和这个字符串相同,如果有,就配对成功。
-
reg:代表设备的物理地址,在 I2C 总线中,这就是从机地址。对于本例中的 MPU6050 ,该值就是
0x68。内核会根据这个地址,在底层限制 I2C 控制器只与这个地址进行通信。
-
status:设置为
"okay"。很多外设节点在主设备树里默认是"disabled"的,我们需要把它标记为okay,内核才会真正去初始化它。
这里我们只讲这三个重要的属性,具体设备树代码会放在下一章。
2.3 字符设备架构:一切皆文件
I2C 子系统解决了通信的问题,但是我们从 MPU6050 模块中拿到数据后,怎么将这个数据传递给用户呢?
Linux 设计哲学的核心是:一切皆文件。
2.3.1 字符设备
我们要做的是把 MPU6050 这个硬件设备伪装成一个文件,路径通常是/dev/mpu6050。
当用户执行cat /dev/mpu6050时,内核会调用驱动中的 read 函数。
当用户运行 echo ... > /dev/mpu6050 时,内核会调用驱动里的 write 函数。
从而实现用户与设备的交互。
2.3.2 file_operations 结构体
如果说 I2C 驱动的核心是 i2c_driver,那么字符设备驱动的核心就是 file_operations。我们可以把它想象成一个映射表:
如图所示,用户空间的每一个操作都对应着驱动程序中的一个函数。这些操作和函数的映射关系正是 file_operations结构体要实现的。
2.3.3 用户空间与内核空间的数据传输
Linux 操作系统为了安全和稳定,将内存划分为了两块完全隔离的区域:即内核空间和用户空间。
- 内核空间是驱动程序运行的地方,拥有最高权限,可以访问所有硬件。
- 用户空间是应用程序运行的地方,权限受限,不能随意读取内存。
内核空间与用户空间的内存隔离就导致了一个严重的问题:我们的驱动程序读取到的传感器数据位于内核空间,不能直接赋值给应用程序的变量,因为应用程序的变量位于用户空间。
为了解决这个问题,内核提供了两个专用的函数,这是我们在编写 read 函数时必须使用的:
copy_to_user(to, from, n):将数据从内核空间拷贝到用户空间。这个函数用于读取传感器数据。
copy_from_user(to, from, n):将数据从用户空间拷贝到内核空间。这个函数用于写入配置参数。
3. 修改设备树与编写驱动代码
在对核心概念有了一定的了解之后,我们正式开始写代码。本章我们要完成两项任务:
第一、修改内核设备树源码,让系统能识别出我们的 MPU6050。
第二、编写 my_mpu6050.c 驱动代码,实现从硬件初始化到数据读取的全流程。
3.1 修改设备树源码
正如前文所述,我们采用直接修改内核设备树源码的方式。
3.1.1 找到并修改 .dts 文件
一个非常浅显的道理,在修改这个文件之前我们必须先找到它。下面介绍一下如何寻找你使用的板子在 bootloader 阶段解析的设备树文件。
我们在板子的/boot目录下,执行下面命令:
ls -lh
这时我们通常能看到一个后缀为.dtb的文件,系统在启动时就会加载这个文件从而构建出整个设备树,但实际上这个文件是一个软链接,相当于 Windows 上的快捷方式,系统读取这个文件时会顺着软链接的指引找到真实的.dtb文件,它是设备树文件(后缀为.dts)编译后生成的二进制文件。
如下图,是我使用的板子的情况:
请看倒数第二行,系统真正使用的是软链接指向的文件,也就是/boot/dtb/目录下的rk3568-lubancat-2-v3.dtb。
现在我们就有目标了,我们要去修改内核源码目录中的rk3568-lubancat-2-v3.dts文件,然后在 Docker 容器中编译设备树文件得到rk3568-lubancat-2-v3.dtb,注意,只需要编译设备树文件,并不需要编译整个内核源码,然后在 Docker 中把编译好的设备树文件用cp命令拷贝到共享文件夹中。
下面切换到板子的终端,我们将共享文件夹中的rk3568-lubancat-2-v3.dtb拷贝到/boot/dtb/目录下,覆盖原本的旧文件。
完成上面步骤后,重启板子,让系统在启动时将我们新添加的设备加载到系统中。
现在目标已经很明确了,我们下面修改设备树文件。我们去内核源码目录arch/arm64/boot/dts/rockchip/下找到rk3568-lubancat-2-v3.dts文件,各种各样板子的设备树的.dts和.dtb文件都在这个目录下。我们打开这个文件并在文件末尾添加如下代码:
&i2c3 {
status = "okay";
my_mpu6050:mpu6050@68 {
compatible = "lubancat,mpu6050-demo";
reg = <0x68>;
status = "okay";
};
};
添加后的界面如下图:
下面讲讲这里的代码为什么要这样写。
第一章讲硬件连接时,我们将 MPU6050 接到了板子的 Pin 3 (SDA) 和 Pin 5 (SCL) 上。查阅第一章贴出的引脚图可知,这两个引脚是属于 I2C3 控制器的。
打开 rk3568-lubancat-2-v3.dts 设备树文件,你可能会发现里面已经定义了一些代码,里面虽然没有 I2C3 控制器的定义,但我们还输不需要从零定义 I2C3 控制器,为什么呢?因为它定义在头文件里面,大家可以去该dts文件引用的头文件中寻找,如果还是找不到,那就继续去头文件的头文件中找,最终,你会在rk3568.dtsi这个文件中找到 I2C3 控制器的完整定义。如下图:
这里讲这些东西的目的是说明 I2C3 其实已经定义好了,我们只需要在这个基础上面进行简单的修改就行。
& 符号表示引用,I2C3 是一个标签,它指向了 RK3568 芯片级设备树(rk3568.dtsi)中定义的 I2C3 控制器节点。这样我们就不需要关心 I2C3 的物理基地址、中断号等复杂底层信息(那些原厂都写好了)。我们只需要用 & 拿到它的句柄,就可以直接往里面加东西或者对现有的东西进行修改。
如上面rk3568.dtsi文件所示,I2C3 控制器的status默认为"disabled",我们的第一个修改就是将 I2C3 控制器的status修改为"okay",这样内核才会去初始化这个控制器。
然后定义了一个节点,其中my_mpu6050是标签,设备树的其他地方想引用这个节点可以直接&my_mpu6050,可以类比我们添加节点时的&I2C3和dtsi文件中的 I2C3 标签。冒号 : 后面,@ 符号前面的部分mpu6050是节点名称,系统启动后,你在板子的 /proc/device-tree/ 目录下看到的文件夹名字就是由它决定的。@ 符号后面的部分68代表设备的物理地址,这个数字必须和花括号内部的 reg = <0x68> 属性保持一致,这里的 68 是十六进制(设备树节点名中通常不加 0x 前缀)。
除此之外,就是我们之前讲过的compatible属性和status,这里的compatible字符串的内容要和后面驱动程序中的相同。
到此,设备树就修改完成了。
3.1.2 编译设备树并验证
修改完设备树源码后,我们需要在 Docker 容器中编译设备树。在 Docker 容器中进入内核源码目录,执行下面命令只编译设备树:
make ARCH=arm64 dtbs
结果如下:
如图,编译成功后生成的dtb文件依然位于arch/arm64/boot/dts/rockchip/目录下。然后使用我搭建的 NFS 共享文件夹进行文件的传输,将编译生成的dtb文件拷贝到共享文件夹中:
cp arch/arm64/boot/dts/rockchip/rk3568-lubancat-2-v3.dtb ../../nfs_share/
如下图:
可以看到在板子上面挂载的共享文件夹中已经可以看到这个dtb文件了。
然后将这个文件拷贝到/boot/dtb/目录下,覆盖原来的旧文件。之后就可以删除共享文件夹中的这个dtb文件了。
再然后,我们重启开发板,Linux 内核启动后会将设备树展开在 /proc/device-tree/ 目录下,我们去这里看看能不能找到我们在设备树文件中添加的设备。使用下面命令:
cd /proc/device-tree/i2c@fe5c0000/
现在,我们已经可以看到mpu6050@68了。
至此,我们已经成功识别了设备树中添加的节点。
3.2 编写核心驱动代码
硬件和设备树都准备好了,现在我们开始编写 my_mpu6050.c。
为了让代码逻辑清晰,我们将驱动分为三个部分来实现:核心结构体定义、Probe 初始化流程、文件操作接口 。
3.2.1 核心结构体与头文件
首先,我们需要定义一个结构体,把字符设备,I2C 客户端句柄等所有资源都打包在一起,方便在函数之间进行传递。
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#include <linux/i2c.h>
#include <linux/types.h>
#include <linux/device.h>
#include <linux/slab.h>
#define DEVICE_NAME "mpu6050"
#define DRIVER_NAME "mpu6050_driver"
//设备结构体
struct mpu6050_dev{
dev_t devid; //设备号(主+次)
struct cdev cdev; //字符设备核心结构体
struct class *class; //类(用于自动创建/dev节点)
struct device *device; //设备
struct i2c_client *client; //I2C核心指针
};
//声明一个指针,后面分配内存
struct mpu6050_dev *mpu_dev;
在 Linux 内核中,一个驱动可能匹配了多个设备,为了避免全局变量满天飞,我们通常会定义一个结构体,把这个设备的所有资源全部打包起来,这体现了面向对象的思想。
3.2.2 Probe 函数
当设备树中的 compatible 属性与驱动匹配成功时,内核会自动调用 probe 函数。我们需要在这里完成硬件的初始化 和 注册字符设备。
这里有一个关键点:也就是我们在第一章提到的,MPU6500 的 ID 是 0x70,而 MPU6050 是 0x68。为了让驱动通用,我们需要在初始化时做兼容判断。
static int mpu6050_probe(struct i2c_client *client, const struct i2c_device_id *id)
{
int ret = 0;
printk(KERN_INFO "MPU6050:Driver Starting...\n");
//为上面声明的结构体申请内存
mpu_dev = devm_kzalloc(&client->dev, sizeof(struct mpu6050_dev), GFP_KERNEL);
if(!mpu_dev)
{
return -ENOMEM;
}
//保存client指针
mpu_dev->client = client;
//硬件初始化
i2c_smbus_write_byte_data(client,0x6B,0x00);//写入0x00到0x6B(PWR_MGMT_1),唤醒传感器
//注册字符设备
ret = alloc_chrdev_region(&mpu_dev->devid,0,1,DEVICE_NAME);//自动帮我们要一个没被占用的设备号
if(ret < 0)
{
printk(KERN_INFO "MPU6050:Failed to allocate device ID\n");
return ret;
}
mpu_dev->cdev.owner = THIS_MODULE;
cdev_init(&mpu_dev->cdev,&mpu6050_fops);//把file_operations绑定到cdev上
ret = cdev_add(&mpu_dev->cdev,mpu_dev->devid,1);//添加cdev到内核
if(ret < 0)
{
printk(KERN_INFO "Failed to add cdev\n");
goto err_unregister_region;
}
mpu_dev->class = class_create(THIS_MODULE,"mpu6050_class");//创建类
if(IS_ERR(mpu_dev->class))
{
goto err_del_cdev;
}
mpu_dev->device = device_create(mpu_dev->class,NULL,mpu_dev->devid,NULL,DEVICE_NAME);//创建设备
if(IS_ERR(mpu_dev->device))
{
goto err_destroy_class;
}
printk(KERN_INFO "MPU6050:Driver Load Successful\n");
return 0;
//错误处理
err_destroy_class:
class_destroy(mpu_dev->class);
err_del_cdev:
cdev_del(&mpu_dev->cdev);
err_unregister_region:
unregister_chrdev_region(mpu_dev->devid,1);
return ret;
}
probe初始化函数是驱动和设备匹配之后执行的第一个函数。在这里我们需要为之前声明的mpu_dev结构体分配内存并初始化。这里我使用了devm_kzalloc函数,在模块卸载时,这块内存会自动回收,因此不需要我们手动回收。
- 分配好内存后,我们将内核调用
probe函数时传进来的client指针存到我们的设备结构体mpu_dev中。 i2c_smbus_write_byte_data函数用于向设备的指定寄存器写入一个字节的数据。第一个参数的类型是struct i2c_client *,里面包含了从机地址(例如 0x68)和适配器信息,函数通过它知道要往哪条总线、哪个设备发消息。第二和第三个参数分别是要写入的地址和内容。这行代码就是写入0x00到0x6B,从而唤醒 MPU6050 。- 再然后就是注册字符设备。第一步就是申请一个设备号,现代 Linux 推荐使用 动态分配 的方式,而
alloc_chrdev_region正是用来向内核申请一个空闲的设备号。函数执行成功后会将申请好的设备号写入我们传入这个函数的第一个参数位置中,即mpu_dev->devid的地址。第二个参数是次设备号起始值。第三个参数是要申请的数量。第四个参数是设备名称。 mpu_dev->cdev.owner = THIS_MODULE。THIS_MODULE是一个内核宏,它本质上指向当前这个驱动模块自身。cdev.owner是字符设备结构体中的一个指针,用来记录这个设备属于哪个内核模块。当 App 调用open()打开设备时,内核会自动将当前模块的引用计数加一。当 App 调用close()关闭设备时,内核会自动将引用计数 减一。这行代码保证了只要有用户在使用设备,驱动就绝对不会被卸载。cdev_init函数把我们将要注册的字符设备对象 (cdev) 和我们写好的操作函数集 (file_operations) 绑定在了一起。这样内核就知道,当有人对这个设备执行read或者write时,内核应该去调用哪个函数。- 在做完前面的所有准备工作之后,我们使用
cdev_add函数建立 设备号 (dev_t) 与 字符设备结构体 (cdev) 之间的映射关系。一旦这个函数返回成功,内核就会把这个设备加入到内部的散列表中。 - 最后就是模板式的操作,创建类和创建设备。值得一提的是,创建设备成功之后,在
/dev目录下就会生成该设备对应的文件。
3.2.3 文件操作接口
probe 函数执行成功后,/dev/mpu6050 设备节点就已经生成了。但此时如果我们去读取它,什么也拿不到,因为我们还没有实现 read 函数。
我们需要实现 file_operations 结构体中定义的函数,告诉内核当用户操作这个文件时,驱动该干什么。
我们在open 函数中进行一些私有数据的初始化。借用container_of宏,将设备结构体指针保存在 filp->private_data 中,这样在 read 函数中,我们就能直接取出来使用了。
其中inode->i_cdev指向mpu_dev结构体中的cdev成员,container_of宏就是通过这个成员的地址反推出mpu_dev结构体的地址的。
static int mpu6050_open(struct inode *inode,struct file *filp)
{
//inode->i_cdev指向我们后面在probe里注册的那个cdev
filp->private_data = container_of(inode->i_cdev,struct mpu6050_dev,cdev);
return 0;
}
我们还需要实现read函数的逻辑:
static ssize_t mpu6050_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos)
{
int ret;
struct mpu6050_dev *dev = (struct mpu6050_dev *)filp->private_data;
struct i2c_client *client = dev->client;
u8 raw_data[14];//加速度6,温度2,角速度6
//从0x3B寄存器开始往后连续读14个字节,前六个是加速度,中间两个是温度,后面六个是陀螺仪
ret = i2c_smbus_read_i2c_block_data(client,0x3B,14,raw_data);
if(ret < 0)
{
printk(KERN_INFO "MPU6050:I2C Read Failed!\n");
return ret;
}
if(count > 14)
{
count = 14;
}
//因为内核空间与用户空间隔离,因此需要把内核空间的数据拷贝到用户空间
ret = copy_to_user(buf,raw_data,count);
if(ret != 0)
{
return -EFAULT;
}
return count;//返回实际读取的字节数
}
在这个函数,我们就需要从 MPU6050 的寄存器中读取数据了,这里我使用i2c_smbus_read_i2c_block_data一次读取 14 个字节。然后将这些数据拷贝到用户空间buf中。
除了上面的两个函数,还需要实现release函数和file_operations,从而定义退出时的操作和定义函数操作的集合。这都是模板化的操作,就不再赘述了。
3.2.4 驱动出口
当我们需要卸载驱动rmmod时,内核会调用 remove 函数,这里的核心原则是:倒序销毁 , 也就是 Probe 函数里怎么申请的,这里就反着释放。
static int mpu6050_remove(struct i2c_client *client)
{
device_destroy(mpu_dev->class,mpu_dev->devid);
class_destroy(mpu_dev->class);
cdev_del(&mpu_dev->cdev);
unregister_chrdev_region(mpu_dev->devid,1);
printk(KERN_INFO "MPU6050:Driver Removed\n");
return 0;
}
3.2.5 驱动注册
static const struct of_device_id mpu6050_of_match[] = {
{.compatible = "lubancat,mpu6050-demo"},
{}
};
MODULE_DEVICE_TABLE(of,mpu6050_of_match);
static const struct i2c_device_id mpu6050_id[] = {
{"mpu6050",0},
{}
};
MODULE_DEVICE_TABLE(i2c,mpu6050_id);
static struct i2c_driver mpu6050_driver = {
.probe = mpu6050_probe,
.remove = mpu6050_remove,
.id_table = mpu6050_id,
.driver = {
.name = DRIVER_NAME,
.of_match_table = mpu6050_of_match,
},
};
module_i2c_driver(mpu6050_driver);
内核依靠compatible这个字符串来将驱动与设备树节点配对,因此必须与 dts 文件中的 compatible 属性完全一致。
虽然主要用设备树,但保留这个 ID 匹配表可以保证驱动在旧内核或无设备树系统下的兼容性。
下面的 I2C 驱动核心结构体mpu6050_driver就相当于是填空了,将我们编写的函数等内容与结构体成员对应起来就行了。
最后使用module_i2c_driver宏进行注册, 这个宏相当于自动生成了 module_init 和 module_exit 函数,并能分别调用 i2c_add_driver 和 i2c_del_driver。
3.2.6 小结
到这里,驱动的核心代码就全部完成了。其中大部分都是一些模板化的操作,只要第一次把它搞明白,后面再编写驱动代码时,大部分内容都是不变的,只要小部分需要修改。
3.3 编译与验证
代码写好了,我们需要把它编译成内核模块并加载到开发板上。
Makefile 文件如下:
KERNEL_DIR := /home/xlp/workspace/kernel #指向内核源码目录,要根据实际情况修改
obj-m := my_mpu6050.o
all:
make -C $(KERNEL_DIR) M=$(PWD) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules
clean:
make -C $(KERNEL_DIR) M=$(PWD) clean
然后在 Docker 中进行编译,就会生成my_mpu6050.ko文件。
然后我们在板子上面加载这个模块:
可以看到内核日志中已经打印出驱动成功加载的信息了。
使用ls命令也可以查看到/dev目录下的相应设备:
说明字符设备注册成功,此时,驱动已经就绪,只等应用层来读取数据了。
4. 应用代码编写
驱动只负责搬运原始的二进制数据,数据的解析、物理量转换等操作,都应该在应用层完成。
本章我们将编写一个 C 语言应用程序 app.c,实现以下功能:
- 打开
/dev/mpu6050设备文件 - 读取原始的二进制数据
- 将原始数据转换为温度、加速度、角速度
- 计算精准的
Roll和Pitch角度
由于篇幅限制,本章只介绍核心的内容,完整代码会放在文章末尾的链接。
4.1 数据整合
驱动层通过 copy_to_user 传来的是 14 个连续的 unsigned char。但 MPU6050 的寄存器是 16 位的,且包含负数。
我们需要做的第一件事,就是把两个 8 位数据合成一个 16 位数据。核心操作如下:
//与驱动对应的数据结构(14字节)
struct mpu_data {
unsigned char accel_x_h,accel_x_l;
unsigned char accel_y_h,accel_y_l;
unsigned char accel_z_h,accel_z_l;
unsigned char temp_h,temp_l;
unsigned char gyro_x_h,gyro_x_l;
unsigned char gyro_y_h,gyro_y_l;
unsigned char gyro_z_h,gyro_z_l;
};
//读取核心逻辑
read(fd, &data, 14);
//整合逻辑
short acc_x = (data.accel_x_h << 8) | data.accel_x_l;
C 语言中 short 通常是 16 位有符号整数,MPU6050 的数据是补码形式,使用 short 可以自动处理正负号。
4.2 转换原始数据
拿到了 acc_x = 16384 这样的整数,对我们来说依然没有意义,我们需要结合物理原理将其转化为角度。
MPU6050 内置的加速度计本质上是在测量力。当传感器静止平放时,它只受重力加速度(1g)的作用。
如果平放,重力全在 Z 轴。如果侧立,重力全在 Y 轴。如果倾斜,重力会按三角函数关系分摊到各个轴上。
利用反三角函数,我们可以反推出倾斜的角度:
#include <math.h> //必须引入数学库
//使用atan2(Y, Z)而不是atan(Y/Z),因为atan2能处理所有象限和分母为0的情况
double roll = atan2(acc_y, acc_z) * 57.296; //57.296=180/π
//计算Pitch(俯仰角) 绕Y轴旋转的角度
double pitch = atan2(-acc_x, sqrt(acc_y*acc_y + acc_z*acc_z)) * 57.296;
这里编译时需要加上 -lm 参数链接数学库。
4.3 互补滤波算法
如果只使用上面的 atan2 公式,会发现一个严重的问题:数据抖动非常厉害。只要稍微碰一下桌子,角度就会乱跳。
这是因为加速度计对高频震动非常敏感,为了解决这个问题,我们需要引入陀螺仪。
加速度计:长期准,短期抖(受震动影响)。
陀螺仪:短期准,长期漂(积分误差累积)。
互补滤波就是取长补短:
// 核心公式
angle = 0.98 * (angle + gyro * dt) + 0.02 * accel_angle;
大部分时间我们相信陀螺仪算出来的角度变化,因为它极其平滑,不受震动影响,留一小部分权重给加速度计,用来不断修正陀螺仪的积分漂移,确保角度最终回归到重力方向。
4.4 最终效果验证
要提一下,因为我们的应用程序要在板子上运行,因此要在 ARM 环境下进行编译,在虚拟机的 x86 环境下编译后是无法在板子上运行的。
如下图是刚运行应用程序时的截图,这是进行零点校准之后的结果,初始角度值还是误差很小的。
换个角度结果如下:
事实上还是有一些误差,也有可能是因为我手拿着,所以数据抖动比较严重。
但是我们最重要的目标是完成这个驱动本身的过程。到现在为止,我们已经成功完成了用户与传感器交互的整个流程。
5. 完整代码
完整代码链接如下:
👉完整代码