SMARTWATCH 学习手册有 C 基础 · 嵌入式从零开始
主线 / 从共用线路到独立器件驱动

I²C:读懂总线,写出一个传感器驱动

从为什么只需要一根数据线讲起,再沿 MPU6050 的手册完成身份、配置、数据读取与驱动封装。

前置:GPIO、UART、中断、SPI实战器件:历史 MPU6050先轮询,再类比 IT / DMA
这一章要带走什么?不只是记住收发函数:要能说明谁控制线路、地址怎样选择设备、寄存器怎样配置功能、原始字节怎样变成带单位的数据,并据此阅读新器件手册,搭起基础驱动。

第 1 节 · 从 SPI 出发:省掉片选线,再省掉一根数据线

前面讲 SPI 时,主机通过 CS 片选线选中要通信的器件。连接多个器件时,每个器件通常都需要自己的一根 CS,器件越多,占用的片选引脚也越多。

现在我们想省掉这些片选线,让多个器件共用通信线路。但这样就出现了一个问题:没有 CS 了,主机怎样确定这次选哪颗器件?器件又怎样知道主机是不是在找自己?

I²C(也常写 IIC)的办法是引入设备地址,用一个编号区分总线上的器件。主机在通信开头,先通过数据线发出要找的设备地址。总线上的器件都检查这个地址,只有地址匹配的器件回应并参与后续通信,其余器件不应答。这样,就能通过发送地址来选择器件,省去每颗器件单独的一根片选线。

下面先讨论一个主机连接多个从机的情况;手册中的 controller/target 与常见的主机/从机说法对应。

既然已经省掉了片选线,能不能再省一根线呢?可以,还能把 SPI 原来的两根数据线合成一根。接下来就看看,这一根数据线怎样完成两个方向的通信。

为什么只要一根数据线?代价是什么?

SPI 全双工接法用 MOSI 和 MISO 分别承载两个方向,可以一边发、一边收。如果放弃“两个方向同时传数据”,就能让双方轮流使用同一根线:主机发送时,从机听;从机发送时,主机听。这就是本章 IIC 的半双工方式。

因此 IIC 只保留两条通信线:SCL 时钟线维持共同节奏,SDA 数据线轮流承载两个方向的数据。电源与 GND 仍要连接,“两线”不包含它们。省引脚的代价包括不能同时双向交换有效数据,以及需要地址、应答和方向交接等过程。

一颗主机和两颗不同地址的器件共用 SCL SDA
先按地址选设备,再由双方按规则轮流使用 SDA;同一总线上地址冲突必须解决。

设备地址与寄存器地址不是一回事

设备地址回答“找哪颗芯片”。选中芯片后,通常还要指定“操作里面哪个位置”,这就用到寄存器地址。可以把前者理解成选择一个设备,后者理解成选择它的某项配置或数据。它们不是同一层的地址,恰好数值相同也没有特殊关系。

例如 MPU6050 的七位设备地址可为 0x68;它内部的身份寄存器地址是 0x75,读出的身份值又是 0x68。这里第一个 0x68 是找设备用的编号,第二个是从数据位置读出的内容,不能因相同就认为读到了设备地址本身。

同一颗器件,为什么会出现 0x68、0xD0、0xD1?

先固定看一颗 MPU6050:AD0 接低电平时,它的设备地址是 0x68。把它写成七位二进制,就是 1101000。这七位只负责告诉总线上的器件“我找谁”。无论接下来读还是写,找的都是这颗 MPU6050,这七位就保持不变。

但主机还要告诉被选中的器件:这次是主机写入,还是主机读取?IIC 在这七位后面再接一位:0 表示写,1 表示读。这样,通信时先发出的就是一个完整的八位字节:

接下来要做什么线上发出的八位:找谁 + 读写位整个字节的值
主机向这颗器件写入1101000 00xD0
主机从这颗器件读取1101000 10xD1

注意看前七位,两行都是 1101000,所以选中的器件相同。只有最后一位变了,用来区分读、写。0x68 是设备地址;0xD0 和 0xD1 是加上读写位后,通信时发出的地址字节。

现在再看数据手册的不同写法,就能对应起来了:

  • 写着 7-bit address = 0x68:手册只给出了用于选择器件的七位。
  • 分别写着 Write address = 0xD0、Read address = 0xD1:手册已经把读写位也加进去了。手册把它们称为“八位地址”时,说的就是上表中的整个地址字节。

所以下面统一用“设备地址”指 0x68 这样的七位编号,用“地址字节”指 0xD0 / 0xD1 这样包含读写位的八位数。查手册时先看它是否包含 R/W 位,再决定怎样处理。

七位设备地址后接一位读写标志,见 NXP IIC 规范 §3.1.10、图 10。

写程序时,什么时候需要 << 1?

刚才是在七位编号 1101000 的后面接上一个读写位。在 C 里,可以先用 << 1 把原来的七位向左移动,末尾补一个 0,为读写位留出位置:

0x68 << 1:1101000 → 11010000,结果就是 0xD0。

接下来要区分:交给 HAL 函数的参数,和最终在线路上发出的地址字节。本项目使用 STM32U5 HAL1,在七位地址模式下,DevAddress 这个参数要求调用者先把设备地址左移一位。因此,调用函数时这样填写:

调用哪种函数传给 DevAddress 的值
HAL 的写函数0x68 << 1,也就是 0xD0
HAL 的读函数同样传 0x68 << 1,也就是 0xD0

为什么读的时候也传 0xD0?因为读写方向由 HAL 根据调用的函数另行设置。传参时,调用者只需要把设备编号左移、留好最低位;发出读方向的地址字节时,HAL 会把读写位设成 1,使它成为 0xD1。参数里末尾补的 0,不代表调用者已经选定了写方向。完整的读寄存器过程会在第三节展开。

因此,拿到手册后,填写本章 HAL 参数时只需分清这两种常见情况:

  1. 手册给的是设备地址 0x68:使用 0x68 << 1,得到 0xD0。
  2. 手册已经列出写地址 0xD0、读地址 0xD1:直接取其中的写地址 0xD0,读、写函数都使用它,不再左移。

仓库的 MPU6050.c 已经把 MPU6050_Addr 定义为 0xD0,所以后面读它的代码时,会看到函数直接使用这个值。左移已经做过一次,就不要再移一次。器件内部的寄存器地址 0x75 则仍原样传入,不参与这里的转换。

查阅:手册只给读地址字节,或者换了其他函数库怎么办?

如果手册明确给的是含读写位的读地址字节 0xD1,可以先用 0xD1 >> 1 去掉最低读写位,得到设备地址 0x68,再按本章 HAL 的要求左移一次。这样最终传入的仍是 0xD0。对已经包含读写位的数,不要直接再左移。

如果换用的库规定参数直接接收七位设备地址,那就直接传 0x68,不用左移。是否移位由函数接口约定决定;本章“读、写都传 0xD0”的规则针对本项目的 STM32U5 HAL1 接口。

同样,I2C_MEMADD_SIZE_8BIT 表示器件内部寄存器地址占一个字节,后面会在 HAL 参数表里解释。它与设备地址是否包含读写位无关。

接口约定见仓库 HAL_I2C_Mem_Read 参数说明;驱动中的定义见 MPU6050.c。函数的完整参数在第四节讲解。

试一试:七位地址、线上字节与 HAL 参数

