I2C接口及其应用

总线(Bus)是计算机世界的关键概念,数据总线、地址总线、控制总线(统称三总线)等用于MCU片内的CPU、存储器和I/O功能单元之间互联,我们熟悉的USB、Ethernet等通讯总线常用于系统间通讯(将在第9章了解这些总线),

还有一些我们不熟悉但用来连接嵌入式系统内部组件的重要总线,过去我们通常把MCU片上的三总线延伸到片外用于连接系统内组件,这种伪共享型并行总线不仅占用很多I/O引脚,

还使得MCU的片外功能组件占用很大的PCB面积,除了高带宽和大数据量的视觉传感器、大屏幕高分辨率的点阵显示器等组件仍在使用此类总线外,
现今的大多数嵌入式系统内组件都使用少信号线的通讯总线。

嵌入式系统内部(组件之间)的数字通讯总线接口主要包括1-Wire(单总线)、2-Wire(即I2C)、SPI(3-/4-Wire)、I2S(IC之间音频总线)和TTL异步串口等。除了I2S和异步串口之外,其他通讯接口所使用的信号线个数如其名称。I2S与I2C一样都由Philips电子部门提出,I2S专门用于PCM(脉冲编码调制)等音频数据传输,一般使用3或4根信号线即可传输单声道或立体声音频数据。

异步串口不仅用于嵌入式系统内部的组件之间也用于嵌入式系统之间通讯,譬如我们使用该接口实现BlueFi与电脑之间传输数据,双工的异步串口使用独立的数据发送和接收信号线,单工通讯则只需要其中一根信号线即可,我们将在第7章详细了解该接口。SPI接口是一种多组件伪共享的总线,但每个从组件必须有额外独立的片选(Chip Select),第6章将会详细探讨该接口。I2C(集成电路互联接口)和单总线都是真正的多组件共享总线,分别使用2根和1根信号线即可连接上百个嵌入式系统组件。

本章将详细了解I2C主从设备的接口单元结构组成和工作原理、通讯时序和协议、主/从模式的工作流程和编程控制,并以BlueFi板载的温湿度、加速度和陀螺仪等传感器为例掌握I2C类组件的接口应用和编程。

通讯接口相关的基础概念:

  1. 双工,允许通讯双方之间互相传输数据。按通讯收发机制又分为全双工和半双工
  2. 全双工,允许通讯双方同时互相发送和接收数据,这意味着通讯接口拥有2各独立的信息收发通道
  3. 半双工,允许通讯双方互相传输数据,但任意时刻仅允许一个发送者(另一个则为接收者)
  4. 单工,只允许单方向传输数据,通讯双发的角色是固定的:一个发送者,一个接收者
  5. 并行通讯,传统的三总线是典型的并行通讯,每一个时钟周期能够传输半字节/整字节/多字节(由数据总线宽度决定)信息
  6. 串行通讯,每一个时钟周期只能传输单个二进制位,将待传输的数据按MSB(最高位)到LSB(最低位)或反之的顺序逐位传输
  7. 同步串行通讯,使用独立的数据线和同步时钟线的串行通讯接口,每一个数据位时钟与一个时钟对齐
  8. 异步串行通讯,无同步时钟线,仅用一根数据线的串行通讯接口,仅使用一个或若干个特殊同步位来对齐字节数据

5.1 I2C通讯接口

I2C是一种典型的同步串行通讯接口,单个接口支持单主多从、多主多从(但任何时刻仅有一个主机)等模态的多组件间半双工通讯。虽然I2C协议支持多主多从的模态,
但实际应用中绝大多数几乎都是单主多从模态,本章仅限这种常见的I2C模态。

上世纪80年代Philips电子部门定义I2C(Inter-Integrated Circuit的缩写)通讯总线的主要目的是用于连接计算机周边的音频和视频等低速设备,
最初定义的通讯时钟速度是100KHz(那个时候的音视频数据流极地),I2C发展到今天已经支持100KHz、400KHz、1MHz、3.4MHz和5MHz等多种时钟速度。
现存很多种派生型I2C接口,最著名的是Intel提出的SMBus(系统管理总线),目前仍用于计算机周边设备接口的配置等领域,
譬如现在所开发的音视频设备、视觉传感器和点阵图形显示器接口中常用I2C或SMBus作为这些设备的参数配置通讯接口。

随着嵌入式计算机系统和物联网的飞速发展,I2C逐步成为系统内各功能组件之间最常见的互联总线之一,仅占用MCU的2个I/O引脚就可以将系统内的最高达128个组件连接起来。
I2C是真正的多组件共享总线,不仅占用极少的MCU资源,嵌入式系统PCB板的布局和走线也非常简单。图5.1是BlueFi开源板上的4种传感器与主控制器之间的连接示意图。

I2C2021-08-04-19-55-44

图5.1 BlueFi开源板上4种传感器的接口电路

理论上,单个I2C接口能够连接高达128个组件。这需要每一个组件拥有一个惟一的7位地址码,称之为I2C从地址,图5.1中的每种I2C接口传感器拥有惟一的从地址。
这意味着,同一个型号的I2C接口组件不能同时连接到单个I2C接口上,除非他的I2C从地址是可配置的。譬如,NXP的16通道PWM控制器——PCA9685采用I2C接口且具有3个从地址配置输入引脚,
意味着他可以配置为8种不同从地址,单个I2C接口总线上允许最多连接8个PCA9685器件(其基地址为0x40,可配置的从地址为0x40~0x47)。也有很多I2C器件的从地址是不可配置的,
譬如抗疫期间常用的一种24x32阵列红外温度传感器(IR array thermal sensors)——Melexis的MLX90640,其惟一的从地址为0x33,单个I2C接口上只能连接一个这种传感器,
如果想要在一个热成像系统内同时使用多个这种阵列传感器以成倍地提升成像的像素数,如何设计传感器接口才能满足需求呢?本章的学习将会帮助我们实现这一目标。

I2C接口的两个信号分别称作SCL和SDA,SCL是主设备输出的同步时钟信号,SDA是双向的串行数据信号。虽然SCL是单方向的信号,只能从主设备输出,
但为支持多主多从模态,实际的I2C接口单元的SCL信号仍被定义成双向的。I2C能够实现真正的多组件共享总线应归功于独特的“线与(wire-AND)”接口设计,
如图5.1所示。

I2C2021-08-04-19-56-34

图5.2 共享总线的“线与”接口电路

上图中两个“线与”接口信号的外部上拉电阻是必须的,上拉电阻的阻值选择与该接口的互联设备数量、传输线长度、分布电容和通讯速度等有关,一般在2K~47K欧之间。图中使用MOS仅是原理性示意的目的,
实际I2C接口组件的硬件实现又多种选择,譬如使用三态门电路。当主机发送-从机接收数据位流时,数据位流的“1”/“0”被转换为“高”/“低”电平随着同步时钟信号SCL而顺序地出现在SDA上,
SCL和SDA两个信号都由I2C主机驱动,I2C从机根据SCL信号同步地逐位锁存数据位流信号并形成字节数据。当从机发送-主机接收数据位流时,
I2C主机输出同步时钟信号SCL给工作中的从机,I2C从机根据SCL信号同步地将待传输的数据位流逐位地发送到SDA上,同时I2C主机同步地接收数据位流。

虽然同步发送和接收数据位流的描述有点拗口,但具体的实现却非常简单,如果你能记起数字电路课程所掌握的“移位寄存器”的概念。I2C接口的移位寄存器仅有8位宽度,
这是因为I2C接口采用单字节的数据帧格式。I2C支持多字节连续读或写操作,但始终保持单字节帧,相邻的字节帧之间必须有一个接收者的应答位(ACK)。按通讯领域的规则,
这个接收者的ACK位是帧同步的目的。

为了更好地理解通讯协议中的“同步”,需要对I2C接口传输数据帧(字节)的时序稍作了解,如图5.3所示,(a)给出单帧/单字节的数据传输时序,(b)给出2(或更多)帧/字节的数据传输时序。

I2C2021-08-04-19-56-56

图5.3 I2C接口传输数据的时序(/协议)

对于I2C通讯接口的数据帧传输,不必刻意区分时序和通讯协议,虽然时序仅规定总线上信号之间时空关系,通讯协议却是更宽泛的概念。
I2C接口的每一次数据传输必须以“START”时序开始并以“STOP”时序终止,由于I2C接口仅支持单字节的数据帧,每帧/字节数据必须以数据接收者的“ACK”为结束。
“START”、“STOP”、“ACK”的作用都是为了“同步”目的,对比单字节和两字节传输时序时会发现“ACK”尤为重要,完全可以把“ACK”理解为字节同步位。
正是这些特殊的同步状态才让I2C通讯接口更加可靠、稳定。

值得注意的是,I2C通讯接口传输数据位的顺序按最高位(MSB)先发送、最低位(LSB)最后发送。这在上图中已明确标示。

