简介:本资源是一套面向嵌入式开发初学者与中级工程师的QMI8658C六轴IMU传感器驱动实现方案,专为缺乏硬件I²C外设或需灵活引脚配置的MCU平台(如STM32F1、GD32、C2000等)设计,解决GPIO模拟I²C通信下的传感器初始化、寄存器读写与功能配置难题。压缩包共5个文件,含2个核心源码文件(.c)、2个头文件(.h)及1份官方PDF数据手册(C2842151_QMI8658C.PDF),其中SoftI2CMaster.c/h提供可移植的软件I²C底层时序实现,QMI8658C.c/h封装了完整的寄存器操作接口与控制寄存器配置函数(CTRL1–CTRL7)、软复位、设备ID/版本号读取等关键功能,便于快速集成与调试。资源包大小约2MB,结构精简、注释清晰,适合作为学习I²C协议软件模拟、传感器驱动开发与低层外设移植的实践范例。目前已有1607人学习下载,配套代码即拿即用,显著降低QMI8658C在资源受限平台上的接入门槛。 最近在调一颗六轴惯性传感器 QMI8658C,项目里没有多余引脚走硬件 I2C,于是把 GPIO 模拟 I2C 的驱动从头到尾写了一遍。这套驱动代码我已经整理成压缩包,完整可用,但光甩一个 zip 出来意义不大——我更想把为什么要这么写、时序怎么抠、调试时踩了哪些坑一次讲透。QMI8658C 是国产 IMU 里性价比很高的一颗料,量程可配、功耗低、体积小,用在姿态解算、计步、倾斜检测这些场景都很合适。这篇就围绕 GPIO 模拟 I2C 接口驱动 QMI8658C 的完整实现,从选型原因、协议时序、寄存器配置到代码移植和调试方法,逐步拆开讲。


1. 为什么放着硬件I2C不用,非要GPIO模拟I2C

很多朋友看到“GPIO 模拟 I2C”这几个字,第一反应是没必要——MCU 片上不都带着 I2C 外设吗?省事、可靠、还能进中断,为什么非要拿 GPIO 去一点一点翻电平?这个问题我一开始也觉得是脱裤子放屁,直到实际做项目才明白,GPIO 模拟 I2C 在不少场景下不是退步,反而是最省心的选择。

1.1 引脚冲突和驱动移植带来的现实麻烦

先说我遇到的具体情况。板子上一颗 MCU 的硬件 I2C 外设引脚是固定的,比如 I2C1 在 PB6/PB7,I2C2 在 PB10/PB11,画板时这两组引脚要么被 SPI Flash 占了,要么被 UART 调试口占了,剩下的引脚位置离传感器又远,走线绕来绕去还容易引入干扰。硬件 I2C 的引脚重映射功能不是每颗芯片都有,就算有,映射到的引脚也未必是你想要的那几个。

更麻烦的是驱动移植。硬件 I2C 一旦换了 MCU 平台,底层寄存器、中断、DMA 全部得重写一遍,今天用的 STM32,明天换成国产 ARM 核,后天又换成 RISC-V 的,每一家的 I2C 外设寄存器都长得不一样。而 GPIO 模拟 I2C 的代码,本质上就是几条电平翻转宏加延时函数,换平台只需要改引脚定义和延时实现,驱动逻辑一行都不用动。我后来在三个不同平台间移植这套 QMI8658C 驱动,每次只花半小时改宏定义,这就是 GPIO 模拟方案的迁移成本优势。

1.2 时序全在自己手里,排查问题更直观

硬件 I2C 外设虽然自动化程度高,但也正因为自动化,它对开发者来说像一个黑盒子:时钟极性配错了、时序不满足从机要求、总线被拉死等原因导致读写失败时,你只能对着寄存器手册一遍遍猜。

GPIO 模拟就没有这个问题。代码执行到哪一步、SCL 是高还是低、SDA 什么时候变,每一拍都能用逻辑分析仪直接看到,也能在代码里打点跟踪。你可以在关键位置加调试断点,或者把电平翻转前后打印出来,这种透明程度对调传感器驱动特别重要。QMI8658C 这种 IMU 芯片的通信速率要求本来就不高,标准模式 100kHz 完全够用,GPIO 模拟出来的时序虽然不如硬件外设细腻,但稳定性完全够。

