本文会从 i2c-tools 入手,到用户层程序编写,最后到驱动程序的编写,由浅入深的逐步学习。通过 MPU6050 这个 i2c 设备为例,比较全面地介绍 Linux 中 I2C 子系统的一些知识点和程序编写的方法,同时会补充一些基础的知识点,帮助大家更容易的学习。
本文收录在我的专栏《Linux驱动开发》中,感兴趣的读者可以关注一下我,订阅我的专栏。我会持续更新这个系列。
温馨提示:本文较长,可以先点个收藏,防止划走找不到了。
1. 用 i2c-tools 操作 MPU6050 的寄存器
本章算是从最表层接触一下 i2c 吧,主要内容是讲讲 i2c-tools 这个工具的使用方法和 i2c 设备的一些特点,会掺杂一些我在终端执行命令后返回结果的截图,以及一些我刚好想到就随手写下的内容。
1.1 简要了解 i2c-tools
i2c-tools 是 Linux 系统下用于 调试 和 管理 i2c 总线的一套开源命令行工具包。它非常适合嵌入式开发,可以快速扫描 i2c 总线、检测设备地址、读取或写入寄存器,而不需要编写代码或重新编译驱动,是硬件调试时不可缺少的工具。
如果没有安装这个工具,可以使用下面命令进行下载:
sudo apt update
sudo apt install i2c-tools -y
1.2 MPU6050的一些重要的寄存器
在使用 i2c-tools 进行操作之前,我们必须了解一些重要的寄存器。下面列举一些重要寄存器的名称,地址和作用。
芯片本身方面:
WHO_AM_I寄存器,地址为0x75,只读,通常用来 验证芯片 的身份。PWR_MGMT_1是电源管理寄存器,地址为0x6B,第 6 位写 1 可以复位整个芯片,第 5 位写 0 可以唤醒芯片,第 3-0 位用来选择时钟源。通常初始化时给这个寄存器赋0x00,这会 唤醒芯片并使用内部时钟。PWR_MGMT_2用的较少,地址为0x6C,通常用于 进一步关闭加速度计或者陀螺仪,从而进一步降低功耗。
配置量程方面:
GYRO_CONFIG寄存器,地址为0x1B,第 4-3 位用来设置 陀螺仪的满量程。这两位的数值与对应的量程如下表:
我们通常把这个寄存器设置为
0x08,对应的量程为 ±500 °/s,这是平衡了范围与精度的选择。
ACCEL_CONFIG寄存器,地址为0x1C,第 4-3 位用来设置 加速度计的满量程。数值与对应的量程如下表:
我们通常把这个寄存器设为
0x00,也就是 ±2g。
传感器原始值相关的寄存器:
这些寄存器是 连续 的 14 字节,通常情况下的做法是一次读完所有数据,各寄存器地址及描述如下表:
1.3 i2c-tools实战
本小节我们进行实际操作,用 i2c-tools 操作 MPU6050 的寄存器。
先来看看硬件的连线:
1.3.1 第一个命令
i2cdetect -y 3
- 默认情况下,
i2cdetect在扫描 i2c 总线前会询问用户是否确认,这是为了防止误操作总线。 加了-y后,它会直接执行扫描,不再弹出确认提示。 - 3 代表 i2c 总线编号,指定要扫描哪一条 i2c 总线,我的 MPU6050 连接的板子上的
i2c3,所以这里用 3 。 - 总体看来,这条命令的作用是遍历 i2c3 的 7 位 I2C 地址空间
0x03-0x77,尝试向每个地址发送一个探测信号,如果那个地址有设备回了 ACK,就会显示出地址。
我们在终端执行这条命令,结果如下:
68 这个位置有显示 68,这就表示在 i2c 总线 3 上,检测到了一个从机地址为 0x68 的设备,这与 MPU6050 的默认地址完全一致,当 AD0 引脚接地时,默认地址就是 0x68。
1.3.2 第二条命令
i2cdetect -l
列出当前系统所有已注册的 I2C 适配器,如下图:
1.3.3 第三条命令
i2cget -y 3 0x68 0x75
i2cget用于读取单个寄存器的值,一次只能读一个地址。- 语法为
i2cget -y 总线 设备地址 寄存器地址 模式。 - 常用模式有
b和w,默认为b,b代表一次读 8 位,w代表一次读 16 位。 - i2c 通常是 大端 传输,但
i2cget读word时可能会受限于主机端字节序,通常用b读两次。 - 这条命令总体上看,就是读取
i2c3上的从设备的指定地址的寄存器的值,从设备地址为0x68,寄存器地址为0x75。其中0x68是我们上面用i2cdetect测出来的,0x75是上面WHO_AM_I寄存器的地址。
执行结果如下:
可以看到,WHO_AM_I 寄存器的值为 0x70,这是正常的。原装 MPU6050 会返回 0x68,很多兼容芯片会返回 0x70,但是他们的功能是相同的,不会影响使用。
1.3.4 第四条命令
i2cset -y 3 0x68 0x6B 0x00
- 该命令用来写单个寄存器,是我们在命令行改变寄存器状态的关键。
- 语法为
i2cset -y 总线 设备地址 寄存器地址 值。 - 上面介绍过,
PWR_MGMT_1寄存器的地址正是0x6B,向这个寄存器写入0x00,从而唤醒芯片并使用芯片内部时钟。
执行结果如下:
可以看到,当向寄存器中写入 0x00 之后,在读取该寄存器的值,确实为 0x00 。
1.3.5 第五条命令
i2cdump -y 3 0x68
这条命令能一口气读出设备前 256 个寄存器的值并以表格的形式显示出来。
假如你想知道芯片的某个功能有没有在工作,可以对比两次 i2cdump,看对应的寄存器位置是否变化就可以判断了。
执行结果如下:
可以看到,图中的 WHO_AM_I 寄存器的地址 0x75 中的值为 0x70,与我们上面用 i2cget 读取的值一致。
1.3.6 第六条命令
i2ctransfer -y 3 w1@0x68 0x41 r2
这是 i2c-tools 里最强大也最接近底层驱动逻辑的命令,它可以一次性发送多个消息,模拟 重复起始信号。
i2ctransfer 与 i2cget 的区别如下:
w1@0x68表示向0x68,也就是从机地址 写入一个字节。0x41是要写入的字节。w后面也可以跟2,3,4等等,后面跟的要写入的字节数量也要和这个值相同。r2代表读取两个字节,在同一个i2ctransfer中,写操作设置了内部指针,即寄存器地址0x41,然后立即发起读操作,芯片就会从0x41这个寄存器开始返回后续字节。
执行结果如下:
1.4小结
i2c-tools 的命令就介绍这么多了,大家可以结合上面的介绍的寄存器地址,自己在终端敲一敲,提升一下熟练度。
在进行后面的学习之前,还需要再了解一些基础知识:
- 上面我们已经看到 MPU6050 的从机地址是
0x68,但其实在物理传输时,它是1101000,只有 7 位。 - Linux 内核统一使用 7 位地址,底层驱动会 自动移位并加上读写位 。
- 加上读写位之后,写地址是
0xD0,相当于0x68左移一位。读地址是0xD1,相当于0x68左移一位再或上 1。 - 每传 8 位,接收方必须拉低
SDA一下,如果i2cget报了 Read failed,通常就是因为没收到 ACK。 - 最后一点,i2c永远都是先传最高位。
下一章我们将进入用户态 C 编程。
2. 用户态 C 编程
现在我们要从敲命令逐步向写程序深入,用户态控制 i2c 主要依赖 /dev/i2c-x 节点。由于我们用的是 i2c3,所以自然就是 /dev/i2c-3 节点。
当你打开 /dev/i2c-3 时,你面对的是内核提供的 i2c-dev 通用驱动,这个驱动很重要,因为它在应用层完全模拟了内核驱动的操作逻辑。
2.1 核心数据结构
上面在敲命令时,我们对 i2ctransfer 已经有了一定的了解, i2ctransfer 里的每一个 w 或 r 动作,在 C 语言里都对应一个 i2c_msg 结构体。该结构体在内核源码中的原型如下:
我们先不看那些宏,只看四个重要的成员。
addr代表从机地址;flags是标志位,0 表示写,1 表示读;len代表数据长度;buf是数据缓冲区。
再来看看 struct i2c_rdwr_ioctl_data 结构体:
2.2 系统调用ioctl
我们会用到两个重要的 ioctl 命令:
I2C_SLAVE:简单的读写,类似于i2cget和i2cset。I2C_RDWR:高级的组合读写,类似i2ctransfer,我们主要学这个。
2.3 实战练习
下面,我们要用 C 语言实现:w1@0x68 0x3B r2,也就是读取加速度计 x 轴的高低两个字节。
完整代码,以及详细注释如下:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/i2c.h>
#include <linux/i2c-dev.h>
int main()
{
int fd;
//打开文件
fd = open("/dev/i2c-3", O_RDWR);
if(fd < 0)
{
perror("open failed");
return -1;
}
//先唤醒芯片,向0x6b写入0x00
uint8_t wake_data[] = {0x6b, 0x00};
struct i2c_msg wake_msg = {
.addr = 0x68, //从机地址0x68
.flags = 0, //0表示写
.len = 2, //两个字节
.buf = wake_data, //数据缓冲区
};
struct i2c_rdwr_ioctl_data wake_packets = {
.msgs = &wake_msg, //消息结构体的地址
.nmsgs = 1,//消息数量
};
ioctl(fd, I2C_RDWR, &wake_packets);
//唤醒芯片完成
while(1)
{
uint8_t reg = 0x3B; //加速度x轴高位寄存器地址
uint8_t data[2] = {0}; //存放读取到的数据
//先写0x3B,然后重发起始位读2字节
struct i2c_msg msg[2] = {
{
.addr = 0x68,
.flags = 0, //写
.len = 1, //一个字节
.buf = ®, //要写的寄存器地址
},
{
.addr = 0x68,
.flags = I2C_M_RD, //读数据,i2c_msg中的宏,值为1
.len = 2, //读两个字节
.buf = data, //读到的两个字节存到data中
}
};
struct i2c_rdwr_ioctl_data packets = {
.msgs = msg,
.nmsgs = 2,
};
ioctl(fd, I2C_RDWR, &packets);
//读取两个字节完成
//数据合成
int16_t acc_x = (data[0] << 8) | data[1];
printf("acc_x: %d\n",acc_x);
//500ms刷新一次
usleep(500000);
}
return 0;
}
整个 C 代码的逻辑可以总结为下面命令:
i2ctransfer -y 3 w1@0x68 0x6B 0x00
i2ctransfer -y 3 w1@0x68 0x3B r2 #循环
2.4 编译并运行
这个C程序我们直接拷贝到板子上用 gcc 编译即可,编译完成即可运行。拷贝的方法有很多,我这里就不介绍了。
下面编译加运行,一气呵成:
程序运行之后,我大幅摇晃芯片,可以看到数据确实发生了较大变化。
到此为止,我们已经学会了应用层的使用方法,下一章正式进入内核驱动层。
3. 内核驱动程序
3.1 设备树修改
我们需要在设备树里面添加硬件相关的描述,然后编译并拷贝到板子上。
3.1.1 添加节点
我们需要进入设备树文件中,在根节点之外,向 i2c3 控制器追加内容,如下,添加我们自己的设备节点:
reg 属性就是 i2cdetect 扫出来的 0x68。
还需要强调,我们的 MPU6050 节点必须作为 &i2c3 的 子节点,因为在内核中,I2C 控制器是一个父总线,传感器是挂在它下面的。
3.1.2 编译设备树
添加好节点之后,我们就可以编译设备树了,在内核源码中用下面命令仅编译设备树:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs -j8
编译结果如下:
编译完成之后,将 dtb 文件拷贝到板子上。
3.1.3 拷贝并验证
如下图,将 dtb 文件拷贝到板子上,然后在板子上将 dtb 文件拷贝到 /boot/dtb 目录下覆盖掉原先的旧 dtb 文件。
然后重启板子,看看我们编写的节点是否已经被内核在启动时成功解析了。
首先,需要知道 i2c3 对应的设备节点叫什么名字,如下图,可以在设备树头文件中找到 i2c3 节点:
节点名称为 i2c@fe5c0000,知道了节点名称之后,我们在板子上进入 /proc/device-tree/i2c@fe5c0000 这个目录,查看目录下的文件:
可以看到,我们在设备树中添加的节点 mpu6050@68 确实已经存在了,我们进去查看节点属性与我们设置的是否相同:
如图,文件内容为字符串时,可以直接用 cat 查看内容,如果 cat 查看时为乱码,可以使用 hexdump 查看十六进制内容。
截图中 compatible,name 和 status 的内容都和我们设备树中编写的相同,这很容易就能看出来。
其余的两个,phandle 和 reg我们详细解释一下:
phandle是设备树的内部索引号,是内核用来在不同节点之间建立 引用 的指针,它的值全局唯一,是自动生成的,我们不需要知道它的值的具体含义。- 用
hexdump查看reg的值为0000 6800,这其实就是大端序的0x68,与设备树中相同。
之前在用户态使用 i2c-tools,必须要 手动 告诉工具地址为 0x68,现在我们已经把这个地址固化到设备树节点里面了,驱动程序中只需要与该设备节点进行匹配,就能自动获取地址。
3.2 i2c_client 结构体解析
我们先要知道 i2c_client 这个结构体是哪里来的:
- 我们在设备树中编写了
mpu6050@68 { reg = <0x68>; ... }节点。 - 内核启动时,会去遍历 I2C 控制器下的所有子节点。
- 每发现一个合法的节点,内核就会在内存中 动态创建 一个
struct i2c_client。 - 创建好之后,内核会自动把设备树里的从机地址 0x68、中断号等信息填进这个结构体。
- 当驱动匹配成功后,内核把这个结构体的 指针 直接传给
probe函数。
这就是我们在驱动程序中能直接使用 struct i2c_client 结构体的原因。
在 <linux/i2c.h> 中,它的定义非常长,但我们只需要死磕下面这几个成员:
struct i2c_client {
unsigned short addr; //从设备地址,如 0x68
struct i2c_adapter *adapter; //指向该设备所属的 I2C 控制器,也就是适配器
struct device dev; //通用的设备结构体,继承自总线驱动模型
int irq; //中断号,如果在设备树里配置了中断引脚的话
char name[I2C_NAME_SIZE]; //设备名字
//...
};
addr:这是内核从设备树里提炼出来的,当我们调用i2c_smbus_read_byte_data(client, ...)时,内核底层其实就是从client->addr获取到的从设备地址。adapter:代表 i2c3 控制器,client描述的是传感器类型,adapter描述的是传感器接在哪。dev:这是标准的 Linux 的struct device,在驱动里,我们用dev_info打印出来的日志会自动带上设备的名字和总线编号。irq:如果 MPU6050 的INT引脚接到了 GPIO 上,并在设备树里配了中断,内核会自动申请好中断号存在这里,只需要直接用request_irq(client->irq, ...)即可。
3.3 驱动程序编写
之前我们在应用层编写程序时用的是 i2c_msg 的方法,这种方法我们需要自己分配消息数组、填地址、设置读写标志、指明长度、准备缓冲区。读一个寄存器要写一堆模版代码,效率比较低。下面介绍一种比较高效的方法。
3.3.1 smbus相关API
在内核驱动 i2c 的学习中,主要会用到以下四类 API:
第一类:读写8位,字节操作
这是用得最多的,比如读 ID、设量程、唤醒芯片都用这类。
下面贴出这类函数的原型:
s32 i2c_smbus_read_byte_data(const struct i2c_client *client, u8 command);
s32 i2c_smbus_write_byte_data(const struct i2c_client *client, u8 command, u8 value);
第一个函数用来从 command 读一个字节,command 是寄存器的地址。
第二个函数用来向 command 写一个字节,写入的内容是 value。
第二类:读写16位
MPU6050 的加速度、温度都是 16 位的。可以用下面的函数读取:
s32 i2c_smbus_read_word_data(const struct i2c_client *client, u8 command);
s32 i2c_smbus_write_word_data(const struct i2c_client *client, u8 command, u16 value);
要注意的是,smbus 默认是 小端,也就是先发低字节,但 MPU6050 是 大端,先发高字节。因此,读 MPU6050 的 16 位数据,通常建议用 be16_to_cpu() 进行转换,或者干脆分两次每次读一个字节再手动拼接。
第三类:连续读写
可以一次性把加速度 X, Y, Z 的 6 个字节全读出来:
s32 i2c_smbus_read_i2c_block_data(const struct i2c_client *client, u8 command, u8 length, u8 *values)
s32 i2c_smbus_write_i2c_block_data(const struct i2c_client *client, u8 command, u8 length, const u8 *values)
其中 length 是需要读取或写入的字节数。
3.3.2 编写驱动程序
这里我们编写一个简单的驱动程序,读取 WHO_AM_I 寄存器的值,并把他打印到内核日志。
先把代码放出来:
#include <linux/module.h>
#include <linux/i2c.h>
#include <linux/init.h>
#include <linux/device.h>
//从datasheet查相关的寄存器地址
#define MPU6050_WHO_AM_I 0x75
#define MPU6050_PWR_MGMT_1 0x6B
//Probe函数
static int mpu6050_probe(struct i2c_client *client, const struct i2c_device_id *id)
{
int ret;
u8 id_val;
dev_info(&client->dev, "MPU6050 probe success!\n");
//检查该I2C控制器硬件是否支持smbus读写功能
if (!i2c_check_functionality(client->adapter, I2C_FUNC_SMBUS_BYTE_DATA))
{
dev_err(&client->dev, "I2C adapter doesn't support smbus!\n");
return -ENODEV;
}
//唤醒芯片,往0x6B写入0x00
ret = i2c_smbus_write_byte_data(client, MPU6050_PWR_MGMT_1, 0x00);
if (ret < 0)
{
dev_err(&client->dev, "Failed to wake up MPU6050!\n");
return ret;
}
//读取寄存器0x75
ret = i2c_smbus_read_byte_data(client, MPU6050_WHO_AM_I);
if (ret < 0)
{
dev_err(&client->dev, "Failed to read WHO_AM_I!\n");
return ret;
}
id_val = (u8)ret;
//验证ID是否正确
if (id_val != 0x70 && id_val != 0x68)//常见的两个值,如果都不是,说明有错误
{
dev_warn(&client->dev, "ID 0x%x error!\n", id_val);
}
else
{
dev_info(&client->dev, "MPU6050 ID : 0x%x!\n", id_val);
}
return 0;
}
//Remove函数
static int mpu6050_remove(struct i2c_client *client)
{
dev_info(&client->dev, "Driver Removed!\n");
return 0;
}
//设备树匹配表
static const struct of_device_id mpu6050_of_match[] = {
{ .compatible = "lubancat,mpu6050",},
{}
};
MODULE_DEVICE_TABLE(of, mpu6050_of_match);
//i2c_driver结构体
static struct i2c_driver mpu6050_driver = {
.driver = {
.name = "my_mpu6050_driver",
.of_match_table = mpu6050_of_match,
},
.probe = mpu6050_probe,
.remove = mpu6050_remove,
};
module_i2c_driver(mpu6050_driver);
MODULE_LICENSE("GPL");
学习过 Platform 驱动框架的应该能够一眼看出来,这个 I2c 的驱动框架其实和 platform 的框架很相似,不同之处就是一些 API 的名称变了,但是编写的思路是不变的。
这个驱动程序的逻辑其实很简单,就是唤醒芯片,然后读取 WHO_AM_I 寄存器的值。但是,事实上,你会了唤醒芯片,你就会往其他所有的寄存器写入内容,你学会了读取 WHO_AM_I 的纸,你就会了读取其他所有寄存器的值了。此外,读写一个字节的操作你会了,那么读写两个字节也应该会了吧,换个 API 的事嘛。
如果你还有其他的想法,还可以进一步拓展,我下面叙述一下思路:
如果你想读取温度,加速度和陀螺仪的数据,直接用上面的 API 读就可以了。此外,你还可以写一个字符设备的框架,编写对应的 read 函数,让用户程序能够通过设备节点完成这些数据的读取,,然后在用户程序中进行对原始数据的处理。
由于篇幅限制,我就不做太多的重复工作了。
3.4 编译驱动程序
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
然后直接 make 进行编译:
如图,编译成功,我们后面加载驱动进行验证。
4. 验证
将编译生成的 my_mpu6050.ko 拷贝到板子上,然后加载:
可以看到,驱动程序成功获取了 WHO_AM_I 寄存器的值,值为 0x70 。
内核日志中,在打印信息前面,我们可以看到 my_mpu6050_driver 3-0068,这就是 dev_info(&client->dev, ...) 的好处,它自动把设备挂在 i2c-3 总线上、地址是 0x68 的信息打出来了,这在多传感器系统中非常有帮助,能更容易的区分不同的传感器。
在驱动加载成功之后,可以进入 /sys/bus/i2c/drivers/my_mpu6050_driver 目录:
我们可以看到一个名为 3-0068 的软链接,它指向了我们的设备。这就是驱动和设备匹配成功产生的现象。
结语:
本篇文章写到这里就结束了,如果帮助到你,可以点个关注支持我一下。
同时,也可以看看我《Linux 驱动开发》专栏的其他文章,可能会对你有帮助。