I2C通讯接口的连续读/写操作是指,从I2C从机上读取某些连续地址的寄存器内容时,或者向I2C从机上某些连续地址的寄存器顺序地写入内容时,I2C主机首先传输给从机一个待读/待写的寄存器起始地址(仍可以是8/16/32位地址信息),
然后读取/写入第一个字节,接收者给出“ACK”,接着继续读取/写入下一个字节,接收者给出“ACK”,如此重复直到连续读/写操作完毕,期间不必再指定读取/写入的寄存器地址,
因为每读/写一个字节之后,下一个寄存器地址默认是前一个操作的地址自增1。

高效率的批量读/写操作的支持,源于I2C通讯接口组件的RAM型寄存器映射机制。从图5.3可以看出,单字节或连续多字节的数据传输期间,要求主机和从机都是“Ready”状态,
不允许任何“Wating”状态迫使暂停传输,这就要求主机读操作期间从机上的待读数据是全部“Ready”状态,主机写操作期间从机上的待写入寄存器也全部“Ready”状态。
显然,这就要求主机和从机上所有的I2C通讯接口的寄存器具有RAM的操作特性。现今的半导体技术,满足这一要求是非常容易的。对于I2C通讯接口单元的硬件实现,
目前普遍采用有限状态机(FSM)和RAM型寄存器的组合,这样设计不仅将传输控制和数据流分离(更容易实现),允许I2C接口的功能组件内部单元也采用存储器映射机制(在第2章已探讨过)。
譬如,一个I2C接口的数字湿度传感器,湿度信号转换(成电信号)、采集(ADC)和滤波等过程由湿度采样控制的状态机按照设定的采样周期自治地工作,并将每次采样结果自动保存在特定地址的寄存器内,
当I2C主机需要读取湿度信息时,湿度传感器直接输出最新更新的湿度值,I2C主机无需启动再等待数百毫秒后读取湿度结果。

如图5.4,I2C通讯接口的主机,通常可以理解为MCU的片上I2C接口功能单元;I2C通讯接口从机的片内功能单元的配置、数据/状态等都被映射到寄存器区;
主机通过读/写寄存器实现对从机的控制和数据/状态的获取。

I2C2021-08-04-19-57-19

图5.4 I2C通讯接口的主机和从机的结构组成

现在我们可以来回答“I2C从地址为什么是7位?” 当主机需要访问某个从机的某个/某些寄存器时,首先发出7位从地址和1位“R/W”组成的“读/写指定从地址”的指令帧,
当“R/W=1”时为读,反之为写。与从地址匹配的从机被选择,即被选中的从机的传输控制状态机被激活。

接着主机发出寄存器地址信息帧,根据从机上寄存器资源(和从机的功能)的多少,或许超过1个字节就需要使用批量传输模式,被选中的从机将会把接收到的地址信息传入地址译码器,
于是对应地址的寄存器被选择。现在我们的I2C接口主机已经选择指定的从机及其内部的寄存器。

最后,主机和从机的传输控制状态机将会根据第一帧的“R/W”位信息完成进一步操作。如果“R/W=1”,主机驱动SCL输出同步时钟信号,从机上被选择的寄存器内容自动填入输出移位寄存器,
并随着SCL同步时钟逐位顺序地输出到SDA线上,主机驱动SCL的同时会在SCL下降沿出采样SDA线并移入输出移位寄存器。如果“R/W=0”,主机驱动SCL输出同步时钟信号,
同时在SCL低电平期间将输出移位寄存器的内容逐位顺序地输出到SDA线上,同时从机随着SCL同步时钟信号采样SDA线并移入输入移位寄存器,一个字节传输完毕后,
将输入移位寄存器的字节内容保存到被选择寄存器中。

简而言之,一次I2C通讯接口操作包括三步,主机使用7位从机地址和读/写控制位选中I2C总线上的从机,然后指定从机的寄存器(起始)地址,最后读/写从机的寄存器。
使用从机惟一地址编码的寻址方法,与传统三总线接口、SPI接口等伪共享总线相比,I2C接口没有专用的从机选择信号线,既节约MCU的I/O引脚又能简化PCB布板。
当我们认识到真正的共享总线型I2C通讯接口带来的方便时,或许也会遇到另外一些困难(好坏总是相伴而来),譬如一个系统内I2C组件的电平电压、时钟速度等不一致。

遇到接口两端的电平电压不一致时,通常会想到使用电平转换逻辑门(Level shifter)来解决,但在I2C通讯接口的总线上使用的电平转换必须支持双向传输!
一种简易的支持双向传输的电平转换接口可用于I2C总线 [2]_ ,如图5.5所示。

I2C2021-08-04-19-57-32

图5.5 使用电平转换电路让I2C通讯接口支持不同电平电压

如果设计系统时遇到多个从机的时钟速度不一致的问题,留给你来解决。
前面我们已经初步了解I2C通讯接口的硬件和时序,包括总线架构、线与和移位寄存器结构、时序/协议、RAM型存储器映射及访问、电平匹配等。
I2C通讯接口软件如何实现呢?尤其面对一个系统或单个I2C接口上连接着很多个I2C接口的功能组件时,合理封装接口软件是非常重要的。
我们仍然使用分层抽象的思想来封装I2C接口软件,如图5.6所示。
I2C2021-08-04-19-57-41

图5.6 I2C通讯接口软件分层封装

I2C接口的硬件层,除了硬件电路设计前需查阅具体的MCU那些I/O引脚可用于I2C接口,以及系统所用的I2C组件的电平电压是否一致外,其他工作几乎都是软件接口设计,
根据MCU片上功能单元的存储器映射机制,可以想象这些软件的工作就是访问存储器单元配置I2C接口(包括时钟速度、引脚、数据发送和接收中断等)、
使能和禁止I2C接口,以及中断服务程序等底层操作。凡涉及存储器访问的操作都是很繁琐的,而且几乎都是没有可移植性代码。幸运的是,
我们无须编写这些代码,源文件都由半导体厂商提供。

I2C接口的硬件抽象层具有承上启下的作用,封装合理的I2C接口硬件抽象层是系统内所有I2C功能组件的共享代码。向下访问MCU硬件层接口(那些具体的MCU的存储器资源访问)实现I2C接口的基本协议,
包括启动时序“beginTransmission”、停止时序“endTransmission”、字节帧批量输出“write()”、输入“requestFrom()”和“read()”等,以及数据接收中断“onReceive()”(仅从机模式)、
主机请求中断“onRequest()”(仅从机模式)等中断服务程序。向上提供I2C通讯协议的实现接口。

对于任意的I2C组件的操作,我们只需要访问其寄存器即可实现目标功能,譬如读数字湿度传感器的湿度寄存器到变量(根据湿度的分辨率或许需要连续地读多个寄存器)。
一个系统内使用的每一种I2C组件的从地址、寄存器列表等都是固定的(常量),调用硬件抽象层的接口访问寄存器实现I2C组件的功能封装,这部分工作属于BSP的一部分。
我们已经在前一章中多次实施BSP代码,本章后续内容将会实施I2C组件的BSP。I2C接口软件的BSP部分的基本实施规则就是,隐藏寄存器及其访问操作,
按照I2C组件的功能封装参数配置和功能操作接口,譬如设置温湿度传感器分辨率、获取当前的环境湿度或当前温度、配置加速度传感器的量程、读取当加速度的3分量等。

用户层调用特定开源板的BSP接口实现传感器应用,如环境温度或湿度测量及处理(滤波、显示、存储到本地或云端)、根据加速度和陀螺仪的分量值估算姿态、
根据当前姿态角调整飞控系统驱动马达转速等。

以BlueFi开源板和兼容Arduino的nrf52开源软件包为例,硬件层源码位于“…/Hardware/nrf52/版本号/cores/nordic/hal/”文件夹,
硬件抽象层源码位于“…/Hardware/nrf52/版本号/libraries/Wire/”,I2C通讯接口的BSP与其他接口的BSP都在一个文件夹中,
下一节开始实施I2C接口部分的BSP编码。


I2C接口协议的规范和实现方法 [3]_ 并不复杂,接口硬件方面仅仅是数字电路领域的基础知识(线与、同步时钟和锁存、移位寄存器等),
接口协议方面只涉及通讯领域的字节同步基本概念,接口软件方面我们仍采用分层抽象的思想来封装。

下一节将以主机的角色深入了解MCU片上的I2C功能单元的结构和数据传输操作流程,硬件层和硬件抽象层的接口,以及BSP层软件封装。
如何使用I2C通讯接口连接两个MCU实现双向通讯,这是再下一节的核心内容。


参考文献:
::

[1] https://learn.adafruit.com/i2c-addresses/the-list
[2] https://www.nxp.com/docs/en/application-note/AN10441.pdf
[3] http://www.i2c-bus.org/

5.2 I2C主机模式