再强调一点:GPIO 模拟 I2C 非常考验对协议本质的理解。很多人写驱动用库函数一把梭,遇到问题就晕,而亲手用 GPIO 实现一遍起始、停止、读写字节后,I2C 协议对你来说就没有秘密了。包括以后去调 EEPROM、OLED、各种 I2C 传感器,这套思路完全通用。

1.3 什么时候应该老老实实切回硬件I2C

GPIO 模拟也不是万能的。如果项目对以下指标有硬要求,我建议你还是优先用硬件 I2C:

  • 通信速率需求高,比如要跑 400kHz 甚至 1MHz 快速模式,GPIO 翻转加延时很难做到准确和稳定。
  • 总线任务重,比如同一个 I2C 总线上挂多个设备,还需要频繁大批量读写,硬件 I2C 配合 DMA 可以大幅降低 CPU 占用。
  • 低功耗要求严格,GPIO 模拟在每次通信期间要不停翻转电平,功耗明显高于硬件外设的硬件状态机。
  • 需要 I2C 中断、超时、错误检测等高级功能,GPIO 模拟全部要自己造轮子,这种场景性价比太低。

一句话概括:GPIO 模拟 I2C 适合“传感器读个数据、对速率不极端敏感、希望代码可移植性强”的场景。QMI8658C 这种 IMU 正好落在最舒服的区域里。


2. QMI8658C的寄存器地图和初始化要点

第一次拿到 QMI8658C 的数据手册,你可能跟我一样被一堆寄存器地址吓到。其实传感器驱动不需要把每个寄存器都吃透,只要把“认门”、“配置量程/采样率”、“读数据”这三件事做好就够了。

2.1 先认门:WHO_AM_I 和器件地址

QMI8658C 的 I2C 7 位地址是 0x6A,这个地址由引脚 SA0 的电平决定,SA0 接地时为 0x6A,接高电平则为 0x6B。在代码里通常写成 8 位操作地址:写地址 0xD4,读地址 0xD5。刚上手时最容易犯的错就是直接把 0x6A 当成发送的地址字节,结果协议层根本不通,后面我会专门讲这个坑。

WHO_AM_I 寄存器地址是 0x00,读出来固定为 0x05。这个寄存器是判断芯片有没有正常上电、I2C 通信是否打通的第一道关卡。驱动初始化时先读它,值不对就不要再往下走了,直接报错返回,能省下大量后续排查时间。

2.2 初始化必须动的那几个寄存器

QMI8658C 的寄存器不少,但真正每次上电都要配置的就那么几个:

寄存器 地址 作用 典型值
CTRL1 0x02 软件复位、地址自增等控制 0x60
CTRL2 0x03 传感器功能配置 0x00
CTRL7 0x08 加速度计量程、采样率、工作模式 按需求配置
CTRL8 0x09 加速度计低功耗/高性能相关 按需求配置
CTRL9 0x0A 陀螺仪量程、采样率、工作模式 按需求配置
CTRL10 0x0B 陀螺仪低功耗/高性能相关 按需求配置

其中 CTRL1 的 bit[6:5] 控制地址自增模式,默认是 0b10(自动递增),连续读取多个寄存器数据时会自动指向下一个地址,这个必须确认开了,不然待会儿读加速度计六个字节会翻车。CTRL1 的 bit7 是软复位位,写 1 触发芯片复位,初始化时可以先用它把芯片恢复到已知状态。

加速度计量程由 CTRL7 的量程位配置,一般支持 ±2g、±4g、±8g、±16g 四档;陀螺仪量程由 CTRL9 配置,常见有 ±16dps 到 ±2048dps 多档。具体数值一定要以你手里那份手册为准,不同批次甚至不同后缀的芯片,寄存器位可能略有差异,我代码里会把所有配置值单独抽成宏,方便你对照修改。

2.3 量程和分辨率:1个LSB到底代表多少物理量

这一节是理解 IMU 数据的关键。传感器输出的原始数据是 16 位有符号整数,但 16 位全量程对应的是你配置的满量程范围。换算公式很简单:

1 LSB 代表的物理量 = 满量程 / 32768

为什么分母是 32768 而不是 65536?因为 16 位有符号数的范围是 -32768 到 +32767,总共 65536 个刻度,但最大正值只有 32767。以加速度计 ±8g 量程为例,每个 LSB 代表 8 / 32768 ≈ 0.000244g,大约是 0.244mg。陀螺仪如果配 ±512dps,每个 LSB 代表 512 / 32768 = 0.015625dps,如果要转成 rad/s,再乘以 π/180≈0.01745 就行。

