4、I2C从机驱动开发:以MPU6050为例,编写I2C读写寄存器函数

好,咱们进入正题。上一节我们把I2C总线的时序和协议理清楚了,说白了就是搞懂了怎么发数据、怎么收数据。但光会发收还不够,你得让MPU6050这个从机听你的话。怎么让它听话?就是通过读写它的寄存器。

MPU6050内部有一堆寄存器,就像一个个小抽屉。你往抽屉里写数据,就能配置它的工作模式;你从抽屉里读数据,就能拿到加速度、角速度这些传感器值。今天我就带你手写这两个最核心的函数:I2C_ReadRegI2C_WriteReg

核心思路: 读写寄存器本质上就是组合I2C的起始信号、设备地址、寄存器地址、数据字节,再加上应答信号。写操作发两段数据,读操作发完地址后要重新起始再收数据。

4.1 写寄存器函数:把数据塞进去

先看写操作。你要往MPU6050的某个寄存器里写一个字节,流程是这样的:

  1. 发送起始信号
  2. 发送设备地址(7位地址左移1位,最低位写0表示写操作)
  3. 等待从机应答
  4. 发送寄存器地址(你要写哪个寄存器)
  5. 等待从机应答
  6. 发送要写入的数据字节
  7. 等待从机应答
  8. 发送停止信号

嗯,就这么简单。我刚开始写这个函数时,老想着要不要加超时处理,后来发现MPU6050的响应很快,基本不会卡住。但为了健壮性,我还是建议你加个简单的超时计数。

/**
 * @brief  向MPU6050指定寄存器写入一个字节
 * @param  reg: 寄存器地址
 * @param  data: 要写入的数据
 * @retval 0: 成功, -1: 失败
 */
int I2C_WriteReg(uint8_t reg, uint8_t data)
{
    // 1. 发送起始信号
    I2C_Start();
    
    // 2. 发送设备地址 + 写标志 (0xD0 = 0x68 << 1)
    if (I2C_SendByte(0xD0) != 0) {
        I2C_Stop();
        return -1;  // 无应答,设备可能不在线
    }
    
    // 3. 发送寄存器地址
    if (I2C_SendByte(reg) != 0) {
        I2C_Stop();
        return -1;
    }
    
    // 4. 发送数据
    if (I2C_SendByte(data) != 0) {
        I2C_Stop();
        return -1;
    }
    
    // 5. 发送停止信号
    I2C_Stop();
    
    return 0;
}

小提示: 设备地址0x68是MPU6050的默认地址。如果你的AD0引脚接了高电平,地址就变成0x69。我在项目中就踩过这个坑——焊了两块板子,一块能通信一块不行,查了半天发现是AD0引脚电平没统一。

4.2 读寄存器函数:把数据掏出来

读操作稍微绕一点。你不能直接读,得先告诉MPU6050你要读哪个寄存器,然后再重新发起一次读操作拿数据。具体流程:

  1. 发送起始信号
  2. 发送设备地址 + 写标志
  3. 等待应答
  4. 发送寄存器地址(告诉从机你要读哪个位置)
  5. 等待应答
  6. 发送重新起始信号(Restart)
  7. 发送设备地址 + 读标志
  8. 等待应答
  9. 读取一个字节数据(主机要发送非应答信号)
  10. 发送停止信号

为什么会这样?因为I2C协议规定,从机在收到读请求之前,必须先知道你要读哪个地址。所以先假装写一次,把寄存器地址告诉它,然后再真正去读。说白了就是「先指路,再取货」。

/**
 * @brief  从MPU6050指定寄存器读取一个字节
 * @param  reg: 寄存器地址
 * @param  data: 存放读取数据的指针
 * @retval 0: 成功, -1: 失败
 */
int I2C_ReadReg(uint8_t reg, uint8_t *data)
{
    // 1. 发送起始信号
    I2C_Start();
    
    // 2. 发送设备地址 + 写标志,先告诉从机要读哪个寄存器
    if (I2C_SendByte(0xD0) != 0) {
        I2C_Stop();
        return -1;
    }
    
    // 3. 发送寄存器地址
    if (I2C_SendByte(reg) != 0) {
        I2C_Stop();
        return -1;
    }
    
    // 4. 发送重新起始信号
    I2C_Start();
    
    // 5. 发送设备地址 + 读标志 (0xD1 = 0x68 << 1 | 0x01)
    if (I2C_SendByte(0xD1) != 0) {
        I2C_Stop();
        return -1;
    }
    
    // 6. 读取一个字节,主机发送非应答
    *data = I2C_RecvByte();
    I2C_SendNack();
    
    // 7. 发送停止信号
    I2C_Stop();
    
    return 0;
}