地址字节由“七位地址+一位方向”组成,0 表示写、1 表示读。此处演示普通七位设备,不使用保留地址与十位地址。

第 2 节 · 两根线怎样传出一个字节?

先认清两根线的名字:SDA 是数据线,线上电压的高、低用来表示 1、0;SCL 是时钟线,电压反复由低变高、再变低,两端按照这个节奏安排准备数据和读取数据的动作。下面固定用 STM32 作主机、一颗传感器作从机来讲。

以采用 3.3 V 逻辑电平的接线为例,以 GND 为 0 V:SDA 电压接近 0 V 时,芯片读作 0;接近 3.3 V 时,读作 1。这里先用“低”和“高”指这两种电压状态,实际判定范围以芯片手册为准。

先看电路:“拉低”和“释放”究竟做了什么?

STM32 和传感器都连在同一根 SDA 上。每颗芯片内部都有控制这根线的电路,可以先用一个连接 SDA 与 GND 的开关来理解:

  1. 内部开关接通:SDA 经过芯片内部电路接到 GND,电压接近 0 V。这叫拉低 SDA。
  2. 内部开关断开:这颗芯片停止把 SDA 接到 GND。这叫释放 SDA。断开的是内部连接,外面的导线仍然连在引脚上。
SDA通过电阻接向3.3V,两颗芯片内部各有接向GND的开关
图中的两个开关都断开,SDA 经电阻连接电源,因此为高。任意一个开关接通,SDA 就经那颗芯片接到 GND,变成低电平。开关是内部电路的示意。

那么,两边的内部开关都断开后,SDA 怎样回到高电平?接线中还有一只电阻,把 SDA 连向 3.3 V 电源。没有芯片把 SDA 接到 GND 时,它使 SDA 回到接近 3.3 V。这只电阻叫上拉电阻。

这就是本节使用的开漏输出:芯片负责接通或断开到 GND 的内部连接,上拉电阻负责在两端都断开时使线路回到高电平。后文说“拉低”,就对应第一种动作;说“释放”,就对应第二种动作。

试一试:改变哪一条到 GND 的内部连接?

勾选表示这颗芯片的内部开关接通;取消勾选表示内部开关断开。

先只勾选传感器,再勾选、取消 STM32。观察:只要传感器仍把 SDA 接到 GND,STM32 断开自己的连接后,线上电压仍然是低。

把这个电路与 GPIO 的 SET、RESET 对上

在 GPIO 配置为开漏输出时,写 RESET 使内部电路接到 GND;写 SET 使这条内部连接断开。于是 SET 之后,读到高还是低,还要看另一颗芯片和上拉电阻。使用硬件 IIC 时,IIC 外设按通信过程控制这些动作。

先只传一个 0:哪根线先变,另一端什么时候读?

现在让 STM32 发送、传感器接收。STM32 设置 SDA 上的电平,传感器按照 SCL 的节奏读取。

  1. 让 SCL 处于低电平。这段时间用来准备要发的数据。
  2. STM32 让 SDA 变为低电平。低电平表示要发送的 0。
  3. 让 SCL 变为高电平。SDA 保持低,传感器在这段时间读到 0。
  4. 让 SCL 再变回低电平。这一位结束,可以准备下一位。

要发送 1,只需在第 2 步让 SDA 为高,并在第 3 步保持高,传感器读到的就是 1。其余步骤相同。

把动作顺序记成一句话SCL 为低时,发送方准备 SDA 上的 0 或 1;SCL 为高时,发送方保持这个电压,让接收方读取;SCL 回到低后,再准备下一位。

一个 0 或 1 叫一位(bit),八位组成一个字节。IIC 先传最高位 bit7,最后传 bit0。例如 0xD0 的二进制为 11010000,就按 1、1、0、1、0、0、0、0 的顺序重复上面的过程。

怎样让器件知道“开始了”和“结束了”?

前面只看了一位数据。真正通信时,STM32 还要先发出一个开始标志,让总线上的器件准备接收。没有通信时,SDA、SCL 都保持高电平,称为空闲。

开始标志 START:STM32 先让 SCL 保持高,再让 SDA 从高变低。器件看到“SCL 仍然高,SDA 却从高变低”,就知道一段通信开始了。随后 STM32 让 SCL 变低,准备第一个数据位。

结束标志 STOP:最后一次应答结束后,STM32 先在 SCL 为低时让 SDA 变低;接着让 SCL 变高,SDA 仍保持低;最后让 SDA 从低变高。器件看到“SCL 仍然高,SDA 从低变高”,就知道这段通信结束了。

START与STOP分别分成三步,标出每一步SCL和SDA的电压
开始和结束也有动作顺序。STOP 中先使 SCL 回到高,再让 SDA 回到高,才能形成规定的结束标志。点击图片可查看大图。

为什么不会把它们误读成普通的 0 或 1?因为传数据时,SDA 在 SCL 为高的整段时间保持不变;START、STOP 则利用这段时间内 SDA 的变化,分别表示开始和结束。

八位发完,怎样给接收方一个回应的机会?

STM32 发出八位数据,只能说明它完成了发送,还不知道传感器是否在场、是否能够接收。因此 IIC 在每个字节后留出一次应答机会,由接收方回应。

STM32 发完八位后,还会提供第九个时钟。前八拍由 STM32 在 SDA 上放数据;第九拍把 SDA 留给刚才接收数据的传感器回应,这一拍不是第九个数据位。SCL 的节奏仍由 STM32 控制。

  1. 第八拍结束,SCL 回到低电平。STM32 释放 SDA,把第九拍的 SDA 电平交给传感器决定。
  2. 传感器准备应答。确认接收时,让 SDA 为低;没有应答时,SDA 为高。
  3. STM32 让 SCL 变高,并读取 SDA。读到低,就是 ACK(应答);读到高,就是 NACK(未应答)。
  4. STM32 再让 SCL 变低。这一拍结束,传感器释放 SDA,双方再准备后续传输或结束信号。
STM32发送八位后释放SDA,由传感器在第九拍给出应答电平,STM32读取结果
这一例是 STM32 发数据、传感器回应。第九拍换的是 SDA 上负责回应的器件,SCL 仍由 STM32 控制。点击图片可查看大图。

跟着一个字节走:八位数据,再读一次应答

这里演示发送 0xD0 = 11010000。开始标志已发出,当前 SCL 为低;每点击一次,观察一拍由谁设置 SDA、谁读取。

动画刻意放慢,只演示一个字节与一次 ACK。完整读寄存器的例子接着在下一节展开。

传感器向 STM32 返回数据时,谁来应答?

读取传感器数据时,前八拍改由传感器设置 SDA,STM32 读取。第九拍就由刚接收这八位的 STM32回应。可以先用这条规则判断:这一字节是谁接收的,应答就由谁决定。

前八拍是谁发送?谁接收?第九拍由谁应答?
STM32 发送设备地址、寄存器编号或待写入的值传感器传感器
传感器返回读出的数据STM32STM32

例如 STM32 要连续读取两个字节。传感器发完第一个字节后,STM32 在第九拍让 SDA 为低,回复 ACK,表示还要继续接收;第二个字节发完后,STM32 在第九拍让 SDA 为高,回复 NACK,表示这次读取到此为止。随后由 STM32 发 STOP。

同样读到 NACK,要结合它出现的位置理解发设备地址后得到 NACK,表示这次没有收到该地址的应答,需要检查地址、供电或器件状态。STM32 读完所需的最后一个字节后主动发 NACK,则是正常结束读取的步骤。