这个换算系数就是你在代码里把原始 int16_t 数据转成物理量时要乘的那个常数。这个常数千万别写成 65536,我当时就栽过这一下,算出来的值全部偏了一半。


3. GPIO模拟I2C时序:从波形到代码逐行拆

GPIO 模拟 I2C 的核心不是代码多复杂,而是对时序的理解要到位。理解了时序,代码就是按部就班地把时序“翻译”成电平操作。

3.1 I2C时序的本质:一个节拍器加一根信号线

你想象 SCL 是节拍器,SDA 是信号线。通信时,主机通过 SCL 给出节拍,从机在节拍里采样 SDA 电平。I2C 协议有几个最基础的动作:

  • 起始条件(START):在 SCL 为高电平时,SDA 从高拉低。表示“主机要开始通信了”。
  • 停止条件(STOP):在 SCL 为高电平时,SDA 从低拉高。表示“通信结束”。
  • 数据位:在 SCL 为低电平时,SDA 电平可以变化;在 SCL 为高电平时,SDA 必须保持稳定,从机在 SCL 高电平期间采样 SDA。每个字节 8 位,从最高位(MSB)开始发送。
  • 应答位(ACK/NACK):第 9 个时钟,主机释放 SDA(设为输入),从机把 SDA 拉低表示 ACK,拉高或无响应表示 NACK。

可以用一个生活化的类比:SCL 就像老师在课堂上说的“看黑板”,SDA 就是黑板上的字。老师说“看黑板”的时候(SCL 为高),黑板上的字不能变,学生才能看清;老师说“自由活动”的时候(SCL 为低),字才可以擦掉重写。

还有个常见误区:有些人以为 GPIO 模拟 I2C 时 SDA 要一直当作输出使用。实际上,读数据、等 ACK 的时候,主机必须把 SDA 引脚切换成输入模式,把总线释放给从机。很多新手就是忘了这个方向切换,导致读回来永远是同一个值。

3.2 用代码实现起始、停止、读写字节

先把引脚宏定义抽出来。这套驱动之所以能快速跨平台移植,全靠这一层宏隔离:

/* 引脚宏定义:换平台时只需要改这里 */
#define I2C_SCL_H()    SCL_PORT->BSRR = SCL_PIN
#define I2C_SCL_L()    SCL_PORT->BRR  = SCL_PIN
#define I2C_SDA_H()    SDA_PORT->BSRR = SDA_PIN
#define I2C_SDA_L()    SDA_PORT->BRR  = SDA_PIN
#define I2C_SDA_READ() ((SDA_PORT->IDR & SDA_PIN) ? 1 : 0)
#define I2C_SDA_OUT()  /* 配置SDA为推挽/开漏输出 */
#define I2C_SDA_IN()   /* 配置SDA为浮空/上拉输入 */

然后是延时基准。标准模式 I2C 要求 SCL 高低电平各不小于 4.7us,我取半周期 5us,即 100kHz。如果你的主频比较高,用空循环估算不一定准,建议用定时器或者 DWT 做微秒延时。这里再用一个空循环示意:

static void I2C_Delay(void)
{
    /* 具体循环次数和主频、编译器优化有关,
       用逻辑分析仪看波形后调整,保证半周期约 5us */
    for (volatile uint32_t i = 0; i < 50; i++);
}

起始条件:

static void I2C_Start(void)
{
    I2C_SDA_OUT();
    I2C_SDA_H();
    I2C_SCL_H();
    I2C_Delay();
    I2C_SDA_L();      /* SCL高时,SDA拉低 = START */
    I2C_Delay();
    I2C_SCL_L();      /* 拉低SCL,准备传输数据 */
    I2C_Delay();
}

停止条件:

static void I2C_Stop(void)
{
    I2C_SDA_OUT();
    I2C_SDA_L();
    I2C_SCL_H();
    I2C_Delay();
    I2C_SDA_H();      /* SCL高时,SDA拉高 = STOP */
    I2C_Delay();
}

写一个字节,并读取从机 ACK:

static uint8_t I2C_WriteByte(uint8_t data)
{
    uint8_t i;
    I2C_SDA_OUT();

    for (i = 0; i < 8; i++)
    {
        I2C_SCL_L();
        if (data & 0x80)
            I2C_SDA_H();
        else
            I2C_SDA_L();
        data <<= 1;
        I2C_Delay();
        I2C_SCL_H();   /* SCL高电平期间,SDA必须稳定 */
        I2C_Delay();
    }

    /* 第9个时钟:释放SDA,读取从机应答 */
    I2C_SCL_L();
    I2C_SDA_IN();      /* 释放总线,改为输入 */
    I2C_Delay();
    I2C_SCL_H();
    I2C_Delay();
    uint8_t ack = (I2C_SDA_READ() == 0) ? 0 : 1; /* 0=ACK, 1=NACK */
    I2C_SCL_L();
    I2C_SDA_OUT();     /* 恢复输出状态 */
    return ack;
}