主机(Master)模式是MCU片上I2C功能单元的缺省工作模式,MCU仅使用2个I/O引脚就可以通过从机(Slave)寻址方式与上百个I2C从机通讯(或称作会话)。
按照I2C协议规范,SCL信号由主机驱动(主机输出的同步时钟信号),SDA信号是双向驱动的。主机与任一从机之间的通讯都必须以“Start时序”作为开始,
然后主机发送的第一帧数据必须是由“(7位从地址<<1) | R/W位”组成的寻址帧,被寻址的从机被选中并给向主机发送“ACK时序”确认,
后续两者之间的通讯始终以主机发出的同步时钟信号为节拍,并以8位数据和1位接收者“ACK时序”为一个数据帧,当主机发出“Stop时序”后结束本次通讯,
主机和从机双方都暂时释放I2C总线。很显然,I2C总线的主从机之间的每次会话都以“Start时序”和“Stop时序”为界定,即使与同一个从机之间的多次会话也都遵循这一原则。

当MCU片上I2C功能单元工作在主机模式时,I2C接口的存储器(譬如小容量EEPROM非易失性数据存储器)、传感器、执行器和显示器等功能组件为从机,
我们编程控制MCU片上I2C功能单元访问这些片外I2C组件上的寄存器以实现他们的功能,前一节我们已经给出分层的I2C通讯接口软件的框架,参见图5.6。
绝大多数嵌入式系统软件开发平台都包含有硬件层和硬件抽象层的接口库,硬件层是通过访问I2C功能单元映射的存储器实现I2C通讯接口的硬件控制,
硬件抽象层是I2C协议的实现。在Arduino开源平台上,这两个层次的接口库都是以源码形式提供给系统开发者,其中硬件层由半导体厂商提供,
软件抽象层则是由开源社区的贡献者按照Arduino开源平台的“Wire”库的接口规范所编写的特定系列MCU的I2C接口的兼容库,Arduino标准的“Wire”库共有10种接口(包含主机模式的和从机模式的接口),
详见页面 [1]_ ,对应的源代码见“…/Hardware/nrf52/版本号/libraries/Wire/Wire.h”文件,使用该I2C通讯接口前必须用“#include <Wire.h>”语句来引用这些接口。

值得注意的是,Arduino的I2C通讯接口的硬件抽象层不仅支持主机模式,同时还支持从机模式。关于MCU片上I2C工作在从机模式的情形,将在下一节探讨。

… Note:: I2C硬件抽象层接口(仅主机模式的接口)

  1. begin(),将I2C通讯接口配置为主机模式,并配置SCL和SDA的I/O引脚、SCL时钟速度(使用默认的设置)、中断等。注意,只能在初始化时调用一次
  2. setClock(clockFrequency),重置I2C通讯接口的SCL时钟速度,参数clockFrequency以Hz为单位,譬如400,000
  3. beginTransmission(slave_addr),产生“Start时序”,并将后续会话的从地址参数配置为slave_addr(7位地址!!),直到“endTransmission()”执行后
  4. endTransmission(stop),(如果发送缓冲区不为空)将发送缓冲区中的数据传送给指定的从机;参数“stop”的有效值是“true”或“false”,该参数指定本次传输结束时是否产生“Stop时序”释放I2C总线
  5. write(val)/,向从机写数据,必须在“beginTransmission(slave_addr)”和“endTransmission()”之间调用该接口。这个接口还有另外两种形式:write(val[], len)和write(string)
  6. requestFrom(slave_addr, quantity, stop),向指定地址(slave_addr)的从机请求(读取)指定个数(quantity)的数据,然后使用“available()”和“read()”检查并读取数据;“stop"参数的有效值是"true"或"false”,用于指定本次请求操作结束时是否发送STOP时序
  7. available(),返回接收缓冲区中有效的/可读取的字节数据个数,在调用“requestFrom(slave_addr, quantity)”后使用该接口检查请求回来的有效数据
  8. read(),从接收缓冲区读取请求到的有效数据

基于这些I2C通讯协议的实现(即I2C硬件抽象层)接口,对于给定的嵌入式系统的I2C硬件层,我们可以就可以定义系统内I2C接口的功能组件的BSP接口。
按照图5.6所示的软件架构,每一个I2C功能组件的BSP层有4个基本接口:begin(i2cBus)、readRegister(regAddr)、readRegisters(regAddr, rBuf[], num)、
writeRegisters(regAddr, wBuf[], num)。其中“begin(i2cBus)”是IC通讯接口初始化,另外3个接口的功能与名称一致。
使用这些基本接口,我们就可以直接访问I2C功能组件上的寄存器实现其特设的功能,譬如获取温湿度或加速度值、配置采样率等。

此外,每一个I2C功能组件的BSP层接口最好的封装形式是类(class)的形式,这样就可以把该组件的从地址、寄存器列表及其4个基本接口等定义为私有的变量和(内部)接口以避免与其他I2C功能组件的接口混淆。


现在我们以BlueFi开源板上的6DoF惯性测量单元(IMU)——LSM6DS33为例,使用Arduino开源平台的(nRF52)I2C硬件层和硬件抽象层接口实现加速度传感器的用户接口,
即BlueFi开源板的BSP层的加速度传感器的代码实现。具体的实现代码由以下两个文件组成:

(BlueFi_LSM6DS3.h)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

#ifndef __BLUEFI_LSM6DS3_H_
#define __BLUEFI_LSM6DS3_H_

#include <Arduino.h>
#include <Wire.h>

#define DefaultSlaveAddress_LSM6DS3 0x6A
//#define DefaultSlaveAddress_LSM6DS3 0x6B
#define LSM6DS3_WHO_AM_I_REG 0X0F
#define LSM6DS3_CTRL1_XL 0X10
#define LSM6DS3_CTRL2_G 0X11
#define LSM6DS3_CTRL6_C 0X15
#define LSM6DS3_CTRL7_G 0X16
#define LSM6DS3_CTRL8_XL 0X17
#define LSM6DS3_STATUS_REG 0X1E
#define LSM6DS3_OUTX_L_G 0X22
#define LSM6DS3_OUTX_H_G 0X23
#define LSM6DS3_OUTY_L_G 0X24
#define LSM6DS3_OUTY_H_G 0X25
#define LSM6DS3_OUTZ_L_G 0X26
#define LSM6DS3_OUTZ_H_G 0X27
#define LSM6DS3_OUTX_L_XL 0X28
#define LSM6DS3_OUTX_H_XL 0X29
#define LSM6DS3_OUTY_L_XL 0X2A
#define LSM6DS3_OUTY_H_XL 0X2B
#define LSM6DS3_OUTZ_L_XL 0X2C
#define LSM6DS3_OUTZ_H_XL 0X2D

class LSM6DS3 {

public:
LSM6DS3(TwoWire& wire, uint8_t slaveAddress=DefaultSlaveAddress_LSM6DS3);
virtual ~LSM6DS3(){ };
bool begin(void);
void end(void);
// Accelerometer
virtual bool readAcceleration(float& x, float& y, float& z); // Results are in G (earth gravity).
virtual float accelerationSampleRate(); // Sampling rate of the sensor.
virtual bool accelerationAvailable(); // Check for available data from accerometer
// Gyroscope
virtual bool readGyroscope(float& x, float& y, float& z); // Results are in degrees/second.
virtual float gyroscopeSampleRate(); // Sampling rate of the sensor.
virtual bool gyroscopeAvailable(); // Check for available data from gyroscopeAvailable

private:
int readRegister(uint8_t address);
int readRegisters(uint8_t address, uint8_t* data, size_t length);
int writeRegister(uint8_t address, uint8_t value);
int writeRegisters(uint8_t regAddr, uint8_t* data, size_t length);

TwoWire* __wire;
uint8_t __Address;
};

#endif // __BLUEFI_LSM6DS3_H_

注意,这个版本仅是I2C通讯接口的示例目的,并不是完整的IMU功能接口。所有外部接口都在LSM6DS3类的“public”域,私有的/内部的接口在“private”域。
读单个/多个寄存器、写单个/多个寄存器等操作是每一种I2C功能组件的最基本的4种内部接口实现。此外,连接该组件所用的硬件抽象层的I2C类接口,
使用指针型的内部私有变量“__wire”来保存。