把这一节连起来看,一段写入通常是:开始 → 发八位 → 读第九拍应答 → 按需要再发下一个字节并读取应答 → 结束。下一节把设备地址、寄存器编号和实际数值放入这个过程。

扩展:上拉电阻、等待时钟和线路故障

上拉电阻要与接线和通信速率匹配。阻值过大,电压回到高可能太慢;阻值过小,接到 GND 时的电流会增加。模块可能已经带上拉电阻,接线前先查其原理图。初次实验可先用 100 kHz,随后再核对波形。

主机释放 SCL 后,线路的电压需要时间升高;传感器也可能暂时将 SCL 保持为低,让主机多等一会儿,这叫时钟拉伸。主机应确认 SCL 实际已为高,再继续相应动作;软件等待也需要超时限制。多主机同时访问和线路一直为低的恢复过程,留到基本读写通顺后再学习。

对应规范:NXP UM10204 Rev.7.0 §3.1.1~3.1.6、§3.1.8~3.1.9、§3.1.16。

名词查阅:SDK 与 SDA、SCL 有什么区别?

SDA、SCL 是本节两根通信线的名称。SDK 是 Software Development Kit(软件开发工具包),通常指厂商提供的一套库文件、头文件、示例和开发工具,帮助我们写程序。STM32 的库函数和示例属于软件开发资料;SDA、SCL 则对应硬件接线。

第 3 节 · 从一次传输,走到一次寄存器读取

寄存器先理解成芯片开放的配置与数据位置

传感器并非接上就会主动往 MCU 内存里写数据。它通常提供若干位置:有的用于选择量程,有的表示是否准备好,有的保存测量结果。给这些位置编号,应用就能通过读写改变设置或取得信息。具体哪个地址有什么含义,必须查对应型号的寄存器手册。

一个寄存器常有八位,但每一位可能做不同事情。手册里的 R 表示只读、R/W 表示可读写;Reserved 是保留位,不应随意设置。有些状态读一次就清除,有些位写 1 才清除,不能认为所有地址都像普通 RAM 一样随便读回、覆盖。

完整走一遍:让 STM32 读出 MPU6050 的型号标识

现在假设 STM32 已经接好一颗 MPU6050。我们先做一件具体的事:读出它的型号标识,检查接上的器件是否符合预期。数据手册把保存这个标识的寄存器叫作 WHO_AM_I,可以理解成芯片回答“我是谁”的位置。

根据MPU6050 寄存器手册 §4.34,本次读取要用到下面三个数。这里仍以 AD0 接低电平为例:

先明确的问题本例的答案
找总线上的哪颗芯片?设备地址为 0x68 的 MPU6050
读它内部的哪个位置?寄存器地址 0x75,名称为 WHO_AM_I
预期从这个位置读到什么?型号标识值 0x68

本例的设备地址和预期型号标识都是 0x68:前一个是 STM32 发出去选芯片的,后一个是芯片返回的数据。我们要完成的是:先告诉 MPU6050“我要读 0x75 这个寄存器”,再让它把其中的一个字节发回来。

STM32选择MPU6050并指定0x75寄存器,重复开始后改为读取,传感器返回0x68,STM32结束通信
先看清整次读取的目标和方向,再按下面的步骤逐个理解。图中的 ACK 表示接收方确认,最后一次 NACK 表示 STM32 已经读完所需的一个字节。点击图片可查看大图。

前半段:STM32 告诉 MPU6050,要读哪个寄存器。

  1. STM32 发出 START。总线上的器件开始等待主机发送设备地址。
  2. STM32 发送 0xD0,MPU6050 回复 ACK。0xD0 是前面算过的“设备地址 0x68 加写方向”。它选中这颗 MPU6050,并说明接下来的字节由 STM32 发送。
  3. STM32 发送 0x75,MPU6050 再回复 ACK。这个字节告诉芯片:“接下来访问 WHO_AM_I 寄存器。”芯片记下要访问的位置,准备随后返回这里的内容。

到这里,STM32 已经把“读哪个位置”告诉了芯片。接下来要让 MPU6050 把寄存器内容发回来,因此需要把传输方向改为读。

后半段:MPU6050 返回数据,STM32 接收并结束。

  1. STM32 再发出一次 START,中间先不发 STOP。上一次 ACK 结束后,SCL 先回到低,MPU6050 释放 SDA,使 SDA 回到高。随后 STM32 让 SCL 变高,再让 SDA 从高变低,形成新的 START。这就叫重复起始(Repeated START):在同一次操作里,重新开始发送设备地址。
  2. STM32 发送 0xD1,MPU6050 回复 ACK。这次仍选中设备地址为 0x68 的同一颗芯片,但最后的方向位变成了 1,表示“现在请你发送数据给我”。
  3. MPU6050 发送寄存器中的一个字节,STM32 读取。此时数据的发送方换成了 MPU6050;SCL 时钟仍由 STM32 产生。对于 WHO_AM_I,预期收到的八位是 01101000,也就是 0x68。
  4. STM32 在第九拍回复 NACK。因为只需要这一个字节,STM32 用高电平表示“这次读到这里就够了”,不再要求下一个字节。
  5. STM32 发出 STOP。本次通信结束,读到的 0x68 已经保存在 STM32 的接收变量中。

这次读取的结果是什么?STM32 取得了 WHO_AM_I 寄存器的内容,再将它与手册给出的预期值 0x68 比较;一致时,就通过了这一步型号标识检查。

为什么读之前,先用写方向发两个字节?因为芯片还不知道 STM32 想读哪一个寄存器。0xD0 让芯片准备接收主机发来的字节,紧接着的 0x75 用来指定寄存器位置;随后通过重复 START 和 0xD1 改成读方向,芯片才把这个位置的内容返回。把它连起来记,就是:选芯片 → 指定寄存器 → 改为读方向 → 取回数据 → 结束。

这条先指定寄存器、再改为读取的流程,见MPU6050 产品规格书 §9.3,单字节读取时序。重复 START 连接着前后两段操作;其他器件是否允许中间先 STOP、是否也采用寄存器读写方式,应查它自己的手册。

对照一下:真正写入寄存器内容时,后面还会发什么?

例如,要把数值 0x01 写入 MPU6050 的电源管理寄存器 0x6B,STM32 会先发 START,再依次发送三个字节:

  1. 0xD0:选中 MPU6050,采用写方向。
  2. 0x6B:指定要写入的寄存器。
  3. 0x01:把这个值作为寄存器的新内容。

三个字节每发送完一个,都由 MPU6050 回复 ACK,最后由 STM32 发 STOP。可以看到:指定寄存器地址之后,再发送数值,是写入内容;指定寄存器地址之后,改为读方向,则是取回内容。后面的初始化例子会再结合寄存器位定义解释具体配置值。

把刚才的寄存器读写,写成函数调用

刚才把总线上的动作逐步拆开,是为了看懂 STM32 和传感器分别做了什么。写程序时,可以调用 HAL 提供的两个函数,让 IIC 外设完成这些时序:

函数调用后要完成的事
HAL_I2C_Mem_Read()指定芯片和寄存器,将读出的字节存入我们提供的变量。
HAL_I2C_Mem_Write()指定芯片和寄存器,将我们提供的字节写入那个位置。