读一个字节,主机在第 9 个时钟发送 ACK 或 NACK。规则是:如果你还要继续读下一个字节,就回 ACK;读最后一个字节,回 NACK,告诉从机不再读了:

static uint8_t I2C_ReadByte(uint8_t ack)
{
    uint8_t i, data = 0;

    I2C_SDA_IN();      /* SDA切输入,准备接收数据 */

    for (i = 0; i < 8; i++)
    {
        I2C_SCL_L();
        I2C_Delay();
        I2C_SCL_H();
        I2C_Delay();
        data = (data << 1) | I2C_SDA_READ();  /* SCL高时采样 */
    }

    /* 第9个时钟:主机发送ACK(0)或NACK(1) */
    I2C_SCL_L();
    I2C_SDA_OUT();
    if (ack)
        I2C_SDA_H();
    else
        I2C_SDA_L();
    I2C_Delay();
    I2C_SCL_H();
    I2C_Delay();
    I2C_SCL_L();
    I2C_SDA_OUT();

    return data;
}

3.3 把字节读写组装成寄存器访问函数

QMI8658C 的寄存器读写流程就是标准的 I2C 内存寻址:先发送器件写地址,再发送寄存器地址,之后可以写数据,或者重新发起 START 然后发送器件读地址,连续读数据。

uint8_t QMI8658C_WriteReg(uint8_t reg, uint8_t val)
{
    I2C_Start();
    if (I2C_WriteByte(QMI8658C_ADDR_W)) return 1; /* 器件地址无ACK */
    if (I2C_WriteByte(reg))              return 2;
    if (I2C_WriteByte(val))              return 3;
    I2C_Stop();
    return 0;
}

uint8_t QMI8658C_ReadRegs(uint8_t reg, uint8_t *buf, uint8_t len)
{
    uint8_t res = 0;
    I2C_Start();
    res |= I2C_WriteByte(QMI8658C_ADDR_W);
    res |= I2C_WriteByte(reg);
    /* 重新发START,切换为读方向 */
    I2C_Start();
    res |= I2C_WriteByte(QMI8658C_ADDR_R);
    while (len--)
    {
        *buf++ = I2C_ReadByte(len ? 0 : 1); /* 最后一个字节回NACK */
    }
    I2C_Stop();
    return res;
}

这段代码里, QMI8658C_ADDR_W 是 0xD4, QMI8658C_ADDR_R 是 0xD5。很多人在读操作时忘记发第二次 START,直接接着发读地址,那样从机是不认的,因为你在同一个传输方向里既发了写又发了读,从机会把读地址当作数据来处理。这种细节只能靠亲手调波形才能印象深。


4. 加速度与角速度数据的读取和换算

I2C 通信打通之后,读传感器数据就是水到渠成的事。QMI8658C 内部维护一组输出寄存器,加速度和陀螺仪的数据都从这里读。

4.1 数据寄存器的排列方式

数据寄存器的地址布局如下:

寄存器 地址 内容
ACCEL_X_L 0x35 加速度 X 低字节
ACCEL_X_H 0x36 加速度 X 高字节
ACCEL_Y_L 0x37 加速度 Y 低字节
ACCEL_Y_H 0x38 加速度 Y 高字节
ACCEL_Z_L 0x39 加速度 Z 低字节
ACCEL_Z_H 0x3A 加速度 Z 高字节
GYRO_X_L 0x3B 陀螺仪 X 低字节
GYRO_X_H 0x3C 陀螺仪 X 高字节
GYRO_Y_L 0x3D 陀螺仪 Y 低字节
GYRO_Y_H 0x3E 陀螺仪 Y 高字节
GYRO_Z_L 0x3F 陀螺仪 Z 低字节
GYRO_Z_H 0x40 陀螺仪 Z 高字节

注意数据是低字节在前(小端序),别搞反了。

4.2 原始数据的拼装与符号处理

直接调用前面写的连续读函数,一次把 12 个字节读回来:

uint8_t raw[12];
QMI8658C_ReadRegs(0x35, raw, 12);

