Linux驱动:用MPU6050手把手带你入门I2C子系统(附完整源码)

0 阅读20分钟

本文会从 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 位用来设置 陀螺仪的满量程。这两位的数值与对应的量程如下表:

1. 陀螺仪满量程.png 我们通常把这个寄存器设置为 0x08,对应的量程为 ±500 °/s,这是平衡了范围与精度的选择。

  • ACCEL_CONFIG 寄存器,地址为 0x1C,第 4-3 位用来设置 加速度计的满量程。数值与对应的量程如下表:

2. 加速度计满量程.png 我们通常把这个寄存器设为 0x00,也就是 ±2g

传感器原始值相关的寄存器:

这些寄存器是 连续 的 14 字节,通常情况下的做法是一次读完所有数据,各寄存器地址及描述如下表:

3. 原始数据.png

1.3 i2c-tools实战

本小节我们进行实际操作,用 i2c-tools 操作 MPU6050 的寄存器。

先来看看硬件的连线:

4. 连线图.jpg

1.3.1 第一个命令

i2cdetect -y 3
  • 默认情况下,i2cdetect 在扫描 i2c 总线前会询问用户是否确认,这是为了防止误操作总线。 加了 -y 后,它会直接执行扫描,不再弹出确认提示。
  • 3 代表 i2c 总线编号,指定要扫描哪一条 i2c 总线,我的 MPU6050 连接的板子上的 i2c3,所以这里用 3 。
  • 总体看来,这条命令的作用是遍历 i2c3 的 7 位 I2C 地址空间 0x03-0x77,尝试向每个地址发送一个探测信号,如果那个地址有设备回了 ACK,就会显示出地址。

我们在终端执行这条命令,结果如下:

5. detect结果.png

68 这个位置有显示 68,这就表示在 i2c 总线 3 上,检测到了一个从机地址为 0x68 的设备,这与 MPU6050 的默认地址完全一致,当 AD0 引脚接地时,默认地址就是 0x68

1.3.2 第二条命令

i2cdetect -l

列出当前系统所有已注册的 I2C 适配器,如下图:

6. detect-l.png

1.3.3 第三条命令

i2cget -y 3 0x68 0x75
  • i2cget 用于读取单个寄存器的值,一次只能读一个地址。
  • 语法为 i2cget -y 总线 设备地址 寄存器地址 模式
  • 常用模式有 bw ,默认为 bb 代表一次读 8 位,w 代表一次读 16 位。
  • i2c 通常是 大端 传输,但 i2cgetword 时可能会受限于主机端字节序,通常用 b 读两次。
  • 这条命令总体上看,就是读取 i2c3 上的从设备的指定地址的寄存器的值,从设备地址为 0x68,寄存器地址为 0x75。其中 0x68 是我们上面用 i2cdetect 测出来的,0x75 是上面 WHO_AM_I 寄存器的地址。

执行结果如下:

7. who_am_i.png

可以看到,WHO_AM_I 寄存器的值为 0x70,这是正常的。原装 MPU6050 会返回 0x68,很多兼容芯片会返回 0x70,但是他们的功能是相同的,不会影响使用。

1.3.4 第四条命令

i2cset -y 3 0x68 0x6B 0x00
  • 该命令用来写单个寄存器,是我们在命令行改变寄存器状态的关键。
  • 语法为 i2cset -y 总线 设备地址 寄存器地址 值
  • 上面介绍过,PWR_MGMT_1 寄存器的地址正是 0x6B ,向这个寄存器写入 0x00,从而唤醒芯片并使用芯片内部时钟。

执行结果如下:

8. 唤醒芯片.png

可以看到,当向寄存器中写入 0x00 之后,在读取该寄存器的值,确实为 0x00

1.3.5 第五条命令

i2cdump -y 3 0x68

这条命令能一口气读出设备前 256 个寄存器的值并以表格的形式显示出来。

假如你想知道芯片的某个功能有没有在工作,可以对比两次 i2cdump,看对应的寄存器位置是否变化就可以判断了。

执行结果如下:

9. dump.png

可以看到,图中的 WHO_AM_I 寄存器的地址 0x75 中的值为 0x70,与我们上面用 i2cget 读取的值一致。

1.3.6 第六条命令

i2ctransfer -y 3 w1@0x68 0x41 r2

这是 i2c-tools 里最强大也最接近底层驱动逻辑的命令,它可以一次性发送多个消息,模拟 重复起始信号

i2ctransferi2cget 的区别如下:

10. 对比.png

  • w1@0x68 表示向 0x68,也就是从机地址 写入一个字节
  • 0x41 是要写入的字节。
  • w 后面也可以跟 2,3,4 等等,后面跟的要写入的字节数量也要和这个值相同。
  • r2 代表读取两个字节,在同一个 i2ctransfer 中,写操作设置了内部指针,即寄存器地址 0x41,然后立即发起读操作,芯片就会从 0x41 这个寄存器开始返回后续字节。

执行结果如下:

11. i2ctransfer.png

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 里的每一个 wr 动作,在 C 语言里都对应一个 i2c_msg 结构体。该结构体在内核源码中的原型如下:

12. msg结构体.png

我们先不看那些宏,只看四个重要的成员。

  • addr 代表从机地址;
  • flags 是标志位,0 表示写,1 表示读;
  • len 代表数据长度;
  • buf 是数据缓冲区。

再来看看 struct i2c_rdwr_ioctl_data 结构体:

13. ioctl-msg.png

2.2 系统调用ioctl

我们会用到两个重要的 ioctl 命令:

  • I2C_SLAVE:简单的读写,类似于 i2cgeti2cset
  • 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 = &reg,  //要写的寄存器地址
            },
            {
                .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 编译即可,编译完成即可运行。拷贝的方法有很多,我这里就不介绍了。

下面编译加运行,一气呵成:

14. 应用程序测试.png

程序运行之后,我大幅摇晃芯片,可以看到数据确实发生了较大变化。

到此为止,我们已经学会了应用层的使用方法,下一章正式进入内核驱动层。

3. 内核驱动程序

3.1 设备树修改

我们需要在设备树里面添加硬件相关的描述,然后编译并拷贝到板子上。

3.1.1 添加节点

我们需要进入设备树文件中,在根节点之外,向 i2c3 控制器追加内容,如下,添加我们自己的设备节点:

15. 添加节点.png

reg 属性就是 i2cdetect 扫出来的 0x68

还需要强调,我们的 MPU6050 节点必须作为 &i2c3子节点,因为在内核中,I2C 控制器是一个父总线,传感器是挂在它下面的。

3.1.2 编译设备树

添加好节点之后,我们就可以编译设备树了,在内核源码中用下面命令仅编译设备树:

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

编译结果如下:

16.编译设备树.png

编译完成之后,将 dtb 文件拷贝到板子上。

3.1.3 拷贝并验证

如下图,将 dtb 文件拷贝到板子上,然后在板子上将 dtb 文件拷贝到 /boot/dtb 目录下覆盖掉原先的旧 dtb 文件。

17. 拷贝.png

然后重启板子,看看我们编写的节点是否已经被内核在启动时成功解析了。

首先,需要知道 i2c3 对应的设备节点叫什么名字,如下图,可以在设备树头文件中找到 i2c3 节点:

18.标签.png

节点名称为 i2c@fe5c0000,知道了节点名称之后,我们在板子上进入 /proc/device-tree/i2c@fe5c0000 这个目录,查看目录下的文件:

19. 验证节点存在.png

可以看到,我们在设备树中添加的节点 mpu6050@68 确实已经存在了,我们进去查看节点属性与我们设置的是否相同:

19.5 查看文件.png

如图,文件内容为字符串时,可以直接用 cat 查看内容,如果 cat 查看时为乱码,可以使用 hexdump 查看十六进制内容。

截图中 compatiblenamestatus 的内容都和我们设备树中编写的相同,这很容易就能看出来。

其余的两个,phandlereg我们详细解释一下:

  • 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 进行编译:

20. 驱动程序编译.png

如图,编译成功,我们后面加载驱动进行验证。

4. 验证

将编译生成的 my_mpu6050.ko 拷贝到板子上,然后加载:

21. 加载驱动(1).png

22. 加载驱动(2).png

可以看到,驱动程序成功获取了 WHO_AM_I 寄存器的值,值为 0x70

内核日志中,在打印信息前面,我们可以看到 my_mpu6050_driver 3-0068,这就是 dev_info(&client->dev, ...) 的好处,它自动把设备挂在 i2c-3 总线上、地址是 0x68 的信息打出来了,这在多传感器系统中非常有帮助,能更容易的区分不同的传感器。

在驱动加载成功之后,可以进入 /sys/bus/i2c/drivers/my_mpu6050_driver 目录:

23. 查看驱动.png

我们可以看到一个名为 3-0068 的软链接,它指向了我们的设备。这就是驱动和设备匹配成功产生的现象。

结语:

本篇文章写到这里就结束了,如果帮助到你,可以点个关注支持我一下。

同时,也可以看看我《Linux 驱动开发》专栏的其他文章,可能会对你有帮助。