名字中的 Mem 在这里对应器件内部的位置,MPU6050 的寄存器也可以这样读写。这两个函数都会返回本次通信的状态,其中 HAL_OK 表示成功;读函数实际读到的数值,放进我们指定的变量里。

下面使用已经完成 I2C1 配置和初始化的实验工程。i2c.h 声明了工程中的 hi2c1 变量,用它管理 I2C1;把 &hi2c1 传给函数,就表示本次使用 I2C1。配置和初始化步骤接着在第四节讲。

例一:读取 WHO_AM_I,让结果进入变量 id。

这次选设备地址 0x68 的 MPU6050,读寄存器 0x75,只取一个字节。MPU6050 规定寄存器编号用一个字节发送,所以这里的地址长度参数写 I2C_MEMADD_SIZE_8BIT。

包含 i2c.h;调用部分放在 I2C1 初始化完成之后

#include "i2c.h"

uint8_t id = 0;

HAL_StatusTypeDef read_status = HAL_I2C_Mem_Read(
    &hi2c1, 0x68 << 1,
    0x75, I2C_MEMADD_SIZE_8BIT,
    &id, 1, 50);

按顺序读这次调用:&hi2c1 选择 I2C1,0x68 << 1 选择 MPU6050,0x75 选择它内部的 WHO_AM_I 寄存器。&id 告诉函数把数据存到哪里,1 表示读取一个字节,50 是以毫秒为单位的超时参数。

HAL_StatusTypeDef 是 HAL 用来表示通信状态的类型。调用返回后,先看 read_status:为 HAL_OK 时,才能把 id 当作本次读到的结果,再检查它是否等于 0x68。read_status 保存通信状态,id 保存寄存器里的值。可以在调试器中分别观察它们。

这一句读函数,会完成前面图里的“选择芯片 → 指定 0x75 → 重复 START → 改成读方向 → 收取一个字节 → NACK → STOP”,并把收到的字节存入 id。

例二:把变量 value 里的 0x01 写入寄存器 0x6B。

仍然使用同一颗 MPU6050。这次将要写入的数值放在 value 中,再把它的地址交给写函数:

同样包含 i2c.h,并在 I2C1 初始化完成后调用

uint8_t value = 0x01;

HAL_StatusTypeDef write_status = HAL_I2C_Mem_Write(
    &hi2c1, 0x68 << 1,
    0x6B, I2C_MEMADD_SIZE_8BIT,
    &value, 1, 50);

与读函数对照:寄存器改成了 0x6B,数据地址改成了 &value。在写函数里,这个地址告诉 HAL 从哪里取得要发出的数据;1 表示写入一个字节。

当 write_status == HAL_OK 时,这次写入通信已完成。对应的总线过程就是“START → 发送 0xD0 选芯片 → 发送 0x6B 选寄存器 → 发送 0x01 作为内容 → STOP”,三个字节都由 MPU6050 应答。

后面阅读仓库的 reg_read() 和 reg_write() 时,会看到它们正是调用这两个 HAL 函数来完成寄存器操作。完整参数表见第四节的 Mem_Read / Mem_Write 说明。

函数签名和参数约定可对照仓库中的 HAL_I2C_Mem_Write、HAL_I2C_Mem_Read。

本章把两份 MPU6050 手册放在旁边

主例使用仓库曾用、现已停用的 MPU6050。以下是 InvenSense 厂商原文的 SparkFun 托管副本:产品规格书 PS-MPU-6000A-00 Rev.3.4(2013-08-19),负责电气、接口和通信;寄存器说明 RM-MPU-6000A-00 Rev.4.0(2012-03-09),负责地址和位定义。页码按各自 PDF,不要在另一版本中直接套页码。链接用于查阅,不把 MPU6000 的 SPI 能力套到 MPU6050 上。

要查的问题手册入口本章怎么用
引脚、供电与地址选择§7.1~7.2,p.21~22;§9.2~9.3,p.33 起AD0 低选 68,高选 69;模块的稳压及上拉另查其原理图
身份与电源管理§4.34,p.46;§4.30,p.4175:WHO_AM_I;6B:复位、睡眠和时钟
量程、滤波与输出节奏§4.2~4.5,p.11~1519、1A、1B、1C,读位定义再设置
是否有新数据、怎样拼接§4.17~4.20,p.29~323A 状态;3B 起连续读取加速度、温度、角速度

接成品模块时,VCC 标记不必然等于芯片裸片 VDD,AD0 也可能已有默认电阻。先确认模块额定供电与 IO 电平,再把 SDA/SCL 接到实验工程相应引脚并共地。INT 本章轮询实验暂不接,XDA/XCL 是辅助接口,不要误当成主 IIC 接口。

已经知道线上动作之后,下一步由硬件 IIC 外设完成这些时序,先把器件读通。用普通 GPIO 手动产生相同动作的软件 IIC,放在本章末尾的可选扩展中。

第 4 节 · 在 CubeMX 与 HAL 中完成同一件事

先读配置截图:哪些是主机实验需要的?

选择 I2C1 的 I2C 模式,核对 SDA、SCL 对应引脚。仓库 i2c.c 使用 PB3 作 SDA、PB6 作 SCL,配置为 Alternate Function Open Drain;这里的复用表示由 IIC 外设接管时序,开漏仍保持前面“拉低或释放”的电气规则。

CubeMX I2C1 参数,Fast Mode 400kHz,七位地址和滤波设置
I2C1 参数页面示例。初次独立接线也可从 Standard Mode 100 kHz 开始,按实际时钟重新计算 Timing。 点击可查看原图。
界面设置怎样理解本次使用
I2C Speed Mode / Frequency总线时钟速率类别与目标值截图 Fast Mode、400 kHz;不是每秒 400000 个有效数据字节
Rise/Fall Time对线路上升/下降时间的输入估计,参与时序计算截图 100/20 ns,不是软件设置后线路就必然达到这些值
Analog / Digital Filter对输入毛刺的过滤,会影响时序截图模拟 Enabled、数字系数 0;先沿用并核对实际波形
Timing根据外设时钟与时序要求计算的寄存器值截图 0x10F02A88,与当前仓库一致;换时钟或速率不能原样照搬
Primary Address Length地址格式选择本章七位设备地址
Primary slave addressSTM32 自己作为从机时使用的地址主机实验中的 0 不是 MPU6050 的地址;设备地址在 HAL 调用中传入
Clock No Stretch ModeSTM32 作从机时是否禁止其时钟拉伸Disabled 不代表把所有从机的拉伸关掉
General Call / Dual address / Autonomous广播、自身双地址与自主工作等选项本次不使用,按截图禁用

GPIO 页检查开漏与上拉,线路上的外部上拉仍需按硬件核对。PB6 的 Fast-mode Plus 驱动能力选项不等于把总线频率自动变成 1 MHz。本章解释配置,不通过 Generate Code 覆盖现有固件。

工程中的 hi2c1 从哪里来?

I2C_HandleTypeDef 是 HAL 定义的结构体类型,用来保存某个 IIC 外设的配置、运行状态及缓冲信息。hi2c1 是本工程为 I2C1 定义的管理对象,&hi2c1 是它的地址。它不是传感器设备地址;同一总线上的多个传感器使用同一个管理对象,设备地址不同。

工程的 i2c.h 提供声明,i2c.c 提供对象定义与 MX_I2C1_Init。函数无参数、无返回值,完成片内 I2C1 的初始化;main 的基础初始化路径必须真正调用它。不要在实验源文件里重新定义另一个同名 hi2c1。