int16_t ax_raw = (int16_t)((raw[1] << 8) | raw[0]);
int16_t ay_raw = (int16_t)((raw[3] << 8) | raw[2]);
int16_t az_raw = (int16_t)((raw[5] << 8) | raw[4]);
int16_t gx_raw = (int16_t)((raw[7] << 8) | raw[6]);
int16_t gy_raw = (int16_t)((raw[9] << 8) | raw[8]);
int16_t gz_raw = (int16_t)((raw[11] << 8) | raw[10]);

为什么要把两个 uint8_t 拼成 int16_t?因为传感器的输出是二进制的补码形式,最高位是符号位。如果不强转成有符号类型,后面算平均和比较大小都会出错。 (raw[1] << 8) | raw[0] 这个写法在编译器里要留意 raw[1] 是 uint8_t,先转成 int 再移位,就不会出现符号扩展问题。

转换成物理量时,乘上我们在第 2 章算好的比例系数:

#define ACCEL_SCALE_8G  (8.0f / 32768.0f)     /* 单位:g/LSB */
#define GYRO_SCALE_512  (512.0f / 32768.0f)   /* 单位:dps/LSB */

float ax = (float)ax_raw * ACCEL_SCALE_8G;
float ay = (float)ay_raw * ACCEL_SCALE_8G;
float az = (float)az_raw * ACCEL_SCALE_8G;

float gx = (float)gx_raw * GYRO_SCALE_512;
float gy = (float)gy_raw * GYRO_SCALE_512;
float gz = (float)gz_raw * GYRO_SCALE_512;

4.3 用真实数值验证驱动是否正确

代码写完后,第一件事不是去跑算法,而是先验证读数合不合理。我自己验证的方法是:

  • 把板子水平静止放在桌面上,加速度 Z 轴读数应该在 1g 附近,X 和 Y 轴接近 0。如果计算出的模长 sqrt(ax*ax + ay*ay + az*az) 在 0.9g 到 1.1g 之间,说明量程配置和换算系数基本正确。
  • 陀螺仪静止时读数应该在 0 附近,一般 ±5dps 以内都算正常。如果静止时陀螺仪读数非常大,先检查是不是寄存器配置错误,或者芯片被焊偏了。
  • 用手慢慢转动板子,看对应轴向的陀螺仪读数是否跟着变化,方向是否正确。这一条能帮你发现坐标轴是否接反。

有些驱动代码还会读温度寄存器(0x33/0x34),用来验证 I2C 连续读是否稳定。温度值跳变很小,如果读回来的数字一直在跳,要么是 I2C 时序不稳,要么是电源纹波太大,可以顺带排查。


5. 调试路上踩过的坑和排查方法

这部分是全文最想让你重点看的内容。GPIO 模拟 I2C 的代码逻辑并不复杂,真正花时间的是那些看起来“莫名其妙”的问题。我把这几年调 I2C 传感器时踩过的典型坑整理出来,每一个都有现象、有排查过程、有解决方案。

5.1 读 WHO_AM_I 永远不对:地址和 ACK 的常见混淆

现象:代码运行后,读 WHO_AM_I 返回 0xFF 或者 0x00,和期望的 0x05 完全对不上。

第一次遇到这个问题,你可能跟我一样怀疑是芯片坏了。后来用逻辑分析仪抓总线波形才发现,发送的地址字节根本不对。QMI8658C 手册写的器件地址是 0x6A,这是 7 位地址。但在 I2C 协议里,发送时要把 7 位地址左移一位,再加上读写位。也就是说写地址是 (0x6A << 1) | 0 = 0xD4,读地址是 (0x6A << 1) | 1 = 0xD5。

很多人直接把 0x6A 发送出去,从机当然不回应,后续所有寄存器操作全部失败。还有一个隐藏点:如果读回的不是 0xFF 而是 0x00,大概率是地址对了但没有正确释放 SDA,导致从机拉低总线的 ACK 信号始终被主机输出电平覆盖,数据线被钳位在低电平。这时检查一下 I2C_WriteByte 里读 ACK 前是否调用了 I2C_SDA_IN() 释放总线。

排查步骤建议:

  1. 用逻辑分析仪抓 SCL 和 SDA,看看起始地址字节到底是什么。
  2. 确认代码里 QMI8658C_ADDR_W 和 QMI8658C_ADDR_R 是否分别为 0xD4 和 0xD5。
  3. 确认芯片的 SA0 引脚实际接法,如果 SA0 接高,地址要相应修改。