注意: 读操作的最后,主机必须发送非应答信号(NACK),然后才是停止信号。如果你发了应答,MPU6050会以为你还要继续读下一个字节,然后总线就乱套了。我曾经因为这个bug,读出来的数据全是0xFF,排查了一整个下午。

4.3 连续读写:批量操作更高效

实际项目中,你很少只读一个寄存器。比如读加速度值,需要连续读6个字节(X、Y、Z轴各2字节)。MPU6050支持自动地址递增,你只要在读操作时连续收数据,它就会自动把下一个寄存器的值发给你。

/**
 * @brief  从MPU6050连续读取多个字节
 * @param  reg: 起始寄存器地址
 * @param  buf: 存放数据的缓冲区
 * @param  len: 要读取的字节数
 */
int I2C_ReadRegs(uint8_t reg, uint8_t *buf, uint16_t len)
{
    I2C_Start();
    if (I2C_SendByte(0xD0) != 0) { I2C_Stop(); return -1; }
    if (I2C_SendByte(reg) != 0)  { I2C_Stop(); return -1; }
    
    I2C_Start();
    if (I2C_SendByte(0xD1) != 0) { I2C_Stop(); return -1; }
    
    // 连续读取len个字节
    for (uint16_t i = 0; i < len; i++) {
        buf[i] = I2C_RecvByte();
        if (i == len - 1) {
            I2C_SendNack();  // 最后一个字节发NACK
        } else {
            I2C_SendAck();   // 前面的字节发ACK,告诉从机继续发
        }
    }
    
    I2C_Stop();
    return 0;
}

经验之谈: 连续读取时,记得最后一个字节发NACK,前面的都发ACK。这个逻辑我一开始写反了,结果读出来的数据顺序全是乱的。你想想看,从机每收到一个ACK就继续发下一个字节,收到NACK就停,顺序搞反了数据当然对不上。

4.4 把这些函数串起来:初始化MPU6050

有了读写函数,初始化MPU6050就简单了。比如让它退出休眠模式、设置量程:

void MPU6050_Init(void)
{
    // 退出休眠模式:写0x00到电源管理寄存器(0x6B)
    I2C_WriteReg(0x6B, 0x00);
    
    // 设置加速度计量程为±2g:写0x00到配置寄存器(0x1C)
    I2C_WriteReg(0x1C, 0x00);
    
    // 设置陀螺仪量程为±250°/s:写0x00到配置寄存器(0x1B)
    I2C_WriteReg(0x1B, 0x00);
    
    // 读取WHO_AM_I寄存器(0x75),验证通信是否正常
    uint8_t whoami;
    I2C_ReadReg(0x75, &whoami);
    if (whoami == 0x68) {
        // 通信正常,MPU6050在线
    } else {
        // 通信异常,检查硬件连接
    }
}

我个人习惯在初始化最后读一下WHO_AM_I寄存器,它的值固定是0x68。如果读出来不对,说明I2C总线或者MPU6050本身有问题。这个自检步骤帮我省了不少调试时间。

4.5 知识体系总览

下面这张图把本章的核心逻辑串起来了,你看一眼就能明白读写函数的全貌:

I2C读写寄存器核心流程 写操作流程 ① 起始信号 ② 发送设备地址 + 写标志 (0xD0) ③ 发送寄存器地址 ④ 发送数据字节 ⑤ 停止信号 读操作流程 ① 起始信号 ② 发送设备地址 + 写标志 (0xD0) ③ 发送寄存器地址 ④ 重新起始信号 ⑤ 发送设备地址 + 读标志 (0xD1) ⑥ 读取数据 + 发送NACK + 停止 关键区别:写操作一次起始即可,读操作需要重新起始切换方向

4.6 避坑指南

最后,我把自己踩过的坑总结一下,你写代码时注意避开:

  • 设备地址搞错: 0x68左移一位是0xD0,不是0x68。很多人直接拿0x68当地址发,结果从机根本不搭理你。
  • 应答检查不能省: 每个字节发送后都要检查从机是否回了ACK。如果没收到,说明从机忙或者地址不对,赶紧发停止信号退出。
  • 读操作别忘了重新起始: 我见过有人读操作时直接发停止再发起始,虽然也能工作,但不符合I2C规范,而且多占用了总线时间。
  • 连续读时最后一个字节发NACK: 这个前面说过了,再强调一遍——最后一个字节不发NACK,从机会一直发,总线就死锁了。

好了,读写寄存器的函数就这些。你把这几个函数写好,MPU6050的驱动就完成了一大半。下一节我们拿这些函数去读加速度和角速度数据,到时候你就知道传感器值是怎么从寄存器里变成物理量的了。


专注资料整理