第一次调用:检查指定地址是否应答

HAL_I2C_IsDeviceReady 依次接收管理对象地址、设备地址、尝试次数和超时毫秒数,返回 HAL_StatusTypeDef。HAL_OK 表示该地址通过就绪检查,不证明型号正确或已经能测量。HAL_ERROR、HAL_BUSY、HAL_TIMEOUT 要当成不同排查线索,不用一个空循环掩盖失败。

这里沿用第一节已经算出的地址:七位设备地址 0x68 左移一次得到 0xD0。本项目 STM32U5 HAL1 的读、写接口都把这个值传给 DevAddress,读写方向由 HAL 设置。若地址定义已经是 0xD0,就直接使用,不能再次左移。

独立实验中的教学片段 · 不与现有驱动重复定义

#include "i2c.h"

/* 放在 HAL、GPIO、I2C1 均已初始化之后的普通 main 路径。 */
const uint16_t mpu_address = (uint16_t)(0x68U << 1);
HAL_StatusTypeDef status = HAL_I2C_IsDeviceReady(
    &hi2c1, mpu_address, 2, 50);
/* 在调试器观察 status;失败时先排查,不继续假定器件存在。 */

读取寄存器:Mem_Read 的 Mem 指器件内部地址

上一节的两个示例已经展示了 Mem_Read 读回寄存器内容、Mem_Write 写入寄存器内容的效果。这里以读取 WHO_AM_I 为例,把函数的七个参数逐一对上工程中的变量和手册中的地址:

参数顺序本例作用
hi2c&hi2c1选片内 I2C1
DevAddress0x68 << 1选总线上的 MPU6050
MemAddress0x75选器件内部 WHO_AM_I 位置
MemAddSizeI2C_MEMADD_SIZE_8BIT内部地址占一个字节,不是设备地址位数,也不是结果长度
pData&id接收字节写到哪里
Size1读取多少字节
Timeout50阻塞调用的超时毫秒数

接在上例同一作用域 · 已有 status 与 mpu_address,不重复声明

uint8_t id = 0;
status = HAL_I2C_Mem_Read(&hi2c1, mpu_address,
                          0x75, I2C_MEMADD_SIZE_8BIT,
                          &id, 1, 50);
if (status == HAL_OK && id == 0x68) {
    /* 型号检查通过,再进入复位和配置。 */
} else {
    /* 分别检查通信失败与身份不符,不把 id 当有效测量值。 */
}

AD0 高时,把七位地址改为 0x69,WHO_AM_I 仍按其手册返回身份值 0x68,不能要求它也变成 0x69。设备存在、型号正确、测量配置正确,是三次不同的检查。

Master_Transmit/Receive 与 Mem_Read 有什么区别?

HAL_I2C_Master_Transmit 的参数为管理对象、左移后的设备地址、发送区地址、字节数、超时;Master_Receive 同样排列,但缓冲区用于接收。两者都返回 HAL_StatusTypeDef。它们适合按器件协议发送或接收字节,不会凭空知道内部寄存器编号。

普通阻塞 Master_Transmit 后接 Master_Receive 通常是两笔带 STOP 的事务,不能据此声称一定生成了重复 START。内部地址模型适合时用 Mem_Read;特殊命令协议则按手册组织 Master 或顺序接口,不是所有器件都套同一函数。

和 SPI 一样:阻塞、_IT、_DMA

IIC 也有前一章 SPI 的三种使用方式:不带后缀的阻塞函数等到完成或失败再返回;_IT 启动后由中断继续处理;_DMA 让 DMA 搬运数据,再走完成与错误处理。UART 串口也有这三种方式,例如 HAL_UART_Transmit、HAL_UART_Transmit_IT 和 HAL_UART_Transmit_DMA。

本次寄存器读取完成时走到哪里调用者注意
HAL_I2C_Mem_Read函数返回后检查状态本次先用它,把协议和测量配置看清楚
HAL_I2C_Mem_Read_ITHAL_I2C_MemRxCpltCallback启动成功不等于读取完成,接收区必须一直有效
HAL_I2C_Mem_Read_DMAHAL_I2C_MemRxCpltCallback还需 DMA 通道与中断链路;不能在完成前使用或改写接收区

Mem_Write 的完成通知是 MemTxCpltCallback;Master_Receive 则有 MasterRxCpltCallback,不要混用。默认回调有 __weak,可以在应用中按相同签名重新实现,并先判断 hi2c 是否对应本模块。失败走 HAL_I2C_ErrorCallback,清理忙状态并把失败交回普通流程;不要在回调中 HAL_Delay 或无限重试。

I2C1 Event 与 Error 中断勾选,抢占优先级7,子优先级0
I2C1 的事件与错误中断配置。截图是项目设置,不是所有独立实验必须采用的优先级。 点击可查看原图。

截图中的 Event 负责外设事件、Error 负责错误,两者优先级为 7/0。只用本章阻塞函数时,不需要因为这些框勾选了才会读到数据;使用 IT/DMA 则要检查对应 IRQHandler 调到 HAL、DMA 配置和完成路径。优先级与 HAL 时基的关系按中断章处理,数字 7 不提供普遍保证。

第 5 节 · 读懂配置位,让 MPU6050 真正开始测量

从读身份,走到复位、唤醒和配置

WHO_AM_I 能读通,只能证明访问链路与身份检查有结果。传感器可能还在睡眠,量程也不一定符合预期。下一步先按寄存器手册复位,再退出睡眠、设置测量范围。

位置本例做法怎样对照代码理解
6B:PWR_MGMT_1原代码写 80 复位,等待 100 ms,再写 00 唤醒bit7 DEVICE_RESET,bit6 SLEEP;00 清除睡眠,CLKSEL=0
1A:CONFIG;19:SMPLRT_DIV原初始化没有写这两个位置了解原有默认配置后,再按需要修改滤波与采样分频
1B:GYRO_CONFIG原代码整字节写 18,其中 FS_SEL=3字段位于 bit4:3,移到位置后是 18,范围 ±2000°/s
1C:ACCEL_CONFIG原代码整字节写 10,其中 AFS_SEL=2同在 bit4:3,移到位置后是 10,范围 ±8g

这里的 80、00、18、10 都是十六进制字节。±8g 表示各轴配置的加速度测量范围,±2000°/s 是角速度范围;不是“角度正负 2000 度”。滤波选择与分频需要配套看,不能把任意配置都代入 1 kHz。器件采样与应用读取是不同动作,不能只看主循环多久执行一次就推断器件内部的采样率。

为什么修改两位,不能直接乱写整个字节?

例如只改加速度量程:字段值 2 的二进制是 10,要放到 bit4:3,得到 0001 0000,即 0x10。掩码 0x18 的二进制为 0001 1000,表示这两位需要修改。先读出旧配置,将对应位清零,再填入新值,其余位保留。

位运算示例 · old 是已成功读出的普通 R/W 配置字节

uint8_t next = (uint8_t)((old & (uint8_t)~0x18U)
                         | ((2U << 3) & 0x18U));

这叫读改写。只在手册允许的普通配置寄存器上这样做;读清零、写一清零、写入触发动作的特殊位置不能机械套用。复位后明确建立全部初始状态时,可以依据位定义写整个字节,但仍要明确保留位和其他功能的取值。