5.2 SDA 总是拉不低或总是低:上拉电阻和 GPIO 模式

现象:写地址发送后,从机一直回 NACK,或者 SDA 在通信开始时就被死死拉低。换了一颗芯片也一样,说明问题不在芯片,而在硬件电路或 GPIO 配置。

GPIO 模拟 I2C 有一个很多新手会忽略的硬件要求:SDA 和 SCL 必须接上拉电阻。I2C 总线标准里 SDA/SCL 是开漏结构,靠外部上拉电阻把电平拉到高。如果漏接上拉电阻,GPIO 配置成开漏输出时根本拉不高,SDA 平时就是低电平,起始条件都发不出来。

解决方案:在 SDA、SCL 上各加一颗 4.7kΩ 上拉电阻到 VCC。如果用内部上拉,部分 MCU 的内部上拉阻值在 30kΩ~50kΩ 左右,维持高电平没问题,但在高速通信或者总线电容较大时容易拉不上去,还是建议外部加电阻。

GPIO 模式也需要注意。有些同学把 SCL 和 SDA 都配成推挽输出,这样在不通信时总线电平可能不是高。I2C 规范要求总线空闲时 SDA 和 SCL 都是高电平,推挽输出虽然也能工作,但一旦你的驱动里对 SDA 方向切换实现得不好,总线状态就可能冲突。更稳妥的做法是 SCL、SDA 都配成开漏输出,配合外部上拉;需要读数据时把 SDA 切到输入模式,但外部上拉仍然生效。这样无论主机还是从机,释放总线时电平自动回到高。

5.3 偶发失败和数据错位:延时余量、地址自增和停止条件

现象:初始化后偶尔能读到正确数据,但多读几次就错位,或者连续读六个加速度字节时,发现读出来的值像是寄存器地址没变,每个字节都是同一个数。

这一类问题通常有三个原因。第一个是延时不足。前面说过标准模式要求 SCL 半周期不小于 4.7us,如果你用空循环延时太短,比如半周期只有 1~2us,传感器虽然大多数情况下也能响应,但在温度变化、电压波动时就可能丢 ACK 或数据错位。解决方法是把延时调大,比如半周期 10us,SCL 频率降到 50kHz 左右,对 IMU 这种数据量来说完全没影响,稳定性却大幅提升。

第二个原因是 CTLR1 的地址自增位没配置正确。连续读加速度数据时,如果地址不自增,每次读的都是同一个寄存器,表现为“数据重复”。QMI8658C 的 CTRL1 bit[6:5] 默认就有自动递增,但如果你在初始化时不小心把整个寄存器覆盖成别的值,就会关掉这个功能。建议初始化时先读一次 CTRL1,改完再写回,别整字节覆盖。

第三个原因是停止条件(STOP)没发对。有些驱动在读完最后一个字节后忘了发 STOP,或者把 STOP 和读字节的 ACK/NACK 时序搞混。结果就是总线还停留在读状态,下一次 START 后从机还认为你在继续读上一段数据。这个问题在波形上看非常明显:读操作结束后 SDA 没有在 SCL 高时拉高。保证 I2C_Stop 里先拉低 SDA、再让 SCL 变高、最后 SDA 拉高,顺序不能反。

5.4 一个实用的调试技巧:分步写一个最小验证函数

如果初始化怎么都不通,别急着调全流程,先写一个最小验证函数:只做 START、发送写地址、读 ACK、发送 STOP,然后在逻辑分析仪上看波形。这个函数如果你能看到完整的起始条件、地址字节和 ACK 低脉冲,说明底层时序没问题;如果看不到,说明底层就错了,往 GPIO 配置、上拉电阻、延时方向查。

我当时就是靠这个最小函数排掉了一个 GPIO 初始化顺序的 bug——SDA 方向切换被放在 SCL 初始化之前,导致 SCL 还处于未知状态时 SDA 就被拉低了,起始条件整个变形。调整顺序后,后面所有问题一下子全消失了。

调完这套驱动,我个人最大的体会是:GPIO 模拟 I2C 不是低效的代名词,而是一个非常趁手的调试和底层掌握工具。它逼着你去理解每一拍电平的意义,理解了以后再用硬件 I2C,你也会更清楚外设到底替你做了什么。如果你正在移植 QMI8658C 或者其他 I2C 传感器,建议把这套驱动先跑通、看懂,再往上叠加你自己的滤波和姿态解算逻辑,会顺手很多。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