(BlueFi_LSM6DS3.cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124

#include "BlueFi_LSM6DS3.h"

LSM6DS3::LSM6DS3(TwoWire& wire, uint8_t slaveAddress) :
__wire(&wire),
__Address(slaveAddress) {
}

bool LSM6DS3::begin(void) {
__wire->begin();
if (readRegister(LSM6DS3_WHO_AM_I_REG) != 0x69) {
end();
return false;
}
//set the gyroscope control register to work at 104 Hz, 2000 dps and in bypass mode
writeRegister(LSM6DS3_CTRL2_G, 0x4C);
// Set the Accelerometer control register to work at 104 Hz, 4G,and in bypass mode and enable ODR/4
// low pass filter(check figure9 of LSM6DS3's datasheet)
writeRegister(LSM6DS3_CTRL1_XL, 0x4A);
// set gyroscope power mode to high performance and bandwidth to 16 MHz
writeRegister(LSM6DS3_CTRL7_G, 0x00);
// Set the ODR config register to ODR/4
writeRegister(LSM6DS3_CTRL8_XL, 0x09);
return true;
}

void LSM6DS3::end() {
writeRegister(LSM6DS3_CTRL2_G, 0x00);
writeRegister(LSM6DS3_CTRL1_XL, 0x00);
__wire->end();
}

bool LSM6DS3::readAcceleration(float& x, float& y, float& z) {
int16_t data[3];
if (!readRegisters(LSM6DS3_OUTX_L_XL, (uint8_t*)data, sizeof(data))) {
x = NAN, y = NAN, z = NAN;
return false;
}
x = data[0] * 4.0 / 32768.0;
y = data[1] * 4.0 / 32768.0;
z = data[2] * 4.0 / 32768.0;
return true;
}

bool LSM6DS3::accelerationAvailable() {
if (readRegister(LSM6DS3_STATUS_REG) & 0x01) {
return true;
}
return false;
}

float LSM6DS3::accelerationSampleRate() {
return 104.0F; // 104Hz
}

bool LSM6DS3::readGyroscope(float& x, float& y, float& z) {
int16_t data[3];
if (!readRegisters(LSM6DS3_OUTX_L_G, (uint8_t*)data, sizeof(data))) {
x = NAN, y = NAN, z = NAN;
return false;
}
x = data[0] * 2000.0 / 32768.0;
y = data[1] * 2000.0 / 32768.0;
z = data[2] * 2000.0 / 32768.0;
return true;
}

bool LSM6DS3::gyroscopeAvailable() {
if (readRegister(LSM6DS3_STATUS_REG) & 0x02) {
return true;
}
return false;
}

float LSM6DS3::gyroscopeSampleRate() {
return 104.0F;
}

int LSM6DS3::readRegister(uint8_t regAddr) {
uint8_t value;
if (readRegisters(regAddr, &value, sizeof(value)) != 1) {
return -1;
}

return value;
}

int LSM6DS3::readRegisters(uint8_t regAddr, uint8_t* data, size_t length)
{
__wire->beginTransmission(__Address);
__wire->write(regAddr);
if (__wire->endTransmission(false) != 0) {
return -1;
}
if (__wire->requestFrom(__Address, length) != length) {
return 0;
}
for (size_t i=0; i<length; i++) {
*data++ = __wire->read();
}
return 1;
}

int LSM6DS3::writeRegister(uint8_t regAddr, uint8_t value) {
__wire->beginTransmission(__Address);
__wire->write(regAddr);
__wire->write(value);
if (__wire->endTransmission() != 0) {
return 0;
}
return 1;
}

int LSM6DS3::writeRegisters(uint8_t regAddr, uint8_t* data, size_t length) {
__wire->beginTransmission(__Address);
__wire->write(regAddr);
for (size_t i=0; i<length; i++) {
__wire->write(*data++);
}
if (__wire->endTransmission() != 0) {
return 0;
}
return 1;
}

上面的LSM6DS3类接口主要包括,初始化(begin)、读取3-DoF加速度(/陀螺仪)的三坐标分量值、检查LSM6DS3内部状态寄存器(LSM6DS3_STATUS_REG)确定是否有数据可读等。
完成这个LSM6DS3类接口的代码编写后,将两个源文件(BlueFi_LSM6DS3.h和BlueFi_LSM6DS3.cpp)保存到“…/Documents/Arduino/libraries/BlueFi/src/utility/”文件夹,
然后打开“…/Documents/Arduino/libraries/BlueFi/src/”文件夹中的BlueFi.h文件,并在BlueFi类的“public”域增加“LSM6DS3 imu = LSM6DS3(Wire1, 0x6A);”语句,
定义一个名叫“imu”的LSM6DS3类接口;打开该文件夹中的“BlueFi.cpp”文件,为begin()接口函数增加“imu.begin();”语句,当BlueFi开源板初始化时调用LSM6DS3类接口——begin()对“imu”对象初始化。
现在,我们的BlueFi开源板的BSP已具有读取加速度/陀螺仪原始数据的接口。注意,初始化LSM6DS3类对象“imu”时,将加速度/陀螺仪的采样率设置为104Hz。

为了更好地了解LSM6DS3的用法,详见 [2]_ 。

下面的简单示例代码可能演示LSM6DS3类接口的用法:

(LSM6DS3_accelerometer_simplest.ino)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

// show float data on the console, or draw ployline on the plotter (baudrate=115200)
#include <BlueFi.h>
void setup() {
bluefi.begin(); // 初始化BlueFi开源板(含imu初始化)
}

void loop() {
float x=0.0F, y=0.0F, z=0.0F;
if (bluefi.imu.accelerationAvailable()) { // 检查加速度原始数据的可读性
bluefi.imu.readAcceleration(x, y, z); // 读取加速度传感器的三分量
Serial.print(x); Serial.print(",");
Serial.print(y); Serial.print(",");
Serial.println(z);
}
}

现在你可以使用Arduino IDE编译并下载上面这个简单示例,当程序下载到BlueFi开源板上之后,打开串口监视器(或串口绘图器)就可以看到加速度传感器三分量的原始数据(或三色折线图),
保持USB数据线完好连接到电脑,再通过摇晃、移动、旋转BlueFi开源板,观察加速度三分量的值与你的操作之间存在什么样的关联关系。在这个示例代码运行期间,
我们使用Arduino IDE的串口绘图器绘制的加速度三分量的折线图,参考图5.7所示。

i2c_lsm6ds3_plotter

图5.7 使用加速度传感器原始数据绘制的图形

将上面示例代码稍作修改就可以使用LSM6DS3类接口读取3DoF陀螺仪三分量的原始数据,示例代码如下:

(LSM6DS3_gyroscope_simplest.ino)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// show float data on the console, or draw ployline on the plotter (baudrate=115200)
#include <BlueFi.h>
void setup() {
bluefi.begin(); // 初始化BlueFi开源板(含imu初始化)
}

void loop() {
float x=0.0F, y=0.0F, z=0.0F;
if (bluefi.imu.gyroscopeAvailable()) { // 检查陀螺仪原始数据的可读性
bluefi.imu.readGyroscope(x, y, z); // 读取陀螺仪的三分量
Serial.print(x); Serial.print(",");
Serial.print(y); Serial.print(",");
Serial.println(z);
}
}

IMU用于运动物体的姿态和位置估算,譬如飞行器和汽车等姿态稳定和导航定位(无GPS信号期间的短距离定位)。加速度、陀螺仪和地磁传感器(电子罗盘)是IMU的基本测量传感器,
基于这些传感器的原始数据(9个分量)并使用姿态和位置估算算法即可确定飞行器和汽车等运动物体的当前姿态和位置。我们将在后续的内容中给出完整的IMU接口及其算法,
本节仅仅是作为I2C通讯接口的示例使用。


接着,我们以BlueFi开源板上的数字环境温湿度传感器——SHT30-DIS为例,使用Arduino开源平台的(nRF52)I2C硬件层和硬件抽象层接口实现温湿度传感器的用户接口,
即BlueFi开源板的BSP层的温湿度传感器的代码实现。具体的实现代码由以下两个文件组成:

(BlueFi_SHT30.h)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

#ifndef __BLUEFI_SHT30_H_
#define __BLUEFI_SHT30_H_

#include <Arduino.h>
#include <Wire.h>
#include <math.h>

#define DefaultSlaveAddress_SHT30 0x44
//#define DefaultSlaveAddress_SHT30 0x45
#define SHT31_MEAS_HIGHREP_STRETCH 0x2C06 /**< Measurement High Repeatability with Clock Stretch Enabled */
#define SHT31_MEAS_MEDREP_STRETCH 0x2C0D /**< Measurement Medium Repeatability with Clock Stretch Enabled */
#define SHT31_MEAS_LOWREP_STRETCH 0x2C10 /**< Measurement Low Repeatability with Clock Stretch Enabled*/
#define SHT31_MEAS_HIGHREP 0x2400 /**< Measurement High Repeatability with Clock Stretch Disabled */
#define SHT31_MEAS_MEDREP 0x240B /**< Measurement Medium Repeatability with Clock Stretch Disabled */
#define SHT31_MEAS_LOWREP 0x2416 /**< Measurement Low Repeatability with Clock Stretch Disabled */
#define SHT31_READSTATUS 0xF32D /**< Read Out of Status Register */
#define SHT31_CLEARSTATUS 0x3041 /**< Clear Status */
#define SHT31_SOFTRESET 0x30A2 /**< Soft Reset */
#define SHT31_HEATEREN 0x306D /**< Heater Enable */
#define SHT31_HEATERDIS 0x3066 /**< Heater Disable */
#define SHT31_REG_HEATER_BIT 0x0d /**< Status Register Heater Bit */
#define msONGOING 50 /* >=20ms */

class SHT30 {

public:
SHT30(TwoWire& wire, uint8_t slaveAddress=DefaultSlaveAddress_SHT30);
virtual ~SHT30(){};
bool begin(void);
uint16_t readStatus(void);
void reset(void);
void heater(bool on); // true: on, false: off
bool isHeaterEnabled(void);
void RHT_FSM(void);
bool isReady;
float temperature, humidity;

private:
bool writeCommand(uint16_t command);
bool readRegisters(uint8_t *buf, size_t len);
bool writeRegisters(uint8_t *buf, size_t len);

TwoWire* __wire;
uint8_t __Address;
uint32_t __startMillis;

enum rht_FSM
{
IDLE = 0,
ONGOING,
READY
} __rht_FSM;

};

#endif // __BLUEFI_SHT30_H_

这个SHT30类温湿度传感器接口主要包括,初始化(begin)和温湿度测量和数据处理的状态机(RHT_FSM),以及3个成员变量:状态机的温湿度结果是否可用(isReady)、
当前温度(temperature,摄氏度为单位)、当前相对湿度(humidity)。此外,SHT30类还有一些辅助功能接口,包括传感器状态读回(readStatus)、
传感器复位(reset)、传感器内部加热器的控制(heater)和状态查询(isHeaterEnabled)。SHT30类的内部/私有接口包括写命令字(writeCommand)、
读多个寄存器(readRegisters)和写多个寄存器(writeRegisters),私有成员变量包括硬件抽象层的I2C类接口指针、从机地址等。

(BlueFi_SHT30.cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131

#include "BlueFi_SHT30.h"

SHT30::SHT30(TwoWire& wire, uint8_t slaveAddress):
__wire(&wire),
__Address(slaveAddress) {
humidity = NAN;
temperature = NAN;
isReady = false;
__rht_FSM = IDLE;
}

bool SHT30::begin(void) {
__wire->begin();
reset();
return readStatus() != 0xFFFF; // check read-back operation
}

static uint8_t crc8(const uint8_t *data, int len) {
/*
* CRC-8 formula from page 14 of SHT3x spec pdf
* Test data 0xBE, 0xEF should yield 0x92
* Initialization data 0xFF
* Polynomial 0x31 (x8 + x5 +x4 +1)
* Final XOR 0x00
*/
const uint8_t POLYNOMIAL(0x31);
uint8_t crc(0xFF);
for (int j=len; j; --j) {
crc ^= *data++;
for (int i=8; i; --i)
crc = (crc&0x80) ? (crc<<1)^POLYNOMIAL : (crc<<1);
}
return crc;
}

uint16_t SHT30::readStatus(void) {
uint8_t data[3];
writeCommand(SHT31_READSTATUS);
readRegisters(data, 3);
uint16_t stat = data[0];
stat <<= 8;
stat |= data[1];
return stat;
}

void SHT30::reset(void) {
writeCommand(SHT31_SOFTRESET);
delay(10);
}

void SHT30::heater(bool on) {
if (on)
writeCommand(SHT31_HEATEREN);
else
writeCommand(SHT31_HEATERDIS);
delay(1);
}

bool SHT30::isHeaterEnabled(void) {
uint16_t regValue = readStatus();
return (regValue&SHT31_REG_HEATER_BIT);
}

/* the Finite State Machine for starting measure and readout data
* |---------------------------------|
* initialize --> IDLE --> ONGOING --> READY --->
* --> start --> delay --> readout ->
*/
void SHT30::RHT_FSM(void) {
uint8_t _readbuffer[6]; // TTCHHC
int32_t _stemp;
uint32_t _shum;
switch (__rht_FSM) {
case IDLE:
writeCommand(SHT31_MEAS_HIGHREP); // start
__startMillis = millis();
__rht_FSM = ONGOING;
break;
case ONGOING:
if ( (millis()-__startMillis) >= msONGOING ){ // check delay
__rht_FSM = READY;
}
break;
case READY:
readRegisters(_readbuffer, sizeof(_readbuffer));
if ( (_readbuffer[2]==crc8(_readbuffer, 2)) && (_readbuffer[5] == crc8(_readbuffer + 3, 2)) ) {
_stemp = (int32_t)(((uint32_t)_readbuffer[0] << 8) | _readbuffer[1]);
// simplified (65536 instead of 65535) integer version of:
// temperature = (_stemp * 175.0f) / 65535.0f - 45.0f;
_stemp = ((4375 * _stemp) >> 14) - 4500;
temperature = (float)_stemp / 100.0f;
_shum = ((uint32_t)_readbuffer[3] << 8) | _readbuffer[4];
// simplified (65536 instead of 65535) integer version of:
// humidity = (_shum * 100.0f) / 65535.0f;
_shum = (625 * _shum) >> 12;
humidity = (float)_shum / 100.0f;
}
isReady = true;
__rht_FSM = IDLE;
break;
default:
__rht_FSM = IDLE;
break;
}
}

bool SHT30::writeCommand(uint16_t command) {
uint8_t cmd[2];
cmd[0] = command >> 8;
cmd[1] = command & 0xFF;
return writeRegisters(cmd, 2);
}

bool SHT30::readRegisters(uint8_t *buf, size_t len)
{
if (__wire->requestFrom(__Address, len) != len)
return 0;
for (size_t i=0; i<len; i++)
buf[i] = __wire->read();
return 1;
}

bool SHT30::writeRegisters(uint8_t *buf, size_t len) {
__wire->beginTransmission(__Address);
if (__wire->write(buf, len) != len)
return 0;
if (__wire->endTransmission() != 0)
return 0;
return 1;
}

可以从以下几个方面对比LSM6DS3和SHT30-DIS两种I2C传感器的接口:

  • 接口封装的结构
  • 寄存器的读写

两种传感器接口的封装都是采用C/C++的类结构。“public”域是外部接口,“private”域是内部接口。接口类型不仅有类成员函数,也有成员变量。
因此,C/C++的类相关的概念和用法在这里完全通用。

两种传感器的寄存器读写接口虽然都是私有的,但区别较大。这是因为,LSM6DS3内部功能单元采用RAM型存储器映射的模式,但SHT30-DIS采用写入不同命令字来控制内部功能单元。
SHT30-DIS没有存储器映射机制,对传感器内部功能单元的每一次操作都必须先写入命令字(16位无符号型),譬如启动温湿度测量、启动/停止内部加热器等,
然后再执行多字节读操作获取传感器的测量结果、查询内部状态等。此外,从SHT30-DIS读回的数据(温湿度和状态)也都是固定3字节格式:2字节数据和1字节CRC(循环冗余校验)。
SHT30-DIS使用8位CRC算法,算法所使用的多项式、初始值都在其数据页 [3]_ 第14页给出描述。

我们用一个示例来演示如何使用SHT30类温湿度传感器接口。本示例首先初始化BlueFi开源板上所以资源(含温湿度传感器及其接口),在主循环中调用bluefi.rht.RHT_FSM()
执行温湿度测量的状态机更新温湿度数据到变量bluefi.rht.temperature和bluefi.rht.humidity,当状态机完成一次温湿度数据更新时bluefi.rht.isReady被置位为true,
主循环测试该状态并将当前温湿度结果打印到串口控制台。示例代码如下:

(SHT30_simplest.ino)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

// The simplest operation using SHT3x sensor
#include <BlueFi.h>
void setup() {
bluefi.begin(); // initialize all resource on the BlueFi
}

void loop() {
bluefi.rht.RHT_FSM(); // run the Finite State Machine to update RHT
if (bluefi.rht.isReady) {
bluefi.rht.isReady = false;
Serial.print("Temperature: ");
Serial.print(bluefi.rht.temperature);
Serial.write("\xC2\xB0"); //The Degree symbol
Serial.println("C");
Serial.print("Humidity: ");
Serial.print(bluefi.rht.humidity);
Serial.println("%");
}
delay(249);
}

将上面示例代码复制-粘贴到Arduino IDE并编译-下载到BlueFi开源板上,当BlueFi执行示例程序期间,打开Arduin IDE的串口监视器,
我们将会看到主循环程序输出到串口控制台(print)的文本格式的当前温湿度信息,参考图5.8所示。

I2C2021-08-04-20-09-21

图5.8 使用SHT30类接口读取当前温湿度并输出到字符控制台的效果

现在你可以使用SHT30类接口来监测本地的环境温湿度,确定本地区最舒适的温湿度是什么季节?对应的具体环境温湿度是多少呢?
标定是正确使用传感器的基本要求。如何标定温湿度传感器呢?


在Python解释器环境如何使用I2C通讯接口的主机模式进行编程呢?请参考第4.1节末尾的步骤,下载BlueFi的Python解释器固件,并双击BlueFi的复位按钮,
并将固件拖放到BLUEFIBOOT磁盘,将BlueFi恢复到执行Python解释器模式,我们的电脑资源管理器中将会出现名为“CIRCUITPY”磁盘。

… Note:: Python解释器的安全模式

  • 单击BlueFi的复位按钮,当第1颗彩灯(靠近复位按钮)显示黄色状态时,再次按下复位按钮,迫使BlueFi终止执行用户脚本程序,并进入安全模式,此时第一颗彩灯呈黄色呼吸灯效果
  • 当Python解释器在执行某些脚本程序时,可能会导致不出现“CIRCUITPY”磁盘,可以通过强制进入Python解释器的安全模式来终止脚本执行
  • 在Python解释器的安全模式,仍可以修改“CIRCUITPY”磁盘上任一文件,但Python解释器不会立即执行更新后的code.py程序
  • 只能通过按复位按钮才能退出Python解释器的安全模式

“CIRCUITPY/hiibot_bluefi/sensors.py”是BlueFi板上所有传感器的Python接口库模块,在我们的Python脚本程序中直接导入(import)这个模块就可以访问BlueFi的传感器。
将下面的示例代码保存到“/CIRCUITPY/code.py”文件,在BlueFi执行程序期间,我们可以使用任意串口字符控制台(MU编辑器的串口、Arduino IDE的串口监视器等)查看输出,
Python解释器的所有字符输出也都会同步地显示在BlueFi的LCD显示屏上。

1
2
3
4
5
6
import time
from hiibot_bluefi.sensors import Sensors
sensor = Sensors()
while True:
print("T: {}°C, RH: {}%".format(sensor.temperature, sensor.humidity))
time.sleep(1)

这个示例输出的文本字符的参考效果,如“T: 30.9388°C, RH: 52.6817%”,这显然由第5行“print()”函数中的“format”的作用。
示例程序的第2行脚本语句的执行效果是,从“CIRCUITPY/hiibot_bluefi/sensors.py”文件中导入“Sensors类”模块。第3行将“Sensors类”实例化一个名叫“sensor”的对象,
并在第5行将该对象的temperature和humidity属性值按指定的字符格式输出到字符控制台。

加速度和陀螺仪传感器——LSM6DS3也有相似的用法,示例代码如下:

1
2
3
4
5
6
7
8
9
10

import time
from hiibot_bluefi.sensors import Sensors
sensor = Sensors()
while True:
ax, ay, az = sensor.acceleration
gx, gy, gz = sensor.gyro
print("Acce X:{:.2f}, Y:{:.2f}, Z:{:.2f}".format(ax, ay, az))
print("Gyro X:{:.2f}, Y:{:.2f}, Z:{:.2f}".format(gx, gy, gz))
time.sleep(0.1)

这个示例代码的初始化部分与前一个示例完全相同。主循环程序中,首先将加速度和陀螺仪的三分量分别赋给6个变量,然后使用“format”转换成指定格式的字符串输出到字符控制台。
其中“{:.2f}”.format(var)是将变量var以浮点数输出且只保留小数点后两位。

事实上,BlueFi开源板上共有4种I2C接口的传感器组件,即温湿度传感器(SHT30-DIS)、加速度和陀螺仪(LSM6DS3)、地磁传感器(LIS3MDL)和集成光学传感器(APDS-9960,含颜色感知、接近感知、手势感知和光强度感知等)。
其中加速度、陀螺仪和地磁传感器能组合实现9-DoF惯性测量单元的传感器。这些传感器的Python库模块在“CIRCUITPY/hiibot_bluefi/sensors.py”文件中,
你可以直接打开这个Python脚本源文件了解具体的Python接口。


为了便于测试,请先删除“…/Documents/Arduino/libraries/BlueFi”文件夹中的全部文件,然后下载下面的压缩文件包,
并解压到“…/Documents/Arduino/libraries/BlueFi”文件夹中,

. :download:本节内容所用到的BlueFi的BSP源文件 <../_static/dl_files/bluefi_ch5_2/BlueFi_bsp_ch5_2.zip>

本节所增加的加速度和陀螺仪传感器、温湿度传感器的接口代码实现和示例程序都已在这个压缩包中。解压到指定文件夹后,直接用Arduino IDE打开对应示例程序即可编译-下载到BlueFi开源板。

在I2C总线上,每一从机都有惟一的7位地址,主机通过寻址从机实现一对一的半双工通讯,包括读写从机上的寄存器或者控制/查询从机上的功能单元。
本节以MCU片上功能单元工作在主机模式时,如何通过编程访问各种从机,譬如加速度和陀螺仪传感器、温湿度传感器等。为了能够掌握I2C主机端软件的设计和实现思路,
我们采用分层抽象的思想将I2C功能组件相关的接口分层封装,并以加速度和陀螺仪、温湿度传感器为例分别给出软件的实现,方便我们通过对比和总结。
虽然我们仅仅是C/C++类封装为例,Python语言的类封装和接口设计并无本质区别,查看“CIRCUITPY/hiibot_bluefi/sensors.py”文件并与上面的C/C++语言的类封装进行对比,
有利于理解I2C主机接口的编程和实现。


参考文献:
::

[1] https://www.arduino.cc/en/Reference/Wire
[2] https://www.st.com/resource/en/datasheet/lsm6ds33.pdf
[3] https://www.sensirion.com/fileadmin/user_upload/customers/sensirion/Dokumente/2_Humidity_Sensors/Datasheets/Sensirion_Humidity_Sensors_SHT3x_Datasheet_digital.pdf

===========================
5.3 I2C从机模式

绝大多数情况,嵌入式系统的MCU都是系统的主控制器,MCU片上I2C功能单元都工作在主机模式与系统内的I2C接口的传感器、执行器或显示器等外设互联。
但也有少数情况MCU片上I2C功能单元工作在从机模式,譬如通过I2C接口升级MCU固件,或者通过I2C接口协同工作的两个MCU组成的系统中一个MCU做主机另一个做从机。
本节主要了解MCU片上I2C功能单元工作在从机模式下的编程控制。注意,并不是所有MCU片上I2C功能单元都支持主机模式和从机模式,有些MCU仅支持主机模式的I2C接口。

当我们把MCU片上I2C功能单元配置为从机模式时,其内部结构组成如图5.7所示。

I2C2021-08-04-20-10-38
图5.7 从机模式的MCU片上I2C功能单元的结构组成

在从机模式下,MCU的I2C接口所使用的I/O引脚中,连接SCL信号的是输入引脚,SDA信号的是双向引脚。根据I2C通讯接口的要求,任一从机都必须有惟一的从机地址,
当我们将MCU片上I2C功能单元配置为从机模式时,必须指定本机的7位惟一地址。相对于主机,从机始终是被动的,主机何时寻址本机、读或写操作均有主机发起。
因此,从机模式需要配置一定RAM空间用于缓存接收数据,并开启中断,当从模式的I2C接口识别到本机被寻址,并接收到主机的数据时,向CPU发起中断请求并响应主机请求。

Arduino的I2C通讯接口的硬件抽象层不仅支持主机模式,也支持从机模式。从机模式的I2C硬件抽象层接口共有7各,具体接口如下:

… Note:: I2C硬件抽象层接口(仅从机模式的接口)

  1. begin(slave_addr),将I2C通讯接口配置为从机模式,并配置惟一的7位从机地址、SCL和SDA的I/O引脚、SCL时钟速度(使用默认的设置)、中断等。注意,只能在初始化时调用一次
  2. onReceive(cb_rev),注册“onReceive”事件的回调函数,当“onReceive”事件发生后需要执行的代码,譬如调用“available()”检查可读数据个数、调用“read()”读取接收缓冲区的数据并处理
  3. onRequest(cb_req),注册“OnRequest”事件的回调函数,当“OnRequest”事件发生后需要执行的代码,譬如调用“write()”发送数据给主机
  4. write(val)/,向主机写/发送数据(当主机请求数据时,即“OnRequest”事件发生后)。这个接口还有另外两种形式:write(val[], len)和write(string)
  5. available(),返回接收缓冲区中有效的/可读取的字节数据个数,即“onReceive”事件发生后使用该接口检查接收缓冲区的有效数据字节数
  6. read(),从接收缓冲区读取有效数据

注意,Arduino平台的I2C硬件抽象层的主机模式和从机模式的接口都被封装在“TwoWire类”中,详见页面 [1]_ ,从机模式的接口仅有这6种(具体种类还与Arduino内核的版本有关),
主机模式共8种接口(见前一节),其中部分接口是主机模式和从机模式共用的,如“write()”、“read()”、“available()”等,部分接口是各自专用的,
譬如注册事件的回调函数是从机模式专用的接口,而“beginTransmission()”、“endTransmission()”和“setClock()”是主机模式专用的接口。

使用I2C硬件抽象层的主机模式接口和从机模式接口,两个MCU之间的通讯流程参见图5.8所示。

I2C2021-08-04-20-13-35

图5.8 两个MCU之间使用I2C通讯的工作流程(使用硬件抽象层接口)

图中的实线框内的操作是软件部分,实线框外的操作由I2C功能单元的硬件自动完成。除了图中的“主机写-从机读”和“主机读(请求)-从机写”的I2C接口数据传输流程外,
还有“主机写-从机读-主机请求-从机写”(简单理解为“主机写后读”)的数据传输流程,这个流程要求主机“write(val)”后调用“endTransmission(false)”执行数据发送且发送完毕后不发起“STOP时序”,
即不释放I2C总线,继续向从机请求数据,当从机数据发送完毕后,主机才发起“STOP时序”释放I2C总线。请参照图5.8的流程自行设计“主机写后读”的操作流程。

下面我们找来两个BlueFi,并使用一根型号为“SH1.0mm-4P”双头同向的信号线将他们连接起来。BlueFi开源板带有一个专用的4脚I2C扩展插座,在复位按钮旁边,
该插座的4各信号分别为3.3V、GND、SDA、SCL,并顺序排列。使用I2C接口连接两个BlueFi的方法如图5.9所示。

I2C2021-08-04-20-13-48

图5.9 使用I2C接口连接两个BlueFi的方法

请注意4芯连接线的型号、脚间距,并确保引脚是同向一一对应的,即两个BlueFi开源板的i2C专用插座的4个脚分别一一对应连接。

现在我们可以参考图5.8所示的流程,分别编写“主机写”和“从机接收”的程序对儿,并分别编译下载到一个BlueFi上执行,使用USB数据线将工作在从机模式的BlueFi连接到电脑,
打开Arduino IDE串口监视器可以看到主机写给从机的数据。程序代码如下:

(master_write.ino文件,编译并下载到一个BlueFi开源板,他是I2C接口的Master设备)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

#include <Wire.h>
TwoWire* __wire; // define a pointer "__wire" to TwoWire type

void setup() {
delay(500);
__wire = &Wire; // the pointer __wire point to Wire
__wire->begin(); // join i2c bus (address optional for master)
}

void loop() {
static uint8_t x=0;
__wire->beginTransmission(0x72); // transmit to device #114
__wire->write("x is "); // sends five bytes
__wire->write(x); // sends one byte
__wire->endTransmission(); // stop transmitting
x++;
delay(998);
}

在这个“主机写”的程序中,首先声明一个TwoWire型指针“__wire”,并在初始化时将这个指针指向BlueFi的I2C接口0,即“Wire”,并使用指针访问这个I2C接口,
在初始化阶段将这个I2C接口初始化为主机模式(使用无参数的“begin()”初始化接口)。在主循环中每隔1秒从这个I2C接口写出写字符串“x is 12”,其中字符串中的数值是可变的,
根据“static uint8_t x=0;”语句,以及每写出一次后执行“x++;”语句,这个字符串的变化规律是怎么样的呢?

(slaver_receive.ino文件,编译并下载到一个BlueFi开源板,他是I2C接口的Slave设备)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

#include <Wire.h>
TwoWire* __wire; // define a pointer "__wire" to TwoWire type

void setup() {
__wire = &Wire; // the pointer __wire point to Wire
__wire->begin(0x72); // join i2c bus with address #114
__wire->onReceive(cb_rev); // register a callback function on Receive event
Serial.begin(115200); // start serial for output
}

void loop() {
//delay(500);
}

// callback function that executes whenever data is received from master
// this function is registered as an event, see setup()
void cb_rev(int num) {
while( 1 < __wire->available() ) { // loop through all but the last
char c = __wire->read(); // receive byte as a character
Serial.print(c); // print the character
}
uint8_t x = __wire->read(); // the last received byte as an integer
Serial.println(x); // print the integer
}

“从机接收”程序中,同样使用指针“__wire”指向I2C接口0,即Wire。初始化时使用“__wire->begin(0x72)”将I2C接口0配置为从机模式,且从地址为114,
并使用“__wire->onReceive(cb_rev);”语句注册“当接收到主机发送的数据”事件的回调函数——“cb_rev(int num)”。定义这个回调函数时,监测I2C接口0是否有数据可读,
如果有效数据个数大于1个则读出1个数据并打印到串口字符控制台,最后一个数据作为整数打印到控制台。

注意,从机的程序中使用的回调函数“void cb_rev(int num)”带有的输入参数“int num”是“onReceive”接口指定的,用于传递发生“onReceive”事件时接收缓冲区内有效的数据个数,
此示例中未使用这个参数。

最后,根据图5.8的流程,实现“主机请求读”和“从机写”的程序对儿。示例代码如下:

(master_request.ino文件,编译并下载到一个BlueFi开源板,他是I2C接口的Master设备)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

#include <Wire.h>
TwoWire* __wire; // define a pointer "__wire" to TwoWire type

void setup() {
__wire = &Wire; // the pointer __wire point to Wire
__wire->begin(); // join i2c bus (address optional for master)
Serial.begin(115200); // start serial for output
}

void loop()
{
__wire->requestFrom(0x72, 6);// request 6 bytes from slave device #114
while(__wire->available()) { // slave may send less than requested
char c = __wire->read(); // receive a byte as character
Serial.print(c); // print the character
}
delay(998);
}

在这个主机程序中,初始化部分与前一个“主机写”程序完全一样,但是主循环中的程序完全不同。主主循环程序中,每秒从I2C接口0向地址为114的从机请求6字节数据,
然后监测接收缓冲区是否有数据可读,如果有则逐个读出并打印到串口字符控制台。

(slaver_send.ino文件,编译并下载到一个BlueFi开源板,他是I2C接口的Slave设备)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

#include <Wire.h>
TwoWire* __wire; // define a pointer "__wire" to TwoWire type

void setup() {
__wire = &Wire; // the pointer __wire point to Wire
__wire->begin(0x72); // join i2c bus with address #114
__wire->onRequest(cb_req); // register the callback function of OnRequest event
}

void loop() {
delay(100);
}

// callback function that executes whenever data is requested by master
// this function is registered as an event, see setup()
void cb_req(void) {
__wire->write("hello "); // respond with message of 6 bytes as expected by master
}

在这个“从机写”的程序中,首先初始化I2C接口0,并注册“当主机请求读数据”事件的回调函数“cb_req”。在回调函数“cb_req”中仅发生6个字符给主机。


上面的两对示例程序中,我们仅仅使用I2C硬件抽象层的接口实现两个BlueFi之间通讯,虽然表面上看两对程序各自实现的数据传输都是单工的,
即“主机写”和“从机读”、“主机请求”和“从机发送”,实际的输出协议都是双向的。

两个MCU如何使用I2C接口实现双向数据通讯呢?我们可以采用“存储器映射”方案。从机端的数据信息按特定的数据结构(如数组)顺序地存储,
主机端首先向从机“写”数据的顺序号来指定数据单元,然后通过请求读取该数据单元,该方法的主机和从机的具体流程参见图5.10所示。

I2C2021-08-04-20-11-47

图5.10 使用I2C接口实现两个MCU双向通讯的主机和从机流程(存储器映射)

请根据上图的流程并参考前面的示例程序,分别编写对应的主机端和从机端的程序对儿,并使用两个BlueFi测试程序是否达到目标。

当然,图5.10中的主机流程仅仅是请求从机端指定的静态数据项,因为从机端并没有改变任何数据项。事实上,如果我们允许从机端程序改变图5.10中的数据项,
这种改变必须十分的谨慎,因为正在修改数据项时或许会发生回调函数正好读取该数据项,这将引起“竞争”。避免这种竞争的方法之一就是使用“锁(lock)”,
数据项操作方在操作前首先检查“锁”的状态,如果被上锁则等待解锁后方可操作,如果未被上锁则先上锁再操作数据项。


本节探讨如何使用I2C硬件抽象层的接口实现两个MCU之间通讯,对于主机端的软件操作和实现方法,与前一节所用的方法并无区别。由于I2C从机始终处于被动状态,
I2C硬件抽象层为从机端提供专用的接口,包括“OnReceive”和“OnRequest”两种事件的回调函数,使用回调函数确保从机实时地响应主机的写和请求读操作,
当然MCU片上I2C接口功能单元的硬件自动处理主机的寻址,以及事件触发,无需从机端软件干预。


参考文献:
::

[1] https://www.arduino.cc/en/Reference/Wire

===========================
5.4 I2C接口应用设计

I2C通讯接口作为一种真正的多个外设共享的总线,且只需要2根信号线(SCL和SDA)即可实现上百种外设连接,本节进一步探讨如何使用I2C总线拓展嵌入式系统的功能。
图5.11是知名开源硬件供应商——SparkFun推出的Qwiic类开源硬件产品应用示例图 [1]_ ,该产品的主控制器带有I2C通讯接口且工作在主机模式,
所有扩展功能模块都采用统一的Qwiic接口,并支持顺序串联联或菊花链等多种连接拓扑。目前SparkFun已推出数百种Qwiic接口的主控制器、传感器、显示器、
执行器、I/O扩展等模块,几乎可以满足大多数产品原型开发阶段的功能验证和软件开发测试。

I2C2021-08-04-20-11-58
图5.11 Qwiic接口产品应用示例(SprakFun)

Qwiic采用4根连接线和4脚的1.0mm间距的连接器,推荐使用的连接器内部带有键槽以防插错,4根连接线的信号分别为SCL、SDA、Vcc和GND,即2根电源线和2根I2C接口信号线。
本质上,Qwiic接口就是带有电源线的I2C通讯接口。Qwiic接口与传统的4线USB、PS2等接口相似,不仅具有数据接口信号线还具有电源线,使用这样的接口时从机无需额外供电。

此外,另一家知名开源硬件供应商——Adafruit推出的STEMMA QT接口 [2]_ 与Qwiic几乎完全相同,两种接口的所用连接器的机械标准和电气标准完全兼容。这种接口为什么备受欢迎呢?
主要原因是I2C接口的共享总线方便嵌入式系统扩展更多种(上百种)功能,以及扩展功能单元的模块化等。图5.12是Qwiic接口或STEMMA QT接口的电路模型。
I2C2021-08-04-20-12-16

图5.12 Qwiic/STEMMA QT接口的电路模型

上图中,我们给出4种典型的I2C接口的功能扩展模块的电路模型,左侧两种扩展单元都具有标准I2C通讯接口(从机),右侧两种都是采用MCU(I2C从机模式)转换为标准I2C通讯接口。

许多集成型I2C接口的传感器,譬如SHT30-DIS、LSM6DS3、VL53L0X(TOF型激光测距传感器)等,以及显示器和RTC,譬如OLED点阵屏等,除了I2C接口和供电之外无需额外的元件和接口,
这类传感器和显示器的I2C接口模块非常适合采用Qwiic/STEMMA QT接口,4芯连线就可以将这些模块串联起来并与主控制器的MCU连接起来。还有一些I2C接口的传感器、ADC和DAC,
如MPR121(12通道人体触摸感知)、MCP9600(热电偶传感器)、ADS1115(8路ADC)、MCP4728(4路DAC)等,以及I2C接口的电机驱动和I/O扩展单元,譬如PCA9685、
MCP23017等,除了I2C接口和供电之外还需要一些特殊连接器与目标传感器、电机等连接。

基于I2C通讯接口也可以实现分布式系统,这样的分布式系统不仅容易开发和维护,而且采用连接子系统的总线拓扑也十分灵活。仅有UART接口的GPS(全球定位系统)/BDS(北斗系统)等模块,
可以使用MCU单独设计“I2C-UART”桥接单元将非I2C接口的功能单元连接到I2C总线。

下面用两种具体的设计示例来帮助我们了解上述的电路模型。第一个示例是Adafruit的TOF(Time-Of-Flight)激光测距模块 [3]_ ,该模块的采用ST公司的集成型TOF传感器VL53L0X,
具体的电路原理图、PCB和实物参见图5.13。

I2C2021-08-04-20-12-25

图5.13 采用Qwiic/STEMMA QT接口的TOF激光测距传感器模块(Adafruit)

这个I2C接口的激光测距模块的有效量程和编程控制API请参阅页面 [4]_ ,该传感器非常适合于机器人避障、抗疫自动测温控制等应用场景。

第二个示例来自SparkFun [5]_ ,这是一种步进电机(或双直流电机)控制模块。该模块使用一颗小型ARM Cortex-M0系列MCU——CY8C4245控制一个步进电机驱动器,
并使用I2C从机模式接入I2C总线。

I2C2021-08-04-20-12-42

图5.14 采用Qwiic/STEMMA QT接口的步进电机驱动模块(SparkFun)

很显然,上述两种示例都采用Qwiic/STEMMA QT接口,主控制器端用于控制这两种扩展模块的软件几乎相同,主控制器的I2C接口工作在主机模式,两种扩展模块都是I2C从机,
他们都具有相同内惟一的I2C从机地址,主机使用惟一的从机地址分别寻址其中某个扩展模块并实现测控功能。请参考第5.2节编写这些扩展功能单元的软件实现,此处不在赘述。


本节使用知名开源硬件供应商推出的Qwiic/STEMMA QT接口类产品为例,详细地探讨基于I2C通讯接口的嵌入式系统原型设计方法和硬件模型。

在前几节的内容中我们已经了解MCU片上I2C功能单元的主机模式和从机模式及其接口和编程控制,现在你可以花一些时间完善BlueFi开源板上I2C接口传感器的BSP,
完成这些工作需要参考第5.2节的温湿度传感器(SHT30-DIS)、加速度和陀螺仪传感器(LSM6DS33)的BSP实现,并查阅LSM3MDL和APDS-9960两种传感器的手册,
以及github等开源代码库中搜索相关开源代码。


参考文献:
::

[1] https://www.sparkfun.com/qwiic
[2] https://learn.adafruit.com/introducing-adafruit-stemma-qt?view=all
[3] https://learn.adafruit.com/adafruit-vl53l0x-micro-lidar-distance-sensor-breakout?view=all
[4] https://www.st.com/resource/en/datasheet/vl53l0x.pdf
[5] https://www.sparkfun.com/products/15451

===========================
5.5 本章总结

I2C是一种同步通讯接口,通讯数据信号SDA和同步时钟信号SCL始终保持同步,两者之间的时空关系是I2C接口时序。

I2C是嵌入式系统中的一种真正的共享总线,仅使用2种信号即可实现上百种传感器、执行器、显示器等外设扩展。I2C总线的这种性能得益于I2C接口所采用的“线与”电路结构,
以及I2C接口时序和数据传输协议。I2C通讯接口采用主从模式,支持一主多从和多主多从的灵活结构。一主多从的系统结构中,要求每一个从机都有惟一的7位从机地址,
主机通过寻址某个指定的从机以实现一对一半双工通讯。多主多从的系统结构中,为实现多个主机同时抢占I2C总线需要每一个I2C主机单元都支持总线仲裁,
发起抢占总线的主机根据总线仲裁结果确定是否抢占成功或失败,抢占失败的主机将进入等待,所以多主多从的系统结构的通讯接口操作存在不确定性和非实时性。
本章内容中仅涉及最常用的一主多从结构。当单个I2C通讯总线上挂接多种从机时,逻辑电平的电压匹配非常重要,适合于I2C通讯接口电平匹配必须是双向的,
我们在本章提供一种简易型电平转换电路(Level Shifter)单元,也可以采用专用的电平转换单元,专用的电平转换单元具有通讯速度高、漏电流小等特点。

嵌入式系统MCU的片上I2C通讯接口具有两种工作模式:主机模式和从机模式。主机模式的I2C接口可用于扩展系统内的各种I2C接口显示器、传感器、执行器等,
从机模式下的I2C接口允许MCU作为另一个主控制器的子系统,两个MCU之间可以使用I2C总线实现半双工通讯。

本章中,我们分别以主机和从机两种模式讨论I2C通讯接口的软件封装,仍采用分层抽象的方法,将I2C通讯的软件接口分割为硬件层、硬件抽象层、中间层和用户层。
其中,硬件层的接口软件由半导体厂商实现,主要是访问MCU片上I2C功能单元相关的寄存器;硬件抽象层是基于硬件层的软件接口为中间层分别提供I2C主机和从机两种模式的I2C协议实现的软件接口;
中间层是针对特定的嵌入式系统内I2C总线上各个I2C功能组件的软件接口,基于硬件抽象层的I2C通讯协议接口访问I2C功能组件上的寄存器等;
特定嵌入式系统的用户层直接调用BSP中的相关I2C功能组件接口,无需了解I2C协议和I2C功能组件内寄存器等细节即可使用I2C功能组件。

基于I2C总线的原型系统是较为流行的一种模块化的、快速的原型搭建系统,“如何将各种功能单元设计成具有标准的I2C接口的模块”是此类系统的设计关键,
本章给出I2C接口应用设计的电路模型,以及主机和从机模式的软件实现。

通过本章学习,我们了解I2C通讯接口的原理、协议、软硬件应用的设计方法等。I2C通讯接口是现代MCU标配的片上功能单元,也是最常用的嵌入式系统内各组件之间的互联总线。
本章内容属于嵌入式系统应用和开发的基础之一。


本章总结如下:

  1. 数字通讯相关的基础概念
  2. I2C通讯接口的“线与”电路、时序、协议、寻址方法、通讯流程、电平转换方法等
  3. MCU片上I2C功能单元工作在主机模式时,I2C接口软件的封装和实现
  4. MCU片上I2C功能单元工作在从机模式时,I2C接口的通讯流程和软件实现
  5. I2C总线的原型系统的模型和设计示例

===========================
思考题

  1. 查阅“双向三态门”电路及其逻辑,并根据图5.2的“线与”接口电路,请试着使用双向三态门单元改进I2C接口单元的硬件接口电路,并分别描述主机发送-从机接收、从机发送-主机接收的两种工作模式的控制信号状态。
  2. 根据图5.5所示的双向电平电压转换电路,请简要分析其工作过程。
  3. 当你设计一个嵌入式系统时所用到的I2C功能组件通讯接口速度不一致,请给出合理的解决方案。
  4. 单主-多从结构的I2C通讯接口中仅使用7位宽从机地址即可连接上百个I2C功能组件,请说明7位从机地址的作用,并简述主机访问某个从机的过程。
  5. 当MCU片上I2C功能单元工作在主机模式时,以读取某I2C接口的传感器数据为例,简述SCL和SDA信号的输出方向和两者关系。
  6. 以I2C通讯接口软件的分层抽象为例,简述硬件层、硬件抽象层、BSP(或中间层)、用户层等各层的功能和作用,并总结分层抽象软件结构的优缺点。
  7. 在Arduino平台的I2C硬件抽象层中,为什么“beginTransmission()”、“endTransmission()”和“setClock()”是主机模式专用的接口?
  8. 参照图5.8的流程,设计I2C接口的“主机写后读”的操作流程,即“主机写-从机读-(无STOP时序)-主机请求-从机写”的操作流程。