只改变 bit4:3,其余位是否保留下来?

仅演示位运算,不让网页配置实际器件;真实初始化还应按手册关闭不需要的自检等功能。

先把反复用到的读写动作,取两个简单的名字

前面读身份时,每次都要写 HAL_I2C_Mem_Read,并填写总线、设备地址、内部地址长度和超时。接下来还要读配置、读状态,而同一个器件的许多参数都相同。仓库的 MPU6050.c 将这部分重复内容放进 reg_read、reg_write,调用处就能集中看“操作哪个寄存器、读出或写入什么值”。

以下直接阅读 仓库的 MPU6050.c;它是被 #if 0 停用的历史代码,当前手表使用 LSM6DSOX。摘录用于理解,不要求将旧驱动重新启用。

文件先用 MPU6050_Addr 保存 HAL 所需的地址 D0,用指针 hi2c 保存选中的 IIC 管理对象。static 表示这些名字只供本 C 文件使用。注意这里的 hi2c 是指向已有对象的指针,不是另建一份 IIC 外设。

原文件中的地址、超时与总线指针 · 只阅读,不在已有文件重复定义

#define MPU6050_Addr 0xd0
#define SENSOR_I2C_TIMEOUT 10
static I2C_HandleTypeDef *hi2c = NULL;

reg_read(reg) 只接收内部寄存器地址,返回读取的一个字节。先给 result 留出存储,再把它的地址交给 HAL;成功后将字节返回。原函数最多尝试三次,每次失败后等待 1 ms。里面的 HAL 参数都对应第 4 节已经使用过的接口。

原 MPU6050.c 节选 · 单字节读取

static uint8_t reg_read(uint8_t reg)
{
	uint8_t result = 0;
	for (int retry = 0; retry < 3; retry++) {
		if (HAL_I2C_Mem_Read(hi2c, MPU6050_Addr, reg, I2C_MEMADD_SIZE_8BIT, &result, 1, SENSOR_I2C_TIMEOUT) == HAL_OK)
			return result;
		HAL_Delay(1);
	}
	return result;
}

reg_write(reg,value) 接收内部地址与要写的字节,没有返回值。下面只要顺着参数看:reg 交给 MemAddress,value 的地址交给发送缓冲,长度为 1。

原 MPU6050.c 节选 · 单字节写入

static void reg_write(uint8_t reg, uint8_t value)
{
	if(hi2c == NULL) return;
	for (int retry = 0; retry < 3; retry++) {
		if (HAL_I2C_Mem_Write(hi2c, MPU6050_Addr, reg, I2C_MEMADD_SIZE_8BIT, &value, 1, SENSOR_I2C_TIMEOUT) == HAL_OK)
			return;
		HAL_Delay(1);
	}
}

到这里,reg_read(0x75) 的意思就是“读内部 75 位置的一个字节”;reg_write(0x6B,0x80) 的意思就是“往内部 6B 位置写入 80”。先读懂这两个动作,再看初始化,就不会被反复出现的 HAL 固定参数打断。

把初始化从头走一遍:认身份、复位、唤醒、选量程

MPU6050_Init(handle) 接收已经初始化好的 IIC 管理对象地址,先保存到 hi2c;原函数用 0/1 表示身份检查失败或完成本段初始化。第一次看下面这段时,先按执行顺序读,再回到上方的寄存器表核对每个常量。

原 MPU6050.c 初始化节选 · 原注释保留,中文说明标注为“教学补加”

uint8_t MPU6050_Init(I2C_HandleTypeDef* handle)
{
	uint8_t chip_dev = 0;
	hi2c = handle; //indicates that the initialization function has been called
	chip_dev = reg_read(0x75);
	if(chip_dev != 0x68){
		return 0;
	}
	reg_write(0x6b,0x80);
	HAL_Delay(100);
	reg_write(0x6b,0x00);
	/* 教学补加:原注释的角速度单位应读作 degrees/s,不是直接输出的角度。 */
	reg_write(0x1b,0x18);//range:-2000 degrees ~ 2000 degrees
	reg_write(0x1c,0x10);//range:-8g ~ 8g
	return 1;
}
  1. 保存 handle 后,reg_read 就知道应该通过哪一个 IIC 外设访问器件。
  2. 读 75 得到身份值,检查是否为 68;这里先确认访问的是预期器件。
  3. 向 6B 写 80,触发复位,等待 100 ms 后再向 6B 写 00,清除睡眠并采用该值对应的内部时钟选择。
  4. 向 1B 写 18:FS_SEL 为 3,角速度量程为 ±2000°/s。单位是每秒角度,不是直接输出的角度。
  5. 向 1C 写 10:AFS_SEL 为 2,加速度量程为 ±8g。

这段代码没有主动配置 19、1A 的分频和滤波,因此不能认定旧驱动已经设置为某个指定的采样率。需要改变时,先查对应字段,再增加明确的寄存器写入。

调试时还要知道:这份旧代码怎样处理读写失败?

原 reg_read 在三次失败后仍返回 result,reg_write 失败后没有把状态传出;所以读到 0 不能独立证明通信成功,Init 返回 1 也不能证明每次配置都已写入。实验先在 HAL 调用处观察返回值,并读回允许读取的配置。需要完善错误报告时,直接传回 HAL 状态即可,无需先设计设备框架与新的状态枚举。

第 6 节 · 读出字节,再得到加速度与角速度

先只读一个轴:两个字节分别是什么?

初始化后,先用前面已经学会的 HAL_I2C_Mem_Read,从加速度 X 的高字节地址 3B 连续读两个字节。它们依次是高八位、低八位。下面是在独立实验 main 中增加的观察代码,不是另一套驱动;地址使用同一个 68 左移结果。

基础初始化和 MPU6050_Init 已完成 · 单独观察一个轴,不同时启动旧驱动的 DMA

uint8_t x_bytes[2];
HAL_StatusTypeDef status = HAL_I2C_Mem_Read(
    &hi2c1, (uint16_t)(0x68U << 1), 0x3B,
    I2C_MEMADD_SIZE_8BIT, x_bytes, 2, 50);
if (status == HAL_OK) {
    /* 本次读取成功后,再按下面的方法拼接。 */
}

以 10 00 为例,高字节 10 放到整个数的高八位,再与低字节 00 合并,得到 0x1000=4096。若收到 F0 00,则位模式为 F000,按 16 位补码是 -4096。负号表示测量方向,不是通信错误。

先无符号拼接,再解释符号 · 当前原驱动设置 ±8g,对应 4096 LSB/g

/* 放在上面的 status == HAL_OK 分支内。 */
uint16_t bits = (uint16_t)(((uint16_t)x_bytes[0] << 8) | x_bytes[1]);
int32_t value = bits;
if (bits >= 0x8000U) {
    value -= 65536;  /* 将 16 位补码解释为负数。 */
}
int16_t ax = (int16_t)value;
float ax_g = ax / 4096.0f;

也可以像旧驱动中那样将两字节的拼接直接写在赋值语句里。上面多展开几行,只为先看清位模式怎样变成正负数。量程改变后,换算系数必须一起改;±2000°/s 对应的角速度系数为 16.4 LSB/(°/s)。静止时,加速度某个轴出现约 ±1g 是正常现象;陀螺仪输出的是角速度,不是直接输出角度。

拼一次字节,再按量程换算

按原驱动加速度 ±8g、4096 LSB/g 换算。试试 10 00、F0 00、7F FF 与 80 00。

要读齐所有轴,就沿着手册连续读十四个字节

从 3B 起依次是加速度 X/Y/Z、温度、角速度 X/Y/Z,每项两个字节,共十四个字节。中间的温度不能跳过去,否则后面三个轴的位置就错了。

十四字节依次为加速度XYZ温度角速度XYZ,每项高字节在前
先将数组下标与手册输出位置对应,再写数据处理。

再读项目原有的 DMA 路径:开始、完成、取走

原驱动用 DMA 一次读取十四字节。DmaBuffer 是长期存在的接收数组;raw_data[2][7] 保存两组七项结果;active_buf_idx 表示完成回调当前写哪一组,isready 表示有一组数据待应用读取。先沿一次正常流程理解这些变量,不把它们当成写所有驱动都必须具备的结构。

原文件的接收与结果存储 · aligned(4) 为工程 GCC 的四字节对齐写法

static uint8_t DmaBuffer[14] __attribute__((aligned(4)));
static int16_t raw_data[2][7];
static volatile uint8_t isready = 0;
static volatile uint8_t active_buf_idx = 0;

MPU6050_readbegin() 没有参数和返回值,向 HAL 提交一次从 3B 开始的十四字节读取。HAL_I2C_Mem_Read_DMA 返回表示启动是否成功,不表示数据已到达。

原 MPU6050.c 节选 · 认识启动动作;旧代码未报告启动失败

void MPU6050_readbegin(void){
	if(hi2c == NULL || hi2c->State == HAL_I2C_STATE_BUSY) return;//if not initialized or it is busy, do not execute
	HAL_I2C_Mem_Read_DMA(hi2c, MPU6050_Addr, 0x3b, I2C_MEMADD_SIZE_8BIT, DmaBuffer, 14);
}

数据全部收到后再拼接。整理后的文件直接展示了回调,与启动、取数据函数连在一起阅读;整个驱动仍被外层 #if 0 排除编译。Mem_Read_DMA 使用 HAL_I2C_MemRxCpltCallback,源码注释也说明了这个对应关系。下面保留逐项展开的处理方式;实际移植时需接入应用已有回调,不重复定义。

MPU6050.c 回调节选 · 随整个旧驱动被 #if 0 排除;拼接的补码含义见前文

void HAL_I2C_MemRxCpltCallback(I2C_HandleTypeDef* hi2cPar){
	if(hi2cPar == hi2c) {
		raw_data[active_buf_idx][0] = ((int16_t)DmaBuffer[0] << 8) | DmaBuffer[1];
		raw_data[active_buf_idx][1] = ((int16_t)DmaBuffer[2] << 8) | DmaBuffer[3];
		raw_data[active_buf_idx][2] = ((int16_t)DmaBuffer[4] << 8) | DmaBuffer[5];
		raw_data[active_buf_idx][3] = ((int16_t)DmaBuffer[6] << 8) | DmaBuffer[7];
		raw_data[active_buf_idx][4] = ((int16_t)DmaBuffer[8] << 8) | DmaBuffer[9];
		raw_data[active_buf_idx][5] = ((int16_t)DmaBuffer[10] << 8) | DmaBuffer[11];
		raw_data[active_buf_idx][6] = ((int16_t)DmaBuffer[12] << 8) | DmaBuffer[13];
		active_buf_idx = !active_buf_idx;
		isready = 1;
	}
}

先写完七项,再切换 active_buf_idx、置 isready。应用通过 MPU6050_isReady 判断,再由 MPU6050_getData 把另一组结果写入传入的七个变量地址,并清掉 isready。取走的是原始 int16_t;需要显示 g 或 °/s 时再按配置换算。

原 MPU6050.c 节选 · 参数依次对应加速度、温度、角速度

void MPU6050_getData(int16_t* ax, int16_t* ay, int16_t* az, int16_t* temp, int16_t* gx, int16_t* gy, int16_t* gz)
{
	if(hi2c == NULL) return;
	uint8_t read_idx = 0;
	if(active_buf_idx == 0) read_idx =1;//read inactive cache area only
	*ax = raw_data[read_idx][0];
	*ay = raw_data[read_idx][1];
	*az = raw_data[read_idx][2];
	*temp = raw_data[read_idx][3];
	*gx = raw_data[read_idx][4];
	*gy = raw_data[read_idx][5];
	*gz = raw_data[read_idx][6];
	isready = 0;
}

这不是无限缓冲:如果采集不断继续而应用一直不取,两组结果仍可能被后续覆盖。初次实验先一次启动、等完成、取走,再启动下一次;更高采样率下的数据一致性、丢样策略和 DMA 配置留到相应章节展开。不要仅靠 volatile 或“两组数组”就认定任何并发访问都安全。

没有硬件时,先对照手册手算字节位置与换算;有设备后,从身份、配置读回、一个轴,再到全部数据逐步检查。读取失败时,不将数组原来留着的内容当成本次新数据。

第 7 节 · 回到仓库,把方法迁移到其他器件

现在回看 旧 MPU6050.c:文件被 #if 0 排除,旧地址 D0 正是 68 左移一次;初始化中的 1B/18、1C/10 就是本篇逐项读过的量程设置。但旧 reg_write 没有把失败传回上层,旧代码的 DMA 与回调片段也不是本章可直接启用的完整驱动。不要为了练习删除 #if 0、替换当前 IMU。

手表当前 IMU 是 LSM6DSOX,光学传感器是 MAX30102。它们使用同样的 IIC 事务思路,但身份、位定义、字节顺序及 FIFO 行为需要重新查各自手册,不能只把 MPU6050 的设备地址改掉。

器件与资料沿仓库找什么迁移时重新核对
LSM6DSOX:ST DS12814 Rev.4(2024-06)LSM6DSOX.c 的初始化、WHO_AM_I、FIFO 读取器件地址、输出高低字节顺序、连续读控制、FIFO 标签与水位
MAX30102:ADI/MAXIM Rev.1(2018-10)max30102.c 的身份检查、模式与 FIFO 读取每个通道的有效位、每样本字节数、FIFO 指针及溢出处理

查新芯片时先找 Interface、Register Map、Reset/Power、Data Output 等章节,再沿驱动调用对应起来。上表给出的是控制器/传感器原文,不替代具体成品模块的稳压、上拉或排针说明。

到这里,“能写驱动”应具体意味着什么?

已有 GPIO、SPI、IIC 后,应能为项目中这些接口的普通器件搭起基础驱动:确认接线与协议,建立初始化,读写配置,解释数据,返回错误,再让应用调用。这里也有一条结构上的共同点:底层搬字节,器件层解释命令和寄存器,应用层决定数据显示和业务行为。

GPIO SPI IIC收发到器件语义再到应用的数据路径
屏幕驱动把像素变成命令,传感器驱动把寄存器字节变成测量值;应用都不必重复实现底层事务。

麦克风的数据采集、持续缓冲留到采样与 DMA 等知识后再讲。FIFO、中断与 DMA 是提升采集效率的下一步,本章先完成基础轮询路径,不把“会读寄存器”扩大成已经实现所有器件功能。

独立任务:不用照抄,自己补一个小功能

  1. 把 AD0 置为模块支持的另一个状态,重新确定设备地址;解释为什么 WHO_AM_I 不跟着变。
  2. 把加速度范围改为 ±4g,根据手册修改字段和换算系数,读回确认。不要只改打印单位。
  3. 从手册找到温度输出的转换关系,增加一个接口,说明它测的不是哪一种温度。
  4. 迁移到一颗当前项目传感器:先完成身份检查与一个配置字段读回,再继续数据输出。列出与 MPU6050 不同的三处协议或数据细节。

排错与验收 · 从线上应答,检查到应用数据

现象先看什么不要直接下结论
地址后 NACK七位地址和左移次数、AD0、供电、SDA/SCL 与上拉不是扫描出任意地址就能确定型号
HAL_BUSY上次操作是否结束、总线是否卡低、是否多个路径同时访问不是立刻再启动十次传输
一直等不到数据初始化返回值、SLEEP、配置读回、数据就绪不是把未更新缓冲区重复画出来
数值极大或变号异常高低字节、补码、14字节中的温度位置不是一看到负数就认为测量错误
换量程后结果翻倍配置字段与换算常数是否同时改变不是固定除以一个数适用于所有配置
100kHz可用、400kHz失败上拉、电容、上升时间、Timing所用外设时钟不是只把频率框改成400就完成了提速
IT/DMA没有回调事件/错误IRQ、DMAIRQ、HAL处理入口、正确Mem回调不是启动返回HAL_OK就已经拿到数据

最终验收:能在纸上写出 WHO_AM_I 的完整事务和各拍 SDA 的控制方;能解释每个 HAL 参数;能从手册推导量程配置与换算;断开器件后能报告失败,恢复连接后按应用策略重新初始化;旧数据不会继续被当作新样本。没有硬件时先完成网页推导、代码编译与逻辑测试,真机读数、时序和电气性能仍需后续测量。

可选扩展 · 用已经学过的 GPIO,手动发送一个字节

主线用硬件 IIC 完成器件访问,到这里可以直接继续后面的课程。本扩展只给希望亲自控制电平的读者:把第 2 节的动作逐句写成 HAL_GPIO_WritePin 和 HAL_GPIO_ReadPin,不使用隐藏实现的时序辅助函数。

使用独立实验板的 PB8 作 SCL、PB9 作 SDA,在 CubeMX 将它们设为普通开漏输出,初始高,按实验硬件配置外部上拉与共地;不配置成 IIC 复用功能,也不接管手表正在使用的 IIC1。开漏写 SET 是释放,写 RESET 是拉低,ReadPin 读实际线上电平。

先明确时间:这个例子刻意很慢

为直接使用已经学过的 HAL_Delay,每阶段等待 1 ms,因此只是低速观察实验,不是 100/400 kHz 的软件 IIC;也不适用于带特殊事务超时限制的任意器件。函数在普通 main 路径调用,HAL 毫秒时基必须工作,不能放中断里。精确的微秒时序到时钟、定时器章节再处理。

一位怎样发出去?看见每一次引脚操作

下面的 SoftI2C_SendByte(value) 接收一个字节,返回 HAL_OK 表示收到 ACK,HAL_ERROR 表示 NACK,HAL_TIMEOUT 表示释放 SCL 后等不到高。调用前已开始事务、SCL 为低;它负责八位和第九拍应答,不负责选设备、START 或 STOP。

低速独立 GPIO 实验 · 每步操作明确展开;不是完整多主机驱动

HAL_StatusTypeDef SoftI2C_SendByte(uint8_t value)
{
    for (int bit = 7; bit >= 0; bit--) {
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET);
        if ((value & (1U << bit)) != 0) {
            HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET);
        } else {
            HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET);
        }
        HAL_Delay(1);  /* SCL 低时,先准备好 SDA。 */
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET);
        uint32_t started = HAL_GetTick();
        while (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_8) == GPIO_PIN_RESET) {
            if ((uint32_t)(HAL_GetTick() - started) >= 10U) {
                HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET);
                return HAL_TIMEOUT;
            }
        }
        HAL_Delay(1);  /* SCL 已高:保持 SDA,让接收方读。 */
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET);
        HAL_Delay(1);
    }

    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET); /* 松手等应答。 */
    HAL_Delay(1);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET);
    uint32_t started = HAL_GetTick();
    while (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_8) == GPIO_PIN_RESET) {
        if ((uint32_t)(HAL_GetTick() - started) >= 10U) {
            return HAL_TIMEOUT;
        }
    }
    HAL_Delay(1);
    GPIO_PinState ack_level = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET);
    HAL_Delay(1);
    return ack_level == GPIO_PIN_RESET ? HAL_OK : HAL_ERROR;
}

先只看循环第一轮:bit 为 7,取出最高位,决定 PB9 拉低还是释放;等待后释放 PB8,读回时钟确实为高,再保持数据。八轮结束后 PB9 释放,把第九拍交给接收方。这里保留了重复的等待代码,便于将每一行与电平动作对应;不先提取新函数。

START 与 STOP 怎样接上去?

START 前,先释放 SDA/SCL 并确认两者都为高;若读到低,本次不要开始,先排查总线占用或卡低。确认空闲后,SCL 仍高时拉低 SDA,再拉低 SCL。下面是这一段的实际操作:

独立 main 中观察:开始后仅发送 MPU6050 写地址字节;不是完整寄存器访问

HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8 | GPIO_PIN_9, GPIO_PIN_SET);
HAL_Delay(1);
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_8) == GPIO_PIN_SET &&
    HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9) == GPIO_PIN_SET) {
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET); /* START */
    HAL_Delay(1);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET);
    HAL_Delay(1);
    HAL_StatusTypeDef status = SoftI2C_SendByte(0xD0);
    /* 检查 status;随后完成 STOP 或进入下一字节。 */
} else {
    /* 本次没有开始事务,不继续发送。 */
}

STOP 的顺序相反:SCL 低时先将 SDA 拉低,释放 SCL 并确认实际变高,然后释放 SDA,使它在 SCL 高时上升。若 SCL 等不到高,STOP 就没有真正完成,需按失败处理,不能继续说总线已经空闲。

低速 STOP 操作片段 · 超时不是通过输出一个高电平就能消除的

/* 接在 SCL 当前为低、单字节正常结束的路径。 */
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET);
HAL_Delay(1);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET);
uint32_t stop_started = HAL_GetTick();
while (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_8) == GPIO_PIN_RESET &&
       (uint32_t)(HAL_GetTick() - stop_started) < 10U) {
    /* 有限等待实际时钟变高。 */
}
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_8) == GPIO_PIN_SET) {
    HAL_Delay(1);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET); /* STOP */
    HAL_Delay(1);
} else {
    /* 总线仍有故障,不当作 STOP 成功。 */
}

读者可再按相同电平规则实现读取八位及主机 ACK/NACK;先画清每拍由谁控制 SDA,再写 GPIO 语句。完整重复开始、故障清理、仲裁和精确时序另行实现,本扩展不把一个 SendByte 当成完整驱动。

检查理解

七位地址 68 的 MPU6050 读取 75 身份寄存器,哪条顺序正确?

接下来:四篇项目传感器实战

目录中点击“I²C / 总线与传感器驱动”即可展开基础篇和四篇实战。先用刚学会的读写方法驱动两颗器件,再组织联合采集与重采样,最后按需部署片内决策树。

参考与源码入口

原创图解和交互模型用于理解流程,不是实测波形。配置截图按所示 I2C1 页面解释。相关资料亦见 资料索引。