I2C-之书-全-
I2C 之书(全)
原文:
zh.annas-archive.org/md5/2a2d5295728383d55c977fd496cc0e02译者:飞龙
前言

欢迎来到《I^(2)C 之书》。本书提供了设计和编程使用互连电路总线(IIC、I2C 或 I²C)的系统所需的资源,I²C 是一种用于将各种集成电路(IC)连接到计算机系统中的串行协议。本书将教你如何通过添加 I²C 外设来扩展嵌入式系统设计,且仅需极少的接线和软件。
引用 i2c.info 的内容,I²C 只使用两根线即可轻松地将微控制器、A/D 和 D/A 转换器、数字 I/O、存储器等设备连接到嵌入式系统中。尽管最初由飞利浦(现为 NXP 半导体)开发,但现在大多数主要的 IC 制造商都支持 I²C。I²C 之所以受欢迎,是因为它普及——大多数嵌入式系统所用的 CPU 都支持 I²C——并且其外设 IC 价格低廉。它广泛应用于像 Arduino 和 Raspberry Pi 这样的爱好级系统,以及大多数用于嵌入式系统的专业单板计算机(SBC)中。
I²C 总线在由“创客”用于个人项目的爱好级嵌入式系统中尤为重要,这些系统通常使用类似 Arduino Uno、Teensy 4.x 或 Raspberry Pi 等市售的单板计算机(SBC)作为系统的“大脑”。这些 SBC 通常具有有限的 I/O 能力或其他限制,因此可能需要添加外设 IC 来实现特定设计。I²C 总线是扩展这些系统的最流行和常见方式之一,因为它易于使用、方便且廉价。此外,市面上有成百上千种具有广泛功能的独立 IC 设备,可以直接连接到 I²C 总线。再加上庞大的开源代码库(特别是针对 Arduino 设备的代码),使用 I²C 总线扩展小型系统几乎变得轻而易举。
尽管面向专业嵌入式系统的高端定制 SBC 通常包括爱好级 SBC 所缺少的许多外设,但 I²C 总线仍然是设计此类系统的一种成本效益高的方式。通常,性能要求不高的外设通过 I²C 总线与 SBC 上的 CPU 连接。
由于 I²C 总线的普遍存在,现如今如果不至少对 I²C 总线有一些基本了解,就很难从事嵌入式系统的开发。不幸的是,大多数程序员都被期望通过搜索互联网、拼凑设计和编程信息来弄清楚如何使用 I²C 总线。本书解决了这个问题,收集了所有需要的资源,将它们整合成一本全面的书籍,帮助读者完全理解如何使用 I²C 总线设计和编程系统。
预期与前提条件
使用 I²C 外设需要一定的硬件和软件专业知识。理论上,一个没有软件经验的电子工程师可以设计一些硬件,然后交给一个没有硬件经验的软件工程师,两人合作可以完成某些工作。然而,本书并不是为这种团队而写的。相反,它是为那些不怕直接与硬件打交道的软件工程师,或那些不怕坐下来用文本编辑器编写软件的硬件工程师所准备的。
《I^(2)C 的书* 假设你能够阅读原理图,并将 COTS SBC(如 Arduino、Pi 或其他商用 SBC)通过面包板或原型板上的点对点布线连接到各种外设。你应该能够熟练使用万用表、示波器、逻辑分析仪等工具来检查和调试这些电路。
本书还假设你熟悉 C/C++ 编程语言,并能够在上述单板计算机(SBC)上创建、测试和调试适当大小的程序。虽然 I²C 代码可以用多种不同的语言编写(包括汇编语言、Java 和 Python),但 C/C++ 是嵌入式系统的通用语言。几乎每个商用现成单板计算机(COTS SBC)的开发软件都支持使用 C/C++,因此本书假设读者已经掌握了这门语言。
本书中的大多数示例使用了 Arduino 库,因为它广泛应用且易于使用。因此,假设读者至少对 Arduino 系统有基本的了解。树莓派的示例显然使用树莓派操作系统(Linux)和 Pi OS I²C 库代码;本书提供了这些库文档的链接。对于其他系统(例如,运行在 NetBurner 模块上的 µC/OS 或运行在 STM32 模块上的 MBED),本书假设读者没有先前的知识,并提供必要的信息或相关文档的链接。
嵌入式系统编程的软件工具通常运行在 Windows、macOS 或 Linux 上。你应该熟悉运行这些工具的特定系统(例如,C/C++ 编译器),并能够在自己的系统上运行这些工具,包括学习如何使用、安装和配置这些工具及其相关文档。必要时,本书会描述如何找到这些工具及其文档;然而,本书的重点是 I²C 总线,而不是如何运行 C/C++ 编译器和集成开发环境(IDE),因此它留给你自己去了解这些工具。
本书中的源代码
本书包含大量的 C/C++ 源代码,代码形式有三种:代码片段、模块和完整程序。
代码片段是程序的片段,提供这些片段是为了说明某个观点或提供某种编程技巧的示例。它们不是独立的,你不能使用 C/C++ 编译器编译它们。以下是一个典型的代码片段示例:
while( inputPin() == 0 )
{
.
.
.
}
这个示例中的竖直省略号表示可以替代的任意代码。
模块是可以编译但不能独立运行的小型 C/C++ 代码块。模块通常包含某些其他程序将调用的函数。以下是一个典型的示例:
// inputPin function
int inputPin( void )
{
int p = readPort( 0x48 )
return p & 1;
}
本书中完整的程序称为 listings,我通过 listing 编号或文件名来引用它们。例如,以下是 Arduino “闪烁”程序的示例 listing,来自文件 Listing1-1.ino。文件名表示它是第一章中的第一个 listing,我在周围的文本中将其称为 Listing 1-1,并在代码本身的注释中标明文件名:
// Listing1-1.ino
//
// An Arduino ″Blink″ program.
int led = 13;
void setup()
{
pinMode( led, OUTPUT );
}
void loop()
{
digitalWrite( led, HIGH ); // Turn on the LED
delay( 500 ); // Wait for 1/2 second
digitalWrite( led, LOW ); // Turn off the LED
delay( 500 ); // Wait for a second
}
请注意,Listing1-1.ino 文件名格式仅适用于我自己编写的代码。其他源代码保持其原始文件名。例如,我提到的第十六章中 TinyWire 库的代码是 attiny84_Periph.ino。某些非 Arduino 系统(例如 Pi OS 和 MBED)使用标准的 main.cpp 文件名作为主程序;本书通常会将这些程序放在一个名为 Listingx-x 的子目录中,并将整个目录称为“listing”。本书中的许多 listing 足够长,我已经将它们分成多个部分,并在各部分之间加上文本注释。在这种情况下,我会在每个部分的开头放置类似 // Listing10-1.ino (cont.) 的注释,以保持连续性。
所有 listing 和模块的电子版本都可以在我的网站 bookofi2c.randallhyde.com 上获取,可以单独下载或作为一个包含所有 listing 和其他支持信息(包括勘误表、电子章节等)的 ZIP 文件下载。
除非另有说明,本书中出现的所有源代码都受到 Creative Commons 4.0 许可证的保护。根据 Creative Commons 许可证,你可以自由地将这些代码用于你自己的项目。更多详情请参见 creativecommons.org/licenses/by/4.0。
排版与学究主义
计算机书籍往往有滥用英语语言的习惯,而这本书也不例外。每当源代码片段出现在英文句子中间时,编程语言的语法规则和英语的语法规则之间通常会发生冲突。在本节中,我将描述我在区分英语语法规则与编程语言语法规则时的选择,以及其他一些约定。
首先,本书使用等宽字体来表示任何作为程序源文件一部分的文本。这包括变量和过程函数名称、程序输出以及用户对程序的输入。因此,当你看到像get这样的词时,你知道本书是在描述程序中的标识符,而不是命令你去获取某样东西。
有一些逻辑操作的名称也有常见的英语含义。这些逻辑操作是 AND、OR 和 NOT。当作为逻辑函数使用这些术语时,本书使用全大写字母以帮助区分可能会让人困惑的英语语句。当这些术语作为英语使用时,本书使用标准排版字体。第四个逻辑运算符,异或(XOR),通常不会出现在英语语句中,但本书仍然将其大写。
通常情况下,我总是尽量在第一次使用任何缩略语或简称时进行定义。如果我很久没有使用这个术语了,我通常会在下一次使用时重新定义它。我在本书中增加了一个术语表,定义了大部分出现的缩略语(和其他技术术语)。
最后,硬核电气工程师通常在描述一组电子信号时使用buss一词,尤其是在描述总线条时。然而,我使用bus和buses的拼写,因为它们在讨论 I²C 总线的文献中更为常见。
关于术语的说明
在 2020 年,几家主要电子公司和开放源代码硬件协会(OSHWA)的其他成员提议更改各种 SPI 总线术语的名称,以消除一些人认为在道德上有问题的术语。电子行业长期以来一直使用master(主设备)和slave(从设备)来描述系统中各种设备的操作层次结构。这些名称没有技术上的正当理由;它们甚至无法精确地描述设备之间的关系,因此即使没有其他问题,使用更合适的术语也是值得推崇的。
尽管这是一本关于 I²C 总线的书,而不是 SPI 总线,但 I²C 可能是下一个需要修改的术语(正如 SparkFun 在www.sparkfun.com/spi_signal_names所提到的)。虽然 I²C 总线没有使用master或slave的引脚名称,但术语master、slave、multimaster和multislave在 I²C 文献中很常见。根据 OSHWA 的 SPI 总线指南,本书采用了以下更具描述性且不具冒犯性的术语:
- Master 变为controller**
** Multimaster 变为multicontroller** Slave 变为peripheral** Multislave 变为multiperipheral**
当然,控制器和外设各自有其特定含义,并不总是与 I²C 总线控制器或外设设备对应。然而,本书的上下文会明确表明我所指的意义。虽然大量历史文档仍使用主机和从机这两个术语,但你可以在脑中简单地将主机/控制器和从机/外设相互转换。为了避免与这些历史文档产生混淆,本书仅在引用使用这些术语的外部文档时使用主机和从机。
组织结构
本书组织结构分为四个部分,除此之外还有附录和在线章节:
第一部分:低级协议和硬件
- 本部分描述了 I²C 的信号和硬件。尽管你不一定需要了解这些信息就能设计基于 I²C 总线的系统或编写代码来编程外设,但这些知识在调试使用 I²C 总线的硬件和软件时非常有用。第一部分还包括了 I²C 总线的软件实现,专为那些更倾向于代码而非电气规格的软件工程师准备,同时还包含了一个关于分析和调试 I²C 总线事务的章节。最后,本部分通过讨论 I²C 总线的各种实际扩展来结束。
第二部分:硬件实现
- 本部分描述了几个 I²C 总线的实际应用,特别是回顾了以下硬件的 I²C 实现:
-
Arduino 系统(及兼容系统)
-
Teensy 3.x 和 4.x SBC I²C 实现
-
Raspberry Pi、BeagleBone Black、PINE64、ROCKPro64、Onion 及其他 Linux 系统
-
STM32/Nucleo-144/Nucleo-64 I²C 实现
-
NetBurner MOD54415 I²C 实现
- 第二部分还描述了以下 I²C 总线实现:
-
Adafruit Feather 总线
-
SparkFun Qwiic 总线
-
Seeed Studio Grove 总线
第三部分:I²C 总线编程
- 本部分讨论了在 I²C 总线上编程设备的相关内容。包括了各种通用编程技术,如实时操作系统 I²C 编程,并提供了针对 Arduino、Raspberry Pi、Teensy、MBED 和 NetBurner 的具体实际编程示例。第三部分还描述了如何使用裸机编程技术实现 I²C——这种技术直接在硬件层面工作,而不是调用库代码。
第四部分:I²C 外设编程示例
- 本部分提供了一些常见实际 I²C 外设 IC 的编程示例,包括 MCP23017 GPIO 扩展器、ADS1115 16 位 A/D 转换器、MCP4725 D/A 转换器和 TCA9548A I²C 多路复用器。第四部分还描述了如何将 SparkFun Atto84 模块用作自定义 I²C 外设。
附录
-
附录 A 是 Adafruit I²C 地址汇编的快照,列出了数百种市售 I²C 外设 IC 的地址。
-
附录 B 包含在线内容的概述。无论我向本书添加多少页,它都将显得不完全,因为市场上有太多 I²C 控制器和外设。此外,在本书出版后,肯定会有新的外设出现。为了解决这个难题(并降低你购买本书的价格),额外的章节可以在
bookofi2c.randallhyde.com在线访问。 -
在线内容将涵盖(其中包括)以下主题:
-
MCP4728 四通道 DAC
-
Maxim DS3502 数字电位计
-
DS3231 精密实时时钟
-
MCP9600 热电偶放大器
-
I²C 显示器
-
SX1509 GPIO 接口
-
PCA9685 PCM/伺服接口
-
INA169 和 INA218 电流传感器
-
MPR121 电容式触摸接口
-
Raspberry Pi Pico SBC
-
Espressif ESP32(以及 ESP8266)SBC
术语表
-
本书中出现的术语和缩写列表。
-
除了在线章节外,网站还将提供帮助,指导如何构建本书中出现的电路,并提供有关编程 I²C 外设的其他信息。它还将包含本书中所有电子项目的零件清单。我的目标是随着新的(重要的)外设和控制器的出现,不断更新这些信息,这些新设备利用 I²C 总线。**
第一部分
低级协议与硬件
第一章:I²C 低级硬件

I²C 总线是一个全球标准,用于在印刷电路板(PCB)上的集成电路(IC)之间以及系统内多个 PCB 之间的通信。根据 NXP 半导体公司的说法,I²C 被超过 50 家不同制造商生产的 1,000 多种不同的 IC 所使用。毫无疑问,I²C 是现有的较为流行的 IC 数据通信方案之一(串行外设接口 [SPI] 是另一种)。
随着 Arduino 和 Raspberry Pi 等支持 I²C 的爱好者级单板计算机(SBC)的推出,I²C 总线的流行度不断增加。今天,成千上万的程序员已经学习了 I²C 总线的基础知识,因为他们想将某个设备与 Arduino 或 Pi 系统连接起来。由于大量的开源库代码,用户可以在不完全理解其低级信号协议的情况下使用和编程 I²C 总线上的设备。然而,要真正充分利用使用 I²C 外设的设计,你需要理解这些协议,包括硬件和软件。 本章介绍了 I²C 的低级硬件方面,在学习信号协议之前,你需要了解这些内容。
1.1 I²C 概述
在 I²C 总线等总线出现之前,计算机系统的不同组件使用传统的 CPU 风格总线进行通信。这些总线通常使用 8 到 32 条数据线以及一些地址信号。将一个 8 位并行 I/O 设备连接到 CPU 需要相当大的 PCB 空间来容纳所有信号线路。当然,增加额外的 I/O 会相应地增加信号、空间和噪声。除了空间,这些线路还增加了系统工程师在设计中需要处理的噪声量。
I²C 总线的发明是为了解决这些问题。有了 I²C 总线,一对信号线(PCB 路径)就可以将各种不同的 I/O 外设连接到 CPU。这减少了成本,并且在构建复杂(嵌入式)计算机系统时消除了许多问题(更多信息请参见文本框“ I²C 总线的优势”)。
I²C 设备分为两类:控制器设备(以前称为主设备)和外设设备(以前称为从设备)。控制器设备,顾名思义,控制着控制器和外设设备之间的通信。外设设备则不会主动发起任何通信,而是依赖控制器来管理通信过程。
I²C 协议是一种同步串行通信,使用两条信号线:SCL,代表“串行时钟”,和SDA,代表“串行数据”。控制器驱动时钟线。当外设向控制器发送数据时,它将数据位放置在数据线上;当外设接收数据时,控制器将串行数据位放置到数据线上。除了一个特殊情况——时钟拉伸,稍后会在本章讨论——外设永远不会控制时钟线。
一个典型的系统有一个控制器和一个或多个外设设备。每个外设设备在给定的 I²C 总线上都有一个唯一的地址,控制器通过该地址区分同一总线上的多个外设。从理论上讲,单个 I²C 总线支持最多 127 个甚至 1,024 个不同的外设设备,尽管实际情况会将外设设备的数量限制在更小的范围内。
尽管一个典型的系统只有一个控制器,但 I²C 总线支持同一总线上有多个控制器。这允许多个控制器共享一组公共外设设备。给定的系统还可以支持多个 I²C 总线,因此具有相同地址的外设设备,不能在同一 I²C 总线上一起使用,仍然可以在给定的系统中部署。
控制器与外设之间的关系是 I²C 协议的基础。虽然理论上,一个单一的集成电路可以既作为控制器也作为外设,并且在给定的系统中在这两种功能之间切换,但这种情况较为罕见;通常,设备要么作为外设,要么作为控制器在系统中运行。
1.2 开漏(开集电极)逻辑和四线模式
I²C 总线的一个最基本的电气特性是它基于开漏(或开集电极)逻辑系统。也就是说,连接到 I²C 总线的设备不会驱动信号线的高电平或低电平;相反,它只能通过开漏(FET)连接将这些信号线拉低。连接 I²C 总线信号线到电源的上拉电阻默认将总线的两条信号线 SDA 和 SCL 拉高。这种设计允许多个控制器和外设控制数据线和时钟线,而不会遇到多个输出引脚连接到同一信号线时的问题。
要在其中一条信号线上放置逻辑 1,设备将其开漏(或如果使用双极性设备,则为开集电极)设置为高阻抗状态。这样,上拉电阻会将默认的逻辑 1 放置到信号线上。要在其中一条信号线上放置逻辑 0,设备激活其开漏设备,将信号线拉到地面。
大多数 I²C 设备提供开漏信号以连接到 I²C 总线,因此你不需要额外的硬件来连接这些设备到总线。然而,也可以通过将任意逻辑设备通过双极性晶体管(开集电极)、JFET、MOSFET 或其他开漏设备连接到 I²C 总线,来控制 I²C 总线的电气访问。以下小节提供了这个例子的说明。
SDA 和 SCL 线路根据定义在控制器设备上是双向的,并且在外围设备上通常也是双向的。某些单板计算机或 CPU 可能不支持能够在双向模式下工作的数字 I/O 引脚——也就是说,它们只能被编程为输入引脚或输出引脚。为了解决这个问题,一些系统设计师实现了 I²C 的四线模式。在四线模式下,I²C 总线仍然有两条线路,但控制器使用两条线路实现每个信号:两条输出引脚和两条输入引脚。图 1-1 展示了这个四线控制器连接。

图 1-1:四线控制器连接
为了防止电气冲突(将两个输出连接在一起),输出引脚驱动晶体管的基极(FET 或 MOSFET 的栅极),集电极(漏极)连接到相应的总线线路。然后,控制器 CPU 可以同时使用输入引脚读取总线线路上的数据,同时使用输出引脚写入数据,从而避免电气冲突,并且无需支持双向 I/O 引脚。
1.3 I²C 信号电平
当 I²C 在 1980 年代初期设计时,5 V 逻辑是主流的设计技术,因此原始的 I²C 假设使用 5 V 逻辑信号。然而,由于 I²C 总线基于开漏连接,高电压电平完全由系统设计时上拉电阻连接的电源决定。随着计算机系统开始使用 3.3 V 甚至低至 1.8 V,系统设计师开始将这些上拉电阻连接到 5 V 以外的电源。
从 I²C 总线的角度来看,只要控制器和外围设备能够处理并正确地工作于总线上出现的电压电平,任何电压应该都能正常工作。然而,实际上,一些电压可能会导致问题。为此,最近的 I²C 标准规定,总线上的电压必须至少为 2 V(因此 1.8 V 逻辑无法工作)。它们还规定,逻辑高电平定义为高于电源电压的 70%,而逻辑低电平定义为低于电源电压的 30%。如今,通用的 I²C 设备几乎总是期望使用 5 V 或 3.3 V 逻辑。
1.3.1 电平转换
如果你想在同一条总线上混合使用 3.3V 和 5V 的 I²C 设备,会发生什么情况?将 3.3V 信号发送到 5V 设备可能不会损坏它,但设备可能无法将 3.3V 识别为逻辑 1,因为标准要求在 5V 系统中,逻辑 1 等于 3.5V(5V 的 70%是 3.5V)。将 5V 信号发送到 3.3V 设备则更为严重;通常情况下,这会损坏设备。显然,你应该尽一切可能避免这种情况。
当在同一个 I²C 总线上混合使用 3.3V 和 5V(或其他不同电压范围)设备时,必须使用电压转换或电平转换,将总线上的实际电压转换为与设备兼容的电压。NXP 半导体公司提供了一份技术说明,描述了如何使用离散 MOSFET 进行此操作(参见本章结尾的“更多信息”)。另一种解决方案是购买现成的商用部件,如 Adafruit 的四通道 I²C 安全双向电平转换器(BSS138)。Adafruit BSS138 设备提供了一个桥接器,用于连接运行在 3.3V 和 5V 的两个 I²C 总线——或者,如果需要更大的电压范围,它的工作电压范围为 1.8V 至 10V。正如我写这段文字时所说,这些 Adafruit 设备的价格大约是每个 4 美元(美国),并支持两个独立的 I²C 总线电平转换器(I²C 总线需要两个电平转换器,一个用于 SDA 线,另一个用于 SCL 线)。
第三种选择是使用 TCA9548A I²C 多路复用器进行电平转换。这个集成电路将 I²C 总线分成八个分别控制的 I²C 总线。每个独立的总线可以连接到独立的电源(例如,3.3V 或 5V)并配备独立的上拉电阻。因此,你可以将 3.3V 设备放在一条总线上,将 5V 设备放在另一条总线上。传入的总线(从 CPU 到 TCA9548A)可以是任何电压。更多信息请参见第十二章。
1.4 选择上拉电阻的大小
选择 SDA 和 SCL 上拉电阻的大小需要一些思考。虽然我不会深入讲解这个选择背后的复杂数学原理,但请记住,上拉电阻的大小应该在 1 kΩ到大约 20 kΩ之间,具体取决于电源电压和总线电容。总线电容越大(特别是当 I²C 总线较长时),电阻应该越小。最小值通常由电源电压决定。对于 3.3V 系统,如果需要,可以将电阻值设置为略低于 1 kΩ。对于 5V 系统,1.5 kΩ大概是你应该选择的最低值。
通常,大多数系统开始时会使用 4.7 kΩ的上拉电阻,并在必要时逐步降低。请注意,如果你使用的是常见的 COTS 零件,例如 Adafruit、SparkFun 或 Seeed Studio 的扩展板,这些板子上通常已经安装了上拉电阻。如果你将两个或更多此类设备连接到系统中,必须通过并联电阻计算来计算最终的电阻值(1/R = 1/R[1] + 1/R[2] + . . . + 1/R[n])。这意味着如果你安装了两块板子,每块板子上有 4.7 kΩ的上拉电阻,你实际上在这两条线路上有 2.35 kΩ的上拉电阻。如果你在同一总线上添加过多这样的板子,可能会导致上拉电阻值低于建议的最小电阻值。
1.5 总线电容和上拉电阻值
I²C 总线的速度(在下一节中我会进一步讨论)主要由上拉电阻的值(R[p])和总线电容的值(C[p])决定。虽然系统设计师可以直接控制上拉电阻的值,但总线电容主要取决于两件事:连接到总线的设备的输入电容和总线本身的长度。总线长度增加时,电容也会增加,尽管电阻的增加非常小,通常可以忽略不计。那么,这两个参数为什么会影响总线速度呢?
根据电子学原理,你知道电阻和电容的乘积是时间。具体来说,1 Ω × 1 法拉 = 1 秒。当设备拉低某条总线线路或释放总线线路,以便上拉电阻将信号拉高时,总线电容和电阻会影响信号上升或下降所需的时间。如果这个时间超过一定值,I²C 总线将无法以其最大额定速度运行。
I²C 标准将总线电容限制为 400 pF(皮法,每个皮法是法拉的十亿分之一)。I²C 总线上的典型上拉电阻值在 1 kΩ到 10 kΩ之间。这会产生 0.4 μsec(微秒)到 4 μsec 的上升时间。如果 I²C 总线以 100 kHz 的频率工作(即 10 μsec 周期),使用 10 kΩ的上拉电阻(4 μsec 上升时间)可能无法正常工作。解决方案是减少电容或电阻。
减少总线电容的主要方法是尽量缩短总线的长度。较长的总线线路会显著增加总线电容。你还可以通过减少总线上 I²C 设备的数量来降低总线电容。如果必须在总线上连接固定数量的设备,可以通过使用两个独立的总线来减少每条总线上的设备数量。
当然,减少上升时间的另一个解决方案是减小上拉电阻的值。例如,使用 4.7 kΩ电阻而不是 10 kΩ电阻可以将上升时间减少大约一半。
1.5.1 如果总线电容过高怎么办?
降低总线电容可能是一个困难的过程。缩短 I²C 总线长度是主要的方法。如果 I²C 信号通过导线传输,你还可以使用更好的电缆,或者减少总线上的设备数量(例如,将一半设备移到第二条 I²C 总线上)。
如果这些解决方案不可行或不足以解决问题,可以降低总线速度。如果当前速度为 400 kHz,可以降低到 100 kHz;如果已经是 100 kHz,可以降低到 50 kHz,以此类推。如果这仍然无法解决问题,可能需要进行重大电路重设计。此时,另一个可以考虑的解决方案是像 SparkFun QwiicBus Kit 这样的差分总线驱动器:www.sparkfun.com/products/17250。
1.6 I²C 总线速度
如前所述,I²C 总线有两条信号线:串行数据线和串行时钟线。控制器通过 SDA 线与外设之间传输数据。SCL 线控制着串行数据传输的速度。SCL 线上的信号频率决定了数据在控制器和外设之间的传输速度。I²C 总线标准定义了以下数据传输速率:
-
标准模式:100 kHz 的 SCL 频率,在设备之间以 100 kbit/sec 的速度传输数据。
-
快速模式:400 kHz 的 SCL 频率,在控制器和外设之间以 400 kbit/sec 的速度传输数据。
-
快速模式加:1 MHz 的 SCL 频率,在控制器和外设之间以 1 Mbit/sec 的速度传输数据。
-
高速模式:最高支持 3.4 MHz 的 SCL 频率,在控制器和外设之间以最高 3.4 Mbit/sec 的速度传输数据。
-
超高速模式:5 MHz 的 SCL 频率,但数据传输仅为单向。
实际上,I²C 总线频率的上限主要由总线上拉电阻的总线电容决定。然而,通常没有频率的下限。实际上,许多外设会使用时钟拉伸(参见本章后面的第 1.9 节“时钟拉伸”)来冻结数据传输,以便外设有时间处理来自控制器的数据,这有效地降低了时钟速度和平均数据传输速度。此外,控制器设备不要求在 SCL 线上输出 100 kHz 信号(或其他频率)。如果需要,SCL 频率可以设置为 50 kHz 或任何低于常规时钟速度的频率。
SCL 信号不是一个自由运行的时钟。当控制器将一个比特移到 SDA 线上时,I²C 控制器显式地切换该线。当控制器不在 SDA 线上发送或接收数据时,控制器会将 SCL 线保持为高电平。因此,I²C 总线频率就是数据传输过程中的 SCL 频率。
I²C 总线速度的主要限制可能是你必须以总线上最慢设备的速度运行总线。如果总线上有一个 100 kbit/sec 的外设,你必须以 100 kHz 的速度运行总线,即使与同一总线上的 400 kbit/sec(或更快)外设通信也是如此。因为大多数 I²C 兼容的集成电路是 100 或 400 kbit/sec 的设备,所以除了一些非常特殊的硬件特定情况外,系统很少将 I²C 总线运行超过 400 kHz。通常,如果你想运行超过 400 kHz,你会切换到 SPI 总线。
1.7 多控制器 I²C 总线时钟同步
I²C 总线可选地支持多个控制器在同一总线上运行,这被称为多控制器 配置。在这种情况下,两个控制器的 SCL 时钟频率必须匹配,并且它们必须都支持多控制器操作。然而,仅仅在相同频率下运行并不足以满足多控制器环境的要求;它们的时钟也必须同步。两个控制器是相对于彼此异步运行的。也就是说,如果总线当前没有被任何控制器使用,两个控制器可能都会决定同时使用总线。然而,两个控制器几乎不可能在完全相同的时刻决定激活它们的 SCL 线。I²C 协议要求两个控制器的时钟信号大致在同一时刻上升和下降,以保持适当的时序。为此,I²C 协议引入了一种时钟同步操作,延迟其中一个信号的开始,使得它们在同步操作后大致同时上升和下降。
时钟同步依赖于一种开漏总线的特性,称为有线与操作,它通过无需额外硬件的方式模拟逻辑与电路。逻辑与(两输入)函数仅在两个输入都为真时才产生正确的结果。如果任意一个或两个输入为假,逻辑与函数则产生错误的结果。如果考虑将两个开漏设备连接到 SCL 线,那么结果就相当于一个逻辑与电路。如果两个设备都被编程为 1,使它们的输出处于高阻抗状态,那么 SCL 线上的上拉电阻将把总线拉高。如果任意一个或两个设备被编程为使开漏输出处于激活状态,则将 SCL 线拉至 Gnd,从而使 SCL 线为 0。
时钟同步利用总线的有线与功能来同步两个控制器之间的时钟,这些控制器竞争使用总线。第一个将 SCL 线拉低的控制器开始计数它的低电平周期,大约是一个半时钟周期。稍后(仍在低电平周期内),第二个控制器将 SCL 线拉低。当第一个控制器到达其低电平周期的结束时,它释放 SCL 线。然而,由于第二个控制器仍然拉低 SCL 线,它保持低电平。稍后,当第二个控制器释放 SCL 线时,它将变为高电平,因为两个控制器都已释放 SCL 线,如 图 1-2 所示。

图 1-2:多个控制器驱动低 SCL
第一个控制器应该注意到 SCL 线没有变为高电平,并将延迟计数一个半时钟周期(此时 SCL 为高电平,其 高电平周期),直到它注意到 SCL 线确实已经变为高电平,如 图 1-3 所示。然后,两个控制器都开始计数高电平周期,SCL 线为高电平。第一个计数完一个半时钟周期的控制器将把 SCL 线拉低;第二个控制器应紧随其后。

图 1-3:多个控制器进行 SCL 时钟同步
到此为止,时钟应该已大致同步,其中低 SCL 周期为两个控制器中最长的,而高 SCL 周期为两个控制器中最短的。两个控制器的时钟周期将相似,但不会完全相同。
1.8 多控制器 I²C 总线仲裁
虽然时钟同步是允许多控制器访问 I²C 总线的必要条件,但它不是充分条件。即使两个控制器的时钟已经同步,它们也可能同时将不同的数据写入 SDA 线;这样会破坏总线上的数据,并导致不可靠的结果。仲裁是两个(或更多)控制器决定谁可以实际控制总线的过程。
在控制器获取 I²C 总线使用权限之前,它首先会检查总线是否已经被占用。它通过观察 SDA 和 SCL 线超过一个半时钟周期,并验证在此期间两个信号是否保持高电平来实现。如果是这样,它会发出一个 I²C 总线 启动序列(见第二章),并开始传输数据。
当然,在这个序列之后并不能保证总线真的空闲,因为第二个控制器可能几乎在同一时间启动了相同的过程。因此,两个控制器在总线上放置的数据可能会发生冲突。为了检测这个问题,I²C 总线仲裁系统利用了总线的线性与(wired-AND)操作。如果两个设备都向 SDA 线写入 1,或者两个设备都向 SDA 写入 0,这条线将正确反映两个控制器写入的数据。然而,如果一个控制器写入 0,而另一个写入 1,那么写入 0 的控制器将“赢得战争”——即 SDA 线将被拉低。为了处理总线仲裁,两个控制器始终查看它们写入 SDA 线的数据,以确认其包含的是它们所写入的内容。如果一个控制器向总线写入一个值,然后读取回一个不同的值,那么两个控制器就失去了仲裁,必须停止控制总线。
请注意,在控制器检查 SDA 线上的数据时,它们还会观察 SCL 线上的信息,以便在仲裁总线时同步时钟。
1.9 时钟拉伸
时钟信号始终由 I²C 总线上的控制器设备生成。控制器期望外设在控制器在 SCL 线上发出的时钟频率下正常运行。如果外设无法在该频率下操作,则必须重新编程控制器,以便以较低的时钟频率与外设通信。
正如你在下一章中将看到的,I²C 总线上的数据通信由一串 8 位字节组成,这些字节传输到外设,外设通过使用确认位来确认每个字节。当外设将确认位传回控制器时,外设可以选择将时钟线保持为不活动状态,持续一个任意的时间段。如前几个章节所述,这就是所谓的时钟拉伸(参见 1.6 节和 1.1 节,分别为“I²C 总线速度”和“I²C 概述”)。它有效地暂停了控制器的操作,直到外设处理完接收到的数据(即时钟拉伸为 I²C 时钟添加了等待状态)。
与仲裁和时钟同步一样,时钟拉伸利用了 I²C 总线的线性与操作。如果外设在控制器将 SCL 线设为高时将其拉低,那么 SCL 线将保持低电平。控制器会监视这种行为,并暂停数据和时钟输出,直到外设释放 SCL 线。
时钟拉伸是 I²C 协议的一个可选功能,这意味着控制器不必支持该功能。显然,如果外设使用时钟拉伸,则该设备将与不支持此功能的控制器不兼容。
1.10 交叉干扰
I²C 通信中可能遇到的另一个问题是串扰。如果你将 SDA 和 SCL 线并行布置,尤其是在较长距离下,一条线上的信号变化可能会影响另一条线。最常见的问题是 SCL 线影响 SDA 线。为减少串扰问题,简单的解决方案是在 PCB 上的 SCL 和 SDA 线之间加一条接地走线。同样,在排线电缆中,在 SDA 和 SCL 导线之间加一条接地线——或者更好的是,一条电源线和一条接地线——可以减少串扰发生的可能性。
尽管 SCL 和 SDA 线之间的串扰是 I²C 总线上最常见的串扰问题,但请记住,其他信号也可能在这两条信号线上引入噪声。在布置 PCB 时,尽量将其他高频或高电流的线路远离 SDA 和 SCL 走线。遵循良好的 PCB 设计规则真的会有所帮助。同样,当运行 I²C 信号或排线(或其他布线)时,在 SDA 和 SCL 信号之间交错布置接地线可以减少系统中的噪声。
解决串扰问题的一种方法是使用差分线驱动器在 I²C 上。SparkFun 提供了 QwiicBus 套件来解决串扰和其他由于总线长度导致的问题。详见“更多信息”。
1.11 小结
在本章中,你了解了 I²C 总线解决了早期嵌入式系统设计人员在为设计添加 I/O 时遇到的几个问题。双线(串行数据和时钟)接口减少了 PCB 的尺寸、噪声和设计难度。I²C 总线具有以下规格:
-
开漏(开集电极)总线信号,允许多个控制器在单条数据线上进行双向通信
-
100 kHz、400 kHz、1 MHz、3.4 MHz 和 5 MHz 总线速度
-
2 V 到 5 V 工作(通过上拉电阻)
-
总线仲裁和时钟拉伸以解决时序问题
-
可连接到任何 I²C 总线的各种 I²C 外设 IC
第二章:I²C 协议

I²C 总线的定义不仅仅是总线上出现的电气电平。同样重要的是定义出现在这两条线上的信号。本章讨论了与 I²C 总线相关的数据协议——即数据传输发生的速度,设备如何强制控制器等待,控制器如何执行以下操作:
-
向设备发送和接收位
-
指定设备的地址
-
指定数据方向
-
指定数据传输的结束
总线上出现的位的顺序和定义,以及它们如何在总线上被时钟控制,由 I²C 协议 决定。本章描述了该协议,并讨论了一些有用的主题,如重置 I²C 总线和检测总线上的外设。
2.1 I²C 总线上的数据
I²C 总线通过 SDA 线串行传输数据,时钟由 SCL 信号控制(见图 2-1)。当 SCL 线为高电平(1)时,SDA 线上的数据必须保持稳定(0 或 1);数据只能在 SCL 线为低电平时(见图 2-1)发生变化。

图 2-1:I²C 总线上的串行数据传输
I²C 总线上的数据传输由一个起始信号开始,随后是一个或多个字节的数据,最后以停止信号结束。在数据传输之间,SDA 和 SCL 线处于非激活状态(即两个信号都被拉高)。如前一章所述,如果 SCL 线保持高电平超过半个时钟周期,表示总线当前未使用。
起始条件是控制器将 SDA 线拉低,而 SCL 线已经保持高电平一段时间(见图 2-2)。如前所述(见图 2-1),SDA 线在时钟线高电平时通常必须保持稳定。这是为了让 I²C 总线上的设备能够检测到起始(以及稍后将看到的停止)条件。
通常,SCL 必须在 SDA 线变低并发送起始条件之前保持高电平,持续时间在四分之一和半个时钟周期之间。具体而言,这个起始设置时间是以下之一:
-
对于标准模式(10-微秒时钟周期):4.7 微秒
-
对于快速模式(2.5-微秒时钟周期):0.6 微秒
-
对于快速增强模式(1-微秒时钟周期):0.26 微秒
一旦数据线变低,表示起始条件,控制器可以将 SCL 线拉低,开始在以下启动保持时间后进行数据时钟传输:
-
对于标准模式:4.0 微秒
-
对于快速模式:0.6 微秒
-
对于快速增强模式:0.26 微秒

图 2-2:I²C 总线上的起始条件
一个字节的传输包括 8 个数据位和一个确认位(见图 2-3)。8 位数据字节从 SDA 线上最重要的位(MSB)开始传输,最不重要的位(LSB)在八个时钟周期后传输。字节数据后紧跟一个确认位。如果接收设备确认数据,该位始终为 0;如果发生传输错误,该位为 1(NAK,或负确认)。

图 2-3:I²C 总线上的字节传输
请注意,数据总线上的数据可能由控制器或外设放置。在写操作中,控制器负责将数据放置到 SDA 线上;在读操作中,外设负责将数据放置到 SDA 线上。对于确认,角色是反转的:在读取时,控制器将 SDA 线拉低以确认从外设读取的数据;而在写入时,外设将 SDA 线拉低以确认写操作。如果由于某种原因传输失败,接收数据的设备将不会拉低 SDA 线进行确认。这样,SDA 线保持高电平(即 NAK)。下一节,“I²C 地址与读写控制”,将讨论控制器如何指定是否进行读写操作。
停止条件包括在保持 SCL 线为高电平的同时,将 SDA 线从低电平拉升到高电平(请记住,数据在时钟线为高电平时通常必须保持稳定)。这通常包括在数据传输结束时,将 SDA 线在 SCL 线最后的上升沿之前拉低,然后再将 SDA 线拉高,如图 2-4 所示。与启动条件类似,SCL 线必须在转换 SDA 线从低到高之前保持高电平一段时间(即停止条件的设置时间)。这些设置时间与启动条件相同,具体如下:
-
对于标准模式:4.7 微秒
-
对于快速模式:0.6 微秒
-
对于快速增强模式:0.26 微秒
一旦控制器生成停止条件,I²C 总线将空闲,经过适当的启动设置时间后,控制器可以重新占用并使用总线。

图 2-4:I²C 停止条件(S[p])
停止条件并不是在每个字节之后都发生的。相反,停止条件标志着一串字节传输在 I²C 总线上完成。具体而言,一次单一(原子)传输包括一个启动条件,随后是一个或多个字节传输(每个字节都有其对应的确认或负确认位),最后是停止条件。这种传输被认为是原子的,因为驱动传输的控制器在整个传输过程中对 I²C 总线具有完全控制权;在此期间,其他控制器不能占用 I²C 总线。
2.2 I²C 地址与读写控制
启动条件后,第一个出现在 I²C 总线上的字节是特殊的。该字节包含了 I²C 设备地址和读/写状态。图 2-5 显示了外设地址和 R/W(读/写)字节的格式(W 上的下划线表示写信号为低有效 [0])。

图 2-5:启动条件后的第一个字节(外设地址和 R/W 位)
该字节的高 7 位包含了该传输的目标外设地址。由于是 7 位,你最多可以在这个外设地址字节中指定 128 个外设地址(不过请参见本章后面的 2.5 节,“特殊地址”,了解有关 I²C 总线上的 10 位地址的信息)。该字节的 LO 位包含一个 R/W 标志。此位为 0 时,表示写操作;为 1 时,表示读操作。
R/W 位决定了在外设地址字节之后所有字节的数据传输方向。如果该位为 0(写操作),则控制器将在随后的字节中通过 SDA 线提供所有数据;如果该位为 1(读操作),则外设将在传输过程中通过 SDA 线放置数据。无论数据方向如何,控制器仍然负责驱动 SCL 线(不过请参见本章后面的 2.4 节,“时钟延伸”)。
通常,本书会将 I²C 地址表示为 7 位二进制或十六进制值(使用 C 表示法,0xnn 或 0bnnnnnnn)。完整的地址和 R/W 字节通常会以 8 位十六进制形式表示。
2.3 重复启动条件
在一些特殊情况下,控制器可能需要向某个特定外设写入数据,然后立即从该外设读取数据,这一过程必须是原子操作,且写操作与读操作之间不能允许其他控制器访问该外设。由于数据方向是由启动条件后的第一个字节中的 LO 位指定的,因此控制器必须发送另一个启动条件来改变方向。然而,如果控制器在发送另一个启动条件之前通过发出停止条件来完成当前传输,这将给其他控制器提供在第二次传输前抢占总线的机会。这意味着控制器需要通过不发送停止条件来保住总线。此操作通过重复启动条件来实现。
重复启动条件,顾名思义,是在没有中间停止条件的情况下(SDA 从高到低,SCL 保持高电平)第二次发起的启动条件。在直到出现停止条件之前(等待控制器在 I²C 总线上等待的条件),当前控制器拥有总线。因此,控制器可以在数据传输期间使用一系列重复启动来反转数据传输方向,甚至与多个外围设备进行通信,而无需放弃 I²C 总线。当原子操作完成时,控制器可以通过发出停止条件来释放总线。
在多控制器环境中,控制器应当注意避免垄断 I²C 总线。对于不需要原子性操作的任务,控制器应当在传输之间使用停止条件,以便不同的控制器能够公平地访问总线。
2.4 时钟拉伸
如前一章所述,时钟拉伸是一种技术,允许外围设备在处理数据时迫使控制器等待。例如,一个简单的外围设备轮询 SDA 和 SCL 线并手动处理传入数据,在每个字节之后可能需要一点时间。在此期间,控制器可能会传输更多的数据,而这些数据可能会在外围设备忙于处理时丢失。为了解决这个问题,外围设备可以使用时钟拉伸强制控制器暂停,直到外围设备的 CPU 处理完传入的数据。这有效地为数据传输增加了等待状态。
外围设备可以通过拉低 SCL 线来拉伸时钟。从技术角度讲,这可以在任何时候发生。然而,在从控制器向外围设备传输数据时,外围设备通常会在确认位后,SCL 线处于低电平时拉低 SCL 线,因为这是外围设备接收完整字节并需要处理数据的时刻。从外围设备向控制器传输数据时,如果外围设备需要额外时间来生成下一个要传输的字节,那么在完成字节传输后,外围设备拉低 SCL 线也是一个合适的时机。
请注意,时钟拉伸是 I²C 标准中的可选功能。事实上,大多数外围设备不支持时钟拉伸,因为它们可以以与控制器发送数据一样快的速度处理数据传输。虽然应该是比较少见的情况,但在某些情况下,控制器可能不支持时钟拉伸,例如当使用简单的微控制器设备构建一个多功能 I²C 控制器时,这个控制器必须处理许多不同的任务,从而导致性能问题。有关如何处理这种情况的详细信息,请参见本章末尾的“更多信息”。
2.5 特殊地址
使用 7 位地址时,你可能会以为 I²C 总线最多支持 128 个设备。实际上并非如此,原因有二。首先,I²C 标准为特殊用途保留了两组 8 个地址(0 到 7 和 120 到 127)。其次,I²C 使用其中一些保留地址来支持最多 10 位长度的扩展地址。理论上,这允许在总线上增加 1,024 个设备。
表 2-1 列出了当前为 I²C 总线定义的特殊地址。这些地址中出现的“无关位”(xx)可以是 0b00、0b01、0b10 或 0b11。大多数情况下,程序会为这些位提供 0b00。
表 2-1:特殊 I²C 地址
| 地址位 | 读/写 | 描述 |
|---|---|---|
| 0000-000 | 0 | 通用呼叫地址 |
| 0000-000 | 1 | 启动字节 |
| 0000-001 | x^(*) | CBUS 地址 |
| 0000-010 | x | 为不同的总线格式保留 |
| 0000-011 | x | 预留以备未来使用 |
| 0000-1xx | x | 高速模式控制器代码 |
| 1111-0aa | 读/写 | 10 位外设寻址(关于 aa 位的讨论,请参见本章后面的第 2.5.6 节,“10 位外设寻址”) |
| 1111-1xx | 1 | 设备 ID |
| ^(*)x = “无关”并且可以是 0 或 1 |
以下小节将更详细地描述通用呼叫地址(包括硬件通用呼叫)、启动字节、CBUS、高速控制器模式、10 位寻址和设备 ID 特殊地址。还有几个地址预留用于未来设备扩展。现有的控制器不应使用这些地址,直到其使用在 I²C 标准中被定义。
2.5.1 通用呼叫地址
通用呼叫地址(0x00,读/写 = 0)是一个特殊的广播地址,可以寻址总线上的所有设备。读/写位始终为 0(写),因为你不能同时从所有设备读取数据,否则它们的返回值会互相干扰。
通用呼叫操作通常由至少两个字节组成:通用呼叫地址(0x00),后跟命令字节(参见 图 2-6)。通常,系统使用此命令通过单一的总线命令初始化所有响应通用呼叫操作的外设。

图 2-6:通用呼叫命令格式
当 B 位为 0 时,I²C 协议当前定义了以下命令(ccccccc 位):
-
ccccccc= 0b0000011:重置并设置外设可编程地址。 -
ccccccc= 0b0000010:设置外设可编程地址,但不重置。 -
ccccccc= 0b0000000:非法命令代码,不允许作为第二个字节。
外围可编程地址是一个可以通过外围设备上的硬件引脚设置的地址。许多设备在集成电路(IC)封装上包含引脚,可以指定多个不同的 I²C 地址,以便设备响应。这允许设计师例如将多个相同的 IC 连接到 I²C 总线上,并通过设置这些引脚的高电平或低电平来让它们响应不同的地址。例如,MCP4725 DAC 包含一个引脚,允许你通过将引脚连接到 Vcc 或 Gnd 来选择两个不同的 I²C 地址之一。通用调用命令 0x00/0x07(ccccccc = 0b0000011,B = 0)和 0x00/0x05(ccccccc = 0b0000010,B = 0)指示这些芯片从引脚加载地址(0x00/0x06 命令也指示外围设备重置自身)。
大多数外围 IC 会在上电时设置其编程地址,且该地址在此之后不会发生变化,因此大多数外围设备会忽略此命令,或仅对(0x00,0x06)命令执行复位操作。
请注意,外围设备不必支持通用调用命令——也就是说,其实现是可选的。如果设备不支持通用调用命令,它必须忽略此命令。
由于 I²C SDA 线的开漏特性,如果任何设备响应了通用调用地址和命令字节,控制器将看到一个 ACK 响应。只有当没有设备响应通用调用命令时,控制器才会看到 NAK 响应。
除(0x00,0x00)、(0x00,0x04)和(0x00,0x06)外的命令保留用于未来使用,设备必须忽略它们。然而,如果你正在为自定义系统创建自定义外围设备,你可以创建自己的通用调用命令。你甚至可以在命令字节后面传递附加数据,广播给所有响应该命令的设备。只需记住,I²C 协议的未来修订可能会与你的定义冲突。另外,请记住,你只能使用广播(通用调用)命令写入数据。
2.5.2 硬件通用调用
硬件通用调用是通用调用的一种特殊形式,支持点对点通信。如果图 2-6 中的B位为 1,则该 2 字节序列是硬件通用调用。ccccccc位指定了控制器的地址,控制器将该地址广播给所有设备,后面可以跟随 0 个或多个字节的附加数据。总线上的其他设备可以读取这些数据并进行相应的解释。通常,硬件通用调用是一个控制器设备与另一个控制器设备之间通信并传递数据块的方式。然而,为了使此方案有效,接收数据的第二个控制器设备必须在硬件通用调用中查找第一个控制器的地址。
商用设备中不会找到支持硬件总线调用的设备。通常,系统中彼此通信的定制编程控制器会使用这些消息。
老实说,与其设计这种协议到您的系统中,您可能更好地使用 CANBUS 或其他点对点网络方案在系统中的控制器之间传输数据。正如您可能预期的那样,很少有设备利用 I²C 协议中的此功能。大多数设备忽略硬件总线调用。
2.5.3 起始字节
起始字节(adrs = 0, R/W = 1)是在快速控制器和响应较慢的外设之间介入 I²C 通信的软件机制。一些低成本的外设设备偶尔只轮询 SDA 线,以查看是否存在起始条件。如果外设在实际起始条件到达时正忙于其他事务,则可能会错过其预期接收的消息。起始字节是序列 0x01(即地址为 0 且 R/W 位为高)。这些七个 0 位将在使用 100 kHz 时钟时分布在 70 微秒内,应足以让外设检测到 SDA 线已变低。
起始字节后总是跟随重复的起始条件和实际的外设地址字节(参见图 2-7)。请注意,起始字节永远不会被确认 —— 在起始字节后的第九位始终会跟随一个 NAK。同时请注意,起始字节与总线调用功能(参见图 2-6)共享相同的地址。区别在于 R/W 位;起始字节序列的 R/W 位置始终为 1,而总线调用操作在该位置为 0。

图 2-7:起始字节序列
如果外设设备速度较慢并且需要起始字节前缀,则控制器必须在与外设通信之前显式传输起始字节。同样地,如果外设设备速度较慢,则其软件必须识别起始序列的尾端,并准备好从总线上读取后续地址。
请注意,并非所有外设集成电路都支持起始字节。这意味着如果在 I²C 总线上发送起始字节来与响应较慢的设备通信,总线上的其他设备可能会误解此信号。如果要在 I²C 协议中使用此功能,请确保总线上的所有设备都能正确响应(至少忽略起始字节)。
2.5.4 CBUS 和保留地址
CBUS(adrs = 1, R/W = x [不重要])是 I²C 总线的一个旧的、废弃的变种。CBUS 地址最初用于在 I²C 总线上激活 CBUS 设备。然而,现在不再使用此地址用于此目的,现代控制器不应将此地址放置在总线上。
2.5.5 高速模式控制器代码
高速模式控制器代码(地址 = 4 至 7,读/写 = x)是 I²C 协议用于在高速模式和较慢模式(标准、快速、快速+)之间切换的特殊地址。由于在 I²C 总线上实际使用高速模式的设备非常少,本书主要忽略了高速和超高速模式。有关高速控制器模式的更多信息,请参考 I²C 总线规范和用户手册(见“更多信息”)。
2.5.6 10 位外设寻址
对许多人来说,I²C 总线上的 7 位寻址方案,支持最多 112 个设备,似乎有限制。从多个角度来看,这实际上远远足够。然而,I²C 总线确实定义了一个特殊扩展,允许在总线上使用 10 位地址,从而在单个总线上增加最多 1,024 个地址(地址 = 0x78 至 0x7B)。
当特殊地址 0b1111000 (0x78)、0b1111001 (0x79)、0b1111010 (0x7A) 或 0b1111011 (0x7B) 作为启动条件后的第一个字节出现时,表示这是一个 2 字节地址序列的开始。该地址的低两个比特将成为结果的最重要(高位)2 位。随后会有第二个字节,包含地址的剩余(低位)8 位。有关详细信息,请参见图 2-8。

图 2-8:10 位地址格式
外设设备从以 0b11110aa 开头的两个字节构造 10 位地址。随后第三个字节开始的传输与使用 7 位地址的 I²C 传输相同,遵循第一个字节。
尽管拥有 10 位地址方案看起来合情合理,但在现实中几乎没有用。首先,很少有市售外设设备支持 10 位地址。理论上,你可以创建一个自定义外设设备来寻找 10 位地址。然而,这几乎没什么意义,因为在系统中找到一个未使用的 7 位地址并使用它可能同样容易。这样可以节省传输额外地址信息所需的额外 100 微秒(在 100 kHz 下),而 I²C 传输本身已经足够慢。
另一个问题是,112 个独特地址已经远远足够 I²C 总线使用。由于总线电容的限制,在同一物理总线上连接这么多设备几乎是不可能的。更不用说再添加 1,024 个设备了,这远远超出了总线的电气能力。
10 位地址的唯一优点是,它扩大了外设设计师在总线上放置多个相同设备的能力,从而减少了与其他设备发生地址冲突的可能性。例如,你可能希望将四个数模转换器放置在 I²C 总线上,但这样做可能会与其他你想使用的芯片发生地址冲突。使用 10 位地址可以给你更多的空间来分配外设的地址。然而,由于几乎没有外设 IC 支持扩展地址,这种方法并不实用。
请注意,如果你确实希望在设计中包括多个相同的 IC,并且可能发生地址冲突,另有一种解决方案:I²C 多路复用器。有关更多信息,请参见第十二章。
2.5.7 设备 ID
设备 ID 是另一个非常好的概念,但不幸的是,大部分设备并不支持该功能。其概念是让控制器传输“设备总线 ID”(0xF8,即地址 0x7C 加 R/W = 0),后面跟着外设地址。然后控制器进行重启,传输 0xF9(0x7C 加 R/W = 1),并从外设设备读取 3 字节数据:12 位指定制造商(由 NXP 半导体公司指定的值)、9 位指定部件号(由制造商分配),以及 3 位指定芯片(IC)版本。
从理论上讲,这将是一个很好的功能,可以用于识别总线上的设备。实际上,由于很少有芯片支持该功能,因此它几乎是一个没用的功能。然而,如果某个特定部件支持该功能,获取芯片版本信息还是有用的,因为它可能帮助你为该芯片的不同版本的 bug 编写工作绕过程序。
关于设备 ID 命令的更多信息,请参阅“更多信息”中的 I²C 总线规范和用户手册。
2.6 复位 I²C 总线
由于应用软件、设备驱动程序、固件或硬件中的错误,一些外设芯片可能会“锁死”并进入一个未知的,即非法的状态。有时,这意味着你失去了该芯片的功能,直到它被复位。更糟糕的是,有时该芯片会停机,同时拉低 SDA 或 SCL 线,导致从那时起 I²C 总线无法使用。在这种情况下,你将需要复位设备。
当然,你可以使用通用调用命令向系统中的所有设备发送复位命令。然而,这种方法存在一些问题:
-
并非所有设备都能响应通用调用/复位命令。
-
通用调用/复位命令会复位每一个正在监听该命令的设备。你可能不希望这样做,因为你需要重新初始化总线上的所有设备。
-
如果设备本身发生故障,可能无法响应通过 I²C 总线发送的软件命令。尤其是当它拉低 SDA 或 SCL 线时,命令将无法到达。
重置 I²C 总线需要硬件解决方案。一些设备支持 IC 上的复位引脚。通常,将该引脚拉低会重置设备并将其初始化为上电状态。一旦设备达到该状态,可能还需要进一步初始化,但这总比重置整个系统来恢复芯片要好。将设备的复位引脚连接到主 CPU 上可用的通用输入输出(GPIO) 引脚,可以在程序控制下通过编程方式重置设备。
当然,你可以将一条复位线连接到所有提供复位引脚的设备。但是,这会遇到与通用调用/复位命令相同的问题——你需要重置所有设备,并且必须重新初始化它们,才能修复单个 IC 的问题。
硬件复位方法的最大问题是,并非每个 I²C 外设都有复位引脚。对于没有复位引脚的设备,另一种解决方案是暂时切断 IC 的电源一段时间,然后重新恢复电源。这肯定会将芯片重置为上电状态。你可以使用一些晶体管来实现这一点。另一种解决方案是像 SparkFun Qwiic Power Switch(www.sparkfun.com/products/16740)这样的现成设备。这是一个 I²C 外设,允许你开关其他 I²C 设备的电源,无论是为了重置还是低功耗待机操作。
2.7 在总线上检测 I²C 外设
程序员常常希望确定某个外设是否存在于 I²C 总线上的某个地址。理论上,设备 ID 操作(参见本章前面的 2.5.7 节“设备 ID”)可以提供这一功能。控制器可以在总线上发送设备 ID 特殊地址(0xF8),然后跟随设备地址进行检查。如果响应是 NAK,则该地址没有设备;如果返回 ID 响应,则控制器知道该地址有设备存在,并且 ID 信息准确地指定了设备。
使用设备 ID 方法的唯一问题是,外设支持设备 ID 命令是可选的。如果外设不支持设备 ID 命令,它只是不会响应请求。因此,尽管外设实际存在,但设备 ID 命令会表明它不存在。由于大量 I²C 外设 IC 不支持设备 ID 命令,这并不是检测这些外设的实用方法。
一个常见的解决方案是发送一个启动条件,一个带有高 R/W 位的地址字节(即读取操作),然后立即发送停止条件,而不等待外设返回任何数据。外设将确认地址字节。然而,停止条件将阻止外设实际将数据传回控制器。控制器可以检查 ACK 或 NAK 并使用该响应来确定指定地址处是否有外设存在。这不会告诉控制器哪个外设存在,但至少会给出“有设备”或“没有设备”的响应。这就是许多工具(如 Linux 的 i2cdetect 工具)工作的方式。
采用这种检测方法会遇到几个阻碍性问题。一个问题是,根据标准,SMBus 外设(SMBus 是 I²C 总线的一个变种;参见第五章第 5.1 节“SMBus”)可以将 R/W 位作为数据元素。例如,R/W 位可以根据你是读取地址还是写入地址,打开或关闭某些外部设备。如果你向这样的外设发送一个读取命令,并立即跟随一个停止条件,它可能会改变该设备的状态。如果设备之前是关闭的,发送读取命令可能会将状态改为开启(假设外设直接将 R/W 位的值复制到状态中,其中 1 = 开启 = R,0 = 关闭 = W)。显然,使用读取命令来检测设备的存在是不可取的,因为检测过程也可能改变设备的状态。
解决这个问题的一种方法是同时使用设备 ID 和“带立即停止条件的读取”命令。SMBus 标准要求所有设备都支持设备 ID 命令。如果你首先发出设备 ID 命令,并且存在 SMBus 设备,它会以适当的标识信息进行响应。如果从设备 ID 收到 NAK,则表示该地址没有 SMBus 设备,你可以尝试读取操作,看是否收到响应。如果仍然收到 NAK,你可以假设该地址没有设备。
当然,这假设只有那些将 R/W 位作为数据的设备才会支持设备 ID 命令。我见过唯一将 R/W 位作为数据使用的地方是 SMBus 文档,所以这个假设可能是安全的。当然,如果你自己创建外设并使用 R/W 位作为数据,你应该支持设备 ID 命令,以便检测软件能绕过这个问题。
将读取命令放到总线上并检查响应的另一个问题是,只写设备可能不会响应读取命令。Linux 的 i2cdetect 工具通过对某些地址进行读取、对其他地址进行写入,并提供命令行选项以强制执行特定类型的检测算法来解决这个问题。有关该技术的更多信息,请查看 i2cdetect 应用程序的源代码(有关 i2cdetect 的更多详细信息,请参阅“更多信息”)。
关键是,没有完美的方法来检测 I²C 总线上的外设设备。某些方案可能无法检测到已连接的设备;而其他方案则可能改变已连接设备的状态。最终,最好的检测方法是设计:了解系统中安装了哪些设备,并针对这些设备进行有意的编程。
2.8 创建自定义设备
对于大多数实际应用,你可能能找到一个与 I²C 总线接口并完全符合需求的集成电路。然而,也有可能你的应用过于专业,以至于没有任何现成的外设能够满足。幸运的是,你不必仅仅依赖现成的零件:你还可以自己创建 I²C 外设设备。有几种方法可以做到这一点,本书将介绍其中的几种。例如,下一章将讨论如何完全通过软件实现 I²C 控制器和外设设备。后续章节将讨论如何利用各种 SBC 上的 I²C 硬件来创建这些设备。你的创意是唯一的限制。
2.9 章节总结
本章讨论了 I²C 总线数据的格式——I²C 协议。这包括对 I²C 总线上的数据、I²C 地址和读写控制的描述,以及启动和结束 I²C 传输的特殊模式(启动和停止条件)。本章还描述了一种小优化:重复启动条件,并介绍了如何通过时钟拉伸在 I²C 传输中引入等待状态。
I²C 总线支持通过特殊地址实现多个功能,包括通用调用地址、硬件通用调用、启动字节、高速控制、10 位外设寻址和设备 ID。有关详细信息,请参阅本章中的相关部分。
本章最后讨论了如何重置 I²C 总线并检测 I²C 总线上的外设设备,同时简要讨论了如何创建自定义 I²C 外设。
下一章使用本章的信息,描述如何通过软件实现 I²C 协议。该章中的代码还提供了 I²C 协议的另一种视角,帮助那些倾向于查看正式(代码)描述的人更好地理解该协议。
第三章:I²C 总线的软件实现

尽管广泛使用的大多数 I²C 功能是作为外围集成电路(IC)或 SBC 上的 CPU 的一部分提供的,但有时你可能需要为不提供 I²C 支持的硬件开发固件。在这种情况下,你将需要在软件中实现 I²C 协议。
本章展示了如何仅使用标准微控制器(MCU)上的 GPIO 引脚实现控制器和外围设备。我将以 Teensy 3.2 为例,尽管相同的原理适用于几乎任何具有至少两个可编程 I/O 引脚的设备。如果你想将这段代码用于其他 MCU,可能需要稍作调整和优化,特别是在性能较低、内存较少的 MCU 上,比如 Teensy 3.2。第十七章(在线阅读:bookofi2c.randallhyde.com)提供了这样的优化示例。
本章并未提供一个即插即用的、现成可用的基于软件的 I²C 库。几乎任何你将在需要 I²C 的环境中使用的 MCU 都提供硬件支持——Teensy 3.2 本身提供了两个独立的硬件 I²C 总线。也就是说,软件实现使硬件发生的具体情况更加清晰,因此你将从学习这段代码中受益。
3.1 在 Teensy 3.2 上的软件 I²C 实现
Teensy 3.2 是一款 32 位 ARM(Cortex M4)处理器,运行频率为 72 MHz,尽管通常会超频到 96 MHz。它具有 256KB 的闪存用于存储代码,64KB 的 RAM 用于存储数据,2,048KB 的 EEPROM 用于非易失性存储,并且提供大量 I/O 接口,包括三个 UART、两个 I²C 总线和一个 SPI 总线,所有这些都集成在一块小型的——呃,teensy,大约 1.4 英寸 x 0.7 英寸——PCB 上。Teensy 配有自己的 3.3V 稳压器,因此处理器以 3.3V 运行,但所有 I/O 引脚均支持 5V 容忍。通常,你通过 Arduino IDE 编程 Teensy,大多数 Arduino 代码都能在 Teensy 3.2 上运行。
在 Teensy 3.2 上实现 I²C 的软件主要是一个教育性练习:由于 Teensy 3.2 支持两个硬件 I²C 接口,因此没有太多理由运行基于软件的 I²C 系统。Teensy 非常强大,可以用 C/C++编写 I²C 模块,而无需频繁使用硬件特定的代码。本章中的大部分代码是标准 Arduino 代码,这比为低功耗 MCU 优化的 I²C 代码更容易接近和理解。
以下小节描述了 Teensy 上基于软件的 I²C 系统的两种变体:控制器实现和外围设备实现。对于那些有兴趣了解基于硬件的 I²C 实现的读者,请参阅第六章和第十一章第 11.1 节《Teensy 4.x控制器编程》。
3.1.1 Teensy 3.2 的软件 I²C 控制器
列表 3-1 中的代码实现了一个基于软件的 I²C 控制器,它通过使用 Arduino 库调用,在 Teensy 3.2 上运行。我将在列出各个代码段之间的文本中讨论每个函数及其代码段。
这段代码仅用于演示如何在软件中实现 I²C 控制器,因此不要将其视为用于生产的 I²C 库模块。它旨在以具体和正式的方式阐明 I²C 协议,仅用于教育目的。
作为测试示例,本程序从 Adafruit ADS1115 I²C ADC 模块的输入 A0 读取一个值,将二进制输入转换为与 MCP4725 对应的范围,然后将结果写入 SparkFun MCP4725 DAC 模块(请参阅 图 3-1 中的接线图)。

图 3-1:基于 Teensy 控制器的电路示例
输入电压范围为 0 V 到 4.1 V 时,应在 DAC 输出端产生相似的电压。此示例程序测试了使用基于软件的 I²C 控制器软件对 I²C 外设的读取和写入。
MCP4725_ADDR 和 ADS1115_ADDR 符号指定了这些模块的地址。DAC 地址应为 0x60 或 0x61,具体取决于 SparkFun 板上的地址跳线(图 3-1 中的原理图将 ADDR 引脚接地,从而选择地址 0x60)。请注意,尽管 列表 3-1 中的代码写入了 SparkFun 板,你也可以写入 Adafruit MCP4725 扩展板。在这种情况下,DAC 地址为 0x62 或 0x63,具体取决于扩展板上的地址设置。ADS1115 地址为 0x48、0x49、0x4A 或 0x4B,具体取决于扩展板上的地址引脚跳线设置;图 3-1 中的原理图假设你将 ADDR 引脚接地使用地址 0x48。有关更多详细信息,请参阅 Adafruit 文档(在本章末尾的“更多信息”部分)。
以下代码片段开始于 列表 3-1,在本节中继续,并穿插有注释和说明。你可以在 bookofi2c.randallhyde.com 找到完整的代码文件 Listing3-1.ino。
// Listing3-1.ino
//
// Software-based I2C controller device for
// the Teensy 3.2.
//
// Copyright 2020, Randall Hyde.
// All rights reserved.
// Released under Creative Commons 4.0.
#include <arduino.h>
// I2C address of the SparkFun MCP4725 I2C-based
// digital-to-analog converter.
#define MCP4725_ADDR 0x60
#define ADS1115_ADDR 0x48
// Pins on the Teensy 3.2 to use for the
// software SCL and SDA lines:
❶ #define SCL 0
#define SDA 1
// digitalWriteFast is a Teensy-specific function. Change
// to digitalWrite for standard Arduino.
❷ #define pinWrite digitalWriteFast
SCL 和 SDA 符号定义了用于 I²C 时钟和数据线的 Arduino 基础引脚号 ❶。引脚 0 和 1 是任意选择的。任何可用的数字 I/O 引脚都可以正常使用。
pinWrite 符号映射到 Arduino 兼容的 digitalWrite() 函数 ❷。在正常情况下,这将是 digitalWrite() 函数本身。然而,Teensy 库有一个特殊的函数 digitalWriteFast(),它与 Arduino digitalWrite() 函数兼容调用,但通过将 pinWrite() 映射到 Teensy 函数,运行速度大约是原来的三倍。如果你打算在不同的 MCU 上运行此代码,请将定义更改为 digitalWrite()。
I²C 的 SDA 和 SCL 引脚是 双向 的;也就是说,控制器必须能够从这两条引脚读取数据,同时也能向它们写入数据。一般来说,Arduino 的 GPIO 引脚要么是输入,要么是输出,但不能同时兼具两者功能。为了模拟双向 I/O,Teensy 3.2 基于软件的 I²C 模块利用了 Arduino 兼容 GPIO 引脚可以在输入和输出之间动态切换的特性。在大多数情况下,控制器总是知道信号线何时需要为输入或输出,因此它可以即时切换引脚模式,以适应 I²C 总线的需求。
// Listing3-1.ino (cont.):
//
// Pin set functions.
//
// setSCL-
//
// Sets the SCL pin high (1) by changing the pin mode to
// input and relying on the I2C bus pullup resistor to
// put a 1 on the bus.
❶ void setSCL( void )
{
pinMode( SCL, INPUT );
}
// clrSCL-
//
// Sets the SCL pin low by changing the pin mode to output and
// writing a 0 to the pin. This will pull down the SCL line.
❷ void clrSCL( void )
{
pinMode( SCL, OUTPUT );
pinWrite( SCL, 0 );
}
// setSDA, clrSDA-
//
// Same as setSCL and clrSCL except they set/clr the SDA line.
❸ void setSDA( void )
{
pinMode( SDA, INPUT );
}
❹ void clrSDA( void )
{
pinMode( SDA, OUTPUT );
pinWrite( SDA, 0 );
}
setSCL() ❶、clrSCL() ❷、setSDA() ❸ 和 clrSDA() ❹ 函数负责在 SCL 和 SDA 引脚上写入 0 或 1。向任一引脚写入 1 的过程是将相应的引脚切换为输入模式。这会将引脚置于高阻抗状态(开集电极或三态),而不会在引脚上输出实际信号。引脚上的上拉电阻随后将引脚拉高(1)。向任一引脚写入 0 的过程是将引脚模式更改为输出模式,然后将 0 写入该引脚。这会将引脚拉低,即使在有上拉电阻的情况下也是如此。
当你没有主动向 SCL 和 SDA 引脚写入 0 时,保持这些引脚的高电平是非常重要的。这不仅是软件的要求,也是 I²C 总线的一般要求——记住,其他设备可能正在试图将这些引脚拉低。
// Listing3-1.ino (cont.):
//
// Reading the SCL and SDA pins.
//
// readSCL-
//
// Reads SCL pin until it gets the same value twice in
// a row. This is done to filter noise.
❶ inline byte readSCL( void )
{
byte first;
byte second;
do
{
first = digitalRead( SCL );
second = digitalRead( SCL );
}while( first != second );
return first;
}
❷ // readSDA-
//
// Reads SDA pin until it gets the same value twice in
// a row. This is done to filter noise.
inline byte readSDA( void )
{
byte first;
byte second;
do
{
first = digitalRead( SDA );
second = digitalRead( SDA );
❸ }while( first != second );
return first;
}
readSCL() 函数❶读取当前 SCL 引脚上的数据。readSDA() 函数❷读取当前 SDA 引脚上的数据。I²C 标准要求对输入进行滤波,以去除持续时间小于或等于 50 纳秒(ns)的任何毛刺❸。通常,这通过主动滤波器(硬件)设计来实现。虽然可以将这样的硬件附加到微控制器的引脚上,但此软件实现的 I²C 包通过在软件中进行毛刺滤波来实现,方法是将所有输入读取两次,并且仅在连续两次读取相同值时返回。在大多数微处理器上,这将滤除长度明显大于 50 纳秒的毛刺。然而,由于该软件实现仅处理标准速度 I²C 操作(100 kHz),所以任何小于一微秒的信号都可能被视为噪声。
这些函数在读取之前并不会将引脚切换为输入模式。大多数情况下,这些函数会连续被调用几次,因此让调用者设置引脚模式比每次调用时让这些函数设置模式更高效。此外,有些代码调用这些函数来验证信号线在设置为 0 后是否已实际达到最终状态(处理由于总线电容等造成的延迟)。
// Listing3-1.ino (cont.):
//
// Setting the start condition on the SCL and SDA lines.
//
// setStartCond-
//
// First checks to see if the bus is being used, which requires
// a full 10 usec. If the bus is being used by some other bus
// controller, this function returns false.
//
// If the bus is not being used, this function issues a start
// condition for 5 usec (SDA = 0, SCL = 1) and then raises SDL
// in preparation for an address byte transmission. If it
// successfully sends a start condition, this code returns true.
//
// Postcondition:
// SDA and SCL will both be low if this function is
// successful. They will be unaffected if this function
// returns false.
❶ int setStartCond( void )
{
byte bothPins;
pinMode( SDA, INPUT ); // Going to be reading pins
pinMode( SCL, INPUT );
bothPins = readSDA() && readSCL();
❷ delayMicroseconds(1);
bothPins &= readSDA() && readSCL();
delayMicroseconds(1);
bothPins &= readSDA() && readSCL();
delayMicroseconds(1);
bothPins &= readSDA() && readSCL();
delayMicroseconds(1);
bothPins &= readSDA() && readSCL();
delayMicroseconds(1);
bothPins &= readSDA() && readSCL();
delayMicroseconds(1);
bothPins &= readSDA() && readSCL();
delayMicroseconds(1);
bothPins &= readSDA() && readSCL();
delayMicroseconds(1);
bothPins &= readSDA() && readSCL();
delayMicroseconds(1);
bothPins &= readSDA() && readSCL();
if( bothPins )
{
// Both pins have remained high for around 10 usec
// (one I2C clock period at 100 kHz). Chances
// are, the bus isn't currently being used.
// Go ahead and signal the start condition
// by setting SDA = 0.
❸ clrSDA();
delayMicroseconds( 4 );
clrSCL();
return 1; // In theory, this code has the bus
}
return 0; // Bus is busy
}
setStartCond() ❶ 函数允许调用者控制 I²C 总线。该函数处理两个主要任务:确保总线当前未被使用,然后如果总线可用,它会在总线上发送启动信号以占用总线。
为了检查总线是否已被使用,setStartCond() ❷ 函数每微秒检查一次 SCL 和 SDA 线,持续 10 微秒。如果在这 10 微秒内,任一线路为低(或变为低),则表示总线正在使用中,此函数返回失败(0)。如果两条线在此期间始终为高,说明总线空闲,代码可以占用总线进行使用。
为了获取总线,代码在总线上设置启动条件 ❸。该启动条件以两条线高电平持续 5 微秒(半个时钟周期)开始,接着 SDA 线从高电平转为低电平,然后 SDA 为低电平,SCL 为高电平,持续半个时钟周期(见 图 3-2)。

图 3-2:启动条件
如果 setStartCond() 函数成功地在总线上设置了启动条件,它会返回 1 作为函数结果。当此函数返回给调用者时,调用者会检查返回结果,以确定是否可以开始使用 I²C 总线,或者是否需要等待并尝试再次获取总线。
// Listing3-1.ino (cont.):
//
// Outputting a stop condition on the SCL and SDA lines.
//
// setStopCond-
//
// Generates an end-of-transmission stop sequence.
//
// Precondition:
// SCL must be low when this is called.
// Postcondition:
// SCL and SDA will be high.
❶ void setStopCond( void )
{
clrSDA(); // Initialize for stop condition
delayMicroseconds( 1 ); // Give SDA time to go high
setSCL();
while( !readSCL() )
{
// Clock stretching-
//
// Wait while the peripheral is holding the clock
// line low.
}
delayMicroseconds( 4 ); // SCL = 1, SDA = 0 for 5 usec
setSDA(); // Signal stop condition
}
当软件完成数据传输或接收,并准备放弃 I²C 总线时,它必须在总线上设置停止条件,如 setStopCond() 代码 ❶ 所示。
图 3-3 显示了在 I²C 总线上出现的停止条件。setStopCode() 函数将 SCL 线拉高(同时 SDA 线为低),然后 5 微秒后再次拉高 SCL 线。

图 3-3:I²C 停止条件
以下代码演示了如何检测并等待停止条件。
// Listing3-1.ino (cont.):
//
// Waiting for the stop condition to occur.
//
// waitForStop-
//
// If the bus is busy when this controller
// tries to use the I2C bus, this code
// must wait until a stop condition occurs
// before trying to use the bus again.
//
// Stop condition is:
// SCL is high.
// SDA goes from low to high.
void waitForStop( void )
{
setSCL(); // Just make sure these are high;
setSDA(); // they already should be
do
{
while( !(readSCL() && !readSDA()) )
{
// Wait until the SCL line is high
// and the SDA line is low
}
// Stop condition might have begun. Wait
// for the data line to go high while
// the SCL line remains high:
while( !readSDA() && readSCL() )
{
// Wait for data line to go high
}
// Is the SCL line still high?
// If not, you are just getting
// some data and the code needs to
// repeat until SCL is high again.
}while( !readSCL() );
}
如果其他控制器已经在使用 I²C 总线,软件必须等待直到该控制器完成总线操作。这发生在该控制器在总线上放置停止条件时。waitForStop() 函数监视总线,并等待停止条件出现(SCL 为高,SDA 从低转为高)。
// Listing3-1.ino (cont.):
//
// Transmitting a single bit on the I2C bus.
//
// sdaOut-
//
// bit:
// Bit to transmit.
// Transmits a single bit over the SDA/SCL lines.
//
// Returns:
// 1: If successful.
// 0: If arbitration failure or other error.
//
// Note:
// Caller is responsible for setting SCL and SDA
// high if there is an arbitration fault.
int sdaOut( byte bit )
{
bit = !!bit; // Force 0/1
// Take SCL low so you can write to the
// data line. Wait until SCL is actually
// low before proceeding:
❶ clrSCL();
while( readSCL() );
// Set the SDA line appropriately:
❷ if( bit )
{
setSDA();
}
else
{
clrSDA();
}
// Wait for 1/2 of the I2C clock period
// while SCL is low:
❸ delayMicroseconds( 3 );
// Check to see if the value put on
// the SDA line can be read back. The code
// needed to delay before this call in order
// to allow signal time to rise on the
// SDA line.
❹ if( readSDA() != bit )
{
// If the bit just written does not
// match the bit just read, then
// the code must have written a 1 and
// some other controller has written
// a 0 to the SDA line. In this
// case, the controller loses the
// arbitration test.
return 0;
}
// Raise the SCL line to indicate to the
// peripheral that the data is valid:
❺ setSCL();
// Must hold SCL line high for 5 usec:
delayMicroseconds( 4 );
// Clock stretching or synchronization
// is handled here. Wait for the SCL
// line to go high (it can be held
// low by the peripheral or by another
// controller):
❻ while( !readSCL() )
{
// Wait for SCL to go high
}
// Return success
return 1;
}
sdaOut() 函数向 I²C 总线写入单个比特。向 I²C 总线写入比特的过程包括以下步骤:
-
将 SCL 线设置为低并验证其是否为低 ❶。除了启动和停止条件外,SDA 线只能在 SCL 线为低时发生变化。
-
将位设置到 SDA 线 ❷。
-
等待大约半个时钟周期 ❸。
-
验证 SDA 线上的数据是否与刚刚写入的数据匹配(即验证总线争用没有发生)。如果数据不匹配,则失败(返回 0)❹。
-
将 SCL 线设置为高 ❺。
-
等待 SCL 线变高(时钟延展和同步)❻。
总线争用发生在两个控制器同时尝试访问总线时。如果此代码将 1 写入 SDA 线并读取回 0,表示另一个控制器正在写入 0,从而发生总线争用。I²C 仲裁规则是“谁写 0,谁赢”。如果此代码返回失败给调用者,必须停止传输,并在下一个停止条件到来时重新启动传输。
// Listing3-1.ino (cont.):
//
// Transmitting a byte on the I2C bus.
//
// xmitByte-
//
// Transmits a byte across the I2C bus.
//
// Returns:
// 1: If ACK received after the transmission.
// 0: If NAK received after the transmission or
// if there was bus contention (and this code
// has to give up the bus).
//
// Precondition:
// SCL must be low.
//
// Postcondition:
// If arbitration failure, SDA and SCL will
// both be high (to avoid conflicts with some
// other controller).
//
// If successful:
// SCL will be low.
int xmitByte( byte xmit )
{
❶ int result = sdaOut( xmit & 0x80 ); // MSB first!
if( result )
result = sdaOut( xmit & 0x40 ); // Bit 6
if( result )
result = sdaOut( xmit & 0x20 ); // Bit 5
if( result )
result = sdaOut( xmit & 0x10 ); // Bit 4
if( result )
result = sdaOut( xmit & 0x8 ); // Bit 3
if( result )
result = sdaOut( xmit & 0x4 ); // Bit 2
if( result )
result = sdaOut( xmit & 0x2 ); // Bit 1
if( result )
result = sdaOut( xmit & 0x1 ); // Bit 0
if( result )
{
// And now the code must wait for
// the acknowledge bit:
❷ clrSCL();
delayMicroseconds( 1 );
pinMode( SDA, INPUT ); // It's an input
delayMicroseconds( 3 ); // 1/2 clock cycle
// Raise the clock line and wait for it
// to go high, which also handles clock
// stretching and synchronization.
setSCL(); // Raise clock line
while( !readSCL() );
// Note that the clock line is high, so
// this code can read the SDA bit (acknowledge).
delayMicroseconds( 3 ); // Data valid for 5 usec
result = readSDA();
❸ clrSCL(); // Exit with SCL = 0
while( readSCL() );
return !result;
}
// If there is an arbitration failure,
// then try to transmit a 1 bit while the
// other controller transmits a 0 bit.
// The 0 bit always wins, so this function
// sets SDA and SCL to 1 to avoid creating
// other problems for the other controller.
setSCL();
setSDA();
return 0; // Arbitration failure
}
xmitByte() 函数通过 I²C 总线传输一个完整的字节。显然,此函数调用 sdaOut() 八次来传输这 8 位 ❶。根据 I²C 标准,此代码按从 MSB 到 LSB 的顺序发送比特。如果任何一次调用 sdaOut() 返回失败,此函数也将返回失败。
字节传输结束时,代码将 SDA 线设为高电平,并在 SCL 线上发出额外的脉冲 ❷。在 SCL 为高电平时,代码读取 SDA 线。这会获取外设的确认位(0),或者如果没有确认,则为默认的 NAK(1)。如果传输得到正确确认,函数返回 true,否则返回 false。
在读取确认位后,代码将 SCL 设为低电平并持续读取该线,等待其实际变为低电平 ❸。
// Listing3-1.ino (cont.):
//
// Transmitting a sequence of bytes on the I2C bus.
//
// xmitBytes-
//
// Transmit a block of bytes (in write mode)
// via the I2C bus. adrs is the I2C device
// address. bytes is the array of bytes
// to transmit (after the address byte).
// cnt is the number of bytes to transmit
// from the bytes array. addStop is true
// if this function is to add a stop condition
// at the end of the transmission.
//
// Note that, including the address byte,
// this function actually transmits cnt + 1
// bytes over the bus.
int xmitBytes
(
byte adrs,
byte bytes[],
int cnt,
int addStop
)
{
int result;
// Send the start condition.
result = setStartCond();
if( result )
{
// If bus was not in use, transmit
// the address byte:
result = xmitByte( adrs << 1 );
if( result )
{
// If there wasn't bus contention,
// ship out the bytes (as long as
// bus contention does not occur):
for( int i=0; i < cnt; ++i )
{
result = xmitByte( bytes[i] );
if( !result ) break;
}
}
// If the transmission was correct to this
// point, transmit the stop condition.
// Note: if addStop() is false, don't send a
// stop condition after this transmission
// because a repeated start is about
// to happen.
if( result && addStop )
{
setStopCond();
}
}
return result;
}
xmitBytes() 函数处理完整的 I²C 写入传输。调用者传递给它外设的 I²C 地址、一个字节数组(以及一个计数),以及一个特殊的“停止”标志,函数会发送适当的起始条件和地址字节,并写入所有数据字节。如果 addStop 标志为 true,则该函数还会在传输结束时附加一个停止条件。如果 addStop 为 false,则该函数在没有停止条件的情况下传输数据——可能是因为你希望保持 I²C 总线并在稍后发出重复的起始条件。
此函数根据传输的成功或失败返回 true 或 false。如果此函数返回 false,可能是总线已被占用或发生了总线争用,并且该代码未能赢得仲裁。无论原因是什么,如果此函数返回 false,调用者必须稍后重试传输。
// Listing3-1.ino (cont.):
//
// Receiving a single bit on the I2C bus.
//
// sdaIn-
//
// Retrieves a single bit from the SDA line.
byte sdaIn( void )
{
// Take SCL low before writing to the
// data line. Wait until SCL is actually
// low before proceeding:
clrSCL();
while( readSCL() );
// Wait for 1/2 clock period for
// the peripheral to put the data
// on the SDA line:
delayMicroseconds( 4 );
// Bring the clock line high.
setSCL();
// Wait until it actually goes high
// (stretching or syncing might be
// happening here).
while( !readSCL() );
// Wait for 1/2 of the I2C clock period
// while SCL is high:
delayMicroseconds( 3 );
// Read the data from the SDA line:
byte input = readSDA();
// Hold SCL line high for the
// remainder of this 1/2
// clock period:
delayMicroseconds( 2 );
// Return result.
return input;
}
sdaIn() 函数从 I²C 总线读取单个位。此函数类似于 sdaOut(),不同之处在于它是从 SDA 读取数据,而不是写入数据。此外,读取时无需检查仲裁失败,尽管该函数仍然处理时钟拉伸和同步。虽然数据来自外设,但仍由控制器负责驱动 SCL 线上的时钟信号。
// Listing3-1.ino (cont.):
//
// Receiving a byte on the I2C bus.
//
// rcvByte-
//
// Receives a byte from the I2C bus.
//
// Precondition:
// SCL must be low.
// Postcondition:
// SCL will be low.
byte rcvByte( void )
{
setSDA(); // Before reading inputs
byte result = sdaIn() 7;
result |= sdaIn() << 6;
result |= sdaIn() << 5;
result |= sdaIn() << 4;
result |= sdaIn() << 3;
result |= sdaIn() << 2;
result |= sdaIn() << 1;
result |= sdaIn();
// Generate the ACK bit:
clrSCL();
while( readSCL() ); // Wait until it's low
delayMicroseconds( 2 );
clrSDA();
delayMicroseconds( 2 );
setSCL();
while( !readSCL() )
{
// Wait until SCL goes high (could be
// waiting for stretching or syncing).
}
delayMicroseconds( 4 );
// Leave SCL low for the next byte
// or the beginning of the stop
// condition:
clrSCL();
return result;
}
rcvByte() 函数通过调用 sdaIn() 函数八次从 I²C 总线读取一个 8 位字节。在函数读取完这 8 位之后,控制器需要在 SDA 线上放置 ACK 信号(即 0),以告知外设所有操作顺利完成。此函数返回读取的字节作为函数结果。
// Listing3-1.ino (cont.):
//
// Receiving a sequence of bytes on the I2C bus.
int rcvBytes( byte adrs, byte bytes[], int cnt, int addStop )
{
int result;
// Send the start condition.
result = setStartCond();
if( result )
{
// If bus was not in use, transmit
// the address byte:
result = xmitByte( (adrs << 1) | 1 );
if( result )
{
// Read the specified number of
// bytes from the bus:
for( int i=0; i < cnt; ++i )
{
bytes[i] = rcvByte();
}
}
// If no errors at this point, transmit
// the stop condition.
// Note: if addStop is false, don't send
// a stop condition after this transmission
// because a repeated start is about
// to happen.
if( result && addStop )
{
setStopCond();
}
}
return result;
}
rcvBytes() 函数是 xmitBytes() 函数的输入模拟。它获取 I²C 总线,传输一个启动条件;发送带有高 R/W 位的地址字节;然后从外围设备接收指定数量的字节。可选地,接收字节后,此函数会传输一个停止条件。
直到目前为止,Listing3-1.ino 的代码是实现基于软件的 I²C 控制器所需的完整例程。Listing3-1.ino 的其余部分包含了常规的 Arduino 初始化(setup)和主 loop 函数。
// Listing3-1.ino (cont.):
//
// Arduino setup() function for Listing3-1.ino.
//
// Standard Arduino initialization code:
void setup( void )
{
pinMode( SCL, INPUT ); // Begin with SCL/SDA = 1
pinMode( SDA, INPUT );
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "teensyTest" );
}
setup() 函数将 SCL 和 SDA 引脚设置为输入,以便它们保持在高电平状态,避免与其他控制器产生干扰。
// Listing3-1.ino (cont.):
//
// Arduino main loop() function for Listing3-1.ino.
//
// Arduino main loop:
void loop( void )
{
static int result;
static word adcValue;
static byte bytes[16];
// Read a 12-bit value from
// an Adafruit ADS1115 breakout
// board. The following configuration
// is for:
//
// * AIN[0]
// * 0-4.096 V operation
// * 1600 samples/second
// * Disabled comparator
adcValue = 0;
bytes[0] = 1; // Point at config reg
bytes[1] = 0xc2; // MSB of config
bytes[2] = 0x03; // LSB of config
// adcValue = ADS1115.readADC_SingleEnded( 0 );
// Serial.print( "ADC: ");
// Serial.println( adcValue, 16 );
result = xmitBytes( ADS1115_ADDR, bytes, 3, true );
if( result )
{
// Point at the conversion register. Note that
// this is a repeated start condition command
// but ends with a stop condition.
bytes[0] = 0;
result = xmitBytes( ADS1115_ADDR, bytes, 1, true );
// Read the ADC value from the ADS1115.
if( result )
{
// This really should go into a loop
// testing bit 16 of the config (status)
// register, but this is easier:
delay( 1 );
result = rcvBytes( ADS1115_ADDR, bytes, 2, true );
if( result )
{
adcValue = (bytes[0] << 8) | bytes[1];
}
}
}
// Start by writing 64 (0x40) to
// the DAC register (command byte
// which states that the next two
// bytes go into the DAC register).
bytes[0] = 64;
// The next two bytes to write are the
// 12 bits of the DAC value. The HO
// 4 bits are put in the first byte
// and the LO 8 bits appear in the
// second byte.
float volts = (((float) adcValue) * 4.096 / 32768.0 );
Serial.print( "Volts: " ); Serial.print( volts, 3 );
adcValue = (word) (volts * 65536.0/5);
bytes[1] = (adcValue >> 8) & 0xff;
bytes[2] = (adcValue & 0xf0);
// Transmit the data to the DAC IC:
if( !xmitBytes( MCP4725_ADDR, bytes, 3, true ) )
{
// If there was an arbitration failure,
// wait for a start condition to come along.
waitForStop();
}
Serial.println();
delay( 100 );
}
主 loop() 函数从 ADS1115 模拟到数字转换器(ADC)读取数据,翻译输入,并将数据写入 MCP4725 数字到模拟转换器(DAC)。尽管转换不是完美的,但这有效地将 ADC 上 A0 的输入电压复制到 DAC 上的模拟输出。
如前所述,这段代码只是一个示例,用来演示如何在软件中实现 I²C 控制器,因此并不真正打算用于实际应用。一方面,它是使用标准函数编写的,而不是类和方法,这使得它更容易理解,但使用起来更困难。它也可能无法直接移植到其他 MCU;尽管我主要使用标准 Arduino 调用编写了这段代码,但我通过逻辑分析仪运行了它,并手动调节所有延迟,以在 96 MHz 的 Teensy 3.2 上产生合理的时序。我怀疑在其他更快或更慢的 MCU 上,时序值可能会有所不同。
这段代码的另一个问题是,它使用一对 I/O 引脚作为 SDA 和 SCL 线。在引脚上不断改变数据方向以使其双向是软件 I²C 实现中的标准做法。虽然这种方法在单控制器环境中以及与不实现太多可选功能的外围设备配合使用时可能效果很好,但我不确定它在多控制器环境中能否完美工作。不幸的是,可能存在的竞争条件——基于程序执行时序的可能错误计算——很难创建(用于测试)。
3.1.2 基于软件的 Teensy 3.2 I²C 外围设备
上一节提供了 I²C 控制器设备的软实现。本节提供了该实现的配套部分:一个软件控制的 I²C 外围设备。本节中的代码将 Teensy 3.2 转换为具有以下特性的 I²C 外围设备:
-
它保存任何写入的数据字节值。
-
当控制器从中读取一个字节时,它返回最后写入的值,如果没有先前写入的字节,则返回 0。
实际上,这段代码将 Teensy 转变为一个 1 字节的 I²C 存储单元。虽然这是一个简单的 I²C 外设,但它完全展示了你开发自己基于软件的 I²C 外设所需的一切。
在许多方面,外设的软件更容易编写。外设无需担心总线争用、时钟同步等问题。另一方面,外设需要响应几个控制器不必担心的可选消息。
以下代码忽略了所有保留的地址值。对于通用调用地址复位功能,可能合理将存储的内存值设置为 0(尽管如果你想的话,可以轻松地将 0 写入该外设)。你也可以为这个外设创建一个设备 ID;我将这个任务留给你。
列表 3-2 提供了这个简单外设的源代码。
// Listing3-2.ino
//
// Software-based I2C peripheral device for
// the Teensy 3.2.
//
// Copyright 2020, Randall Hyde.
// All rights reserved.
// Released under Creative Commons 4.0.
#include <arduino.h>
// Pins on the Teensy 3.2 to use for the
// software SCL and SDA lines:
#define SCL 0
#define SDA 1
// PeriphAddress is the address of this PeripheralAddress.
#define PeriphAddress (0x50)
#define PeripheralAddress ((PeriphAddress) << 1)
// digitalWriteFast is a Teensy-specific function. Change
// to digitalWrite for standard Arduino.
// Likewise, digitalReadFast changes to digitalRead for
// standard Arduino.
#define pinWrite digitalWriteFast
#define pinRead digitalReadFast
至于控制器代码,两个标识符 SCL 和 SDA 定义了 Teensy 3.2 上将用于 SCL 和 SDA 线的引脚编号。PeriphAddress 定义指定了该外设响应的 I²C 地址。为了完成定义,pinRead 和 pinWrite 扩展为 Teensy 特定的(快速)版本的 Arduino digitalRead() 和 digitalWrite() 函数。
// Listing3-2.ino (cont):
//
// Pin control functions.
//
// setSCL-
//
// Sets the SCL pin high (1) by changing the pin mode
// to input and relying on the I2C bus pullup resistor
// to put a 1 on the bus.
❶ void setSCL( void )
{
pinMode( SCL, INPUT );
}
// clrSCL-
//
// Sets the SCL pin low. Changes the pin mode to output and
// writes a 0 to the pin to pull down the SCL line. Used
// mainly for clock stretching.
❷ void clrSCL( void )
{
pinMode( SCL, OUTPUT );
pinWrite( SCL, 0 );
}
// setSDA, clrSDA-
//
// Same as setSCL and clrSCL except they set or clr the SDA line.
❸ void setSDA( void )
{
pinMode( SDA, INPUT );
}
❹ void clrSDA( void )
{
pinMode( SDA, OUTPUT );
pinWrite( SDA, 0 );
}
❺ // readSCL-
//
// Reads SCL pin until it gets the same value twice in
// a row. This is done to filter noise.
inline byte readSCL( void )
{
byte first;
byte second;
do
{
first = pinRead( SCL );
second = pinRead( SCL );
}while( first != second );
return first;
}
❻ // readSDA-
//
// Reads SDA pin until it gets the same value twice in
// a row. This is done to filter noise.
inline byte readSDA( void )
{
byte first;
byte second;
do
{
first = pinRead( SDA );
second = pinRead( SDA );
}while( first != second );
return first;
}
setSCL() ❶、clrSCL() ❷、setSDA() ❸ 和 clrSDA() ❹ 函数直接从控制器代码复制过来;它们设置或清除 SCL 和 SDA 线。同样,readSCL() ❺ 和 readSDA() ❻ 函数(同样来自控制器代码)读取 SDA 和 SCL 线上的当前值。更多细节请参考 Listing3-1.ino 中以 Pin set functions 和 Reading the SCL and SDA pins 注释开头的部分。
// Listing3-2.ino (cont.):
//
// Transmitting a single bit on the I2C bus.
//
// sdaOut-
//
// bit: Bit to transmit.
// Transmits a single bit over the SDA/SCL lines.
//
// Returns:
// 1: If successful.
// 0: If arbitration failure or other error.
void sdaOut( byte bit )
{
unsigned long time;
bit = !!bit; // Force 0/1
// Wait until SCL is low.
// It's okay to change SDA
// when SCL is low:
❶ while( readSCL() );
// Set the SDA line appropriately.
❷ if( bit )
{
setSDA();
}
else
{
clrSDA();
}
// Wait for the SCL line to go high and
// then back to low. After that, release
// the SDA line by setting it to 1.
❸ while( !readSCL() );
time = micros() + 15;
while( readSCL() )
{
// If stuck in this loop for
// more than 15 usec, then bail.
// Need a timeout so it doesn't
// hold SDA low for an extended
// period of time.
❹ if( micros() > time ) break;
}
// Release the SDA line by setting it high.
setSDA();
}
sdaOut() 函数在响应 SCL 时钟过渡时,将一个作为参数传递的单个位放置到 SDA 线上。与控制器代码不同,外设代码不控制 SCL 线。相反,控制器必须脉冲时钟线。
-
sdaOut()函数必须等待时钟线变低 ❶。 -
然后,它可以将数据写入 SDA 线 ❷。
-
最后,它等待 SCL 线变高然后再变低,之后返回 ❸。
注意,这段代码在等待 SCL 线变高时有一个超时机制。如果由于某种原因,控制器没有将 SCL 线重新拉高,这段代码将在大约 15 微秒后跳出等待循环,而不是一直挂起 ❹。
// Listing3-2.ino (cont.):
//
// Transmitting a byte on the I2C bus.
//
// xmitByte-
//
// Transmits a whole byte by call sdaOut
// eight times.
void xmitByte( byte xmit )
{
unsigned long time;
❶ sdaOut( xmit & 0x80 );
sdaOut( xmit & 0x40 );
sdaOut( xmit & 0x20 );
sdaOut( xmit & 0x10 );
sdaOut( xmit & 0x8 );
sdaOut( xmit & 0x4 );
sdaOut( xmit & 0x2 );
sdaOut( xmit & 0x1 );
// The controller will generate the ACK
// bit. This code will ignore it.
// However, it does have to wait for
// the clock pulse (low->high->low)
// to come along.
❷ while( readSCL() ); // Wait for low clock
time = micros()+25;
while( !readSCL() )
{
// Wait until SCL goes high (could be
// waiting for stretching or syncing).
// Bail if there is a timeout, though.
❸ if( micros() > time ) break;
}
// Okay, SCL is (probably) high; wait for it
// to go low again:
while( readSCL() );
}
xmitByte() 函数通过调用 sdaOut() 八次在 SDA 线上发送一个 8 位字节 ❶。这段代码还会消耗第九位的时钟周期——确认位 ❷——尽管它忽略了该位的状态,因为一些控制器不会将 ACK 位放到 SDA 线上。这段代码也有一个超时机制 ❸,以防在等待确认位时 SCL 线始终未变高。
// Listing3-2.ino (cont.):
//
// Receiving a single bit on the I2C bus.
//
// sdaIn-
//
// Retrieves a single bit from the SDA line.
// Note: no timeout on the loops because this
// code doesn't mess with the SDA line.
byte sdaIn( void )
{
byte input;
❶ while( readSCL() );
// Wait until the SCL line is high.
// That is when data will be valid
// on the SDA line:
❷ while( !readSCL() );
// Wait for a small amount of time for the
// controller's data to be stabilized
// on the SDA line:
❸ delayMicroseconds( 1 );
// Read the data from the SDA line:
input = readSDA();
// Return result:
return input;
}
sdaIn() 函数用于读取 SDA 线路上的一个比特。它首先等待 SCL 线路变为低电平 ❶,如果它已经是低电平则不做处理。然后它等待 SCL 线路变为高电平(读取周期的开始),因为只有在时钟线为高电平时,SDA 数据才是有效的 ❷。一旦时钟线变为高电平,该函数会延迟一个微秒,以便给数据时间稳定,然后从 SDA 线路读取数据 ❸。
// Listing3-2.ino (cont.):
//
// Receiving a byte on the I2C bus.
//
// rcvByte-
//
// Receives a byte from the I2C bus.
byte rcvByte( void )
{
unsigned long time;
pinMode( SDA, INPUT );
// Read 8 bits from the SDA line:
❶ byte result7 = sdaIn() << 7;
byte result6 = sdaIn() << 6;
byte result5 = sdaIn() << 5;
byte result4 = sdaIn() << 4;
byte result3 = sdaIn() << 3;
byte result2 = sdaIn() << 2;
byte result1 = sdaIn() << 1;
byte result0 = sdaIn();
byte result = result7
| result6
| result5
| result4
| result3
| result2
| result1
| result0;
// Generate the ACK bit.
// Wait for the SCL line to go low,
// pull SDA low, then wait for the
// SCL line to go high and low:
while( readSCL() );
❷ clrSDA();
time = micros()+25;
while( !readSCL() )
{
// Wait until SCL goes high (could be
// waiting for stretching or syncing).
// Bail if there is a timeout, though.
if( micros() > time ) break;
}
// Okay, SCL is (probably) high; wait for it to go
// low again and then release the SDA line:
while( readSCL() );
setSDA(); // Set SDA high (releases SDA)
return result;
}
rcvByte() 函数调用 sdaIn() 函数八次,以从 I²C 总线读取一个字节 ❶。在这 8 位数据 ❷ 结束时,函数必须将 SDA 线路拉低以确认数据:即 ACK 位。该函数在 SCL 线路为低电平时将 SDA 线路拉低,并在 SCL 线路为高电平时释放 SDA 线路,同时执行常规的超时检查,以防控制器将 SCL 线路保持高电平过长时间。
// Listing3-2.ino (cont.):
//
// Waiting for a start condition, while allowing other work.
//
// waitForStart-
//
// Wait until a start condition arrives.
// The peripheral address byte will follow.
//
// Start condition is:
//
// SCL is high.
// SDA goes from high to low.
//
// An address byte immediately follows the
// start condition. Read it. Return
// one of the following values:
//
// Negative:
// Address does not match or
// start condition yet to be
// received.
// 0: Address match, R/W = 0 (W)
// 1: Address match, R/W = 1 (R)
//
// This function is a state machine that
// rapidly returns. It has the following
// states:
//
// -1: Waiting for SCL and SDA to both
// be high.
// -2: SCL and SDA are both high, waiting
// for SDA to go low.
int waitForStart( void )
{
static int state = -1;
byte sdaVal;
byte sclVal;
switch( state )
{
case -1:
// Wait until the SCL line is high and the
// SDA line is high.
if( readSCL() && readSDA() )
{
state = -2;
}
return state;
case -2:
// Start condition may have begun. Wait
// for the data line to go low while
// the SCL line remains high:
sdaVal = readSDA();
sclVal = readSCL();
if( !sdaVal && sclVal )
{
break;
}
// If code sees anything other than
// SCL = 1 and SDA = 1 at this point,
// it has to reset the state machine
// to -1.
if( !( sclVal && sdaVal ) )
{
state = -1;
}
return state;
// Just a fail-safe case:
default:
state = -1;
return state;
} // Switch
// Reset the state machine for the next invocation:
state = -1;
// Okay, there is a start condition.
// Read the address byte.
byte address = rcvByte();
if( (address & 0xFE) == PeripheralAddress )
{
return address & 1;
}
return -1; // Not our address
}
waitForStart() 函数实际上并不会等待开始条件的发生。相反,它是一个状态机,在每次调用时,根据 SDA 和 SCL 线路的状态在各个状态之间切换。该函数返回 0、1 或某个负数。
返回 0 表示地址匹配并进行写操作。返回值 1 表示地址匹配并进行读操作。负值返回则表示尚未出现感兴趣的内容,调用者应在不久的将来再次调用此函数,足够快以捕捉到即将到来的开始条件。
waitForStart() 函数被这样编写——而不是直到开始条件和有效地址出现后才返回——是因为这样可以让 CPU 在等待开始条件的同时执行其他工作。
// Listing3-2.ino (cont.):
//
// Standard Arduino setup() function.
//
// Standard Arduino initialization code:
void setup( void )
{
pinMode( SCL, INPUT ); // Begin with SCL/SDA = 1
pinMode( SDA, INPUT );
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "teensy Peripheral Test" );
}
标准的 Arduino setup() 函数仅初始化 SDA 和 SCL 线路为输入,将这两条线路置为 1,以确保此外设不会干扰总线上正在进行的任何其他活动。此特定代码还会将一条调试信息打印到 Serial 输出。
// Listing3-2.ino (cont.):
//
// Standard Arduino loop() function.
//
// Arduino main loop:
void loop( void )
{
static byte memory = 0; // Holds I2C memory byte
// Wait for a start condition to arrive.
// If not a start condition yet, just
// keep looping.
//
// Assumption: Arduino code outside this
// loop takes less than about 5 usec
// to execute. If that is not the case,
// then waitForStart() should be called
// in a hard loop to continuously poll
// for a start condition.
int gotStart = waitForStart();
❶ if( gotStart == 0 ) // Write request
{
// On write operation, read the next byte
// coming from the I2C bus and store it
// into the memory location.
memory = rcvByte();
Serial.print( "Memory=" );
Serial.println( memory, 16 );
}
❷ else if( gotStart == 1 ) // Read request
{
// On a read request, transmit the
// value in memory across the I2C bus.
xmitByte( memory );
Serial.print( "Transmitted " );
Serial.println( memory );
}
❸ // else: not of interest to us.
}
Arduino 的循环函数是外设程序的主体。它调用 waitForStart() 函数并检查返回值。它处理以下三种情况:
-
0:发生写入操作(控制器向外设写数据)。在这种情况下,代码从 I²C 总线读取下一个字节,并将其存储到内存位置 ❶。
-
1:发生读取操作(控制器正在读取外设)。在这种情况下,代码将内存变量的值写入 I²C 总线,作为控制器时钟输出下一个字节 ❷。
-
负值:在这种情况下,主循环不做任何事情;它只是返回给调用者,调用者在完成一些内部记录工作后会再次调用
loop()函数 ❸。
清单 3-3 是我用来测试出现在清单 3-2 中的软件基础 I²C 外设代码的一个简短的 Teensy 3.2/Arduino 程序。
// Listing3-3.ino
//
// Software-based I2C test program.
#include <Wire.h>
#define LED 13
void setup( void )
{
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "test.ino" );
Wire.begin();
pinMode( LED, OUTPUT );
}
void loop( void )
{
static byte value = 0;
digitalWrite( LED, 0 );
Serial.print( "Writing: " );
Serial.print( value, 16 );
Wire.beginTransmission( 0x50 );
Wire.write( value );
Wire.endTransmission();
delay( 250 );
digitalWrite( LED, 1 );
Wire.requestFrom( 0x50, 1 );
while( Wire.available() )
{
byte data = Wire.read();
Serial.print( ", read=" );
Serial.print( data, 16 );
Serial.print( ", value=" );
Serial.print( value, 16 );
}
Serial.println();
++value;
delay( 250 );
}
Listing 3-3 中的代码可以在 Teensy 3.2 或几乎任何 Arduino 兼容系统上运行。它反复写入一个值到外设,然后读取该值。关于该示例程序如何工作的更多细节,请参见第八章。
3.1.3 关于 Teensy 3.2 软件 I^(2)C 代码的最终评论
前两节中的软件是手动调优的,专为 Teensy 3.2 设计。它可能不适用于不同的系统,也很可能不适用于显著更快或更慢的系统。如果你想在其他 MCU 上使用这段代码,你可能需要稍作调整和优化,特别是在性能较低、内存较小的 MCU 上,像 Teensy 3.2 这样的平台。为了提供一个优化示例,第十七章(在线阅读:bookofi2c.randallhyde.com)描述了如何使用仅 8 MHz 运行的 ATtiny84 CPU 实现一个控制器和外设。
Teensy 软件实现的 I²C 总线存在一些问题。首先,这段代码在时间上相对较脆弱。它使用软件延迟循环来测量 I²C 时钟周期。中断和其他可能暂停延迟代码执行的活动可能会导致计时不准确,从而导致操作不当。尽管有一些超时检查用于各种循环,但现实项目需要更多这样的检查,以防止代码在总线上有异常控制器或外设时挂起。虽然本章避免了过多的超时检查,因为那样会使代码显得杂乱且更难理解,但如果你打算将这段代码作为实际项目的基础,你应该解决这个缺陷。
3.2 基本的 ATtiny84 和 ATtiny85 硬件
到目前为止给出的示例代码运行在高性能的 Teensy 微控制器上。虽然这段代码作为教育工具非常有用,但实际上,你通常不需要在如此强大的处理器上实现 I²C 协议——这些处理器通常会内置 I²C 硬件。基于软件的 I²C 包更多地出现在低端 CPU 上,比如 ATtiny84。
ATtiny84 是典型的 8 位 AVR 微控制器,类似于低端 Arduino 板上使用的那些微控制器。这些微控制器通常从供应商(如 SparkFun:www.sparkfun.com/products/11232)购买,价格不到 3 美元(美国),你也可以在 Amazon 或 eBay 上以更低价格(大约 25 个一批)找到它们。它们具有以下特性:
-
8KB 闪存
-
512 字节 EEPROM
-
512 字节的 RAM
-
使用内部时钟运行 12 MHz,使用外部晶振可达 20 MHz
-
一个 8 位计数器和一个 16 位计数器
-
10 位 ADC
-
内置模拟比较器
-
12 个 I/O 引脚(14 引脚双列直插式 [DIP] 封装)
-
在 12 MHz 下近 12 MIPS 操作(1 MIPS/MHz)
这款 CPU 非常适合处理 100 kHz 的 I²C 操作,前提是非 I²C 活动的计算负荷不是特别高。
ATtiny85 微控制器是一个类似的设备,采用 8 引脚 DIP 封装,并具有六个 GPIO 引脚。它内置的外设较少(由于封装限制),定时器是 8 位的,但除此之外,它的功能和 ATtiny84 相同。大多数不使用额外 ATtiny84 特性的源代码都能在这两款 MCU 上运行。
你可以在第十七章的在线版本中找到完整的 SparkFun Atto84 设备代码,该代码被编程为 I²C 控制器。该代码与本章中的 Teensy 代码不完全相同,但许多部分是相似的;为了避免本章内容重复,代码被放置在在线章节中。
3.2.1 Atto84 基于软件的 I²C 外设
不幸的是,Atto84 的性能稍显不足,无法在软件中可靠地支持 I²C 外设模式。我花了好几天尝试这个,作出了以下妥协:
-
移除 SDA 和 SCL 输入线路上的滤波器
-
移除循环中的超时检查
-
将大多数操作 I/O 端口位的函数内联(包括读取位、等待时钟线高电平或低电平,以及许多其他函数)
-
为了处理时间关键的代码,转向 AVR 汇编语言
最终,Atto84 偶尔能够支持外设模式,但并不稳定。Atto84 可以作为控制器,因为它控制时钟频率;如果半个时钟周期被拉伸到 5.5 微秒、6.0 微秒,甚至 7.1 微秒,任何正常的外设都能很好地处理。然而,作为外设,Atto84 必须能够始终跟上 100 kHz 时钟(5 微秒的半个时钟周期),即使使用直线汇编语言代码并做出前面提到的所有妥协,也不足以完成这项工作。Atto84 有时还是会错过在 SDA 线上发送 ACK 位。当然,一旦你从循环中移除了所有超时检查,代码在错过一个比特后就会失去同步,从那时起事情就会变得非常糟糕。
这并不是说你不能使用 Atto84(或通用的 ATtiny84)作为 I²C 外设。通用 ATtiny84 可以通过外部晶体以最高 20 MHz 运行,这可能足够快来工作。更重要的是,ATtiny84 内建硬件(USI)提供了 I²C 操作的硬件支持。这个话题将在第十六章中进一步探讨。
3.3 章节总结
本章提供了一个在 Teensy 3.2 微控制器上运行的软件实现 I²C 协议,作为一个教育工具。它首先描述了一个小型硬件设置,使用了 Teensy 3.2、Adafruit ADS1115 ADC 扩展板和 SparkFun MCP4725 DAC 扩展板。基本硬件介绍后,展示了 I²C 控制器和 I²C 外设的软件实现。最后,简要回顾了在 ATtiny84 微控制器上实现基于软件的 I²C 协议时遇到的问题。
仔细研究 I²C 协议的软件实现将有助于加深你对其底层细节的理解。当你开始使用逻辑分析仪和协议分析仪调试 I²C 信号时,这些信息尤为重要。下一章将深入探讨这一主题,讨论 I²C 调试工具。
第四章:I²C 传输分析与调试工具

在设计 I²C 硬件和编写与 I²C 硬件配合使用的软件时,你通常会发现,基于软件的调试器和 printf 语句不足以快速定位硬件和软件中的问题。如果你打算定期编程 I²C 设备,你会希望投资一些合适的硬件工具,以减少调试和测试的工作量。
本章讨论了其中几种工具,包括万用表、示波器、逻辑分析仪、总线监视器和协议分析仪。尽管这些工具需要花费一定的金钱,但使用它们能够减少调试代码时的时间消耗。
4.1 通用硬件测试与调试工具
如果你在处理硬件设备(通用硬件,而不仅仅是 I²C)时,有几个工具你应该在工具箱中准备好:
-
数字万用表(DMM)
-
示波器
-
5V、3.3V 和可调电源(至少 0V 到 10V)
DMM 在检查 I²C 设备上的电源引脚以及其他直流信号时非常有用。大多数 DMM 在测量电压变化的引脚上的信号时毫无价值,例如 SDA 和 SCL 线,因为 DMM 会对电压进行相对较长时间的平均处理,如果信号不稳定,这将导致测量结果不准确。
DMM 还可以用来测量 I²C 线上的上拉电阻。理论上,你应该能通过读取颜色代码或 SMT 电阻代码来计算总线上的电阻。然而,如果多个上拉电阻分布在系统各处,电阻可能比你预期的要小。快速测量 Vcc 与 SDA 或 SCL 线之间的电阻可能会很有用。
一些 DMM 内置了电容表,但这些功能通常不足以测量总线电容。除非你有一台非常昂贵的 DMM,否则不要尝试进行这种测量。电容通常太低,普通的万用表无法测量。另一方面,有些电容表能够处理低电容,并且能给你一个关于系统总线电容如何的估计,价格也从 $100 起。幸运的是,你可以使用示波器观察信号,并确定是否存在过多的总线电容,因此电容表并不是必需的。如果你没有电容表,也无法为其他原因证明购买的必要性,那就不值得购买一个。
从理论上讲,示波器并非调试 I²C 信号时绝对必要的工具,但它仍然是一个有用的设备,可以快速判断信号是否处于活动状态以及这些信号的电压水平。如前所述,拥有示波器的一个有用理由是它可以让你监控 I²C 总线上信号的模拟状态。你可以很容易地判断电压水平是否合理——即,不是过高——以及是否出现了巨大的电压下降。示波器还可以帮助你确定总线电容是否已经失控,通过显示 SCL 和 SDA 信号的上升时间。如果这些信号的上升时间过长,总线上的设备可能无法将这些信号识别为逻辑 1。例如,图 4-1 展示了一个合理系统中的 SCL 线。此图像来源于一个 100kHz 的系统,其中 Teensy 3.2 作为控制器,Adafruit ADS1115 作为外设。它们通过一个“无线”面包板(该面包板以电容高而著称)连接在一起。

图 4-1:示波器显示 SCL 线图像
图 4-2 展示了一个非常糟糕的时钟信号版本,具有非常慢的上升时间。在这个特定的例子中,我在 SCL 和 Gnd 线路之间连接了一个 470pF 的电容,以模拟过度的总线电容。如你所见,信号遭受了严重的衰减。当信号已经上升到足够高以被识别为高电平时,已经过去了大约 2 微秒。这并没有给在 SDA 线上放置比特的设备留出太多的时间来完成它的工作。使用示波器可以最容易地发现这些类型的问题,所以示波器确实是一个很有用的工具。

图 4-2:示波器显示高总线电容下的 SCL 线图像
一台“体面”的示波器,足够用于调试 I²C 信号,可能会花费你大约 300 到 600 美元。当然,一台品牌良好的示波器价格可能会达到几千美元。然而,这类设备对于观察 I²C 总线信号来说,可能有些过于奢侈。除非你有其他项目需要这种设备的速度和功能,或者你真的想让朋友们印象深刻,否则你完全可以选择一台“高级爱好者”级别的设备。
一些非常便宜的设备(价格低于 100 美元到大约 200 美元)使用低端 LCD 显示屏或通过连接电脑来工作。它们可能适合预算极为紧张的人,但如果你经常使用它,你最终还是会买一台真正的示波器。正如人们常说的:“一次购买,一次哭泣。”
4.2 逻辑分析仪
毫无疑问,处理 I²C 硬件和软件时,你最需要获得的工具就是逻辑分析仪。像示波器一样,逻辑分析仪也有各种形状和尺寸,功能列表差异巨大,价格从不到 30 美元到几千美元不等。
在低端市场上,有两种有趣的设备,尽管它们价格低廉,但实际上相当有用:I²C 驱动器和总线海盗。这两种设备可能更准确地称为总线监控器或总线驱动器,而不是逻辑分析仪。虽然它们具有一些实际逻辑分析仪的功能,但与这些(开放硬件或开放软件)项目相关的软件支持,远不如真正的逻辑分析仪。
在 $300 到 $500 的价格范围内,事情开始变得更有趣。Total Phase 提供几种不同的 I²C 和 SPI 调试模块。这些设备可以连接到 PC(Windows、Linux 或 macOS),并且运行在这些机器上的软件允许你捕获并操作 I²C 数据。(请参阅本章结尾的“更多信息”部分,了解 Total Phase 和其他调试模块的链接。)
另一种有趣的设备来自模拟设备公司(这家集成电路制造商生产多种 I²C 集成电路),它是 ADALM2000 活动学习模块。这款设备是为学生实验室设计的,支持多种测量和控制选项,其中包括 I²C 监控。
如果你真的想花钱,可以考虑 Corelis 的 BusPro-I,它是一个专业级的 I²C 总线分析仪,价格大约为 $1,700。Corelis 还有一个更高级的版本(无疑价格更高),可以模拟 I²C 控制器和外设设备。
到目前为止,我描述的设备主要是为 I²C 和 SPI 测量而设计的工具。从某种程度上说,这些设备是所谓逻辑分析仪的简化版。逻辑分析仪类似于示波器,因为它会随着时间的推移进行一系列读数,并显示这些读数的状态(通常是在某种 LCD 显示器上,这个显示器可能内置于逻辑分析仪中,也可能是在与逻辑分析仪连接的 PC 上)。不过,示波器和逻辑分析仪之间还是有一些主要区别的:
-
逻辑分析仪本质上是数字设备,而示波器则是模拟设备。
-
逻辑分析仪通常会存储数据并在事后显示,而示波器则更倾向于实时显示。
-
逻辑分析仪通常根据某种协议(例如 I²C 协议)解释它们记录的数字信息,而示波器则倾向于显示原始的模拟数据。
-
逻辑分析仪倾向于同时捕获多个数据位(通常是 4 到 16 个通道),而示波器一般限制为 1 到 4 个通道。
这些差异并非绝对;例如,一些存储示波器也可以存储数据,而某些逻辑分析仪可以实时显示它们的数据和分析结果。甚至有可能将逻辑分析仪和示波器集成到同一个设备中。例如,Siglent SDS1104X-E 是一款 100 MHz 示波器,带有 4 通道逻辑分析仪,而 Owon MSO8102T 和 Rigol MSO1104Z-S 则提供 16 个通道以及示波器功能。
当然,如果你是在花别人的钱,你可以从 Tektronix、Keysight Technologies、NCI Logic Analyzers、National Instruments 以及其他高端专业仪器公司购买一些非常精密的逻辑分析仪。然而,如果你不需要千兆赫采样率、多个输入通道和一个华丽的名字,或者如果你需要自己支付这个设备的费用,那么你可能需要考虑一些低端的设备。
在 100 美元到 1000 美元的范围内,有许多 USB 接口、可以连接到 PC 的不错的逻辑分析仪;详情请见“更多信息”。
最终,当你寻找逻辑分析仪时,你会想问以下问题:
-
它是否支持你感兴趣的协议(暂时是 I²C,但你可能还会用它来调试 SPI、CAN 和其他总线协议)?
-
软件是否高质量,并且它是否能在你的开发机器上运行?
-
它的文档是否完备?
-
是否有持续的支持(例如,软件更新)?
我拥有一台 Saleae Logic 8,并且可以证明它是一款高质量的设备,且有很好的支持。并不是说我列出的其他设备也不好(我不知道,我从未使用过它们),或者某些未列出的设备也能为你带来良好的体验。然而,Saleae 的设备在工程界得到了很好的评价。或许唯一的抱怨是它们有点贵(400 美元到 1000 美元),但这就是高质量硬件和软件的价格。
本章的其余部分将集中讨论前面提到的三种设备:I²C 驱动器、Bus Pirate 和 Saleae Logic 8。
4.3 I²C 驱动器
I²C 驱动器是一个小型板子,带有一个小格式的彩色 LCD 显示屏。它有三组 I²C 探针从板子上引出;我不确定为什么有不止一组探针,因为这些连接器都有相同的信号并且是连在一起的。它有一个 micro-USB 端口,可以连接到 Linux、Mac 或 Windows PC。
当设备启动时,它会在小型 LCD 显示屏上显示任何 I²C 活动。虽然这看起来很漂亮,但实际上并不十分有用:I²C 的数据传输相较于其他协议可能较慢,但仍然比你在实时显示器上看到的要快得多。
实际功能在于运行在 USB 电缆另一端 PC 上的软件。开发 I²C 驱动程序的 Excamera Labs 提供了一些 Python 代码来支持 I²C 驱动程序。该软件很简陋,功能基础,但也算是 30 美元能期待的水平。
主要的 Python 软件提供了类似命令行的接口(在 Python 内)。你可以通过手动调用 Python 函数来执行各种操作。例如,如果你想进行总线扫描,以查看哪些外围设备在总线上响应,可以在 Python > 提示符下输入命令 i2c.scan()。调用 i2c.scan() 函数后,会显示类似以下内容:
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
48 -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
-- -- -- -- -- -- -- --
[72]
其中,-- 表示设备在特定的 I²C 地址没有响应,而十六进制数字值(这里唯一对应响应设备地址的是 48)。在此案例中,我有一个配置为地址 0x48 的 Adafruit ADS1115 ADC 分离板,安装在 I²C 总线上。
I²C 驱动程序的 Python 软件提供了许多额外的命令,你可以在自己编写的 Python 代码中执行或调用。调用 help(i2cdriver) 会显示 Python 应用程序编程接口(API)。一些可以直接执行的有用命令包括:
-
setspeed(speed)参数为 100 或 400(分别对应 100 kHz 或 400 kHz) -
setpullups(bitmask)参数是一个 6 位值,指定 I²C 驱动程序上三个 I²C 连接器(每个 SCL 和 SDA 各 2 位)的上拉电阻值。 -
reset()发送总线重置(通用调用地址) -
scan()扫描总线并显示响应的地址 -
monitor(flag)如果flag为真(非零),则开启监视模式;如果flag为假(0),则关闭监视模式。 -
getstatus()显示状态信息
还有一些命令用于启动 I²C 总线事务、向总线写入数据、从总线读取数据以及发送停止命令。然而,这些操作通常是在 Python 程序中进行的。
I²C 驱动程序软件还有一个 GUI 应用程序,可以打开 图 4-3 所示的窗口。点击 监视模式 按钮以激活 I²C 驱动程序内置 LCD 的监视模式。再次点击按钮可以关闭监视模式。当不在监视模式时,选择一个地址(如果该地址有设备连接),并使用窗口底部的编辑框读取或写入该设备的数据。

图 4-3:Mac 上的 I²C 驱动程序 GUI 显示
当 I²C 总线有活动时,点击 捕获模式 按钮,将 I²C 数据重定向到逗号分隔值 (.csv) 文件中。以下是该文件中数据的一个小样本:
START,WRITE,72,ACK
BYTE,WRITE,0,ACK
STOP,,,
START,READ,72,ACK
BYTE,READ,12,ACK
BYTE,READ,23,NACK
STOP,,,
START,WRITE,72,ACK
BYTE,WRITE,1,ACK
BYTE,WRITE,193,ACK
BYTE,WRITE,131,ACK
STOP,,,
START,WRITE,72,ACK
BYTE,WRITE,0,ACK
STOP,,,
START,READ,72,ACK
BYTE,READ,12,ACK
BYTE,READ,43,NACK
STOP,,,
START,WRITE,72,ACK
BYTE,WRITE,1,ACK
BYTE,WRITE,193,ACK
BYTE,WRITE,131,ACK
STOP,,,
当 I²C 驱动程序在总线上捕获数据时,我让一块 Teensy 3.2 与 Adafruit ADS1115 分离板进行通信。可惜的是,该程序显示的所有数字都是十进制格式,而不是更有用的十六进制格式。值 72[10] 是 0x48——即 ADS1115 的地址。
编写一些软件来解析这些行并以更合适的方式显示数据其实很容易。不幸的是,这个显示中缺少了时序信息。然而,它是开源软件,因此如果你希望不同的输出,可以随意进去修改。
I²C Driver 捕获模式最有趣的用途之一是生成测试结果数据。你可以使用你的 I²C 软件对控制器或外设运行一些测试,捕获输出,然后将输出与已知数据进行比较,或者将输出通过过滤程序检查其正确性。这种生成测试结果的方式是半自动化复杂测试过程的有用工具。
因为 I²C Driver 还允许你读写 I²C 外设的数据,所以它也对检查你创建的外设的操作非常有用。你可以手动向设备写入字节,读取设备的响应,并验证结果是否符合预期。
尽管 I²C Driver 并不是 I²C 调试工具的终极之选,但它仍然是一个有趣的工具。此外,它提供了一个 USB 接口到 I²C 总线,你可以从 PC 进行编程;详情请参见他们的网站(链接见《更多信息》)。不到$30,将这个设备放入你的工具箱中是一个明智的选择。
4.4 Bus Pirate
Bus Pirate 是另一个开源硬件、价格低于$30 的设备,你可以用来分析 I²C 总线上的信号。而 I²C Driver 基本上是一个 USB 转 I²C 设备,附带显示屏,Bus Pirate 实际上是一个小型微控制器(PIC),被编程用于读取和写入各种数字 I/O 引脚。通过位编程软件,它慢慢地模拟 I²C 协议。由于它是开源硬件和软件、成本低且已有很长时间,Bus Pirate 吸引了大量希望进行低成本硬件黑客、分析或测试的用户。
Bus Pirate 与本章提到的大多数设备不同,因为它实际上并没有任何与 PC 相关的软件。Bus Pirate 看起来像一个串行设备(USB 转串行),所以你需要使用串行终端仿真程序来操作 Bus Pirate。你在终端中输入命令,Bus Pirate 会做出相应的反馈。在操作中,这与 I²C 驱动程序的命令行模式类似。
大多数 Bus Pirate 命令是单字符输入。你需要知道的最重要的命令是?命令。这是帮助命令,它会在终端上显示所有命令的列表。
默认情况下,Bus Pirate 以特殊的Hi-Z(高阻抗)模式启动,这基本上关闭了所有输出,以防止对 Bus Pirate 或连接到 Bus Pirate 的任何设备造成损坏。你可以通过按M切换到新的模式。这将显示一个菜单,供你选择新的操作模式(例如 I²C 模式)。如果你选择 I²C,它会要求你输入总线频率。
一旦 Bus Pirate 进入 I²C 模式,你可以向总线写入数据、从总线读取数据,或监控总线上的数据(类似于 I²C 驱动程序)。更多详细信息请参见 Bus Pirate 文档(链接见“更多信息”部分)。
4.5 Saleae 逻辑分析仪
尽管 I²C 驱动程序和 Bus Pirate 是用于某些类型 I²C 监控、测试和调试的有用设备,但它们并不是真正的逻辑分析仪。它们在实时监控和显示 I²C 信息方面并不出色。此外,尽管这两种设备可以监控—并在某种程度上捕捉—总线上的数据,但它们几乎在时间分析方面毫无用处,例如,验证每个比特是否在特定时间内发生。这正是一个真正的逻辑分析仪的强项。
Saleae Logic 8、Logic 8 Pro 和 Logic 16 Pro 设备是功能齐全的逻辑分析仪,提供 8 或 16 个通道。Logic 8 设备的采样率为每秒 1 亿次采样(Msps),而 Logic 8 Pro 或 Logic 16 Pro 的采样率为 500 Msps。通常,你希望逻辑分析仪的速度是你需要捕捉的最快信号的 5 到 10 倍,因此 Logic 8(100 Msps)足以处理 10 MHz 到 20 MHz 范围内的信号。这肯定覆盖了所有 I²C 的频率。
设备本身提供 8 或 16 个探针,供你连接到电路。对于标准的 I²C 测量,你实际上只需要连接其中两个探针,以及一根接地线。额外的通道很有用,因为它们可以让你在 I²C 传输过程中检查系统中其他引脚的状态。例如,如果你正在向 GPIO 扩展器发送数据,可以将一些探针连接到输入或输出引脚,以查看在 I²C 传输前、传输中和传输后的电平变化。
在 PC 上运行的 Logic 软件看起来像 图 4-4 中所示(macOS 版本)。屏幕的左侧定义了信号,你可以指定这里显示的名称。屏幕的中间部分显示时序和协议信息,右侧部分让你选择要解码的协议。

图 4-4:在 macOS 上运行的 Logic 软件
点击窗口左侧的 Start 按钮开始捕获操作。你可以指定捕获的数据量;我个人的设置是捕获两秒钟的数据。图 4-5 显示了典型的数据捕获情况。像本章中的其他内容一样,逻辑分析仪正在捕获 Teensy 3.2 和 ADS1115 之间的通信。

图 4-5:Logic 软件时序显示
Logic 已编程以分析 I²C 数据流。因此,它显示总线上的地址字节、读写命令以及每个数据字节。尽管在这幅黑白图像中不清楚,但显示屏也会用绿色和红色的点标记数据波形的开始和停止条件。
我将这个时序图缩小了,这样你就可以看到完整的 I²C 传输。不过,Logic 允许你扩大或缩小时序图,以便你调整详细程度。图 4-6 展示了从图 4-5 中扩展的第一个(地址)字节传输。

图 4-6:Logic 中的时序扩展
Logic 中的另一个不错的功能是,你可以将光标移动到某个时序信号的某一部分,并获得时序信息。图 4-7 显示了当我将光标移动到 SCL 时钟脉冲上时发生的情况。Logic 通过显示脉冲宽度和频率(5.12 µsec 和 96.9 kHz)做出了响应。在前一章编写基于软件的 I²C 控制器和外设实现时,我大量使用了这个功能。这就是我如何微调延迟,以使软件 I²C 仿真运行接近 100 kHz。

图 4-7:从 Logic 中提取时序信息
Logic 还允许你在时序图中设置光标,从而可以测量任意两点之间的时间,而不仅仅是某个信号脉冲的宽度。例如,当测量整个传输的时间,而不是单个传输位的时间时,这个功能非常有用。
售价不到 400 美元,最便宜的 Saleae 单元并不是超级便宜,但如果你要调试大量 I²C 代码和硬件,拥有这样一个设备是一个明智的投资。
4.6 关于 I²C 监视器和逻辑分析仪的最终评论
因为这是《I²C 之书》^(2)*,本章对分析仪的讨论集中在其用于调试和分析 I²C 信号上。事实上,本章中的大多数设备还支持几种其他协议,包括 SPI、CANBUS、MIDI、DMX、1-Wire 以及几乎所有你能想象到的典型协议。(I²C 驱动程序是这个例外,它仅支持 I²C 监控。)因此,购买像 Saleae Logic 8 这样的设备实际上是一个不错的投资,因为你不仅可以用它来测试和调试 I²C 信号,还可以用于测试和调试各种硬件。
4.7 本章总结
编写与 I²C 设备配合使用的软件不可避免地需要测试和调试这些软件。这种工作使用硬件测试和调试工具会更容易完成。本章讨论了几种可以用于此目的的设备,包括示波器、逻辑分析仪和总线嗅探器。还介绍了几种市面上可用的选项,包括 I²C 驱动程序、总线海盗和 Salae Logic 分析仪。并提到了各种组合示波器和逻辑分析仪选项,最后指出,这些设备不仅对调试 I²C 设备有用,还适用于其他协议,这使得它们具有更广泛的适用性。
第五章:I²C 变种

本章简要介绍了几种 I²C 总线的变种,包括系统管理总线(SMBus)、VESA DDC 和 E-DDC、ACCESS.bus 和两线接口。这些变种大多数是在 I²C 总线的物理两线接口之上应用协议,定义了流经总线的消息和其他数据。
详细讲解这些协议扩展通常超出了本书的范围。然而,本章提供了这些协议(以及其他特定总线变种)概述,并说明了你可以在哪里找到更多关于这些变种的信息。我们首先讨论 SMBus 协议,因为它广泛应用于计算机系统,值得深入讨论。
5.1 SMBus
SMBus 最初由英特尔和杜拉塞尔共同开发,旨在管理计算机系统中的电池电力。SMBus v1.0 和 v1.1 处理低功耗设备,如电池电力管理系统,而 SMBus v2.0 则将高功耗设备添加到规范中。
可能看起来不需要专门为 I²C 总线的 SMBus 实现单独设立一节内容,因为支持 SMBus 的外设很少,而且大多数嵌入式软件设计师不会花太多时间在电池管理上,SMBus 的主要用途就是电池管理。然而,Linux 的 I²C 支持,包括树莓派,提供了基于 SMBus 协议的 API 函数。仅仅因为这一点,这一稍多于简要的 SMBus 介绍是值得的。此外,在所有 I²C 扩展和变种中,SMBus 绝对是最常见的。
大多数支持 SMBus 协议的外设 IC,如果忽略本章中的信息,按照数据手册编程,它们的行为与标准 I²C 外设相同。许多设备虽然不支持完整的协议,但仍然支持 SMBus 协议的某些方面。例如,MCP23017 GPIO 扩展器的许多命令序列遵循本章中的 SMBus 协议(见第十三章)。因此,即使某些外设并未完全支持 SMBus 规范,理解 SMBus 也有助于你处理许多外设。
5.1.1 SMBus 与标准 I²C 的区别
虽然 SMBus 基于 I²C 总线,但它对 I²C 信号提出了一些额外的要求:
-
时钟——SMBCLK,即 SMBus 对 SCL 的命名——必须在 10 kHz 到 100 kHz 之间。特别需要注意的是,SMBus 不支持任意的时钟拉伸(低于 10 kHz)。SMBus 规范的后续版本还支持 400 kHz 和 1 MHz 的信号。
-
SMBus v3.0 支持的信号电压范围是 1.8 V 到 5 V。此外,SMBus 明确指出,逻辑 0 小于 0.8 V,逻辑 1 大于 1.35 V。
-
SMBus 规范规定了 35 毫秒的时钟低电平超时(大约 15 Hz,假设占空比为 50%)。而 I²C 标准没有此类超时要求。
-
SMBus 为总线信号规定了上升时间和下降时间;I²C 标准没有提供这样的规格(除了总线电容,它会影响上升时间和下降时间)。
-
NAK 行为在 I²C 和 SMBus 之间有所不同。
-
SMBus 设备必须始终确认在 I²C 总线上接收到它们的地址;标准 I²C 协议不要求这样做(例如,如果设备正在忙于执行其他操作)。
-
SMBus 支持总线上三种类型的设备:控制器、外设,以及一种特殊版本的控制器,称为 主机。
-
所有 SMBus 设备必须具有与之关联的唯一 ID。
-
SMBus v2.0 引入了动态分配设备地址的概念。
-
SMBus 支持可选的硬件信号 SMBAlert 和 SMBSuspend,这些信号可以生成中断或挂起操作以实现低功耗操作。
SMBus 还为诸如原型设备等目的保留了某些设备地址,远远超过 I²C 总线为特殊用途保留的地址数量。SMBus 还支持动态指定的设备地址,允许设备在操作过程中选择其地址。
除了硬件差异之外,SMBus 规范还指出了几项协议变更,包括用于传输数据块、总线特定命令和设备枚举的功能。SMBus 规范提供了更多的细节(更多信息请参见本章末尾的“更多信息”链接)。
5.1.2 SMBus 电气规格
如前所述,SMBus 的 SMBCLK 信号必须在 10 kHz 至最大总线速度(100 kHz、400 kHz 或 1 MHz)之间工作。实际上,大多数现代 SMBus 实现运行在 50 kHz 或更快的速度。NXP 的文档(见“更多信息”中的 SMBus 快速入门指南)指出,系统不得因外设设备的时钟拉伸而将时钟频率降低至最小速度。此外,SMBus 设备必须在加电后的 500 毫秒内准备好工作。
SMBus 的电气规格优于标准 I²C 总线。它将时钟或数据线上的逻辑 0 定义为 0.8 V 或更低,将逻辑 1 定义为 1.35 V 或更高。
5.1.3 SMBus 保留地址
SMBus 保留了 I²C 保留地址之上的几个地址(见第二章 2.5 节,“特殊地址”)。除了这些地址外,SMBus 还保留了 7 位地址 0x08 用于 SMBus 主机设备,0x0C 用于 SMBus 警报响应,以及 0x61 用于 SMBus 设备默认地址。SMBus 规范还为特定用途保留了某些设备地址,如表 5-1 所示。
表 5-1:保留的 SMBus 设备地址
| 地址位 | 描述 |
|---|---|
| 0001-000 | SMBus 主机 |
| 0001-001 | 智能电池充电器 |
| 0001-010 | 智能电池选择器或智能电池系统管理器 |
| 地址位 | 描述 |
| 0001-011 | 智能电池 |
| 0001-100 | SMBus 警报响应 |
| 0101-000 | ACCESS.bus 主机 |
| 0101-100 | 最初为 LCD 对比度控制器保留(可能在 SMBus 的未来版本中重新分配) |
| 0101-101 | 最初为 CCFL 背光控制器保留(可能在 SMBus 的未来版本中重新分配) |
| 0110-111 | ACCESS.bus 默认地址 |
| 1000-0xx | 最初为 PCMCIA 插座控制器保留(可能在 SMBus 的未来版本中重新分配) |
| 1000-100 | 最初为 VGA 图形控制器保留(可能在 SMBus 的未来版本中重新分配) |
| 1001-0xx | 无限制地址 |
| 1100-001 | SMBus 设备默认地址 |
请参阅 SMBus 文档查看此列表是否有任何新增项。SMBus 标准的后续版本可能会将设备地址添加到此列表中。
5.1.4 SMBus 协议命令
标准 I²C 总线协议只定义了地址字节和 R/W 位格式。它没有定义总线上出现的任何进一步数据。相比之下,SMBus 协议定义了几种不同的命令格式,包括快速命令、发送字节、接收字节、写字节、写字、读字节、读字、处理调用、块读取和块写入。
SMBus 设备不必实现所有 SMBus 协议命令—只需要实现与特定设备相关的命令。如果设备支持快速命令,它可能只支持该命令。类似地,如果设备支持读字节命令和发送字节命令,它可能只支持这两个命令。其余的 SMBus 协议命令包括一个额外的命令字节;该字节可以指定要使用的特定命令协议。以下子节定义了每种命令类型。
5.1.4.1 SMBus 快速命令
SMBus 快速命令是内建于地址字节的 R/W 位中的简单 1 位命令(参见图 5-1)。快速命令将一个单个位传输到外设,外设可以基于这一位的二进制数据来开启或关闭设备,或执行其他操作。

图 5-1:快速命令格式
快速命令中除了地址字节外,不会向设备发送额外的数据。
5.1.4.2 SMBus 发送字节和读字节命令
SMBus 发送字节和读字节命令在地址字节后包含 1 字节数据。地址字节的 R/W 位指定特定的命令(读或写;参见图 5-2)。

图 5-2:发送或读取字节命令格式
在发送字节命令中,主机/控制器设备将第二个字节传输到外设;在读字节命令中,外设将数据放置在 SMBDAT(SDA)线上供主机/控制器设备读取。
5.1.4.3 SMBus 读字节和读字命令
SMBus 读取字节命令允许你从外设设备中读取单个字节的数据,但无法指定你正在读取的字节数据。这个命令对于返回单个字节值的简单设备很有用,比如读取一组 8 位数字 I/O 引脚。另一方面,SMBus 读取字节和读取字命令包括一个特殊的命令字节,允许你指定参数信息,从而选择你想要读取的特定字节。这可能是一个寄存器或内存地址,或者其他选择或控制信息。读取字节命令的序列见图 5-3。

图 5-3:读取字节命令格式
读取字命令的序列见图 5-4。

图 5-4:读取字命令格式
由于控制器必须首先将命令字节写入外设设备,读取字节和读取字命令以写操作开始(地址字节的第 0 位为 0)。接着,序列必须包含一个重启操作,之后是第二个地址字节,其中第 0 位为 1(表示读取)。然后,控制器从外设设备中读取下一个字节或字(取决于命令)。
并非所有设备都支持读取字节和读取字命令。外设设备的设计决定了控制器是否可以读取单个字节、一个字,或两者都可以。如果设备支持使用此命令读取字节和字,那么控制器必须以某种方式指定是否要从设备中读取字节或字,通常是通过命令字节中的某个位来实现。
5.1.4.4 SMBus 写字节和写字命令
SMBus 写字节和写字命令也包括一个命令字节,允许你指定参数信息,从而选择你想要写入外设的特定字节或字。这可能是一个寄存器或内存地址,或者其他选择或控制信息。写字节命令的序列见图 5-5。

图 5-5:写入字节命令格式
写入字命令的序列见图 5-6。

图 5-6:写入字命令格式
因为控制器严格地向外设设备写入数据,所以这些序列中不需要重启命令和额外的地址读/写字节。至于读取字节和读取字命令,设备的设计决定了是否支持写字节、写字或两者都支持。如果设备支持字节和字的写操作,那么控制器必须以某种方式在命令字节中指定写入的大小。
5.1.4.5 SMBus 块读取命令
尽管大多数 SMBus 事务涉及读取或写入单个字节或字,但少数设备支持更大的数据传输。SMBus 块读命令处理数据块的读取。与读字节和读字命令一样,控制器会先传输一个地址字节(LO 位为 0,表示写操作),接着是一个命令字节。然后,控制器会发送一个重复启动操作,后跟一个地址字节,LO 位为 1(表示读取)。外围设备响应并发送一个字节,包含字节计数值,接着发送相应数量的数据字节,如图 5-7 所示。
外围设备通过字节计数值来指定它返回多少字节。理论上,控制器可以通过在命令字节字段中提供字节计数来指定它想要读取的字节数。然而,外围设备的设计决定了由谁来指定返回的字节数;这个值可能是固定的,也可能是编程的值。

图 5-7:块读序列
5.1.4.6 SMBus 块写命令
当然,SMBus 提供了一个与块读命令互补的块写命令。这个命令比块读命令更简单,因为在发送命令字节后,不需要反转数据方向。图 5-8 提供了写操作的顺序。

图 5-8:块写序列
因为不需要重新发送设备地址并改变 R/W 位,这个序列比块读命令操作更短(也更高效)。
5.2 VESA DDC 和 E-DDC
VESA DDC(过时)和 E-DDC(现代)接口允许主机(计算机)系统与显示器(VESA 设备)之间进行通信。DDC/E-DDC(以下简称 E-DDC)是一种基于 I²C 总线的两线通信总线。E-DDC 协议允许主机系统获取显示器信息、设置显示参数(如亮度),并执行其他操作。
与 VESA E-DDC 兼容的显示器在 I²C 总线上看起来像 I²C 外围设备。特别是,兼容设备可以响应(8 位)地址 0xA0、0xA1、0xA4 或 0xA5,以及地址为 0x60 的命令寄存器。主机(计算机)通过这些地址与显示器交换信息。大部分情况下,这些信息是显示器的标识和参数信息。
VESA 仅在 VGA、HDMI 和 DVI 接口中指定了 I²C 信号。DisplayPort 接口使用不同的机制来在主机计算机和显示设备之间传输数据。有关更多详细信息,请参阅 VESA E-DDC 规范(“更多信息”中的链接)。
5.3 ACCESS.bus
ACCESS.bus 系统是早期在 USB 出现之前尝试将低速外围设备如键盘和鼠标连接到计算机系统上的一种方式。其目的是支持热插拔设备,可以在不关闭系统的情况下连接和移除设备,不同于当时的 AT 和 PS-2 键盘。ACCESS.bus 基于 I²C 总线,支持最多 125 个设备。
随着 USB 大约一年后到来,ACCESS.bus 的兴趣迅速减少,尽管它成为了 VESA DDC 通信系统的基础(以及已经废弃的 Apple Desktop Bus)。
5.4 两线接口和两线串行接口
两线接口(TWI)和两线串行接口(TWSI)是不同厂商用来避免与 I²C 的商标和合规性问题而采用的名称。当设备不完全支持完整的 I²C 标准时,一些厂商会使用 TWI—例如,如果设备不支持 START 字节时。有些人也会在总线不支持多个控制器、时钟拉伸或其他 I²C 功能时使用这个术语。一般来说,如果你看到这个术语,通常可以假设设备不完全支持 I²C 标准,尽管如果你不依赖尖端功能,它在你的应用中可能仍然能正常工作。
5.5 小结
本章简要介绍了 I²C 总线的各种协议扩展,包括 SMBus、VESA(DDC 和 E-DDC)、ACCESS.bus 和 TWI。其中,SMBus 和 VESA 总线今天仍然被广泛使用。SMBus 主要用于系统电源管理,而 VESA 变体则用于控制视频显示。
SMBus 协议之所以重要,是因为 Linux 的 I²C 支持基于它。因此,本章花费了大量时间讨论了几种 SMBus 命令,包括快速命令、发送和读取字节命令、读取字节和读取字命令、写入字节和写入字命令,以及块读取和块写入命令。
除非你处理的是这些高级协议所支持的特定设备类别,否则你不太可能需要对这些协议有更深入的了解。尽管如此,理解它们的核心仍然是基于久负盛名的 I²C 总线这一点非常重要。
第二部分
硬件实现
第六章:常见单板计算机上的 I²C

一些常见的单板计算机至少提供一个 I²C 端口,用于与 I²C 外设接口。虽然一些低端开发板可能使用软件实现(或类似硬件实现,如 ATtiny84 的实现),但 I²C 支持几乎在大多数爱好者级和专业级单板计算机上都是普遍存在的。本章简要介绍了许多常见单板计算机上的 I²C 实现。
本章列出的单板计算机远非详尽无遗,主要关注的是使用较广、成本较低的单板计算机。我并不打算提供这些单板计算机上 I²C 接口的低级细节,而是提供一个支持 I²C 通信的各种板子的概述。我会提供各个网页的链接(参见本章末尾的“更多信息”),对于那些有兴趣进一步了解的读者,能帮助他们深入了解这些板子。
当然,许多支持 I²C 接口的新单板计算机将在本书出版后出现。在线章节(可在bookofi2c.randallhyde.com访问)将包括不断更新的关于许多新单板计算机的信息,这些计算机未出现在本章中。
6.1 Arduino 家族
Arduino 家族是连接 I²C 设备时比较流行的选择之一。每当有人用尽 Arduino 板上的有限 I/O 端口(模拟或数字)或想要连接没有简单数字或模拟接口的设备(例如热电偶或小型显示器)时,I²C 接口常常提供了解决方案。
Arduino 并不是一块单独的计算机板。的确,大多数人提到Arduino时,可能是指 Arduino Uno Rev3 单板计算机。然而,存在多种不同的 Arduino 开发板,其中许多板子有不同类型的 I²C 接口。
首先,Arduino^®是 Arduino 的注册商标。然而,Arduino 设计是开源且开放硬件的,因此市场上有许多不同的单板计算机,它们或多或少兼容 Arduino 系统。本书的态度是,任何你能通过 Arduino IDE 和 Arduino 库进行编程的单板计算机都可以视为 Arduino,或者更准确地说,Arduino 兼容。例如,因为你通常通过 Arduino IDE 对其编程,本书认为第三章中使用的 SparkFun ATTO84 开发板(参见第三章的 3.2 节,“基础 ATtiny84 和 ATtiny85 硬件”)是 Arduino 兼容的,即使它缺乏许多常见 Arduino 兼容设备的特性,包括完全支持的硬件 I²C 接口。
由于有大量的与 Arduino 兼容的开发板,讨论“Arduino 上的 I²C 端口”作为标准连接有些困难。Arduino 类设备上的 I²C 端口可能在以下几个方面有所不同:
-
有些端口是 3.3 V,有些是 5 V。
-
不同的 Arduino 类设备可能有零个、一个、两个或更多的硬件 I²C 端口。
-
一些设备可能不支持基于硬件的外设模式。
-
一些设备可能不支持同一 I²C 总线上的多个控制器。
-
一些设备可能不支持其他 I²C 总线功能,例如时钟拉伸。
关键在于,你必须仔细查看你特定“Arduino”板的文档,以确定它支持哪些 I²C 功能(如果它支持 I²C)。
即使是来自 Arduino,你也可以选择各种不同型号的 Arduino。以下小节将介绍一些流行的型号及其对 I²C 的支持。
6.1.1 Arduino Uno Rev3 和 Leonardo
Uno Rev3 是经典的 Arduino 单元。它具有 16-MHz 的 8 位 ATmega328P,具有以下特点:
-
5-V 操作
-
标准 Arduino 引脚排列和扩展总线(用于扩展板)
-
单一 I²C 接口(5 V)
-
32-KB Flash ROM 用于程序存储
-
2-KB RAM
-
1-KB EEPROM(非易失性存储)
图 6-1 显示了 Arduino 总线上的 I²C 线路位置。

图 6-1:Arduino Uno Rev3(和 Leonardo)引脚排列
Arduino Leonardo 使用(大部分)与 Arduino Uno Rev3 相同的 CPU 和引脚排列。主要区别在于 Leonardo 是第一个内置 USB 端口的 Arduino,可以编程为键盘或其他 USB 设备;它还具有 2.5-KB RAM 的 ATmega32u4。当然,Uno Rev3(与原始 Uno 相比)也有内置 USB,因此 Leonardo 相对于 Uno Rev3 的优势较小(尽管 Leonardo 有更多的模拟 I/O 引脚)。有关更多信息,请参见“更多信息”部分。
请注意,本章中出现的 Arduino 引脚排列图来自 Arduino 官网 (www.arduino.cc)。这些图片受到 Creative Commons 4.0 许可协议的保护(可以免费使用,但需要注明出处)。更多法律信息请参见 www.arduino.cc/en/Main/FAQ 和 www.arduino.cc/en/Trademark/HomePage。
6.1.2 Arduino Nano
Arduino Nano 使用与 Arduino Uno Rev3 相同的 CPU,这意味着它们的技术规格是相同的,但 Arduino Nano 被封装成更小的体积,适用于空间有限的应用。图 6-2 显示了 Nano 的引脚排列。

图 6-2:Arduino Nano 引脚排列
Arduino Nano 使用 A4 和 A5 引脚分别作为 SDA 和 SCL 线路;这些是许多 Arduino 单元的 I²C 标准引脚。
6.1.3 Arduino Micro
Arduino Micro 是另一款小型 Arduino 单元。它的 CPU 稍微比 Nano 强大一些:
-
16 MHz 的 ATmega32U4 CPU
-
32-KB Flash 用于程序存储
-
2.5-KB RAM
-
1-KB EEPROM
图 6-3 显示了 Arduino Micro 的引脚排列。

图 6-3:Arduino Micro 引脚排列
I²C 总线出现在 D2 (SDA) 和 D3 (SCL) 引脚上。
6.1.4 Arduino Nano Every
Arduino Nano Every 是 Nano(和 Micro)的更强大版本,同时仍然采用紧凑的外形。Arduino Nano Every 具有以下特点:
-
20 MHz 的 ATMega4809
-
5 V 工作
-
48 KB 闪存存储
-
6 KB RAM
-
256 字节 EEPROM
图 6-4 显示了 Arduino Nano Every 的引脚图。

图 6-4:Arduino Nano Every 引脚图
Nano Every 支持在 D18 (PA2) 和 D19 (PA3) 引脚上的单个 I²C 端口。
6.1.5 Arduino Mega 2560 Rev3
Arduino Mega 2560 Rev3(或简称“Arduino Mega”)是常见的 Arduino 板中体积最大的。它具有以下特点:
-
5 V 工作
-
16 MHz 运行的 8 位 CPU
-
Mega 扩展板功能(这基本上是标准 Arduino 扩展板连接的向上兼容变种)
-
单一 I²C 接口(5 V)
-
256 KB 闪存 ROM 用于程序存储
-
8 KB RAM
-
4 KB EEPROM(非易失性存储)
由于 I/O 引脚数量众多,Arduino Mega 板与 Uno 布局有所不同。然而,大多数标准 Uno 扩展板可以安装在 Mega 板上对应的连接器上(参见 图 6-5)。

图 6-5:Arduino Mega 2560 Rev3 引脚图
Arduino Uno Rev3 向上兼容 Arduino Mega 2560 Rev3 的总线。因此,I²C 引脚与 Uno Rev3 上的物理位置相同;但是,请注意,电气上这些信号出现在 D20 和 D21 信号上,而不是 Rev3 上的 D18 和 D19 信号。
6.1.6 Arduino Zero
Arduino Zero 是一款高性能的 32 位板,基于 ARM Cortex M0+。它有 256 KB 的闪存用于程序存储,32 KB 的 RAM(没有 EEPROM)。在电气上,Arduino Zero 与其 8 位兄弟之间有一个巨大区别:Zero 上的所有引脚都是 3.3 V,包括其 I²C 引脚。请参见 图 6-6 了解 Arduino Zero 引脚图。

图 6-6:Arduino Zero 引脚图
板上的引脚布局与 Arduino Uno 物理兼容。然而,请记住,它们在电气上并不兼容,因为它们是 3.3 V 逻辑。
6.1.7 Arduino Due
Arduino Due 是目前性能最强的官方 Arduino 板。它具有以下特点:
-
基于 Atmel SAM3X8E ARM Cortex M3 的 84 MHz 32 位 CPU
-
512 KB 闪存内存用于程序存储
-
96 KB RAM
-
大量 I/O 引脚(可与 Mega 2560 相媲美)
-
3.3 V 工作
图 6-7 提供了引脚图。

图 6-7:Arduino Due 引脚图
Arduino Due 基本上是 Arduino Mega 2560 的 32 位版本(但使用 3.3 V 引脚)。它去除了四个 ADC 输入并用 DAC 和 CANBUS 端口替代。
6.1.8 其他 Arduino 品牌单板计算机
本节已经涵盖了 Arduino 提供的主要单板计算机(SBC)。然而,本章忽略了几款专为物联网(IoT)设计的额外板子,而且 Arduino 不断推出新产品。访问其官网 www.arduino.cc 以查看最新产品。
6.2 Adafruit 单板计算机
Adafruit 提供许多具有 I²C 功能并与 Arduino 兼容的单板计算机。或许这份名单上的第一款是 Adafruit Metro 328 (www.adafruit.com/product/50),它是 Arduino Uno 的克隆,并做了一些改进。Adafruit 还提供几款小型的、与 Arduino IDE 兼容的板子。以下是本章编写时 Adafruit 提供的一些单板计算机。你可以通过提供的链接找到每款产品的技术信息:
-
Adafruit Metro Mini 328: 一个“口香糖”大小的 Metro 328 版本:
www.adafruit.com/product/2590 -
Adafruit METRO M0 Express: Metro 328 的 32 位版本:
www.adafruit.com/product/3505 -
Adafruit Metro M4 Express AirLift 和 Metro M4 Feat: 32 位版本(AirLift 内建 Wi-Fi)和高速度(120 MHz),支持硬件浮点运算:
www.adafruit.com/product/4000和www.adafruit.com/product/3382 -
Adafruit Grand Central M4 Express: 类似于 Arduino Due,配备高性能的 32 位 CPU 和大量的 I/O 引脚:
www.adafruit.com/product/4064 -
Arduino Pro Mini 328: 另一款小型设备(3.3-V 和 5-V 版本):
www.adafruit.com/product/2378 -
Adafruit FLORA、GEMMA M0 和 Circuit Playground(或 Circuit Playground Express)迷你型可穿戴电子平台设备:
-
Circuit Playground Express:
www.adafruit.com/product/3333 -
Circuit Playground Classic:
www.adafruit.com/product/3000 -
Flora:
www.adafruit.com/product/659 -
Gemma v2:
www.adafruit.com/product/1222 -
Gemma M0:
www.adafruit.com/product/3501
-
-
Adafruit Trinket M0: 一款非常小巧的 32 位单板计算机:
www.adafruit.com/product/3500 -
Adafruit ItsyBitsy M0 Express 和 ItsyBitsy 32u4: Trinket 的现代版本,具有大量的 I/O 引脚:
www.adafruit.com/product/3727和www.adafruit.com/product/3677 -
Adafruit Bluefruit LE Micro: 一款内建蓝牙的迷你单板计算机:
www.adafruit.com/product/2661
由于可用板子的种类繁多,本章未包括每个板子的引脚图。有关信息,请查看 Adafruit 网站。
除了这些单板计算机(SBC),Adafruit 还提供了大量的 Adafruit Feather SBC。有关 Feather 总线的更多信息,请参阅第七章 7.1.2 节,“Feather 总线上的 I²C”。
Adafruit 不断开发支持 I²C 总线的新单板计算机,特别是基于 Feather 的设备。几乎所有 Adafruit 的单板计算机都在硬件上完全支持 I²C。当你阅读本书时,Adafruit 可能已经推出了许多其他未在此列出的板子,毫无疑问,在我从 Adafruit 网站上搜寻信息时可能遗漏了一些。请访问www.adafruit.com/category/17查看 Adafruit 可能开发的任何新款 Arduino IDE 兼容单板。
6.3 SparkFun 单板计算机
SparkFun 是 Adafruit 的“弟弟”。这两家公司都服务于电子和软件爱好者以及创客,提供各种各样的单板计算机(SBC)和可以插入这些 SBC 的模块。虽然 Adafruit 以其 Feather 模块而闻名,SparkFun 则因基于 I²C 技术的 Qwiic(“快速”)总线的创建而著名。SparkFun 提供了多种支持 I²C 的单板计算机,通常使用 Qwiic 连接器。以下是其一些最新的产品:
-
SparkFun RedBoard:SparkFun 版本的 Arduino Uno:
www.sparkfun.com/products/13975和www.sparkfun.com/products/15123 -
SparkFun Qwiic Pro Micro,USB-C:小型 Arduino 兼容模块:
www.sparkfun.com/products/15795 -
SparkFun RedBoard Turbo:RedBoard 的 32 位 Cortex M0+(ARM)变种:
www.sparkfun.com/products/14812 -
SparkFun Pro nRF52840 Mini:一款具有蓝牙功能的小型开发板:
www.sparkfun.com/products/15025 -
SparkFun Thing Plus,ESP32 WROOM:一款基于 ESP-32 的模块,支持蓝牙和 Wi-Fi:
www.sparkfun.com/products/15663 -
SparkFun RedBoard Artemis Nano:一款小型、高性能的单板计算机,配备四个板载 I²C 端口:
www.sparkfun.com/products/15443 -
SparkFun RedBoard Artemis:一款高性能的 32 位 CPU,最多支持六个独立的 I²C 总线,采用 Arduino Uno 外形:
www.sparkfun.com/products/15444 -
SparkFun RedBoard Artemis ATP:Artemis 的超大尺寸版本,最多支持六个 I²C 总线:
www.sparkfun.com/products/15442 -
SparkFun Thing Plus,Artemis:一种在 Feather 尺寸下的 Artemis 模块(请参见第七章第 7.1.2 节“Feather 总线上的 I²C”),支持两个 I²C 总线:
www.sparkfun.com/products/15574 -
SparkFun Thing Plus,SAMD51:基于 Cortex M4(ARM)高性能模块,具有非常小的占地面积:
www.sparkfun.com/products/14713 -
FreeSoC2 开发板:基于 Freescale Cortex M3(ARM)的开发板:
www.sparkfun.com/products/13714 -
SparkFun RED-V RedBoard:基于 RISC-V 的 Arduino Uno 形式的开发板。请注意,该开发板无法使用 Arduino IDE 进行编程(至少在本文撰写时是如此):
www.sparkfun.com/products/15594 -
SparkFun RED-V Thing Plus:RED-V 的小型版本:
www.sparkfun.com/products/15799 -
SparkFun Edge 开发板,Apollo3 Blue:基于 Cortex M4 的边缘计算(AI)开发模块:
www.sparkfun.com/products/15170 -
Alchitry Au FPGA 开发板:根本不是一块单板计算机。这是一个支持 I²C 的现场可编程门阵列(FPGA)模块。提供两个版本:Alchitry Au(黄金版)
www.sparkfun.com/products/16527和 Alchitry Cu(铜版)www.sparkfun.com/products/16526。
由于可用开发板种类繁多,本章将不包括每块开发板的引脚图。有关这些信息,请参见 SparkFun 网站。更多关于本书中 Qwiic 总线的信息,请参阅第七章第 7.2 节“SparkFun Qwiic 总线上的 I²C”。
和往常一样,你可以预期自本章撰写以来,此清单已经扩展。请访问 SparkFun 网站(www.sparkfun.com)查看当前可用的产品。
6.4 Teensy 系列
PJRC 的 Teensy 系列是一套受欢迎的微控制器开发板,你可以使用 Arduino IDE 和 PJRC 提供的库进行编程。Teensy 系列受到设计师的喜爱,他们希望使用非常小的(敢说是微小的)MCU 模块来编程高性能的嵌入式系统。
在本文撰写时,PJRC 正在销售八种不同的 SBC 模块:
-
Teensy 2.0:基于 8 位 CPU(即将被弃用)。提供一个 I²C 端口。
-
Teensy++ 2.0:Teensy 2.0 的扩展(I/O)版本(也即将被弃用)。提供一个 I²C 端口。
-
Teensy LC:Teensy 的低成本 32 位版本,仅支持 3.3 V。提供两个 I²C 端口。
-
Teensy 3.2:基于 32 位 Cortex M4(ARM)CPU,主频为 72 MHz(可超频至 96 MHz),支持 3.3 V 和 5 V 容错引脚。大小约为两张邮票的尺寸。提供两个 I²C 端口。
-
Teensy 3.5:一款 32 位的 Cortex M4 CPU,运行频率为 120 MHz,支持 3.3V 且具有 5V 耐受引脚。尺寸约为一大块口香糖的大小。提供三个 I²C 端口。
-
Teensy 3.6:一款 32 位的 Cortex M4 CPU,运行频率为 180 MHz,仅支持 3.3V(没有 5V 耐受引脚)。尺寸约为一大块口香糖的大小。提供四个 I²C 端口。
-
Teensy 4.0:一款 32 位的 Cortex M7(ARM)CPU,运行频率为 600 MHz(可超频至 1 GHz),仅支持 3.3V。尺寸约为两个邮票的大小。提供三个 I²C 端口。
-
Teensy 4.1:一款 32 位的 Cortex M7(ARM)CPU,运行频率为 600 MHz(可超频至 1 GHz),仅支持 3.3V。Teensy 4.0 的扩展 I/O 版本,支持以太网和 SD 卡。尺寸约为一大块口香糖的大小。提供三个 I²C 端口。
所有 Teensy 的 I²C 接口都由硬件控制。通过 Teensy 特定的库代码,你可以以 400 MHz 的速度操作这些 I²C 接口。了解这些单板计算机,请访问www.pjrc.com/store/index.xhtml。
6.5 其他兼容 Arduino 的单板计算机
由于 Arduino 的设计是开源和开放硬件,许多不同的公司生产兼容 Arduino 的板子。如果你足够仔细,可以在线找到一些不到 10 美元的中国仿制品。一个值得关注的供应商是 Seeed Studio,他们在我写这段话时,正在线上广告出售低于 8 美元的 Arduino 兼容板子(www.seeedstudio.com/Seeeduino-V4-2-p-2517.xhtml)。Seeed Studio 还推广了 Grove 互联总线(见第七章“Seeed Studio Grove 总线上的 I²C”,第 7.4 节),它拥有一个庞大的传感器和其他设备生态系统,这些设备可以与 Grove 兼容的板子连接。
如果你对其他兼容 Arduino 的板子感兴趣,可以查看以下 Wikipedia 页面,列出了 Arduino 和兼容设备:en.wikipedia.org/wiki/List_of_Arduino_boards_and_compatible_systems。
许多不同的板子都兼容 Arduino IDE,涵盖了各种不同的性能级别、内存能力和 I/O 能力。几乎所有兼容 Arduino 的板子至少都包含一个硬件 I²C 接口,因为 I²C 是扩展 Arduino 设备 I/O 能力的最常见方式之一。
6.6 树莓派
在 Arduino 单板计算机之后,树莓派可能是最常用的单板计算机,用于通过 I²C 与现实世界的设备进行接口。图 6-8 展示了树莓派 GPIO 连接器上的引脚,其中硬件I²C 引脚的位置。我强调“硬件”,因为树莓派还支持软件控制的 I²C 端口(稍后会详细介绍)。

图 6-8:树莓派的 GPIO 引脚图(40 引脚连接器)。此图像由树莓派基金会版权所有。根据 Creative Commons 4.0 许可协议,允许在此处使用。
树莓派是一个 3.3V 设备。因此,必须仅将 3.3V 的 I²C 设备连接到树莓派的 GPIO 接口。连接 5V 设备可能会损坏树莓派。如果需要使用 5V 设备,务必使用电平转换器,例如 TXB0104 双向电平转换器(www.adafruit.com/product/1875)。
树莓派 I²C 接口存在一些已知问题。特别是,它不支持(至少在写作时)硬件时钟拉伸,因此无法连接通过拉伸时钟添加等待状态的设备。不过,可以指定较低的时钟频率;在某些情况下,这会解决问题。也可以使用基于软件的 I²C 系统(使用一对 GPIO 引脚作为 SDA 和 SCL)。基于软件的解决方案支持时钟拉伸。有关如何在树莓派上设置软件 I²C 端口的详细信息,请参见 github.com/fivdi/i2c-bus/blob/master/doc/raspberry-pi-software-i2c.md。有关树莓派 4 上时钟拉伸的信息,请参见 forums.raspberrypi.com/viewtopic.php?t=302381。
6.7 树莓派 Pico
2021 年,树莓派团队推出了他们自己的嵌入式微控制器 RP2040,并随之发布了一个小型单板计算机——树莓派 Pico。该设备旨在与 Arduino 板在小型实时嵌入式应用中竞争。RP2040 具备以下特性:
-
双核 Arm Cortex M0+,运行频率为 133 MHz
-
264KB 的片上 RAM
-
通过专用 QSPI 总线支持最多 16MB 的外部闪存(Pico 板提供 2MB 的闪存 ROM)
-
DMA 控制器
-
插值器和整数分频器外设
-
30 个 GPIO 引脚,其中 4 个可以用作模拟输入
-
两个 UART、两个 SPI 控制器和两个 I²C 控制器
-
16 个 PWM 通道
-
一个 USB 1.1 控制器和 PHY,支持主机和设备模式
-
八个树莓派可编程 I/O (PIO) 状态机
-
支持 UF2 的 USB 大容量存储启动模式,支持拖放编程
-
写作时价格仅为 4 美元
树莓派 Pico 支持两个独立的 I²C 端口,可以将其分配给设备上的任何一对数字 I/O 引脚(参见 图 6-9)。树莓派 Pico 是一个 3.3V 设备,这意味着只能将 3.3V 的 I²C 设备连接到 Pico 的 I²C 引脚。连接 5V 设备可能会损坏树莓派 Pico。如果需要使用 5V 设备,请使用电平转换器。

图 6-9:树莓派 Pico 上的 GPIO 引脚分配(40 引脚排头)。此图像由树莓派基金会版权所有,依据 Creative Commons 4.0 许可在此使用。
虽然树莓派 Pico 不是速度最快的小型开发板(Teensy 4.x可能在更流行的低成本开发板中荣登此榜首),但它以 133 MHz 的主频,依然不算慢。而且,与大多数 Arduino 级别的 SBC 不同,Pico 实际上在板上有两个 CPU 核心,你可以使用 Pico SDK(C/C++)或 Micro Python 进行编程。在写作时,一些富有创新精神的人已经破解了 Arduino IDE 来编程 Pico。不久后,Arduino 官方提供了支持(www.tomshardware.com/news/raspberry-pi-pico-arduino-official)。
6.8 BeagleBone Black
BeagleBone Black 是一个低成本、开源且开放硬件的树莓派替代品。从技术上讲,它提供了三个独立的 I²C 接口,尽管在标准(默认)配置中,只有一对线路(I2C2)可用。SCL 和 SDA 线路分别出现在 BeagleBone Black 的 P9 连接器上的 19 和 20 号引脚(见图 6-10)。
与树莓派不同,BeagleBone Black 似乎能够很好地支持时钟延伸。I²C 接口可以在 100 kHz 或 400 kHz 下运行。

图 6-10:BeagleBone Black 的 P9 引脚分布
6.9 PINE A64 和 ROCKPro64
PINE A64 和 ROCKPro64 SBC 的推出旨在打造一个非常低成本、64 位的、类似树莓派的系统。原版 Pine A64 售价为 15 美元。ROCKPro64 是一个高端单板计算机,配备了板载 EMMC(闪存)存储、六个计算核心、USB-C/3.0 接口、PCI-e 插槽等更多功能。两个开发板都包括一个 40 针连接器,与树莓派的 GPIO 连接器兼容,包括 I²C 接口。
如需了解更多关于 PINE64 产品的信息,请访问www.pine64.org。
6.10 Onion Omega
Onion Omega 系列由一些非常小巧、低成本的 SBC 组成,运行 Linux 变种。Onion Omega2+配备了 MT7688 SoC,集成了 580 MHz 的 MIPS CPU、Wi-Fi 和 10/100 Mbps 以太网接口。它包括 128MB 的 DDR2 DRAM 内存和 32MB 的板载闪存存储——并且支持 I²C。一个典型的 Onion Omega2+模块售价低于 15 美元,但购买系统中的其他模块可能会提高价格,具体取决于你添加的功能。
尽管 Onion Omega2+模块非常小巧(大约 1 英寸 × 1.7 英寸,或 2.5 厘米 × 4.4 厘米),实际的系统尺寸稍大一些,因为 Onion 系统由一系列可堆叠的模块组成。例如,为了方便访问 I²C 引脚,你可能需要使用 Onion Dock 模块(虽然这是可选的,因为 Omega Onion2+模块本身也有一些适合面包板使用的 I²C 引脚,你可以直接将其连接到电路中)。Omega2 的引脚分布如图 6-11 所示。

图 6-11:Onion Omega2+引脚分布
你可以在onion.io/omega2找到更多关于 Onion 产品的信息。SparkFun 销售 Onion Omega 系列产品;你可以访问其网站www.sparkfun.com/search/results?term=onion。
6.11 STM32 单板计算机系列
STMicroelectronics 生产了一系列用于其 ARM 微控制器的开发板。这些单板计算机提供了不同的评估环境,适用于不同速度和 I/O 量的 CPU。许多开发板可以使用 Arduino IDE 进行编程,尽管 STMicroelectronics 还提供了适用于更专业用途的开发软件,以及运行在各种 STM32 板上的 MBED 实时操作系统。
尽管 STMicroelectronics 的开发板列表过长,无法在此列出,但以下部分提供了本书中使用的几款开发板的详细信息。
6.11.1 STM32F767/Nucleo-144
STM32F767/Nucleo-144 基于 216 MHz 的 ARM Cortex M7 处理器。它配备了 2MB 的闪存、512KB 的 RAM、USB 和以太网接口以及其他 I/O 接口。它提供了一组与 Arduino Uno V3 兼容的接头(但仅支持 3.3 V)。
因为 STM32F767/Nucleo-144 提供了一套(3.3 V)连接,这些连接大多与 Arduino Uno 兼容,所以你可以在常见的位置找到 I²C 引脚,这些引脚分别是设备上的 PB8(SCL)和 PB9(SDA)(也对应 Arduino 的 A4 和 A5 引脚)。有关该开发板的更多信息,请访问www.st.com/en/evaluation-tools/nucleo-f767zi.xhtml。
6.11.2 STM32F746G-Disco
STM32F746G-Disco(如发现套件,而非迪斯科音乐)是另一款 216 MHz、M7 处理器的设备,具有与 Arduino 兼容的引脚布局(但仍仅支持 3.3 V),配备了彩色 LCD 显示屏、以太网、USB、音频、摄像头接口及其他 I/O 接口。由于它包含与 Arduino 兼容的扩展板连接器,你可以在常见的位置找到 I²C 引脚。有关该模块的更多信息,请访问 STMicroelectronics 官方网站www.st.com/en/evaluation-tools/32f746gdiscovery.xhtml。
6.11.3 STM32 开发板一览
STMicroelectronics 生产了多种不同 I/O 数量和类型的评估板。如果你对这些开发板感兴趣,可以访问产品评估网站www.st.com/en/evaluation-tools/mcu-mpu-eval-tools.xhtml#products。
6.12 NetBurner MOD54415
本章介绍的大多数单板计算机(SBC)可能都可以归类为爱好者、探索型或评估型设备。尽管很多真实世界的专业系统使用这些板卡,但专业嵌入式工程师可能会质疑这些单板计算机的资质。就个人而言,我避免进行关于“专业”与“爱好者”之间的宗教或精英主义辩论。如果一个板卡能够完成任务,那就足够成为使用它的理由。然而,除了 Arduino 和 Raspberry Pi 单板计算机,还有更广阔的世界,而我不提到至少一个在真实世界中常用的、用于专业嵌入式应用的严肃单板计算机,就有失公允。
其中一个板卡是 NetBurner MOD54415 单板计算机,这是一款基于 250 MHz Coldfire CPU 并运行µC/OS 实时操作系统的小型板卡(NetBurner 还有 MODM7AE70,这是一个运行在 300 MHz 的 Cortex M7 变种)。良好的 TCP/IP 协议栈和 RTOS 的使用使得 NetBurner 产品区别于许多爱好者级别的产品。我将在本书后面提供多个使用 MOD54415 的编程示例。
欲了解更多关于 NetBurner 产品的信息,请参见www.netburner.com。
6.13 个人计算机上的 I²C
尽管大多数 PC(Windows、macOS 和 Linux)出于内部原因(例如,电源管理的 SMBus 和显示器上的 VESA E-DDC)支持 I²C 通信,但很少有用户程序能够访问这些 I²C 控制器。即使有,也不容易将 I²C 外设连接到这些总线。假如你的 PC 有一个 I²C 端口,可以直接与 I²C 外设接口,岂不是很方便?
好吧,有几种方法可以实现这一点。当然,你可以使用 Arduino 或其他单板计算机(SBC)作为协处理器,通过 PC 来处理 I²C 通信。不过,这样做会涉及很多工作。另一种方法是使用 Bus Pirate 或 I²C 驱动器设备(请参见第 4.3 节《I²C 驱动器》和第 4.4 节《Bus Pirate》)。这些设备允许你通过各种命令(使用 Bus Pirate 的终端窗口或 I²C 驱动器的应用程序)从 PC 向 I²C 总线发送命令。虽然使用这些设备可以让你从 PC 应用程序控制 I²C,但它并不非常方便。
幸运的是,FTDI(www.ftdichip.com)创造了一款集成电路,能够为你完成大部分工作。Adafruit 的团队基于这款芯片制作了一个小型板卡,让你可以通过 PC 的 USB 端口控制 I²C。这个设备就是 Adafruit FT232H Breakout——通用 USB 到 GPIO、SPI、I²C——USB C & Stemma QT/Qwiic(www.adafruit.com/product/2264)。顾名思义,它将 USB 连接到 I²C 总线、SPI 总线或通用 GPIO 引脚。结合 FTDI 提供的一些库,你可以轻松地通过 PC 应用程序直接控制 I²C 外设。
除了 FT232H Breakout,Adafruit 还销售一款小型板子——Adafruit MCP2221A Breakout – 通用 USB 到 GPIO ADC I2C – Stemma QT/Qwiic (www.adafruit.com/product/4471),它同样能够通过 USB 驱动 I²C 总线。虽然它提供的 GPIO 引脚数量不如 FT232H Breakout,但价格大约只有其一半。它还包括一个 Qwiic 连接器(在 Adafruit 术语中称为Stemma QT),便于与 Qwiic 兼容设备连接。
6.14 章节总结
本章提供了关于多种支持 I²C 通信的业余爱好者级和专业级 SBC 的通用概述,并简要提及了使用传统 PC 的控制器 I²C 设备。其列出的 SBC 并不完全。如果你想了解更多本章讨论的产品,请查看与每个项目相关的网页链接。若要深入了解本章未涉及的众多 SBC,请参见接下来的“更多信息”部分。
第七章:I²C 在厂商总线上的应用

本章介绍了三种流行的厂商总线,它们支持 I²C 信号:Adafruit Feather 总线、SparkFun Qwiic 总线(Adafruit 称其为 Stemma QT)和 Seeed Studio Grove 总线。数百种外设设备可以连接到这些总线,因此,如果你想在系统中使用大量现有外设,了解这些总线是至关重要的。你也很可能在现有系统中遇到这些总线。
要连接一对 I²C 设备,您只需要三根线:SCL、SDA 和 Gnd。像 Adafruit、Seeed Studio 和 SparkFun 这样的公司销售各种 I²C 扩展板,这些扩展板通过带有这三种信号的引脚连接到系统。
不幸的是,扩展板设计师往往将 IC 的信号引出到板上的任意插针。即使是同一制造商的两个扩展板,也可能将 SCL 和 SDA 信号引出到不同的引脚组。这使得交换不同的扩展板变得困难。一些公司曾尝试使用一致的互连方案来组织这些信号,以及接地和电源线。不幸的是,这些系统并未得到广泛采用,因此你无法使用相同的连接器将来自不同厂商的模块混合使用。
在某些方面,Arduino、Raspberry Pi 和 BeagleBone 的 GPIO 接口为将 I²C 设备连接到嵌入式系统提供了事实上的标准。通常可以看到 扩展板(Arduino 附加板)、帽子(Raspberry Pi 附加板)和 外壳(BeagleBone 附加板)将 I²C 外设连接到这些计算机系统。然而,这些附加板体积较大,成本相对较高。若要在这些平台上构建 I²C 外设是浪费空间和资金的,因为实际连接通常只需要四根线(SDA、SCL、Gnd 和电源)。
因此,厂商创建了 Feather、Qwiic、Grove 和其他总线,将 I²C 信号以及有时的其他信号,整合到更紧凑的外形中。这些总线为 I²C 设备提供了一个明确定义的机械连接器,允许您轻松连接外设和控制器,通常只需通过插入一根电缆连接这两个设备,无需焊接。许多控制器和外设设备支持这些总线,使得用它们原型化系统变得更加容易。
7.1 Adafruit Feather 总线
Adafruit 开发了 Feather 总线,以满足行业对具有更小外形尺寸的、与 CPU 和外设无关的平台的需求,这个平台可以与任何组合的适当 CPU 和外设配对。随着 Arduino 设备的流行,一些设计师希望能够拥有可以轻松适应小空间,甚至作为服装的一部分的更小型设备。标准的 Arduino Uno 和 Mega 2560 板对于这个目的来说过于庞大。为了解决这个问题,Arduino 公司开发了 Arduino Micro 和 Nano 设备。同样,第三方也开发了像 Adafruit Trinket 系列这样的设备。然而,所有这些新设备都使用了不同的信号引脚布局。因此,类似于 Arduino Uno 的扩展板的充满活力的生态系统并没有发展起来。这使得设计能够轻松更换不同 CPU、外设、电池组和其他设备的产品变得困难,就像标准的 Arduino Uno 平台一样。
Feather 总线通过在 28 个针脚(一个插头上 16 个针脚,另一个插头上 12 个针脚)上使用一组固定信号,定义了任意 CPU 与任意外设设备之间的标准化物理连接,解决了这个问题。由于信号线在 Feather 总线上处于固定位置,Adafruit 和许多其他供应商能够创建一整套附加板,这些板可以在支持 Feather 总线的各种 SBC 上使用。
Feather 总线在需要紧凑、低功耗设计的服装设计师和其他系统工程师中变得非常流行。尽管它包含了许多超出 I²C 的信号,但它的小巧外形使得即使只连接 I²C 线、电源和 Gnd,它也是一个合理的平台。许多供应商已经生产了 Feather 总线组件,创造了一个庞大的 Feather 生态系统。
Feather 总线支持两种基本组件类型:Feathers 和 FeatherWings。Feather 是一个小型 SBC,提供各种模拟、数字信号和其他信号,包括 I²C。不同的 Feather 实现包括 8 位和 32 位 CPU,具有广泛的性能、内存和功耗特点,使得系统设计师能够选择最适合当前任务的 CPU 或其他 Feather 特性,同时仍然可以将各种 Feather 外设连接到该 SBC。
FeatherWing 是一种附加板,类似于 Arduino 扩展板、Pi HAT 或 BeagleBone Cape,用于连接外设到控制器。虽然通常只连接一个 FeatherWing 到一个 Feather,但也可以通过堆叠 FeatherWings 或使用 FeatherWing 扩展板,将多个 FeatherWings 连接到一个 Feather(SBC)。这使得你可以例如,将 OLED 显示屏和以太网 FeatherWings 连接到同一个 Feather。
某些 FeatherWing 板可能不会使用总线上的所有 I/O 引脚;实际上,许多 FeatherWings 仅使用总线上的 I²C 和 Gnd 引脚,并且不会连接到其他引脚。许多 FeatherWings 也根本不使用 I²C 信号——它们可能只使用一些数字或模拟引脚,或者可能使用 Feather 总线上的 SPI 引脚。本书仅讨论那些使用总线 I²C 引脚的 FeatherWings。
Adafruit 提供了 Feathers 和 FeatherWings 的正式规格;有关更多细节,请参见 Adafruit 特性总线规范(在本章末尾的“更多信息”部分提供了链接)。Feather 的基本规格如下:
-
标准的 Feathers 和 Wings 尺寸为 0.9 英寸 × 2.0 英寸,每个角落都有 0.1 英寸的孔。Feather 的长度可能会有所不同,但宽度应始终保持 2.0 英寸。
-
一个 16 引脚的插针条位于底部,居中,距离左侧边缘 1.0 英寸。
-
一个 12 引脚的插针条位于顶部,距离左侧 1.2 英寸。
-
两个插针条之间的间距必须保持 0.8 英寸,以确保与 FeatherWings 的兼容性。
-
所有的 Feathers 和 FeatherWings 都使用 3.3V 逻辑电平来处理所有数字输入和输出。模拟输入可能有所不同,但通常也是 3.3V。
SparkFun 将其兼容 Feather 的板子称为 Thing Plus 板。Particle 也有一套兼容 Feather 的板子,称为 Photon。详情请参阅他们的网站(在“更多信息”部分提供了链接)。
7.1.1 Feather 总线引脚定义
图 7-1 显示了典型的 Feather 总线引脚布局。我之所以说“典型”,是因为引脚分配并不是绝对的,并非所有 Feather 都支持 Feather 总线上所有的引脚类型。例如,并非所有设备都支持六个模拟输入。每当某个特定的 CPU 不支持某种特定的引脚类型时,该 Feather 会尝试将其他适当的 CPU 功能映射到该引脚上。例如,如果某个 CPU 不支持六个模拟输入,它的 Feather 可能会用一个数字 I/O 引脚来代替。

图 7-1:典型的 Feather 总线引脚布局
在 图 7-1 中,我为 16 引脚和 12 引脚的插针标注了引脚编号。然而,这并不是标准的 Feather 总线功能。Feather 的引脚通常是根据其功能来识别的,而不是按引脚编号。
通常,Feather 总线上的引脚可以根据 CPU 的特性集承担其他功能。例如,大多数 Arduino 类的 CPU 允许在不需要模拟输入功能的情况下将模拟引脚重新定义为数字 I/O 引脚,因此许多 Feather 支持在 A0 到 A5 引脚上的数字 I/O。同样,一些 CPU 提供数字到模拟输出的功能。根据惯例,大多数具有此功能的 Feather 会尝试将 DAC 输出映射到 A0 引脚,因此需要此功能的各种 FeatherWings 可以在公共引脚上找到它。
A0 到 A5 引脚的标记并不一定对应于 Arduino 的模拟引脚编号。例如,Adafruit Feather 32u4 Basic 映射了在表 7-1 中显示的 Arduino 模拟引脚。
表 7-1:Arduino 与 Feather 引脚映射
| Arduino 引脚 | Feather 引脚 |
|---|---|
| ADC 7 | A0 |
| ADC 6 | A1 |
| ADC 5 | A2 |
| ADC 4 | A3 |
| ADC 1 | A4 |
| ADC 0 | A5 |
Feather 通常保持与 Arduino 数字引脚相同的编号,因为 I²C 和 SPI FeatherWings 常常使用这些引脚作为中断输入和芯片选择信号。例如,D10 是 SPI 总线的常用芯片选择信号。
7.1.2 Feather 总线上的 I²C
本书的主题是 I²C 总线,而非 Feather 总线。尽管许多 Feather 总线引脚对想使用 I²C FeatherWing 的人可能会很有用——例如,提供复位引脚或中断输入——但本书中的主要关注点是 SCL(在图 7-1 中为第 27 引脚)和 SDA(第 28 引脚)引脚。因为 SDA 和 SCL 引脚始终连接到 Feather 总线上的相同引脚,所以任何基于 I²C 的 FeatherWing 外设都可以自动工作。
由于 Feather 总线是一个 3.3V 专用总线,SDA 和 SCL 引脚有拉高电阻连接到 3.3V 电源。如果 FeatherWings 在这些引脚上施加 5V 电压,可能会损坏底层的 Feather CPU。
Feather 通常会提供自己的拉高电阻,大多数 FeatherWings 也是如此。如果你将多个 FeatherWings 连接到单个 Feather,每个 FeatherWing 都有自己的拉高电阻,则累计的拉高电阻可能会降低到一个较低的值,从而干扰 I²C 总线的正常工作。那些提供自有 I²C 拉高电阻的 FeatherWings 通常会提供可以切断的焊接跳线,以便从引脚中移除电阻(或者,至少在文档中会描述应该移除哪些电阻)。有关更多详细信息,请查阅你的 FeatherWing 文档。
7.1.3 多控制器操作
Feather 总线假设有一个单独的 Feather(控制器)控制一个或多个 FeatherWings(外设)。Feather 总线一般不支持多控制器操作。你或许能将一个独立的控制器设备接入 SDA 和 SCL 引脚,但 Feather 的 CPU 可能不支持这种操作。通常,你应该假设 Feather 总线是单控制器操作。当然,多个外设是完全可以的。
7.1.4 Feathers 和 FeatherWings
Adafruit 和其他几家制造商生产了种类繁多的 Feather CPU 模块和更为广泛的 FeatherWings 外设。尽管每个 Feather 模块都支持 I²C,但并非所有的 FeatherWings 都使用 I²C:有些使用 SPI 总线,有些则仅使用 Feather 总线上的数字和模拟引脚。表 7-2 列出了多个供应商提供的许多 Feather 模块。
表 7-2:常见的 Feather 模块
| 名称 | 制造商 | 描述 | 链接 |
|---|---|---|---|
| nRF52840 Express | Adafruit | 带 Cortex M4 CPU 的蓝牙 LE | www.adafruit.com/product/4062 |
| 32u4 Bluefruit LE | Adafruit | 带 ATmega32u4 CPU 的蓝牙 LE | www.adafruit.com/product/2829 |
| M0 Bluefruit LE | Adafruit | 带 Cortex M0 CPU 的蓝牙 LE | www.adafruit.com/product/2995 |
| M0 WiFi | Adafruit | 带 Cortex M0+ CPU 的 WiFi | www.adafruit.com/product/3010 |
| HUZZAH32ESP32 | Adafruit | 带有 ESP 32 CPU 的 WiFi 和蓝牙 | www.adafruit.com/product/3405 |
| Feather 32u4 RFM95 | Adafruit | LoRa 无线电和 ATmega32u4 CPU | www.adafruit.com/product/3078 |
| M0 RFM69HCW 数据包无线电 | Adafruit | 数据包无线电收发器和 Cortex M0 CPU | www.adafruit.com/product/3176 |
| STM32F405 Express | Adafruit | 高性能 Cortex M4 CPU | www.adafruit.com/product/4382 |
| WICED WiFi | Adafruit | 带 Cortex M3 CPU 的 WiFi | www.adafruit.com/product/3056 |
| Teensy 3.x Feather 适配器 | Adafruit | 将 Teensy 3.2 连接到 Feather 总线 | www.adafruit.com/product/3200 |
| Thing Plus SAMD51 | SparkFun | Cortex M4 CPU | www.sparkfun.com/products/14713 |
| Thing Plus Artemis | SparkFun | Artemis 模块(Cortex M4F,用于机器学习) | www.sparkfun.com/products/15574 |
| Thing Plus ESP32 WROOM | SparkFun | 带 ESP32 模块的 WiFi | www.sparkfun.com/products/15663 |
| RED-V Thing Plus | SparkFun | RISC-V CPU | www.sparkfun.com/products/15799 |
| Particle Boron LTE | Particle | 蜂窝调制解调器和 nRF52840 SoC(Cortex M4)CPU | www.adafruit.com/product/3998 |
| Particle Argon | Particle | 带有 nRF52840 和 ESP32 处理器的 WiFi 和蓝牙 | docs.particle.io/argon |
表 7-3 列出了通过 I²C 总线接口的部分 FeatherWings 模块。
表 7-3:基于 I²C 的 FeatherWing 模块示例
| 名称 | 制造商 | 描述 | 链接 |
|---|---|---|---|
| 128×64 OLED | Adafruit | 用于 Feather 的小型 OLED 显示屏 | www.adafruit.com/product/4650 |
| RTC 加 SD 扩展板 | Adafruit | 基于 I²C 的实时钟和基于 SPI 的 SD 卡接口 | www.adafruit.com/product/2922 |
| DS3231 精密 RTC | Adafruit | 高精度实时时钟 | www.adafruit.com/product/3028 |
| 8 通道 PWM 或伺服 | Adafruit | 八通道电机(伺服)控制器 | www.adafruit.com/product/2928 |
| 4 位 7 段 LED 矩阵显示 | Adafruit | 七段显示驱动器 | www.adafruit.com/product/3088 |
| AMG8833 红外热成像相机 | Adafruit | 红外热成像相机 | www.adafruit.com/product/3622 |
| 14 段字母数字 LED | Adafruit | 14 段显示驱动器 | www.adafruit.com/product/3089 |
| 8×16 LED 矩阵 | Adafruit | 8×16 LED 矩阵驱动器 | www.adafruit.com/product/3090 |
| Joy FeatherWing | Adafruit | 摇杆和游戏按钮适配器 | www.adafruit.com/product/3632 |
| LSM6DSOX 加上 LIS3MDL FeatherWing 精密 9-DoF IMU | Adafruit | 九自由度传感器 | www.adafruit.com/product/4565 |
| ADXL343 加上 ADT7410 传感器 | Adafruit | 运动与温度传感 | www.adafruit.com/product/4147 |
| Qwiic Shield for Thing Plus | SparkFun | 从 Feather 总线中分离的 Qwiic 接口 | www.sparkfun.com/products/16790 |
请参阅“更多信息”部分,获取 Adafruit Feather 模块、SparkFun 模块和销售 Feathers(SBCs)以及 FeatherWing(外设)板的供应商的完整且最新的链接列表。
7.2 SparkFun Qwiic 总线上的 I²C
与 Feather 总线不同,SparkFun Qwiic 总线严格来说是一个 I²C 总线。Qwiic 的理念是创建一个标准的插头和插座,用于连接 I²C 设备。与 Adafruit Feather 总线一样,Qwiic 接口因其广泛的兼容性而变得非常流行,许多兼容产品都采用了 Qwiic 总线,包括外设和 CPU 模块。
Qwiic 接口是四针 JST SH 接口,针脚非常小(1 毫米间距)。这些连接器具有在 表 7-4 中给出的标准引脚布局。
表 7-4:Qwiic 接口引脚图
| 引脚 | 功能 |
|---|---|
| 1 | Gnd |
| 2 | Vcc |
| 3 | SDA |
| 4 | SCL |
图 7-2 显示了连接器布局。

图 7-2:Qwiic 接口引脚图
Qwiic 总线仅支持 3.3 V。将 5 V 设备连接到 Qwiic 总线上可能会损坏设备、总线上的其他设备或总线上的控制器。
虽然 Qwiic 总线理论上可以支持多个控制器,但与 Feather 总线一样,物理约束阻止了这一点。例如,控制器通常负责在 Qwiic 总线上提供 3.3V。如果两个不同的控制器分别为总线提供不同的 3.3V 值,这可能会导致一些问题。理论上,你可以从多个控制器切换 3.3V,并使用独立的 3.3V 电源或允许单个控制器提供电压。但实际上,你可能会遇到问题。
Adafruit 也制造了几种连接到 Qwiic 总线的控制器和外围设备。虽然它有一些“纯”Qwiic 设备,但大多数模块使用 STEMMA/QT 名称而不是 Qwiic。STEMMA/QT 与 Qwiic 向上兼容,主要区别在于它支持 5V 设备和 3.3V 设备。STEMMA/QT 外围设备在每个板上都包含电平转换电路,使它们可以正常工作于 3.3V 或 5V 信号线上。理论上,这是一个很好的想法,允许在总线上同时使用 5V 和 3.3V 部件。但实际上,几乎所有的 Qwiic 总线外围设备和控制器今天都是 3.3V,因此这种扩展似乎是多余的。
与 Feather 总线类似,许多不同的制造商已经创建了与 Qwiic 总线兼容的产品:SparkFun 有数十块板,Adafruit 制造了几块,Smart Prototyping 制造了很多(如 Zio;在“更多信息”中提供了链接),其他许多公司也是如此。请参阅下一节“Qwiic 总线外围设备”,了解可用的 Qwiic 兼容产品的小样本。
7.3 Qwiic 总线外围设备
在撰写本文时,SparkFun 已经创建了超过 150 种不同的 Qwiic 兼容模块(SBC 和外围设备)。许多其他供应商也生产 Qwiic 兼容板。表 7-5 提供了可以购买的 Qwiic 外围设备的小样本。
表 7-5:Qwiic 外围设备
| 名称 | 制造商 | 描述 | 链接 |
|---|---|---|---|
| Zio Qwiic MUX | 智能原型 | 八通道 I²C 多路复用器 | www.smart-prototyping.com/Zio-Qwiic-Mux.xhtml |
| Zio OLED 显示器 | 智能原型 | 128×32 OLED 显示屏 | www.smart-prototyping.com/Zio-OLED-Display-0-91-in-128-32-Qwiic.xhtml |
| Zio 16 舵机控制器 | 智能原型 | 16 通道舵机控制器 | www.smart-prototyping.com/Zio-16-Servo-Controller.xhtml |
| Zio Qwiic IO 扩展器 | 智能原型 | 16 通道 GPIO 扩展器 | www.smart-prototyping.com/Zio-Qwiic-IO-Expander.xhtml |
| Zio 4 DC Motor Controller | Smart Prototyping | 双通道双向电机控制 | www.smart-prototyping.com/Zio-4-DC-Motor-Controller.xhtml |
| Zio TOF Distance Sensor RFD77402 | Smart Prototyping | 飞行时间距离测量(10 cm 至 200 cm) | www.smart-prototyping.com/Zio-TOF-Distance-Sensor-RFD77402.xhtml |
| Zio 9DoF IMU BNO055 | Smart Prototyping | 九自由度位置测量 | www.smart-prototyping.com/Zio-9DOF-IMU-BNO055.xhtml |
| 9DoF Sensor Stick | SparkFun | 九自由度位置测量 | www.sparkfun.com/products/13944 |
| 6 Degrees of Freedom Breakout LSM6DS3 | SparkFun | 六自由度位置测量 | www.sparkfun.com/products/13339 |
| Atmospheric Sensor Breakout BME280 | SparkFun | 大气压力、湿度和温度传感器 | www.sparkfun.com/products/13676 |
| I²C DAC Breakout | SparkFun | 12 位数字到模拟转换器 | www.sparkfun.com/products/12918 |
| 16 Output I/O Expander Breakout SX1509 | SparkFun | 16 通道 GPIO 扩展器 | www.sparkfun.com/products/13601 |
| GPS Breakout XA1110 | SparkFun | 全球定位系统模块 | www.sparkfun.com/products/14414 |
| RFID Qwiic Reader | SparkFun | 射频识别标签读取器 | www.sparkfun.com/products/15191 |
| Qwiic Thermocouple Amplifier MCP9600 | SparkFun | 使用热电偶读取温度 | www.sparkfun.com/products/16295 |
| Qwiic Quad Solid State Relay Kit | SparkFun | 四通道高电流/高电压固态继电器 | www.sparkfun.com/products/16833 |
| Qwiic Twist RGB Rotary Encoder Breakout | SparkFun | 带 RGB LED 的旋转编码器 | www.sparkfun.com/products/15083 |
| BH1750 Light Sensor | Adafruit | 环境光传感器 | www.adafruit.com/product/4681 |
| LPS25 Pressure Sensor | Adafruit | 大气压力传感器 | www.adafruit.com/product/4530 |
| PCF8591 Quad 8-bit ADC plus 8-bit DAC | Adafruit | 四通道 8 位 ADC 和单通道 8 位 DAC | www.adafruit.com/product/4648 |
| DS3502 I²C 数字 10K 电位器扩展板 | Adafruit | 10 kΩ 数字电位器 | www.adafruit.com/product/4286 |
| MCP4728 四通道 DAC 带 EEPROM | Adafruit | 四通道 12 位 DAC | www.adafruit.com/product/4470 |
| PMSA003I 空气质量扩展板 | Adafruit | 空气质量监测器 | www.adafruit.com/product/4632 |
请参阅“更多信息”部分,获取有关 Zio 设备、SparkFun 设备和 Adafruit STEMMA/QT 设备的链接。
7.4 Seeed Studio Grove 总线上的 I²C
Grove 总线由 Seeed Studio 于 2010 年创建,是最早尝试标准化业余爱好者互连系统之一。它使用专有的四针锁定连接器,针距为 2 毫米,尽管强行将 JST PH 四针母连接器插入 Grove 插座也不难。
Grove 系统就像是有人花费精力创建了一个定制连接器,然后试图将该连接器用于尽可能多的不同用途。Grove 使用一个单一的连接器来承载 3.3V 和 5V 版本的数字信号、模拟信号、I²C 信号和 UART 信号。这意味着,若将设备插入具有不同信号或电压的连接器中,就很容易损坏设备。你必须小心确保连接的设备兼容,因为该连接器没有提供任何保护或信号类型的指示。
图 7-3 显示了 Grove 接口的针脚布局。

图 7-3: Grove 接口
本书将不会考虑 Grove 连接系统的 UART、数字或模拟功能;表 7-6 仅列出了每个 Grove 针脚可能的不同功能,目的是展示 I²C 信号与 Grove 系统中其他功能共享相同的针脚。请注意,不同的设备可能对 Vcc 针脚的电压(即 3.3V 或 5V)有不同的要求。
表 7-6: Grove 接口针脚功能
| 针脚 | I²C 功能 | UART 功能 | 数字功能 | 模拟功能 |
|---|---|---|---|---|
| 1 | SCL | Rx | Dn | An |
| 2 | SDA | Tx | Dn+1 | An+1 |
| 3 | Vcc | Vcc | Vcc | Vcc |
| 4 | Gnd | Gnd | Gnd | Gnd |
尽管少数制造商已将 Grove 插座安装在他们的 CPU 板上,但 Seeed Studio 似乎是 Grove 模块的主要(如果不是唯一的)制造商。Seeed Studio Wiki 提供了 Grove 互连系统的规格和该公司目前生产的 Grove 产品列表(“更多信息”中提供链接)。
7.5 本章总结
本章讨论了支持 I²C 总线的三种常见厂商定义总线:Adafruit Feather 总线、SparkFun Qwiic 总线和 Seeed Studio 的 Grove 连接。
Adafruit Feather 总线可能是三者中最受欢迎的,按照 Feathers(CPU 板)和 Featherwings(外设板)的数量来看。SparkFun Qwiic 总线也非常受欢迎。两者的主要区别在于,Qwiic 总线仅支持 I²C(加电源),而 Feather 总线则包含了其他信号。Qwiic 连接系统在将小型设备连接或断开到面包板系统时更加方便。
Grove 连接系统类似于 Qwiic,因为它也是一个四线系统。不同版本的 Grove 连接器支持 I²C、SPI、串口、模拟和数字信号,但在使用 Grove 总线时,你必须小心不要混淆信号类型。
这些供应商总线的主要优势在于它们提供的生态系统支持。多个不同的制造商提供小型板卡,这些板卡可以连接到这些总线上,使得在你组装的系统中增加功能变得更加容易。
第三部分
I²C 总线编程
第八章:Arduino I²C 编程

本章的 I²C 编程将从讨论 Arduino 平台开始,因为可以说为 Arduino 编写的 I²C 代码行数可能超过了其他任何平台。
本章涵盖以下内容:
-
基础 I²C 编程介绍
-
讨论 Arduino 库和 IDE 使用的 Wire 编程模型
-
Arduino I²C 读写操作
-
访问多个 I²C 端口的不同 Arduino 设备
本书倾向于以 Arduino 草图(程序)为基础来举例,所以在接下来的章节中,深入理解 Arduino I²C 编程将对你大有帮助。
8.1 基础 I²C 编程
在第二章中,你学习了 I²C 传输开始于输出起始条件,接着是地址-读写字节,接下来是零个或多个数据字节,最后以停止条件结束。控制器将这些数据字节放到 I²C 总线上,可能是通过位运算或某些硬件寄存器实现的。
这次传输中唯一对所有 I²C 设备通用的部分是起始条件、第一次地址字节和停止条件。控制器在地址字节后直到遇到停止条件之前传输的任何字节都是特定于响应该地址字节的外设的。
MCP4725 支持几种基于你在地址字节后立即传输的数据的命令格式。本书中的编程示例将只使用其中一种命令:快速模式写入命令。该命令在 I²C 总线上需要 3 个字节,如 表 8-1 所示。
表 8-1: 快速模式写入命令
| 第一个字节 | 第二个字节 | 第三个字节 |
|---|---|---|
| 地址 | 高位 DAC 值 | 低位 DAC 值 |
aaaa aaax |
0000 hhhh |
llll llll |
在表 8-1 中,aaaa aaa位是 MCP4725 的地址。这些地址位是1100cba,其中c和b是硬编码到 IC 中的,而a来自芯片上的地址线。它对应于地址 0x60 至 0x67。(请记住,I²C 协议将这些地址位左移一位,并期望 R/W 位在第 0 位。因此,地址字节实际上会包含值 0xC0 到 0xCF,具体取决于 IC 的地址和 R/W 线的状态。)hhhh llll llll位是要写入数字到模拟转换电路的 12 位数据。第二个字节的高 4 位必须为零(它们指定快速模式写入命令和关机模式)。假设芯片使用 5V 电源,3 字节序列0xC0, 0x00, 0x00(来自表 8-1 的 3 个字节)将把 12 位值 0x000 写入地址 0x60 的 DAC,这将使 DAC 的输出端出现 0V。写入 3 字节序列0xC0, 0x08, 0x00将把 2.5V 输出到输出引脚。写入 3 字节序列0xC0, 0x0F, 0xFF将把 5V 输出到模拟输出引脚。通常,0x000 到 0xFFF 之间的值(线性)映射到 DAC 模拟输出端的 0V 到 5V 之间的电压。您只需要通过某种方式将这 3 个字节放置到 I²C 总线上。
而 DAC 使用第二个字节的高 4 位来指定命令(0b0000 是快速模式写入命令),DAC 的读取命令更简单。地址字节中的 R/W 位就是 MCP4725 确定如何响应的全部内容。它通过返回 5 个字节来响应:第一个是一些状态信息(在第十五章中我会详细讨论 MCP4725 之前,您可以忽略它),第二个字节包含写入 DAC 的最后一个值的高 8 位,第三个字节包含写入的最后一个值的低 4 位(第 4 至第 7 位),而第 0 至第 3 位不包含任何有效数据。第四和第五个字节包含一些状态信息和芯片内 EEPROM 中存储的 14 位(有关 EEPROM 的更多信息,请参见第十五章)。
您如何将字节放置到 I²C 总线上以及如何从 I²C 总线上读取数据,完全取决于您使用的系统、库函数和操作系统(如果有的话)。本章讨论了 Arduino 上的 I²C,因此我们将考虑如何使用 Arduino 库代码在 I²C 总线上读取和写入数据。
8.2 基本的 Wire 编程
负责 I²C 通信的 Arduino 库是 Wire 库。I²C 通信功能并未内置于 Arduino 语言中(Arduino 语言实际上只是 C++,并附带了一些默认的包含文件)。相反,您需要通过在程序源文件的开头附近包含以下语句来启用 Arduino 的 I²C 库代码:
#include <Wire.h>
请注意,在某些操作系统(尤其是 Linux)中,Wire.h必须以大写的W开头。
Wire.h头文件创建了一个名为Wire的单例类对象,你可以使用它来访问类的函数。你不需要在程序中声明这个对象;头文件会自动为你完成这项工作。接下来的章节将介绍各种可用的Wire函数。
8.2.1 Wire 实用函数
Wire.begin()函数初始化 Arduino 的 Wire(I²C)库。在执行 Wire 库中的任何其他函数之前,必须先调用此函数。通常约定是在 Arduino 的setup()函数中调用此函数。
如果不带参数,Wire.begin()将初始化库,使其作为 I²C 总线上的控制器设备工作。如果你指定一个 7 位整数作为参数,则会初始化库,使其作为 I²C 总线上的外设设备工作。
Wire.setClock()函数允许你更改 I²C 时钟频率,并通过整数参数传递。此调用是可选的;默认时钟频率为 100 kHz。大多数 Arduino 板支持 100,000 或 400,000 作为参数。一些高性能板可能支持 3,400,000(高速模式)。还有一些支持 10,000(SMBus 上的低速模式)。
请记住,I²C 总线上的所有外设和 CPU 必须能够支持你选择的时钟频率。也就是说,你必须设置一个不快于总线上最慢外设的时钟频率。
8.2.2 Wire 读操作
Wire.requestFrom()函数用于从 I²C 外设设备读取数据。Wire.requestFrom()函数调用有两种形式:
Wire.requestFrom( `address`, `size` )
Wire.requestFrom( `address`, `size`, `stopCond` )
在这些调用中,address是 7 位外设地址,size是要从设备读取的字节数, 可选的stopCond参数指定是否在接收到字节后发出停止条件(如果为 true)。如果为 false,则函数会发送重启条件。如果没有提供可选的stopCode参数,函数将使用默认值true(在接收到数据后发出停止条件)。
一旦控制器接收到外设的数据,应用程序可以使用Wire.read()和Wire.available()函数读取这些数据。Wire.available()函数返回内部接收缓冲区中剩余的字节数,而Wire.read()函数从缓冲区读取一个字节。通常,你会使用这两个函数,通过以下类似的循环来读取内部缓冲区中的所有数据:
while( Wire.available() )
{
char c = Wire.read(); // Read byte from buffer
// Do something with the byte just read.
}
无法保证外设在调用Wire.requestFrom()函数时实际上会传输请求的字节数——外设可能返回更少的数据。因此,始终使用Wire.available()函数来准确判断内部缓冲区中的数据量;不要自动假设它是你请求的数量。
外设决定返回给控制器的实际数据量。在几乎所有情况下,数据量是固定的,并在外设的数据手册中指定(或由外设设计确定)。理论上,外设也可以返回可变数量的数据。如何获取这些数据由外设的设计决定,超出了本章的讨论范围。
要从外设设备读取数据,控制器必须向该外设传输外设地址和 R/W 位(该位等于 1)。Wire.requestFrom()函数处理此操作。之后,外设将传输其数据字节。Arduino 控制器将接收这些字节并将其缓冲,以便稍后读取。然而,请注意,完整的读取操作是在执行Wire.requestFrom()函数时进行的。
8.2.3 I²C 写操作
控制器可以使用Wire.beginTransmission()、Wire.endTransmission()和Wire.write()函数向外设写入数据。beginTransmission()和endTransmission()函数将一系列写操作括起来。
Wire.beginTransmission()函数具有以下形式:
Wire.beginTransmission(address)
其中,address是 7 位外设地址。此函数调用构建数据传输的第一个字节,包括地址和清晰的 R/W 位。
Wire.write()函数有三种形式:
Wire.write( `value` )
Wire.write( `string` )
Wire.write( `data`, `length` )
第一种形式将一个字节附加到内部缓冲区,以便传输到外设。第二种形式将字符串中的所有字符(不包括零终止字节)添加到内部缓冲区,以便传输到外设。第三种形式将字节数组中的一些字节复制到内部缓冲区(第二个参数指定要复制的字节数)。
Wire.endTransmission()函数从内部缓冲区获取地址字节和数据字节,并通过 I²C 总线进行传输。此函数调用有两种形式:
Wire.endTransmission()
Wire.endTransmission( stopCond )
第一种形式传输内部缓冲区中的数据,并在传输后发送停止条件。第二种形式使用单个布尔值参数来决定在传输数据后是否发送停止条件(如果stopCond为false,则下一个读写操作将从重启开始)。
请记住,实际的数据传输直到执行Wire.endTransmission()函数调用时才会发生。其他调用只是将数据积累到内部缓冲区,以便稍后传输。
8.2.4 外设总线函数
到目前为止,Arduino 函数假设 Arduino 充当 I²C 总线控制器设备。你也可以编程让 Arduino 充当外设设备。Arduino 库提供了两个函数用于此目的:
Wire.onReceive( inHandler )
Wire.onRequest( outHandler )
在第一个函数中,inHandler是指向具有以下原型的函数的指针:void inHandler( int numBytes )。在第二个函数中,outHandler是指向具有以下原型的函数的指针:void outHandler()。
每当(外部)控制器设备请求数据时,Arduino 系统将调用outHandler。然后,outHandler函数将使用Wire.beginTransmission()、Wire.endTransmission()和Wire.write()函数将外设的数据传输回(外部)控制器。inHandler函数将使用Wire.begin()、Wire.available()和Wire.read()函数从控制器设备检索数据。
8.3 Arduino I²C 写入示例
清单 8-1 中的程序演示了如何使用 I²C 总线与 SparkFun MCP4725 DAC 扩展板进行通信。该程序为 Teensy 3.2 编写并进行了测试,尽管它也应适用于任何兼容的 Arduino 设备(只是时序略有不同)。
该程序通过不断地将 DAC 输出从 0x0 增至 0xfff(12 位),然后再从 0xfff 减至 0x0,生成连续的三角波。正如你所看到的,当在我的设置上运行时,该程序生成的三角波周期略小于 2.4 秒(大约 0.42 Hz)(你的结果可能会有所不同)。该频率由将 8,189 个 12 位数值写入 DAC 所需的时间决定。由于每次传输需要 3 个字节(地址、HO 字节和命令、LO 字节),再加上起始和停止条件的时序,在 100 kHz 下,每传输一个值需要大约 35 个比特时间(每个比特时间为 10 微秒)。
// Listing8-1.ino
//
// A simple program that demonstrates I2C
// programming on the Arduino platform.
#include <Wire.h>
// I2C address of the SparkFun MCP4725 I2C-based
// digital-to-analog converter.
#define MCP4725_ADDR 0x60
void setup( void )
{
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "Test writing MCP4725 DAC" );
Wire.begin(); // Initialize I2C library
}
void loop( void )
{
// Send the rising edge of a triangle wave:
for( int16_t dacOut = 0; dacOut < 0xfff; ++dacOut )
{
// Transmit the address byte (and a zero R/W bit):
❶ Wire.beginTransmission( MCP4725_ADDR );
// Transmit the 12-bit DAC value (HO 4 bits
// first, LO 8 bits second) along with a 4-bit
// Fast Mode Write command (00 in the HO 2 bits
// of the first byte):
❷ Wire.write( (dacOut >> 8) & 0xf );
Wire.write( dacOut & 0xff );
// Send the stop condition onto the I2C bus:
❸ Wire.endTransmission( true );
// Uncomment this delay to slow things down
// so it can be observed on a multimeter:
// delay( 5 );
}
// Send the falling edge of the triangle wave:
for( int16_t dacOut = 0xffe; dacOut > 0; --dacOut )
{
// See comments in previous loop.
Wire.beginTransmission( MCP4725_ADDR );
Wire.write( (dacOut >> 8) & 0xf );
Wire.write( dacOut & 0xff );
Wire.endTransmission( true );
// Uncomment this delay to slow things down
// so it can be observed on a multimeter:
// delay( 5 );
}
}
Wire.beginTransmission()初始化 Wire 库以开始接收数据,并准备在 I²C 总线上进行(后续的)传输❶。Wire.write()函数将数据复制到用于稍后传输的内部Wire缓冲区❷。之后,Wire.endTransmission()指示设备实际上开始将内部Wire缓冲区中的数据传输到 I²C 总线上❸。
图 8-1 显示了程序在执行清单 8-1 时,在 I²C 总线上出现的一个 DAC 3 字节传输(该传输是将 0x963 写入 DAC)。

图 8-1:三角波传输过程中的 I²C 输出示例
如你在图 8-2 中出现的示波器输出中所看到的,三角波的一个完整周期(一个上升沿和一个下降沿)大约需要 2.4 秒。使用逻辑分析仪,我能够确定每个 3 字节传输所需的时间略小于 300 微秒,这大致与你在图 8-2 的示波器输出中看到的情况相符。需要注意的是,传输之间的时序并不恒定,每次传输之间的时间差会有几个微秒的波动。这意味着 300 微秒并不是 3 字节传输的固定时间。
这个软件基于 100 kHz 总线速度能够产生的最大频率大约为 0.4 Hz。为了产生更高的频率值,您需要将 I²C 总线运行在更高的时钟频率(例如 400 kHz),或者减少每单位时间写入 DAC 的值的数量(例如,您可以通过将循环计数器增量设置为 2 而不是 1 来将频率加倍)。

图 8-2:来自 MCP4725 的三角波输出
清单 8-1 中的代码在每次 DAC 传输后都会释放 I²C 总线。如果总线上有其他控制器与不同的外设进行通信,这将进一步降低三角波的最大时钟频率(更不用说,如果输出序列中有很多暂停,可能会对三角波产生一些失真)。理论上,您可以通过在传输期间拒绝释放 I²C 总线来防止这种失真;然而,考虑到这里所需的传输数量,要生成无失真的三角波,唯一合理的解决方案是确保 MCP4725 是 I²C 总线上唯一的设备。
8.4 Arduino I²C 读取示例
从根本上说,DAC 是一个(模拟)仅输出的设备。您将一个值写入 DAC 寄存器,并且模拟电压会神奇地出现在模拟输出引脚上。从 DAC 读取没有太大意义。话虽如此,MCP4725 芯片确实支持 I²C 读取操作。一个读取命令会返回 5 个字节。
要从 MCP4725 读取值,只需将设备的地址放置到 I²C 总线上,并将 R/W 线拉高。MCP4725 会响应并返回 5 个字节:第一个字节为状态信息,接下来的两个字节是最后写入的 DAC 值,最后一对字节是 EEPROM 值。EEPROM 存储一个默认值,用于在设备上电时初始化模拟输出引脚,在写入任何数字值到芯片之前。更多详细信息请参见第十五章。
清单 8-2 中的程序演示了一个 I²C 读取操作。
// Listing8-2.ino
//
// This is a simple program that demonstrates
// I2C programming on the Arduino platform.
//
// This program reads the last written DAC value
// and EEPROM settings from the MDP4725\. It was
// written and tested on a Teensy 3.2, and it also
// runs on an Arduino Uno.
#include <Wire.h>
// I2C address of the SparkFun MCP4725 I2C-based
// digital-to-analog converter.
#define MCP4725_ADDR 0x60
#define bytesToRead (5)
void setup( void )
{
int i = 0;
int DACvalue;
int EEPROMvalue;
byte input[bytesToRead];
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "Test reading MCP4725 DAC" );
Wire.begin(); // Initialize I2C library
Wire.requestFrom( MCP4725_ADDR, bytesToRead );
while( Wire.available() )
{
if( i < bytesToRead )
{
input[ i++ ] = Wire.read();
}
}
// Status byte is the first one received:
Serial.print( "Status: " );
Serial.println( input[0], 16 );
// The previously written DAC value is in the
// HO 12 bits of the next two bytes:
DACvalue = (input[1] << 4) | ((input[2] & 0xff) 4);
Serial.print( "Previous DAC value: " );
Serial.println( DACvalue, 16 );
// The last two bytes contain EEPROM data:
EEPROMvalue = (input[3] << 8) | input[4];
Serial.print( "EEPROM value: " );
Serial.println( EEPROMvalue, 16 );
while( 1 ); // Stop
}
void loop( void )
{
// Never executes.
}
以下是来自清单 8-2 程序的输出。请注意,输出仅对我特定的设置有效。其他 MCP4725 板可能有不同的 EEPROM 值。此外,先前的 DAC 值输出是特定于我特定系统上最后一次写入的(这可能是清单 8-1 中的最后一次输出,当时我在上一个程序运行时上传了清单 8-2 中的程序)。
Test reading MCP4725 DAC
Status: C0
Previous DAC value: 9B
EEPROM value: 800
在这个输出中唯一有趣的事情是,我已编程 MCP4725 的 EEPROM,在上电时将输出引脚初始化为 2.5V(在 5V 电源下的中间值)。
8.5 Arduino I²C 外设示例
前两节从控制器设备的角度描述了读写操作。本节描述了如何创建一个作为 I²C 外设设备的 Arduino 系统。特别是,列表 8-3 中的源代码使用 Teensy 3.2 模块模拟 MCP4725 DAC 设备。Teensy 3.2 具有一个板载 12 位 DAC,连接到 A14 引脚。写入 0x000 到 0xfff 之间的值会在该引脚产生 0V 到+3.3V 之间的电压。列表 8-3 中的代码将rcvISR(和 ISR)与数据接收中断相关联。当数据到达时,系统会自动调用此例程,并传递 I²C 总线上接收到的字节数。
rcvISR中断服务例程(ISR)从控制器接收传输到外设的字节,构建这些字节的 12 位 DAC 输出值,然后将这 12 位写入 DAC 输出(使用 Arduino 的analogWrite()函数)。输出完成后,代码等待下一个传输的发生。就像一个调试和测试功能,这个程序每 10 秒钟将一个字符串写入Serial输出,以便你可以验证程序是否仍在运行。
// Listing8-3.ino
//
// This program demonstrates using an
// Arduino as an I2C peripheral.
//
// This code runs on a Teensy 3.2
// module. A14 on the Teensy 3.2 is
// a true 12-bit, 3.3-V DAC. This program
// turns the Teensy 3.2 into a simple
// version of the MCP4725 DAC. It reads
// inputs from the I2C line (corresponding
// to an MCP4725 fast write operation)
// and writes the 12-bit data to the
// Teensy 3.2's hardware DAC on pin A14.
#include <Wire.h>
// I2C address of the SparkFun MCP4725 I2C-based
// digital-to-analog converter.
#define MCP4725_ADDR 0x60
// Interrupt handler that the system
// automatically calls when data arrives
// on the I2C lines.
void rcvISR( int numBytes )
{
byte LObyte;
byte HObyte;
word DACvalue;
// Expecting 2 bytes to come
// from the controller device.
if( numBytes == 2 && Wire.available() )
{
HObyte = Wire.read();
if( Wire.available() )
{
LObyte = Wire.read();
DACvalue = ((HObyte << 8) | LObyte) & 0xfff;
analogWrite( A14, DACvalue );
}
}
}
// Usual Arduino initialization function:
void setup( void )
{
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "I2C peripheral test" );
// Initialize the Wire library to treat this
// code as an I2C peripheral at address 0x60
// (the SparkFun MCP4725 breakout board):
Wire.begin( MCP4725_ADDR );
// Set up the Teensy 3.2 DAC to have
// 12-bit resolution:
analogWriteResolution(12);
// Define the I2C interrupt handler
// for dealing with incoming I2C
// packets:
Wire.onReceive( rcvISR );
}
void loop( void )
{
Serial.println( "MCP4725 emulator, waiting for data" );
delay( 10000 ); // Delay 10 seconds
}
我将两个 Teensy 3.2 设备的 SCL、SDA 和 Gnd 引脚连接在一起(使用 Teensy 和 Arduino 也可以)。在其中一个单元上,我编写了类似于列表 8-1 中找到的 DAC 输出代码。在另一个单元上,我编写了列表 8-3 中的代码。我将示波器连接到运行外设代码(列表 8-3)的 Teensy 的 A14 引脚上。输出如图 8-3 所示。请注意,三角波的峰值在 0.0V 和 3.3V 之间(而不是图 8-2 中的 0V 和 5V),因为 Teensy 是一个 3.3V 设备。

图 8-3:Teensy 3.2 A14 引脚的三角波输出
图 8-4 显示了发生时钟拉伸时输出的一个小片段。

图 8-4:拉伸的时钟信号降低了三角波的频率。
如图 8-4 所示,时钟在字节传输后被拉伸到 8.4 微秒。
8.6 多 I²C 端口编程
标准的 Arduino 库假设板子上只有一个 I2C 总线(基于 Arduino Uno 的硬件)。许多 Arduino 兼容板提供多个 I²C 总线。这使你可以将 I²C 设备分布在多个总线上,从而让它们运行得更快,或者可能在不使用 I²C 总线多路复用器的情况下,连接两个具有相同地址的设备。
标准 Arduino 库不支持多个 I²C 总线;然而,支持多个 I²C 总线的设备通常会提供一些特殊的库代码,让您能够访问系统中的额外 I²C 总线。对于多个设备实例的 Arduino 约定,是在设备名称后加上数字后缀以指定特定的设备。在 I²C 总线的情况下,这些设备名称分别是 Wire(表示第一个或 0 号端口)、Wire1、Wire2 等等。
例如,要向第二个 I²C 端口写入一系列字节,您可以使用如下代码:
Wire1.beginTransmission( 0x60 );
Wire1.write( (dacOut << 8) & 0xf );
Wire1.write( dacOut & 0xff );
Wire1.endTransmission( true );
实现这一机制的方法是硬件和系统特定的。请查阅您特定单板计算机(SBC)的文档,了解如何实现此功能。
8.7 本章总结
Arduino 库提供了 Wire 对象来支持 I²C 总线事务。本章描述了 Arduino 库中可用的基本 Wire 函数,包括初始化 I²C 库、选择 I²C 时钟频率、发起从 I²C 外设读取、读取存放在内部缓冲区中的外设数据、初始化用于传输到外设的缓冲区等功能。
本章还包含了使用 SparkFun MCP4725 进行 I2C 通信的多个实际示例。
第九章:树莓派(和 Linux)I²C 编程

在 Arduino 之后,树莓派在 I²C 总线的使用上可能排第二。在某些方面,I²C 总线对树莓派硬件黑客来说甚至比对 Arduino 用户更重要,因为树莓派并不提供模拟到数字转换器。树莓派用户最常通过 I²C 总线为系统增加这类功能。
本章讨论了以下内容:
-
树莓派 GPIO 头上的 I²C 总线
-
如何在树莓派上启用 I²C 总线(默认情况下,它是禁用的)
-
如何设置 I²C 总线的速度,这对某些慢速外设可能是必要的,因为树莓派不支持时钟拉伸
-
如何在树莓派上使用 I²C 工具包
-
在树莓派上编程 I²C 设备
-
其他基于 Linux 的系统上的 I²C
-
在树莓派上通过位带操作实现的 I²C,以克服一些 Pi 的限制
尽管本章特别讨论树莓派单板计算机,Pi OS 实际上只是 Linux 操作系统的一个变种,因此本章中的许多信息也适用于通用的 Linux 系统以及树莓派。
9.1 树莓派通用输入输出头上的 I²C 总线引脚
树莓派一直支持至少一条 I²C 总线在 GPIO 连接器上。3 号和 5 号引脚(GPIO 2 和 GPIO 3)分别提供 SDA 和 SCL 线路。这些引脚在原始的 26 针 GPIO 头上就有。
在树莓派 B+推出后,GPIO 头扩展至 40 个引脚,并增加了第二条硬件 I²C 总线。第二条 I²C 总线(位于 40 针头的 27 号和 28 号引脚;见图 9-1)最初用于连接 Pi HAT 上的 EEPROM 设备——Pi HAT 是指“硬件附加在顶部”的树莓派附加板。I²C EEPROM 设备提供了板卡的标识信息,以便操作系统能够识别它,并以“即插即用”的方式加载适当的设备驱动程序。
第二条 I²C 线路最初是为 EEPROM、摄像头和 DSI 显示器使用而设计的。启用这些线路可能会导致显示器、摄像头和 HAT 单元出现故障,因此大多数程序员和系统设计师都会保持这些线路不变。然而,如果你不使用这些设备,你可以将 I²C 总线用于 27 号和 28 号引脚,按自己的需求使用。
从技术上讲,HDMI 连接器上还有第三条 I²C 总线(用于支持 VESA E-DDC 的 5V 变种)。理论上,经过一些工作,你可以使用它。然而,本书不会讨论该总线的使用,因为它实际上是为视频显示子系统使用而设计的。

图 9-1:树莓派总线上的主 I²C 和备用(HAT EEPROM)I²C 引脚
随着树莓派 4 的推出,可用的 I²C 总线数量再次增加。图 9-2 显示了树莓派 4 上 40 针 GPIO 连接器的引脚分配。

图 9-2:树莓派 4 GPIO 引脚分配图
图 9-2 显示了 Raspberry Pi 4 上的六个 I²C 总线:
-
i2c-0 SDA0 和 SCL0 位于第 27 和 28 引脚
-
i2c-1 SDA1 和 SCL1 位于第 3 和 5 引脚
-
i2c-3 SDA3 和 SCL3 位于第 7 和 29 引脚
-
i2c-4 SDA4 和 SCL4 位于第 31 和 26 引脚
-
i2c-5 SDA5 和 SCL5 位于第 32 和 33 引脚
-
i2c-6 SDA6 和 SCL6 位于第 15 和 16 引脚(与 i2c-0 共用这些引脚)
请注意,Raspberry Pi 上的 i2c-1 总线提供了拉升电阻至 +3.3 V。其他 I²C 端口则没有提供。因此,如果你启用除了 i2c-1 以外的任何 I²C 总线,你需要为该总线添加拉升电阻,以确保它能够正常工作。
9.2 手动激活 I²C 总线
默认情况下,Raspberry Pi OS 不会启用 GPIO 连接器上的任何 I²C 总线——这些引脚默认定义为 GPIO 引脚。你可以使用 raspi-config 应用程序来激活 I²C 总线。该应用程序会自动编辑适当的系统文件以激活 I²C 总线。如果你想手动进行这些更改,必须编辑 Raspberry Pi 上的几个文件,以启用相应的 I²C 总线。
如果你想激活 i2c-1,你需要以超级用户身份编辑 /boot/config.txt 文本文件。在该文件中,你通常会找到以下这一行:
#dtparam=i2c_arm=on
该语句开头的#将整行变为注释,使得该语句对系统不可见,I²C 总线也被禁用。要激活 I²C 总线,只需删除该行开头的#字符。
i2c_arm标签告诉你,这个特定的 I²C 端口属于 ARM 处理器(Raspberry Pi 的 CPU)。第二个 I²C 端口(第 27 和 28 引脚,实际上是 Linux 的 i2c-0 端口)属于视频控制器芯片。你可以使用以下语句激活该 I²C 总线:
dtparam=i2c_vc=on
然而,Raspberry Pi 的文档非常明确指出,i2c-0 是为 HAT EEPROM 预留的,不应将其用于其他目的(请参见 github.com/raspberrypi/hats/blob/master/designguide.md)。滥用这一建议需自行承担风险。
如果你编辑了/boot/config.txt中的dtparam=i2c_arm=on语句并重新启动,你会发现 I²C 总线仍然不可用。这是因为 Raspberry Pi OS 使用可加载的内核模块(LKM)来处理 I²C 相关的操作。此时,系统尚未加载相应的模块。要加载这些模块,请执行以下两条命令:
modprobe i2c-bcm2708 #Note:use i2c-bcm2835 on Pi zero W, 3, and 4
modprobe i2c-dev
当然,如果你一直使用 I²C,每次启动系统时手动加载这些模块会变得很麻烦。如果你以超级用户身份编辑/etc/modules文件,并将以下两行添加到该文件中,系统将在启动时自动加载这些模块:
i2c-bcm2708
i2c-dev
如果你有一个 Raspberry Pi 4 系统,你可以通过将以下一行或多行添加到/boot/config.txt文本文件中,启用额外的 I²C 总线:
dtoverlay=i2c1,pins_2_3
dtoverlay=i2c3,pins_4_5
dtoverlay=i2c4,pins_6_7
dtoverlay=i2c5,pins_12_13
dtoverlay=i2c6,pins_22_23
pins``_xx_yy 参数引用的引脚编号是 GPIO 引脚编号,而不是 Pi 40 引脚连接器上的物理引脚编号。表 9-1 列出了 GPIO 引脚编号和物理引脚编号之间的对应关系。更多信息,请参见 www.raspberrypi.com/documentation/computers/os.xhtml#gpio-and-the-40-pin-header 。
表 9-1:GPIO 引脚与物理引脚编号对应关系
| GPIO 引脚编号 | 连接器引脚编号 |
|---|---|
| GPIO 2 | 板 pin 3 |
| GPIO 3 | 板 pin 5 |
| GPIO 4 | 板 pin 7 |
| GPIO 5 | 板 pin 29 |
| GPIO 6 | 板 pin 31 |
| GPIO 7 | 板 pin 26 |
| GPIO 12 | 板 pin 32 |
| GPIO 13 | 板 pin 33 |
| GPIO 22 | 板 pin 15 |
| GPIO 23 | 板 pin 16 |
最后,请确保在 config.txt 中存在以下行:
enable_uart= 1
理论上,在树莓派上启用 UART(串行端口)不应该影响 Pi 上的 I²C。但实际上(至少在 Pi 3 上),如果您不包括此行,系统将以约 65 kHz 而不是名义上的 100 kHz 运行 SCL 线。
9.3 改变 I²C 时钟频率
默认情况下,树莓派将 I²C 时钟频率设置为 100 kHz。要更改主 I²C 总线(i2c-1)的速度,请在 /boot/config.txt 中使用以下语句:
`dtparam=i2c_arm_baudrate=``xxxxxx`
其中 xxxxxx 代表您想要使用的时钟频率(例如 100000)。通常,您会将此语句直接放置在文件中 dtparam=i2c_arm=on 之后。
树莓派 OS 将选择一个小于或等于您指定值的可用时钟频率。
在树莓派 4 上,您可以使用以下语句设置 i2c-3、-4、-5 和 -6 的时钟频率:
dtoverlay=i2c3,pins_4_5
dtparam=baudrate=`xxxxxx` #sets clock frequency for i2c-3
dtoverlay=i2c4,pins_6_7
dtparam=baudrate=`xxxxxx` #sets clock frequency for i2c-4
dtoverlay=i2c5,pins_12_13
dtparam=baudrate=`xxxxxx` #sets clock frequency for i2c-5
dtoverlay=i2c6,pins_22_23
dtparam=baudrate=`xxxxxx` #sets clock frequency for i2c-6
再次提醒,Pi OS 将选择一个小于或等于您指定的实际值的时钟频率。
9.4 I²C 时钟拉伸问题及解决方案
截至撰写本文时,树莓派上的 I²C 协议已经存在已久的问题:Pi 不支持时钟拉伸。这个问题似乎与硬件有关;它存在已久(跨多种不同的树莓派型号),如果这是软件问题,我们期望早就修复了。关键问题在于,如果您有一个依赖于通过时钟拉伸添加等待状态的 I²C 设备,在标准 Pi I²C 设置上,该设备可能无法正常工作。
这个问题有两个解决方案。第一个是一个大的变通办法:使用上一节中的技术减少 SCL 时钟频率,希望将时钟速度降低到足以使外设处理 I²C 数据的程度。例如,Adafruit 建议将 I²C 时钟频率(config.txt 表示中的波特率)设置为 10 kHz。理想情况下,这么慢的速度可以给您的外设足够的时间来处理 I²C 数据。
降低时钟频率是不令人满意的,主要有两个原因。首先,没有保证新的、更慢的时钟频率能够为任意外设提供足够的时间来完成工作。其次,这种技术会减慢所有位传输,包括那些不使用时钟拉伸的外设,以及所有在使用时钟拉伸的设备上不需要时钟拉伸的位。总之,这种方法不能保证成功,而且效率非常低。
第二种解决方案是使用位编程(软件)I²C 传输。软件 I²C 处理可以正确地处理位拉伸。确实,位编程比硬件实现慢得多且效率低,但可能也不会比降低硬件 SCL 频率更慢。
要使用(任意)GPIO 引脚设置软件控制的 I²C 总线,请在你的/boot/config.txt文件中添加以下语句:
dtoverlay=i2c-gpio,bus=`x`,i2c_gpio_sda=`y`,i2c_gpio_scl=`z`
其中x表示总线号,y和z表示 Raspberry Pi GPIO 连接器上的 GPIO 引脚。这将创建一个使用名称i2c-``x的 I²C 设备,且该设备在指定的 GPIO 引脚上运行(这些是 GPIO 引脚的编号,而不是 Pi GPIO 连接器上的物理引脚编号)。
如果没有i2c_gpio_sda参数,系统将使用 GPIO 23 作为默认(连接器上的物理引脚 16)。如果没有i2c_gpio_scl参数,系统将使用 GPIO 24(物理引脚 18)作为默认。如果没有bus参数,系统将动态分配设备号,因此你确实应该显式提供bus参数。
请注意,i2c-gpio 设备可以使用任何任意 GPIO 引脚;你不必指定与 I²C 硬件相关联的引脚。这意味着通过使用基于软件的 I²C 设备,你实际上可以增加系统中支持的 I²C 总线数量(尽管说实话,如果你需要额外的 I²C 总线,I²C 多路复用器可能是一个更好的解决方案)。
通常情况下,我建议将所有依赖时钟拉伸的 I²C 设备放在 i2c-gpio 设备上,并将所有其他 I²C 设备放在基于硬件的 I²C 总线上,以提高系统效率。
9.5 Raspberry Pi OS(Linux)I²C 工具
在 Raspberry Pi 上,有几个 I²C 特定的工具非常有用。除了这些,还有一些常规的 Linux 和 Raspberry Pi 命令,在使用 I²C 设备时也很有用。
首先,为了确定 I²C 设备驱动程序是否能正常工作,请输入以下 Linux 命令:
**ls /dev/i2c***
此命令列出了当前可以访问的所有 Linux I²C 设备。例如,在我的 Raspberry Pi 4 上,得到以下输出:
/dev/i2c-1 /dev/i2c-6
这告诉我 I²C 接口 1(i2c-1,在引脚 3 和 5 上)和 I²C 接口 6(i2c-6,在引脚 15 和 16 上)目前处于活动状态。在尝试运行使用 I²C 信号的应用程序之前,你应该先运行此命令以验证 I²C 总线是否正常工作。
我将在本节中讨论的剩余四个工具属于 i2c-tools 包。最初,Raspberry Pi OS 并未默认包含这些工具,但后来的 Pi OS 版本似乎包含了它们。如果你的系统中没有这些工具,你必须使用以下命令下载它们:
**sudo apt-get install i2c-tools libi2c-dev**
这会在你的系统上安装四个程序,i2cdetect、i2cdump、i2cget和i2cset。i2cdump工具主要用于查看 I²C EEPROM 设备的内容。在这里我们不再讨论这个应用程序;有关此代码的更多信息,请参阅 Linux 手册页。
i2cget和i2cset程序允许你从某些 I²C 设备读取字节或字,或向某些 I²C 设备写入字节或字。由于它们的操作方式,它们在本章中对我们来说的价值有限。这两个应用程序假设它们首先向 I²C 地址写入寄存器编号,然后写入附加数据(对于i2cset)或写入寄存器编号后读取指定寄存器的数据(对于i2cget)。这种方式适用于像 MCP23017 GPIO 扩展 IC 这样的 I²C 设备,但不适用于像我们本章示例中的 MCP4725 DAC 这样的设备。
要运行i2cget应用程序,请输入以下内容(大括号中的项是可选的):
**i2cget {-y}** `i2cbus` `device_address` **{**`register` **{**`mode`**}}**
**i2cget -y** `i2cbus device_address register mode`
其中,i2cbus是活动 I²C 总线的编号(例如,1表示i2c-1),device_address是要读取的设备的 7 位 I²C 地址,register是 8 位寄存器编号(指定设备上的特定寄存器),mode是字母b、w或c之一(分别对应字节、字或读写字节)。如果register操作数存在,该命令将在总线上放置 I²C 地址并将register值写入 I²C 地址。在如 MCP23017 之类的设备上,这会设置 IC 内部的寄存器以供读取。下一步是读取操作,系统将从 IC 中读取指定的寄存器值。以 MCP4725 为例,由于你不能通过首先向 IC 写入字节来选择寄存器,因此在使用此命令时,你不应指定register参数。这样做会向 MCP4725 写入值,并影响模拟输出。
不幸的是,i2cget应用程序与 MCP4725 不太兼容。MCP4725 在读取芯片时会返回 5 字节的数据。i2cget命令最多只能读取 2 个字节。由于没有真正的方法使用此命令读取所有 MCP4725 数据,因此我们将在第十三章中忽略该命令。
i2cset命令是i2cget程序的输出版本。它具有以下语法:
**i2cset {-y}** `i2cbus` `device_address` `data_address` **{**`value`} **{**`mode`**}**
其中i2cbus和device_address的含义与i2cget命令中的相同。data_address参数实际上与i2cget命令中的register操作数相同:它是一个字节值,在地址字节放到总线上之后立即写入 IC,假设这是选择 IC 上的某个寄存器。
再次强调,由于该程序期望能够写入寄存器(data_address),它与 MCP4725 DAC 存在一些不兼容性。然而,通过一些技巧,实际上可以使其与 DAC 一起工作。MCP4725 期望一个 3 字节的传输(用于快速模式写入命令)。第一个字节当然是地址和读写位,第二个字节是 12 位 DAC 值的高 4 位,第三个字节是 DAC 值的低 8 位。事实证明,i2cset命令可以通过以下语法强制输出此数据:
i2cset -y `i2cbus` `device_address` `HOByte` `LOByte`
其中HOByte是 DAC 输出值的高 4 位,LOByte是 DAC 值的低 8 位。
在 i2c-tools 软件包中的四个实用程序中,i2cdetect程序无疑是最有用的。顾名思义,该程序用于检测 I²C 总线上的 I²C 设备。该程序有三种主要形式,下面的段落将对其进行描述。
**i2cdetect -l**
第一种形式扫描系统中所有可用的 I²C 总线并显示它们。请注意,命令行选项是l(字母 L,表示列出),而不是1(数字 1)。此命令类似于使用ls /dev/i2c*来识别可用的 I²C 总线。在启用了 I²C 的 Raspberry Pi 3 上执行此命令时,我得到以下输出:
i2c-1 i2c bcm2835 (i2c@7e804000) I2C adapter
i2cdetect命令的第二种形式输出关于指定 I²C 总线的状态和能力信息:
**i2cdetect -F** `bus`
下面是 Raspberry Pi 上i2c-1的示例输出:
Functionalities implemented by /dev/i2c-1:
I2C yes
SMBus Quick Command yes
SMBus Send Byte yes
SMBus Receive Byte yes
SMBus Write Byte yes
SMBus Read Byte yes
SMBus Write Word yes
SMBus Read Word yes
SMBus Process Call yes
SMBus Block Write yes
SMBus Block Read no
SMBus Block Process Call no
SMBus PEC yes
I2C Block Write yes
I2C Block Read yes
有关这些 Linux 内核级功能的描述,请访问www.kernel.org/doc/html/latest/i2c/functionality.xhtml。
i2cdetect命令的第三种形式扫描总线,寻找有效的 I²C 设备,并(如果可能)报告它们的存在:
**i2cdetect {-y} {-a} {-q|-r}** `bus` **{**`first last`**}**
其中bus是 I²C 总线规格(可以是一个整数,如1,也可以是总线名称,如i2c-1)。可选的first和last参数是设备地址(first < last),用于限制i2cdetect扫描的 I²C 总线地址范围。
通常,使用以下命令,或者传入不同的总线值作为参数:
**i2cdetect 1**
输入命令后,您将看到一个警告提示,说明此命令可能会干扰总线上的 I²C 设备。了解这一点后,系统会要求您确认是否希望该命令探测总线。
如果您希望在运行i2cdetect时跳过此提示(例如,在 shell 脚本中运行时),可以添加-y选项,告诉程序“自动回答是”:
**i2cdetect -y 1**
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- 04 -- -- -- -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- 62 -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --
这个矩阵显示了有效的 I²C 地址以及i2cdetect是否在该地址找到设备。--条目表示i2cdetect认为该地址上没有设备,而矩阵中的十六进制数字表示该地址上存在设备。在这个例子中,i2cdetect在地址 0x04(可能是 Broadcom I²C 硬件,因为该地址保留给高速控制器)和地址 0x62(我当前连接到总线的 Adafruit MCP4725 设备)找到了两个设备。
如果矩阵中的某个条目显示UU,说明该地址上安装了一个设备,但该设备当前正在被内核使用,例如,当你将实时时钟(RTC)连接到系统,以便在系统启动时自动设置日期和时间时,通常会发生这种情况。
如第二章所述,I²C 总线没有提供标准化的设备检测机制。特别是,SMBus 设备可能会在没有数据载荷的情况下简单地尝试读取或写入设备时产生不良反应。因此,i2cdetect程序可能仅通过探测就会改变 I²C 设备在总线上的状态,这也是为什么在传输数据之前,i2cdetect要求你确认是否真的要扫描总线的原因。
因为i2cdetect可能会干扰总线上某些类型的外设,它提供了一个选项来限制扫描的地址范围。例如,如果你知道安装了一个 MCP4725 DAC,但不知道它连接到哪个地址,可以使用以下命令来搜索 DAC:
**i2cdetect -y 1 0x60 0x67**
0x60和0x67参数限制了程序的扫描范围(我们知道 MCP4725 的地址必须在 0x60 到 0x67 范围内,因为它的硬件设计决定了这个范围)。
-q(快速写入)和-r(快速读取)参数是高级选项,使用这些选项可能会损坏 EEPROM 或导致系统挂起。请参阅i2cdetect手册页面了解详细信息,并在使用这些选项之前认真考虑你的操作。
9.6 读取和写入 I²C 数据
一旦你安装并初始化了 I²C 驱动程序,I²C 总线上的数据传输和接收就相对简单了。像大多数设备一样,树莓派操作系统(Linux)将 I²C 总线视为文件。你像打开文件一样打开设备驱动程序,然后使用 Linux 的read()和write()函数读取和写入数据。
首先,调用 Linux 的open()函数获取与 I²C 总线关联的文件句柄:
`handle` = open( `busName`, O_RDWR );
其中,handle是整数(文件描述符)类型,busName是一个包含你要使用的 I²C 总线设备名称的字符串。总线名称是你在/boot/config.txt文件中定义的/dev/i2c**名称。例如,标准的 I²C 总线是/dev/i2c-1*。
open()函数在出错时返回负数,成功时返回非负的文件句柄值。保存文件句柄值,以便稍后可以读取和写入数据。
在访问 I²C 总线之前,必须使用 Linux 的ioctl()(I/O 控制)函数设置要访问的外设设备的地址,方法如下:
`result` = ioctl( `handle`, I2C_SLAVE, `i2cAddr` );
其中result是一个整数变量,用于存储错误的返回结果,handle是open()函数调用返回的句柄,i2cAddr是要访问的外设的 7 位地址。
你可以在同一个文件句柄上多次调用ioctl(),以便在同一 I²C 总线上访问不同的外设。树莓派操作系统将继续使用相同的外设地址进行所有读写操作,直到你显式更改它。
要从 I²C 外设读取数据,可以使用 Linux 的read()函数,语法如下:
`result` = read( `handle`, `buffer`, `bufferSize` );
其中result是一个整数变量,用于存储函数返回结果(如果发生错误则为负值,如果读取字节数为非负值,则为读取的字节数),handle是open()函数返回的 I²C 总线句柄,buffer是一个字节数组,用于接收数据,bufferSize是要读取的字节数。如果一切正常,函数将返回bufferSize作为结果。
要将数据写入外设,使用write函数:
`result` = write( `handle`, `buffer`, `bufferSize` );
参数与read()相同,不同之处在于缓冲区存储要写入的数据(而不是存储读取数据的位置)。
清单 9-1 中的程序演示了如何使用open()、read()、write()和ioctl()函数在 I²C 总线上读取和写入数据。与前一章一样,这个程序在 MCP4725 DAC 输出端口上发出三角波。
// Listing9-1.cpp
// Demonstrates reading from and
// writing to an MCP4725 DAC.
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#define ever ;;
❶ #define i2cDevname "/dev/i2c-1"
//#define i2cAddr (0x60) // Adafruit MCP4725 address
#define i2cAddr (0x62) // SparkFun MCP4725 address
int main()
{
#define bufferSize (5)
static unsigned char buffer[bufferSize + 1];
// Open the I2C interface (i2c-1):
❷ int fd_i2c = open( i2cDevname, O_RDWR );
if( fd_i2c < 0 )
{
printf
(
"Error opening %s, terminating\n",
i2cDevname
);
return -1;
}
// Assign the device address of the MCP4725 to
// the open handle:
❸ int result = ioctl( fd_i2c, I2C_SLAVE, i2cAddr );
if( result < 0 )
{
printf
(
"Error attaching MCP4725: %d, %s\n",
result,
strerror( result )
);
return result;
}
// Just for fun, read the 5 data bytes from the
// MCP4725.
❹ result=read( fd_i2c , buffer, bufferSize );
if( result < 0 )
{
printf
(
"Error reading from %s, terminating: %s\n",
i2cDevname,
strerror( errno )
);
return -1;
}
printf( "Data from DAC:\n" );
for ( int i = 0; i < bufferSize; i++ )
{
printf( "0x%02x ", (int) buffer[i] );
}
printf( "\n" );
// Continuously send a triangle wave to the
// MCP4725 until the user hits CTRL-C:
for(ever)
{
// Output the rising edge:
for( int i=0; i < 4095; ++i )
{
buffer[0] = (i >> 8) & 0xf; // HO 4 bits is first
buffer[1] = i & 0xff; // LO byte is second
// Write the two bytes to the DAC:
❺ result = write( fd_i2c, buffer, 2 );
}
// Output the falling edge:
for( int i=4095; i > 1; --i )
{
buffer[0] = (i >> 8) & 0xf; // HO 4 bits is first
buffer[1] = i & 0xff; // LO byte is second
// Write the two bytes to the DAC:
result = write( fd_i2c, buffer, 2 );
}
}
return 0;
}
在清单 9-1 中,树莓派 I²C 主端口的 Linux 文件名是"/dev/i2c-1" ❶。要写入树莓派 I²C 端口,像打开文件一样打开它 ❷。要读取或写入特定的 I²C 地址,必须首先发出带有I2C_SLAVE参数和要使用的 I²C 地址的ioctl()调用。从那时起(直到另一个ioctl()调用更改地址为止),对 I²C 总线的读写将使用此地址 ❸。要从 I²C 总线读取数据,只需调用read()函数,指定先前open()调用返回的 I²C 端口的文件句柄 ❹。要将数据写入 I²C 总线,调用write()函数并指定 I²C 文件句柄 ❺。
图 9-3 显示了示波器上的 DAC 输出。

图 9-3:树莓派 3 的三角波输出
输出只有 3.3 V(而不是 5 V),因为树莓派是 3.3 V 的设备(我在 3.3 V 下运行 MCP4725,尽管你可以在 5 V 下运行它,只要 SCL 和 SDA 线是 3.3 V)。
9.7 高级 I²C 内核调用
尽管使用open()、read()、write()和ioctl()对于简单的 I²C 总线事务来说相当有效,但ioctl()函数的不同形式提供了更多高级操作,称为内核函数调用。
内核函数调用通过 Linux ioctl() API 函数进行。你将一些参数封装在一个数据结构中,并附带一个函数标识符,然后调用 ioctl()。ioctl() 函数解码其参数,并将参数传递给指定的函数。该函数返回适当的函数结果(通过 ioctl() 返回值和你传递给 ioctl() 的参数列表)。考虑以下 i2c_smbus_access() 函数,它为各种 Linux SMBus(I²C)函数调用设置参数:
#include <linux/i2c.h>
static inline __s32 i2c_smbus_access
(
int file,
char read_write,
__u8 command,
int size,
union i2c_smbus_data *data
){
struct i2c_smbus_ioctl_data args;
args.read_write = read_write;
args.command = command;
args.size = size;
args.data = data;
return ioctl( file,I2C_SMBUS,&args );
}
这个函数将其参数复制到一个本地数据结构(args)中,然后将它们与 I2C_SMBUS 参数一起传递给 ioctl() 函数,告诉 ioctl() 调用其中一个 SMBus 函数;args.command 参数指定要调用的特定函数。大多数 I2C_SMBUS 函数使用相同的参数列表:args 结构中的 read_write、size 和 data 字段。
请注意,i2c_smbus_access() 实际上并不是一个特定的 SMBus 函数。它是一个将参数封装并传递给 ioctl() 的函数,一个 分发函数:一个单一的入口点(在本例中是操作系统),将控制(分发)传递给多个不同的函数之一。一个特定的 SMBus 函数示例是 i2c_smbus_read_byte():
static inline __s32 i2c_smbus_read_byte( int file )
{
union i2c_smbus_data data;
if
(
i2c_smbus_access
(
file,
I2C_SMBUS_READ,
0,
I2C_SMBUS_BYTE,
&data
)
){
return -1;
}
return 0x0FF & data.byte;
}
该函数使用 i2c_smbus_access() 来封装参数并实际调用 ioctl()。
以下子章节描述了通过 ioctl() 分发器可以使用的 SMBus 函数。虽然其中一些函数非常特定于 SMBus,但许多函数对于普通的 I²C 操作非常有用。
9.7.1 i2c-dev 函数
linux/i2c-dev.h 头文件定义了以下的 SMBus 函数。前面章节中输入的 apt-get install libi2c-dev 命令会安装这个头文件,从而让你在应用程序中使用此库。你不需要在代码中链接特定的库来使用这些函数,因为 i2c-dev 函数作为内核(或可加载模块)的一部分安装,并通过 ioctl() API 调用来访问这些函数。要访问这些函数,请在 C/C++ 应用程序的开头包含以下语句:
**extern "C" { // Required for CPP compilation**
**#include <linux/i2c-dev.h>**
**#include <i2c/smbus.h>**
**#include <sys/ioctl.h>**
**}**
头文件本身包含了以下章节描述的函数定义。它们都是 static inline 函数,因此编译器会直接在你调用的地方(作为宏)展开它们。
通过自身的包含文件,此头文件定义了表 9-2 中显示的类型。
表 9-2:整数类型
| 类型 | 含义 |
|---|---|
| __u8 | 无符号 8 位整数 |
| __u16 | 无符号 16 位整数 |
| __u32 | 无符号 32 位整数 |
| __s8 | 有符号 8 位整数 |
| __s16 | 有符号 16 位整数 |
| __s32 | 有符号 32 位整数 |
你必须传递以下函数一个指定 I²C 总线的文件句柄。你可以通过open()函数获取文件句柄(见本章 9.6 节,“读取和写入 I²C 数据”)。你不需要传递设备地址给这些函数。相反,你需要通过ioctl()调用指定要使用的设备地址,例如,ioctl(``handle``, I2C_SLAVE, i2cAddr)。一旦为特定 I²C 总线(通过handle指定)设置了设备地址,该地址将保持有效,直到你明确通过另一个ioctl()调用更改它。
以下函数如果发生错误都会返回-1。write()函数成功时返回0。read()函数会返回从总线读取的值(当读取单个值时),或者在读取字节块时返回读取的字节数。
9.7.2 i2c_smbus_write_quick 函数
i2c_smbus_write_quick()函数向 I²C 设备写入单一的位值:
__s32 i2c_smbus_write_quick( int file, __u8 value );
在这个函数中,file是由 open 函数返回的文件句柄(通常指定 I²C 设备,例如 i2c-1),而value是要写入指定 I²C 总线的位值(0 或 1)。
这个函数将一个单一的位写入 I²C 总线。数据负载被封装在总线上传输的地址字节的 R/W 位中。这个函数传输起始条件、地址字节(数据负载在 R/W 位中)以及停止条件。在这个操作中不会传输数据字节。
9.7.3 i2c_smbus_read_byte 函数
i2c_smbus_read_byte()函数从 I²C 总线读取一个字节。以下是该函数的原型:
__s32 i2c_smbus_read_byte( int file );
该函数从通过文件句柄传递的 I²C 设备中返回一个字节(设备地址此前已通过ioctl()调用设置)。该函数首先向设备传输起始条件和地址字节。然后,它从设备读取响应字节。最后,传输停止条件。
不要使用这个函数从 I²C 设备读取字节序列——例如,从 MCP4725 读取所有状态信息。由于它将地址和值包围在起始和停止条件之间,你可能会发现读取到的数据序列的第一个字节会被重复读取两次。清单 9-2 中的程序演示了这个问题。
// Listing9-2.cpp
//
// Demonstration of two consecutive
// i2c_smbus_read_byte calls.
//
// gcc Listing9-2.c -li2c
#include <unistd.h> // Needed for I2C port
#include <fcntl.h> // Needed for I2C port
extern "C" // Needed for C++ compiler
{
#include <linux/i2c-dev.h> // Needed for I2C port
#include <i2c/smbus.h>
}
#include <sys/ioctl.h>
#include <stdio.h>
#define i2cDevname "/dev/i2c-1"
#define i2cAddr (0x62) // Adafruit MCP4725 address
//#define i2cAddr (0x60) // SparkFun MCP4725 address
int main()
{
unsigned char buffer[2];
int file;
file = open( i2cDevname, O_RDWR );
ioctl( file, I2C_SLAVE, i2cAddr );
buffer[0] = i2c_smbus_read_byte( file );
buffer[1] = i2c_smbus_read_byte( file );
printf( "Buffer[0,1]=%02x, %02x\n", buffer[0], buffer[1] );
return 0;
}
当连接到地址为 0x60 的 SparkFun MCP4725 时,清单 9-2 中的程序输出如下:
Buffer[0,1]=c0, c0
程序只是读取了状态信息字节两次。当从非 SMBus 设备(如 MCP4725)读取数据时,使用标准的 I²C 读取操作;将此函数保留给实际的 SMBus 设备使用。
9.7.4 i2c_smbus_write_byte()函数
i2c_smbus_write_byte()函数向 I²C 设备写入单个字节:
__s32 i2c_smbus_write_byte( int `file`, __u8 `value` );
file参数是指定总线(设备)的句柄,value是要在总线上传输的字节。此函数传输起始条件、地址字节、数据字节,最后是停止条件。
与i2c_smbus_read_byte()函数一样,不要使用此函数向 I²C 设备写入字节序列(例如,将 DAC 值写入 MCP4725)。由于它在地址和数据值之间加上起始和停止条件,你可能最终只会连续两次写入数据序列的第一个字节。
9.7.5 i2c_smbus_read_byte_data()函数
i2c_smbus_read_byte_data()函数将一个寄存器号写入 I²C 设备,然后读取一个数据字节(假设来自写操作指定的寄存器)。其原型为:
__s32 i2c_smbus_read_byte_data( int `file`, __u8 `command` );
其中,file是指定 I²C 设备的文件句柄,command是写入设备的寄存器号或命令字节,然后再进行读取操作。
此函数传输起始条件,地址字节(R/W 位设置为 0,即写操作),然后是command字节。接着,它发送一个(重新)起始条件,随后发送另一个地址字节(这次 R/W 位设置为 1,即读操作)。外设响应并传输一个数据字节,然后控制器在总线上发送停止条件。Listing 9-3 中的程序演示了这个调用。
// Listing9-3.cpp
//
// Demonstration of i2c_smbus_read_byte_data call.
//
// gcc listing-9-3.c -li2c
#include <unistd.h>
#include <fcntl.h>
extern "C" // Needed for C++ compiler
{
#include <linux/i2c-dev.h> // Needed for I2C port
#include <i2c/smbus.h>
}
#include <sys/ioctl.h>
#include <stdio.h>
#define i2cDevname "/dev/i2c-1"
#define i2cAddr (0x62) // Adafruit MCP4725 address
//#define i2cAddr (0x60) // SparkFun MCP4725 address
int main()
{
unsigned char result;
int file;
file = open( i2cDevname, O_RDWR );
ioctl( file, I2C_SLAVE, i2cAddr );
result = i2c_smbus_read_byte_data( file, 0 );
printf( "Result=%02x\n", result );
return 0;
}
图 9-4 显示了运行 Listing 9-3 程序时逻辑分析仪的输出。从该图中可以看出,i2c_smbus_read_byte_data()函数调用发出了两个 I²C 操作:一个写操作(写入字节 0,这是调用中的命令参数)和一个读操作,最终从 DAC 读取 0xC0(状态字节)。你看不见这一点,因为图 9-4 是黑白的,但在两个传输之间有一个重启条件:一个没有停止条件的起始条件(命令之间的点)。

图 9-4:运行 Listing 9-3 程序时逻辑分析仪的输出
通常,你不会在 MCP4725 DAC 设备上使用此函数。写入单个字节会清除输出值的高 4 位(不会影响低字节)。当然,读取单个字节也没什么意义,除非你只对 MCP4725 的状态字节感兴趣。
通常,你会使用此函数与更复杂的设备进行通信,这些设备需要在读取设备中的字节之前先写入命令或寄存器字节,这是与 SMBus 设备常见的操作顺序。例如,MCP23017 GPIO 扩展器 IC 就是这样工作的。
此函数调用与写一个字节后再读一个字节的调用之间的主要区别在于,后者在写入第一个字节后会发出停止条件。这个停止条件会重置设备(如 MCP23017)的状态机逻辑,可能会导致它将第二次写入当作独立操作处理,从而使总线容易受到其他控制器发送数据的干扰。
9.7.6 i2c_smbus_write_byte_data() 函数
i2c_smbus_write_byte_data() 函数将一个字节写入以指定 I²C 设备寄存器,然后将第二个字节写入该寄存器。其原型如下:
static inline __s32 i2c_smbus_write_byte_data
(
int file,
__u8 command,
__u8 value
);
与 i2c_smbus_read_byte_data() 函数一样,此调用主要用于与像 MCP23017 GPIO 扩展器这样的设备进行通信,这些设备要求在传输数据字节之前立即传输寄存器号。
9.7.7 i2c_smbus_read_word_data() 函数
i2c_smbus_read_word_data() 函数将一个寄存器号写入 I²C 设备,然后从设备读取一对字节(假定来自指定寄存器)。其原型如下:
__s32 i2c_smbus_read_word_data( int file, __u8 command );
这个函数类似于 i2c_smbus_read_byte_data(),不同之处在于它在将 command 字节写入 I²C 总线后,读取 2 个字节(一个字)。
当此函数执行时,以下 I²C 总线事务将发生:
-
发送一个启动条件。
-
带有 R/W 位为 0 的地址字节被发送。
-
发送
command字节。 -
发送一个(重新)启动条件。
-
带有 R/W 位为 1 的地址字节被发送。
-
从外设设备读取 2 个字节。
-
发送停止条件。
这个函数按小端字节顺序读取字节;也就是说,它从总线读取的第一个字节是 LO 字节,第二个字节是 HO 字节,这通常与数据实际到达的顺序相反。例如,在使用此函数读取 MCP4725 DAC 时,返回的字是状态字节位于 LO 8 位,接下来读取的字节(恰好是上次写入的 DAC 值的 HO 8 位)在 HO 字节中。在使用此函数时,请注意这个问题。
9.7.8 i2c_smbus_write_word_data() 函数
i2c_smbus_write_word_data() 函数将 3 个字节写入 I²C 设备:第一个字节指定寄存器号,接下来的 2 个字节是写入该寄存器的字值。其原型如下:
i2c_smbus_write_word_data( int file, __u8 command,__u16 value )
这个函数类似于i2c_smbus_write_byte_data(),不同之处在于它在将 command 字节写入 I²C 总线后,写入 2 个字节(一个字)。请注意,这个函数按小端字节顺序写入字节。在使用这个函数时,请注意这个问题。
当此函数执行时,以下 I²C 总线事务将发生:
-
发送一个启动条件。
-
带有 R/W 位为 0 的地址字节被发送。
-
发送命令字节。
-
值的 LO 字节被发送。
-
值的 HO 字节被发送。
-
发送停止条件。
请注意,与读取字函数不同,此函数仅发送一个地址字节,不需要重新启动条件。该函数仅在地址字节后写入 3 个字节(一个命令字节和 2 个数据字节)。第一个字节很可能是寄存器或命令字节,后面跟着 2 个数据字节。
9.7.9 i2c_smbus_read_block_data()函数
i2c_smbus_read_block_data()函数从指定设备读取一块数据,并将该数据放入values数组中。该函数首先将寄存器号或命令字节写入设备,然后设备返回数据。其原型如下:
static inline __s32 i2c_smbus_read_block_data
(
int file,
__u8 command,
__u8 *values
}
此命令对于某些特定的 I²C 设备非常有用。总线事务如下:
-
发送启动条件。
-
带有 R/W 位为 0 的地址字节被发送。
-
command字节被发送。 -
发送(重新)启动条件。
-
带有 R/W 位为 1 的地址字节被发送。
-
系统读取一个计数字节(n),然后从设备读取n个字节(具体读取多少字节由设备决定)。
-
发送停止条件。
要从 I²C 总线读取任意字节块,无需命令或寄存器字节,只需使用 Linux 的read函数。
9.7.10 i2c_smbus_write_block_data()函数
i2c_smbus_write_block_data()函数将一块数据写入指定设备。该函数首先将寄存器号或命令字节写入设备,然后是数据。其原型如下:
static inline __s32 i2c_smbus_write_block_data
(
int file,
__u8 command,
__u8 length,
const __u8 *values
)
该函数将一个command字节写入设备,然后写入由值数组指定的length字节。总线事务如下:
-
在总线上放置启动条件。
-
带有 R/W 位设置为 0 的地址字节被写入总线。
-
command字节被写入总线。 -
从
values中写入length字节到总线。 -
在总线上放置停止条件。
在command字节后没有写入额外的地址字节。实际上,这个函数大致相当于将command字节放入values数组的开头,并调用 Linux 的write函数,写入字节数为length+1。
9.7.11 杂项函数
还有一些其他杂项(仅限 SMBus)较少使用的函数,我在这里不会记录。有关这些函数的更多信息,请查看 Linux 内核 I²C 文档:www.kernel.org/doc/Documentation/i2c/smbus-protocol。
9.8 I²C 操作的重入问题
请记住,Linux(树莓派操作系统)是一个多任务操作系统。因此,两个不同的线程或进程完全有可能同时尝试访问 I²C 总线。Linux 会自动序列化对 I²C/SMBus 设备驱动程序的访问。因此,如果某个线程当前在 Linux 内核中执行 I²C 代码,而另一个线程尝试调用某些 I²C 内核代码,Linux 系统将会阻塞第二个任务,直到第一个线程退出ioctl()调用。从这个意义上讲,你不必担心重入性或类似的问题。
也就是说,两个不同的线程不能同时与同一个设备通信,但两个不同的线程可以与 I²C 总线上的两个独立设备通信。因此,Linux 允许两个线程,甚至是同一个线程,多次打开同一个总线。这意味着,例如,两个不同的线程可以同时打开 i2c-1 总线,并且它们都可以向同一个 MCP4725 数模转换器写入数据。当然,如果两个线程独立地向 DAC 写数据,那么 DAC 的输出就会变得非常混乱。不幸的是,Linux 无法为你解决这个问题。当编写多个线程或程序时,你必须小心,以防它们可能同时访问同一 I²C 设备。
9.9 Linux 下的多控制器操作
据我所知,树莓派操作系统(以及 Linux 系统)不支持同一 I²C 总线上的多个控制器。Linux 是一个单控制器、多外设的 I²C 接口。
我怀疑树莓派硬件不支持多个控制器,考虑到前面提到的树莓派 I²C 控制器硬件问题,该控制器无法正确处理时钟延展,而且时钟同步和仲裁需要执行相同类型的操作。当然,这个问题仅适用于树莓派;其他 Linux 系统可能能够很好地支持同一总线上多个控制器。
9.10 其他 Linux 系统
本章主要集中在树莓派上,但实际上,本章中唯一真正与树莓派相关的话题是激活 I²C 总线。本章讨论的大多数功能和工具对于 Linux 而言都是通用的。以下小节描述了一些支持 I²C 的其他常见 Linux 系统,用于一般接口目的。
9.10.1 PINE A64 和 ROCKPro64
ROCKPro64 是一款 64 位 ARM 单板计算机,它的外观和行为与树莓派非常相似。尽管它是一款出色的小板(以及它的更小型号 PINE A64),PINE64 的团队依赖第三方提供 Linux 操作系统的移植版本。提供了多个版本,这使得很难找到一篇合适的教程来启用该单板的 I²C 线路。以下是我找到的一些参考资料(这两篇资料都描述了在 ROCKPro64 单板上的 I²C 编程),如果你正在使用这些设备,可能会对你有所帮助:
9.10.2 BeagleBone Black
BeagleBone Black 是树莓派的一个开源替代品。由于该设备是为硬件黑客设计的,因此它自带 i2c-tools 套件并预激活了 I²C 总线,这并不令人惊讶。
在 BeagleBone Black 上,i2c-2 总线(P9 连接器的 19 和 20 引脚;参见第六章的图 6-10)通常可供外部使用。使用 "/dev/i2c-2" 作为文件名来打开总线,以访问 BeagleBone Black 上的总线。
9.10.3 Onion Omega2+
Onion Omega2+ 是一款小型的基于 Linux 的模块,旨在用于物联网操作。这个小模块自带 I²C 通信,并且可以直接运行。
Onion 提供了一个 I²C 库,您可以链接它来访问 I²C 设备。请查看 Onion 的 I²C 文档:docs.onion.io/omega2-docs。
9.11 使用树莓派作为 I²C 外设设备
尽管标准的树莓派硬件和设备驱动程序不支持将 Pi 用作 I²C 外设设备,但可以通过位波控制技术来实现。pigpio 免费软件库(abyz.me.uk/rpi/pigpio)提供了一个 API,用于处理树莓派上的 GPIO 引脚。该库提供了一个基于软件的 I²C 接口,支持控制器模式和外设模式。
pigpio 库也支持位波控制器操作。使用软件控制的 I²C 驱动程序提供了硬件 I²C 系统无法实现的附加功能,包括以下内容:
-
最低波特率为 50
-
重复启动
-
时钟拉伸
-
I²C 可通过任意一对空闲 GPIO 引脚使用
请参见 abyz.me.uk/rpi/pigpio/cif.xhtml#bbI2COpen 获取关于 pigpio 库中位波 I²C 函数的更多信息。
最后,pigpio 还提供了现有 ioctl() 函数的外观(外观设计模式)。请参见 abyz.me.uk/rpi/pigpio/cif.xhtml#i2cOpen 获取这些函数的列表。
9.12 章节总结
本章讨论了在树莓派上进行 I²C 编程,首先介绍了树莓派 40 引脚 GPIO 头上的 I²C 引脚。内容展示了如何在树莓派上启用 I²C 总线并调整 SCL 时钟频率,接着讨论了树莓派 I²C 的一些问题,例如其不支持时钟拉伸。你还学习了如何使用各种通用的 Linux 工具来探测 I²C 总线并访问某些类型的 I²C 外设。然而,本章的重点是通过 Linux 在 I²C 总线上进行读写数据,包括各种高级内核调用。与 Arduino 和其他简单系统不同,Linux 是一个完整的多任务/多处理操作系统。为了应对这种系统中的问题,本章简要讨论了重入性问题,以及当多个线程或进程同时访问同一个 I²C 总线时,如何规避这些问题。
虽然本章主要聚焦于树莓派,但树莓派是一个通用的 Linux 系统,本章中大部分与硬件无关的内容同样适用于其他基于 Linux 的系统。因此,本章还概述了在 PINE A64 或 ROCKPro64、BeagleBone Black 和 Onion Omega2+上的 I²C 编程。最后,你学习了如何使用 pigpio 库将树莓派用作 I²C 外设设备,以及如何使用同一库进行通用的位操作 I²C 支持。
第十章:I²C 在实时操作系统中的编程

I²C 传输较慢,通常为 100 kHz。在像 Arduino 这样的系统中,你的代码必须等待每次传输或接收完成才能进行其他工作,这会大幅度降低应用程序的性能。在等待过程中,CPU 只是在执行一个繁忙等待循环(也叫自旋循环),浪费了 CPU 周期。在本章中,你将学习如何使用实时操作系统(RTOS)来有效利用这些 CPU 周期。
本章介绍了几种不同的 RTOS——µC/OS、FreeRTOS、Teensy Threads 和 Mbed——你可以在典型的 SBC 上运行这些 RTOS,并提供了每个 RTOS 使用 I²C 的示例程序。有些 RTOS,如 Mbed,提供完整的 I²C 支持。而像 FreeRTOS 和 Teensy Threads 这样的 RTOS 是简单的调度器,你必须提供自己的兼容 I²C 库代码。RTOS 的选择通常由你使用的 SBC 决定,因为如果你选择了某个 SBC,你只能运行已经移植到该板的 RTOS。相反,如果你想使用某个 RTOS,你必须选择一个已经为其移植的 SBC——除非你愿意自己进行移植,而这通常是一个非常繁琐的工作。
本章首先介绍一些基本的 RTOS 概念,然后介绍几个 RTOS,以及一些代表性的单板计算机(SBC),供本节所描述的 RTOS 使用。这并不是说我为给定的 RTOS 选择的 SBC 是唯一的(甚至是最好的)SBC——这些只是我在写这本书时所能使用的组件。对于大多数 RTOS,设计你自己的系统时,你通常会有更多平台可供选择。
10.1 实时操作系统基础
RTOS 的目的是在保证的时间内处理异步事件,例如 I²C 传输的完成。当然,另一种做法是通过轮询——CPU 简单地在自旋循环中等待,测试事件是否发生,直到事件发生后立即处理它。虽然轮询有一些优点(特别是它可以提供对事件的最快响应时间),但也有一个巨大的缺点:CPU 被困在自旋循环中,无法执行其他任务。
本书通常使用任务一词来表示某个通用的执行单元,该单元与其他执行单元并行(伪)执行。线程和进程是任务类型的例子,我将很快讨论这些内容。
RTOS 允许其他任务在 CPU 等待某些事件发生时进行工作。要使这一点可行,需要一些硬件支持;特别是,外部事件必须能够在 CPU 上生成中断信号。正如其名称所示,中断信号 会导致 CPU 暂停当前执行的任务,并将控制转移到一个特殊的 ISR 来处理该事件。对于某些设备,ISR 完全处理该事件,暂停的任务会恢复控制。然而,对于大多数 RTOS 和 ISR,ISR 只是设置一个标志,标记事件已发生,然后 RTOS 会在未来某个时刻调度原始代码(该代码原本在等待 I²C 事务或其他任务完成)的执行。
以 I²C 写操作为例,调用写函数将会配置 I²C 硬件,开始在 I²C 总线上传输数据。然后,执行写数据的任务将会 挂起,以允许其他任务进行一些工作。当 I²C 传输完成后,I²C 硬件将生成一个中断,ISR 会发出一个特殊的系统调用,通知 RTOS 唤醒之前挂起的写数据到 I²C 总线的任务。
RTOS 将 I²C 写任务从挂起队列移到就绪队列。然而,这并不保证 I²C 写任务会立即开始执行(这取决于 RTOS 的调度策略)。控制可能会回到中断发生时刚被挂起的任务。
在未来的某个时刻,RTOS 会决定允许 I²C 写入任务继续执行。然后它会将任务从就绪队列中移除并开始执行,暂停当前正在执行的任务。此时,I²C 写入任务可以继续完成它需要做的工作,比如写入额外的数据、读取数据,或者简单地返回到请求 I²C 写操作的应用程序。
10.1.1 进程与线程
操作系统理论定义了多个任务级别,包括进程和线程。如前所述,本书将使用通用术语 任务 来描述进程和线程。
线程 是一个执行单元,它与其他并发执行的线程共享地址空间。因为线程共享内存(地址空间),一个线程可以修改另一个线程读取的内存。这为线程间通信提供了一种简便的方式,但它也带来了一些问题,正如你将在下一节看到的那样。
进程是一个执行单元,具有自己的地址空间,并且不会与其他进程共享该内存。进程间的通信比线程间通信稍微复杂一些,因为通常需要使用文件或其他操作系统定义的数据结构来进行通信。然而,由于进程无法覆盖彼此的内存空间,因此它们相互干扰的机会较少。
一个应用程序可以由一个或多个进程组成。每个进程将包含一个或多个线程。最简单的应用程序由单个进程执行单个线程组成。稍微复杂一点的应用程序是执行一个具有多个执行线程的单个进程的应用程序。再往上是具有多个进程的应用程序,每个进程都有一个或多个执行线程。
可视化多个进程和线程最简单的方式是将每个进程和线程视为编程语言中的程序或函数。每个独立的进程或线程对应一个唯一的函数,执行该进程或线程的代码。虽然这是一个简单的模型,但实际上,不同的进程和线程共享相同代码是很常见的。例如,两个执行线程可能在内存中运行相同的函数,也许传递不同的参数来允许它们执行不同的操作。
10.1.2 多线程与多任务
实时操作系统(RTOS)的主要功能是允许多个线程并发执行。一些微控制器包括多个 CPU(多核 CPU),这意味着两个或更多任务确实可以在不同的 CPU 上同时运行。然而,大多数嵌入式微控制器仅限于单个 CPU(核心),因此任何时刻只能执行一个任务。为了模拟多任务(也称为多线程),RTOS 会快速切换任务,给人一种多个任务同时执行的错觉。
大多数现代实时操作系统(RTOS)使用抢占机制来暂停一个任务,然后允许另一个任务执行。每个 RTOS 都有自己的策略来决定如何抢占正在运行的任务。有些 RTOS 为每个任务分配一个固定的运行时间,并在定时器到期时切换任务。这个时间段称为时间片或时间量子;在任务之间切换的过程称为时间复用。其他 RTOS 为不同的任务分配优先级,允许优先级较高的任务在没有阻碍的情况下运行,直到它们被暂停或更高优先级的任务准备好运行。许多 RTOS 使用这些策略的组合。例如,如果两个任务具有相同的优先级并准备好运行,它们使用时间切片在彼此之间切换,而低优先级任务则保持暂停状态,直到这两个任务都暂停自己。
在纯优先级调度系统中,如果某个高优先级任务始终在运行,低优先级任务可能永远无法运行。这可能会导致饥饿现象,意味着某个任务永远不会执行。许多 RTOS 会在一段时间后临时提升低优先级任务的优先级,以确保它偶尔能获得一些处理时间。
RTOS 的调度策略决定了它如何选择下一个运行的任务。例如,如果 RTOS 为任务分配相同的优先级并给每个任务分配相等大小的时间片,那么调度策略决定了当一个任务完成其时间片(或由于其他原因挂起)时,CPU 如何选择下一个任务来运行。一种明显的解决方案是轮询调度策略,其中 RTOS 维护一个准备运行任务的队列,在任务切换时从队列前端挑选任务;它会将新挂起的任务放到队列的末尾。大多数时候,这能确保公平地分配 CPU 资源给每个准备运行的任务。但也有一些特殊情况,这个方案并不完全公平。例如,如果某个任务比其他任务更频繁地挂起,那么即便它在运行时占用的 CPU 时间很少,它也必须重新等待整个队列。然而,作为一种快速且简单的解决方案,轮询调度效果很好。
有时应用程序可以控制调度优先级的各个方面,但更多情况下,应用程序必须接受操作系统提供的调度策略。调整 RTOS 策略超出了本书的范围,但若想了解更多,请参见本章结尾的“更多信息”或你所使用的 RTOS 手册。幸运的是,I²C 活动通常非常缓慢(至少在 100 kHz 下运行时如此),因此调度调整通常不会对基于 I²C 的应用程序的性能产生太大影响。
10.1.3 重入性
在多线程环境中编程 I²C 设备时,也许最大的难题是重入性,它发生在两个不同的线程尝试同时执行相同代码时。I²C 设备是一个单一的共享系统资源。如果由两个不同线程调用的某个函数试图与 I²C 设备通信,第二个线程重入该函数时将尝试与同一个设备并行通信。如果一个线程开始向设备写入 2 字节或 3 字节的数据序列,并在传输第一个字节后被中断,那么从设备的角度来看,第二个线程发送的第一个字节看起来像是第一个线程发送的第二个字节。如果两个线程共享同一个 I²C 设备,两个线程需要非常小心地同步,以确保正确操作。
即使两个线程不访问相同的设备,两个不同的线程也不能在同一总线上同时与两个不同的设备通信。再一次,不同的线程必须同步使用相同的 I²C 总线。从某些方面来看,这类似于有两个控制器共享总线;然而,并没有协议来处理冲突——各个线程必须自己处理争用问题。
10.1.4 同步
同步通常通过互斥锁(mutual exclusion primitives)、临界区、信号量、事件和其他操作系统同步原语来处理。这些操作的基本思想是只允许一个线程在某一段代码中访问,防止多个线程同时进入。在典型的 RTOS 中,线程会请求对某个临界区的独占访问。如果 RTOS 批准了该请求,其他线程的后续请求将被阻塞,直到原始线程释放临界区。这个机制确保一次只有一个线程可以进入临界区,从而消除了重入问题。
当一个线程在等待其他线程释放临界区时,等待的线程会被挂起(阻塞),并且在等待临界区释放的过程中不会消耗任何 CPU 周期。在 I²C 传输的情况下,这种阻塞可能会持续相当长的时间;持有临界区的线程可能正在 I²C 总线上传输和接收几个字节(通常每个字节需要 100 微秒到 1000 微秒,如果发生时钟拉伸,时间会更长)。好消息是,被阻塞的线程不会干扰当前在 I²C 总线上进行的传输。
10.1.5 安全关键系统
某些 RTOS,如 µC/OS 或 FreeRTOS,已经获得安全认证,意味着它们经过严格的质量保证测试。这是一个重要的优势,因为如果你在开发医疗设备、核仪器或汽车应用时,行业监管机构可能会要求你使用安全认证的操作系统,或者提供适当的文档和测试,证明你选择的系统是合适的,才会允许你部署系统。例如,我在为核反应堆开发仪器时使用过 µC/OS(运行在 NetBurner 上)。
当然,如果你不是在开发任务关键型应用,你可能不需要一个安全认证的 RTOS。显然,选择的操作系统非常依赖于具体的应用,但需要注意的是,质量保证问题可能会限制你选择实时操作系统的范围。
10.2 实时操作系统 I²C 编程
本章主要讨论四种 RTOS:µC/OS、FreeRTOS、Teensy Threads(其实不算真正的 RTOS,只是一个多线程包)和 Mbed。
FreeRTOS 和 Teensy Threads 其实只是线程调度包,提供基本的多任务和线程同步功能。它们不提供任何其他库代码,例如 I²C 通信功能;你需要自己提供这些代码。特别是,你需要负责同步对共享资源(如 I²C 总线)的访问。
第二种 RTOS,µC/OS 和 Mbed,是功能齐全的 RTOS,提供了许多活动的库支持,例如 I²C 通信。这些更复杂的 RTOS 提供了对它们所使用资源的同步访问。
以下小节将简要讨论这些 RTOS 的每个特点。在适用的情况下,还会描述如何保护对共享资源(如 I²C 总线)的访问。
10.2.1 µC/OS
本章中,我将使用由 NetBurner, Inc. 提供的 NBRTOS 变体,它运行在 NetBurner MOD54415 SBC 上。NBRTOS 是 µC/OS I 的一个变体,包含了几个额外的库来支持 MOD54415,其中包括一些 I²C 库。
原始的 µC/OS I RTOS 是一个完全基于优先级的 RTOS,有 64 个不同的优先级等级。它有一个严格的限制,即每个任务(µC/OS 中对线程的称呼)必须运行在不同的优先级上,因此在使用轮转/时间复用调度时,你不能有两个任务在相同优先级下运行。µC/OS 的后续版本,如 µC/OS III,引入了更多的优先级等级(最多 256 个),并允许多个任务在相同优先级下运行,使用时间复用切换任务。然而,由于 NBRTOS 使用的是 µC/OS I 的版本,本书将坚持使用基于优先级的调度方式。书中的大多数其他 RTOS 使用的是时间切片(时间复用)而不是基于优先级的方案,因此 µC/OS 在这方面显得有些不同。
MOD54415 SBC 支持最多四个不同的 I²C 端口。一个名为 MultiChannel_I2C 的特殊库,在多线程环境中为这四个通道提供支持。这个库提供了几个不同的 I²C 函数,虽然本章只会使用其中的两个函数,MultiChannel_I2CInit() 和 MultiChannel_I2CSendBuf(),来演示如何向 MCP4725 DAC 写数据:
void MultiChannel_I2CInit
(
int moduleNum = DEFAULT_I2C_MODULE,
uint8_t slave_Addr = 0x08,
uint8_t freqdiv = 0x3C
);
uint8_t MultiChannel_I2CSendBuf
(
int moduleNum,
uint8_t addr,
puint8_t buf,
int num,
bool stop = true
);
第一个函数 MultiChannel_I2CInit() 初始化你将使用的 I²C 端口。这个端口通常是一个小整数,范围从 0 到 3(对应 i2c-0 到 i2c-3 端口)。第二个参数指定了该端口的外设地址;如果你使用的是外设模式,则需要指定这个地址。如果你使用的是控制器模式,可以忽略这个参数(默认值 0x08 就可以)。最后一个参数指定了 I²C 总线的频率除数。0x3C 的默认值适用于 100 kHz 的操作;如果你想在其他时钟频率下运行,可以参考 NetBurner 的文档。
第二个函数MultiChannel_I2CSendBuf()将数据写入 I²C 总线。第一个参数是 I²C 端口号(例如,0表示 i2c-0),第二个参数是设备的 I²C 地址,第三个参数是一个包含要写入数据的字节数组,第四个参数指定要写入的字节数,最后一个参数指定在传输后是否向 I²C 总线写入停止条件(默认值为true,即发送停止条件)。
The NetBurner library provides a fair number of other functions you can use to manipulate the I²C bus. For more detail, see the NetBurner documentation linked in “For More Information.” The program in Listing 10-1 is the usual triangle wave DAC output sample program. Other than a few µC/OS peculiarities (outside the scope of this book), this program is equivalent to the demonstration program from other chapters. ``` // Listing 10-1 (main.cpp) // // DAC output example for µC/OS. #include "predef.h" #include <stdio.h> #include <startnet.h> #include <autoupdate.h> #include <multichanneli2c.h> #define ever ;; #define I2C_CHANNEL 0 // Going to use I2C0 on NetBurner #define mcp4725 0x60 // DAC I2C address extern "C" { void UserMain( void * pd ); } // DACout- // // Draws one cycle of a triangle waveform on // the MCP4725 I2C DAC device (for example, // Adafruit MCP4725 breakout board). // // Argument: I2C address for the DAC. For // Adafruit MCP4725 breakout boards, this // is either 0x62 or 0x63\. For SparkFun // boards, this is either 0x60 or 0x61. void DACout( int adrs ) { uint8_t buf[2]; // Send the rising edge of a triangle wave: for( uint16_t dacOut = 0; dacOut < 0xfff; ++dacOut ) { // Note: MCP4725 requires that you write // the HO byte first and the LO byte second! buf[0] = (dacOut << 8) & 0xff; buf[1] = dacOut & 0xff; // Transmit the data from the buffer: ❶ MultiChannel_I2CSendBuf ( I2C_CHANNEL, adrs, // Device address buf, // Data to write 2, // 2 bytes to write true // Send stop condition ); } // Send the falling edge of the triangle wave. for( uint16_t dacOut = 0xffe; dacOut > 0; --dacOut ) { // HO then LO byte: buf[0] = (dacOut << 8) & 0xff; buf[1] = dacOut & 0xff; MultiChannel_I2CSendBuf ( I2C_CHANNEL, adrs, // Device address buf, // Data to write 2, // 2 bytes to write true // Send stop condition ); } } void UserMain( void * pd ) { int cntr = 0; // Standard NetBurner initialization stuff: InitializeStack(); EnableAutoUpdate(); // Allow Ethernet update of code // Initialize I2C0 on the NetBurner MOD54415: ❷ MultiChannel_I2CInit( I2C_CHANNEL ); for( ever ) { // Print status information to the serial console // every now and then to show that something is // happening: iprintf( "main loop, cntr=%d\n", cntr++ ); // Draw one cycle of the triangle waveform // on the DAC: DACout( mcp4725 ); // MCP4725 output } // endfor } // UserMain ``` The `MultiChannel_I2CInit()` ❷ and `MultiChannel_I2CSendBuf()`❶ functions are the µC/OS I²C initialization and I²C output routines. Figure 10-1 shows the oscilloscope output from the program in Listing 10-1. Note that the frequency is much closer to the Arduino example (see Figure 8-2 in Chapter 8) than the Raspberry Pi example (see Figure 9-3 in Chapter 9). The slower frequency in the Pi example is, undoubtedly, due to all the extra work happening under the multitasking Pi OS (Linux).  Figure 10-1: Oscilloscope output from Listing 10-1 Since µC/OS supports multitasking, you might wonder, why not write some code to generate two separate triangle waves concurrently? Of course, two tasks cannot access the same I²C device concurrently, but you might reason that you could fix that issue by putting two different DACs on the I²C bus. One task could write to the first DAC, and the second task could write to the second DAC. Unfortunately, under a pure priority-based system such as µC/OS, this won’t work out well. The higher-priority task always runs, and the lower-priority task never gets a chance to execute (unless you put in code to explicitly suspend the higher-priority task). Listing 10-2 provides the source code to the version of the code that demonstrates one way to do this. ``` // Listing 10-2 (main.cpp) // // Multi-threaded I2C demonstration #2. // This program writes to two separate // MCP4725 devices on the same I2C bus // using separate threads for each of the // DACs, with a semaphore to protect // writes to the I2C port. #include "predef.h" #include <stdio.h> #include <startnet.h> #include <autoupdate.h> #include <pins.h> #include <multichanneli2c.h> #define ever ;; #define I2C_CHANNEL0 0 // Going to use I2C0 #define dac1 0x62 // MCP4725 #1 address #define dac2 0x63 // MCP4725 #2 address extern "C" { void UserMain(void * pd); } // Stack for DACthread: #define DACthread_STK_SIZE (4096) static DWORD DACthread_stack[ DACthread_STK_SIZE ] __attribute__((aligned(4))); // Critical section protecting console I/O: OS_CRIT ioCS; ❶ OS_SEM threadSem; OS_SEM mainSem; // DACout- // // Draws one cycle of a triangle waveform on // the MCP4725 I2C DAC device (e.g., Adafruit // MCP4725 breakout board). // // Argument: I2C address for the DAC. For // Adafruit MCP4725 breakout boards, this // is either 0x62 or 0x63\. For SparkFun // boards, this is either 0x60 or 0x61. void DACout( int adrs, OS_SEM *enter, OS_SEM *leave ) { uint8_t buf[2]; // Send the rising edge of a triangle wave: for( uint16_t dacOut = 0; dacOut < 0xfff; ++dacOut ) { // Note: MCP4725 requires that you write // the HO byte first and the LO byte second! buf[0] = (dacOut << 8) & 0xff; buf[1] = dacOut & 0xff; // Transmit the data from the buffer: ❷ OSSemPend( enter, 0 ); // Protect call ❸ MultiChannel_I2CSendBuf ( I2C_CHANNEL0, adrs, // Device address buf, // Data to write 2, // 2 bytes to write true // Send stop condition ); ❹ OSSemPost( leave ); // Enable other thread } // Send the falling edge of the triangle wave. for( uint16_t dacOut = 0xffe; dacOut > 0; --dacOut ) { // HO then LO byte: buf[0] = (dacOut << 8) & 0xff; buf[1] = dacOut & 0xff; OSSemPend( enter, 0 ); // Protect call MultiChannel_I2CSendBuf ( I2C_CHANNEL0, adrs, // Device address buf, // Data to write 2, // 2 bytes to write true // Send stop condition ); OSSemPost( leave ); // Enable other thread } } void DACthread( void *parm ) { int cntr = 0; for( ever ) { // Print a message each time the thread // completes one cycle of the triangle // wave. Note that iprintf must be // protected by a critical section. OSCritEnter( &ioCS, 0 ); iprintf( "thread loop, cntr=%d\n", cntr++ ); OSCritLeave( &ioCS ); // Draw one cycle of the triangle waveform // on the DAC at address 0x63: DACout( dac2, &threadSem, &mainSem ); } // endfor } void UserMain( void * pd ) { int cntr = 0; // Standard NetBurner initialization stuff: InitializeStack(); EnableAutoUpdate(); // Allow Ethernet update of code // Initialize the critical sections used to protect // console I/O and the I2C output. OSCritInit( &ioCS ); OSSemInit( &threadSem, 1 ); OSSemInit( &mainSem, 0 ); // Initialize I2C0 pins on the NetBurner MOD54415: MultiChannel_I2CInit( I2C_CHANNEL0 ); // Start a thread running that will write to the // DAC at address 0x63\. Give the thread a higher // priority than that of the main thread. // // The parameters are the following: // // 1\. Address of function to invoke as the // new thread ("task" in NBRTOS terminology). // // 2\. Parameter to pass to the thread. // // 3\. Address of the first byte beyond the // stack space allocated for the thread. // // 4\. Address of the start of the task. // // 5\. Thread priority (lower number is // higher priority). ❺ OSTaskCreate ( DACthread, NULL, (void*)&DACthread_stack[DACthread_STK_SIZE], (void *)DACthread_stack, MAIN_PRIO + 1 ); for( ever ) { // Print a message each time the main thread // completes one cycle of the triangle // wave. Note that iprintf must be // protected by a critical section. OSCritEnter( &ioCS, 0 ); iprintf( "main loop, cntr=%d\n", cntr++ ); OSCritLeave( &ioCS ); // Draw one cycle of the triangle waveform // on the DAC at address 0x62: DACout( dac1, &mainSem, &threadSem ); } // endfor } // UserMain ``` Unfortunately, you cannot use µC/OS critical section variables (`OS_CRIT`) to protect access to the I²C bus. As noted earlier, because µC/OS is strictly a priority-based system, the lower-priority thread will not get a chance to run unless some system call explicitly blocks the main thread. To overcome this problem, the code in Listing 10-2 uses semaphores. Semaphores are similar to critical sections insofar as you can use them to protect a section of code. They differ from critical sections in that they have a counter associated with them. When you enter a critical section (a µC/OS `OSSemPend()` call), the system first checks to see if this counter is 0\. If so, the code blocks; if not, the code decrements the counter and enters the critical section. Note that if you initialize a semaphore with 1, it behaves like a critical section variable. µC/OS semaphores use three main functions: `OSSemInit()`, `OSSemPend()`, and `OSSemPost()`. In addition to initializing internal data structures, the `OSSemInit()` function allows you to initialize the counter with the integer; for managing critical sections, the initial value is usually 0 or 1\. As already noted, the `OSSemPend()` function checks the counter for 0 (and blocks if 0) and decrements the counter if it is nonzero, as well as allowing entry into the critical section. `OSSemPost()` increments the counter associated with the semaphore. This means you would normally use `OSSemPend()` to enter a critical section and `OSSemPost()` to leave a critical section. The trick in Listing 10-2 is to use two semaphores, `mainSem` and `threadSem`, to protect access to the DACs by multiple tasks ❶. Whenever one of these semaphores contains 1, the associated task can execute; when the semaphore is 0, the task will block. The trick is to make sure that the two threads alternate setting the semaphores to 0 or 1 to allow execution to “ping-pong” between the two tasks. If you look at the `DACout()` function in Listing 10-2, you’ll see that a task enters its critical section by executing `OSSemPend()` on the semaphore associated with that task ❷. To exit the critical section, the code executes the `OSSemPost()` function on the semaphore associated with the other thread ❹. This might seem incorrect, but let’s consider this sequence step-by-step: 1. Assume the `mainSem` (enter parameter) counter is 1 and the `threadSem` (leave parameter) is 0. 2. Upon executing `OSSemPend( enter, 0 );`, the system decrements the counter to 0 and enters the critical section (because the counter wasn’t already 0). Note that because the `UserMain()` task has a higher priority than the `DACthread()` task, the `UserMain()` task continues execution (and the `DACthread()` task is currently blocked). 3. The `UserMain()` task writes data to the I²C bus ❸. 4. The `UserMain()` task executes `OSSemPend()` on the `threadSem` semaphore (`leave` parameter) ❹. This increments the counter associated with `threadSem`; note that the `mainSem` counter is still 0. 5. The `UserMain()` task continues execution, which in this case means repeating the loop and re-executing the `OSSemPend()` function at ❸. Because the counter is now 0, the task blocks. 6. Once `UserMain()` blocks, the `DACthread()` task begins execution and eventually winds up in `DACout()` executing the `OSSemPend()` call. Because the `OSSemPost( leave );` call in the `UserMain()` task incremented the `threadSem` counter, the counter now contains 1 so the `DACthread()` task can enter its critical section. 7. The `DACthread()` task calls `DACout()` to write a value to the DAC. 8. The `DACthread()` task exits its critical section by calling `OSSemPost()` but passing the `mainSem` semaphore variable. This increments the `mainSem` counter; note that the `threadSem` counter is still 0. 9. Because the `UserMain()` thread has the highest priority, it immediately takes over, and this process repeats itself. Note that both tasks in Listing 10-2 call the `DACout()` function to actually write the data to the DAC (the DAC address and the two semaphores are passed as arguments). The calls to `DACout()` swap the two semaphore arguments so that `UserMain()` passes `mainSem` as the first semaphore argument, whereas `DACthread()` passes `threadSem` as the first semaphore argument. Figure 10-2 shows the oscilloscope output for the program in Listing 10-2.  Figure 10-2: Oscilloscope output from Listing 10-2 As you can see, both tasks are producing proper triangle waves. The frequency of the triangle waves in Figure 10-2 is about half that of Figure 10-1 (note the time scale for the oscilloscope in the two figures). The reason for this discrepancy is that the frequency is completely determined by the speed at which the program transmits data to the MCP4725\. In Listing 10-2, twice as much data is transmitted by sending roughly the same data to both MCP4725 devices, so the frequency is cut roughly in half. ### 10.2.2 FreeRTOS I²C Programming FreeRTOS is, in its developer’s words, “the market leading, de facto standard, and cross platform RTOS kernel.” You’ll likely encounter this popular open source kernel if you work with many different RTOSs. To use FreeRTOS, you’ll need a port of the OS to your particular device (or you’ll have to port it yourself). In this chapter I’m going to use the Teensy 4.0 port created by Julian Desvignes running under the PlatformIO IDE ([`platformio.org/lib/show/6737/FreeRTOS-Teensy4`](https://platformio.org/lib/show/6737/FreeRTOS-Teensy4)). The PlatformIO, FreeRTOS, and Teensy4 port uses the Arduino library and Teensyduino support to run FreeRTOS code in an Arduino environment. This makes it possible to create multithreaded applications while using Arduino-style programming. Because FreeRTOS is just a scheduler that provides basic task switching functionality along with synchronization primitives, you’ll have to provide your own I²C library code. Fortunately, such code is easy to find from the Arduino libraries and elsewhere. However, the Arduino libraries are not reentrant, so you have to ensure that only one task is calling a particular library function (or family of functions) using mutexes, critical sections, semaphores, or other synchronization operations. See [`www.freertos.org/a00113.xhtml`](https://www.freertos.org/a00113.xhtml) for more details on FreeRTOS synchronization primitives. Listing 10-3 presents the usual triangle wave output demo under FreeRTOS that creates two tasks; one of the tasks will blink the Teensy’s LED, and the other will output the triangle wave data to the MCP4725 DAC device: ``` // Listing 10-3 (main.cpp) // // Simple demonstration of I2C programming // under FreeRTOS running on a Teensy 4.0. #include <FreeRTOS_TEENSY4.h> #include <Wire.h> #define ever ;; // The LED is attached to pin 13 on the Teensy 4.0. const uint8_t LED_PIN = 13; // Thread1- // // This task blinks the Teensy 4.0's LED // every second (1/2 second on, 1/2 second off). static void Thread1( void* arg ) { for( ever ) { // Turn LED on: digitalWrite( LED_PIN, HIGH ); // Delay 1/2 second: vTaskDelay( (500 * configTICK_RATE_HZ) / 1000 ); // Turn LED off: digitalWrite( LED_PIN, LOW ); // Delay 1/2 second: vTaskDelay( (500 * configTICK_RATE_HZ) / 1000 ); } } // Thread2- // // This task outputs a triangle wave // to the MCP4725 device at address 0x62 // (i.e., an Adafruit MCP4725 breakout board). static void Thread2( void* arg ) { for( ever ) { for( uint16_t dacOut = 0; dacOut < 0xfff; ++dacOut ) { // Transmit the address byte (and a zero R/W bit): Wire.beginTransmission( 0x62 ); // Transmit the 12-bit DAC value (HO 4 bits first, // LO 8 bits second) along with a 4-bit // "fast write" command (0000 in the HO 4 bits // of the first byte): Wire.write( (dacOut << 8) & 0xf ); Wire.write( dacOut & 0xff ); // Send the stop condition onto the I2C bus: Wire.endTransmission( true ); } // for // Send the falling edge of the triangle wave: for( uint16_t dacOut = 0xffe; dacOut > 0; --dacOut ) { // See comments in previous loop. Wire.beginTransmission( 0x62 ); Wire.write( (dacOut << 8) & 0xf ); Wire.write( dacOut & 0xff ); Wire.endTransmission( true ); } // for } // forever } // Thread2 // In FreeRTOS for the Teensy 4.0, the // Arduino "setup" function is really the // equivalent of the main program. void setup() { portBASE_TYPE s1, s2; pinMode( LED_PIN, OUTPUT ); // LED is output Wire.begin(); // Initialize I2C library // Create task at priority two // Arguments: // // 1\. Address of function to serve as task code. // 2\. A descriptive name for the task (can be NULL). // 3\. Stack depth for the task. // 4\. Parameter to pass to task. // 5\. Task priority. // 6\. Task handle returned here (ignored if NULL). s1 = xTaskCreate ( Thread1, NULL, configMINIMAL_STACK_SIZE, NULL, 2, NULL ); // Create task at priority one // (see comments above concerning parms). s2 = xTaskCreate ( Thread2, NULL, configMINIMAL_STACK_SIZE, NULL, 1, NULL ); if ( s1 == pdPASS && s2 == pdPASS ) { // Start scheduler: vTaskStartScheduler(); } // Drop down here if there was // insufficient RAM to create // the tasks or if there was // any other problem in their // creation. for( ever ); } // WARNING: idle loop has a very small stack // (configMINIMAL_STACK_SIZE), so // loop must never block. void loop() { // Not used. } ``` The actual I²C code was taken straight out of Listing 8-1 (Arduino code), which is not reentrant code. The key thing to note is that the tasks do not call common library code. `Thread1()` calls only the Arduino `digitalWrite()` function, and `Thread2()` calls only the `Wire` class functions. Had this example tried to write to I²C devices from two separate tasks (even devices on separate I²C buses), it would have required mutexes to ensure that only one task at a time could actually execute those function calls. Here’s an example: ``` SemaphoreHandle_t xSemaphore = NULL; . . . xSemaphore = xSemaphoreCreateMutex(); . . . if( xSemaphoreTake( xSemaphore, portMAX_DELAY ) == pdTRUE ) { // Own the critical section. . . // In critical section, access I2C device here. . xSemaphoreGive( xSemaphore ); } ``` Figure 10-3 shows the oscilloscope output from the program in the FreeRTOS demonstration program.  Figure 10-3: Oscilloscope output from Listing 10-3 Because this program is writing only one stream of data to the MCP4725, the frequency is back up to about 0.5 Hz (again, this is limited by the 100-kHz data transmission speed). ### 10.2.3 Teensy Threads I²C Programming There are thread scheduling packages written for many low-end SBCs that you can grab off the Internet and use in simple applications. In this section, I’ll demonstrate how to use one such package: the Teensy Threading Library, created by Fernando Trias. This section also uses the Teensy 4.0 I²C library by Richard Gemmell. (See “For More Information” for the links.) It supports the Teensy 3.*x* and 4.*x* CPU modules from PJRC and it has the ability to create multiple threads—up to eight, by default, though this can be changed—along with some simple synchronization primitives and various thread utilities. Because the Teensy Threads package uses the term *threads*, I will use that specific term in this section rather than *tasks*. The Teensy Threading Library is an Arduino library that assumes code is being developed in the Arduino programming model; as such, when you work with Teensy Threads, use the standard Arduino (or Teensy-specific) I²C programming libraries to communicate with I²C devices. Remember that Arduino code is not reentrant and must be protected when called from various threads. The example in this section will avoid calling the same function in different threads so that synchronization is not required. The program in Listing 10-4 demonstrates multithreading using the Teensy Threading Library. It creates three additional threads (plus the main thread that continues execution). One thread blinks the LED every second, two threads transmit triangle waves to MCP 4725 DAC devices (on separate I²C buses), and the main thread writes “loop” to the serial output every two seconds. ``` // Listing 10-4 (Listing10-4.ino) // // Simple demonstration of I2C programming // using Teensy Threads running on a Teensy 4.0. #include <TeensyThreads.h> #include <i2c_driver_wire.h> #define ever ;; #define dac1 0x62 #define dac2 0x60 // The LED is attached to pin 13 on the Teensy 4.0. const uint8_t LED_PIN = 13; // Thread1- // // This thread blinks the Teensy 4.0's LED // every second (1/2 second on, 1/2 second off). ❶ static void Thread1( int arg ) { for( ever ) { // Turn LED on: digitalWrite( LED_PIN, HIGH ); // Delay 1/2 second: delay( 500 ); // Turn LED off: digitalWrite( LED_PIN, LOW ); // Delay 1/2 second: delay( 500 ); } } // Thread2- // // This thread outputs a triangle wave // to the MCP4725 device at address 0x62 on // I2C bus zero (SDA0/SCL0 on Teensy 4.0) // (i.e., an Adafruit MCP4725 breakout board). ❷ static void Thread2( int arg ) { for( ever ) { for( uint16_t dacOut = 0; dacOut < 0xfff; ++dacOut ) { // Transmit the adrs byte (and a 0 R/W bit): Wire.beginTransmission( dac1 ); // Transmit the 12-bit value (HO 4 bits first, // LO 8 bits second) along with a 4-bit // "fast write" command (0000 in the HO 4 bits // of the first byte): Wire.write( (dacOut << 8) & 0xf ); Wire.write( dacOut & 0xff ); // Send the stop condition onto the I2C bus: Wire.endTransmission( true ); } // for // Send the falling edge of the triangle wave: for( uint16_t dacOut = 0xffe; dacOut > 0; --dacOut ) { // See comments in previous loop. Wire.beginTransmission( dac1 ); Wire.write( (dacOut << 8) & 0xf ); Wire.write( dacOut & 0xff ); Wire.endTransmission( true ); } // for } // forever } // Thread2 // Thread3- // // This thread outputs a triangle wave // to the MCP4725 device at address dac2 on // I2C bus one (SDA1/SCL1 on Teensy 4.0) // (i.e., an Adafruit MCP4725 breakout board). ❸ static void Thread3( int arg ) { for( ever ) { for( uint16_t dacOut = 0; dacOut < 0xfff; ++dacOut ) { // Transmit the adrs byte (and a 0 R/W bit): Wire1.beginTransmission( dac2 ); // Transmit the 12-bit DAC value (HO 4 bits // first, LO 8 bits second) along with a // 4-bit "fast write" command (0000 in the HO // 4 bits of the first byte): Wire1.write( (dacOut << 8) & 0xf ); Wire1.write( dacOut & 0xff ); // Send the stop condition onto the I2C bus: Wire1.endTransmission( true ); } // for // Send the falling edge of the triangle wave: for( uint16_t dacOut = 0xffe; dacOut > 0; --dacOut ) { // See comments in previous loop. Wire1.beginTransmission( dac2 ); Wire1.write( (dacOut << 8) & 0xf ); Wire1.write( dacOut & 0xff ); Wire1.endTransmission( true ); } // for } // forever } // Thread3 // In TeensyThreads for the Teensy 4.0, the // Arduino "setup" function is really the // equivalent of the main program. void setup() { Serial.begin( 9600 ); pinMode( LED_PIN, OUTPUT ); // LED is output Wire.begin(); // Initialize I2C port 0 Wire1.begin(); // Initialize I2C port 1 // Create thread // Arguments: // // 1\. Address of function to serve as thread code. // 2\. Optional argument passed to thread function. // 3\. Stack size (default is 1024). // 4\. Stack address (default is on heap). ❹ int id1 = threads.addThread( Thread1, 0 ); if ( id1 == -1 ) { Serial.println( "Thread 1 creation failed" ); for( ever ); } // Create task at priority one // (see comments above concerning parms). ❺ int id2 = threads.addThread( Thread2, 0 ); if ( id2 == -1 ) { Serial.println( "Thread 2 creation failed" ); for( ever ); } // Create task at priority three // (see comments above concerning parms). ❻ int id3 = threads.addThread( Thread3, 0 ); if ( id3 == -1 ) { Serial.println( "Thread 3 creation failed" ); for( ever ); } } // The loop function is, essentially, a fourth thread // of execution. ❼ void loop() { Serial.println( "loop" ); delay( 2000 ); } ``` The `Thread1()` function executes for the first thread, blinking the LED on the Teensy ❶. The `Thread2()` function writes a triangle wave to the DAC at address `dac1` (0x62) connected to the Teensy’s I²C port 0 ❷. The `Thread3()` function writes a triangle wave to the DAC at address `dac2` (0x60) connected to the Teensy’s I²C port 1 ❸. Note that because the two DACs are on different I²C ports, the code does not need to synchronize access to the devices. The `setup()` function starts the three threads by calling `threads.addThread()` ❹, ❺, and ❻ and passing in the addresses of the three thread functions. The `loop()` function effectively becomes a fourth thread ❼. Because the `i2c_driver_wire` library allocates separate memory objects for `wire` (SDA0 and SCL0) and `wire1` (SDA1 and SCL1), calls through these two separate objects do not interfere with one another when called from different threads. Were two different threads to call `wire` simultaneously, the code would have needed to protect the calls using the Teensy Threads `lock()` and `unlock()` functions: ``` Threads::Mutex wire_lock; . . . wire_lock.lock(); Wire.beginTransmission( 0x62 ); Wire.write( (dacOut << 8) & 0xf ); Wire.write( dacOut & 0xff ); Wire.endTransmission( true ); wire_lock.unlock(); ``` Figure 10-4 shows the oscilloscope output for the program in Listing 10-4. The time base has changed for this display (two seconds per major division rather than one).  Figure 10-4: Oscilloscope output from Listing 10-4 As you can see, the frequency here is much slower than in previous examples (note the time scale on the oscilloscope). This is likely due to the interaction between the synchronous I²C library calls and the Teensy Threads package, a typical issue when you bolt on a threading library to a nonthreading package (like Arduino) versus running a true RTOS. ### 10.2.4 Mbed I^(*2*)C Programming Mbed is an RTOS developed by ARM Limited for use on ARM CPUs. It is marketed as an IoT development system, though it is certainly useful for normal embedded applications. Unlike many RTOSs, which tend to be very generic, Mbed fully supports features found on typical ARM MCUs, including I²C and other peripherals. The Mbed RTOS provides a rich set of I²C functions you can use in your applications. The library is thread safe, so you don’t have to worry about protecting calls across various threads (of course, your applications must synchronize access to specific devices on the I²C bus). ARM also provides the Mbed Studio IDE that runs under Linux, macOS, or Windows (see “For More Information” for the link). Mbed Studio allows you to edit, compile, run, and debug your applications on any Mbed-enabled SBC. Listing 10-5 provides the standard MCP4725 triangle wave output program running under Mbed. This program has two threads: the main thread and a second thread that it starts. Each thread produces a triangle wave output on separate MCP4725 devices. This particular program runs on an STMicroelectronics Nucleo-F767ZI board ([`www.st.com/en/evaluation-tools/nucleo-f767zi.xhtml`](https://www.st.com/en/evaluation-tools/nucleo-f767zi.xhtml)) that I found on Amazon for around $35; you can also use the NUCLEO-WB55RG available from SparkFun for around $40 ([`www.sparkfun.com/products/17943`](https://www.sparkfun.com/products/17943)). Of the several I²C ports this board supports, I used ports one and two for the program in Listing 10-5. ``` // Listing10-5.cpp // // Mbed RTOS I2C programming example. // // This program writes triangle wave // data to two MCP4725 DAC devices at // addresses 0x62 and 0x63 on I2C ports // one and two on a Nucleo-F767ZI board. // Or ports one and three on a // Nucleo-WB55RG board. #include "PinNames.h" #include "mbed.h" #include "mbed_wait_api.h" #define ever ;; #define mcp4725a (0x62 << 1) #define mcp4725b (0x63 << 1) // Thread1- // // Writes a triangle wave to the // MCP4726 at address 0x62 on // I2C port 1. ❶ void Thread1( void ) { int cntr = 0; char data[2]; I2C i2c1( I2C_SDA, I2C_SCL ); // Set bus frequency to 100 kHz // (this is actually the default, // this call appears here for // testing purposes). i2c1.frequency( 100000 ); // Create a continuous triangle // wave output: for( ever ) { // Create the rising edge of the // triangle wave: for( int tri=0; tri < 4095; ++tri ) { // Note: MCP4725 requires that you // transmit the HO byte first, followed // by the LO byte of the 16-bit // DAC value (HO 4 bits are zeros). data[0] = (char) (tri >> 8) & 0xff; data[1] = (char) (tri & 0xff); i2c1.write ( mcp4725a, data, 2, false ); } // Create the falling edge of the // triangle wave: for( int tri=4094; tri > 0; --tri ) { data[0] = (char) (tri >> 8) & 0xff; data[1] = (char) (tri & 0xff); i2c1.write ( mcp4725a, data, 2, false ); } } } // Application main program and main thread. // This starts Thread1 and then emits the // triangle wave on the second MCP4725: int main() { int cntr = 0; char data[2]; Thread thread1; // Nucleo-F767ZI: PB_11, PB_10 // Nucleo-WB55RG: A1, A0 I2C i2c2( PB_11, PB_10 ); i2c2.frequency( 100000 ); // Start the thread: thread1.start( Thread1 ); // Emit the second triangle wave to // the MCP4725 at address 0x63 on // I2C bus two: for( ever ) { ❷ // See comments in Thread1. for( int tri=0; tri < 4095; ++tri ) { data[0] = (char) (tri >> 8) & 0xff; data[1] = (char) (tri & 0xff); i2c2.write ( mcp4725b, data, 2, false ); } for( int tri=4094; tri > 0; --tri ) { data[0] = (char) (tri >> 8) & 0xff; data[1] = (char) (tri & 0xff); i2c2.write ( mcp4725b, data, 2, false ); } } } ``` The code for the first thread writes a triangle wave to the DAC on I²C bus 1 ❶. It sets the I²C clock frequency to 100 kHz and then writes out 4,000 increasing DAC values followed by 4K decreasing DAC values. The code for the second thread (the main program ❷) writes a triangle wave to the DAC on I²C bus 2 using the same algorithm as employed by `Thread1()`. Once again, the sample program in Listing 10-5 avoids synchronization issues by writing to DAC devices on two separate buses. The triangle wave outputs on the oscilloscope appear in Figure 10-5.  Figure 10-5: Triangle wave output from Listing 10-5 If you look closely at Figure 10-5, you’ll notice that the frequency is about half of what you normally get for this application. Though two separate ports should be able to operate independently, these two ports alternate outputting data to the DACs, as shown by the logic analyzer output in Figure 10-6. The top two traces are from port one; the bottom two traces are from port two.  Figure 10-6: Logic analyzer output from the program in Listing 10-5 I can’t say for sure whether the two ports operating at half speed is because of a limitation of the hardware (or the particular I²C device driver for the hardware port) or the fact that Mbed’s thread-safe code doesn’t allow concurrent I²C transmissions. Whatever the case, the result is that it’s only about half the bandwidth on each bus that you’d expect, and you probably could have gotten the same performance by putting both devices on the same I²C bus. ## 10.3 Other Real-Time Operating System I²C Programming Several RTOSs beyond those discussed in this chapter support I²C devices. Due to limited space in this chapter and the lack of development systems on my part, I won’t include example code for these operating systems, but they still deserve mention. 1. QNX One of the older microcomputer RTOSs. Pronounced “cue-nix,” it was originally named Qunix; although it changed its name to avoid trademark infringement, it started out as a “Unix-like” microkernel operating system running on the original IBM PC (an 8088 CPU). It then quickly morphed into an RTOS supporting embedded systems and became very popular in that field. 2. QNX was originally developed by Quantum Software Systems (QSS), who changed the company’s name to QNX Software Systems. QNX Software Systems was bought out by BlackBerry, and QNX became the basis for BlackBerry’s tablet and phone offerings after the rise of the Apple iPad. Though the BlackBerry phones and tablets eventually died out, QNX prospered as an OS specifically targeted at automotive and safety-based applications. 3. QNX provides I²C communications “baked into” the OS. You can read about the I²C API at the QNX website at [`www.qnx.com/developers/docs/6.5.0_sp1/index.jsp?topic=%2Fcom.qnx.doc.neutrino_technotes%2Fi2c_framework.xhtml`](http://www.qnx.com/developers/docs/6.5.0_sp1/index.jsp?topic=%2Fcom.qnx.doc.neutrino_technotes%2Fi2c_framework.xhtml) (or just search for “QNX I²C Programming”). 4. VxWorks Another early RTOS that appeared in the late 1980s from Wind River Associates. It was based on an earlier operating system, VRTX, which was created by Mentor Graphics (see [`en.wikipedia.org/wiki/VxWorks`](https://en.wikipedia.org/wiki/VxWorks) for more history). VxWorks has been very popular in hardcore embedded systems requiring safety, including aerospace, medical, and nuclear applications. If QNX is best known for automotive applications, VxWorks is best known for aerospace applications. 5. As you can imagine, VxWorks is not a low-cost or open source system hobbyists often use. It does, however, have a no-cost license available for noncommercial or hobbyist use (see [`labs.windriver.com/vxworks-sdk`](https://labs.windriver.com/vxworks-sdk)) that runs on Raspberry Pi and other SBCs. Like QNX, VxWorks includes built-in support for I²C device programming. For more information on the VxWorks I²C library, see [`docs.windriver.com/bundle/vxworks_7_application_core_os_sr0630-enus/page/VXBUS/vxbI2cLib.xhtml`](https://docs.windriver.com/bundle/vxworks_7_application_core_os_sr0630-enus/page/VXBUS/vxbI2cLib.xhtml). 6. eCos The embedded configurable operating system (eCos) was originally developed by Cygnus (of Windows and Unix shell fame) and was later bought out by Red Hat. Eventually, Red Hat abandoned eCos, releasing it as open source, and some of the original developers created eCos Pro as a commercial product. For a couple of years now, they’ve been promising to deliver a version that runs on Raspberry Pi systems. However, as I’m writing this, that version is yet to appear. 7. For more about eCos’s built-in support for I²C programming, see [`ecos.sourceware.org/docs-latest/ref/i2c-porting.xhtml`](https://ecos.sourceware.org/docs-latest/ref/i2c-porting.xhtml). You can find additional eCos information at [`doc.ecoscentric.com/user-guide`](https://doc.ecoscentric.com/user-guide). 8. ChibiOS/RT A small-footprint, open source, real-time operating system. Although ChibiOS has been ported to a wide range of microcontrollers (see [`en.wikipedia.org/wiki/ChibiOS/RT`](https://en.wikipedia.org/wiki/ChibiOS/RT)), perhaps its biggest claim to fame is that it has been successfully ported to the Raspberry Pi, providing an honest-to-goodness RTOS for the Pi (see [`www.stevebate.net/chibios-rpi/GettingStarted.xhtml`](https://www.stevebate.net/chibios-rpi/GettingStarted.xhtml)). 9. See [`chibios.sourceforge.net/docs3/hal/group___i2_c.xhtml`](http://chibios.sourceforge.net/docs3/hal/group___i2_c.xhtml) for more details on ChibiOS’s I²C capabilities. ## 10.4 Chapter Summary This chapter introduced I²C programming under multithreaded, real-time operating systems. It began with a gentle introduction to RTOSs and then provided some simple I²C examples using four different RTOSs: µC/OS, FreeRTOS, Teensy Threads, and Mbed. Finally, it concluded by briefly discussing four other RTOSs you might find in the real world.*
第十一章:裸金属 I²C 控制器编程

到目前为止,本书中的大多数示例程序依赖于一些第三方库代码与 SBC 上的 I²C 硬件接口。在硬件层面,I²C 通信通常包括读取和写入微控制器上依赖于硬件的寄存器。有一个隐藏这些底层细节的库代码非常方便,但如果你负责编写这个库代码——或者如果你需要更好的性能或库没有提供的功能——你就必须学会自己编写底层 I²C 操作。
几乎每个真实世界中的 MCU 在处理底层 I²C 编程时都有不同的做法,即使它们共享一些常见的外设硬件。幸运的是,I²C 协议并不是那么复杂,因此基本的编程思路适用于任何底层硬件。如果你学会了如何编程几种不同的 MCU,那么这些概念应该能帮助你弄清楚如何处理其他 MCU。在本章中,我将描述如何在寄存器(硬件)级别上编程两个 MCU 之间的 I²C 通信。特别地,本章将探讨在 Teensy 4.x 的 NXP i.MX RT1062 MCU 和 ATtiny84 MCU 上进行 I²C 编程。
本章中的编程示例使用了 Teensy 4.x 模块和 SparkFun Atto84。Teensy 4.0 和 4.1 模块共享相同的 MCU IC,因此它们的底层 I²C 编程是相同的;SparkFun Atto84 基于 ATtiny84 MCU。所有这些设备都价格低廉且常见,并且被许多创客和爱好者使用。
11.1 Teensy 4.x 控制器编程
本章的第一个示例将是使用由 Richard Gemmell 主要编写、Paul Stoffregen(PJRC)参与的驱动程序,在 Teensy 4.x 模块上进行 I²C 控制器编程。讨论从描述 Teensy 支持 I²C 通信的硬件寄存器开始,然后是你需要的代码(基于 Gemmell 的驱动程序),通过这些代码,你可以编程这些寄存器,实现类似 Arduino 的 I²C 通信功能。
关于此驱动程序的更多信息以及下载 Gemmell 的代码,请访问他的 GitHub 页面 github.com/Richard-Gemmell/teensy4_i2c。Gemmell 的包包含了控制器和外设代码,但我将仅关注控制器部分。请参阅在线章节(特别是 bookofi2c.randallhyde.com 上的第十八章),获取有关将 Teensy 4.x 编程为 I²C 外设设备的相关讨论。
当你在 MCU 上与底层硬件打交道时,MCU 的参考手册成为了一个不可或缺的资源。请参阅本章末尾的“更多信息”部分,获取链接,访问 PJRC(Teensy)网站上的 NXP i.MX RT1060 MCU(包括 i.MX RT1062)的参考手册。手册中的第四十七章描述了 I²C 接口。
11.1.1 i.MX RT1062 I²C 寄存器
Teensy 4.0 和 4.1 模块使用 NXP i.MX RT1062 MCU,配备 ARM Cortex M7 核心。ARM Cortex M7 核心是 CPU,而 i.MX RT1062 是包含 CPU 及所有相关外设的 MCU。
要理解 Teensy 4.x 的 I²C 代码,你需要了解一些 i.MX RT1062 寄存器,首先从 I²C 控制寄存器集开始。Gemmell 的代码使用的 imx_rt1060.h 文件包含以下声明(注释为我加的):
typedef struct
{
const uint32_t VERID;
const uint32_t PARAM; // The "M" prefix stands
const uint32_t unused1; // for "Master" in the
const uint32_t unused2; // following names:
volatile uint32_t MCR; // 010 Control Reg
volatile uint32_t MSR; // 014 Status Reg
volatile uint32_t MIER; // 018 Int Enable Reg
volatile uint32_t MDER; // 01C DMA Enable Reg
volatile uint32_t MCFGR0; // 020 Config Reg 0
volatile uint32_t MCFGR1; // 024 Config Reg 1
volatile uint32_t MCFGR2; // 028 Config Reg 2
volatile uint32_t MCFGR3; // 02C Config Reg 3
volatile uint32_t unused3[4];
volatile uint32_t MDMR; // 040 Data Match Reg
volatile uint32_t unused4;
volatile uint32_t MCCR0; // 048 Clock Config Reg 0
volatile uint32_t unused5;
volatile uint32_t MCCR1; // 050 Clock Config Reg 1
volatile uint32_t unused6;
volatile uint32_t MFCR; // 058 FIFO Control Reg
volatile uint32_t MFSR; // 05C FIFO Status Reg
volatile uint32_t MTDR; // 060 Transmit Data Reg
volatile uint32_t unused7[3];
volatile uint32_t MRDR; // 070 Receive Data Reg
volatile uint32_t unused8[39];
// The "S" prefix stands
// for "Slave" in the
// following names:
volatile uint32_t SCR; // 110 Control Reg
volatile uint32_t SSR; // 114 Status Reg
volatile uint32_t SIER; // 118 Int Enable Reg
volatile uint32_t SDER; // 11C DMA Enable Reg
volatile uint32_t unused9;
volatile uint32_t SCFGR1; // 124 Config Reg 1
volatile uint32_t SCFGR2; // 128 Config Reg 2
volatile uint32_t unused10[5];
volatile uint32_t SAMR; // 140 Address Match Reg
volatile uint32_t unused11[3];
volatile uint32_t SASR; // 150 Address Status Reg
volatile uint32_t STAR; // 154 Transmit Ack Reg
volatile uint32_t unused13[2];
volatile uint32_t STDR; // 160 Transmit Data Reg
volatile uint32_t unused14[3];
volatile uint32_t SRDR; // 170 Receive Data Reg
} IMXRT_LPI2C_Registers;
// LPI2C2 is not connected to any
// pins on the Teensy 4.*x*.
#define LPI2C1 (*(IMXRT_LPI2C_Registers *)0x403F0000)
#define LPI2C2 (*(IMXRT_LPI2C_Registers *)0x403F4000)
#define LPI2C3 (*(IMXRT_LPI2C_Registers *)0x403F8000)
#define LPI2C4 (*(IMXRT_LPI2C_Registers *)0x403FC000)
该列表末尾的 #define 语句定义了将该结构映射到内存中不同地址的符号。具体来说,LPI2C1 对应与 Teensy 4 的(SDA0, SCL0)引脚相关的寄存器,LPI2C3 对应(SDA1, SCL1)引脚,LPI2C4 对应(SCL2, SDA2)引脚(请注意,Teensy 4.x 只有三个 I²C 端口)。你可以在 NXP 手册的第 47.4 节中了解这些引脚定义。
尽管本书并不详细描述所有这些寄存器的用途,但描述其中的几个寄存器对于理解接下来的代码是非常有价值的。有关所有其他寄存器的更多详细信息,请参见 NXP 手册的第四十七章。
由于 i.MX RT1062 是一个 32 位的 CPU,因此所有寄存器都是 32 位宽。与 CPU 寄存器不同,ARM 核心将其地址编码到指令操作码中,而外设寄存器对处理器来说是内存位置——也就是说,LPI2C1 到 LPI2C4。由于 ARM 支持字节、半字(16 位)和字(32 位)内存访问,因此你可以访问这些外设寄存器中的单个字节或半字(当然,也可以访问整个 32 位寄存器)。
- 主控制寄存器(MCR)MCR 是一个由六个标志(位)组成的集合,这些标志分布在寄存器的低 10 位中。写入这些标志可以启用(1)或禁用(0)I²C 功能。表 11-1 描述了这些标志。
表 11-1:MCR 字段
| 位 | 描述 |
|---|---|
| 0 | MEN(主设备启用):启用或禁用特定 I²C 端口的控制器功能。设置为 1 时将 I²C 端口用作控制器。 |
| 1 | RST(软件重置):该位为 1 时重置 I²C 控制器。该位为 0 时允许正常操作。 |
| 2 | DOZEN(低功耗模式启用):该位为 1 时启用低功耗待机模式操作。该位为 0 时禁用控制器进入待机模式(以便正常操作)。 |
| 3 | DBGEN(调试启用):该位为 1 时启用控制器的调试模式操作。该位为 0 时,I²C 端口处于正常操作模式。 |
| 8 | RTF(重置传输 FIFO [先进先出]):将 1 写入该位会重置传输 FIFO;将 0 写入则没有效果。此位是只写的,读取时总是返回 0。 |
| 9 | RRF(重置接收 FIFO):将 1 写入该位会重置接收 FIFO(写入 0 不做任何操作)。此位是只写的,读取时总是返回 0。 |
- 主状态寄存器(MSR) 主状态寄存器(MSR)是一组位,用于指定 I²C 端口的当前状态。表 11-2 列出了此寄存器中的各个字段。
表 11-2:MSR 字段
| 位 | 描述 |
|---|---|
| 0 | TDF(传输数据标志):每当传输 FIFO 中的字节数小于或等于 TxWater(传输低水位标志)时设置。^(*) TxWater 在 MFCR 中设置。此标志(及其对应的中断)用于通知系统传输 FIFO 需要更多数据。有关设置 TxWater 值的信息,请参见本章后面的表 11-6。 |
| 1 | RDF(接收数据标志):每当接收 FIFO 中的字节数大于 RxWater(接收高水位标志)时设置。RxWater 在 MFCR 中设置。此标志(及其对应的中断)用于通知系统需要从接收 FIFO 中移除数据。有关设置 RxWater 值的信息,请参见表 11-6。 |
| 8 | EPF(结束包标志):当控制器生成 重复 起始条件或停止条件时设置(不会在第一次起始条件时设置)。向此位写入 1 会清除此标志。 |
| 9 | SDF(停止检测标志):当控制器生成停止条件时设置。向此位写入 1 会清除此标志。 |
| 10 | NDF(NAK 检测标志):当控制器在传输地址或数据时检测到 NAK 时设置。当此位为 1 时,系统不会生成起始条件,直到清除此标志。如果预期在地址传输后会出现 NAK,则如果没有生成 NAK,硬件会设置此标志。向此位写入 1 会清除此标志。 |
| 11 | ALF(仲裁丢失标志):如果控制器在 I²C 总线上的仲裁中失败,则会设置此标志。一旦设置,系统将在此标志清除之前不会启动新的起始条件。向此位写入 1 会清除此标志。 |
| 12 | FEF(FIFO 错误标志):如果控制器检测到在没有起始或重复起始条件的情况下试图传输或接收数据,则会设置此标志。向此位写入 1 会清除此标志。 |
| 13 | PLTF(引脚低超时标志):如果控制器检测到 SDA 或 SCL 引脚被拉低,则设置此标志。向此位写入 1 会清除此标志,尽管只要低电平条件存在,它无法被清除。 |
| 14 | DMF(数据匹配标志):当控制器检测到接收到的数据与 MATCH0 或 MATCH1 的值(在 MCFGR1 中指定)匹配时设置。向此位写入 1 会清除此标志。 |
| 24 | MBF(主设备忙碌标志):当控制器忙碌时设置。 |
| 25 | BBF(总线忙碌标志):当 I²C 总线忙碌时设置。 |
| ^(*)水位线是缓冲区中的某个点,在此点上会发生某些事件(例如设置 TDF 或 RDF)。 |
- 主中断使能寄存器 (MIER) MIER 允许你为特定端口启用或禁用各种 I²C 中断。这是一个位域,其中 1 表示中断已启用,0 表示该中断已禁用。表 11-3 列出了该寄存器中的字段。
表 11-3: MIER 字段
| 位 | 描述 |
|---|---|
| 0 | TDIE: 传输数据中断使能 |
| 1 | RDIE: 接收数据中断使能 |
| 8 | EPIE: 数据包结束中断使能 |
| 9 | SDIE: 停止检测中断使能 |
| 10 | NDIE: 检测到 NAK 中断使能 |
| 11 | ALIE: 仲裁丢失中断使能 |
| 12 | FEIE: FIFO 错误中断使能 |
| 13 | PLTIE: 引脚低超时中断使能 |
| 14 | DMIE: 数据匹配中断使能 |
-
主配置寄存器 1 (MCFGR1) Teensy I²C 代码仅使用 MCFGR1 的低 3 位。这 3 位存储时钟预分频器值,将系统时钟除以 2^(n+1),其中 n 是 MCFGR1 中传递的 3 位数字。有关其他位的信息,请参阅 NXP 文档。
-
主配置寄存器 2 (MCFGR2) MCFGR2 包含 I²C 总线的空闲超时和毛刺滤波常数。表 11-4 描述了 MCFGR2 字段。
表 11-4: MCFGR2 字段
| 位 | 描述 |
|---|---|
| 0 到 11 | 总线空闲超时周期,单位为时钟周期。该字段为 0 时,禁用总线空闲检查。 |
| 16 到 19 | SCL 毛刺滤波器。该字段为 0 时,禁用滤波器。否则,长度小于或等于该时钟周期数的脉冲将在 SCL 线上被忽略。 |
| 24 到 27 | SDA 毛刺滤波器。该字段为 0 时,禁用滤波器。否则,长度小于或等于该时钟周期数的脉冲将在 SDA 线上被忽略。 |
-
主配置寄存器 3 (MCFGR3) MCFGR3 存储引脚低超时值。位 8 到 19 存储引脚低超时常数(以时钟周期为单位,乘以 256)。将 0 写入这些位将禁用此功能。MCFGR3 中的所有其他位必须为 0。
-
主时钟配置寄存器 0 (MCCR0) 和 1 (MCCR1) MCCR0 和 MCCR1 指定与 I²C 信号线相关的各种参数。表 11-5 列出了该寄存器中的字段。MCCR1 与 MCCR0 相同,但用于 I²C 高速模式下的操作。
表 11-5: MCCR0 和 MCCR1 字段
| 位 | 描述 |
|---|---|
| 0 到 5 | CLKLO: 硬件将 SCL 时钟拉低的最小周期数(减去 1)。 |
| 8 到 13 | CLKHI: 硬件将 SCL 时钟拉高的最小周期数(减去 1)。 |
| 16 到 21 | SETHOLD: 控制器用作起始条件保持时间的最小周期数(减去 1)。它还用作重复起始条件的设定和保持时间,以及停止条件的设定时间。 |
| 24 到 29 | DATAVD:数据有效延迟。用于 SDA 数据保持时间的最小周期数(减去 1)。必须小于 CLKLO。 |
- 主 FIFO 控制寄存器(MFCR)MFCR 允许你设置 TxWater 和 RxWater 水印。每个水印都是 2 位字段,允许你将水印设置为 0 到 3(FIFO 每个包含 4 个字)。字段位置请参见表 11-6。
表 11-6:MFCR 字段
| 位 | 描述 |
|---|---|
| 0 到 1 | TxWater |
| 16 到 17 | RxWater |
- 主 FIFO 状态寄存器(MFSR)MFSR 保存传输和接收 FIFO 中当前的字数。字段如表 11-7 所示。虽然这些字段与 3 位相关,但 FIFO 仅存储四个字。
表 11-7:MFSR 字段
| 位 | 描述 |
|---|---|
| 0 到 2 | 传输缓冲区中的字数 |
| 16 到 18 | 接收缓冲区中的字数 |
- 主传输数据寄存器(MTDR)MTDR 接受命令和数据字节,用于控制将数据写入 I²C 总线。它有两个字段,如表 11-8 所示。
表 11-8:MTDR 字段
| 位 | 字段 |
|---|---|
| 0 到 7 | 数据 |
| 8 到 10 | 命令 |
-
向低 8 位写入任何数据,无论是通过字节写入还是通过 16 位(或 32 位)写入到该寄存器,都将把数据字节插入到传输 FIFO 的末尾并递增 FIFO 指针——前提是 FIFO 未满。另请注意,向低 8 位写入 8 位数据时,将会零扩展写入操作,并将 0b000 写入命令位。
-
命令字段是一个 3 位的命令代码(见表 11-9)。写入 8 到 15 位的一个字节即可执行命令。
表 11-9:MTDR 命令值
| 命令位 | 命令 |
|---|---|
| 0b000 | 传输数据(位于 0 到 7 位)。 |
| 0b001 | 接收数据。位 0 到 7 指定要接收的字节数(加 1)。 |
| 0b010 | 发送停止条件。 |
| 0b011 | 接收并丢弃字节。位 0 到 7 指定要接收的字节数(加 1)。 |
| 0b100 | 生成(重复)启动条件并传输 0 到 7 位中的地址。 |
| 0b101 | 生成(重复)启动条件并在 0 到 7 位中传输地址。此传输预期返回 NAK。 |
| 0b110 | 生成(重复)启动条件并在高速度模式下传输 0 到 7 位中的地址。 |
| 0b111 | 生成(重复)启动条件并在高速度模式下传输 0 到 7 位中的地址。此传输预期返回 NAK。 |
-
一般来说,作为单字节写入(到 8 到 15 位)的唯一命令是发送停止条件。所有其他命令都与数据相关。
-
请注意,FIFO 存储的是命令和数据。因此,硬件将它执行的特定命令与从传输 FIFO 拉取的项目关联起来。
-
主接收数据寄存器(MRDR)接收到的 I²C 硬件数据会被添加到接收 FIFO 中。读取 MRDR 会从 FIFO 中检索下一个可用的字节(在位 0 到 7 之间)。如果 FIFO 为空,则 MRDR 的第 14 位会被设置。注意,您还可以通过读取 MFSR 中的第 16 到 18 位来检查 FIFO 中是否有可用数据。
11.1.2 Teensy 4.x Wire 代码
以下部分描述了 Gemmell 的 Teensy 4 代码中与 Arduino I²C 函数直接相关的操作。由于您可以通过下载 Gemmell 的库并将其包含在 Teensyduino IDE 中来轻松测试他的代码,我在本节中不提供常规的 DAC 输出演示程序,并运行清单 8-1 中的 Arduino 示例(别忘了将 #include <Wire.h> 替换为 #include <i2c_driver_wire.h>)。结果应该几乎相同,只是由于 Gemmell 的代码运行在比我用来生成清单 8-1 中示波器输出的 Teensy 3.2 更快的处理器上,可能会有轻微的时间差异。
Gemmell 的库提供了以下 Arduino I²C 函数的即插即用代码:
-
Wire.begin() -
Wire.beginTransmission() -
Wire.endTransmission() -
Wire.write() -
Wire.requestFrom() -
Wire.read()
下一部分将描述这些函数的实现。
当你学习 Gemmell 的代码时,最好的方法是从 Arduino Wire 对象开始,采用自上而下的方式查看。i2c_driver_wire.cpp 文件声明了以下三个对象:
I2CDriverWire Wire( Master, Slave );
I2CDriverWire Wire1( Master1, Slave1 );
I2CDriverWire Wire2( Master2, Slave2 );
Wire 对象对应于标准的 Arduino I²C Wire 设备,控制 Teensy 的 SDA 和 SCL 线路上的 I²C 通信。Wire2 和 Wire3 对象是原始 Arduino I²C 库的扩展,支持 Teensy 的 (SDA1, SCL1) 和 (SDA2, SCL2) 线路上的通信。
Wire 对象支持主设备和从设备。(再次声明,我在本节中使用过时的术语 master 和 slave,仅为与 ARM 文档和 Gemmell 的代码保持一致。)在本章中,我将专注于主设备部分。
11.1.2.1 begin() 函数
begin() 函数替代了标准 Arduino 的 Wire.begin() 函数。它负责在调用其他 I²C 函数之前初始化 I²C 硬件和软件。它会为 I²C 通信初始化适当的 Teensy 引脚,重置任何现有的 I²C 初始化,设置 I²C 时钟频率,设置中断向量,并根据需要初始化 iMXRT 1062 寄存器。
Wire、Wire1 和 Wire2 对象声明(在 i2c_driver_wire.cpp 文件中)分配了存储空间,但几乎没有做其他事情。在 Arduino 编程范式中,对象的初始化不会发生在构造函数中;相反,初始化是在调用 I2CDriverWire::begin() 函数时进行的。
void I2CDriverWire::begin()
{
end();
master.begin( master_frequency );
}
begin()函数通过调用end()函数停止当前的任何活动,然后将任务推给I2CMaster类构造函数(master_frequency参数是默认的 I²C 速度 100 kHz)。I2CMaster类只是一个抽象基类,被IMX_RT1060_I2CMaster类重写,提供实际的代码。
这是重写的begin()函数:
void IMX_RT1060_I2CMaster::begin( uint32_t frequency )
{
// Make sure master mode is disabled before configuring it:
stop( port, config.irq );
// Set up pins and master clock:
initialise_common( config, pad_control_config );
// Configure and Enable Master Mode.
// Set FIFO watermarks. Determines when the RDF and TDF
// interrupts happen:
port->MFCR = LPI2C_MFCR_RXWATER( 0 ) | LPI2C_MFCR_TXWATER( 0 );
set_clock( frequency );
// Set up interrupt service routine:
attachInterruptVector( config.irq, isr );
// Enable all the interrupts you use:
port->MIER =
LPI2C_MIER_RDIE | LPI2C_MIER_SDIE |
LPI2C_MIER_NDIE | LPI2C_MIER_ALIE |
LPI2C_MIER_FEIE | LPI2C_MIER_PLTIE;
NVIC_ENABLE_IRQ( config.irq );
}
在讨论该函数中的各个语句之前,请注意标识符port是指向此函数操作的 I²C 端口的寄存器集的类变量;config也是包含端口配置的类变量。
在上述代码中begin()函数中调用stop()函数会关闭 I²C 端口上的任何活动,在此代码初始化系统之前执行,例如如果程序员调用了两次begin()函数。以下是stop()函数的代码:
static void stop( IMXRT_LPI2C_Registers* port, IRQ_NUMBER_t irq )
{
// Halt and reset Master Mode if it's running:
❶ port->MCR = (LPI2C_MCR_RST | LPI2C_MCR_RRF | LPI2C_MCR_RTF);
port->MCR = 0;
// Halt and reset Slave Mode if it's running:
❷ port->SCR = (LPI2C_SCR_RST | LPI2C_SCR_RRF | LPI2C_SCR_RTF);
port->SCR = 0;
// Disable interrupts:
❸ NVIC_DISABLE_IRQ( irq );
attachInterruptVector( irq, nullptr );
}
使用port->MCR❶写入 MCR 执行以下操作:
-
LPI2C_MCR_RST执行控制器的软件复位。 -
LPI2C_MCR_RRF用于重置接收 FIFO 内存缓冲区。 -
LPI2C_MCR_RTF用于重置发送 FIFO。
通过向port->SCR存储写入 SCR 完成了从设备(从设备)模式下的相同操作。最后,stop函数❶关闭并断开任何中断(和中断服务程序)。
在begin函数中调用initialise_common()函数初始化控制器(主控制器)和外围设备(从设备)的共同硬件。以下是该函数的代码:
static void initialise_common
(
❶ IMX_RT1060_I2CBase::Config hardware,
uint32_t pad_control_config
){
// Set LPI2C Clock to 24 MHz. This is required by
// slaves as well as masters:
❷ CCM_CSCDR2 = (CCM_CSCDR2 & ~CCM_CSCDR2_LPI2C_CLK_PODF( 63 )) |
CCM_CSCDR2_LPI2C_CLK_SEL;
❸ hardware.clock_gate_register |= hardware.clock_gate_mask;
// Set up SDA and SCL pins and registers:
❹ initialise_pin( hardware.sda_pin, pad_control_config );
initialise_pin( hardware.scl_pin, pad_control_config );
}
IMX_RT1060_I2CBase::Config❶是imx_rt1060_i2c_driver.h文件中的结构体(请参阅下一个框以获取其格式)。它定义了 Teensy 引脚和其他信息。CCM_CSCDR2❷是 MCU 上的硬件定义,CCM 串行时钟分频器寄存器 2。对此硬件寄存器的赋值设置了生成 I²C 时钟所需的内部时钟分频器。PODF字段是时钟分频器,CCM_CSCDR2_LPI2C_CLK_SEL常数指定 I²C 时钟来源于系统振荡器。
对hardware.clock_gate_register❸的赋值通过变量覆盖了 MCU 内存映射中的 CCM 时钟门控寄存器,实现了向 CCM 时钟门控寄存器的神奇写入数据。最后,该函数中最后两个赋值语句❹根据端口的不同初始化了适当的 SDA 和 SCL 线,以便它们用于 I²C 通信,而不是例如数字 I/O 线。
begin()函数中的port->MFCR赋值定义了中断的发生时机。这个特定语句设置了当发送 FIFO 完全为空或接收 FIFO 至少包含一个字时发生中断。在 Arduino 环境中,0(空 FIFO)可能是一个不错的值。在多线程环境中,通过将传输值设置为 1 或其他非零值,可以在 I²C 总线上获得更好的吞吐量,以保持 FIFO 非空,只要有数据要传输。
调用 set_clock() 将 I²C 总线设置为此代码的参数传递的频率。参数应为 100,000、400,000 或 1,000,000。在调用 begin() 函数之前,必须调用 set_clock()。以下是 set_clock() 函数的代码:
// Supports 100-kHz, 400-kHz, and 1-MHz modes.
void IMX_RT1060_I2CMaster::set_clock( uint32_t frequency )
{
if( frequency < 400000 )
{
// Use Standard Mode (up to 100 kHz).
port->MCCR0 = LPI2C_MCCR0_CLKHI( 55 ) |
LPI2C_MCCR0_CLKLO( 59 ) |
LPI2C_MCCR0_DATAVD( 25 ) |
LPI2C_MCCR0_SETHOLD( 40 );
port->MCFGR1 = LPI2C_MCFGR1_PRESCALE( 1 );
port->MCFGR2 = LPI2C_MCFGR2_FILTSDA( 5 ) |
LPI2C_MCFGR2_FILTSCL( 5 ) |
LPI2C_MCFGR2_BUSIDLE
(
2 * (59 + 40 + 2)
);
port->MCFGR3 =
LPI2C_MCFGR3_PINLOW
(
CLOCK_STRETCH_TIMEOUT * 12 / 256 + 1
);
}
else if( frequency < 10000000 )
{
// Use Fast Mode - up to 400 kHz.
❶ port->MCCR0 = LPI2C_MCCR0_CLKHI( 26 ) |
LPI2C_MCCR0_CLKLO( 28 ) |
LPI2C_MCCR0_DATAVD( 12 ) |
LPI2C_MCCR0_SETHOLD( 18 );
❷ port->MCFGR1 = LPI2C_MCFGR1_PRESCALE( 0 );
❸ port->MCFGR2 = LPI2C_MCFGR2_FILTSDA( 2 ) |
LPI2C_MCFGR2_FILTSCL( 2 ) |
LPI2C_MCFGR2_BUSIDLE
(
2 * (28 + 18 + 2)
);
port->MCFGR3 =
LPI2C_MCFGR3_PINLOW
(
CLOCK_STRETCH_TIMEOUT * 24 / 256 + 1
);
}
else
{
// Use Fast Mode Plus (up to 1 MHz).
port->MCCR0 = LPI2C_MCCR0_CLKHI(9) |
LPI2C_MCCR0_CLKLO(10) |
LPI2C_MCCR0_DATAVD(4) |
LPI2C_MCCR0_SETHOLD(7);
port->MCFGR1 = LPI2C_MCFGR1_PRESCALE(0);
port->MCFGR2 = LPI2C_MCFGR2_FILTSDA(1) |
LPI2C_MCFGR2_FILTSCL(1) |
LPI2C_MCFGR2_BUSIDLE
(
2 * (10 + 7 + 2)
);
port->MCFGR3 =
LPI2C_MCFGR3_PINLOW
(
CLOCK_STRETCH_TIMEOUT * 24 / 256 + 1
);
}
❹ port->MCCR1 = port->MCCR0;
}
MCCR0 ❶ 包含多个位字段,在分配后由宏填充。CLKLO 字段指定系统在 I²C 总线上使用低时钟信号的最小时钟周期数。CLKHI 字段类似,但指定时钟必须保持高电平的时间(此值必须小于 CLKLO)。DATAVD 字段指定数据在 SDA 线上必须保持有效的时间。SETHOLD 字段指定启动或停止条件的时间。
MCFGR1 ❷ 控制多个字段(详见 NXP 文档)。此处设置的主要值是时钟除数。对于 100 kHz,除数为 2;对于其他频率,除数为 1。
MCFGR2 寄存器 ❸ 确定:
-
用于 SDA 抖动滤波的周期数(
LPI2C_MCFGR2_FILTSDA) -
用于 SCL 抖动滤波的周期数(
LPI2C_MCFGR2_FILTSCL) -
用于确定总线何时变为空闲的周期数(
LPI2C_MCFGR2_BUSIDLE)
MCCR1 寄存器 ❹ 包含 I²C 高速模式的时钟配置。关于速度以外的内容,请参阅前面几段关于 MCCR0 的讨论以获取更多细节,因为除了速度外,其行为类似于 MCCR1。
After setting the clock frequency, the `begin()` function attaches an ISR and enables interrupts. In theory, a nonthreaded system such as Arduino won’t benefit as much from an interrupt-driven I²C device driver; all I²C calls are synchronous, so you still have to enter a busy-waiting loop until the transmission or reception is complete. Nevertheless, using interrupts can be useful if you’re using the Teensy Threading Library, and an interrupt-driven system combined with the FIFOs can improve transmission and reception latency. Each object gets its own instance of an ISR, which handles errors, nonempty receive FIFOs (meaning data has arrived), and empty transmission FIFOs (meaning you can send more data). Here’s the source code for the ISR (cleaned up for publication): ``` void IMX_RT1060_I2CMaster::_interrupt_service_routine() { uint32_t msr = port->MSR; // Check for the following errors (prioritized // in this order): // // NDF- NAK detection flag. // ALF- Arbitration lost flag. // FEF- FIFO error flag. // PLTF- Pin low timeout flag. ❶ if ( msr & ( LPI2C_MSR_NDF | LPI2C_MSR_ALF | LPI2C_MSR_FEF | LPI2C_MSR_PLTF ) ){ // If you got a NAK, determine who caused // the NAK: if( msr & LPI2C_MSR_NDF ) { port->MSR = LPI2C_MSR_NDF; // Clear the error if( state == State::starting ) { _error = I2CError::address_nak; } else { _error = I2CError::data_nak; } } // If you got an arbitration lost error, that // takes precedence over NDF: if( msr & LPI2C_MSR_ALF ) { port->MSR = LPI2C_MSR_ALF; // Clear the error _error = I2CError::arbitration_lost; } // FIFO empty error takes precedence over // earlier errors: if( msr & LPI2C_MSR_FEF ) { port->MSR = LPI2C_MSR_FEF; // Clear error if( !has_error() ) { _error = I2CError::master_fifo_error; } // else FEF was triggered by another error. // Ignore it (and keep previous error status). } // If pin low timeout, clear error and set // error return value. if( msr & LPI2C_MSR_PLTF ) { port->MSR = LPI2C_MSR_PLTF; // Clear error _error = I2CError::master_pin_low_timeout; } // On any of the above errors, put this in the // stopping state if it's not already there. if( state != State::stopping ) { state = State::stopping; abort_transaction_async(); } // else already trying to end the transaction. } // The following are "normal" conditions (not errors). // // Check for the "Stop Detected" flag, indicating end // of transmission has been received. ❷ if( msr & LPI2C_MSR_SDF ) { // You don't want to handle TDF if you can avoid it, // so disable that interrupt. port->MIER &= ~LPI2C_MIER_TDIE; state = State::stopped; port->MSR = LPI2C_MSR_SDF; // Clear stop detected } // Check the received data flag. This bit gets set // whenever the number of bytes in the FIFO exceeds // the "high water" mark. Because this code sets // the HWM to 0, you get an interrupt whenever // any byte comes along. ❸ if( msr & LPI2C_MSR_RDF ) { if( ignore_tdf ) { // Copy the byte out of the receive // register into the memory buffer: if( buff.not_started_reading() ) { error = I2CError::ok; state = State::transferring; } if( state == State::transferring ) { buff.write( port->MRDR ); } else { // Reset the receive FIFO if // not expecting data. port->MCR |= LPI2C_MCR_RRF; } if( buff.finished_reading() ) { if( tx_fifo_count() == 1 ) { state = State::stopping; } else { state = State::transfer_complete; } // Avoids triggering PLTF if // you didn't send a STOP. port->MCR &= ~LPI2C_MCR_MEN; // Master disable } } else { // This is a write transaction. // Code shouldn't have gotten a read. state = State::stopping; abort_transaction_async(); } } // Handle writing data to the I2C bus here. // Is data available (and code is not ignoring it)? ❹ if( !ignore_tdf && (msr & LPI2C_MSR_TDF) ) { if( buff.not_started_writing() ) { _error = I2CError::ok; state = State::transferring; } if( state == State::transferring ) { // Fill the transmit buffer (FIFO). uint32_t fifo_space = NUM_FIFOS - tx_fifo_count(); while ( buff.has_data_available() && fifo_space > 0 ){ port->MTDR = LPI2C_MTDR_CMD_TRANSMIT | buff.read(); fifo_space--; } // If writing is done, disable transmission // interrupts and clean up. if ( buff.finished_writing() && tx_fifo_count() == 0 ){ port->MIER &= ~LPI2C_MIER_TDIE; if ( stop_on_completion ) { state = State::stopping; port->MTDR = LPI2C_MTDR_CMD_STOP; } else { state = State::transfer_complete; } // Avoids triggering PLTF if // you didn't send a STOP. port->MCR &= ~LPI2C_MCR_MEN; } } // else ignore it. This flag is frequently // set in read transfers. } } ``` The ISR begins by checking for possible errors ❶ and sets an appropriate error condition based on the type of error, if an error condition exists. If no error exists ❷, then the ISR checks to see if a stop condition has been detected and sets the appropriate stop flag if so. Next, the ISR checks to see if any data has been received ❸, in which case it adds the data to the receive buffer. It then checks to see if it is transmitting any data to the I²C bus ❹, in which case it removes data from the transmit buffer to transmit, cleaning up and terminating the transmission if there is no data left to send in the buffer. #### 11.1.2.2 The beginTransmission() and endTransmission() Functions In Arduino I²C programming, the `beginTransmission()` function marks the beginning of a sequence to be transmitted across the I²C bus, while the `endTransmission()` function marks the end of a write operation. These two functions bracket a sequence of write commands (described in the next section). The write commands simply place data in a buffer (initialized by the `beginTransmission()` call), and then the `endTransmission()` function transmits the data in the buffer across the I²C bus. The `beginTransmission()` function accepts a single argument, which is the I²C device address. Here’s its code: ``` void I2CDriverWire::beginTransmission( int address ) { write_address = (uint8_t)address; tx_next_byte_to_write = 0; } ``` This function doesn’t accomplish much. It saves the device address into a local (object) field for later use, initializes (to 0) a queue index for storing data to transmit, and then returns. Most of the real work happens in other functions. Here’s the code for the `endTransmission()` function: ``` uint8_t I2CDriverWire::endTransmission( int stop ) { master.write_async ( write_address, tx_buffer, tx_next_byte_to_write, stop ); finish(); return toWireResult( master.error() ); } ``` The single parameter is a Boolean flag indicating whether the function transmits a stop condition after it completes transmitting the data in the buffer. The `write_async()` function within `endTransmission()`, as its name suggests, asynchronously writes the data in the buffer (`tx_buffer`) across the I²C bus. Because it is asynchronous, this function returns before the transmission is complete. Here’s the `write_async()` code: ``` void IMX_RT1060_I2CMaster::write_async ( uint8_t address, uint8_t* buffer, size_t num_bytes, bool send_stop ){ ❶ if( !start( address, MASTER_WRITE )) return; if( num_bytes == 0 ) { // The caller is probably probing // addresses to find slaves. // Don't try to transmit anything. ignore_tdf = true; ❷ port->MTDR = LPI2C_MTDR_CMD_STOP; return; } ❸ buff.initialise( buffer, num_bytes ); stop_on_completion = send_stop; port->MIER |= LPI2C_MIER_TDIE; } ``` The `start()` function call ❶ within `write_async()` puts an I²C start condition on the bus. It returns true if the operation was successful. If it fails, `write_async()` simply returns. If it succeeds, but there are no bytes to send, the `write_async()` function writes the appropriate bit to the MTDR to stop the whole transmission ❷. If `start()` succeeds and there is data to transmit, then `write_async()` initializes a transmission buffer ❸ and enables the transmit data interrupt enable bit. Here’s the code for the `start()` function within `write_async()`: ``` bool IMX_RT1060_I2CMaster::start ( uint8_t address, uint32_t direction ){ { ❶ if( !finished() ) { // Code hasn't completed the previous transaction yet. abort_transaction_async(); _error = I2CError::master_not_ready; state = State::idle; return false; } // Start a new transaction. ❷ ignore_tdf = direction; _error = I2CError::ok; state = State::starting; // Make sure the FIFOs are empty before you start. if( tx_fifo_count() > 0 || rx_fifo_count() > 0 ) { // This should never happen. error = I2CError::master_fifos_not_empty; abort_transaction_async(); return false; } // Clear status flags. clear_all_msr_flags(); // Send a START to the slave at ″address.″ ❸ port->MCR |= LPI2C_MCR_MEN; uint8_t i2c_address = (address & 0x7F) << 1; port->MTDR = LPI2C_MTDR_CMD_START | i2c_address | direction; return true; } ``` If the system is not finished with a previous transmission ❶, this function will terminate the previous transmission and set the state to idle. Then the `start()` function initializes a new I²C bus transaction ❷ by clearing all FIFOs and status flags. Finally, this function places a start condition on the I²C bus ❸. If this code is successful, it initializes the `state` field with `State::starting`. The `endTransmission()` function is synchronous and does not return to the caller until the transmission is complete. To match those semantics, this version of the function calls the `finish()` function, which waits until the transmission is complete. The `finish()` function is a simple little class method: ``` void finish() { elapsedMillis timeout; while( timeout < timeout_millis ) { if( master.finished() ) { return; } } } ``` This function is a short extension of the `master.finished()` function that adds a timeout capability, which looks like this: ``` inline bool IMX_RT1060_I2CMaster::finished() { return state >= State::idle; } ``` The `state` field is initialized in the call to `start()` within `write_async()` and then set by the ISR. If the state is `transfer_complete` or `stopped`, then the ISR is done transferring data. Otherwise, the ISR is still reading or writing data, and the `endTransmission()` function will wait until the ISR has completed transferring data after the write operation begins. At the end of `endTransmission()`, the function calls `toWireResult()` to translate the error status bitmap returned by `master.error()` into an Arduino-compatible error code, and the function returns that value to the caller. #### 11.1.2.3 The write Functions Between the `beginTransmission()` and `endTransmission()` calls, Arduino code calls the `write()` function to append data to the transmission buffer. There are two variants of the `write()` function: one that writes a single byte and one that writes a buffer. Here’s the source code for the two `write()` functions: ``` size_t I2CDriverWire::write( uint8_t data ) { if( tx_next_byte_to_write < tx_buffer_length ) { tx_buffer[tx_next_byte_to_write++] = data; return 1; } return 0; } size_t I2CDriverWire::write( const uint8_t* data, size_t length ) { size_t avail = tx_buffer_length - tx_next_byte_to_write; if( avail >= length ) { uint8_t* dest = tx_buffer + tx_next_byte_to_write; memcpy( dest, data, length ); tx_next_byte_to_write += length; return length; } return 0; } ``` The maximum buffer length (defined in the `I2CDriverWire` class) is 32 bytes. If an application attempts to transmit more than 32 bytes in a single I²C transmission, this code will ignore all bytes beyond the size of the buffer. #### 11.1.2.4 The requestFrom(), read(), and available() Functions Reading bytes from the I²C bus is slightly less complex than writing data. There are three functions associated with reading: `requestFrom()`, `read()`, and `available()`. The `requestFrom()` function reads the data from a peripheral device and buffers the data up in memory, while the `read()` function retrieves bytes from the buffer and `available()` returns the number of bytes in the buffer. Here’s the source code for the `requestFrom()` function: ``` uint8_t I2CDriverWire::requestFrom ( int address, int quantity, int stop ){ rx_bytes_available = 0; rx_next_byte_to_read = 0; master.read_async ( (uint8_t)address, rxBuffer, min( (size_t)quantity, rx_buffer_length ), stop ); finish(); rx_bytes_available = master.get_bytes_transferred(); return rx_bytes_available; } ``` The first two statements in this function initialize the buffer index and count. The call to `master.read_async()` starts the actual read operation (it primes the system and notifies the ISR to start accepting data). As its name suggests, `master.read_async()` returns immediately, before the actual data is read. As with the `endTransmission()` function, `requestFrom()` calls the `finish()` function to wait until all the data has arrived from the peripheral device. Finally, `requestFrom()` returns the actual number of bytes read from the peripheral. Here’s the source code to the `read_async()` class function (which is called from `requestFrom()`): ``` void IMX_RT1060_I2CMaster::read_async ( uint8_t address, uint8_t* buffer, size_t num_bytes, bool send_stop ){ ❶ if( num_bytes > MAX_MASTER_READ_LENGTH ) { error = I2CError::invalid_request; return; } ❷ if( !start( address, MASTER_READ )) { return; } ❸ if( num_bytes == 0 ) { // The caller is probably probing addresses // to find slaves. Don't try to read anything. ❹ port->MTDR = LPI2C_MTDR_CMD_STOP; return; } buff.initialise( buffer, num_bytes ); port->MTDR = LPI2C_MTDR_CMD_RECEIVE | (num_bytes - 1); if( send_stop ) { port->MTDR = LPI2C_MTDR_CMD_STOP; } } ``` The `read_async()` function begins with a quick validity check ❶ of the requested length. If the caller requested too many bytes (more than 32, the size of the internal buffer), the function returns an error. Next, `read_async()` sends a start condition on the bus, along with the peripheral address and read command ❷. If that transmission is successful, the function checks to see if the caller is requesting 1 or more bytes to read ❸. If the caller specified 0 bytes, the function is done. Reading 0 bytes is a common way application code probes an address to see if there is a device present; `read_async()` will acknowledge the address byte transmission if it’s there. If no device is present at the address, a NAK happens. The `read_async()` function transmits a stop condition ❹ if the caller specified a read of 0 bytes. If the caller wants to read 1 or more bytes, `read_async()` calls the buffer initialization function to initialize the buffer associated with the calling `IMX_RT1060_I2CMaster` object. Then `read_async()` writes the receive command to the MTDR along with the number of bytes (minus 1) to receive. This particular write (32 bits) inserts the command into an on-chip command FIFO and increments the FIFO pointer. At this point, the on-chip hardware will begin processing this receive data request independently of code execution. After writing the receive command to the MTDR, `read_async()` checks whether the caller wants to send a stop condition after receiving the data. If the caller does want to send a stop, `read_async()` writes a stop command to the MTDR register (placing it in the command FIFO to execute once the read command finishes). Once the `read_async()` function returns, the MCU hardware takes over and handles the receive requests. The hardware notifies the software via interrupts as data arrives, and the ISR checks to see if the read request is complete, updating the class’s `state` object as appropriate. The call to finish in the `requestFrom()` function returns once the read completes or a timeout occurs. Of course, this call to `finish()` completes the `requestFrom()` operation. The `read()` function is straightforward: it just returns a byte from the buffer filled by a call to the `requestFrom()` function (or –1 if no data is available). Here’s its source code: ``` int I2CDriverWire::read() { if( rx_next_byte_to_read < rx_bytes_available ) { return rxBuffer[rx_next_byte_to_read++]; } return no_more_bytes; } ``` Finally, the `available()` function is simple—it just returns the number of bytes available in the read buffer as the function result. #### 11.1.2.5 Beyond the Arduino Library Gemmell’s code was specifically written to support Teensyduino programming using the Arduino (“Wiring”) programming paradigm. *Wiring programming* consists of some initialization code (the `setup()` procedure) followed by a main loop (the `loop()` function) that executes repeatedly. Wiring was designed to mimic the typical programming paradigm used for most noninterrupt-driven, non-RTOS embedded systems. Most MCUs that support hardware I²C communication will also support RTOS environments by using interrupts, hardware FIFOs, and DMA. Indeed, Gemmell’s code uses the NXP i.MX RT1062 MCU’s interrupt and FIFO capabilities but returns to the Wiring paradigm via the `finish()` function (which effectively stops the program’s execution until the data transfer is complete). If you’re running your code under some environment other than Arduino, especially a multithreaded environment, you can dramatically improve the performance of the system by replacing the `finish()` function with a wait-on-event–type API call. Such a call would suspend the thread until the ISR signals it and tells it that the transmission or reception is complete. Rather than burning CPU cycles in a busy-waiting loop, the thread would simply stop, and those CPU cycles would be available for other threads to use. Modifying Gemmell’s code to support this would be easy: you’d simply replace the call to finish with an appropriate wait API call and modify the ISR to signal that thread when the I²C operation is complete. One issue with this current implementation is that an interrupt consumes CPU cycles every time a character is received or whenever the transmit FIFO empties and the system needs to add more characters in the output FIFO. Using DMA, you could spare the CPU from having to do this work. In a DMA-driven system, the application programs the CPU’s DMA registers with an address and a count. Whenever an I²C transmission needs more data or whenever an I²C reception receives data, the DMA controller accesses memory without intervention of the CPU, saving all the work associated with an interrupt (saving machine state) and processing the interrupt. Although Gemmell’s code does not use DMA, the i.MX RT1062 supports this for I²C. For multithreaded RTOSs, this can make I²C operations even more efficient. See the NXP documentation in “For More Information” for more details. ## 11.2 ATtiny Controller Programming This section describes how to use the UART on an ATtiny84 MCU to create an I²C controller device. Chapter 3 originally described a software-based I²C controller on the ATtiny84; I moved that discussion to the online chapters because the code was largely redundant with respect to the Teensy code appearing in Chapter 3\. See Chapter 17 online at [`bookofi2c.randallhyde.com`](https://bookofi2c.randallhyde.com) for the software-based controller. This chapter describes a more efficient implementation using the universal serial interface hardware on the ATtiny84. The code in this section is based on Adafruit’s open source TinyWireM package, which is based on the Atmel AVR310 application note and was originally written by BroHogan and jkl (the names given in the source code). For the original code, visit [`github.com/adafruit/TinyWireM`](https://github.com/adafruit/TinyWireM). The ATtiny84 MCU—the MCU used on the Atto84 board from SparkFun—includes a hardware shift register known as the Universal Serial Interface (USI). You can employ the USI for any arbitrary shifting applications, including I²C, SPI, USB, or generic serial communication. Because the shifting is done in hardware, it is much more efficient than the bit-banging approach used in a software implementation of I²C. However, USI has a few limitations: * It can be used for only one interface at a time, so it’s a bit difficult to use if you need to control multiple serial buses concurrently (I²C and SPI, for example). * It provides no glitch filtering or slew rate limiting, which I²C requires, so it can be a little noisier than hardware implementations. * Clock generation (that is, SCL) must be done in software. * It provides no buffering, so software must continuously monitor the shift register to retrieve data (or transmit new data) when the shift register is full or empty. Generally, ATtiny84 MCUs are employed in low-cost, single-activity applications where they perform a single task, like transmitting or receiving data on the I²C bus. In these cases, their limitations aren’t much of a problem. The ATtiny84 has a few registers associated with the USI that you’ll use for I²C communication: USDR, USISR, and USICR. USDR is the USI 8-bit data register (output data is written here and input data is read from here). USISR is the USI status register, which contains information about the USI shift register’s state. See Table 11-10 for a description of these bits. Table 11-10: USISR Bit Definitions | **Bit** | **Description** | | --- | --- | | 0 to 3 | USICNT: 4-bit counter for the shift register. | | 4 | USIDC: Data collision flag. Set to 1 when the data being transmitted to SDA does not match the value actually on the pin. Use this flag to detect lost arbitration. | | 5 | USIPF: Stop condition flag. Set when a stop condition occurs. | | 6 | USIOIF: Counter overflow interrupt flag. Indicates that the 4-bit counter has overflowed. Must write a 1 to this bit position to clear this flag. If the overflow interrupt is enabled, an interrupt occurs on overflow. | | 7 | USISIF: Start condition interrupt flag. Set (and an appropriate interrupt generated, if enabled) when a start condition is found on the I²C bus. Writing a 1 to this bit position clears this flag. | USICR is the USI control register. Bits written to this port affect the operation of the USI. See Table 11-11 for a description of these bits. Table 11-11: USICR Bit Definitions | **Bit** | **Description** | | --- | --- | | 0 | USITC: Toggle clock pin. Writing a 1 to this bit position toggles the clock pin. | | 1 | USICLK: Strobe clock. Writing a 1 to this bit increments the counter and shifts data in the shift register, but only if USICS0 and USICS1 are 0\. If USICS1 is 1, then setting this bit to 1 will select the USITC as the clock (this is the state the software uses). See Table 11-12 for a description of this bit. | | 2 to 3 | USICS0, USICS1: Clock select. See Table 11-12 for a description of these bits. | | 4 to 5 | USIWM1, USIWM0: Wire mode. These bits control the SDA and SCL operation mode. For normal I²C operation, USIWM0 is 0 and USIWM1 is 1\. Table 11-13 lists the meanings of these two bits. | | 6 | Counter overflow interrupt enable. | | 7 | Start condition interrupt enable. | Table 11-12 lists the clock source settings for bits 1 to 3 in the USICR register. Table 11-12: USICR Clock Source Settings | **USICS1** | **USICS0** | **USICLK** | **Clock source** | 4**-bit counter clock source** | | --- | --- | --- | --- | --- | | 0 | 0 | 0 | No clock | No clock | | 0 | 0 | 1 | Software clock strobe (USICLK) | Software clock strobe (USICLK) | | 0 | 1 | X | Timer/Counter0 Compare Match | Timer/Counter0 Compare Match | | 1 | 0 | 0 | External, positive edge | External, both edges | | 1 | 0 | 1 | External, positive edge | Software clock strobe (USITC) | | 1 | 1 | 0 | External, negative edge | External, both edges | | 1 | 1 | 1 | External, negative edge | Software clock strobe (USITC) | Bits 4 and 5 in the USICR specify the mode of the USI pins on the ATtiny84\. Table 11-13 specifies the various options for these bits. Table 11-13: Pin Mode Settings | **USIWM1** | **USIWM0** | **Description** | | --- | --- | --- | | 0 | 0 | Normal I/O pins (not connected to serial shift register). | | 0 | 1 | Three-wire mode. Uses DO, DI, and USCK pins. This is for SPI bus operation. | | 1 | 0 | Two-wire mode. Uses SDA (DI) and SCL (USCK) pins. This is the setting the software in this chapter uses for I²C operation. | | 1 | 1 | Two-wire mode. Uses SDA and SCL pins. Same as two-wire mode above, but the SCL line is held low when a counter overflow occurs and until the Counter Overflow Flag (USIOIF) is cleared. | In addition to the three USI ports, I²C communication uses the ATtiny84’s PORT A parallel port, on which the SDA and SCL lines appear (SCL on bit 4, SDA on bit 6). Three memory locations are associated with PORT A: * PORTA: Output bits are written to this address. * PINA: Input bits are read from this port. * DDRA: I/O direction for PORTA is set here. Because the I²C SDA and SCL lines are bidirectional, the code is constantly setting the data direction bits in the DDRA register. ### 11.2.1 The Atto84 Triangle Wave Demonstration Program The Atto84 triangle wave output program in Listing 11-1 provides the source code for the triangle wave output program for the Atto84, using the low-level ATtiny84 registers. The first section of Listing 11-1 contains various constant declarations used throughout the code. In particular, it contains port address definitions, timing constants, and various bit patterns the code uses to initialize various registers: ``` // Listing11-1.ino // // Sample triangle wave output // on an Atto84 board from SparkFun // utilizing the ATtiny84 USI. #include "Arduino.h" #include <inttypes.h> #include <avr/interrupt.h> #include <avr/io.h> #include <util/delay.h> // From avr/io.h: // // PORTA output pins: // // PORTA SFR_IO8(0x02) // PORTA7 7 // PORTA6 6 // PORTA5 5 // PORTA4 4 // PORTA3 3 // PORTA2 2 // PORTA1 1 // PORTA0 0 // // USI Control Register: // // USICR SFR_MEM8(0xB8) // USITC 0 Toggle clock port pin. // USICLK 1 1 for software clk strobe. // USICS0 2 0 for clk source select 0. // USICS1 3 1 for clk source select 1. // USIWM0 4 1 for I2C mode. // USIWM1 5 0 for I2C mode. // USIOIE 6 Cntr overflow int enable. // USISIE 7 Start cond int enable. // // USI Status Register: // // USISR SFR_MEM8(0xB9) // USICNT0 0 4-bit counter value. // USICNT1 1 4-bit counter value. // USICNT2 2 4-bit counter value. // USICNT3 3 4-bit Counter value. // USIDC 4 Data output collision. // USIPF 5 Stop condition flag. // USIOIF 6 Cntr overflow int flag. // USISIF 7 Start condition int flag. // // #define USIDR SFR_MEM8(0xBA) // // DAC address: #define DAC_ADRS 0x60 // 0x60 for SparkFun, 0x62 for Adafruit // PORT A input pins: // // #define PINA SFR_IO8(0x19) Port A input pins: #define DDR_USI DDRA // Data direction for port A #define PORT_USI PORTA // Output pins on port A #define PIN_USI PINA // Input pins on port A #define PORT_USI_SDA PORTA6 // Bit 6 on port A #define PORT_USI_SCL PORTA4 // Bit 4 on port A #define PIN_USI_SDA PINA6 // Bit 6 on port (pin) A #define PIN_USI_SCL PINA4 // Bit 4 on port (pin) A #define TRUE (1) #define FALSE (0) // Time constants to pass to // delay_us for clock delays: #define T2_I2C 5 // >4.7 us #define T4_I2C 4 // >4.0 us #define I2C_READ_BIT 0 #define I2C_ADR_BITS 1 #define I2C_NAK_BIT 0 #define USI_I2C_NO_ACK_ON_ADDRESS 0x01 #define USI_I2C_NO_ACK_ON_DATA 0x02 static unsigned char const USISR_8bit = (1 << USISIF) | (1 << USIOIF) | (1 << USIPF) | (1 << USIDC) // Clear flags | (0x0 << USICNT0); // Shift 8 bits unsigned char const USISR_1bit = (1 << USISIF) | (1 << USIOIF) | (1 << USIPF) | (1 << USIDC) // Clear flags | (0xE << USICNT0); // Shift 1 bit union USI_I2C_state { uint8_t allBits; struct { uint8_t addressMode : 1; uint8_t cntlrWriteDataMode : 1; uint8_t memReadMode : 1; uint8_t unused : 5; }; } USI_I2C_state; ``` Next, the `USI_Initialize()` function, as its name suggests, is responsible for initializing the USI. See the comments in the following code for the particular initializations this function provides: ``` // Listing11-1.ino (cont.) // // USI_Initialize- // // Initializes the USI on the Atto84. void USI_Initialize(void) { // Enable pullup on SDA: PORT_USI |= (1 << PIN_USI_SDA); // Enable pullup on SCL: PORT_USI |= (1 << PIN_USI_SCL); // Enable SCL as output: DDR_USI |= (1 << PIN_USI_SCL); // Enable SDA as output: DDR_USI |= (1 << PIN_USI_SDA); // Preload data register with "bus released" data. USIDR = 0xFF; USICR = // Disable all interrupts (0 << USISIE) | (0 << USIOIE) | (1 << USIWM1) | (0 << USIWM0) // Set USI in two-wire mode. | (1 << USICS1) | (0 << USICS0) // Software strobe as counter clock source. | (1 << USICLK) | (0 << USITC); USISR = (1 << USISIF) // Clear flags | (1 << USIOIF) | (1 << USIPF) | (1 << USIDC) // Reset counter. | (0x0 << USICNT0); } // USI_Initialize ``` The `USI_I2C_Cntlr_Start()` and `USI_I2C_Cntlr_Stop()` functions put the start and stop conditions on the USI. In both functions, the code manually pulls the SCL and SDA lines low, as appropriate, with software-based timing to match the I²C specifications: ``` // Listing11-1.ino (cont.) // // USI_I2C_Cntlr_Start- // // Function for generating an I2C start condition. void USI_I2C_Cntlr_Start( void ) { // Release SCL to ensure that (repeated) start // can be performed: PORT_USI |= (1 << PIN_USI_SCL); // Verify that (wait until) SCL becomes high: while( !(PORT_USI & (1 << PIN_USI_SCL)) ); // Delay for 1/2 bit time before generating // the start condition: _delay_us( T2_I2C ); // Generate start condition. The SCL line has // been high for 1/2 bit time, pulling SDA // low generates the start: PORT_USI &= ~(1 << PIN_USI_SDA); // Force SDA low // Leave SDA low for at least 4 us: _delay_us( T4_I2C ); // Okay, clean up after yourself. Start has // been generated, so release SDA and pull // SCL low (start of first bit's clock period): PORT_USI &= ~(1 << PIN_USI_SCL); PORT_USI |= (1 << PIN_USI_SDA); return; } // USI_I2C_Cntlr_Stop- // // Function for generating an I2C stop condition. // Used to release the I2C bus. // Returns true if it was successful. void USI_I2C_Cntlr_Stop( void ) { // Stop condition consists of changing SDA from // low to high while the SCL line is high: PORT_USI &= ~(1 << PIN_USI_SDA); // Pull SDA low PORT_USI |= (1 << PIN_USI_SCL); // Release SCL // Wait until the SCL line registers a high on // the SCL input pin: while( !(PIN_USI & (1 << PIN_USI_SCL)) ); // Minimum setup time is 4 us: delay_us( T4_I2C ); // Okay, raise the SDA line to signal the // stop condition: PORT_USI |= (1 << PIN_USI_SDA); // Release SDA // Minimum hold time is around 5 us: delay_us( T2_I2C ); return TRUE; } ``` The `USI_I2C_Xcvr()` function is responsible for transmitting and receiving data via the USI: ``` // Listing11-1.ino (cont.) // // USI_I2C_Xcvr- // // Transmits and receives data. uint8_t USI_I2C_Xcvr ( uint8_t *msg, uint8_t msgSize ){ uint8_t *savedMsg; uint8_t savedMsgSize; // Caller must clear before calling this function // so that memReadMode can be specified: USI_I2C_state.allBits = 0; // Clear state bits USI_I2C_state.addressMode = TRUE; // True for first byte // Determine if this is a read (1) or write (0) operation // by looking at the LO bit of the first byte (the address // byte) in the message. ❶ if ( !( *msg // The LSB in the address & (1 << I2C_READ_BIT) // byte determines if it ) // is a cntlr Read or Write ) // operation { USI_I2C_state.cntlrWriteDataMode = TRUE; } // Save buffer pointer for later: savedMsg = msg; savedMsgSize = msgSize; // Send a start condition. ❷ USI_I2C_Cntlr_Start(); // Write address and Read/Write data: do { // If cntlrWrite cycle (or initial address transmission): ❸ if ( USI_I2C_state.addressMode || USI_I2C_state.cntlrWriteDataMode ){ // Write a byte. // Pull SCL low. PORT_USI &= ~(1 << PIN_USI_SCL); // Set up data. USIDR = *(msg++); // Send 8 bits on bus. USI_I2C_Cntlr_Transfer( USISR_8bit ); // Clock and verify (N)ACK from peripheral. // Enable SDA as input: DDR_USI &= ~(1 << PIN_USI_SDA); // If you get a NAK, not an ACK, // return an error code: if( USI_I2C_Cntlr_Transfer( USISR_1bit ) & (1 << I2C_NAK_BIT) ){ if( USI_I2C_state.addressMode ) { return USI_I2C_NO_ACK_ON_ADDRESS; } return USI_I2C_NO_ACK_ON_DATA; } if ( (!USI_I2C_state.addressMode) && USI_I2C_state.memReadMode ) { // Memory start address has been written. // // Start at peripheral address again: msg = savedMsg; // Set the Read Bit on peripheral address // (the first byte of the buffer): *(msg) |= (TRUE << I2C_READ_BIT); // Now set up for the Read cycle: USI_I2C_state.addressMode = TRUE; // Set byte count correctly: msgSize = savedMsgSize; // Note that the length should be peripheral // adrs byte + number of bytes to read + 1 // (gets decremented below). USI_I2C_Cntlr_Start(); } else { // Only perform address transmission once: USI_I2C_state.addressMode = FALSE; } } else // cntlrRead cycle { // Enable SDA as input: ❹ DDR_USI &= ~(1 << PIN_USI_SDA); // Read a data byte: *(msg++) = USI_I2C_Cntlr_Transfer( USISR_8bit ); // Prepare to generate ACK (or NAK // in case of End Of Transmission). if( msgSize == 1 ) { // If transmission of last byte was performed, // load NAK to confirm End Of Transmission: USIDR = 0xFF; } else { // Load ACK. // Set data register bit 7 (output for SDA) low: USIDR = 0x00; } // Generate ACK/NAK: USI_I2C_Cntlr_Transfer( USISR_1bit ); } }while( --msgSize ); // Until all data sent/received // Usually a stop condition is sent here, but caller // needs to choose whether or not to send it. // // Transmission is successfully completed. return( 0 ); } ``` This code begins by determining if this is a read or write operation and setting the mode appropriately ❶. Then the code puts a start condition on the bus ❷. If this is a write operation or if the code is writing the peripheral address and R/W bit to the bus, the code transmits the appropriate byte via the USI ❸. If this is a read operation, the code switches SDA to become an input pin and reads the appropriate data from the USI ❹. Next, the `USI_I2C_Cntlr_Transfer()` function is the generic function for reading or writing an array of bytes on the USI: ``` // Listing11-1.ino (cont.) // // USI_I2C_Cntlr_Transfer- // // Core function for shifting data in and out from the USI. // Data to be sent has to be placed into the USIDR before // calling this function. Data read will be returned // by the function. // // Status: // Data to write to the USISR. // In this code, this will be // USISR_8bit (for data transfers) // or USISR_1bit (for ACKs and NAKs). // // Returns the data read from the device. uint8_t USI_I2C_Cntlr_Transfer( uint8_t status ) { USISR = status; // Set USISR to status uint8_t control = // Prepare clocking (0 << USISIE) // Interrupts disabled | (0 << USIOIE) | (1 << USIWM1) // Set USI in two-wire mode | (0 << USIWM0) | (1 << USICS1) | (0 << USICS0) | (1 << USICLK) // Software clock as source | (1 << USITC); // Toggle Clock Port do { // Wait for roughly 1/2 bit time (4.7-5 us): _delay_us( T2_I2C ); // Toggle clock and generate positive SCL edge: USICR = control; // Wait for SCL to go high: while( !(PIN_USI & (1 << PIN_USI_SCL)) ); // Leave SCL high for at least 4 us: _delay_us( T4_I2C ); // Toggle clock to generate negative SCL edge: USICR = control; }while( !(USISR & (1 << USIOIF)) ); // Transfer complete? // Wait for 1/2 bit time so the clock is low for // a full bit time: _delay_us( T2_I2C ); uint8_t data = USIDR; // Read out data USIDR = 0xFF; // Release SDA // Switch the SDA back to an output pin: DDR_USI |= (1 << PIN_USI_SDA); return data; // Return the data read from the USIDR } ``` The `I2C_rw()` function is responsible for reading or writing an array of bytes on the USI and transmitting a stop condition at the completion of the transmission or reception: ``` // Listing11-1.ino (cont.) // // I2C_rw- // // Read or write a sequence of bytes from or to the I2C port. // LO bit of buf[0] is 0 for write, 1 for read. uint8_t I2C_rw( uint8_t *buf, size_t len, uint8_t stop ) { bool xferOK = false; uint8_t errorCode = USI_I2C_Xcvr( buf, len ); // If there wasn't an error, see if code is // supposed to send a stop bit and transmit // it if you are: if( errorCode == 0 ) { if( stop ) { USI_I2C_Cntlr_Stop(); } return 0; // No error } return errorCode; // There was an error } ``` The “main program” (Arduino `loop()` function) is the code that actually transmits a triangle wave to the (Adafruit or SparkFun) DAC: ``` // Listing11-1.ino (cont.) // // Usual Arduino initialization function. void setup( void ) { // Initialize the Atto84 I2C port: USI_Initialize(); } // Arduino main loop function: void loop( void ) { uint8_t writeBuf[3]; // Transmit the rising edge of the triangle wave: for( uint16_t dac=0; dac<4096; ++dac ) { // MCP4725 DAC at address DAC_ADRS (bits 1 to 7). // Create a write operation: writeBuf[0] = (DAC_ADRS << 1) | 0; // Write to DAC writeBuf[1] = (dac >> 8) & 0xff; // HO byte writeBuf[2] = dac & 0xff; // LO byte I2C_rw( writeBuf, 3, TRUE ); } // Transmit the falling edge: for( uint16_t dac=4095; dac>0; --dac ) { // MCP4725 DAC at address DAC_ADRS (bits 1 to 7). // Create a write operation: writeBuf[0] = (DAC_ADRS << 1) | 0; // Write to DAC_ADRS writeBuf[1] = (dac >> 8) & 0xff; // HO byte writeBuf[2] = dac & 0xff; // LO byte I2C_rw( writeBuf, 3, TRUE ); } } ``` The code in Listing 11-1 is relatively efficient, taking approximately 370 µsec per DAC transmission (3 bytes, about 120 µsec per byte). As the expected speed is about 100 µsec per byte (10 bits at 100 kHz), this is actually better than most of the other examples throughout this book, largely because of the streamlined code used to transmit the data. (Most of the other example programs execute considerable extra code between transmissions due to libraries, multitasking, and so on slowing them down.) Figure 11-1 provides the oscilloscope output for the program in Listing 11-1.  Figure 11-1: Atto84 I²C triangle wave output Figure 11-2 shows the logic analyzer output for this program. As you can see, there is very little delay between bytes written in a single message and only a short delay between values (3 bytes) written to the MCP4725 DAC (at address 0x62).  Figure 11-2: Logic analyzer output from Atto84 triangle wave output program The program in Listing 11-1 is very straightforward; it just uses polling for everything (a typical Arduino paradigm). If you needed to do more than output a triangle wave from the Atto84, you would probably want to take advantage of the USI interrupts to allow other work to happen while waiting for I²C transmissions to take place. I’ll demonstrate that ATtiny84 capability when describing bare-metal peripheral programming on the Atto84 later in this book (see section 16.1, “The ATtiny as an I[2]C Peripheral,” in Chapter 16). ## 11.3 Chapter Summary This chapter discussed I²C bus controller programming at the hardware level. Because each MCU provides a different mechanism for I²C communication, no general explanation will work with any MCU. Therefore, this chapter presents a couple of specific examples. In particular, it discusses two separate MCUs (the i.MXRT 1602 MCU used by the Teensy 4.*x*) and the ATtiny84 MCU. Each MCU section begins with a discussion of the appropriate MCU registers needed to control the bus, followed by some sample code to program those registers. The section on the Teensy 4.*x* used an existing library from Richard Gemmell and Paul Stoffregen as the example code. This library is a drop-in replacement for the standard Arduino Wire library. The section on the ATtiny84 used code based on Adafruit’s open source TinyWireM library. Both examples provided the basics for transmitting and receiving data on the I²C bus.
第四部分
I²c 外设编程示例
第十二章:TCA9548A I²C 总线扩展器

I²C 总线的 112 个非保留外设地址对于几乎任何系统来说都足够用了;你会在将如此多设备接入总线之前就达到总线电容限制。然而,由于设备往往将地址硬编码到硬件中,因此同一 I²C 总线上只能出现有限数量的相同设备。此外,由于存在数百或数千个 I²C 外设,常常会在不同 I²C 设备之间发生地址冲突。10 位地址方案的创建旨在缓解这个问题,但很少有设备和控制器利用这个特性。如果你想将两个具有相同地址的设备接入 I²C 总线,你将需要使用 I²C 总线扩展器。
I²C 总线扩展器,也称为 总线多路复用器 或 总线开关,允许你将单个 I²C 总线切换到两个、四个或八个独立的 I²C 总线。简而言之,你可以编程一个多路复用器,将输入的 I²C 线对切换到 IC 支持的某个线对集合。常见的 I²C 多路复用器 IC 包括:
-
TCA9543A:将一个 I²C 总线切换为两个独立的总线
-
TCA9545A:将一个 I²C 总线切换为四个独立的总线
-
TCA9548A:将一个 I²C 总线切换为八个独立的总线
本章重点介绍 TCA9548A IC,因为它支持最多数量的总线。Adafruit 和 SparkFun 都提供适用于该芯片的分 breakout 板,本章也将讨论这些。
12.1 TCA9548A I²C 多路复用器
TCA9548A 可能是爱好者中最受欢迎的 I²C 多路复用器,因为有多个厂商为其提供 breakout 板。因此,本章的剩余部分将讨论这一特定设备(TCA9543A 和 TCA9545A 设备提供 TCA9548A 功能的子集,因此学习后者将帮助你了解大部分与这些其他设备相关的内容)。接下来的子章节将描述如何连接设备、编程寄存器集以及编程 TCA9548A。
TCA9548A 芯片上包含九对(SDA, SCL)引脚。数据手册中将来自控制器设备的主线(SDA, SCL)命名为主线,并将其他八对分别命名为(SD0, SC0)、(SD1, SC1)、...、(SD7, SC7)。
12.1.1 上游设备与下游设备
与 TCA9548A 在同一 I²C 总线上的设备被称为上游设备,因为它们位于任何已切换的 I²C 总线之前,处于主 I²C 总线上。这些已切换的总线是 TCA9548A 之后的下游设备。上游设备直接响应来自控制器设备的地址,且多路复用器不进行任何切换(或掩码)。因此,如果上游设备与下游设备共享相同的地址,当 TCA9548A 切换到连接下游设备的总线时,这两个设备将发生地址冲突。
TCA9548A 是一个 I²C 总线上的设备,这意味着它可以与其他设备共享相同的物理 SDA 和 SCL 线(见图 12-1)。我将把主线称为 上游 对,而剩余的八组线称为 下游 对。
每一组下游(SDA, SCL)对形成自己的 I²C 总线,可以独立于其他七组下游对进行操作。通过将上游线切换到某个下游对(在程序控制下),使用 TCA9548A 的系统可以将单个 I²C 总线扩展为八个。

图 12-1:上游和下游设备
12.1.2 TCA9548A 选择寄存器
TCA9548A 从软件角度来看是 I²C 设备中比较简单的一个。该设备有一个 8 位的读写寄存器,该寄存器出现在其 I²C 地址上。向设备写入数据选择要使用的输出总线;从设备读取则读取最后写入的数据(设备上电时寄存器中写入 0)。
TCA9548A 上的寄存器是一个位图,用于选择哪些下游对连接到上游总线。在位位置 0 中写入 1 会将上游(SDA, SCL)线连接到(SD0, SC0),在位位置 1 中写入 1 会将(SDA, SCL)连接到(SD1, SC1),以此类推。在位位置 7 中写入 1 会将(SDA, SCL)连接到(SD7, SC7)。
虽然可以在 TCA9548A 寄存器的不同位置写入多个 1 位,但通常不建议这么做,因为如果同时访问两个不同的设备,可能会在 I²C 总线上产生冲突。写入多个 1 位到寄存器的一个原因是向所有下游总线上的设备发送一般呼叫命令。然而,大多数时候,你应该确保只向寄存器写入单个 1 位。请注意,将所有 0 写入寄存器是合理的:这样做会关闭所有下游对,此时控制器只能与位于 TCA9548A 上游的设备通信(即在主 SDA 和 SCL 线上)。
12.1.3 TCA9548A 地址和复位线
TCA9548A 可以响应范围在 0x70 到 0x77 的 I²C 地址。该芯片有三条地址线(A0、A1 和 A2),可以将其连接到 Gnd 或 Vcc 来选择地址(TCA9548A 使用 A0、A1 和 A2 作为地址的低 3 位)。本章一般假设 TCA9548A 配置为地址 0x70,除非另有说明。要写入板载寄存器,只需在 I²C 总线上的 TCA9548A 地址写入一个字节。
除了三条地址线和主(上游)SDA 和 SCL 线外,TCA9548A 还有一个重要的输入:复位。短时间拉低复位线将复位设备(向内部寄存器写入 0)。数据手册声称,你可以使用此线从总线故障条件中恢复。通常,你会将此复位线连接到 CPU 的数字 I/O 引脚,或者简单地将其拉高(通常通过 10kΩ 的上拉电阻)。
12.1.4 TCA9548A 电源、上拉电阻和电平转换
TCA9548A 可以在 1.65 V 到 5.0 V 的电压范围内工作,因此它可以与 1.8 V、2.5 V、3.3 V 或 5 V 逻辑兼容。所有引脚都可以容忍 5 V 电压,无论电源电压如何,这意味着你可以将 TCA9548A 用作 I²C 电平转换器。
由于 I²C 总线是开漏的,SDA 和 SCL 线上实际的电压取决于上游和下游信号的上拉电阻连接。如果你有一个 3.3 V 系统,那么 SDA 和 SCL(上游)可能会被上拉到 3.3 V。为了安全起见,你可能会将 TCA8845A 也运行在 3.3 V 下。然而,你可以切换到下游通道(例如,SD3 和 SC3),并将其连接到一个 5 V 设备,通过在该下游总线上的 5 V 上拉电阻来实现。TCA9548A 上的 SD3 和 SC3 引脚将能够正常处理 5 V 信号,而不会将此电压传递到运行在 3.3 V 的控制器设备上。
相反,如果你的控制器运行在 5 V,并且你为 TCA9548A 提供 5 V 电源,你可以通过简单地在该通道的线路上使用上拉电阻将下游通道连接到 3.3 V 系统。
12.1.5 降低总线负载和总线速度
除了作为电平转换器外,TCA9548A 还可以减少(电容性)I²C 总线的负载。假设你有 12 个设备在 I²C 总线上,并且负载迫使你以 100 kHz 的速度而不是 400 kHz 的速度运行。你可以使用 TCA9548A 将这 12 个设备分布到 8 或 9 个总线上,包括原始上游总线,从而减少电容负载。即使没有地址冲突,TCA9548A 在这种情况下也是有用的。
顺便说一下,TCA9548A 可以在正常(100 kHz)或快速(400 kHz)速度下工作。它不能在快速模式加(1 MHz)或更高速度下工作。
12.1.6 总线之间的切换
正如我之前提到的,通过在 TCA9548A 寄存器中向对应的比特位置写入 1(并将所有其他比特位置写为 0),你可以激活一个下游总线。如果你有三个下游设备(SD0, SC0)、(SD1, SC1)和(SD2, SC2),并且你想将数据发送到这些设备中的每一个,可以使用以下过程:
-
写入 0x01(0b0000_0001)到地址 0x70 的 TCA9548A。
-
通过简单地像处理上游设备一样向 SDA 和 SCL 写入数据,将数据写入(SD0, SC0)上的设备。
-
写入 0x02(0b0000_0010)到 TCA9548A 以激活(SD1, SC1)。
-
写入第二个设备(SD1, SC1),就像它是一个上游设备一样进行处理。
-
写入 0x04(0b0000_0100)到 TCA9548A 以激活(SD2, SC2)。
-
写入第三个设备(SD2, SC2),就像它是一个上游设备一样进行处理。
-
(可选)写入 0x0 到 TCA9548A 以禁用所有下游总线。
如前所述,任何 I²C 地址上的上游设备不应出现在下游总线上。那样会在上游和下游总线的设备之间产生冲突。
12.1.7 级联 TCA9548A 多路复用器
因为 TCA9548A 有三个地址线,你可以将最多八个 TCA9548A 设备连接到同一个 I²C 总线上。这样,你就可以通过控制器上的一对 SDA 和 SCL 线访问大约 64 个独立的 I²C 总线;即 112 × 64 = 7,168 个独立地址。如果这还不够,你可以级联多路复用器。每一级下游都需要一个唯一的地址,在同一级别内(也就是说,所有连接到同一个 TCA9548A 输出端的 TCA9548A 设备可以使用相同的地址)。例如,在图 12-2 中,最上面的 TCA9548A 可以使用地址 0x70,浅灰色的 TCA9548A 可以使用地址 0x71,深灰色的 TCA9548A 则都可以使用地址 0x72。
要写入连接到图 12-2 中最右侧 TCA9548A 的设备(假设从左到右的通道编号为 0 到 7),你需要首先向地址 0x70(直接连接到控制器的 TCA9548A)写入 0x80(0b1000_0000)。然后,你需要向地址 0x71(对应最右侧浅灰色 TCA9548A)写入 0x80,最后通过向地址 0x72 写入总线设置来选择你想要的右侧深灰色 TCA9548A 总线。
在实际操作中,以这种方式级联 TCA9548A 设备可能会产生各种定时和负载问题,所以我不推荐这么做。唯一值得级联多路复用器的理由是你需要超过八个总线,而在上游总线的 0x70 到 0x77 范围内只有一个空闲地址。

图 12-2:级联 TCA9548A 多路复用器
由于 TCA9548A 是表面贴装设备(SMD),它有点难以接入典型的面包板或原型电路。幸运的是,Adafruit、SparkFun 和其他制造商提供了可以方便使用这些设备的分线板。接下来的章节将描述这些分线板。
12.2 Adafruit TCA9548A I²C 扩展器
Adafruit TCA9548A I²C 扩展器是一个传统的分线板,包含一个单独的 TCA9548A,其引脚被带出并排布在 0.1 英寸的中心间距(请参见图 12-3 中的较小分线板)。它还提供了一个旁路电容;为 SDA、SCL 和复位引脚提供上拉电阻;以及三个下拉电阻,将 A0、A1 和 A2 拉到地,从而使设备的默认地址为 0x70。PCB 背面有焊接跳线,可以切断 SDA 和 SCL 的上拉电阻(以防你在上游总线上已经有了上拉电阻),并且可以设置地址。
Adafruit 的拓展板没有在下游总线上放置上拉电阻。许多拓展板(如 Adafruit 和 SparkFun 的板)都包含上拉电阻,因此在 TCA9548A I²C 扩展器上不需要额外的上拉电阻。更重要的是,如果你想将此板用作电平转换器,你需要能够控制上拉电阻连接的电压。然而,别忘了,如果你将某些 I²C 集成电路直接连接到下游总线,你必须自己添加上拉电阻。

图 12-3:SparkFun I²C Mux 和 Adafruit TCA9548A I²C 扩展器
12.3 SparkFun I²C Mux
SparkFun 的 I²C Mux 设备(参见图 12-3)对于使用 Qwiic 设备的人非常方便:它接受一个上游 Qwiic 连接器,如果你想连接其他上游设备,它会将信号通过板子路由,然后通过 Qwiic 连接器提供八个下游总线。
SparkFun 实现和 Adafruit TCA9548A I²C 扩展器之间有一些主要区别。首先,由于它基于 Qwiic,SparkFun 扩展器基本上是一个 3.3V 设备(尽管请参见下一个注释)。SparkFun 板为下游和上游总线提供 3.3V 的上拉电阻;你可以通过切断某些线路去除上游的上拉电阻,但下游总线上的上拉电阻没有这个选项。
SparkFun 和 Adafruit 板的第二个主要区别是尺寸。由于 SparkFun 板包括 10 个 Qwiic 连接器(2 个用于上游总线,8 个用于下游总线),因此该板比 Adafruit 设备大得多(参见图 12-3)。
除了这些问题,SparkFun 和 Adafruit 板的功能完全相同。就个人而言,如果我使用 Qwiic 系统部件,我会选择 SparkFun 板;否则,我可能会使用 Adafruit 板。
12.4 本章总结
本章讨论了三种不同的 I²C 多路复用器:TCA9543A、TCA9545A 和 TCA9548A。这些集成电路允许你将单个 I²C 总线扩展为两个、四个或八个独立的总线。本章讨论了如何将设备连接到多路复用器(上游和下游),如何编程多路复用器,以及如何将多路复用器连接到 I²C 总线。还介绍了如何将 TCA9548A 用作电平转换器,并评论了设备的工作频率。本章通过描述如何级联设备以支持超过八个额外的 I²C 总线,结束了 TCA9548A 的通用讨论。
最后,本章介绍了由 Adafruit 和 SparkFun 制造的两个拓展板。Adafruit 的 I²C 扩展板是一个传统的拓展板,将 TCA9548A 芯片的引脚引出到小型 PCB 上的 0.1 英寸间距引脚。SparkFun 的 I²C Mux 提供了八个 Qwiic 连接器(而非引脚)用于扩展总线。
第十三章:MCP23017 和 MCP23008 GPIO 扩展器

大多数 SBC(单板计算机)和 MCU(微控制单元)提供从三个到数十个的数字 I/O 引脚。有时候,你可能需要比标准配置更多的数字 I/O 引脚。即使是那些提供几十个引脚的 MCU,其中大部分也是多功能的。如果你将它们用作其它功能,可能会发现剩余的数字 I/O 引脚不够用。这时,GPIO 扩展器就显得非常有用。
尽管市场上有许多不同的集成电路(IC)可用于通过 I²C 总线扩展 GPIO,MCP23008 和 MCP23017(统称为 MCP230xx)是非常受欢迎的;它们有 DIP(插脚式)封装,并且有大量的库代码可供使用。MCP23008 支持 8 个 GPIO 引脚,而 MCP23017 支持 16 个,但这两个 IC 的其他功能完全相同。
本章描述了 MCP23017 和 MCP23008 设备,它们的电气连接以及如何编程它们。它还描述了内部设备寄存器(以及如何使用它们),并提供了一些示例程序来演示这些 IC 的操作。
13.1 MCP23017 和 MCP23008 引脚排列
MCP23017 的引脚排列如 图 13-1 所示。

图 13-1:MCP23017 引脚排列
MCP23008 的引脚排列如 图 13-2 所示。

图 13-2:MCP23008 引脚排列
表 13-1 显示了每个设备的引脚含义。
表 13-1:MCP230xx 引脚功能
| 引脚 | MCP23008 | MCP23017 |
|---|---|---|
| 1 | SCL(I²C 时钟) | GPIO 0,端口 B |
| 2 | SDA(I²C 数据) | GPIO 1,端口 B |
| 3 | A2(地址选择) | GPIO 2,端口 B |
| 4 | A1(地址选择) | GPIO 3,端口 B |
| 5 | A0(地址选择) | GPIO 4,端口 B |
| 6 | Reset(低电平有效) | GPIO 5,端口 B |
| 7 | NC(无连接) | GPIO 6,端口 B |
| 8 | INT(输入中断) | GPIO 7,端口 B |
| 9 | Vss(地) | Vdd(1.8 V,3.3 V 或 5 V) |
| 10 | GPIO 0 | Vss(地) |
| 11 | GPIO 1 | NC(无连接) |
| 12 | GPIO 2 | SCL(I²C 时钟) |
| 13 | GPIO 3 | SDA(I²C 数据) |
| 14 | GPIO 4 | NC(无连接) |
| 15 | GPIO 5 | A0(地址选择) |
| 16 | GPIO 6 | A1(地址选择) |
| 17 | GPIO 7 | A2(地址选择) |
| 18 | Vdd (1.8 V, 3.3 V 或 5 V) | Reset(低电平有效) |
| 19 | n/a | INTB(端口 B 中断) |
| 20 | n/a | INTA(端口 A 中断) |
| 21 | n/a | GPIO 0,端口 A |
| 22 | n/a | GPIO 1,端口 A |
| 23 | n/a | GPIO 2,端口 A |
| 24 | n/a | GPIO 3,端口 A |
| 25 | n/a | GPIO 4,端口 A |
| 26 | n/a | GPIO 5,端口 A |
| 27 | n/a | GPIO 6,端口 A |
| 28 | n/a | GPIO 7,端口 A |
Vdd 引脚是供电(正电压)引脚。MCP230xx 可以在多个不同的逻辑电平下工作,范围从 1.8 V 到 5.5 V(通常为 1.8 V、3.3 V 或 5.0 V)。Vss 引脚是接地引脚。
SCL 和 SDA 是 I²C 总线引脚。像往常一样,这些是开漏引脚。总线电压(通过 I²C 上拉电阻保持)应接近 Vdd。请注意,MCP230xx IC 能够在 100 kHz、400 kHz 甚至高达 1.7 MHz 的速度下工作。
GPIOx 引脚提供通用 I/O 扩展。MCP23008 有 8 个扩展 I/O 引脚,而 MCP23017 有 16 个(分别命名为端口 A和端口 B,每个端口有 8 个引脚)。当作为输入工作时,这些引脚可接受高达 Vdd 的电压。作为输出引脚工作时,它们在输出端产生 Vdd。
A0、A1 和 A2 引脚指定设备地址的低 3 位。这些引脚应连接到 Vdd 或 Vss 以设置设备地址。MCP230xx IC 支持在 I²C 总线上最多连接八个不同的设备(通过 A0、A1 和 A2 作为低地址位选择)。请注意,高 4 位地址总是 0b0100,因此完整的设备地址范围在 0x20 到 0x27 之间(当移入输出字节时,地址范围为 0x40 到 0x4E)。
重置信号是一个有效低信号,用于复位设备。该引脚在低电平时,将所有引脚配置为输入,并将设备配置为安全模式(最不可能导致硬件问题)。请注意,MCP230xx设备在施加电源时会自动复位,因此除非电路在操作过程中绝对需要能够复位 MCP230xx,否则通常会发现该引脚连接到 Vdd。
MCP23008 的 INT 引脚以及 MCP23017 的 INTA 和 INTB 引脚用于发出中断信号。中断是一个异步信号,提醒 CPU 采取某些操作(通常是暂停当前执行流并运行一个特殊的中断服务程序来处理事件)。你可以编程使 INT、INTA 和 INTB 引脚在 MCP230xx发生变化时脉冲或设置为某个电平,这对于当系统不能频繁轮询输入引脚时,检测输入变化非常有用。
13.2 MCP230xx 寄存器
MCP230xx IC 是功能丰富的设备。不幸的是,这些功能的实现带来了成本:编程复杂性。为了编程这些 IC,你需要读取和写入各种寄存器。MCP23008 有 11 个内部寄存器(参见表 13-2)。
表 13-2:MCP23008 寄存器
| 寄存器编号 | 名称 | 功能 |
|---|---|---|
| 0 | IODIR | I/O 数据方向寄存器 |
| 1 | IPOL | 输入极性 |
| 2 | GPINTEN | GPIO 中断使能寄存器 |
| 3 | DEFVAL | 默认比较值(用于中断) |
| 4 | INTCON | 中断控制寄存器 |
| 5 | IOCON | I/O 配置寄存器 |
| 6 | GPPU | GPIO 上拉寄存器 |
| 7 | INTF | 中断标志寄存器 |
| 8 | INTCAP | 中断捕捉寄存器 |
| 9 | GPIO | GPIO I/O 端口寄存器 |
| 10 (0xA) | OLAT | 输出锁存寄存器 |
MCP23017 有 22 个内部寄存器(参见表 13-3)。
表 13-3:MCP23017 寄存器
| 寄存器编号, BANK = 0 | 备用寄存器编号, BANK = 1 | 名称 | 功能 |
|---|---|---|---|
| 0 | 0 | IODIRA | Port A I/O 数据方向寄存器 |
| 1 | 16 (0x10) | IODIRB | Port B I/O 数据方向寄存器 |
| 2 | 1 | IPOLA | Port A 输入极性 |
| 3 | 17 (0x11) | IPOLB | Port B 输入极性 |
| 4 | 2 | GPINTENA | Port A GPIO 中断使能寄存器 |
| 5 | 18 (0x12) | GPINTENB | Port B GPIO 中断使能寄存器 |
| 6 | 3 | DEFVALA | Port A 默认比较值(用于中断) |
| 7 | 19 (0x13) | DEFVALB | Port B 默认比较值(用于中断) |
| 8 | 4 | INTCONA | Port A 中断控制寄存器 |
| 9 | 20 (0x14) | INTCONB | Port B 中断控制寄存器 |
| 10 (0xA) | 5 | IOCON | I/O 配置寄存器(只有单一的 IOCON) |
| 11 (0xB) | 21 (0x15) | IOCON | I/O 配置寄存器(与寄存器 10/5 相同) |
| 12 (0xC) | 6 | GPPUA | Port A GPIO 上拉寄存器 |
| 13 (0xD) | 22 (0x16) | GPPUB | Port B GPIO 上拉寄存器 |
| 14 (0xE) | 7 | INTFA | Port A 中断标志寄存器 |
| 15 (0xF) | 23 (0x17) | INTFB | Port B 中断标志寄存器 |
| 16 (0x10) | 8 | INTCAPA | Port A 中断捕获寄存器 |
| 17 (0x11) | 24 (0x18) | INTCAPB | Port B 中断捕获寄存器 |
| 18 (0x12) | 9 | GPIOA | Port A GPIO |
| 19 (0x13) | 25 (0x19) | GPIOB | Port B GPIO |
| 20 (0x14) | 10 (0xA) | OLATA | Port A 输出锁存寄存器 |
| 21 (0x15) | 26 (0x1A) | OLATB | Port B 输出锁存寄存器 |
MCP23017 支持两组寄存器编号,标准和备用(或“特殊”)。寄存器编号由 IOCON(控制)寄存器中的第 7 位选择。如果该位为 0(上电/复位状态),则 MCP23017 使用标准寄存器编号。如果第 7 位为 1,则 MCP23017 使用备用寄存器编号,这将两个端口分开成两个独立的寄存器库(0 到 0xA 为端口 A,0x10 到 0x1A 为端口 B)。
13.2.1 访问 MCP230xx 寄存器
由于 MCP230xx 设备具有多个寄存器,向这些设备写入和读取数据比像 MCP4725 这样的简单设备更为复杂。写入单个字节到寄存器的(典型)协议如下:
-
在 I²C 总线上发送启动条件。
-
发送 I²C 地址字节(值范围为 0x40 到 0x46)。这始终是一个写操作,因此地址字节的 LO 位将始终为 0。
-
将寄存器地址写入 I²C 总线。
-
将寄存器数据(写入 MCP230xx 寄存器)放置在 I²C 总线上。
-
在总线上发送停止条件以终止传输。
读取单个字节的寄存器(典型)协议如下:
-
在 I²C 总线上发送启动条件。
-
发送 I²C 地址字节(值范围为 0x40 到 0x46)。这始终是一个写操作,因此地址字节的 LO 位将始终为 0。
-
将寄存器地址写入 I²C 总线。
-
在 I²C 总线上发送(重新)启动条件。
-
发送 I²C 地址字节(值范围为 0x41 到 0x47)。这是一个读取操作,因此地址字节的 LO 位将为 1。
-
从 I²C 总线读取寄存器数据。
-
在总线上加上停止条件以终止传输。
本章讨论了该协议的其他形式,用于块读写;请参阅本章后面的第 13.2.6 节,“顺序寄存器操作”。
13.2.2 MCP230xx 初始化
在上电时,MCP230xx 设备进入以下状态:
-
IOCON 位 7 被设置为 0,以选择 MCP23017 的标准寄存器编号。
-
所有 GPIO 引脚都被编程为输入(请参阅第 13.2.3 节,“编程数据方向”)。
-
所有上拉电阻已关闭(请参阅第 13.2.4 节,“编程输入上拉电阻”)。
-
所有中断均已禁用(请参阅第 13.5.5 节,“启用 MCP230xx 的中断”)。
-
在 MCP23017 上,端口 A 和 B 的中断将独立处理(如果启用)。
-
MCP230xx 被编程为顺序寄存器操作(请参阅第 13.2.6 节,“顺序寄存器操作”)。
-
启用 SDA 跃变速率控制(请参阅第 13.2.7 节,“跃变速率控制”)。
-
INTx 引脚是活动输出(非开漏;请参阅第 13.2.8 节,“读取 MCP230xx 上的通用输入/输出引脚”)。
-
中断输出引脚为低有效(发生中断时为低电平信号;请参阅第 13.5.5 节,“启用 MCP230xx 的中断”)。
IOCON 寄存器(I/O 配置)处理大部分初始化操作。表 13-4 列出了 IOCON 中的位及其功能。
表 13-4:IOCON 寄存器功能
| 位 | 上电或复位时的默认值 | 名称 | 功能 |
|---|---|---|---|
| 7 | 0 | BANK | 仅限 MCP23017。选择标准寄存器编号(BANK = 0)或备用寄存器编号(BANK = 1)。 |
| 6 | 0 | MIRROR | 仅限 MCP23017。INTA/B 镜像功能。如果 MIRROR = 0,则 INTA 和 INTB 引脚独立工作。如果 MIRROR = 1,则这两个引脚内部连接在一起。有关更多信息,请参阅第 13.5.2 节中的“INTx 引脚极性”。 |
| 5 | 0 | SEQOP | 如果 SEQOP = 1,则连续的数据读/写操作读取和写入相同的寄存器编号。如果 SEQOP = 0,则每次操作后寄存器编号递增(主要适用于 MCP23017)。 |
| 4 | 0 | DISSLW | SDA 引脚的跃变速率控制。如果 DISSLW = 0,则启用跃变速率控制。如果 DISSLW = 1,则禁用跃变速率控制。 |
| 3 | 0 | N/A | 仅供 SPI 版本的 MCP23Sxx GPIO 扩展器使用。 |
| 2 | 0 | ODR | 开漏控制。如果 ODR = 1,则 INTx 引脚为开漏输出。如果 ODR = 0,则 INTx 引脚为活动逻辑输出。有关更多信息,请参阅第 13.5.5 节,“启用 MCP230xx 的中断”。 |
| 1 | 0 | INTPOL | 设置 INT 引脚的极性。如果 INTPOL = 0,则 INTx 引脚为低电平有效。如果 INTPOL = 1,则 INTx 引脚为高电平有效。仅当 ODR = 0 时,此位设置极性。有关更多信息,请参阅第 13.5.4 节,“开漏 INTx 输出”。 |
| 0 | 0 | N/A | 未使用。 |
如果你决定将 MCP230xx 初始化为非默认值,你需要将适当的值写入 IOCON 寄存器。此操作应在程序开始执行后立即进行。虽然在执行过程中更改配置是可能的,但这种情况较为少见;大多数情况下,你只需配置一次 MCP230xx,然后就不会再更改该寄存器。
要编程 IOCON 寄存器,你需要向 I²C 总线写入 3 个字节(参见图 13-3):
-
在 I²C 总线上放置启动条件。
-
写入设备地址(0x40 到 0x46),并将 LO 位设置为 0(写操作)。
-
将 IOCON 寄存器号 0x0A(或者如果使用备用寄存器号则为 0x05)写入总线。
-
将新的 IOCON 寄存器值写入 I²C 总线。
-
在 I²C 总线上放置停止条件。

图 13-3:IOC 初始化示例序列
上电或复位操作后,寄存器地址默认采用标准寄存器号。如果你打算使用备用寄存器号,你必须将 IOCON 寄存器的位 7 设置为 1,写入地址 0x0A,这是上电/复位后的 IOCON 地址。之后,所有对 IOCON 的写操作必须发生在寄存器号 0x05。
请注意,如果外部硬件能够复位 MCP230xx,那么 IOCON 寄存器将切换回地址 0x0A。如果软件没有意识到复位操作已发生,可能会造成问题。因此,保持 MCP230xx 在标准寄存器号模式下是一种较好的选择。
13.2.3 编程数据方向
MCP230xx 的 GPIO 引脚可以单独编程为输入或输出。数据方向寄存器(DDRs) 控制每个引脚的输入或输出状态。MCP23008 具有一个(8 位)IODIR(寄存器 0),而 MCP23017 有两个(IODIRA 是寄存器 0,IODIRB 是寄存器 1 或 16)。
IODIRx 寄存器中的每个位位置控制相应 GPIO 引脚的 I/O 状态。也就是说,IODIRA 中的位 0(MCP23008 上的 IODIR)控制 GPA0,位 1 控制 GPA1,以此类推。同样,IODIRB 中的位 0 控制 GPB0,位 1 控制 GPB1,以此类推。某个位位置上的 1 将该引脚配置为输入;该位位置上的 0 将该引脚配置为输出。
当 MCP230xx 上电或复位引脚被拉低时,IC 会将所有 GPIO 引脚配置为输入(即,它会将 IODIRx 寄存器初始化为全 1 位)。这是最安全的初始配置,因为它可以防止将 GPIO 引脚配置为输出,如果该引脚连接到带有活动信号的线路,可能会导致电气冲突。
由于 MCP230xx 的 IODIR 灵活性,你可以将任意位编程为输入或输出。实际上,最方便的做法(至少在 MCP23017 上)是将每个 8 位的组配置为所有输入或所有输出。这样可以更方便地编程 MCP230xx 芯片。当然,如果你的硬件设计要求在单一端口上混合使用输入和输出方向,那也是完全可以接受的;代价是稍微复杂一些的编程要求。
要将数据方向值发送到 MCP230xx 芯片,传输图 13-4 中显示的序列。

图 13-4:IODIRx 初始化序列
出现在图 13-4 中的序列的第三个字节是数据方向初始化值。
13.2.4 编程输入上拉电阻
MCP230xx 的输入引脚通常连接到干接点输入:开关、继电器触点或其他连接两个不同信号线的设备。典型的干接点可能是一个按钮、DIP 开关或其他单极单掷开关(SPST)或继电器。
通常,干接点会将 MCP230xx 上的输入引脚连接到地。当接点闭合时,输入被短接到地,导致对应的 GPIOx 寄存器中的位显示 0 输入值。当接点打开时,输入信号浮空,这通常是不好的;电子设备可能将浮空输入解读为逻辑 0 或 1。为了避免浮空输入,设计人员通常会在输入引脚上加上拉电阻。当干接点处于开路位置时,这会将电压引脚拉至上拉电阻连接的电压(通常是 Vdd)。当干接点闭合时,这会将输入引脚短接到地,从而提供逻辑 0 输入。
上拉电阻唯一的问题是,你需要在印刷电路板或原型板上为其腾出空间,并花费时间和精力进行安装。为方便起见,MCP230xx 部件提供了可编程上拉电阻,允许你通过编程启用或禁用输入引脚上的上拉电阻。GPPUx 寄存器提供了这一功能。将 GPPUx 位编程为 1(并将相同的位在 IODIRx 中编程为 1)会将一个 100-kΩ 上拉电阻连接到该引脚。相反,编程为 0 会断开上拉电阻。
你应该只在连接到干接点输入的 GPIO 引脚上编程上拉电阻。如果一个逻辑电平信号连接到一个 GPIO 引脚,编程该引脚的上拉电阻可能会损坏 MCP230xx 或连接另一端的逻辑设备。即使它不损坏电子元件,它也可能干扰输入信号。
要在 MCP230xx 芯片上设置上拉值,传输图 13-5 中显示的序列。

图 13-5:GPPUx 上拉初始化
在图 13-5 中出现的序列中的最后一个字节是上拉初始化的位图值。
13.2.5 编程输入极性
如果你仔细阅读,可能会注意到,在上一节中使用上拉电阻读取干触点开关时,当开关关闭(按下)时输入为 0,当开关打开(释放)时输入为 1。这种逻辑,低电平有效逻辑,与你在软件中可能期望的相反。直觉上,你可能会认为按下(关闭)开关时应获得逻辑 1,而释放(打开)开关时应获得逻辑 0——也就是,你期望的是高电平有效逻辑。虽然从 GPIO 引脚读取信号后反转它非常简单,但 MCP230xx设备提供了一个特殊的极性寄存器,允许你选择高电平有效或低电平有效逻辑信号。
MCP23008 的 IPOL 寄存器以及 MCP23017 的 IPOLA 和 IPOLB 寄存器让你控制输入引脚的极性。如果 IPOLx中的某个位为 0,则 GPIOx寄存器中的相应位将反映输入引脚的当前状态。如果 IPOLx中的某个位为 1,则 GPIOx中的相应位将反映输入引脚的反向状态。
如果一个实际的输入是低电平有效,但你希望将其读取为高电平有效,只需将 IPOLx中的相应位编程为 1,这样在读取时就会反转信号。例如,反转干触点使其变为高电平有效输入,从而使其逻辑与逻辑电平输入信号匹配。
要设置 MCP230xx ICs 上的输入引脚极性,传输图 13-6 中所示的序列。

图 13-6:IPOLx输入引脚极性序列
在图 13-6 中出现的序列中的第三个字节是极性初始化值。
13.2.6 顺序寄存器操作
在 MCP230xx上读取或写入寄存器值至少需要三次 1 字节的 I²C 总线传输:一个 I²C 地址字节,一个寄存器号,以及与寄存器的数据传输。由于 I²C 传输相对较慢(特别是在 100 kHz 下操作时),MCP230xx提供了一个特殊的顺序寄存器访问模式,以减少 I²C 总线事务的数量。IOCON 中的 SEQOP 位(位 5)控制此模式。如果 SEQOP 为 0,则 MCP230xx会在每次 I²C 总线的数据传输和接收后自动递增寄存器号。如果 SEQOP 为 1,则 MCP230xx会禁用自动增量模式。
当自动增量模式处于激活状态时,控制器设备可以在传输一对 I²C 地址字节和寄存器字节后,读取或写入多个数据字节。只要控制器设备没有在总线上设置停止条件,SCL 上的连续时钟脉冲将继续读取或写入 MCP230xx上的连续寄存器。
这种自动递增特性在 MCP23017 的标准模式(非分组模式)下尤其有用。在标准模式下,端口 A 和端口 B 寄存器出现在连续的位置。这使得你能够连续地从两个端口读取和写入寄存器,作为一个 16 位操作。例如,如果你希望同时初始化 IODIRA 和 IODIRB,你可以使用以下序列(假设 SEQOP 为 0,这是上电/复位条件):
-
在 I²C 总线上放置启动条件。
-
向 I²C 总线写入地址(0x40 至 0x46),LO 位为 0(用于写入)。
-
向 I²C 总线写入 0(IODIRA 寄存器地址)。
-
向 I²C 总线写入 IODIRA 的数据方向位。
-
向 I²C 总线写入 IODIRB 的数据方向位。
-
在 I²C 总线上放置停止条件。
在这段顺序的第 4 步和第 5 步之间,MCP23017 会自动递增寄存器号,从而使第 5 步将数据方向位写入寄存器 1(IODIRB),如 图 13-7 所示。

图 13-7:自动递增寄存器号
这个序列仅需要向 I²C 总线写入 4 个字节。这比使用独立事务分别写入 IODIRA 和 IODIRB 寄存器所需的 6 个字节少了 2 个字节。
使用自动递增模式时,你并不限于只写入两个值。例如,你可以一次性写入方向和极性初始化值:
-
在 I²C 总线上放置启动条件。
-
向 I²C 总线写入地址(0x40 至 0x46),LO 位为 0(用于写入)。
-
向 I²C 总线写入 0(IODIRA 寄存器地址)。
-
向 I²C 总线写入 IODIRA 的数据方向位。
-
向 I²C 总线写入 IODIRB 的数据方向位。
-
向 I²C 总线写入 IPOLA 的极性位。
-
向 I²C 总线写入 IPOLB 的极性位。
-
在 I²C 总线上放置停止条件。
理论上,你也可以在这个序列中写入中断初始化值,尽管很少使用所有的中断初始化特性,所以并非总能顺利进行顺序写入。遗憾的是,拉高寄存器在常见的初始化列表中并不会顺序出现,因此你最终不得不独立地写入它们的地址和寄存器值。
当然,在典型的应用中,你不太可能每次都写初始化的代码,所以单次初始化所带来的节省不会太大。然而,通常会从 MCP23017 读取所有 16 位输入数据,或者写入所有 16 位输出数据。自动递增模式对这些操作非常有用,因为它们在常见应用中频繁发生。
然而,寄存器自动递增模式并非总是有用的。也许你想快速写入 GPIO、GPIOA 或 GPIOB 寄存器,将某些波形输出到输出引脚,这时你:
-
在总线上放置启动条件。
-
向总线写入 I²C 地址,且 LO(读取)位等于 1。
-
向总线写入寄存器号(例如,GPIOA 的值为 12/0xC)。
-
向总线写入一个字节。
-
对每个不同的值,重复步骤 4,以便写入输出引脚。
-
在总线上放置停止条件。
此代码需要将 SEQOP 设置为 1,以禁用在步骤 4 后自动递增寄存器编号。
你需要决定在代码中开启还是关闭自动递增功能更为合适。如果你经常在两种模式之间切换,那么在非自动递增模式下操作可能会更高效。
13.2.7 跃变率控制
IOCON 寄存器的第 4 位(DISSLW)控制 I²C SDA 跃变率。当启用时(0),跃变率控制减少 SDA 线从低到高或从高到低的上升或下降速度(参见 图 13-8)。默认情况下,此位为 0,减少信号的上升和下降时间。

图 13-8:跃变率
降低跃变率可以减少 SDA 线上的噪声(由振铃引起,振铃是信号在变化后暂时上下跳动的现象)。然而,在更高的速度下,降低跃变率可能会引入错误。通常,在 100 kHz 时启用跃变率控制,而在 1 MHz 时禁用。在 400 kHz 时,根据信号噪声的情况决定是否启用或禁用跃变率控制,这需要使用示波器进行验证。由于 MCP230xx 设备默认启用跃变率控制,因此只有在系统存在噪声问题时,才应关闭它。
13.2.8 读取 MCP230xx 上的通用输入/输出引脚
读取 MCP230xx 的 GPIOx 寄存器是最常见的软件操作之一。读取这些寄存器会返回 GPAx 和 GPBx 引脚的当前状态。如果这些引脚被配置为输出,那么读取 GPIOx 寄存器将返回最后写入的值(或默认重置状态)到输出引脚,也就是这些引脚的当前状态。
从 GPIOx 寄存器读取数据需要两次 I²C 总线事务。首先,写入 GPIOx 寄存器地址;其次,读取寄存器值:
-
在 I²C 总线上放置启动条件。
-
将设备地址和 LO 位 0(写入操作)写入 I²C 总线。
-
写入 GPIO、GPIOA 或 GPIOB 寄存器地址(在 MCP23008 上,GPIO = 9;在 MCP23017 上,GPIOA = 9 或 0x12,GPIOB = 0x19 或 0x13)。
-
在 I²C 总线上放置(重复的)启动条件。
-
将设备地址和 LO 位 1(读取操作)写入 I²C 总线。
-
从 I²C 总线读取 GPIO 位。
-
(在 MCP23017 上可选)从 I²C 总线读取第二组 GPIO 位(GPIOB)(参见 图 13-9)。
-
在总线上放置停止条件。
第 7 步假设 IOCON 中的 SEQOP 位为 0,且步骤 3 中写入的寄存器地址为 GPIOA(在标准模式下地址为 0x12)。

图 13-9:GPIO 顺序读取操作
请注意,如果 IOCON 中的 SEQOP 已被设置为 1(没有自动递增的寄存器地址),那么你可以反复读取 GPIOx 位。
13.3 写入 MCP230xx 的通用输入/输出引脚
有两种方法可以将数据写入 MCP230xx 的输出引脚:将数据写入 GPIOx 寄存器或将数据写入 OLATx 寄存器。写入任何一个寄存器集合都会将输出数据放置到输出引脚上。
对于输出目的,写入 GPIOx 和 OLATx 寄存器之间没有实际区别。内部上,MCP230xx 将写入 GPIOx 转换为写入 OLATx。当你从这些寄存器读取时,它们有所不同。从 GPIOx 读取时,当然是读取 GPAn 和 GPBn 输入引脚的当前状态。从 OLATx 读取时,会返回最后写入 OLATx(或 GPIOx)寄存器的值。如果任何引脚被编程为输入,这将产生不同的结果。
写入 OLATx(或 GPIOx)寄存器的复杂度略低于从 GPIOx 寄存器读取的复杂度。以下是步骤:
-
在 I²C 总线上放置一个起始条件。
-
写入带有 LO 位 0(写入操作)的设备地址。
-
写入 OLAT、OLATA 或 OLATB 寄存器地址(MCP23008 上 OLAT = 0xA,OLATA = 0xA 或 0x14,OLATB = 0x1A 或 0x15 在 MCP23017 上)。
-
将 OLAT 位写入 I²C 总线。
-
将第二组 OLAT 位(OLATB)写入 I²C 总线(见图 13-10)。在 MCP23017 上这是可选的(仅当 SEQOP = 0 时)。
-
在总线上放置一个停止条件。
这个过程比读取更简单,因为你不需要重复开始条件,也不需要将第二个设备地址写入总线。

图 13-10:GPIO(OLAT)16 位顺序写入操作
请注意,MCP230xx IC 上的输出引脚无法驱动太多电流。每个引脚最多可以源或吸收 25 毫安——勉强足够点亮一个 LED。整个封装的最大电流限制为 150 毫安,这意味着你不能连接 16 个 LED 并以每个 25 毫安的电流运行它们。为了处理更多的电流,你需要将一个晶体管或其他电流放大器连接到输出引脚。ULN2308 达林顿阵列——在一个 18 引脚封装中的 8 个达林顿放大器,每个能够吸收 500 毫安——是一个非常适合此目的的设备。
13.4 演示 MCP23017 的输入/输出
到现在为止,你已经学到了足够的知识,能够实际编程 MCP230xx 设备以轮询(非中断)模式运行。本节中的示例 Arduino 程序将一些输出数据写入 MCP23017 的端口 B,并从同一设备的端口 A 读取这些数据。这个程序在功能上相对简单,但它展示了编程该设备所需的大部分内容。
程序使用了图 13-11 中所示的电路。MCP23017 的 SDA 和 SCL 线路接到 Arduino Uno Rev3(或其他兼容 Arduino 的设备)上的相应引脚。引脚 A0、A1 和 A2 接地,因此 7 位设备地址将是 0x20。复位引脚接到 +5 V 或 +3.3 V,具体取决于你运行的是 3.3 V 还是 5.0 V 系统。端口 A 引脚接到反向的端口 B 引脚。最后,如果你的 SBC 没有在 SDA 和 SCL 线路上提供适当的上拉电阻,你需要在这两条线路与 Vdd(+5 V 或 +3.3 V)之间放置一对 4.7-kΩ 电阻。

图 13-11:程序的简单接线示例,见列表 13-1
请注意在图 13-11 中,GPB0 连接到 GPA7,GPB1 连接到 GPA6,依此类推,GPB7 连接到 GPA0。所以,当作为输入读取时,输出位会反转。这是为了简化接线;位反转可以通过软件修正。
该程序未使用中断,因此可以将 INTA 和 INTB 引脚悬空。另外,别忘了将 Vdd(引脚 9)和 Vss(引脚 10)引脚分别连接到 +5 V(或 +3.3 V)和接地。
// Listing13-1.ino
//
// A simple program that demonstrates
// MCP23017 programming.
//
// This program writes a value to port B, reads
// a value from port A, and verifies that the
// value sent to port B was properly read on port A.
#include <Wire.h>
#define mcp23017 (0x20)
// MCP23017 registers:
#define IODIRA (0)
#define IOCON (0x0A)
#define GPPUA (0x0C)
#define GPIOA (0x12)
#define OLATB (0x15)
void setup( void )
{
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "Test reading and writing MCP23017" );
Wire.begin(); // Initialize I2C library
// Initialize the MCP23017:
//
// - Sequential port A/B registers (BANK = 0)
// - Don't mirror INT pins (MIRROR = 0)
// - Autoincrement register numbers (SEQOP = 0)
// - Slew rate control on (DISSLW = 0)
// - ODR in open-drain mode (ODR = 1)
// - Interrupt polarity is active low (INTP = 0)
#define initIOCON (4) // ODR = 1
Wire.beginTransmission( mcp23017 );
Wire.write( IOCON );
Wire.write( initIOCON );
Wire.endTransmission(); // Sends stop condition
// Set port A to input, port B to output,
// and polarity noninverting.
Wire.beginTransmission( mcp23017 );
Wire.write( IODIRA );
Wire.write( 0xff ); // Port A = inputs
Wire.write( 0 ); // Port B = outputs
Wire.write( 0 ); // Port A noninverting
Wire.endTransmission(); // Sends stop condition
// Disable pullup resistors on port A.
Wire.beginTransmission( mcp23017 );
Wire.write( GPPUA );
Wire.write( 0 ); // Port A = no pullups
Wire.endTransmission(); // Sends stop condition
}
void loop( void )
{
static byte outputValue = 0;
static byte expectedValue = 0;
++outputValue;
// You simplified the wiring and connected
// GPB0 to GPA7, GPB1 to GPA6, ..., GPB7 to GPA0.
// So you need to reverse the bits in the
// expected value.
expectedValue = ((outputValue & 0x01) << 7)
| ((outputValue & 0x02) << 5)
| ((outputValue & 0x04) << 3)
| ((outputValue & 0x08) << 1)
| ((outputValue & 0x10) >> 1)
| ((outputValue & 0x20) >> 3)
| ((outputValue & 0x40) >> 5)
| ((outputValue & 0x80) >> 7);
// Write the byte to the output (port B).
Wire.beginTransmission( mcp23017 );
Wire.write( OLATB );
Wire.write( outputValue );
Wire.endTransmission(); // Sends stop condition
// Read a byte from the input (port A).
Wire.beginTransmission( mcp23017 );
Wire.write( GPIOA ); // Send register address
Wire.endTransmission( false ); // No stop condition
Wire.requestFrom( mcp23017, 1 ); // Read from portA
while( !Wire.available() ){} // Wait for byte
byte b = Wire.read(); // Get input byte
if( b != expectedValue)
{
Serial.print
(
"Error writing and reading MCP23017, value=0x"
);
Serial.print( b, 16 );
Serial.print( ", output 0x" );
Serial.print( outputValue, 16 );
Serial.print( ", expected 0x" );
Serial.println( expectedValue, 16 );
}
else
{
static uint32_t count = 0;
if( ++count & 0x3f )
Serial.print( "." );
else
Serial.println( "." );
}
}
setup() 函数根据此示例程序的需要初始化 MCP23017 设备。最重要的是,它将端口 A 引脚初始化为输入,端口 B 引脚初始化为输出。它还禁用了端口 A 输入引脚的上拉电阻,因为端口 B 直接连接到端口 A,且端口 B 的引脚提供 TTL(5 V)信号。
loop() 函数简单地将一系列字节值写入端口 B,从端口 A 读取一个字节值,并验证读取的值是否与写入的值(由于接线原因,进行位反转)相等。如果两个值不匹配,该函数会向串口输出错误信息。
13.5 MCP230xx 上的中断
大多数使用 MCP230xx 的 Arduino 编程人员并不使用这些设备上的中断功能。通常轮询设备,查看是否有输入位发生变化,比创建中断服务例程(ISR)并编程中断要少做些工作。如果你编程的系统能够在不影响其他活动性能的情况下以足够高的频率轮询 MCP230xx,那么轮询是一种合理的方法。然而,如果实时性问题使得轮询不可行,那么 MCP230xx 上的中断功能可以是一个救命稻草。
13.5.1 MCP230xx 上的中断操作
清单 13-1 中的程序持续进行数据的读写。因为这个程序既负责写入数据(到端口 B),也负责读取数据(从端口 A),所以应用程序始终知道何时端口 A 上的数据将可用(具体来说,数据会在 loop() 函数将数据写入端口 B 后立即可用)。在大多数实际系统中,输入数据通常来自一些外部硬件,loop() 函数本身无法知道端口 A 上何时有新数据到达。一种解决方案是让 loop() 函数持续读取端口 A,并将读取的值与之前的读取值进行比较。当两个值不同时,函数可以假设外部硬件已经传输了新值,并适当处理它。这种方案称为 轮询。
轮询的一个问题是,即使外部硬件没有传输新值,它仍然会消耗 CPU 时间(用于读取和比较端口 B 的值)。在轮询过程中,CPU 不能用于其他操作。更好的解决方案是让外部硬件通知 CPU 新数据可用;此通知将中断当前 CPU 的活动,使其能够简短地处理已更改的数据,然后在处理完新数据后恢复中断的操作。问题是,如何让外部硬件在将新数据应用到端口 B 时中断 CPU?
你可以编程 MCP230xx 设备,在 MCP23008 的 INT 引脚或 MCP23017 的 INTA 或 INTB 引脚上产生信号,表示状态变化发生。当发生状态变化时,称为 变化中断(IOC),并有两种可编程的情况来表示:
-
引脚状态发生变化(从低到高或从高到低)。
-
当与 DEFVAL 寄存器中的相应位进行比较时,某个引脚会发生状态变化。
INTx 引脚反映当前的中断状态。通常,你会将 INTx 引脚连接到单板计算机(SBC)上的中断输入引脚。不同的 SBC 支持不同引脚上的中断。当然,如果你使用的是不同的 SBC 或不同的实时操作系统(RTOS),你需要查看 SBC 或 RTOS 的文档,以确定哪些引脚适合用作中断输入。本节假设你使用的是 Arduino 库;如果使用不同的系统,请参阅你所使用的 SBC 或 RTOS 文档。
13.5.2 中断服务例程
当中断导致 CPU 停止当前程序执行时,它会将控制权转移到一个特殊的功能:ISR。ISR 会快速处理必要的硬件事件,然后将控制权返回给系统,系统会恢复原来的中断代码。为了支持应用程序中的 ISR,你需要解决一些问题。答案会根据系统有所不同;以下部分将提供 Arduino 系统的解答。
电子信号是如何以及在哪里输入到系统中的?
- 在大多数系统中,包括 Arduino 中,中断是输入到特定 CPU 或系统引脚的数字逻辑信号。在 Arduino 上,选定的数字 I/O 引脚可以作为中断输入。(有关 Arduino 品牌引脚选择的更多信息,请参见“更多信息”。)例如,Arduino Uno Rev3 支持在数字 I/O 引脚 2 和 3 上触发中断,而 Teensy 3.1 支持在任何数字 I/O 引脚上触发中断。并非所有 Arduino 设备都支持引脚 2 作为中断引脚;使用其他设备时请检查文档。
中断输入信号的类型是什么?
-
因为数字 I/O 信号可以是低(0)或高(1),你可能会认为中断只会在这两种情况下发生。实际上,大多数 Arduino 系统会在以下情况之一下触发中断:
-
中断引脚上的低电平到高电平的过渡
-
引脚上的低电平到高电平的过渡
-
中断引脚上的任何变化(低电平到高电平或高电平到低电平)
-
中断引脚上的低电平信号
一些但不是所有的 Arduino 设备也可以在中断引脚为高电平时触发中断。
-
如何指定 ISR 函数?
-
Arduino 系统使用
attachInterrupt()函数将特定的数字 I/O 引脚与中断关联。调用的形式如下:attachInterrupt( digitalPinToInterrupt( `pin` ), `ISR`, `mode` );在此调用中,
pin是数字 I/O 引脚编号,ISR是一个没有参数的空函数的名称,用作中断服务程序,mode是以下标识符之一:-
LOW每当引脚为低电平时触发中断 -
CHANGE每当引脚的值发生变化时触发中断 -
RISING当引脚从低电平变为高电平时触发 -
FALLING当引脚从高电平变为低电平时触发 -
HIGH每当引脚为高电平时触发中断
只有一些兼容 Arduino 的板子支持
HIGH,因此请查看您板子的文档,确认它是否支持主动高电平中断信号(例如,Uno Rev3 不支持HIGH)。若要在其他系统上使用中断,请参阅您的库、操作系统或单板计算机(SBC)的文档。
-
ISR 函数的限制是什么?
-
大多数操作系统对 ISR 施加限制。通常,您应该假设以下几点:
-
ISR 修改的任何全局变量应声明为
volatile。 -
ISR 函数应该简短,执行时间应尽可能短。
-
许多系统不允许中断嵌套(即,不允许一个中断信号中断正在执行的 ISR)。
-
许多系统限制在 ISR 中可以调用的库函数类型。
请参考您特定操作系统的参考手册,以获取有关中断服务程序的更多信息。
例如,Arduino 库对 ISR 有一些额外的限制。特别是,不能在 ISR 中使用
delay()或millis()函数。有关更多信息,请参见“更多信息”中的 Arduino 中断文档链接。 -
INTx 引脚极性
-
当 MCP230xx 检测到会引发中断的引脚变化时,它会将 INTx 引脚设置为高或低。IOCON 寄存器中的 INTPOL 位(第 1 位)决定中断的极性。如果 INTPOL 为 1,则中断信号为高电平有效——即中断发生时,INTx 引脚会变为高电平。如果 INTPOL 为 0,则中断信号为低电平有效,中断发生时,INTx 引脚会变为低电平。
你可以通过使用 Arduino 的
attachInterrupt()mode参数或设置 MCP230xx 上的中断极性来选择合适的中断极性。然而,重要的是要确保通过mode指定的极性与通过 INTPOL 位指定的极性匹配。常见的约定是使用低电平触发中断,并将mode参数的值设置为LOW或FALLING。
13.5.3 中断引脚镜像(仅限 MCP23017)
MCP23017 提供两个独立的中断引脚,一个用于端口 A(INTA 引脚),一个用于端口 B(INTB 引脚)。如果端口 A 和端口 B 都能产生中断,这样可以快速确定中断来源,但需要 CPU 上有两个独立的中断引脚。如果你只想使用一个引脚作为 CPU 的中断线,并愿意使用一些软件来区分端口 A 和端口 B 的中断,你可以将 MCP23017 编程为将 INTA 和 INTB 引脚连接在一起,这样无论哪个端口发生中断,都会将信号发送到 INTA 和 INTB 引脚。
通过在 IOCON 寄存器中将 MIRROR 位(第 6 位)编程为 1 来实现这一点。相反,若将 MIRROR 位编程为 0(默认条件),则会将所有端口 A 的中断路由到 INTA,所有端口 B 的中断路由到 INTB。
13.5.4 开漏式 INTx 输出
IOCON 寄存器的第 2 位(ODR)控制 INTx 引脚的开漏接口。如果此位被编程为 1,则启用开漏输出;如果编程为 0(默认值),则启用 INTx 引脚的活跃逻辑输出。
开漏形式允许你将多个 MCP230xx 设备的 INT 引脚连接在一起。此模式需要在输出线上加上上拉电阻。在开漏模式下,中断信号会将 INTx 引脚拉低,这通常表示控制器上发生了中断。控制器必须轮询各个 MCP230xx 设备,以确定中断的来源。
活跃逻辑输出模式将逻辑信号直接输出到 INTx 引脚。在此模式下,INTx 引脚必须专门连接到控制器设备上的中断;你不能将中断引脚连接在一起,因为那样会产生电气故障。当你只有一个 MCP230xx 设备时,或需要为每个 MCP230xx 提供单独的中断(这样你就不必轮询设备来确定中断源)时,这种模式是最佳选择。
活动逻辑模式是默认的中断模式,这是 MCP230xx 设备中的设计缺陷。如果将多个设备的 INTx 引脚连接在一起,并且忘记将 ODR 位设置为开漏模式,可能会造成电气冲突,从而损坏 MCP230xx 部件。故事的教训是:始终正确编程 IOCON 中的 ODR 位!许多设计师会在 INTx 引脚上连接一个晶体管(如 2N7000 MOSFET),以强制设置为开漏(开集电极)模式,并在活动逻辑模式下编程 ODR 以驱动晶体管。这可以避免由于编程错误导致 MCP230xx 损坏的可能性。
13.5.5 在 MCP230xx 上启用中断
默认情况下,MCP230xx 部件不会生成任何中断;你必须显式启用中断,才能使 INTx 引脚变为活动状态。你可以通过 MCP23008 的 GPINTEN 和 MCP23017 的 GPINTENA 和 GPINTENB 寄存器来实现这一点。
MCP230xx 设备允许你按引脚逐个启用或禁用中断。每个 GPINTENx 寄存器中的位都对应一个 GPIO 引脚:GPINTENA 对应 GPIOA 引脚,GPINTENB 对应 MCP23017 的 GPIOB 引脚。如果 GPINTENx 中的某个位为 0,那么该特定 I/O 引脚的中断被禁用。如果该位为 1,那么启用该位的中断,并根据 INTCON 和 DEFVAL 寄存器中的位设置生成中断。
如果为某个特定的 I/O 引脚启用了中断,那么 INTCON 和 DEFVAL 寄存器允许你编程使 MCP230xx 在引脚变化或特定电平时生成中断。如果某个特定的 INTCON 位为 0,那么 MCP230xx 会在输入位变化时生成中断(也就是说,它会在从低到高或从高到低的转换时生成中断)。在这种情况下,MCP230xx 会忽略 DEFVAL 中相应的位。如果某个特定的 INTCON 位为 1,那么 MCP230xx 会在输入位与 DEFVAL 中相应位的值不同时时生成中断。这使得你可以创建电平感应中断。如果 DEFVAL 中相应的位为 0,那么 MCP230xx 会在输入引脚为高时生成中断;如果 DEFVAL 中的位为 1,那么 MCP230xx 会在输入引脚为低时生成中断。请注意,如果 INTCON 中相应的位为 0,或者 GPINTENx 中该位为 0,系统会忽略 DEFVAL 中的该位。
尽管在复杂系统中可以在程序执行过程中修改 GPINTENx、INTCONx 和 DEFVALx 寄存器,但通常你只在程序开始执行时初始化这些寄存器一次。为了防止竞争条件,应按照以下顺序初始化中断:
-
初始化 DEFVALx 寄存器(如果需要)。
-
初始化 INTCONx 寄存器(如果需要)。
-
读取 GPIO 引脚以清除任何现有的中断。
-
初始化 GPINTENx 寄存器(如果需要)。
-
使用
attachInterrupt()将 ISR 附加到特定引脚。
这样的顺序有助于防止由于输入引脚上的预先存在的条件或初始化过程中发生的条件变化而引起的无意中断。这个特定的顺序适用于 Arduino 系统,但其他系统也有类似的处理方式。
13.5.6 测试和清除中断
MCP230xx 上的 INTx 引脚仅表示在一个(或两个)端口上发生了中断。当系统调用 ISR 时,你无法得知是哪个引脚——如果多个引脚同时变化——导致了中断。为了确定中断的确切来源,你需要读取 MCP23008 的 INTF 或 MCP23017 的 INTFA 或 INTFB 寄存器。
INTFx 寄存器中的位表示哪个引脚导致了中断。当 ISR 开始执行时,它应该读取 INTFx 寄存器,其中某个位为 1 表示中断是由指定输入位的变化引起的。然后,ISR 可以读取相应的 GPIO 引脚,以确定中断发生时该引脚的状态。例如,如果启用了对任何变化的中断,读取 GPIO 引脚将告诉你是引脚的上升沿还是下降沿触发了中断。
理论上,CPU 会在中断引脚状态变化后几乎立即调用 ISR。实际上,ISR 的调用可能会在中断条件发生后略微延迟;例如,某些高优先级的代码(ISR)可能在禁用中断时执行。在这种情况下,ISR 只有在当前的高优先级代码重新启用中断后才会被调用。在此期间,I/O 引脚的状态可能发生变化。因此,到 ISR 读取输入引脚信号时,输入数据可能已经改变,ISR 读取到的数据可能是错误的(这是一种常见的竞争条件)。为了防止这种情况发生,MCP230xx 在中断发生时会“捕获”引脚的状态。MCP230xx 将此引脚快照存储在 MCP23008 的 INTCAP 或 MCP23017 的 INTCAPA 和 INTCAPB 寄存器中。因此,ISR 实际上应该读取适当的 INTCAPx 寄存器的内容,以确定导致中断的引脚状态。
通过读取 GPIO 数据(GPIOx 或 INTCAPx 端口),ISR 解冻了 INTCAPx。这为捕获下一个中断引脚设置做好准备。你应该在 ISR 中首先读取 INTCAPx 寄存器,因为如果有另一个中断等待发生且你首先读取 GPIOx 端口,可能会丢失捕获的信息。通常,在 ISR 中并不需要读取 GPIOx 寄存器——INTCAPx 寄存器通常提供所有所需的信息。
配置为“中断-按变化触发”(在 INTCONx寄存器中)的引脚,会在读取对应的 INTCAPx寄存器后,改变导致下一个中断的状态。例如,如果中断是因为引脚从高电平变为低电平发生的,那么新的中断条件(从低电平到高电平)在你通过读取 GPIOx或 INTCAPx清除中断之前不会激活。如果引脚实际上从高电平变为低电平,再从低电平变回高电平,然后再变回低电平,那么 MCP230xx只会在系统没有在这些电平变化之间清除中断时,发出一次中断信号。
配置为“中断-按电平触发”的引脚——即配置为根据 DEFVALx寄存器中出现的值触发中断的引脚——只要该电平条件存在于输入引脚上,就会持续生成中断信号。读取或写入 GPIOx或 INTCAPx寄存器并不会重置该中断状态,直到中断条件不再存在。
13.6 一个示例的中断驱动 MCP230xx
读取旋转编码器是一种常见的方式,用来展示在 MCP230xx设备上的中断编程。实际上,如果旋转编码器旋转得非常快,而 CPU 又经常忙于执行其他任务,比如显示读取的编码器值,那么很容易发生数据丢失。使用 ISR(中断服务例程)快速捕捉编码器数据并将其提供给主线程处理,可以消除数据丢失。本节提供了一些简单的库代码,演示了如何读取并显示带有红绿 LED 的 SparkFun 旋转编码器的数据(www.sparkfun.com/products/15140)。
SparkFun 旋转编码器外接板(www.sparkfun.com/products/11722)使得将该设备轻松地接入电路面包板,如图 13-12 所示。

图 13-12:SparkFun 旋转编码器外接板
SparkFun 旋转编码器有两个数字 I/O 引脚,标记为 A 和 B,用于指定旋转变化(见图 13-13)。你需要通过一个 10kΩ电阻将这些引脚接到+5 V,并将标记为 C 的引脚接到地(Gnd)。(如果你将旋转编码器连接到 MCP230xx设备,你可以使用内置的可编程上拉电阻替代 10kΩ电阻。) 当你旋转编码器轴时,它会选择性地将 A 和 B 引脚连接到 C 引脚,即接到地(Gnd)。图 13-13 中标记为 1、2、3 和 4 的引脚分别连接到红色和绿色 LED、一个按钮开关和这些引脚的公共端。
将 A 和 B 引脚连接到单板计算机(SBC)上的输入引脚,允许你读取这两个引脚的状态(高电平或低电平)。通过观察这两个引脚的状态变化,你可以判断旋转轴的旋转方向,以及旋转速度(如果你在计时变化的话)。

图 13-13:SparkFun 红/绿旋转编码器
如果以固定速度顺时针旋转轴(CW),你将会在引脚 A 和 B 上看到图 13-14 中的波形。

图 13-14:旋转轴顺时针旋转时的旋转编码器输出
图 13-14 中的输出被称为正交输出,其中两个相位相反的信号决定旋转方向,生成类似于表 13-5 中所示的二进制输出,随着时间的推移变化。
表 13-5:顺时针旋转编码器输出
| 输出 A | 0 | 0 | 1 | 1 | 0 | 0 | 1 | 依此类推 |
|---|---|---|---|---|---|---|---|---|
| 输出 B | 1 | 0 | 0 | 1 | 1 | 0 | 0 | 依此类推 |
如果将输入对视为一个 2 位二进制值,顺时针旋转编码器会产生重复的二进制序列 01 00 10 11 01 00 10 11 . . . 随时间变化。这是一个 2 位灰码的示例,一种二进制计数序列,其中任何两个连续值之间不会改变超过一位(详见“更多信息”)。灰码在处理多位机械输入时非常有用,因为它比常规二进制代码更能抗干扰。
如果以固定速度逆时针旋转轴(CCW),你将会在引脚 A 和 B 上看到图 13-15 中的波形。

图 13-15:旋转轴逆时针旋转时的旋转编码器输出
这也会产生类似于表 13-6 中所示的输出,随着时间的推移变化。
表 13-6:逆时针旋转编码器输出
| 输出 A | 0 | 0 | 1 | 1 | 0 | 0 | 1 | 1 | 0 | 依此类推 |
|---|---|---|---|---|---|---|---|---|---|---|
| 输出 B | 0 | 1 | 1 | 0 | 0 | 1 | 1 | 0 | 0 | 依此类推 |
灰码输出为二进制序列:00 01 11 10 00 01 . . .
另一种解读 A 和 B 输入的方法是使用 A 的变化来指示编码器轴的旋转,然后读取 B 输入来确定旋转方向:
-
在 A 引脚上从高到低的跳变且 B 引脚低时,表示逆时针旋转。
-
在 A 引脚上从低到高的跳变且 B 引脚高时,也表示逆时针旋转。
-
在 A 引脚上从高到低的跳变且 B 引脚高时,表示顺时针旋转。
-
在 A 引脚上从低到高的跳变且 B 引脚低时,也表示顺时针旋转。
MCP23008 GPIO 扩展器允许您将最多四个 SparkFun 发光红/绿旋转编码器连接到系统。然而,本节中使用的编码器只将一个旋转编码器连接到 MCP23008 的 GP0 和 GP1 引脚。具体来说,编码器的 A 引脚连接到 GP1,B 引脚通过可选的去抖电路连接到 GP0。如果您还想控制编码器上的红绿 LED,您可以在 MCP23008 的引脚上添加一些电阻,以将其配置为输出,并与旋转编码器上的 2 和 3 引脚连接。您还需要将标有 1 的引脚连接到地。电阻值通常应在 470 Ω 到 1 kΩ 之间。您可以通过将这些输出引脚编程为逻辑 1 或 0 来打开或关闭红绿 LED;同时编程两个引脚将产生黄色输出。本节中的示例忽略了 LED 输出,但如果您希望控制 LED,可以轻松添加代码。
旋转编码器的 A 和 B 引脚是干触点。为了从这些引脚读取逻辑信号,通常需要在电路中添加上拉电阻(接 +5 V 或 +3.3 V,根据需要选择)。但不必显式添加这些电阻,软件将启用 GP0 和 GP1 引脚使用 MCP23008 可编程引脚的上拉电阻。
列表 13-2 中的程序使用 A 引脚的变化来触发中断。ISR 将读取 GP1(A)和 GP0(B)上的 A 和 B 引脚值,并根据这些引脚的前后读取值来递增或递减全局计数器。主程序(即 ISR 之外的代码)使用这个全局变量的值来确定自应用程序开始执行以来,编码器已顺时针或逆时针旋转了多少“点击”。
对于 列表 13-2 中的程序,MCP23008 的 SCL 和 SDA 引脚(引脚 1 和 2)连接到 Teensy 3.2 的 SDA0 和 SCL0 引脚(引脚 D18 和 D19)。INT 引脚(引脚 8)连接到 Teensy 的 D2 引脚。RESET(引脚 6)和 Vdd(引脚 18)引脚连接到 +3.3 V。地址引脚和 Vss(引脚 3、4、5 和 9)连接到地。最后,GP1 连接到旋转编码器的 A 引脚,GP0 连接到旋转编码器的 B 引脚(旋转编码器的 C 引脚连接到地)。
列表 13-2 程序将 MCP23008 配置为每当 GPA1 引脚发生变化时生成一个中断(低电平有效)。当 ISR 读取 INTCAP 寄存器时,GP1 将反映 A 引脚在上升或下降沿转换后的值。因此,如果 ISR 读取 INTCAP 的 LO 2 位为 0,则表示发生了下降沿(GP1 为 0),且 B 为 0。这表示逆时针旋转。同样,如果 INTCAP 的 LO 2 位为 3(0b11),则表示发生了上升沿,且 B 为 1,这也表示顺时针旋转。
在顺时针旋转时,如果 ISR 在 INTCAP 的低 2 位读取到 1,这表示 A 引脚有下降沿,而 B 引脚为 1。如果 ISR 在 INTCAP 的低 2 位读取到 2(0b10),这表示 A 引脚有上升沿而 B 引脚为低(同样是顺时针旋转)。
中断服务程序(ISR)只需在顺时针(CW)或逆时针(CCW)旋转时分别增减一个全局变量(rotaryPosn)。主程序检查这个全局变量,并在其值发生变化时显示该值。
// Listing13-2.ino
//
// Demonstrate reading a rotary encoder
// using an MCP23008 with interrupts.
//
// Assumptions:
//
// - MCP23008 INT line connected to
// digital I/O line 2.
//
// - SparkFun illuminated R/G rotary
// encoder output A connected to
// GP1 on MCP23008.
//
// - SparkFun illuminated R/G rotary
// encoder output B connected to
// GP0 on MCP23008.
//
// - MCP23008 wired to use address
// 0x20 (A0, A1, A2 = 0, 0 0).
#include <Wire.h>
#define led (13)
#define mcp23008 (0x20)
#define IODIR (0)
#define IOPOL (1)
#define GPINTEN (2)
#define DEFVAL (3)
#define INTCON (4)
#define IOCON (5)
#define GPPU (6)
#define INTF (7)
#define INTCAP (8)
#define GPIO (9)
#define OLAT (10)
// The following variable tracks
// rotations on the rotary encoder.
// This variable is negative if there
// have been more clockwise rotations
// than counterclockwise (likewise,
// it's positive if there have been
// more counterclockwise rotations).
volatile int rotaryPosn = 0;
// Write a value to an MCP23008 register:
❶ void writeReg( int reg, int val )
{
Wire.beginTransmission( mcp23008 );
Wire.write( reg );
Wire.write( val );
Wire.endTransmission();
}
// Read a value from an MCP23008 register:
❷ int readReg( int reg )
{
Wire.beginTransmission( mcp23008 );
Wire.write( reg );
Wire.endTransmission( false );
Wire.requestFrom( mcp23008, 1 );
return Wire.read();
}
// Reset the MCP23008 to a known state:
❸ void mcpReset( void )
{
// I2C General Call is not mentioned in
// the manual so do this the hard way.
// INTF is read only.
// INTCAP is read only.
// Disable interrupts
// and clear any pending.
writeReg( GPINTEN, 0 );
readReg( INTCAP );
// Set remaining registers
// to POR/RST values.
writeReg( IODIR, 0xFF );
writeReg( IOPOL, 0 );
writeReg( GPINTEN, 0 );
writeReg( DEFVAL, 0 );
writeReg( INTCON, 0 );
writeReg( IOCON, 0 );
writeReg( GPPU, 0 );
writeReg( GPIO, 0 );
writeReg( OLAT, 0 );
}
// Interrupt service routine that gets
// called whenever the INT pin on the
// MCP23008 goes from high to low.
// This function needs to be *fast*.
// That means minimizing the number
// of I2C transactions.
❹ void ISRFunc( void )
{
// Read the INTCAP register.
// This reads the GPIO pins at
// the time of the interrupt and
// also clears the interrupt flags.
//
// Note: A rotary encoder input in GPA1,
// B rotary encoder input in GPA0.
int cur = readReg( INTCAP ) & 0x3;
// You have CCW rotation if:
//
// A:0->1 && B==1 (cur=3)
// or A:1->0 && B==0 (cur=0)
if( cur == 0 || cur == 3 )
{
++rotaryPosn;
}
else if( cur == 1 || cur == 2 )
{
--rotaryPosn; // CW rotation
}
// else illegal reading . . .
}
// Usual Arduino initialization code:
❺ void setup( void )
{
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "Rotary Encoder test" );
pinMode( 0, INPUT_PULLUP );
pinMode( 1, INPUT_PULLUP );
pinMode( 2, INPUT );
pinMode( led, OUTPUT );
digitalWrite( led, 0 );
Wire.begin();
// Reset the MCP23008 to a known state.
mcpReset();
// Initialize the MCP23008 (this is the default
// state, so just defensive coding). Note that
// SEQOP = 0 is autoincrementing registers and
// INTPOL = 0 yields active low interrupts.
writeReg( IOCON, 0 );
// Set data direction to input and
// turn pullups on for GPA0/GPA1.
// Set polarity to inverting for GPA0/GPA1.
writeReg( IODIR, 0xff );
writeReg( IOPOL, 3 );
writeReg( GPPU, 3 );
// Initialize MCP23008 interrupts.
writeReg( INTCON, 0 ); // GPA1 int on change
writeReg( GPINTEN, 0x2 ); // Enable GPA1 interrupt
attachInterrupt( digitalPinToInterrupt(2), ISRFunc, FALLING );
interrupts(); // Ensure CPU interrupts enabled
}
// Main Arduino loop:
❻ void loop( void )
{
static int lastRP = 0;
if( rotaryPosn != lastRP )
{
Serial.print( "Posn=" ); Serial.println( rotaryPosn );
lastRP = rotaryPosn;
}
}
示例 13-2 包含了两个函数,writeReg() ❶ 和 readReg() ❷,用于向 MCP23008 寄存器写入和读取数据。这些函数通过传输适当的 I²C 字节来完成此操作。mcpReset() ❸函数将 MCP23008 初始化为上电复位状态,这在代码在不关闭 MCP23008 电源的情况下重新运行时非常有用。代码还包含了一个中断服务例程ISRFunc() ❹,以及常见的 Arduino setup() 5 和 loop() 6 函数。
当 GP1 引脚发生变化时(因为轴已旋转),会触发中断,导致系统调用ISRFunc()函数。该函数判断轴是顺时针还是逆时针旋转,并相应地调整rotaryPosn变量的值。
以下是程序在示例 13-2 中的一些输出示例,这些输出是通过来回旋转编码器旋钮获得的。
Posn=0
Posn=-1
Posn=-2
Posn=-3
Posn=-4
Posn=-5
Posn=-6
Posn=-7
Posn=-8
Posn=-7
Posn=-6
Posn=-5
Posn=-4
Posn=-3
Posn=-2
Posn=-1
Posn=0
Posn=1
Posn=2
Posn=3
Posn=4
从输出结果可以看出,轴在短时间内逆时针旋转,导致输出值减少,随后又顺时针旋转一段时间,导致输出值增加。
13.7 MCP230**xx库代码
由于复杂性,许多程序员更倾向于调用现有的库代码,而不是直接编写硬件操作代码。Arduino 提供了一个 MCP230xx库包来帮助你完成这项工作。详情请参见“更多信息”中的相关库链接。
如果你更倾向于在树莓派上编程 MCP230xx,Adafruit 也已将其库移植到了 Pi 上;详情请参见“更多信息”。同样,这些链接也描述了如何为 Mbed 找到 MCP23008 库。快速的网络搜索会找到多个适用于 MCP23017 的 Mbed 库示例(大多是 Adafruit 库的改编版)。你还可以轻松搜索到遍布互联网的 MCP230xx代码示例,适用于各种 CPU、操作系统和编程语言。
13.8 I²C 性能
尽管数字 I/O 功能,例如读取按钮,不需要极高的性能,但许多此类活动确实需要快速处理,以正确读取或写入高频数字信号。不幸的是,高性能操作与 I²C 总线连接往往是相互排斥的。由于有时通过 MCP230xx 设备读取或写入数字数据需要进行三到四次总线事务,这可能导致单次 I/O 操作在 100 kHz 时需要 400 微秒到 500 微秒,得到 2 kHz 的采样率。这对于某些操作来说可能太慢了。
幸运的是,MCP230xx 设备可以在 400 kHz、1.7 MHz 以及 100 kHz 下运行。在 400 kHz 时,你可以实现至少 8 kHz 的采样率,通过仔细编写代码,10 kHz 到 20 kHz 也不是不可能。如果你愿意以 1 MHz 或更高的频率操作总线,你甚至可以将采样率提高到大约 100 kHz,这对于大多数应用是合适的。如果你需要更高的性能,可能需要使用 I²C 总线以外的其他方式,比如 MCP230xx 的 SPI 版本。
13.9 MCP23Sxx 部件
在查找关于 MCP23008 和 MCP23017 部件的信息时,你可能会遇到对 MCP23S08 和 MCP23S17 设备的引用。这些 GPIO 扩展器与 MCP230xx 部件几乎相同,唯一的区别是它们是为在 SPI 总线上使用而非 I²C 总线上使用设计的。大部分为 MCP23Sxx 部件编写的代码,在 MCP230xx 设备上也能正常工作,只需要最少的修改:基本上,你只需要将代码中的 SPI 库函数替换为 I²C 库函数,以便读写设备的数据。
由于 SPI 总线的工作频率高于 I²C 总线,SPI 变体可以以更高的采样频率读取输入数据。欲了解更多信息,请参阅 MCP23xxx 文档(该文档涵盖了 SPI 和 I²C 部件)。相关链接见“更多信息”部分。
13.10 本章总结
本章介绍了 MCP23017 和 MCP23008 GPIO 扩展 IC。内容涵盖了芯片上的寄存器,包括数据寄存器、数据方向寄存器、上拉寄存器和极性寄存器。它描述了如何通过 MCP230xx 的引脚读取和写入数字数据。本章还讨论了使用 MCP230xx 设备的中断驱动 I/O,提供了一个简短的示例程序,使用 ISR 读取 SparkFun 旋转编码器,以帮助避免错过编码器的任何脉冲。最后,本章结束时讨论了为 MCP230xx 提供的开源软件库的使用。
第十四章:ADS1015 和 ADS1115 模拟到数字转换器

尽管数字 I/O 可能是嵌入式计算机系统中最常见的输入/输出形式,但模拟输入也很流行。将现实世界中的模拟信号(通常是 0 V 到 3.3 V 或 5 V 范围内的电压)转换为数字形式,使应用程序能够使用现实世界的连续测量,而不是来自数字输入设备的简单开/关信号。要进行这种转换,你需要一个 ADC。
市面上有许多种类型的 ADC。绝大多数将输入电压转换为 n 位数字,尽管一些将其他物理量转换为数字形式。大多数现实世界中的传感器输出的是电压,而不是直接输出数字。因此,你需要使用 ADC 将电压值转换为数字值。因此,要读取常见的传感器输出,ADC 是你工具箱中的一个关键部件。
本章讨论了两款流行的 ADC:ADS1015 和 ADS1115。这两款设备将 0.0 V 到某个上限(如 4.095 V)之间的电压转换为数字形式:ADS1015 产生 12 位结果,而 ADS1115 产生 16 位结果。ADS1015 更快,支持每秒 3,300 次采样,而 ADS1115 为每秒 860 次采样(这是一个经典的速度与位数之间的权衡)。虽然其他 I²C ADC 也有,但 ADS1x15 系列设备很受欢迎,因为 Adafruit 创建了一对支持这两个 IC 的扩展板。最初的 Adafruit 设计是开源硬件,随着时间的推移,低成本的这些板的克隆版在亚马逊等地方出现。这些设备容易与任何具有 I²C 总线的系统进行接口。例如,Adafruit 提供了适用于 Arduino 和 Raspberry Pi 系统的库代码,尽管将这些设备与任何 RTOS 或系统接口也是足够容易的。然而,本书的目的是教你如何直接编程 I²C 外设,因此下一节将提供有关 ADS1x15 所需的背景信息,以便你能够编程。
14.1 模拟到数字转换器规格
ADC 有几个重要的规格,影响其使用。表 14-1 列出了其中一些常见的规格。
表 14-1:典型的 ADC 特性
| 特性 | 单位 | 描述 |
|---|---|---|
| 分辨率 | 位 | 转换后产生的整数值的大小 |
| 通道 | 整数 | 设备上可用的独立转换器数量 |
| 极性 | 单极性或双极性 | 确定一个 ADC 是否支持负电压(双极性或差分)^(*),或仅支持非负电压(单极性) |
| 范围 | 伏特 | ADC 输入支持的电压范围 |
| 输入类型 | 差分或单端 | 指定输入是差分的还是单端的(共地) |
| 采样频率 | Hz | ADC 每秒能够读取的次数(也称为每秒样本数或sps) |
| ^()ADS1x*15 设备不接受实际的负电压;它们接受的是差分输入,其中两个正电压输入之间的差异为负值。请参阅本章后面 14.1.5 节“差分模式与单端模式”的差分输入讨论。 |
以下小节将描述这些项目,并讨论实际的 ADC1x15 规格。
14.1.1 模拟到数字转换器分辨率
ADC 的分辨率是 ADC 为一次转换产生的位数。低端 ADC 具有 8 位或 10 位分辨率(典型的 Arduino 类 SBC 的模拟引脚通常为 10 位),而中档 ADC 通常具有 12 位、14 位或 16 位分辨率。高端 ADC 支持 20 位或 24 位分辨率。对于差分输入,ADS1015 设备提供 12 位分辨率,而 ADC1115 提供 16 位分辨率。对于这两种设备的单端应用,您会失去一位(有关差分模式与单端模式的更多详细信息,请参阅 14.1.5 节“差分模式与单端模式”)。
分辨率决定了 ADC 能够检测到的两个电压之间的最小差异。例如,在全尺度模式下,ADS1015 和 ADS1115 ADC 支持 0 V 到 4.095 V 范围内的电压。设备的分辨率将这个范围除以最大值。12 位分辨率将输入范围划分为 4,096 个步进,因此理论上,ADS1015 能够分辨出 0.001 V 的差异。ADS1115 作为 16 位转换器,将输入范围分成 65,536 个步进,理论上能够分辨出 0.0000625 V 的差异——明显更好。单端输入分别具有 11 位和 15 位分辨率,产生 0.002 V 和 0.000125 V 的步进。
一般来说,在 ADC 分辨率方面,更高的分辨率更好。分辨率越高,读数越准确。然而,额外的分辨率是有代价的:在其他条件相同的情况下,高分辨率的 ADC 通常比低分辨率的 ADC 更贵且更慢。使用高分辨率的 ADC 也并不保证能够获得更精确的读数。系统噪声和其他效应可能使额外的分辨率在实际应用中变得毫无意义。对于大多数典型的 SBC 应用来说,12 位分辨率已经足够。0.0001 V 的噪声往往会完全掩盖 16 位 ADC 可能产生的小读数。
14.1.2 模拟到数字转换器通道数
许多 ADC 设备提供多个模拟输入。这允许您将多个模拟源连接到单个 ADC 设备,从而减少部件的数量和成本。例如,ADS1015 和 ADS1115 设备提供四个输入通道。ADS1013、ADS1113、ADS1014 和 ADS1114 设备提供较少的输入。
拥有多个输入通道并不意味着设备板上有多个 ADC。相反,大多数多通道 ADC 具有一个模拟多路复用器,这是一种在每次将单一输入连接到 ADC 时切换输入的开关。这是一个重要的区别:如果你有多个 ADC,它们可以同时并行地将输入从模拟转换为数字,但多路复用器在输入之间切换,每次只允许进行一次转换。ADS1015 和 ADS1115 使用内部多路复用器为单个 ADC 提供信号,因此它们必须依次进行每个通道的模拟到数字转换。
14.1.3 模拟到数字转换器极性
ADC 可以是单极性或双极性。单极性 ADC 只能将非负电压转换为数字形式。这通常出现在常见的单板计算机 (SBC) 上;例如,Arduino 类 SBC 通常将 0 V 到 3.3 V 或 5 V 范围内的电压转换为数字形式。单极性 ADC 无法处理负电压——实际上,负输入电压可能会损坏设备。双极性 ADC 可以处理正负输入电压。
双极性 ADC 通常可以编程为双极性或单极性工作模式。将双极性设备设置为单极性工作模式的原因是分辨率。能够处理正负电压会消耗一部分分辨率。例如,如果你将 16 位 ADC 设置为双极性模式,你将获得 15 位的负电压范围和 15 位的正电压范围。如果只输入正电压,你会失去一些分辨率。然而,如果你将设备编程为单极性模式,你将获得完整的 16 位分辨率,从而能够解析更小的正电压。
ADS1x15 设备不是传统的双极性 ADC。引脚输入电压必须始终相对于地面为正。有关 ADS1x15 如何处理负输入的详细信息,请参见 14.1.5 节,“差分模式与单端模式”。
14.1.4 模拟到数字转换器范围
ADC 范围 是 ADC 可以处理的最小和最大电压范围。例如,典型的 Arduino 类 ADC 可以处理 0 V 到 3.3 V 或 0 V 到 5 V 的范围。其他常见的 ADC 可以处理 3.3 V、4.095 V、5 V 或 10 V 的范围。
一个给定的 ADC 可能有两种不同的范围规格。一种是 ADC 产生不同读数的电压跨度——例如,0 V 到 5 V。另一种范围是输入端可以接受的最大电压,而不会损坏设备,这对于一个典型的支持正常范围 0 V 到 5 V 的 ADC 来说,可能是 –0.5 V 到 5.5 V。通常来说,当讨论 ADC 的范围时,我指的是产生不同读数的输入范围。超出这个范围但仍在最大允许范围内的电压倾向于将其读数限制在最小值或最大值。
单极性设备几乎总是有一个从 0 V 到某个最大电压 n V 的范围。双极性设备通常有一个 ±n V 的范围(n 通常是某个值,例如 3.3 V、5 V 或 10 V)。
ADS1015 和 ADS1115 在单端模式下为单极性,在差分模式下为双极性(关于差分与单端操作的讨论请参见下一节)。它们具有可编程增益阶段,将范围限制为±5 V(实际上是 6.144 V,但引脚限制为 5 V 输入)、±4.095 V、±2.047 V、±1.023 V、±0.511 V 或±0.255 V,适用于差分/双极模式。对于单端/单极模式,将范围缩小一半,最低电压为 0 V。
14.1.5 差分模式与单端模式
ADC 通常在两种模式之一下工作:差分模式或单端模式。单端输入更容易布线,结构简单,并且与几乎所有电压源兼容。差分输入可能需要特殊的差分驱动电压源,因此使用起来更为复杂。
在单端模式下,ADC 具有一个带有公共接地的单一输入(如果 ADC 支持多个输入通道,则所有通道共用接地)。大多数 Arduino 类的模拟输入都属于这一类别,转换测量的是 An模拟输入引脚与所有模拟输入共用的模拟接地引脚之间的电压。
在差分模式下,ADC 转换接受两个输入,通常标记为+和–,并计算这些输入电压之间的差值。然后,它将该差值转换为数字形式。差分输入通常来自差分线路驱动器,这些驱动器通常将单端电压的一半输出到+输出,将其负一半输出到–输出。测量+线和–线之间的电压可以得到原始输入电压。
使用差分输入的主要优点是它们能够减少系统引入的噪声。系统中的电压尖峰(噪声)通常会叠加到输入信号线上,比如 ADC 的输入。在单端输入中,这种噪声尖峰可能会导致输入电压的暂时升高或降低,从而导致 ADC 读取的瞬时偏差。该噪声尖峰会在一组差分输入上产生相同的暂时电压变化。然而,这种尖峰对两个差分输入的增加或减少大致相同。当差分输入计算两个输入之间的差值时,任何加到或从两个差分输入中减去的值都会在转换中被消除,因为加到+线上的值与加到–线上的值相匹配。这意味着差分输入比单端输入噪声小得多,因此在使用高分辨率(16 位或更高)ADC 时,应该尽可能使用差分输入。
差分输入存在两个问题。首先,大多数支持差分输入的多通道 ADC 使用一个通道作为正输入,另一个通道作为负输出。因此,使用差分输入会将可用的 ADC 通道数量减少一半。差分输入还需要额外的费用和复杂性来使用专用的差分驱动电路,将标准电压(单端输入)转换为差分形式。
ADS1x15 设备可以编程以单端模式或差分模式运行。差分模式为每个差分输入使用两个输入通道,因此每个设备有两个差分输入(而不是四个单端输入通道)。尽管如此,如果你使用的是具有 16 位精度的 ADS1115,最好将其设置为差分模式,以充分利用额外的精度,避免在读取时产生低位噪声。
ADS1x15 设备不允许输入引脚上有负电压,这会影响它们的差分性能。如果你的差分线路驱动器将单端输入电压转换为正负电压对,那么负电压可能会损坏 ADS1x15。相反,假设你使用的是 0 V 到 4.095 V 的范围,当你希望得到零读数时,必须设计一个差分驱动电路,在两个引脚上输出 2.047 V。它将正引脚驱动到高于 2.047 V,负引脚低于 2.047 V 以获取正读数;对于负读数,则将正引脚驱动到低于 2.047 V,负引脚高于 2.047 V。当一个引脚为 0 V 另一个引脚为 4.095 V 时,ADS1x15 会产生最大(正或负)读数。如果你使用的是 ADS1x15 上的其他电压范围,则在此描述中将最大电压的一半替换为 2.047 V,最大电压替换为 4.095 V。
还需注意,0 V 到 4.095 V 的范围只产生 15 位结果(0 V 到 32,767 V)。尽管 ADS1115 仅允许正电压输入,它仍然是一个双极性设备,并生成 16 位有符号整数转换(–32,768 到 +32,767)。当负输入的电压高于正输入时,ADS1115 会产生负输出。例如,如果正输入为 0 V,负输入为 4.095 V,则 ADS1115 产生 –32,768 的转换结果。
14.1.6 采样频率
模数转换不是一个瞬时过程。一些 ADC 相对较慢,而其他一些则要快得多(同时也更昂贵)。不同的 ADC 还具有不同的采样频率,即每秒钟读取的次数。
ADC 的采样频率直接影响其适用性。ADS1x15 设备并不特别快。ADS1115 的最大采样率为每秒 860 次(sps);而 ADS1015 稍微更快,最大为 3,300 次每秒。著名的 奈奎斯特定理 说明,为了生成合理的数字波形,必须至少以模拟信号的最高频率的两倍进行采样。这意味着 ADS1115 可以对最多 430 Hz 的波形进行数字化,而 ADS1015 可以捕捉到 1,650 Hz 的波形。这些设备绝对无法用于捕捉数字音频,因为数字音频需要至少 40 kHz 的采样率来捕捉 20 Hz 到 20,000 Hz 之间的频率。然而,ADS1x15 设备非常适合捕捉变化缓慢的信号,例如来自人工控制的电位计、热电偶、热阻温度探测器(RTD)、电源测量、光传感电路等信号。
在某些方面,ADS1x115 的较慢采样速度并不是一个主要问题——I²C 总线数据传输本身限制了转换的速度。然而,这些较慢的转换速度也会影响整体应用性能,特别是在使用 Arduino 单线程代码时。
14.1.7 杂项 ADS1x15 特性
ADS1x15 设备具有以下独特的内建功能:
-
I²C 总线速度 ADS1x15 设备完全支持标准速度模式(100 kHz)、快速模式(400 kHz)和高速模式(最高 3.4 MHz)。
-
通用调用支持 ADS1x15 设备支持 I²C 通用调用复位命令,其中第一个字节为 00h,第二个字节为 06h。
-
可编程增益放大器 ADS1x15 设备配备可编程增益放大器(PGA),可以将增益设置为六个不同的级别之一。增益通过配置寄存器中的 3 位来选择,具体值见第 14.3.2.3 节 “可编程增益放大器控制位”中的表 14-5。有关如何编程配置寄存器增益设置的信息,请参阅第 14.3 节 “ADS1x15 模数转换器寄存器”。
-
可编程比较器 ADS1x15 设备提供两个 16 位比较器寄存器,这些寄存器会自动将当前转换值与低阈值和高阈值进行比较。当激活时,IC 会在转换值低于低阈值或高于高阈值时触发 ALRT 引脚(“窗口模式”),或者当转换值超过高阈值时触发(“传统模式”)。
-
连续(单次)模式 ADS1x15 设备可以被编程为不断执行 ADC 转换,或者以 单次模式 运行,仅在接收到 I²C 总线上的命令时执行一次转换。
14.2 模拟信号调理
ADC 的输入范围通常与实际世界中获得的信号不同。0 V 到 5 V 的范围很常见,既作为 ADC 的输入,也作为现实世界中的单一电压。然而,ADC 范围和输入信号往往不匹配。以 ADS1x15 系列 ADC 为例,在其全分辨率模式下,它们仅支持 0 V 到 4.095 V 的输入范围,这在实际生活中很少见。
工业设备输出的最常见电压范围可能是 0 V 到 5 V、±5 V、0 V 到 10 V 或±10 V。虽然一些 ADC,如 LTC1859 系列,支持这些范围,但你通常需要将你已有的信号转换为 ADC 可以接受的信号。这——以及将电流转换为电压等其他活动——被称为模拟调理(或信号调理)。
图 14-1 显示了一个运算放大器电路的原理图,提供两个功能:信号放大或减小,以及从单端输入到差分输出的转换。图的上半部分(运放连接在 1、2、3 引脚以及 5、6、7 引脚)是一个放大电路,将±10 V 范围内的电压转换为±10 V 范围内的其他电压。两个电位器,ZERO 和 SPAN,分别控制放大器的偏移和增益。ZERO(偏移)电位器为输入添加一个-10 V 到+10 V 范围的电压,而 SPAN(增益)电位器将增益调整为大约 0.05 到 20 之间,输出限制在±10 V 范围内。

图 14-1:ADS1x115 的放大器和差分驱动器
电路的下半部分将来自放大阶段的输出转换为差分信号。这个差分驱动器中的上部运算放大器是一个增益为 1 的非反相放大器,简单地将其输入电压作为+差分输出输出。这个电路中的下部运算放大器是一个反相放大器,增益也是 1(0 dB)。如果 VREF 电位器设置为 0 V(中间位置),该放大器将反转输入。因此,该电路将产生来自上半部分电路的正输出作为+输出,来自上半部分电路的负输出作为-输出。总体而言,这个电路的增益是二倍(6 dB),因为+和-输出之间的差值实际上是上半部分电路输出的两倍。通过减少上半部分放大电路中的增益来校正这一点。
关于图 14-1 电路中元件质量的评论是有必要的,因为将低成本的元件放入该电路可能会产生不理想的结果。
首先,如果你关心长期稳定性和漂移问题,你应该使用仪器级的 OPA4227 或 OPA2227 运算放大器。这些虽然不便宜,但非常好。如果你不介意定期重新校准电路,你可以使用更便宜的(常见的)LM324 运算放大器。
在此电路中使用高精度、低温系数电阻还有助于确保长期稳定性和无漂移。特别是,10 千欧和 1 千欧电阻应为 0.1%、15 PPM/C 的电阻,我在批量购买时发现它们的价格不到 0.50 美元每个(截至本文撰写时)。20 千欧电位器应为 10%和 50 PPM/C 或更低;这些电位器价格不便宜,通常每个 15 到 20 美元。如果你不介意定期重新校准电路,你可以使用便宜的电阻和电位器—如果需要节省开支,使用低温系数电位器会更安全。
此电路中的两个 1 千欧电阻是可选的。它们的作用是防止当 SPAN 电位器接近一个端点时增益出现异常。像 4.7 千欧这样较大的电阻值将使增益调整更加平滑,但代价是增益范围较小。如果选择不同的电阻,请确保它们是低温系数(PPM/C)电阻。由于电位器的存在,精度并不是特别重要,但通常合理价格的低温系数电阻的精度为 0.1%。
电路中的 27 欧电阻也是可选的,它们仅用于防止在运算放大器输出端短路时造成完全失效。如果决定安装这些电阻,请使用 1%的金属膜电阻。390 欧电阻也可以使用 1%的金属膜电阻;它对电路的影响并不大。
最后,如果你使用的是 ADC 的单端输入,可以去掉电路的下半部分,并将 OPA4227 的 1 号引脚输出直接连接到 ADC。当然,考虑到成本问题,如果这样做,你可能需要用 OPA2227 双运算放大器替换 OPA4277 四运算放大器。
在使用此电路之前,你需要进行校准。以下步骤描述了针对 ADS1x15 ADC 设备的校准过程。如果使用不同的 ADC,你需要通过改变电路输出电压来修改此过程。在校准过程中请勿将此电路连接到 ADS1x15。 否则可能会损坏 ADC。
-
尝试将所有电位器大致放在中间位置。位置不必非常准确,但你希望电位器的刮刀远离端子两端。
-
在单端输入端施加 0 V 电压,并为电路提供电源(±12 V)。
-
测量 OPA4277 的 7 号引脚上的电压,并调整 ZERO 电位器,直到输出尽可能接近零。
-
现在,测量 OPA4277 的 1 号引脚上的电压,然后再次调整 ZERO 电位器,直到输出电压尽可能接近零。
-
将输入电压调节至你打算允许的最大电压(例如 5 V)。
-
测量 OPA4277 的 1 号引脚上的电压,调整 SPAN 电位器,直到输出尽可能接近 4.095 V。假设你将使用已编程为接受 0 V 到 4.095 V 的 ADS1x15。如果使用不同的 ADC 或已编程为不同范围的 ADS1x15,请相应调整此数值。
-
重复步骤 4 到 6,直到你不再需要调整 ZERO 或 SPAN 电位器。调整其中一个可能会影响另一个,因此重复此过程可以精细调整标定。
此时,你已经完成了放大器阶段的标定。如果你不使用差分输出,你已经完成了标定;可以将 OPA4277 的第 1 引脚接到 ADC 输入。如果你使用差分输出,则还需要进一步调整。接下来的步骤将扭曲放大阶段的标定;这没关系,因为步骤 1 到 7 中的标定只是为了验证电路在标定差分驱动器之前正常工作。
-
测量 OPA4277 第 10 引脚的电压,并调整 VREF 电位器,使电压尽可能接近零。
-
对单端输入施加 0 V,并测量+差分输出与地之间的电压。此时你应该看到 0 V。否则,使用 ZERO 电位器调整偏移量。
-
在仍然施加 0 V 到单端输入的情况下,测量–差分输出与地之间的电压。此时应为 0 V。否则,使用 VREF 电位器调整偏移量。
-
将单端输入的电压更改为你预期的最大值(比如 5 V),并测量+差分输出与地之间的电压。此时你应该看到 4.095 V。否则,使用 SPAN 电位器调整增益。
-
重复步骤 2 到 4,直到不再需要进一步调整。理想情况下,在第 11 步的–差分输出上,你应该看到–4.095 V 或非常接近的值。如果有较大的偏差(例如,超过 0.01 V),差分驱动电路中的 10 kΩ电阻可能存在问题。
此时,当前的标定存在几个问题:首先,它输出负电压,而你不能将负电压应用到 ADS1x15 输入;其次,增益偏离了两倍。接下来的步骤可以解决这些问题。
-
将单端输入的电压设置为最大电压(例如,5 V)。测量+和–差分输出端子之间的电压;它应该约为 8.191 V。使用 SPAN 电位器减少增益,直到输出电压为 4.095 V。
-
将单端输入的电压设置为 0 V。测量+差分输出和地之间的电压。调整 ZERO 电位器,直到其读数为+2.047 V。
-
将单端输入的电压设置为最大预期电压(例如,5 V)。测量+差分输出和地之间的电压。调整 SPAN 电位器,直到电压为+4.095 V。
-
重复步骤 2 和 3,直到不再需要进一步调整。
-
对单端输入施加最大电压。测量–差分输出和地之间的电压;它应该接近 0 V。调整 VREF 电位器,直到它尽可能接近 0 V。
-
重复步骤 2 到 5,直到不再需要进一步调整。
此时您的电路应该校准,以便与在差分模式下运行的 ADS1x15 设备一起使用。您应该能够输入 ±5 到单端输入,并使用适当的软件从设备读取 -32768 到 +32767。
14.3 ADS1x15 模数转换器寄存器
ADS1x15 设备有五个内部寄存器:8 位指针寄存器(仅写),16 位转换寄存器(仅读),16 位配置寄存器(读/写),16 位低阈值寄存器和 16 位高阈值寄存器。
指针寄存器的低 2 位选择其他四个寄存器之一(00:转换,01:配置,10:低阈值,11:高阈值)。指针寄存器值的高 6 位应始终为 0。在启动条件和 LO 位等于 0 的地址字节后,始终进行指针寄存器选择(写操作)。地址传输后的下一个字节是指针寄存器值(参见 图 14-2)。

图 14-2:指针寄存器值跟随写命令
当写入配置或阈值寄存器时,请按照 图 14-2 中的顺序,使用 2 字节值写入由 reg 位指定的寄存器。16 位值的高字节跟随指针寄存器值,低字节通常在结束停止条件后跟随。详细信息请参见 图 14-3。

图 14-3:向 16 位寄存器写入值
从 16 位寄存器读取比写入寄存器稍复杂,因为它需要发送 2 个地址字节——一个带有写命令和指针寄存器值的,另一个带有读命令的(中间有重新启动条件)。图 14-4 显示从寄存器读取 16 位所需的序列。请注意,系统必须在寄存器指针值和第二个地址之间发送重新启动条件,并且第二个地址的 LO 位设置为 1,表示读操作。

图 14-4:读取 16 位寄存器
序列的最后 2 个字节包含从 ADC 读取的 16 位值。这包括高字节后跟随的低字节。
14.3.1 转换寄存器
转换寄存器(寄存器指针值为 0)是一个只读寄存器,保存最后一次模数转换的值。这是一个范围为 -32768 到 +32767 的二进制补码有符号整数。在 ADS1015 设备上(仅为 12 位 ADC),该寄存器的低 4 位始终包含 0,意味着实际范围是 -32760 到 +32759。
在连续模式下,ADS1x15 自动将此寄存器填充为其进行的最后一次转换。在单次模式下,ADS1x15 将最后请求的转换放入此寄存器。有关启动转换的信息,请参见本章后面的 14.3.2.5 节“操作状态位”。
14.3.2 配置寄存器
执行模拟到数字转换所需的大部分操作都在配置寄存器中进行(寄存器指针值为 1)。表 14-2 列出了配置寄存器中 16 位的含义。
表 14-2:配置寄存器位
| 位 | 读取操作 | 写入操作 |
|---|
| 0 | 读取比较器队列状态 | 00:在 1 次转换后触发 ALRT;01:在 2 次转换后触发 ALRT |
10: 在 4 次转换后触发 ALRT
11: 禁用比较器 |
| 1 | ||
|---|---|---|
| 2 | 读取 ALRT 闩锁设置 | 0:非闩锁 ALRT;1:闩锁 ALRT |
| 3 | 读取 ALRT 引脚极性 | 0:ALRT 为低电平有效;1:ALRT 为高电平有效 |
| 4 | 读取比较器模式 | 写入模式。0:传统模式;1:窗口模式 |
| 5 | 读取转换速率 | 设置转换速率(详见下文) |
| 6 | ||
| 7 | ||
| 8 | 读取设备模式 | 设置设备模式。1:单次触发;0:连续模式 |
| 9 | 读取 PGA 设置 | 设置 PGA 值 |
| 10 | ||
| 11 | ||
| 12 | 读取多路复用选择 | 写入多路复用选择 |
| 13 | ||
| 14 | 读取输入控制 | 设置输入控制。0:差分输入;1:单端输入 |
| 15 | 0:设备正在进行转换;1:设备已准备好 | 写入 1 到该位置将开始从关机模式进行转换 |
以下小节将更详细地介绍这些寄存器配置位。
14.3.2.1 比较器控制位
配置寄存器中的位 0 到 4 控制 ADS1x15 上比较器的操作。这些位控制比较器是否处于激活状态,并控制 ALRT 引脚的极性和闩锁,以及比较器的类型。
位 0 和 1 启用和禁用比较器,并控制警报逻辑。如果这些位为 0b11(上电/复位时的默认状态),则比较器电路被禁用。如果比较器控制位为 0b00、0b01 或 0b10,则比较器会启用,并在转换值超出阈值寄存器范围时触发 ALRT 引脚,分别对应一次、两次或四次读取。增加触发 ALRT 之前的读取次数有助于过滤掉噪声尖峰。
配置寄存器中的位 2 控制 ALRT 引脚的闩锁模式。在默认状态(0)下,ADS1x15 仅在最后一次转换超出阈值范围时触发 ALRT 引脚。如果转换值回落到低于下阈值范围,则 IC 会取消触发 ALRT 引脚。在闩锁模式下,一旦转换值超出阈值范围(指定次数的转换),ALRT 引脚将被锁存为触发状态。要清除锁存,必须读取转换寄存器。
配置寄存器中的位 3 控制 ALRT 引脚的极性。该位为 0 时(上电/复位时的默认值)设置为低电平有效信号;该位为 1 时设置为高电平有效信号。
位 4 设置传统或窗口(范围)比较器模式。在传统模式下,ADS1x15 将最后一次转换值与高阈值寄存器进行比较(具有滞后效应,当输入低于低阈值时,会解除 ALRT 引脚的激活)。在窗口(范围)模式下,它将最后一次转换值与低高阈值寄存器进行比较,如果转换超出该范围,则会生成 ALRT 信号。
除了比较器控制位外,您还可以使用低阈值和高阈值寄存器控制比较器。有关这些寄存器的更多细节,尤其是关于如何定义 ALRT 引脚为警报或就绪功能,请参见 14.3.4 节,“低阈值和高阈值寄存器”中的讨论。
14.3.2.2 设备模式配置位和转换速率
配置寄存器的第 8 位指定 ADS1x15 是否在“单次”转换模式(1,默认开机/复位时)或连续转换模式(0)下工作。在单次模式下,ADC 仅在接收到通过 I²C 总线传送的命令时执行转换(向配置寄存器的第 15 位写入 1)。在连续模式下,当当前转换完成时,ADC 将开始新的转换。位 5 至 7 决定采样频率。ADS1015 的采样频率出现在表 14-3 中。
表 14-3:ADS1015 采样频率
| 配置位 5–7 | 采样频率 |
|---|---|
| 000 | 128 sps |
| 001 | 250 sps |
| 010 | 490 sps |
| 011 | 920 sps |
| 100 | 1600 sps |
| 101 | 2400 sps |
| 110 | 3300 sps |
| 111 | 3300 sps |
ADS1115 的采样频率出现在表 14-4 中。请注意,两个 IC 的采样频率不同。
表 14-4:ADS1115 采样频率
| 配置位 5–7 | 采样频率 |
|---|---|
| 000 | 8 sps |
| 001 | 16 sps |
| 010 | 32 sps |
| 011 | 64 sps |
| 100 | 128 sps |
| 101 | 250 sps |
| 110 | 475 sps |
| 111 | 860 sps |
如果 ALRT 引脚被配置为“就绪”信号,则 ADS1x15 在连续模式下每次转换后都会脉冲 ALRT(或 RDY)引脚。在单次模式下,如果 COMP_POL 位被设置为 0,则 ADS1x15 将在转换完成后激活 ALRT/RDY 引脚。有关如何设置 ALRT 引脚作为警报或就绪信号的更多信息,请参见本章后面的 14.3.3 节,“低阈值和高阈值寄存器”。
14.3.2.3 可编程增益放大器控制位
配置寄存器的第 9 至 11 位指定要应用于输入模拟信号的增益。表 14-5 列出了可能的增益值和电压范围。
表 14-5:可编程增益放大器
| 配置设置 | 增益 | 输入电压范围 |
|---|---|---|
| 000 | 2/3 | ±6.144 V |
| 001 | 1 | ±4.095 V |
| 010 | 2 | ±2.047 V |
| 011 | 4 | ±1.023 V |
| 100 | 8 | ±0.511 V |
| 101, 110, 111 | 16 | ±0.255 V |
在任何情况下,表 14-5 中的电压范围都不能超过 Vdd。这意味着,如果选择配置值 0b000,输入电压仍然限制在 5 V(假设 Vdd 为 5 V),即使范围是 0 V 到 6.144 V。在此模式下,从转换寄存器中读取的最大值约为 26,666,而不是 32,767。
此外,输入引脚上的电压不得低于 0 V。负值仅在差分模式下有效,当+输入小于–输入时;两个输入都必须相对于接地为正。
请记住,在查看表 14-5 时,电压范围还会受到 Vdd 的限制,无论 PGA 的设置如何。例如,如果你以 3.3 V 运行 ADS1x15,并且已将 PGA 编程为 0b001(±4.095 V),则最大电压输入仍然是 3.3 V。这意味着读取值将在–26399 到+26399 的范围内,而不是通常的–32768 到+32767。
你最常见的 PGA 编程通常只进行一次(我最常用的是 0b001,±4.095 V 范围)。然而,如果你在操作过程中动态修改 PGA,例如,为每个输入通道使用不同的增益设置,这可能会影响比较器电路的操作。所有输入通道共享相同的模数转换器电路。如果你为一个通道设置比较器阈值,然后将输入多路复用器切换到具有不同增益的其他通道,比较器将在两个通道上以不同的电压触发。故事的教训是,在使用比较器电路时,最好为所有活动输入通道使用相同的增益设置。
14.3.2.4 多路复用器控制位
配置寄存器中的位 12 和 13 选择输入,位 14 控制差分模式或单端模式。如果位 14 为 0(上电/复位时默认值),ADC 将在差分模式下工作。如果位 14 为 1,ADC 将在单端模式下工作。
位 12 到位 13 选择一个合适的输入,或者一对输入,如表 14-6 所示。
表 14-6:输入多路复用器选择
| 配置 位 12 和 13 | 如果位 14 为 0(差分模式) | 如果位 14 为 1(单端模式) |
|---|---|---|
| + 端子 | – 端子 | |
| 00 | A0 | A1 |
| 01 | A0 | A3 |
| 10 | A1 | A3 |
| 11 | A2 | A3 |
在差分模式下,特殊的 0b01 和 0b10 设置允许你使用最多三个差分输入,前提是这三个输入(A0、A1 和 A2)共享相同的–端。这种情况通常不会发生,因此差分输入通常会使用 0b00 或 0b11 作为位 12 和 13 的值。在单端模式下(位 14=1),位 12 和 13 选择四个单端输入通道中的一个。
14.3.2.5 操作状态位
在读取配置寄存器时,位 0 到位 14 反映的是最后写入这些位的值。然而,位 15 在读取和写入操作时有不同的功能。
读取配置寄存器会返回位 15 的当前准备就绪状态。如果该位返回 1,表示 ADS1x15 当前没有进行转换,因此可以开始新的转换。如果位 15 返回 0,表示 ADS1x15 正在进行转换,此时无法开始新的转换。一旦你在单次转换模式下开始了转换,可以通过测试此位来确定转换何时完成。在连续模式下,你不需要关心这一点,因为转换寄存器中保存的是最后一次转换的值。
要在单次转换模式下开始转换,请将 1 写入转换寄存器的位 15。请记住,写入配置寄存器时,还必须重新写入其他 15 个位的配置。你通常会设置一个 16 位值来定义转换的进行方式,并将位 15 设置为 1(以开始转换)。如果不希望开始新的转换,只需将位 15 设置为 0。
请注意,只有在单次转换模式下,才能将位 15 设置为 1,这种模式也被称为关机模式。如果 ADS1x15 当前在连续模式下工作,必须先通过将位 8 写入 1 将其切换到单次转换模式。然后,你可以通过将位 15 写入 1 来编程启动新的转换。
14.3.3 低阈值和高阈值寄存器
ADS1x15 设备提供两个 16 位的阈值寄存器,低阈值(指针寄存器值为 0b10)和高阈值(0b11)。ADS1115 允许你将完整的 16 位写入这些寄存器。由于 ADS1015 ADC 是 12 位设备,因此你应始终将这些寄存器的低 4 个位写为 0。
当作为传统的比较器工作时,ADS1x15 会将转换寄存器的值与高阈值寄存器中的值进行比较,如果转换值大于高阈值,它会激活 ALRT 引脚。ADS1x15 使用低阈值寄存器来确定何时去激活 ALRT 信号。当输入转换值低于低阈值的值时,ADS1x15 会去激活 ALRT。
在窗口比较器模式(范围模式)下,ADS1x15 会在转换值低于低阈值寄存器值或高于高阈值值时激活 ALRT 引脚。如果你希望在转换值介于两个阈值之间时激活该引脚,可以简单地使用配置寄存器的位 3 反转 ALRT 引脚。
在非锁存模式(见本章前面部分 14.3.2.1 节,“比较器控制位”)下,ADS1x15 会根据转换值是否超出范围自动激活和去激活 ALRT 引脚。在锁存模式下,一旦 IC 激活 ALRT 引脚,该引脚会保持激活状态,直到软件读取转换寄存器——也就是说,假设此时转换值已经回到范围内。
阈值寄存器提供了一个额外的隐藏功能:控制 ALRT 引脚。如果高阈值寄存器的 HO 位为 1 且低阈值寄存器的 HO 位为 0,则 ADS1x15 会在 ALRT 引脚上输出就绪状态(配置寄存器的第 15 位)(在此配置中应称其为 RDY)。由于阈值寄存器中的值是二进制补码有符号整数,这种特定情况意味着高阈值寄存器中的值小于低阈值寄存器中的值。这通常是一个非法组合;除这种特殊情况外,高阈值的值必须始终大于低阈值寄存器中的值。
这部分讨论了 ADS1x15 集成电路的内部架构。然而,要对其进行编程,你需要一些实际的硬件,可以将其接入包含控制 CPU 的电路中。由于其体积较小,通常不会直接将 ADS1x15 接到面包板上。下一部分将介绍解决此问题的方法。
14.4 Adafruit ADS1x15 分 breakout 板
ADS1x15 IC 是微型表面贴装器件,除了最有经验的电子技术员或电路组装公司,几乎没有人能够使用。Adafruit 通过将 IC 放置在一个小型 PCB 上,即一个带有 0.1 英寸引脚头的“分 breakout 板”,解决了这一问题,使得将该 IC 作为其他电路的一部分变得更加容易。
图 14-5 显示了 Adafruit ADS1115 分 breakout 板。值得一提的是,ADS1015 板与 ADS1115 板完全相同,唯一的区别是丝印和板上实际放置的 IC。
ADS1015 和 ADS1115 具有相同的引脚布局,包括 10 个孔,通常在这些孔中焊接一个 1×10 的引脚头,如表 14-7 所描述。

图 14-5:Adafruit ADS1115 分 breakout 板
表 14-7:Adafruit ADS1x15 引脚布局
| 引脚(名称) | 功能 |
|---|---|
| Vdd | 电源 (2 V 至 5 V) |
| Gnd | 数字和模拟接地 |
| SCL | I²C 时钟线 |
| SDA | I²C 数据线 |
| ADDR | 地址选择线 |
| ALRT | 警报(比较器超出范围)或转换完成 |
| A0 | 模拟输入通道 0 (+ 差分输入 0) |
| A1 | 模拟输入通道 1 (– 差分输入 0) |
| A2 | 模拟输入通道 2 (+ 差分输入 1) |
| A3 | 模拟输入通道 3 (– 差分输入 1) |
Vdd、Gnd、SCL 和 SDA 引脚具有常见连接。然而,请记住,尽管电源可以在 2 V 到 5 V 范围内,模拟输入引脚的电压永远不能超过 Vdd。如果你使用 3.3 V 为 ADS1x15 提供电源,模拟输入将被限制在 3.3 V 以内。
Adafruit ADS1x15 分 breakout 板在 SCL 和 SDA 线路上包括了 10-kΩ 的上拉电阻(连接到 Vdd)。虽然不需要自己添加上拉电阻很方便,但如果将大量此类设备(带有自己上拉电阻)连接到同一个 I²C 总线,串联电阻可能会成为问题。如果是这种情况,你需要将 SMD 芯片电阻从 breakout 板上拆除。
ADDR 引脚是一个输入引脚,ADS1x15 使用它来选择四个不同的 I²C 地址之一。将 ADDR 连接到 Gnd、Vdd、SDA 或 SCL 可根据 表 14-8 中所示的方式指定 I²C 地址——这是一种特别巧妙的方式,通过单个地址引脚获得四个独立的地址。
表 14-8:ADS1x15 地址选择
| ADDR 连接到 | I²C 地址 |
|---|---|
| Gnd | 0x48 |
| Vdd | 0x49 |
| SDA | 0x4A |
| SCL | 0x4B |
现在返回查看 表 14-7;ALRT 引脚在 ADS1x15 上有两个用途:当与内置比较器一起使用时,它可以在转换结果超出某个可编程范围时发出信号。这个引脚还可以用来指示转换完成——例如,生成一个中断,这样 CPU 就不必不断轮询设备来查看转换是否完成。ALRT 引脚是一个开漏输出引脚。Adafruit ADS1x15 breakout 板自动在此线路上包含一个上拉电阻,因此你可以将 ALRT 引脚视为标准逻辑输出。
A0、A1、A2 和 A3 是单端输入引脚(另一个信号连接到 Gnd)。在差分模式 0b000(配置寄存器位 12 到 14 为 0b000)下,A0 和 A1 分别对应通道零的正输入和负输入,而 A2 和 A3 分别对应通道 1 的正输入和负输入。在差分模式 0b001、0b010 和 0b011(配置寄存器位 12 到 14)下,使用(A0,A3)、(A1,A3)和(A2,A3)作为三组差分输入,其中 A3 为公共(–)差分信号。
14.5 ADS1x15 编程示例
基本的 ADS1x15 编程包括将适当的配置值写入配置寄存器(包括在单次模式中“启动转换”位),等待转换完成,然后从转换寄存器中读取转换后的值。在典型的系统中,这就是使用 ADS1x15 的全部内容。
作为测试,将 Adafruit ADS1115 breakout 板连接到 Arduino,如 图 14-6 所示。你也可以使用 ADS1015,如果你更喜欢的话;该代码可以与这两种设备兼容,并且我将在本节后面提供两种设备的示例输出。请注意,ADDR 线已连接到 Gnd;这将 I²C 地址设置为 0x48。Vdd 连接到 Arduino 的 +5V。该示例程序将仅读取 A0 输入,因此请将适当的电压源连接到 A0 引脚(0 V 至 4.095 V)。为了快速测试,我只是将 A0 连接到地和 3.3V 电源。

图 14-6:将 ADS1115 扩展板连接到 Arduino
列表 14-1 中的程序演示了读取输入通道 A0 并在 Arduino 串口监视器窗口中显示结果。
// Listing14-1.ino
//
// A simple program that demonstrates
// ADS1115 programming.
//
// This program constantly reads the A0
// ADC channel and displays its values.
#include <Wire.h>
#define ads1115 (0x48) // Connect ADDR to Gnd
// ADS1x15 registers:
#define conversion (0)
#define config (1)
#define lowThresh (2)
#define highThresh (3)
// Usual Arduino initialization code:
void setup( void )
{
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "Test reading ADS1115" );
Wire.begin(); // Initialize I2C library
adsReset();
}
// adsReset-
//
// Reset the ADS1x115 to a known state:
void adsReset()
{
// Use the I2C General Call with a reset command:
Wire.beginTransmission( 0 );
Wire.write( 6 );
Wire.endTransmission();
}
// adsWrite-
//
// Writes a 16-bit value to one
// of the ADS1x115 registers:
void adsWrite( int adrs, int reg, int value )
{
Wire.beginTransmission( adrs );
Wire.write( reg ); // Pointer register value
// Split the output value into 2 bytes
// and write them to the ADS1x15\. Note that
// this is written immediately after the
// pointer register byte.
Wire.write( (value << 8) & 0xff );
Wire.write( value & 0xff );
Wire.endTransmission();
}
// adsRead-
//
// Reads a (signed) 16-bit value from one
// of the ADS1x15 registers.
int adsRead( int adrs, int reg )
{
unsigned char LOByte;
unsigned char HOByte;
Wire.beginTransmission( adrs );
Wire.write( reg ); // Pointer register value
Wire.endTransmission( false ); // No stop condition
// Must send a new start condition and address
// byte with the LO bit set to 1 in order to
// the 2 bytes (Wire.requestFrom does this).
Wire.requestFrom( adrs, 2 ); // Read two bytes from
HOByte = Wire.read(); // the conversion register
LOByte = Wire.read();
// Convert the 2 bytes read from the conversion
// register to a signed integer and return.
return (int) ((short) (HOByte << 8) | LOByte);
}
// wait4Ready-
//
// Polls bit 15 of the configuration register ("ready" bit)
// until it contains a 1 (conversion complete).
void wait4Ready( void )
{
❶ while( (adsRead( ads1115, config ) & 0x8000) == 0 )
{
// Wait for conversion to complete.
}
}
// Arduino main loop.
void loop( void )
{
uint16_t startConv;
// Create value to write to the configuration
// register that will start a conversion on
// single-ended input A0:
startConv =
❷ (1) << 15 // Start conversion
❸ | (0b100) << 12 // A0, single-ended
❹ | (0b001) << 9 // PGA = 4.095 V
❺ | (1) << 8 // One-shot mode
❻ | (0b111) << 5 // 860 sps (ADS1115), 3300 (ADS1015)
| (0) << 4 // Comparator mode (not used)
| (0) << 3 // Comparator polarity (not used)
| (0) << 2 // Non-latching (not used)
❼ | (0b11); // Comparator disabled
// First, wait until any existing conversion completes:
wait4Ready();
// Start a conversion:
❽ adsWrite( ads1115, config, startConv );
// Wait for it to complete:
❾ wait4Ready();
// Read the ADC value:
int16_t adcValue = adsRead( ads1115, conversion );
// Display result:
Serial.print( "ADC: " );
Serial.println( adcValue );
}
为了测试目的,最简单的代码将配置寄存器设置如下:
-
程序将第 0 至 4 位设置为 0b00011(禁用比较器)❼
-
程序将第 5 至 7 位设置为 0b111(860 sps,尽管这个值无关紧要)❻
-
程序将第 8 位设置为 1(单次模式)❺
-
程序将第 9 至 11 位设置为 0b001(PGA = 4.095 V 范围)❹
-
程序将第 12 至 14 位设置为 0b100(单端模式,选择 A0)❸
-
程序将第 15 位设置为 1(开始转换)❷
将此值(0xC3E3)写入配置寄存器❽后,ADS1115 将开始将 A0 上的电压转换为数字形式。软件必须等待大约 1.2 毫秒,直到此转换完成,然后从转换寄存器中读取结果。当然,1.2 毫秒的延迟完全不合适;等待转换完成的正确方法是测试配置寄存器的第 15 位(操作状态位),直到它变为 1(❶和❾)。
执行列表 14-1 中的代码时,将 A0 线连接到地应该产生如下输出:
ADC: -2
ADC: 3
ADC: -1
ADC: -2
ADC: -1
ADC: 1
ADC: -1
ADC: 0
ADC: 0
ADC: 0
ADC: -1
ADC: -1
ADC: 1
ADC: -2
ADC: 0
ADC: -1
ADC: 0
ADC: -1
你可以看到在 16 位转换过程中存在的微小噪声(记住,每个整数单位代表 0.0000625 V)。这个特定的序列确实非常干净,范围从–2 到+3,方差只有大约 0.00003 V,这是因为 A0 直接连接到电源引脚所造成的伪影。
这个软件在 Adafruit ADS1015 12 位 ADC 扩展板上也能正常工作。下面是该板的一些输出:
ADC: 0
ADC: 0
ADC: 0
ADC: 0
ADC: 0
ADC: 0
ADC: -16
ADC: 0
ADC: 0
ADC: 16
ADC: 0
ADC: 0
ADC: 0
尽管错误看起来比 ADS1x15 大得多,但实际上是更好的。回想本章前面提到的“转换寄存器”,转换寄存器的低 4 位始终为 0,而 12 位的转换结果出现在第 4 至第 15 位,所以你在前面的输出中看到的只是偶尔出现的 1 位错误。这是使用 12 位而非 16 位 ADC 的一个优势:噪声更小。
14.6 提升轮询性能
列表 14-1 中的程序轮询配置寄存器的第 15 位,直到它变为 1,表示转换完成,ADS1x15 已经准备好进行另一次转换。这看起来可能不算什么大事,但请记住,读取配置寄存器需要五次 I²C 总线事务:两次设置指针寄存器的值,三次读取实际的转换寄存器。在 100 kHz 的速率下,这可能需要超过 500 µsec——几乎是转换时间的一半!
当然,你可以通过提高 ADS1x15 的时钟频率来减少轮询带来的时间损失,但并非所有单板计算机(SBC)或 CPU 都支持这一点;正如你在本书中看到的,有些甚至无法达到完整的 100 kHz 频率。幸运的是,如果你的 SBC 有空余的输入引脚,就有一个更快的解决方案:将 ALRT 引脚编程为 RDY 引脚,并通过读取该引脚来测试转换是否完成。
要将 ALRT 引脚编程为 RDY,引脚设置方法是:将低阈值寄存器的第 15 位写为 0,高阈值寄存器的第 15 位写为 1,关闭比较器锁存(将配置寄存器的第 2 位写为 0),将 ALRT 极性设置为 0(配置寄存器的第 3 位),并启用比较器。这将允许在 ALRT 引脚上读取 RDY 状态。你可能注意到,图 14-6 中的 ALRT 引脚已经接入了 Arduino 的 D2 数字 I/O 引脚。列表 14-1 中的程序忽略了 D2 引脚,而列表 14-2 中的程序将使用这个连接来测试转换何时完成。
列表 14-2 中的程序仅是对列表 14-1 中程序的一个小修改。因此,我不会重新打印列表 14-1 中的重复部分,而是仅突出显示代码中新加入的部分。我从文件开头的常规#define语句开始:
// Listing14-2.ino
//
// A simple program that demonstrates
// ADS1115 programming.
//
// This program constantly reads the A0
// ADC channel and displays its values.
// It reads the ALRT/RDY pin to determine
// when the conversion is complete (connect
// ALRT to D2 on Arduino).
#include <Wire.h>
#define ads1115 (0x48) // Connect ADDR to Gnd
❶ #define rdy (2) // RDY is on pin 2
这一部分的主要新增内容是对rdy引脚的定义 ❶。
接下来,我转向wait4Ready()函数,它是从列表 14-1 中的代码重写的:
// Listing14-2.ino (cont.)
//
// wait4Ready-
//
// Polls digital I/O pin 2 to see
// if the conversion is complete.
void wait4Ready( void )
{
❷ while( digitalRead( rdy ) != 0 )
{
// Wait for conversion to complete.
}
}
这个wait4Ready()函数从数字输入引脚 D2 ❷读取就绪状态,而不是读取配置寄存器(那样会比较慢)并测试该寄存器的第 15 位。
接下来我们转向setup()函数:
// Listing14-2.ino (cont.)
void setup( void )
{
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "Test reading ADS1115" );
Wire.begin(); // Initialize I2C library
pinMode( 2, INPUT );
// Write a 1 to the HO bit of the
// high threshold register and a 0
// to the HO bit of the low threshold
// register to program the ALRT pin
// to behave as the RDY pin.
adsReset();
adsWrite( ads1115, config, 0x43E0 );
❸ adsWrite( ads1115, lowThresh, 0x0 );
❹ adsWrite( ads1115, highThresh, 0x8000 );
}
setup()函数需要初始化阈值寄存器,使得低阈值寄存器的 HO 位 ❸为 0,高阈值寄存器的 HO 位 ❹为 1。此代码还会激活比较器电路,以便将就绪状态传递到 ADS1x15 上的 ALRT 引脚。
最后的更改是写入配置的代码部分,位于主循环中:
// Listing14-2.ino (cont.)
void loop( void )
{
uint16_t startConv;
// Create value to write to the configuration
// register that will start a conversion on
// single-ended input A0:
startConv =
(1) << 15 // Start conversion
| (0b100) << 12 // A0, single-ended
| (0b001) << 9 // PGA = 4.095 V
| (1) << 8 // One-shot mode
| (0b111) << 5 // 860 sps
| (0) << 4 // Comparator mode (not used)
| (0) << 3 // Comparator polarity (used)
| (0) << 2 // Non-latching (not used)
❺ | (0b00); // Comparator enabled
虽然列表 14-1 中的代码在每次循环时都会禁用比较器,但列表 14-2 中的代码需要保持比较器开启 ❺。
对列表 14-1 所做的这些更改显著提高了输出速度。图 14-7 显示了 ALRT 引脚(输入到数字 I/O D2 引脚)的输出。当该信号为高时,ADC 正在忙于转换。当信号为低时,ADC 准备好进行下一次转换。如图 14-7 所示,在使用 Teensy 3.2 时,转换时间略长于 1 毫秒(记住,ADS1115 在此测量中,大约能够实现 860 采样每秒)。示波器追踪的低部分是发送转换命令到 ADS1115 以及向 Arduino 串口终端输出数据所花费的时间。

图 14-7:来自列表 14-2 的示波器输出
请记住,我是在 96 MHz 的 Teensy 3.2 上运行了生成图 14-7 中输出的代码,而不是 16 MHz 的 Arduino Uno Rev3。在 Arduino 上,周期的底部部分可能稍微宽一些。
使用 RDY 引脚并不是唯一提高 A/D 转换性能的方法。下一节将介绍另一种加速采样率的方法。
14.7 使用连续扫描提高性能
在图 14-7 中,执行时间的一半多一点被花费在进行模拟到数字转换(即,当 ALRT [RDY]引脚为高电平时)。在两次转换之间,主 Arduino loop函数中有三项活动:写入配置寄存器以启动转换、从 ADS1115 读取转换值,以及将结果打印到 Arduino 串口终端。将结果写入串口终端不是一个特别快的过程,但写入配置寄存器和读取转换寄存器需要进行九次 I²C 总线事务——大约在 100 kHz 总线上需要 900 µsec。
对读取转换寄存器的性能无法进行任何改进——这是从 ADS1115 获取数据的唯一方法。然而,你可以通过将 ADS1115 置于连续转换模式,节省每次循环中写入配置寄存器的开销(大约 400 µsec)。在这种模式下,CPU 可以随时请求来自 ADS1115 的数据,而无需检查转换是否完成;ADS1115 会始终返回上一次转换的值,并会在每次新的转换发生时自动更新该值。列表 14-3 提供了将 ADS1115 置于连续转换模式的代码。再次声明,我不会重新打印任何与前两个列表共享的代码。
// Listing14-3.ino
//
// A simple program that demonstrates
// ADS1115 programming.
//
// This program constantly reads the A0
// ADC channel and displays its values
// using continuous conversion mode.
// It reads the ALRT/RDY pin to determine
// when a new conversion occurs (so it can
// output data to the Serial terminal).
//
// adsReset-
//
// Reset the ADS1x115 to a known state:
void adsReset()
{
// Use the I2C General Call with a reset command:
Wire.beginTransmission( 0 );
Wire.write( 6 );
Wire.endTransmission();
}
// wait4Conversion-
//
// Polls digital I/O pin 2 to see if the
// conversion is complete.
void wait4Conversion( void )
{
// Wait for the falling edge that
// indicates a conversion has occurred.
❶ while( digitalRead( rdy ) == 0 )
{
// Wait for conversion to complete.
}
// Wait for the rising edge so that
// the next loop doesn't mistakenly
// think a new conversion occurred.
❷ while( digitalRead( rdy ) == 1 )
{
// Wait for conversion to complete.
}
}
列表 14-3 中的wait4Conversion()函数替代了前两个列表中的wait4Ready()函数。该函数会等待直到 RDY 线(数字输入 D2)变低❶,表示转换已完成。ADS1115 会将 RDY 线拉低约 10 µsec,之后会自动恢复为高电平。这段时间足够 Arduino(或者像我使用的 Teensy)检测到转换已完成。然而,代码还需要等待该线路恢复为高电平❷,以确保loop函数不会在信号仍然为低电平时重复执行,从而误认为另一次转换已完成。
// Listing14-3.ino (cont.)
//
// Usual Arduino initialization code.
void setup( void )
{
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "Test reading ADS1115" );
Wire.begin(); // Initialize I2C library
pinMode( 2, INPUT );
// Write a 1 to the HO bit of the
// high threshold register and a 0
// to the HO bit of the low threshold
// register to program the ALRT pin
// to behave as the RDY pin. Also
// put a 0 in bit 8 to turn on the
// continuous conversion mode.
adsReset();
❸ adsWrite( ads1115, config, 0x42E0 );
adsWrite( ads1115, lowThresh, 0x0 );
adsWrite( ads1115, highThresh, 0x8000 );
}
与之前示例中设置函数的唯一实质性修改是它将配置寄存器的第 8 位编程为 0❸,使设备进入连续转换模式:
// Listing14-3.ino (cont.)
//
// Arduino main loop.
void loop( void )
{
// Wait for a conversion to complete:
❹ wait4Conversion();
// Read the ADC value:
int16_t adcValue = adsRead( ads1115, conversion );
// Display result:
Serial.print( "ADC: " );
Serial.println( adcValue );
}
在loop()函数中,写入配置寄存器的代码已经消失。由于 ADS1115 处于连续模式,因此不再需要通过写入配置寄存器来启动新的转换。wait4Conversion()函数❹非常快速(因为它只涉及数字 I/O,没有 I²C 事务)。这使得唯一会减慢主循环的操作就是读取转换寄存器。
图 14-8 显示了程序在列表 14-3 中的示波器输出。首先要注意的是,时间刻度是前一个图表的一半(500 µsec 而不是 1 msec)。每个脉冲的周期略大于 1.2 msec(而在图 14-7 中约为 2 msec),意味着这段代码运行速度几乎是之前列表中代码的两倍。

图 14-8:示波器输出 列表 14-3
使用连续模式加速应用程序的唯一缺点是只能从一个 ADC 通道读取数据(忽略了单次/关机模式的省电特性)。要更改多路复用器通道,必须写入配置寄存器,这会消耗掉最初通过去掉此调用所节省的所有时间。
14.8 中断与 ADS1x15
理论上,ALRT(RDY)引脚可以连接到 Arduino 的中断输入—例如,D2 是 Arduino Uno Rev3 上的一个中断引脚。每当发生中断(由于转换完成或比较超出范围信号),中断服务程序可以读取数据或处理比较故障,并将这些信息传递给主程序。
然而,实际上,在 Arduino 代码中使用中断充其量是值得怀疑的。I²C 通信必须在 ISR 中进行,这非常慢,而中断服务程序需要非常快。也许在高速度模式下的 I²C 时钟会有效,你得测试一下看看。
另一方面,如果你使用的是支持良好中断的多线程 RTOS(也就是带有中断驱动的 I²C 库),那么中断变得非常实用。中断服务程序(ISR)会在转换完成时通知一个线程,而该线程则可以与 ADS1115 通信,在 I²C 传输(和转换)进行时会被阻塞。与轮询相比,这样的做法会消耗极少的 CPU 时间,并且允许其他线程运行,几乎不影响性能。
14.9 去噪
在实际应用中—与本章中我使用的简单测试电路不同,在那些电路中我将模拟输入直接接到电源引脚—在读取模拟信号时,噪声可能是一个大问题。噪声的来源包括环境、电子电路以及模拟传感器本身。幸运的是,你可以通过一些简单的软件技术来数字滤波掉一部分噪声。
其中最常见的技术之一是进行多次读取——比如三次到九次——并取这些值的中位数。这种方法选择一堆读取值中的中间值,优点是可以消除异常值。一个稍微高效一些的技术是进行多次读取并计算这些值的算术平均值。通常,这比计算中位数更快,但缺点是会把异常值纳入平均值中。
然而,无论是中位数还是平均值,都是基于固定的一组值,而模拟读取往往是连续流动的。因此,最合适的解决方案是创建一个窗口平均值。为此,维护一个包含最后n个读取值的列表,其中n是窗口大小,并基于这些值计算平均值。每次有新的 ADC 读取时,你将其加入窗口列表,并丢弃列表中最旧的读取值。
在相对嘈杂的环境中,我通常同时使用两种技术来过滤噪声。我保留来自 ADC 的最后七次或九次读取,并计算这些值的中位数。然后,我保留最后 4 到 20 个中位数值(具体数量取决于应用),并计算这些值的算术平均值。例如,如果我正在计算 9 个值的中位数和 10 个值的算术平均数,那么我实际上是在任何给定时刻平均 90 个 ADC 读取值。在下一节中,我将描述如何做到这一点。
使用平均化来过滤噪声的代价是,你的结果只能慢慢地反映出模拟读取中的任何突变。例如,如果输入电压突然从 0V 跳变到 5V,可能需要几百次读取,直到你的平均值稳定为 5V。
14.9.1 计算均值和中位数
计算算术平均值相对简单:只需将所有值相加并除以值的数量。选择一个窗口大小为 2 的幂次方可以提高性能,因为除以n(通常是一个较慢的操作)变成了简单的右移操作。通常,窗口大小足够小,以至于对窗口中的所有元素求和不会有什么问题;然而,如果你有大量的项目,通过减去窗口中最旧的元素,然后加上最新的读取,你可以节省少量时间。
计算中位数的通用算法是对数据进行排序,并选择中间的元素(如果元素数量为偶数,则取中间两个元素的平均值)。quickselect 算法表现得更好(参见en.wikipedia.org/wiki/Quickselect)。然而,对于非常小的窗口(比如三、七或九个元素),蛮力方法可能是最有效的。例如,计算三个元素的中位数的常见方法是使用如下代码:
int medianOfThree( int a, int b, int c )
{
if( (a > b) != (a > c) )
return a;
else if( (b > a) != (b > c) )
return b;
else
return c;
}
像这样的代码常用于为快速排序算法创建一个枢轴元素;有关详细信息,请参见“更多信息”。
这是一个通用函数,用于计算任意大小数组的中值(不仅仅是三个元素),并且在某些数组大小下运行更快。你通常会在给定的应用程序中使用固定窗口大小。只需从此函数中提取适当的代码,即可获得适用于特定窗口大小的算法。
#include <string.h>
#define ever ;;
#define breakif(exp) if (exp) break
// Find the median element of an int16_t array.
//
// This Quickselect routine is based on the algorithm
// described in "Numerical recipes in C," Second Edition,
// Cambridge University Press, 1992, Section 8.5,
// ISBN 0-521-43108-5.
//
// This code was originally written by Nicolas Devillard - 1998
// Public domain.
//
// Code was modified to use macros (straight-line code) for
// arrays with 9 or fewer elements (an optimization).
#define ELEM_SORT(a,b) { if((a)>(b)) ELEM_SWAP( (a), (b) ); }
#define ELEM_SWAP(a,b) { register int16_t t=(a);(a)=(b);(b)=t; }
#define ainReadings_c 32 // Maximum number of readings
int16_t quick_select(int16_t array[ainReadings_c], int n)
{
int low;
int high;
int median;
int middle;
int ll;
int hh;
// Make temporary copy here because you will modify array.
int16_t arr[ainReadings_c];
// Macros to handle special cases as fast as possible.
switch( n )
{
case 1:
return array[0];
case 2:
// If just two elements, return their
// arithmetic mean:
return (array[0] + array[1]) / 2;
case 3:
arr[0] = array[0];
arr[1] = array[1];
arr[2] = array[2];
ELEM_SORT( arr[0], arr[1] );
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[0], arr[1] );
return(arr[1]) ;
case 4:
arr[0] = array[0];
arr[1] = array[1];
arr[2] = array[2];
arr[3] = array[3];
ELEM_SORT( arr[0], arr[1] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[0], arr[2] );
ELEM_SORT( arr[1], arr[3] );
// arr[1] and arr[3] may be out of order,
// but it doesn't matter.
// Return the mean of the upper and lower medians:
return( (arr[1] + arr[2]) / 2 );
case 5:
arr[0] = array[0];
arr[1] = array[1];
arr[2] = array[2];
arr[3] = array[3];
arr[4] = array[4];
ELEM_SORT( arr[0], arr[1] );
ELEM_SORT( arr[3], arr[4] );
ELEM_SORT( arr[0], arr[3] );
ELEM_SORT( arr[1], arr[4] );
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[1], arr[2] );
return( arr[2] );
case 6:
arr[0] = array[0];
arr[1] = array[1];
arr[2] = array[2];
arr[3] = array[3];
arr[4] = array[4];
arr[5] = array[5];
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[3], arr[4] );
ELEM_SORT( arr[0], arr[1] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[3], arr[4] );
ELEM_SORT( arr[0], arr[1] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[3], arr[4] );
// ELEM_SORT( arr[2], arr[3] ) results in lower
// median in arr[2] and upper median in arr[3].
// "Median" of an even number of elements is the
// mean of the two middle elements in this code.
return ( arr[2] + arr[3] ) / 2;
case 7:
arr[0] = array[0];
arr[1] = array[1];
arr[2] = array[2];
arr[3] = array[3];
arr[4] = array[4];
arr[5] = array[5];
arr[6] = array[6];
ELEM_SORT( arr[0], arr[5] );
ELEM_SORT( arr[0], arr[3] );
ELEM_SORT( arr[1], arr[6] );
ELEM_SORT( arr[2], arr[4] );
ELEM_SORT( arr[0], arr[1] );
ELEM_SORT( arr[3], arr[5] );
ELEM_SORT( arr[2], arr[6] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[3], arr[6] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[1], arr[4] );
ELEM_SORT( arr[1], arr[3] );
ELEM_SORT( arr[3], arr[4] );
return ( arr[3] );
case 8:
arr[0] = array[0];
arr[1] = array[1];
arr[2] = array[2];
arr[3] = array[3];
arr[4] = array[4];
arr[5] = array[5];
arr[6] = array[6];
arr[7] = array[7];
// No convenient macro to get the median
// of eight elements, so resorted to an
// ugly insertion sort here:
ELEM_SORT( arr[0], arr[1] );
ELEM_SORT( arr[6], arr[7] );
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[5], arr[6] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[3], arr[4] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[5], arr[6] );
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[6], arr[7] );
ELEM_SORT( arr[0], arr[1] );
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[5], arr[6] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[3], arr[4] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[5], arr[6] );
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[2], arr[3] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[3], arr[4] );
ELEM_SORT( arr[2], arr[3] );
return( (arr[3] + arr[4]) / 2);
case 9:
arr[0] = array[0];
arr[1] = array[1];
arr[2] = array[2];
arr[3] = array[3];
arr[4] = array[4];
arr[5] = array[5];
arr[6] = array[6];
arr[7] = array[7];
arr[8] = array[8];
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[7], arr[8] );
ELEM_SORT( arr[0], arr[1] );
ELEM_SORT( arr[3], arr[4] );
ELEM_SORT( arr[6], arr[7] );
ELEM_SORT( arr[1], arr[2] );
ELEM_SORT( arr[4], arr[5] );
ELEM_SORT( arr[7], arr[8] );
ELEM_SORT( arr[0], arr[3] );
ELEM_SORT( arr[5], arr[8] );
ELEM_SORT( arr[4], arr[7] );
ELEM_SORT( arr[3], arr[6] );
ELEM_SORT( arr[1], arr[4] );
ELEM_SORT( arr[2], arr[5] );
ELEM_SORT( arr[4], arr[7] );
ELEM_SORT( arr[4], arr[2] );
ELEM_SORT( arr[6], arr[4] );
ELEM_SORT( arr[4], arr[2] );
return( arr[4]) ;
// Handle the general case (not one of the above) here:
default:
// The quick_select algorithm modifies the array.
// Therefore, you need to make a copy of it prior
// to use.
memcpy( arr, array, n*sizeof( int16_t ) );
low = 0;
high = n-1;
median = (low + high) / 2;
for( ever )
{
if (high <= low) // One element only
{
return arr[median];
} // endif
if (high == low + 1) // Two elements only
{
return (arr[low] + arr[high]) / 2;
} // endif
// Find median of low, middle, and high items;
// swap into position (low).
middle = (low + high) / 2;
if (arr[middle] > arr[high])
{
ELEM_SWAP(arr[middle], arr[high]);
} // endif
if (arr[low] > arr[high])
{
ELEM_SWAP(arr[low], arr[high])
} // endif
if (arr[middle] > arr[low])
{
ELEM_SWAP(arr[middle], arr[low]);
} // endif
// Swap low item (now in position middle)
// into position (low+1).
ELEM_SWAP(arr[middle], arr[low+1]) ;
// Nibble from each end towards middle,
// swapping items when stuck.
ll = low + 1;
hh = high;
for( ever )
{
do ll++; while (arr[low] > arr[ll]);
do hh--; while (arr[hh] > arr[low]);
breakif (hh < ll);
ELEM_SWAP(arr[ll], arr[hh]);
} // endfor
// Swap middle item (in position low) back
// into correct position.
ELEM_SWAP(arr[low], arr[hh]);
// Reset active partition.
if (hh <= median)
{
low = ll;
} // endif
if (hh >= median)
high = hh - 1;
} // endfor
} // end switch
} // quick_select
#undef ELEM_SWAP
插入已排序列表的时间复杂度为 O(lg n),其中 n 是列表中元素的数量。如果你保持一个包含最后 n 次读取结果的已排序列表,你可以更高效地计算中值(尽管移除最旧的元素可能有点棘手)。然而,这是一本关于 I²C 编程的书,而不是算法开发书,因此我将把进一步的优化留给有兴趣的读者。
14.10 章节总结
本章介绍了 ADS1015 和 ADS1115 模拟到数字转换器的编程和使用。首先讨论了通用 ADC 的规格和特点,并特地提到了 ADS1x15 设备的一些特殊功能,为本章讨论提供了适当的背景。
由于大多数 ADC 具有有限的电压输入范围,因此你通常需要添加额外的模拟电路来调理现实世界的信号,即将输入信号转换为适合 ADC 的信号。由于 ADS1x15 IC 的输入范围被限制在 0 V 到 4.095 V(在全范围模式下)的(有点)不寻常的范围内,本章提供了一个运算放大器电路,它将 ±10 V 范围内的电压转换为 ADS1x15 IC 可接受的范围。
在讨论了模拟信号调理后,本章深入讲解了 ADS1x15 设备上存在的寄存器,以及如何通过 I²C 总线编程它们。章节描述了寄存器中的各种位以及如何初始化和使用 ADS1x15。
由于 ADS1x15 部件是表面贴装设备,它们在典型的原型面包板上接线有些困难。因此,本章简要介绍了 Adafruit 为 ADS1015 和 ADS1115 集成电路提供的 breakout 板。Adafruit 还很贴心地为 Arduino 和 Raspberry Pi 系统提供了示例库代码,本章提供了相关链接。
虽然你可以使用 Adafruit 库通过 ADS1x15 设备读取模拟数据,但本章的目标是教你如何直接编程控制 ADS1x15 部件。因此,本章还提供了直接编程控制芯片并获取模拟数据的示例程序。还讨论了如何提高(相对较慢的)ADC 部件的性能,并简要介绍了如何与 ADS1x15 一起使用中断。
本章最后指出,ADC 输入通常会有一些噪声。然后介绍了一种滤波算法(使用算术平均值和中值平均值)来产生更干净的输入。
第十五章:MCP4725 数字到模拟转换器

第十四章介绍了一个 ADC,ADS1x15。本章描述了相反的功能:DAC。虽然 DAC 在系统中的出现频率比 ADC 低,但理解如何编程 DAC 仍然至关重要。本章描述了本书中所有软件示例中使用的 MCP4725 DAC,并补充了此前编码示例中未涉及的几个细节。
MCP4725 DAC 是一种常见的设备,Adafruit 和 SparkFun 都生产其扩展板。它是一个单一的 12 位转换器,将 0 到 4095 范围内的整数转换为 0 V 到 Vdd 之间的电压,Vdd 是 DAC 的电源引脚。
该 DAC 工作在 2.7 V 至 5.5 V 的电源范围内。这意味着其输出电压也将在该范围内。使用 3.3 V 电源时,这对应于每个单位 0.81 mV;使用 5 V 电源时,这对应于每个单位 1.22 mV。
如果你需要生成其他电压范围,可以将该 DAC 的输出信号输入到一个运算放大器电路中,正如图 15-1 所示。该电路将 DAC 的输出转换为范围在±10 V 之间的任意电压(可调)。
由于本书的大部分内容都使用了 MCP4725 作为示例代码,因此我将省略在此提供新示例的冗余内容。可以查看第八章中的清单 8-2 或 8-3,以回顾之前的一些示例。
15.1 MCP4275 概述
MCP4725 支持单一地址引脚,允许你选择两个地址中的一个。在内部,MCP4725 实际上支持 3 个地址位;然而,其中两个地址位在制造过程中是硬编码的。你可以订购最多四个不同的部件,基本地址分别为 0x60、0x62、0x64 或 0x66。MCP4725 上的地址引脚可以进一步区分地址 0x60 和 0x61,0x62 和 0x63,0x64 和 0x65,以及 0x66 和 0x67。
MCP4725 包括一个板载 14 位 EEPROM,它在上电/复位时加载电源关闭模式和初始输出设置。这允许你在启动时将 DAC 输出强制为特定电压。为了理解为何默认值不应该是 0 V,想象你正在驱动一个如图 15-1(从图 14-1 复制)的运算放大器电路,经过校准后能够基于输入 0 V 到 5 V 产生从−10 V 到+10 V 的输出。这意味着 DAC 需要输出+2.5 V,以便在运算放大器电路的输出端产生 0 V,这将是适当的上电复位电压。当然,EEPROM 的另一个用途是允许系统在启动时恢复 DAC 电压到上次断电时的值——例如,它可以在启动时禁用 DAC 输出。
MCP4725 能够在标准速度(100 kHz)、快速速度(400 kHz)或高速(最高可达 3.4 MHz)下运行。如果你的 CPU 或 SBC 支持高速操作,这将允许你以接近 200 kHz 的速度更新波形。
MCP4725 易于编程,这也是本书在大多数通用示例中使用它的原因。它有三种基本的命令格式:
-
一个具有三个总线事务的快速写入命令(可扩展)
-
一个具有四个总线事务的写入命令(可扩展)
-
一个具有六个总线事务的读取命令

图 15-1:提供跨度(增益)和零点(偏移)功能的运算放大器电路
写入命令通常以起始条件和 I²C 地址字节开始,LO 位包含 0。地址字节之后的字节包含命令,HO 的 2 或 3 位(见图 15-2)。

图 15-2:写入命令的前两个字节
表 15-1 列出了 MCP4725 响应的命令。
表 15-1:MCP4725 命令
| C[2] C[1] C[0] | 命令 |
|---|---|
| 00x^(*) | 快速写入命令 |
| 010 | 写入 DAC 寄存器 |
| 011 | 写入 DAC 和 EEPROM |
| 1xx | 预留用于未来使用 |
| ^(*)x/xx = 无关 |
如果表 15-1 中的 C[2]和 C[1]都为 0(即快速写入命令),则 C[0]用作其中一个关机位。我将在下一节中进一步解释。
只有一个读取命令。发送一个地址字节,其中 LO 位为 1,即可调用读取命令(请参见本章后面 15.5 节“读取命令”)。
15.2 快速写入命令
由于向 DAC 寄存器写入值是 MCP4725 上最常见的操作,因此该 IC 支持一个命令,使您能够使用最少的三个 I²C 总线事务来写入新的 DAC 值(见图 15-3)。事务的第二个字节包含三条信息:命令(0b00)在 HO 的 2 位,电源关机选择码在第 4 和第 5 位(请参见本章后面 15.4 节“关机模式”),以及 12 位 DAC 值的 HO 4 位。第三个字节包含 DAC 值的 LO 8 位。
如图 15-3 所示,DAC 值是一个无符号的 12 位二进制数。0xFFF 产生 DAC 的最大电压(Vdd),而 0x000 产生 0 V。

图 15-3:快速写入命令格式
如图 15-3 所示,您可以使用快速写入命令向 DAC 寄存器发送一系列命令。您可以在前三个事务和停止条件之间指定任意数量的 16 位值对(尽管许多库限制了每次可以写入到 I²C 总线的字节数;例如,Arduino 库将此限制为大约 32 个字节)。这使得您能够通过每次电压变化写入两个字而不是三个字,从而创建更快的波形,尽管当您不得不发送新的起始条件和新的地址字节时,可能会偶尔出现短暂的波动。
15.3 写入命令
标准的 MCP4725 写入命令有两种形式:写入 DAC 寄存器(Fast Write 命令的长格式,C[2]C[1]C[0] = 0b010)和写入 DAC 寄存器与 EEPROM(C[2]C[1]C[0] = 0b011)。这种形式需要至少 4 字节来执行其功能,比 Fast Write 命令多 1 字节(参见图 15-4)。

图 15-4:写入命令格式
大多数情况下,你不会使用标准的写入命令将数据写入 DAC 寄存器,因为使用 Fast Write 命令总是更快。唯一支持使用该命令写入 DAC 数据的理由是数据格式不同:它方便地将数据放置在 2 字节的高 12 位中,这与 12 位的 ADS1015 ADC 兼容。例如,然而,考虑到 I²C 总线事务的额外成本,最好将数据移动到 Fast Write 操作的正确位置。
这个命令的主要原因(可以说是唯一的理由)是你可以用它来编程 MCP4725 的 EEPROM 数据。这使你能够设置系统上电/复位时的电压。EEPROM 保存 12 位数据加上 2 个关机模式位。
请注意,编程 EEPROM 大约需要 50 毫秒。在此期间,MCP4725 会忽略任何新的写入命令。你可以通过轮询状态寄存器中的忙碌位来确定 EEPROM 写操作何时完成(见第 15.5 节,“读取命令”)。
EEPROM 的寿命大约是 100 万次写入周期。虽然这通常超过了普通设计师的需求,但频繁写入 EEPROM 会导致其损耗。一般来说,只有在显式初始化时或系统关机时才写入 EEPROM。如果你选择后者,DAC 在电源恢复时将以上次的输出电压启动。
15.4 关机模式
P[1] 和 P[0] 位允许你将 MCP4725 设置为特殊的关机模式或正常模式。表 15-2 显示了这些位的功能。
表 15-2:关机位
| P[1]P[0] | DAC 输出 | 下拉电阻 |
|---|---|---|
| 00 | 已启用 | 无 |
| 01 | 关闭 | 1 kΩ |
| 10 | 关闭 | 100 kΩ |
| 11 | 关闭 | 500 kΩ |
在正常操作过程中,当你期望 DAC 输出电压时,应该将这 2 位设置为 0b00。如果不使用 DAC 输出,则可以将这些位设置为 0b01、0b10 或 0b11;在这三种情况下,都会断开 DAC 输出与 IC 和分立板上的 V[out] 引脚连接。这些关机值还会将一个下拉电阻连接到 V[out] 引脚,以便输出 0 V,而不是漂浮,这通常会产生噪声。使用的下拉电阻值取决于跟随 DAC 的电路。通常,电阻越小,抗噪声能力越强,但较低的电阻也可能会造成阻抗问题。正确的选择取决于你的电路设计。
顾名思义,断电模式旨在减少在低功耗环境下的电力消耗。如果你不担心功耗,可以将设备保持在正常模式。
15.5 读取命令
数模转换器(DAC)本质上是一个只输出的设备,因此读取 DAC 并不常见。你会在以下四种情况下从 MCP4725 读取数据:
-
确定你(或其他线程)最后一次写入 DAC 寄存器的值。
-
确定最后一次写入 MCP4725 EEPROM 的值。
-
确定 MCP4725 何时完成其上电/复位周期,以便开始向其发送写入命令。
-
读取忙碌位的状态,以确定 MCP4725 何时完成向 EEPROM 写入数据。
如图 15-5 所示,读取命令以通常的启动条件开始,并且地址字节的低位(LO bit)包含 1。在控制器将此命令放置到总线上之后,MCP4725 将以 5 字节的数据序列响应。第一个字节是一个状态字节,描述系统状态和断电设置。接下来的 2 个字节包含当前的 DAC 寄存器数据(出现在这 2 个字节的高 12 位)。最后 2 个字节包含 EEPROM 数据(断电位和上电/复位后的 DAC 寄存器值)。可以看到,DAC 寄存器数据出现在接收到的第二、第三字节和第四、第五字节的不同位置。

图 15-5:读取命令格式
设置字节的高位(HO bit)是 RDY(BUSY)位。这个位在 MCP4725 正在忙于向 EEPROM 写入数据时为 0。当此位为低时,DAC 不会接受写入命令。在向 EEPROM 写入数据之后,你应该持续循环,测试此位,直到它返回 1。
当前设置字节的第 6 位是上电复位位。在系统忙于复位时,无论是上电还是通过总线调用复位功能,该位将为 0。在复位操作期间不要执行任何写入命令。
当前设置字节的第 1 位和第 2 位提供当前的断电设置。这与 EEPROM 寄存器数据字节中的同 2 位不同,后者指定在上电/复位操作期间,断电位将被初始化为何值。
15.6 本章总结
本章描述了 MCP4725 数模转换器的使用。当然,到现在你应该已经非常熟悉它,因为它已成为本书中大多数例子的 I²C 外设。
本章首先填补了书中例子中未涉及的 MCP4725 的细节。它提供了设备的概述,然后描述了快速写入和标准写入命令,以及如何向板载 EEPROM 写入数据。接着,讨论了断电模式。最后,本章讨论了读取命令,用于读取芯片上 EEPROM 的内容、当前 DAC 设置,并测试 DAC 忙碌位。
第十六章:裸机外设编程

第十一章描述了如何在寄存器级别编程 I²C 控制器设备。本章提供了与此信息互补的内容,展示了如何在机器寄存器级别将 MCU 编程为 I²C 外设,允许你创建自己的 I²C 外设。为此,本章探索了一个相当全面的编程示例,运行在 ATtiny84 MCU 上,一个 SparkFun Atto84 开发板上。
在像 ATtiny84 这样简单且速度较慢的 MCU 上创建基于软件(位级控制)的 I²C 外设几乎是不可能的(请参见第三章,该章节已放弃这一任务)。幸运的是,ATtiny84 提供了硬件支持,使得本章所涉及的外设编程成为可能,履行了第三章中承诺提供 ATtiny84 支持 I²C 外设的承诺。
16.1 ATtiny 作为 I²C 外设
ATtiny84——事实上,Atmel ATtiny 系列的大部分成员——提供了几种硬件支持,显著减少了处理 I²C 总线事务时软件的负担。首先也是最重要的支持是通用串行接口(USI),一个通用的移位寄存器,能够处理最多 8 位数据,直到溢出(尽管它可以编程为处理更少的位)。在 ATtiny84 上,你可以使用 USI 端口实现 I²C、SPI、串行、USB 和其他类型的通信(因此它名字中的 Universal 代表了这一点)。
你可以通过编程 USI 接收来自外部引脚的数据,并将这些串行数据作为 8 位字节(串行转并行模式)提供,或者使用 USI 接收 8 位数据值并将其串行输出到某个引脚。通过将 USI 的输入和输出连接到 SDA 引脚,你可以启用 USI 从 I²C 总线接收数据或将数据传输到 I²C 总线。你还可以选择 USI 的时钟源,使用内部定时器或外部引脚。这对于 I²C 操作非常有用——如果你选择外部选项并使用 SCL 引脚作为时钟源,你可以将数据与 SCL 时钟信号同步地移入 USI。
除了 USI,ATtiny84 还提供了一个起始条件检测器和一个移位寄存器溢出中断,允许你快速处理一些在 8 MHz CPU 的软件中难以管理的 I²C 条件。
USI 和其他支持硬件并不是真正的 I²C 接口;事实上,Atmel 通常将其称为双线接口,因为它们并不完全支持 I²C 标准。例如,USI 不支持故障滤波或速率控制。你还必须接受其他妥协(例如需要在软件中进行大量工作),因为 USI 并不是专门为 I²C 通信设计的。它是一个多面手,但不是专家。
本章中的信息来源于两个主要来源。第一个是 Atmel AVR312 应用说明《将 USI 模块用作 I²C 从设备》,该说明描述了如何使用 USI 实现 I²C 通信。第二个也是最大的来源是 TinyWire 库(请参阅本章末尾的“更多信息”)。该网站列出了许多贡献者,包括 BroHogan、Don Blake、Jochen Toppe 和 Suovula;完整的详细信息请查看源代码。
本章不会重复讨论第十一章中出现的 ATtiny84 MCU 寄存器。有关寄存器和 USI 的详细信息,请参考第十一章第 11.2 节《ATtiny 控制器编程》。
16.2 介绍内存外围设备
使用像 ATtiny84 这样的通用微控制器,你可以创建各种不同的外围设备。例如,凭借其板载的数字 I/O 和模拟输入引脚,你可以轻松地使用该设备构建一个小型端口扩展器或 ADC。或者,你还可以创建更复杂的设备,如 NeoPixel 驱动程序(参见“更多信息”)或其他任何你能够连接到 Atto84 的设备(减去两个引脚,用于 SDA 和 SCL 线)。
本章将向你展示如何创建一个简单的 4 字节内存外围设备,虽然这个设备在实际应用中可能没什么用处。然而,使用这个几乎是微不足道的设备有一些优势:
-
因为它简单,所以容易理解。你不会浪费时间去弄明白设备如何工作,除了预定的挑战:学习如何创建一个 I²C 外围设备。
-
它减少了你需要阅读的代码量(以及你需要支付的本书页数)。
-
它提供了一个框架,用于创建更复杂的设备:你可以轻松去除不必要的内存设备代码,并插入你自己实际设备的代码。
这个内存设备支持四个内存位置。你可以通过单次操作读取和写入这些位置中的任意数量。你还可以指定数组的起始偏移量;如果你指定的长度超出了数组的末尾,索引会自动回绕到数组的开头。
I²C 写入命令采用图 16-1 所示的形式。

图 16-1:外围设备写入命令
写入命令实际上有两个目的:正如它的名字所暗示的那样,它允许你将数据写入四个内存位置(寄存器)。它还允许你指定内存地址(寄存器号),后续的读取命令将从该地址获取数据,并且可以指定要获取的数据量。
读取命令采用图 16-2 所示的形式。

图 16-2:外围设备读取命令
请注意,在 I²C 写事务中,命令字节紧跟在 I²C 地址字节后面。低 3 位(sss)指定数据传输长度,必须在 0 到 4 的范围内;更大的值会被限制为 4。第 3 位和第 4 位(rr)指定寄存器数组的起始偏移量(寄存器号)。第 5 位(d)指定数据方向:0 表示 rrsss 位应用于紧随命令字节之后的数据,这些数据将写入寄存器,而 1 则告诉外设忽略任何进一步的数据(直到出现停止条件),并将 rrsss 位用于下一个 I²C 读取操作。
考虑以下 I²C 总线序列:
40 04 01 02 03 04
40 24
41 `ww xx yy zz`
第一行的第一个字节是一个 I²C 写操作,针对地址为 0x20(内存外设)的设备。命令字节指定 d = 0(内存写),rr = 0,sss = 4。此操作将 4 个字节(1、2、3 和 4)写入内存位置 0 到 3。
第二行也是一个 I²C 写操作。命令字节指定 d = 1(内存读取),rr = 0,sss = 4。当 d 位为 0 时,命令字节后没有数据负载。这个写操作看起来像是指定一个内存读取操作,虽然这可能有些奇怪;实际上,它只是为数据读取准备外设的读取参数寄存器。
本例中的第三行是一个 I²C 读取操作(地址字节的 LO 位为 1)。当控制器将该地址字节放到总线上时,外设通过返回 4 个字节(由第二行的写命令指定的字节数)作出响应。在此案例中,ww、xx、yy 和 zz 实际上将是 01、02、03 和 04,因为第一行的命令已将这些值写入寄存器。
这个内存外设具有一些额外的语义,但你现在应该理解代码中发生的核心内容。对于更具体的问题,如地址环绕、多次读操作等,我将引导你查看源代码。
16.3 内存外设软件架构
内存外设软件有四个主要组件:
-
初始化代码
-
中断服务例程
-
从 ISRs 到主程序的回调
-
主循环
程序被分为两个部分:一个主文件,包含 Arduino 的setup()和loop()函数,以及回调函数;另一个文件包含处理 I²C 操作的库代码——具体来说,是中断服务例程(ISRs)和实用函数(接下来的段落将简要描述这些)。
setup()函数调用 ISR 库初始化函数,并设置指向回调函数的指针地址。主 Arduino 循环为空(当没有中断处理时,CPU 只是空闲旋转),因为所有工作都由 ISRs 处理。在更复杂的外设设计中,你可能会在主循环中处理后台活动。
这个程序中有三个回调函数,ISRs 会在三种情况中调用它们:
-
在响应 I²C 读取请求之前,从外设传输任何数据到控制器
-
在响应 I²C 读取请求之后,从外设传输数据到控制器时,期望从控制器接收到 ACK 或 NAK
-
在收到来自控制器的数据后,以响应 I²C 写入请求
这些回调函数通常负责提供要发送到控制器的数据(用于 I²C 读取操作),或处理来自控制器的数据(用于 I²C 写入操作)。
系统中的两个主要中断服务例程处理两个事件:启动条件的存在和 I²C 总线上数据字节事务(接收或传输)的完成。由于这些事件发生频率较低,且不会占用过多处理器时间,因此 I²C 事务不会像位操作法那样淹没 CPU(有关位操作法,请参见第三章)。
16.3.1 主文件
本节将讲解attiny84_Periph.ino,这是内存外设设备的主程序。由于此源文件的大小和复杂性,我将其拆分成若干部分并分别描述每一部分。
第一部分涵盖了你在典型的 C 或 C++程序中常见的注释和#include语句:
// attiny84_Periph.ino
//
// Implements a simple I2C peripheral
// running on a SparkFun Atto84 (ATtiny84).
//
// I2C protocol for this device:
//
// For I2C write operations:
//
// |adrs+W| cmd | optional Data |
//
// cmd:
// 00drrsss
//
// where d is 0 for write operation and 1 for read,
// rr specifies a "register number" (which is an index
// into "i2c_regs" array below), and sss is a size (0-4).
//
// For write operations (d = 0) then, there will be sss
// additional bytes (max 4) following the cmd byte.
// These bytes will be written to i2c_regs starting
// at offset rr (wrapping around to the beginning
// of the array if sss+rr >= 4).
//
// For read operations (d = 1), any additional data beyond
// the cmd byte is ignored (up to the stop condition).
//
// For I2C read operations:
//
// |adrs+R| Data |
//
// where "Data" is the number of bytes specified by the
// last I2C write operation with d = 1 (number of bytes will
// be sss). Data transferred is from i2c_regs starting at
// location rr (from the last write operation).
//
// Consecutive I2C read operations, without intervening
// writes, will read the same locations from the registers.
#define I2C_PERIPH_ADDRESS 0x20 // The 7-bit address
#define __AVR_ATtiny84__
extern "C" {
#include <inttypes.h>
#include "usiI2CPeriph.h"
#include <avr/interrupt.h>
}
#include "Arduino.h"
接下来,内存外设程序将 4 个字节存储到i2c_regs变量中:
// attiny84_Periph.ino (cont.)
//
// 4-byte R/W register area
// for this sample program:
volatile uint8_t i2c_regs[4] =
{
0xDE,
0xAD,
0xBE,
0xEF,
};
// Tracks the current register pointer position
// for read and write operations.
//
// *_position holds the index into i2c_regs
// where I2C reads and writes will begin.
//
// *size holds the number of bytes for
// the read/write operation.
volatile byte reg_position; // For writes
volatile byte size; // For writes
volatile size_t read_position; // For reads
volatile size_t read_size; // For reads
const byte reg_size = sizeof( i2c_regs );
// Command byte bits:
#define cmdSizeMask (0b000111)
#define cmdRegMask (0b011000)
#define cmdDirMask (0b100000)
该程序部分还包含全局变量,用于跟踪最后一个命令字节中的 rr 和 sss 值。这里有两组这些变量——一组用于内存读取操作(d = 1),另一组用于内存写入操作(d = 0)。此部分还包括一些用于命令位掩码的定义。
下一节开始回调例程。中断服务例程在完成向控制器传输一个字节以响应 I²C 读取命令后,会调用requestHandledEvent()函数:
// attiny84_Periph.ino (cont.)
//
// requestHandledEvent-
//
// Called after data has been shipped
// to the controller.
void requestHandledEvent()
{
}
此时,代码期望从控制器接收到一个 ACK 或 NAK。通常,这个函数会处理清理工作(如清除缓冲区、关闭电子信号等)。然而,由于内存外设无需清理,因此此函数简单地返回。对于这个项目,技术上你可以跳过初始化该函数的指针,因为库的默认条件是无操作;我加入它只是为了让你知道它在系统中的存在。
接下来是requestEvent()回调函数。当收到 I²C 读取命令时,中断服务例程会调用此函数,实际传输任何数据到控制器之前。
// attiny84_Periph.ino (cont.)
//
// requestEvent-
//
// Called before data has been shipped
// to the controller.
void requestEvent()
{
for( size_t i=0; i < read_size; ++i )
{
size_t index = (read_position+i) % reg_size;
usiI2CTransmitByte( i2c_regs[index] );
}
}
理论上,你可以使用此函数初始化输出流,向控制器发送 ISRs 将传输的数据,但这段代码使用usiI2CTransmitByte()来实现这个目的。与 Arduino 的Wire.write()函数类似,requestEvent()并不实际传输数据;该函数只是将数据附加到一个内部缓冲区中。ISRs 将在稍后处理实际的数据传输。在这段源代码中,这个缓冲区的最大长度为 16 个字节。如果你试图将超过 16 个字节的数据插入缓冲区,代码会阻塞,直到空间可用。对于内存外设,requestEvent()仅获取由read_size变量指定的字节数(由前一个写操作中的命令字节的 sss 字段填写),并从read_position全局变量(来自 rr 字段)指定的偏移量开始。
接下来,回调函数receiveEvent()处理在 I²C 写操作期间从控制器接收的数据流。
// attiny84_Periph.ino (cont.)
//
// receiveEvent-
//
// Called when data has been received
// from the controller.
//
// Parse the received data and set up the
// position and size variables as needed.
// If optional data arrives, store it into
// the i2c_regs array.
//
// rcvCnt parameter specifies the
// number of bytes received.
void receiveEvent( uint8_t rcvCnt )
{
byte cmd;
// Punt if controller sent too
// much data...
if( rcvCnt > I2C_RX_BUFFER_SIZE )
{
return;
}
// ...or too little:
if( rcvCnt > 0 )
{
cmd = usiI2CReceiveByte();
size = cmd & cmdSizeMask;
// cmdSizeMask is 3 bits, but
// the maximum size is 4.
// Enforce that here:
if( size > 4 )
{
size = 4;
}
reg_position = cmd & cmdRegMask;
reg_position >>= 3;
// Determine if the controller is
// specifying a read operation or a
// write operation. This is not the
// R/W bit in the address byte (you
// got here on an I2C write). The
// direction bit specifies whether
// whether you can expect an I2C
// read operation after this command
// to read the specified data.
if( cmd & cmdDirMask )
{
// A read command, just set up
// the count and pointer values
// for any upcoming reads.
read_size = size;
read_position = reg_position;
}
else // A write command
{
// Copy any additional data the
// controller sends you to the
// i2c_regs array. Note that
// this code ignores any bytes
// beyond the fourth one the
// controller sends.
byte maxXfer = 4;
while( --rcvCnt && maxXfer-- )
{
i2c_regs[reg_position] = usiI2CReceiveByte();
reg_position++;
if( reg_position >= reg_size )
{
reg_position = 0;
}
}
}
}
}
receiveEvent()函数负责从命令字节中提取位,解析命令,并处理命令字节之后出现的任何额外数据(即,将数据写入i2c_regs数组)。
接下来,setup()函数调用 ISR 库代码的初始化函数usiI2CPeripheralInit(),该函数完成大部分实际工作,然后设置回调函数的地址。
// attiny84_Periph.ino (cont.)
//
// Usual Arduino initialization code
// even for an I2C peripheral application.
void setup()
{
// Initialize the peripheral code:
usiI2CPeripheralInit( I2C_PERIPH_ADDRESS );
// Set up callbacks to local functions:
usi_onReceiverPtr = receiveEvent;
usi_onRequestPtr = requestEvent;
usi_afterRequestPtr = requestHandledEvent;
}
setup()函数还必须初始化回调函数的指针。由于 ISR 执行所有实际工作,主 Arduino 循环是空的:
// attiny84_Periph.ino (cont.)
//
// The main loop does nothing but spin its
// wheels. All the work in this sample
// program is done inside the ISRs and
// callback functions. If this peripheral
// were a little more complex, background
// activities could be taken care of here.
void loop()
{
// Do nothing.
}
如果这个外设设备稍微复杂一些,main()函数可以在等待 I²C 命令到达时处理一些后台任务。
16.3.2 中断服务例程库
本节将讨论usiI2CPeriph.c,这是 ISR 模块的源代码。它是由 Donald Blake 编写的原始 AVR 双线外设代码的修改版,并由 Jochen Toppe 进行了修改。我进一步修改了它,以适应attiny84_Periph.ino中的示例应用。
和上一节一样,我将逐行描述这段代码。由于这段代码实现了 Atmel 的 AVR312 应用笔记中描述的架构,因此有一份应用笔记作为参考在阅读这段代码时会很有帮助(见“更多信息”)。
像往常一样,源文件的第一部分包含简介注释、头文件包含以及一些重要的定义和宏:
// usiI2CPeriph.c
//
// USI I2C Peripheral driver.
//
// Created by Donald R. Blake, donblake at worldnet.att.net.
// Adapted by Jochen Toppe, jochen.toppe at jtoee.com.
// Further modifications by Randall Hyde for "The Book of I2C."
//
// ----------------------------------------------------------
//
// Created from Atmel source files for Application Note
// AVR312: Using the USI Module as an I2C peripheral.
#include <avr/io.h>
#include <avr/interrupt.h>
#include "usiI2CPeriph.h"
#define breakif(x) if(x) break
// Device dependent defines:
#define DDR_USI DDRA
#define PORT_USI PORTA
#define PIN_USI PINA
#define PORT_USI_SDA PORTA6
#define PORT_USI_SCL PORTA4
#define PIN_USI_SDA PINA6
#define PIN_USI_SCL PINA4
#define USI_START_COND_INT USISIF
#define USI_START_VECTOR USI_START_vect
#define USI_OVERFLOW_VECTOR USI_OVF_vect
// These macros make the stop condition detection code
// more readable.
#define USI_PINS_SCL_SDA \
( \
( 1 << PIN_USI_SDA ) \
| ( 1 << PIN_USI_SCL ) \
)
#define USI_PINS_SDA ( 1 << PIN_USI_SDA )
#define USI_PINS_SCL ( 1 << PIN_USI_SCL )
DDRA、PORTA、PORTA6、PINA6、PINA4、USISIF、USI_START_vect和USI_OVF_vect定义出现在avr/io.h头文件中。
USI 溢出ISR(来自 AVR312,在此代码中为ISR( USI_OVERFLOW_VECTOR ))实现了一个状态机,符合 AVR312 的描述。ISRstate_t类型定义为此函数实现的每个状态提供有意义的名称。请参阅代码注释,了解每个状态的描述:
// usiI2CPeriph.c (cont.)
//
/***********************************************************
typedef's
************************************************************/
// ISRstate_t are the different states possible for
// the ISR state machine that handles incoming
// bytes from the controller.
typedef enum
{
// Address byte has just arrived:
USI_PERIPH_CHECK_ADDRESS = 0x00,
// Peripheral is transmitting bytes to
// the controller (I2C read transaction).
USI_PERIPH_SEND_DATA = 0x01,
// Receive an ACK from controller after sending
// a byte to it.
USI_PERIPH_REQUEST_REPLY_FROM_SEND_DATA = 0x02,
// Deals with ACK or NAK received from
// controller after sending a byte
// to the controller (I2C read).
USI_PERIPH_CHECK_REPLY_FROM_SEND_DATA = 0x03,
// Handle data coming to the peripheral
// (I2C write operation).
USI_PERIPH_REQUEST_DATA = 0x04,
USI_PERIPH_GET_DATA_AND_SEND_ACK = 0x05
} ISRstate_t;
接下来是一些全局变量(仅限于当前源文件):
// usiI2CPeriph.c (cont.)
//
/***********************************************************
local variables
************************************************************/
// periphAddress holds the 7-bit I2C address.
static uint8_t periphAddress;
static uint8_t sleep_enable_bit;
static uint8_t in_transaction;
static volatile ISRstate_t ISRstate;
static uint8_t rxBuf[I2C_RX_BUFFER_SIZE];
static volatile uint8_t rxHead;
static volatile uint8_t rxTail;
static volatile uint8_t rxCount;
static uint8_t txBuf[I2C_TX_BUFFER_SIZE];
static volatile uint8_t txHead;
static volatile uint8_t txTail;
static volatile uint8_t txCount;
这些变量的使用方式如下:
-
periphAddress保存外设设备的 I²C 地址(例如,内存外设设备的地址为 0x20) -
sleep_enable_bit保存 MCUCR 寄存器中 SE 位的状态,因为该位会在 ISR 中修改 MCUCR 时被覆盖 -
in_transaction一个布尔变量,用于跟踪你是否正在进行 I²C 事务(即,在进入和退出溢出 ISR 时,还没有看到停止条件) -
ISRstate保存当前状态值(ISRstate_t),用于溢出 ISR 状态机 -
rx*变量 接收缓冲区变量 -
tx*变量 传输缓冲区变量 -
usi_onRequestPtr指向回调函数的指针,溢出 ISR 在接收到地址字节后但在返回数据给控制器设备之前会调用此函数 -
usi_onReceiverPtr指向回调函数的指针,溢出 ISR 在接收到地址字节后但在读取控制器设备通过 I²C 写操作发送的其他数据之前会调用此函数 -
usi_afterRequestPtr指向回调函数的指针,溢出 ISR 在处理完从控制器接收的所有字节之后会调用此函数,适用于 I²C 读取操作
还有三个函数指针出现在全局声明中:usi_afterRequestPtr、usi_onRequestPtr 和 usi_onReceiverPtr。除了这些变量外,本节定义了两个空函数,通过这些函数初始化回调指针。预初始化这些函数指针可以避免代码检查这些指针是否为 NULL。
// usiI2CPeriph.c (cont.)
//
// Dummy functions so you don’t have to check if
// usi_afterRequestPtr or usi_onReceiverPtr are NULL.
static void dummy1( void ){}
static void dummy2( uint8_t d ){}
void (*usi_afterRequestPtr)( void ) = dummy1;
void (*usi_onRequestPtr)( void ) = dummy1;
void (*usi_onReceiverPtr)( uint8_t ) = dummy2;
接下来是一些仅限于当前源文件的实用支持函数。
// usiI2CPeriph.c (cont.)
//
/***********************************************************
Local functions
************************************************************/
// Flushes the I2C buffers.
static void flushI2CBuffers( void )
{
rxTail = 0;
rxHead = 0;
rxCount = 0;
txTail = 0;
txHead = 0;
txCount = 0;
} // End flushI2CBuffers
startSetConditionMode() 函数初始化 ATtiny84 的中断系统,禁用 USI 溢出中断并启用起始条件中断。例如,当一个 I²C 事务完成后,外设正在等待另一个起始条件时,就会发生这种情况。一旦代码初始化了这些中断,它就可以做其他事情(目前仅在空循环函数中旋转),直到下一个起始条件到来。
// usiI2CPeriph.c (cont.)
//
// startSetConditionMode-
//
// This initializes the interrupt system so that
// the code waits for the arrival of a start
// condition (and generates an interrupt when
// one arrives).
static void setStartConditionMode( void )
{
USICR =
// Enable Start Condition Interrupt.
( 1 << USISIE )
// Disable Overflow Interrupt.
| ( 0 << USIOIE )
// Set USI in two-wire mode.
| ( 1 << USIWM1 )
// No USI Counter overflow hold.
| ( 0 << USIWM0 )
// Shift Register Clock Source = external,
// positive edge.
| ( 1 << USICS1 )
| ( 0 << USICS0 )
// 4-Bit Counter Source = external,
// both edges.
| ( 0 << USICLK )
// No toggle clock-port pin.
| ( 0 << USITC );
// Clear all interrupt flags, except Start Cond.
USISR =
( 0 << USI_START_COND_INT )
| ( 1 << USIOIF )
| ( 1 << USIPF )
| ( 1 << USIDC )
| ( 0x0 << USICNT0 );
}
请参见第十一章中的第 11.2 节,“ATtiny 控制器编程”,以了解代码中出现的 USICR 和 USISR 寄存器的说明。
程序的下一个部分引入了主应用程序可以调用的公共函数,从主 ISR 初始化函数开始。此函数将 SDA 和 SCL 引脚设置为输出,设置为高电平(静态状态),并将系统设置为等待起始条件中断。
// usiI2CPeriph.c (cont.)
//
/***********************************************************
Public functions
************************************************************/
// Initialize USI for I2C peripheral mode.
void usiI2CPeripheralInit( uint8_t ownAddress )
{
// Initialize the TX and RX buffers to empty.
flushI2CBuffers( );
periphAddress = ownAddress;
// In two-wire (I2C) mode (USIWM1, USIWM0 = 1X),
// the peripheral USI will pull SCL low when a
// start condition is detected or a counter
// overflow (only for USIWM1, USIWM0 = 11). This
// inserts a wait state. SCL is released by the
// ISRs (USI_START_vect and USI_OVERFLOW_vect).
//
// Set SCL and SDA as output.
DDR_USI |= ( 1 << PORT_USI_SCL ) | ( 1 << PORT_USI_SDA );
// Set SCL high.
PORT_USI |= ( 1 << PORT_USI_SCL );
// Set SDA high.
PORT_USI |= ( 1 << PORT_USI_SDA );
// Set SDA as input.
DDR_USI &= ~( 1 << PORT_USI_SDA );
USICR =
// Enable Start Condition Interrupt.
( 1 << USISIE )
// Disable Overflow Interrupt.
| ( 0 << USIOIE )
// Set USI in two-wire mode.
| ( 1 << USIWM1 )
// No USI Counter overflow hold.
| ( 0 << USIWM0 )
// Shift Register Clock Source = external,
// positive edge.
// 4-Bit Counter Source = external, both edges.
| ( 1 << USICS1 )
| ( 0 << USICS0 )
| ( 0 << USICLK )
// No toggle clock-port pin.
| ( 0 << USITC );
// Clear all interrupt flags and reset overflow counter.
USISR =
( 1 << USI_START_COND_INT )
| ( 1 << USIOIF )
| ( 1 << USIPF )
| ( 1 << USIDC );
// The in_transaction variable remembers if the
// usiI2CPeriph driver is in the middle of
// an I2C transaction. Initialize it to 0.
in_transaction = 0;
} // end usiI2CPeripheralInit
接下来是一些用于测试传输和接收缓冲区中是否有数据的各种函数,以及向这些缓冲区插入数据和从中提取数据的函数:
// usiI2CPeriph.c (cont.)
//
// usiI2CDataInTransmitBuffer-
//
// Return 0 (false) if the transmit buffer is empty, true
// (nonzero) if data is available in the transmit buffer.
bool usiI2CDataInTransmitBuffer( void )
{
return txCount; // Actual count is nonzero
// if data available :)
} // End usiI2CDataInTransmitBuffer
// usiI2CTransmitByte-
//
// Adds a byte to the transmission buffer,
// wait for bytes to be transmitted
// if buffer is full.
//
// Race condition warning: As this function
// modifies txCount, it should be called only
// from the USI_OVERFLOW_VECTOR ISR or code
// called from it. Otherwise, there could
// be problems with the updates of the global
// txBuf, txHead, and txCount variables (which
// are unprotected).
//
// In particular, it is safe to call this
// function from whomever usi_afterRequestPtr,
// usi_onRequestPtr, or usi_onReceiverPtr
// point at, but you must not call this
// code from the main Arduino loop or
// setup function.
void usiI2CTransmitByte( uint8_t data )
{
// Wait for free space in buffer.
while( txCount == I2C_TX_BUFFER_SIZE ) ;
// Store data in buffer.
txBuf[txHead] = data;
txHead = ( txHead + 1 ) & I2C_TX_BUFFER_MASK;
txCount++;
} // End usiI2CTransmitByte
// usiI2CReceiveByte-
//
// Return a byte from the receive
// buffer, wait if buffer is empty.
// As above, call this only from the
// USI_OVERFLOW_VECTOR ISR or code
// called by it.
uint8_t usiI2CReceiveByte( void )
{
uint8_t rtn_byte;
// Wait for Rx data.
while( !rxCount );
rtn_byte = rxBuf[rxTail];
// Calculate buffer index.
rxTail = ( rxTail + 1 ) & I2C_RX_BUFFER_MASK;
rxCount--;
// Return data from the buffer.
return rtn_byte;
} // End usiI2CReceiveByte
// usiI2CAmountDataInReceiveBuffer-
//
// Returns the number of bytes in the
// receive buffer.
uint8_t usiI2CAmountDataInReceiveBuffer( void )
{
return rxCount;
}
接下来是处理 I²C 总线上起始条件到达的 ISR。ATtiny84 内部的特殊硬件检测到起始条件的存在并触发中断,从而调用以下代码:
// usiI2CPeriph.c (cont.)
//
/********************************************************
USI Start Condition ISR
*********************************************************/
// USI_START_VECTOR interrupt service routine.
//
// This ISR gets invoked whenever a start condition
// appears on the I2C bus (assuming the USISIE/start
// condition interrupt is enabled in USICR).
//
// The global variable "in_transaction" is nonzero if
// this is a repeated start condition (that is, haven’t
// seen a stop condition since the last start).
ISR( USI_START_VECTOR )
{
uint8_t usi_pins;
// Notes about ISR. The compiler in the Arduino IDE handles
// some of the basic ISR plumbing (unless the "ISR_NAKED"
// attribute is applied):
//
// * The AVR processor resets the SREG.I bit
// when jumping into an ISR.
// * The compiler automatically adds code to save SREG.
// * < user’s ISR code goes here >
// * The compiler automatically adds code to restore SREG.
// * The compiler automatically uses the RETI instruction
// to return from the ISR.
//
// The RETI instruction enables interrupts after the
// return from ISR.
//
// cli() call is not necessary. Processor disables
// interrupts when calling to an ISR.
//
// No need to save the SREG. The compiler does this
// automatically when using the ISR construct without
// modifying attributes.
❶ if( !in_transaction )
{
// Remember the sleep enable bit when entering the ISR.
sleep_enable_bit = MCUCR & ( 1 << SE );
// Clear the sleep enable bit to prevent the CPU from
// entering sleep mode while executing this ISR.
MCUCR &= ~( 1 << SE );
}
// Set default starting conditions for new I2C packet.
❷ ISRstate = USI_PERIPH_CHECK_ADDRESS;
// Program SDA pin as input.
DDR_USI &= ~( 1 << PORT_USI_SDA );
// The start condition is that the controller pulls SDA low
// (while SCL is high).
//
// Wait for SCL to go low to ensure the start condition
// has completed (the start detector will hold SCL low);
// if a stop condition arises, then leave the interrupt to
// prevent waiting forever. Don’t use USISR to test for
// stop condition as in Application Note AVR312, because
// the stop condition flag is going to be set from the last
// I2C sequence.
❸ while
(
( usi_pins = PIN_USI & USI_PINS_SCL_SDA )
== USI_PINS_SCL
){
// While SCL is high and SDA is low.
}
// If SDA line was low at SCL edge, then start
// condition occurred.
❹ if( !( usi_pins & USI_PINS_SDA ) )
{
// A stop condition did not occur.
// Execute callback if this is a repeated start.
if( in_transaction )
{
if( usiI2CAmountDataInReceiveBuffer() )
{
usi_onReceiverPtr
(
usiI2CAmountDataInReceiveBuffer()
);
}
}
// Now that you’ve seen a start condition,
// you need to dynamically enable the
// overflow interrupt that tells you when
// you’ve received a byte of data.
❺ USICR =
// Keep start condition interrupt
// enabled to detect RESTART.
( 1 << USISIE )
// Enable overflow interrupt.
| ( 1 << USIOIE )
// Set USI in two-wire mode, hold SCL
// low on USI Counter overflow.
| ( 1 << USIWM1 )
| ( 1 << USIWM0 )
// Shift register clock source = external,
// positive edge.
| ( 1 << USICS1 )
| ( 0 << USICS0 )
// 4-Bit Counter Source = external, both edges.
| ( 0 << USICLK )
// No toggle clock-port pin.
| ( 0 << USITC );
// Remember that the USI is in a valid I2C transaction.
in_transaction = 1;
}
else // SDA was high
{
// A stop condition did occur; reset
// the interrupts to look for a new
// start condition.
❻ USICR =
// Enable start condition interrupt.
( 1 << USISIE )
// Disable overflow interrupt.
| ( 0 << USIOIE )
// Set USI in two-wire mode.
| ( 1 << USIWM1 )
// No USI counter overflow hold.
| ( 0 << USIWM0 )
// Shift register clock source = external,
// positive edge.
| ( 1 << USICS1 )
| ( 0 << USICS0 )
// 4-Bit counter source = external,
// both edges.
| ( 0 << USICLK )
// No toggle clock-port pin.
| ( 0 << USITC );
// No longer in valid I2C transaction.
in_transaction = 0;
// Restore the sleep enable bit.
MCUCR |= sleep_enable_bit;
} // end if
USISR =
// Clear interrupt flags - resetting the Start
// Condition Flag will release SCL.
( 1 << USI_START_COND_INT )
| ( 1 << USIOIF )
| ( 1 << USIPF )
| ( 1 << USIDC )
// Set USI to sample 8 bits (count 16
// external SCL pin toggles).
| ( 0x0 << USICNT0);
} // End ISR( USI_START_VECTOR )
USI_START_VECTOR 中断服务程序首先关闭睡眠模式❶。这可以防止 CPU 在处理来自 I²C 引脚的字节时进入睡眠状态。接下来,ISR 设置状态,以便在开始条件之后立即处理地址字节❷。
while 循环会等待直到 SCL 线变低(即开始条件结束)❸,然后代码检查这是否为一个实际的开始条件(SDA 线为低电平)或停止条件(SDA 线为高电平)。如果是开始条件,ISR 会检查这是否为重新启动条件,意味着从上一个开始条件以来没有出现停止条件❹。一旦 ISR 正确识别到开始(或重新启动)条件,它会启用溢出中断,以便在 USI 接收到下一个完整字节时触发❺。如果到达了停止条件,代码会重置中断,以便寻找新的开始条件❻。
USI 溢出中断服务程序处理从 USI 到达的字节:
// usiI2CPeriph.c (cont.)
//
// USI Overflow ISR-
//
// Invoked when the shift register is full (programmable
// size, usually 1 or 8 bits). Because the byte coming
// in could be any part of an I2C transmission, this ISR
// uses a state machine to track the incoming bytes. This
// ISR handles controller reads and writes (peripheral
// writes and reads).
//
// Note that this ISR is disabled when waiting for a
// start condition to arrive (incoming bytes at that
// point are intended for a different device).
ISR( USI_OVERFLOW_VECTOR )
{
uint8_t finished;
uint8_t usi_pins;
// This ISR is only ever entered because the
// ISR(USI_START_VECTOR) interrupt routine ran
// first. That routine saved the sleep mode and
// disabled sleep.
//
// ISRstate is the state machine variable for
// the overflow.
// Most of the time this routine exits, it has set up the
// USI to shift in/out bits and is expected to have re-entered
// because of the USI overflow interrupt. Track whether or
// not the transaction is completely finished.
finished = 0;
因为每个字节可能有不同的含义,溢出 ISR 使用状态机(以及 ISRstate 变量)来跟踪字节的到达。开始条件之后到达的第一个字节是地址读写字节。LO 位(R/W)决定状态机是处理内存读取操作(R/W = 1,状态 = USI_PERIPH_SEND_DATA)还是内存写入操作(R/W = 0,状态 = USI_PERIPH_REQUEST_DATA)。
以下代码是实际状态机的开始,由 ISRstate 变量控制:
// usiI2CPeriph.c (cont.)
switch ( ISRstate )
{
// Address mode:
// Check address and send ACK (and next
// USI_PERIPH_SEND_DATA) if OK, else reset USI.
case USI_PERIPH_CHECK_ADDRESS:
if(
( USIDR == 0 )
|| (( USIDR >> 1 ) == periphAddress )
){
if( USIDR & 0x01 ) // Controller read request?
{
ISRstate = USI_PERIPH_SEND_DATA;
usi_onRequestPtr();
}
else // Must be controller write operation
{
ISRstate = USI_PERIPH_REQUEST_DATA;
} // end if
// Acknowledge the start frame.
// Sets up the USI to pull SDA low
// and clock 1 bit (two edges).
USIDR = 0; // Prepare ACK, acknowledge is a single 0
// Set SDA data direction as output.
DDR_USI |= ( 1 << PORT_USI_SDA );
// Clear all interrupt flags, except start cond.
USISR =
( 0 << USI_START_COND_INT )
| ( 1 << USIOIF )
| ( 1 << USIPF )
| ( 1 << USIDC )
| ( 0x0E << USICNT0 ); // Shift 1 bit
}
else // I2C transaction for some other device
{
setStartConditionMode();
finished = 1;
}
break;
这段代码中出现的第一个状态是 USI_PERIPH_CHECK_ADDRESS,它对应于开始条件的到来。该状态检查传入的 I²C 地址字节和 R/W 位。如果地址不匹配,代码会关闭溢出中断使能,因为代码会忽略所有传入的字节,直到新的开始条件出现;当前的总线事务是为其他设备准备的。然而,如果地址匹配,代码则会根据 R/W 位改变状态。一个状态处理额外的传入字节(I²C 写操作),另一个状态处理传出的字节(I²C 读操作)。
接下来,USI_PERIPH_CHECK_REPLY_FROM_SEND_DATA 状态会验证外设在向控制器传输一个字节后,控制器是否发送了 ACK 或 NAK(一个 I²C 读取操作)。
// usiI2CPeriph.c (cont.)
//
// USI_PERIPH_CHECK_REPLY_FROM_SEND_DATA-
//
// State that executes when you’ve received
// an ACK or a NAK from the controller after
// sending it a byte.
// Check reply and go to USI_PERIPH_SEND_DATA
// if OK, else reset USI.
case USI_PERIPH_CHECK_REPLY_FROM_SEND_DATA:
// Execute request callback after each byte’s
// ACK or NAK has arrived.
usi_afterRequestPtr();
if( USIDR )
{
// If NAK, the controller does not want more data.
setStartConditionMode();
finished = 1;
break;
}
// From here you just drop straight
// into USI_PERIPH_SEND_DATA if the
// controller sent an ACK.
如果收到 NAK,你的数据传输就结束了;如果收到 ACK,程序继续传输更多数据。如果这段代码接收到来自控制器的 ACK,它通常会将状态设置为 USI_PERIPH_SEND_DATA。然而,这段代码只是直接进入该状态,并立即将下一个字节传输给控制器,而不改变状态(无论如何,它最终会被重新设置为 USI_PERIPH_CHECK_REPLY_FROM_SEND_DATA)。
接下来,USI_PERIPH_SEND_DATA 状态会在响应读取操作时向控制器发送一个字节的数据。传输完字节后,它还会将状态设置为 USI_PERIPH_REQUEST_REPLY_FROM_SEND_DATA,以处理控制器的 ACK 或 NAK。
// usiI2CPeriph.c (cont.)
//
// Controller read operation (peripheral write operation).
//
// Copy data from buffer to USIDR and set USI to shift byte
// next USI_PERIPH_REQUEST_REPLY_FROM_SEND_DATA.
case USI_PERIPH_SEND_DATA:
// Get data from buffer.
if( txCount )
{
USIDR = txBuf[ txTail ];
txTail = ( txTail + 1 ) & I2C_TX_BUFFER_MASK;
txCount--;
ISRstate =
USI_PERIPH_REQUEST_REPLY_FROM_SEND_DATA;
DDR_USI |= ( 1 << PORT_USI_SDA );
// Clear all interrupt flags, except start cond.
USISR =
( 0 << USI_START_COND_INT )
| ( 1 << USIOIF )
| ( 1 << USIPF )
| ( 1 << USIDC)
| ( 0x0 << USICNT0 ); // Shift 8 bits
}
else
{
// The buffer is empty.
// Read an ACK:
//
// This might be necessary sometimes. See
// http://www.avrfreaks.net/index.php?name=
// PNphpBB2&file=viewtopic&p=805227#805227.
DDR_USI &= ~( 1 << PORT_USI_SDA );
USIDR = 0; // Must ship out a 0 bit for ACK
USISR =
// Clear all interrupt flags,
// except start cond.
( 0 << USI_START_COND_INT )
| ( 1 << USIOIF )
| ( 1 << USIPF )
| ( 1 << USIDC )
// Set USI ctr to shift 1 bit.
| ( 0x0E << USICNT0 );
setStartConditionMode();
} // end if
break;
接下来,USI_PERIPH_REQUEST_REPLY_FROM_SEND_DATA 状态将 USI 设置为等待单个位的到来,无论是 ACK 还是 NAK。此状态还会将状态变量更改为 USI_PERIPH_CHECK_REPLY_FROM_SEND_DATA,在 ACK 或 NAK 到达时处理它。
// usiI2CPeriph.c (cont.)
//
// This state sets up the state machine
// to accept an ACK from the controller
// device after sending a byte to the
// controller (an I2C read operation).
//
// Set USI to sample reply from controller
// next USI_PERIPH_CHECK_REPLY_FROM_SEND_DATA.
case USI_PERIPH_REQUEST_REPLY_FROM_SEND_DATA:
ISRstate =
USI_PERIPH_CHECK_REPLY_FROM_SEND_DATA;
// Read an ACK:
DDR_USI &= ~( 1 << PORT_USI_SDA );
USIDR = 0; // Must ship out a zero bit for ACK
USISR =
// Clear all interrupt flags,
// except Start Cond.
( 0 << USI_START_COND_INT )
| ( 1 << USIOIF )
| ( 1 << USIPF )
| ( 1 << USIDC )
// Set USI ctr to shift 1 bit.
| ( 0x0E << USICNT0 );
break;
接下来,USI_PERIPH_REQUEST_DATA 状态会设置系统以期望从控制器接收到一个字节(即一个 I²C 写操作)。该状态在地址字节或从控制器读取任意字节后设置(即控制器传输的字节流中的某个字节)。这段代码会延迟直到 SCL 线变高,然后查找是否存在停止条件。如果没有停止条件,系统会将状态设置为 USI_PERIPH_GET_DATA_AND_SEND_ACK,并等待下一个字节的到来:
// usiI2CPeriph.c (cont.)
//
// Controller-send / peripheral-receive-
//
// Set USI to sample data from controller,
// next: USI_PERIPH_GET_DATA_AND_SEND_ACK.
case USI_PERIPH_REQUEST_DATA:
ISRstate = USI_PERIPH_GET_DATA_AND_SEND_ACK;
// Set USI to read data.
//
// Set SDA as input.
DDR_USI &= ~( 1 << PORT_USI_SDA );
// Clear all interrupt flags, except start cond.
USISR =
( 0 << USI_START_COND_INT )
| ( 1 << USIOIF )
| ( 1 << USIPF )
| ( 1 << USIDC )
| ( 0x0 << USICNT0 ); // Read 8 bits
// With the code above, the USI has been set to catch the
// next byte if the controller sends one. While that′s
// going on, look for a stop condition here when the
// SDA line goes high after the SCL line.
//
// Wait until SCL goes high.
while
(
!(
( usi_pins = PIN_USI & USI_PINS_SCL_SDA )
& USI_PINS_SCL
)
);
// If SDA line was high at SCL edge,
// then not a stop condition.
breakif( usi_pins & USI_PINS_SDA );
while
(
( usi_pins = PIN_USI & USI_PINS_SCL_SDA )
== USI_PINS_SCL
){
// Wait until SCL goes low or SDA goes high.
};
// If both SCL and SDA are high, then stop
// condition occurred.
if( usi_pins == USI_PINS_SCL_SDA )
{
if( usiI2CAmountDataInReceiveBuffer() )
{
usi_onReceiverPtr
(
usiI2CAmountDataInReceiveBuffer()
);
}
setStartConditionMode();
finished = 1;
}
break;
如果已经从控制器接收到了一个字节,接下来的状态将从 USI 中取出该字节并将其添加到接收缓冲区。该代码还会向控制器发送一个 ACK 以响应接收到的字节:
// usiI2CPeriph.c (cont.)
//
// This state sends an ACK to the
// controller after receiving a byte
// from the controller (I2C write).
//
// Copy data from USIDR and send ACK
// next USI_PERIPH_REQUEST_DATA.
case USI_PERIPH_GET_DATA_AND_SEND_ACK:
// Put data into buffer and
// check buffer size.
if( rxCount < I2C_RX_BUFFER_SIZE )
{
rxBuf[rxHead] = USIDR;
rxHead = ( rxHead + 1 ) & I2C_RX_BUFFER_MASK;
rxCount++;
}
else
{
// Overrun, drop data.
}
// Next: USI_PERIPH_REQUEST_DATA
// (keep accepting bytes from
// the controller until a stop
// condition happens).
ISRstate = USI_PERIPH_REQUEST_DATA;
// Send acknowledge.
USIDR = 0; // Prepare ACK, acknowledge is a single 0
// Set SDA data direction as output.
DDR_USI |= ( 1 << PORT_USI_SDA );
// Clear all interrupt flags, except start cond.
USISR =
( 0 << USI_START_COND_INT )
| ( 1 << USIOIF )
| ( 1 << USIPF )
| ( 1 << USIDC )
| ( 0x0E << USICNT0 ); // Shift 1 bit
break;
} // End switch
if(finished)
{
// No longer in valid I2C transaction.
in_transaction = 0;
// Restore the sleep enable bit.
// This allows sleep but does
// not cause sleep; must execute
// the "sleep" instruction to
// actually put MCU in sleep mode.
MCUCR |= sleep_enable_bit;
}
} // End ISR( USI_OVERFLOW_VECTOR )
这部分代码处理 ATtiny84 上的 I²C 外设。除了 attiny84_Periph.ino 和 usiI2CPeriph.c 中的代码外,完整的内存外设软件还有一个小的头文件 (usiI2CPeriph.h)。由于该头文件只是重复了这两个列表中的信息,我不会在这里再现它。请参见在线源文件以获取完整的源代码。
修改 attiny84_Periph.ino 中的代码以在 Atto84 设备上实现你想要的任何外设应该是非常直接的(当然,前提是它足够强大来完成这个任务)。例如,你可以将其编程为 ADC——仅支持 10 位,因为 Atto84 内建的 ADC 是 10 位 ADC——或者作为一个小型 GPIO 扩展器。如果再做一点工作,你可以用它来创建一个 I²C NeoPixel 控制器。你的想象力仅受 ATtiny84 能力的限制。
16.3.3 一个示例控制器应用
如果你编译 attiny84_Periph.ino 和 usiI2CPeriph.c 代码,并将其编程到 SparkFun Atto84 SBC 上,那么该代码在上电后会愉快地开始执行……然后什么也不做(至少是看不见的)。因为 Atto84 变成了一个 I²C 外设,所以你必须将其连接到一个已编程为与 Atto84 通信的 I²C 控制器设备。Listing 16-1 是一个简单的 Arduino 程序,可以用来操作该内存外设。
// Listing16-1.ino
//
// A very simple Arduino application
// that exercises the Atto84 memory
// peripheral device.
#include <Wire.h>
#define periph (0x20) // Peripheral address
// Usual Arduino initialization code.
void setup( void )
{
Serial.begin( 9600 );
delay( 1000 );
Serial.println( "Test reading ATTO84" );
Wire.begin(); // Initialize I2C library
// Initialize the four registers on the
// memory device with 0x12, 0x34, 0x56,
// and 0x78.
Wire.beginTransmission( periph );
// cmd byte; d=0 (W), rr=00, sss=100 (4)
Wire.write( 0b000100 );
// Register initialization data.
Wire.write( 0x12 );
Wire.write( 0x34 );
Wire.write( 0x56 );
Wire.write( 0x78 );
Wire.endTransmission();
}
// Arduino main loop.
void loop( void )
{
static int value =0;
// Send a command to the
// memory peripheral to set
// the read address and length:
//
// d = 1 (R), rr = 00, sss = 100 (4)
Wire.beginTransmission( periph );
Wire.write( 0b100100 );
Wire.endTransmission();
delayMicroseconds( 25 );
// Read the 4 bytes from
// the memory peripheral and
// display them in the
// Arduino Serial window.
Wire.requestFrom( periph, 4 );
uint8_t b = Wire.read();
Serial.print( b, 16 );
b=Wire.read();
Serial.print( b, 16 );
b=Wire.read();
Serial.print( " " );
Serial.print( b, 16 );
b=Wire.read();
Serial.println( b, 16 );
delay( 25 );
}
如果你在一个兼容 Arduino 的系统上运行这个程序,并将其 SDA 和 SCL 线路连接到前面章节中的 Atto84,那么该程序将测试该 Atto84 I²C 外设的内存功能。
16.4 章节总结
本章介绍了如何将 SparkFun Atto84(ATtiny84)编程为 I²C 外设。首先简要讨论了 ATtiny84 通用串行接口,它用于在硬件中实现 I²C 通信。接着描述了作为 I²C 外设实现的一个简单设备:I²C 存储器设备。本章的核心内容是 I²C 存储器外设在 Atto84 上的实际实现。最后,本章以一个简单的 I²C 控制器应用程序作为结尾,适用于与 Arduino 兼容的系统,用于操作存储器外设。
第十七章:后记

这标志着《I2C 之书》的纸质版的结束。然而,这只是你学习如何编程 I²C 设备旅程的开始。现实世界中有成百上千种不同的 I²C 设备,而且新的设备不断涌现。无论本书描述多少种设备,最终都会过时,因为制造商不断推出新设备。
事实上,在本书编辑过程中,出现了两个新的重要设备:MCP4728 四通道 DAC 和树莓派 Pico。这些设备是市场上已存在的许多常见设备之外的新增设备,包括由 Adafruit、SparkFun、Seeed Studio 等公司生产的开发板。
如在前言中所述,为了保持本书的页数和成本合理,我决定将多个设备的讨论移至我的网站 www.randallhyde.com/bookofi2c。本书的第十七章及之后的内容将会发布在该网站上,这样便于随着时间的推移和新设备的流行进行更新和扩展。本书的附录 B 描述了当前计划中的在线章节,但我计划将来会增加更多内容。请访问该网站查找额外的支持材料、扩展的示例代码,以及其他 I²C 编程相关的信息链接。
即便没有在线材料,本书已经教会你几乎所有开始使用 I²C 外设所需了解的内容。到目前为止,你应该能够通读设备的数据手册,并使用这些信息编程任何 I²C 设备。祝你好运,编程愉快!
第十八章:Adafruit I²C 地址编译

本附录包含常见 I²C 外设的列表,按地址分类,主要来自 Adafruit I²C 地址编译。我也添加了一些我曾使用或遇到过的部件。这并不是所有 I²C 设备的完整列表,但涵盖了爱好者常用的设备——特别是那些提供扩展板或 DIP(插孔)封装的设备。
从 表 A-1 收集的信息来自 Limor “Lady Ada” Fried 在 Adafruit 网站上的列表 (learn.adafruit.com/i2c-addresses/the-list)。该信息源自 2022 年初。请访问 Adafruit 网站以查看任何更新。
表 A-1:Adafruit I²C 地址列表
| 起始地址或单一地址 | 结束地址 | 设备 |
|---|---|---|
| 0x00 | 0x07 | I²C 保留地址 |
| 0x00 | 通用调用地址 | |
| 0x01 | 保留用于 CBUS 兼容性 | |
| 0x02 | 保留用于 I2C 兼容的变体 | |
| 0x03 | 保留以兼容未来设备 | |
| 0x04 | 0x7 | 保留用于高速模式控制器 (主设备) |
| 0x0B | 0x0B | LC709203F 电池电量监测仪 |
| 0x0C | 0x0F | MLX90393 三轴磁力计 |
| 0x0E | MAG3110 三轴磁力计 | |
| 0x0F | ||
| 0x10 | 0x1F | |
| 0x10 | VEML6075 紫外传感器 VEML7700 环境光传感器 | |
| 0x11 | Si4713 FM 发射器与 RDS (0x11 或 0x63) | |
| 0x12 | PMSA0031 气体传感器 | |
| 0x13 | VCNL40x0 接近传感器 | |
| 0x18 | MPRLS 压力传感器 | |
| 0x18 | LIS3DH 三轴加速度计 (0x18 或 0x19) | |
| 0x18 | LIS331 三轴加速度计 (0x18 或 0x19) | |
| 0x18 | 0x1F | MCP9808 温度传感器 (0x18–0x1F) |
| 0x19 | LSM303 加速度计和磁力计 (0x19 为加速度计,0x1E 为磁力计) | |
| 0x1C | LIS3MDL 磁力计 (0x1C 和 0x1E) | |
| 0x1C | 0x1D | MMA845x 三轴加速度计 (0x1C 或 0x1D) |
| 0x1C | 0x1D | MMA7455L (0x1C 或 0x1D) |
| 0x1C | 0x1F | FXOS8700 加速度计和磁力计 (0x1C, 0x1D, 0x1E 或 0x1F) |
| 0x1D | ADXL343 三轴加速度计 (0x1D 或 0x53) | |
| 0x1D | ADXL345 三轴加速度计 (0x1D 或 0x53) | |
| 0x1D | 0x1E | LSM9DS0 9 轴 IMU (0x1D 或 0x1E 为加速度计和磁力计,0x6A 或 0x6B 为陀螺仪) |
| 0x1E | HMC5883 磁力计 (仅 0x1E) | |
| 0x1E | LIS2MDL 磁力计 | |
| 0x1E | LIS3MDL 磁力计 (0x1C 和 0x1E) | |
| 0x1E | LSM303 加速度计和磁力计 (0x19 为加速度计,0x1E 为磁力计) | |
| 0x20 | 0x2F | |
| 0x20 | Chirp! 水传感器 (0x20) | |
| 0x20 | 0x21 | FXAS21002 陀螺仪 (0x20 或 0x21) |
| 0x20 | 0x21 | PCAL6408A 8 位 GPIO 扩展器 |
| 0x20 | 0x21 | PCAL6416A 16 位 GPIO 扩展器 |
| 0x20 | 0x23 | PCAL6524 I2C 24 位 GPIO 扩展器 |
| 0x20 | 0x23 | PCAL6534 I2C 34 位 GPIO 扩展器 |
| 0x20 | 0x27 | MCP23008 I2C 8 位 GPIO 扩展器 |
| 0x20 | 0x27 | MCP23017 I2C 16 位 GPIO 扩展器 |
| 0x20 | 0x27 | PCA9555A I2C 16 位 GPIO 扩展器 |
| 0x23 | BH1750 光传感器 (0x23 或 0x5C) | |
| 0x24 | PCA9570 4 位 GPIO 扩展器 | |
| 0x26 | MSA301 三轴加速度计 | |
| 0x28 | 0x29 | BNO055 IMU (0x28 或 0x29) |
| 0x28 | 0x29 | TSL2591 光传感器 |
| 0x28 | 0x2B | DS1841 I2C 数字对数电位器 (0x28–0x2B) |
| 0x28 | 0x2B | DS3502 I2C 数字 10K 电位器 (0x28–0x2B) |
| 0x28 | 0x2D | CAP1188 8 通道电容触摸 (0x28–0x2D) |
| 0x28 | 0x2E | PCT2075 温度传感器 (0x28–0x2E, 0x48–0x4F, 或 0x70–0x77) |
| 0x29 | TCS34725 颜色传感器 (仅 0x29) | |
| 0x29 | TSL2561 光传感器 (0x29, 0x39, 或 0x49) | |
| 0x29 | VL53L0x ToF 距离传感器 (0x29,软件可选择) | |
| 0x29 | VL6180X ToF 传感器 (0x29) | |
| 0x30 | 0x3F | |
| 0x33 | MLX90640 红外热成像相机 | |
| 0x36 | 0x3D | Adafruit I2C QT 旋转编码器与 NeoPixel |
| 0x38 | AHT20 温度传感器 | |
| 0x38 | FT6x06 电容触摸驱动器 | |
| 0x38 | 0x39 | TSL2561 光传感器 |
| 0x38 | 0x39 | VEML6070 紫外线指数 |
| 0x39 | AS7341 颜色传感器 | |
| 0x39 | APDS-9960 红外、颜色和接近传感器 | |
| 0x39 | TSL2561 光传感器 (0x29, 0x39, 或 0x49) | |
| 0x3C | 0x3D | SSD1305 单色 OLED 显示屏 |
| 0x3C | 0x3D | SSD1306 单色 OLED 显示屏 |
| 0x40 | 0x4F | |
| 0x40 | Si7021 湿度和温度传感器 | |
| 0x40 | HTU21D-F 湿度和温度传感器 | |
| 0x40 | 0x41 | HTU31D-F 湿度和温度传感器 |
| 0x40 | 0x43 | HDC1008 湿度和温度传感器 |
| 0x40 | MS8607 温度、气压和湿度传感器 (0x40 为湿度,0x76 为气压和温度) | |
| 0x40 | 0x47 | TMP006 红外温度传感器 |
| 0x40 | 0x47 | TMP007 红外温度传感器 |
| 0x40 | 0x4F | INA219 高侧直流电流和电压传感器 |
| 0x40 | 0x4F | INA260 精密直流电流和功率传感器 |
| 0x40 | 0x7F | PCA9685 16 通道 PWM 驱动器默认地址 (实际上可以使用地址范围 0x40 到 0x7F) |
| 0x41 | STMPE610 或 STMPE811 电阻触摸控制器 (0x41 或 0x44) | |
| 0x42 | 0x49 | STMPE1600 16 位 GPIO 扩展器 |
| 0x44 | STMPE610 或 STMPE811 电阻触摸控制器 (0x41 或 0x44) | |
| 0x44 | SHT40 湿度和温度传感器 | |
| 0x44 | 0x45 | SHT31 湿度和温度传感器 |
| 0x44 | ISL29125 颜色传感器 | |
| 0x44 | STMPE610 或 STMPE811 电阻触摸控制器 (0x41 或 0x44) | |
| 0x48 | PN532 NFC 和 RFID 读卡器 | |
| 0x48 | 0x4B | ADS1115 四通道 16 位 ADC |
| 0x48 | 0x4B | ADT7410 温度传感器 |
| 0x48 | 0x4B | TMP102 温度传感器 |
| 0x48 | 0x4B | TMP117 温度传感器 |
| 0x48 | 0x4F | PCF8591 四通道 8 位 ADC 和 8 位 DAC |
| 0x48 | 0x4F | PCT2075 温度传感器 (0x28–0x2E, 0x48–0x4F, 或 0x70–0x77) |
| 0x49 | TSL2561 光传感器 (0x29, 0x39, 或 0x49) | |
| 0x49 | AS7262 光与颜色传感器 | |
| 0x4A | 0x4B | BNO085 九自由度 IMU |
| 0x4C | EMC2101 风扇控制器 | |
| 0x50 | 0x5F | |
| 0x50 | 0x57 | MB85RC I2C FRAM |
| 0x52 | Nintendo Nunchuck 控制器 | |
| 0x53 | ADXL343 三轴加速度计(0x1D 或 0x53) | |
| 0x53 | ADXL345 三轴加速度计(0x1D 或 0x53) | |
| 0x53 | LTR390 UV 传感器 | |
| 0x57 | MAX3010x 脉搏血氧传感器 | |
| 0x58 | AW9523 GPIO 扩展器和 LED 驱动器(0x58–0x5B) | |
| 0x58 | TPA2016 I2C 控制的放大器 | |
| 0x58 | SGP30 气体传感器 | |
| 0x59 | SGP40 气体传感器 | |
| 0x5A | DRV2605 触觉电机驱动器 | |
| 0x5A | MLX9061x 红外温度传感器 | |
| 0x5A | DRV2605 触觉电机驱动器 | |
| 0x5A | 0x5B | CCS811 VOC 传感器 |
| 0x5A | 0x5D | MPR121 12 点电容式触摸传感器 |
| 0x5C | AM2315 湿度和温度传感器 | |
| 0x5C | BH1750 光传感器(0x23 或 0x5C) | |
| 0x5C | AM2320 湿度和温度传感器 | |
| 0x5C | 0x5D | LPS22 压力传感器 |
| 0x5C | 0x5D | LPS25 压力传感器 |
| 0x5C | 0x5D | LPS33HW 外接压力传感器 |
| 0x5C | 0x5D | LPS35HW 压力传感器 |
| 0x5E | TLV493D 三轴磁力计 | |
| 0x5F | HTS221 湿度和温度传感器 | |
| 0x60 | 0x6F | |
| 0x60 | ATECC608 加密协处理器 | |
| 0x60 | MCP4728 四通道 DAC | |
| 0x60 | MPL115A2 大气压力传感器 | |
| 0x60 | MPL3115A2 大气压力传感器 | |
| 0x60 | Si1145 光和红外传感器 | |
| 0x60 | TEA5767 收音机接收器 | |
| 0x60 | VCNL4040 接近和环境光传感器 | |
| 0x60 | 0x61 | Si5351A 时钟生成器 |
| 0x60 | 0x61 | MCP4725A0 12 位 DAC |
| 0x60 | 0x67 | MCP9600 温度传感器 |
| 0x61 | SCD30 湿度、温度和气体传感器 | |
| 0x62 | 0x63 | MCP4725A1 12 位 DAC |
| 0x63 | Si4713 FM 发射器带 RDS(0x11 或 0x63) | |
| 0x64 | 0x65 | MCP4725A2 12 位 DAC |
| 0x66 | 0x67 | MCP4725A3 12 位 DAC |
| 0x68 | 这个地址在实时钟表中非常常见;几乎所有的都使用 0x68! | |
| 0x68 | DS1307 RTC | |
| 0x68 | DS3231 RTC | |
| 0x68 | PCF8523 RTC | |
| 0x68 | 0x69 | AMG8833 红外热像仪 |
| 0x68 | 0x69 | ICM-20649 加速度计和陀螺仪 |
| 0x68 | 0x69 | ITG3200 陀螺仪 |
| 0x68 | 0x69 | MPU-9250 九自由度 IMU |
| 0x68 | 0x69 | MPU-60X0 加速度计和陀螺仪 |
| 0x6A | 0x6B | LSM9DS0 九轴 IMU(加速度计和磁力计为 0x1D 或 0x1E,陀螺仪为 0x6A 或 0x6B) |
| 0x6A | 0x6B | ICM330DHC 六轴 IMU |
| 0x6A | 0x6B | L3GD20H 陀螺仪 |
| 0x6A | 0x6B | LSM6DS33 六轴 IMU |
| 0x6A | 0x6B | LSM6DSOX 六轴 IMU |
| 0x70 | 0x7F | |
| 0x70 | SHTC3 温湿度传感器 | |
| 0x70 | 0x77 | HT16K33 LED 矩阵驱动器 |
| 0x70 | 0x77 | PCT2075 温度传感器(0x28–0x2E,0x48–0x4F,或 0x70–0x77) |
| 0x70 | 0x77 | TCA9548 1 到 8 的 I2C 多路复用器 |
| 0x74 | 0x77 | IS31FL3731 144-LED CharliePlex 驱动器(0x74,0x75,0x76 或 0x77) |
| 0x76 | MS8607 温度、气压和湿度传感器(0x76 为气压和温度) | |
| 0x76 | 0x77 | BME280 温度、气压和湿度传感器 |
| 0x76 | 0x77 | BME680 温度、气压和湿度传感器 |
| 0x76 | 0x77 | BMP280 温度和气压传感器 |
| 0x76 | 0x77 | BMP280 温度和气压传感器 |
| 0x76 | 0x77 | BMP388 温度和气压传感器 |
| 0x76 | 0x77 | BMP390 温度和气压传感器 |
| 0x76 | 0x77 | DPS310 气压传感器 |
| 0x76 | 0x77 | MS5607 和 MS5611 气压传感器 |
| 0x77 | BMA180 加速度计 | |
| 0x77 | BMP180 温度和气压传感器 | |
| 0x77 | BMP085 温度和气压传感器 | |
| 0x78 | 0x7B | 保留给 10 位 I2C 地址 |
| 0x7C | 0x7F | 保留以供将来使用 |
第十九章:在线章节

有关 I²C 控制器和外设设备的信息实在太多,无法包含在一本书中。即使可能,在本书发布期间及之后,新的 I²C 设备无疑会出现。为了解决这个问题,本书配有几个可以在 bookofi2c.randallhyde.com 免费获取的在线章节。
以下是当前计划包含的在线章节。在印刷时,有些可能已经发布,而其他章节可能仍在“开发中”。随着时间的推移(以及新设备的出现),我将尽力添加此处未列出的额外章节。
-
第十七章:Atto84 上的软件实现 本章包含第三章的溢出信息,描述了在 Atto84(ATtiny84)微控制器上实现 I²C 的软件实现。
-
第十八章:Teensy 4.x 低级 I²C 外设设备 本章提供了 Teensy 4.x 微控制器的低级外设驱动程序。
-
第十九章:MCP4728 四路数模转换器 第十五章描述了 MCP4725 12 位 DAC。本章介绍了其大哥 MCP4728 四路 DAC。
-
第二十章:Maxim DS3502 数字电位器 本章描述了 DS3502 数字电位器,允许您在软件中实现一个 7 位的可变电阻。
-
第二十一章:DS3231 高精度 RTC 本章描述了 DS3231 高精度实时时钟——这是 I²C 总线上用于实时计时的热门选择之一。
-
第二十二章:MCP9600 热电偶放大器 本章描述了 MCP9600 I²C 热电偶放大器和转换器。通过将热电偶连接到该设备,您可以测量高温(取决于热电偶类型,温度可以超过 1,000°C)。
-
第二十三章:I²C 显示器 本章讨论了各种 I²C 显示器(LED 和 LCD)及其驱动器。本章还描述了一个开源显示库,能够控制各种显示器。
-
第二十四章:SX1509 GPIO 扩展器 SX1509 是另一款 16 位 GPIO 扩展器(与 MCP23017 类似,但更简化)。本章描述了该部分的 GPIO 引脚编程。
-
第二十五章:PCA9685 PWM 驱动器 PCA9685 是一个 16 通道、12 位的脉宽调制(PWM)驱动器。通常,您会将其用于 LED 调光和电机速度控制(或 PWM 模拟输出)。本章描述了该部分的编程。
-
第二十六章:INA169 和 INA218 电流传感器 INA169 和 INA218 集成电路是电流传感器,允许您追踪电路中使用的功率。这对于电池监测和需要追踪系统中电流的其他电路非常有用。本章讨论了如何编程这些部件。
-
第二十七章:MPR121 电容触摸传感器 MPR121 是一个 12 通道的电容触摸控制器,可以将任何微弱导电的物体变成开关。本章描述了如何编程 MPR121 以使用电容控制器读取“按钮按压”。
-
第二十八章:Alchitry FPGA SparkFun 销售了一款由 Alchitry 提供的巧妙 FPGA 套件,它以硬件方式提供 I²C 功能(无需 CPU)。本章提供了将 Alchitry FPGA 编程为外设的示例。
-
第二十九章:Raspberry Pi Pico Raspberry Pi Pico 是由 Raspberry Pi 公司推出的一款流行微控制器。由于其低廉的价格,它变得极为重要。本章讨论了在此设备上编程 I²C。
-
第三十章:ESP32 ESP32(以及 ESP 8266)可能是最受爱好者和专业嵌入式程序员欢迎的物联网控制器。本章讨论了如何在该 MCU 上使用 I²C。
除了这些章节,bookofi2c.randallhyde.com还包含了许多本书中示例的有用信息,如样本构建(图片和原理图)。该网站还将包含关于编程 I²C 控制器和外设的有用信息链接。请访问该网站获取最新更新。
第二十章:术语表
A
高电平有效逻辑
一种数字逻辑信号,在有效时为 1(高),无效时为 0(低)。
低电平有效逻辑
当信号为 0(低)时,数字信号为有效;当信号为 1(高)时,数字信号为无效。
A/D(模拟到数字转换)
模拟到数字转换。
ADC(模拟到数字转换器)
模拟到数字转换器。
API(应用程序编程接口)
应用程序编程接口(API)。
B
总线扩展器
一种设备,将多个 I²C 总线复用到一个实际总线中。
C
CCW(逆时针)
逆时针(CCW)
时钟拉伸
外围设备可以保持 I²C 的 SCL 线,以添加等待状态到 I²C 传输中。这叫做时钟拉伸。
COTS(现成商业产品)
现成商业产品(COTS)
CPU(中央处理单元)
中央处理单元(CPU)。
CW(顺时针)
顺时针(CW)
D
D/A
数字到模拟转换。
DAC(数字到模拟转换器)
数字-模拟转换器(DAC)。
去抖动
通过消除首次状态变化后的快速变化(在一段时间内),将噪声开关输入转换为清晰信号的过程。
双列直插封装(DIP)
双列直插封装(DIP)。
调度器
一种特殊功能,作为许多不同功能的入口点。调度器的一个参数指定实际调用的功能。调度器提供许多不同 API 的基本实现。
DMA(直接内存访问)
DMA(直接内存访问)
数字万用表(DMM)
数字万用表(参见 DVM)。
干接点输入
一种被动输入信号(开关式,不是主动逻辑)。通常是开关、继电器接触或其他类似的无源开关设备。
DSI(显示串行接口)
显示串行接口(树莓派)。
DVM(数字电压表)
数字电压表(DVM)。
E
EEPROM(电可擦可编程只读存储器)。
电可擦可编程只读存储器(EEPROM)。
F
FET(场效应晶体管)
场效应晶体管(FET)。
浮动输入
一种未连接到活动信号源的输入信号。
G
通用输入输出(GPIO)
通用输入输出(GPIO)。
格雷码
一种二进制编码(序列),其中序列中任何两个数字之间只有 1 位变化。
H
HAT(树莓派附加硬件)
HAT(树莓派附加硬件)
I
I2C
集成电路之间的互联总线,一种低速总线用于连接嵌入式系统中的集成电路。
I²C
见 I2C。
IC(集成电路)
集成电路(IC)
IDE(集成开发环境)
集成开发环境。
IIC
见 I2C。
物联网(IoT)
物联网(IoT)。
ISR(中断服务例程)
中断服务例程。
J
JFET(结型场效应晶体管)
结型场效应晶体管(JFET)。
L
LKM(可加载内核模块)
可加载内核模块(树莓派操作系统)。
LSB(最低有效位)
最低有效位(LSB)。
M
主设备
古老的 I²C 控制器设备术语。
MCU(微控制单元)
微控制单元(专为嵌入式应用设计的 CPU 或 MPU)。
MOSFET(金属氧化物半导体场效应晶体管)
金属氧化物半导体场效应晶体管(MOSFET)。
MPU
微处理器单元(与 CPU 相同,但指定微处理器作为中央处理单元)。
MSB(最高有效位)
最高有效位(MSB)。
O
OSHWA(开源硬件协会)
开源硬件协会(OSHWA)。
P
PCB(印刷电路板)
印刷电路板(PCB)。
外围设备
一种连接到计算机系统(或总线)的设备,通常用于执行输入或输出操作。
PGA(可编程增益放大器)
可编程增益放大器(也:引脚网格阵列,具体取决于上下文)。
上拉电阻
连接到某个电源(通常是 3.3V 或 5V)上的电阻,它会将电压弱拉高,电路另一端的电阻可以通过提供一个低阻抗路径将信号强制拉到地(0V)。
R
竞争条件
程序中的错误计算,通常由于代码操作的时序或顺序问题(其结果可能会根据代码执行的时间不同而变化)。
RTD
电阻温度探测器。
S
SBC
单板计算机。
SCL
串行时钟线(I²C)。
SDA
串行数据线(I²C)。
单例类对象
只有一个对象实例被创建的单例类。
从设备
I²C 外设设备的古老术语。
SMBus
系统管理总线。
SMD
表面贴装器件。
代码片段
演示某个概念的小代码片段,可能不可编译或运行。
T
TWI
两线接口。
U
USI
通用串行接口(ATtiny84)。
第二十一章:索引
请注意,索引链接指向每个术语的大致位置。
数字
10 位外设地址,24
A
ACCESS.bus,89
访问(MCP230xx)寄存器,266
ACK(确认)位,13
激活 Raspberry Pi I²C 总线,151
高电平有效逻辑,271
低电平有效逻辑,270
Adafruit feather 总线,116
Adafruit METRO SBC,102
Adafruit SBC,101
Adafruit TCA9548A I²C 扩展器,258
Adafruit Trinket,116
ADALM2000 活动学习模块,70
ADC,299
双极,302
通道,300,301
差分输入,300,303
差分线驱动器,303
允许的最大电压,302
极性,300,302
范围,302
分辨率,300,301
样本频率,300
单端输入,300,303
规格,300
地址(I²C),19
地址引脚(MCP230xx),264
地址空间(任务),177
ADS1x15
ADC 引脚分配,317
ADC 寄存器,309
比较器控制位,311
配置寄存器,310
转换速率,312
转换寄存器,310
转换开始/准备位,315
增益设置,305
高阈值寄存器,315
低阈值寄存器,315
多路复用控制位,314
一次性模式,305
查询性能,323
断电模式,315
可编程增益放大器,313
阈值寄存器,315
ADS1015 模拟数字转换器,300
ADS1115 模拟数字转换器,300
I²C 总线的优势,5
Alchitry FPGA,104
模拟调理,305
模拟数字转换器,299
仲裁(I²C 总线仲裁),12
Arduino,94
作为外设,137
I²C 编程,131
Arduino Due,100
Arduino Mega 2560,98
Arduino Micro,97
Arduino Nano,96
Arduino Nano Every,98
Arduino Uno Rev3, 95
Arduino Wire 库, 134
available, 135
begin, 134
beginTransmission, 136
缓冲区大小, 135
endTransmission, 136
onReceive, 137
onRequest, 137
read, 135
requestFrom, 135
SetClock, 134
Wire.h 头文件, 134
write, 136
Arduino Zero, 100
Artemis (SparkFun) SBCs, 103
Atmel ATtiny, 350
原子传输, 18
attachInterrupt 函数 (Arduino), 282
ATtiny84, 349
ATtiny85 MCU, 64
Atto84, 349
自动增量模式 (MCP230xx), 272
B
BANK 位 (MCP23017), 267
裸机外设编程, 349
BeagleBone Black, 108, 172
双向电平转换器, 107
双向逻辑电平转换器, 7
位波动, 153, 172, 349
I²C 总线上的位序, 17
阻塞(线程), 179
分 breakout 板, 115
BSS138, 7
树莓派上的总线激活, 151
总线仲裁, 12
总线电容, 5, 8, 253, 256
总线时钟同步, 10
总线驱动器, 70
总线扩展器, 253
总线监视器, 70
Bus Pirate, 70, 75
总线嗅探器, 70
总线速度, 256
总线开关, 254
忙等待循环, 175, 233
C
电容表, 68
I²C 总线上的电容负载 (TCA9458A), 256
Cape 扩展板, 116
CBUS, 24
清除中断 (MCP230xx), 285
树莓派上的时钟频率 (I²C), 152
I²C 总线上的时钟数据, 16, 28
时钟延伸, 10, 13, 94, 105, 107, 20
时钟同步, 10
比较器控制位 (ADS1x15), 311
任务的并发执行, 177
配置寄存器 (ADS1x15), 310
控制器确认读取,18
控制器设备,4
转换速率(ADS1x15),312
转换寄存器(ADS1x15),310
转换启动/就绪位(ADS1x15),315
临界区,179
交叉干扰,13
自定义 I²C 设备,28
D
数据方向寄存器(MCP230xx),268
DDC(VESA),89
去抖输入,289
DEFVALA 寄存器(MCP23017),265
DEFVALB 寄存器(MCP23017),265
DEFVAL 寄存器(MCP23008),264
DEFVAL 寄存器(MCP230xx),284
检测 I²C 外设,26
设备 ID,25
差分输入,300,303
差分线驱动器,303
DisplayPort,89
DISSLW 位(MCP230xx),267
DMM(数字万用表),68
下游 I²C 设备,254
干接点输入,269
DSI 接口(Raspberry Pi),148
DVI,89
E
E-DDC,112,148
E-DDC 和 VESA DDC,89,112,148
在 Raspberry Pi 上启用 I²C 总线,151
启用 MCP230xx的中断,284
事件(在 RTOS 中),179
F
快速模式,9
快速模式增强版,9,14
Feather 总线,115
PC I²C 信号,119
引脚排列,118
Feather SBCs,102,117
Feather 规格,117
FeatherWings,117,120
过滤模拟输入噪声,331
四线模式,5
FreeRTOS,175,189
FTDI,112
G
广播调用,21
干扰过滤,221,234
GPINTENA 寄存器(MCP23017),265
GPINTENB 寄存器(MCP23017),265
GPINTEN 寄存器(MCP23008),264
GPINTENx寄存器(MCP230xx),284
GPIOA 寄存器(MCP23017),265
GPIOB 寄存器(MCP23017),265
GPIO 引脚(MCP230xx),263
GPIO 寄存器(MCP23008),264
GPPUA 寄存器(MCP23017),265
GPPUB 寄存器(MCP23017),265
GPPU 寄存器(MCP23008),264
GPPUx寄存器(MCP230xx),270
Grand Central M4 Express,102
Grove 总线,115,125
Grove 总线引脚排列,126
保证响应时间(ISR),176
H
硬件通用调用,23
HATs,116,148
HDMI,89
高速模式,10,24
高阈值寄存器(ADS1x15),315
主机(I²C,SMBus),82
I
I²C
地址,5,10,19
总线位顺序,17
总线速度,5,9
总线启动序列,12
树莓派上的时钟频率,152
时钟拉伸,13
控制器设备,4
驱动程序,70
主机,82
PC 支持,112
外设设备,4
外设编程,349
PiOS 内核函数调用,162
PiOS 编程,158
Arduino 上的编程,134
协议,15
在 PC 上的支持,112
I2C 总线
仲裁,12
时钟数据到,16,28
写入数据到,19
i2cdetect 工具(Linux),155
i2c-dev 函数(Linux/PiOS),163
i2cdump 工具(Linux),155
i2cget 工具(Linux),155
i2cput 工具(Linux),155
i2c_smbus_read_block_data 函数,169
i2c_smbus_read_byte_data 函数,166
i2c_smbus_read_byte 函数,165
i2c_smbus_read_word_data 函数,168
i2c_smbus_write_block_data 函数,170
i2c_smbus_write_byte_data 函数,168
i2c_smbus_write_byte 函数,166
i2c_smbus_write_quick 函数,164
i2c_smbus_write_word_data 函数,169
非法命令代码(通用调用操作),22
输入极性(MCP230xx),270
INTCAPA 寄存器(MCP23017),265
INTCAPB 寄存器(MCP23017),265
INTCAP/INTCAPA/INTCAPB 寄存器(MCP230xx),285
INTCAP 寄存器(MCP23008),264
INTCONA 寄存器(MCP23017),265
INTCONB 寄存器(MCP23017),265
INTCON 寄存器(MCP23008),264
INTCON 寄存器(MCP230xx),284
MCP230xx 内部上拉电阻,269
中断极性(MCP230xx),283
中断服务例程,142,176,281
中断信号,176
MCP230xx 的中断,280
INTFA 寄存器(MCP23017),265
INTFB 寄存器(MCP23017),265
INTF/INTFA/INTFB 寄存器(MCP230xx),285
INTF 寄存器(MCP23008),264
INTPOL 位(MCP23017),267
INTPOL 位(MCP230xx),283
INTx 寄存器(MCP230xx),281
IOCON 寄存器(MCP23008),264
IOCON 寄存器(MCP23017),265
IOCON 寄存器初始化(MCP230xx),268
IODIRB 寄存器(MCP23017),265
IODIR 寄存器(MCP23008),264
IPOLA 寄存器(MCP23017),265
IPOLB 寄存器(MCP23017),265
IPOL 寄存器(MCP23008),264
IPOLx 寄存器(MCP230xx),271
ISR,176,281
ISR 函数,282
K
按键弹跳,289
L
最低有效位(LSB),17
电平转换,7,256
Linux i2cdetect 工具,27
Linux i2c-dev 函数,163
Linux i2cdump 工具,155
Linux i2cget 工具,155
Linux I²C 编程,147,158
Linux i2cput 工具,155
Linux 内核 I2C 函数调用,162
LKM(可加载内核模块),151
逻辑分析仪,70
低级外设编程,349
低阈值寄存器(ADS1x15),315
LSB(最低有效位),17
M
主设备,4
主中断使能寄存器(MIER),212
主接收数据寄存器(MRDR),215
主发送数据寄存器(MTDR),214
允许的最大电压(ADCs),302
Mbed
I²C 编程,199
RTOS(实时操作系统)110,175
Studio 集成开发环境,199
MCP23008
DEFVAL 寄存器,264
GPINTEN 寄存器,264
GPIO 寄存器,264
INTCAP 寄存器,264
INTCON 寄存器,264
INTF 寄存器,264
IOCON 寄存器,264
IODIR 寄存器,264
IPOL 寄存器,264
OLAT 寄存器,264
MCP23017
BANK 位
DEFVALA 寄存器,265
DEFVALB 寄存器,265
GPINTENA 寄存器,265
GPINTENB 寄存器,265
GPIOA 寄存器,265
GPIOB 寄存器,265
GPPUA 寄存器,265
GPPUB 寄存器,264
INTCAPA 寄存器,265
INTCAPB 寄存器,265
INTCONA 寄存器,265
INTCONB 寄存器,265
INTFA 寄存器,265
INTFB 寄存器,265
INTPOL 位,267
IOCON 寄存器,265
IODIRB 寄存器,265
IPOLA 寄存器,265
IPOLB 寄存器,265
MIRROR 位,267
OLATA 寄存器,265
OLATB 寄存器,265
MCP230xx
访问,266
地址引脚,264
自动递增模式,272
清除中断,285
数据方向寄存器,268
DEFVAL 寄存器,284
DISSLW 位,267
启用中断,284
GPINTENx 寄存器,284
GPIO 引脚,263
初始化,266
输入极性,270
INTCAP/INTCAPA/INTCAPB 寄存器,285
INTCON 寄存器,284
内部上拉电阻,269
中断使能,284
中断极性,283
开启中断,280
INTF/INTFA/INTFB 寄存器,285
中断引脚,264
INTPOL 位,283
INTx 寄存器,281
IOCON 寄存器初始化,268
IOCON 寄存器写操作,268
IPOLx 寄存器,271
MIRROR 位,283
镜像 INTx 引脚,283
多路复用器控制位,314
引脚分配,262
上电状态,266
上拉电阻,269
读取,266
读取 GPIO 引脚,274
寄存器,264
复位引脚,264
SEQOP 位,267
顺序寄存器操作,271
滑率控制,273
测试中断,285
写入 GPIO 引脚,275
写入,266
写入 IOCON 寄存器,268
MCP4725 数字模拟转换器,132,341
存储器外设设备(I²C),351
Metro 328 SBC 单板计算机,101
MIER(主中断使能寄存器),212
镜像位(MCP23017),267
镜像位(MCP230xx),283
镜像 INTx 引脚(MCP230xx),283
MOD54415 SBC 单板计算机,180
MRDR(主接收数据寄存器),215
MSB(最高有效位),17
MTDR(主传输数据寄存器),214
多控制器,5
I²C 总线配置,10
Linux 下的操作,171
多路复用器控制位(ADS1x15),314
多路复用器(I²C),254
多任务处理,177
多线程,177
互斥体(互斥排斥),179
N
NAK(否定确认)位 17
NBRTOS(NetBurner),180
NetBurner MOD54415 SBC 单板计算机,111
Nucleo-144 SBC 单板计算机,111
NXP 半导体,3
Nyquist 定理,304
O
ODR 位
MCP230xx,267
开漏控制,MCP230xx,283
OLATA 寄存器(MCP23017),265
OLATB 寄存器(MCP23017),265
OLAT 寄存器(MCP23008),264
Onion Omega2+,172
Onion Omega SBC 单板计算机,109
开集电器,5
开漏输出,5
开漏信号,6
运行状态位,315
示波器与逻辑分析仪对比,70
P
Particle Photon,118
外围设备读取的确认,18
外围设备检测,26
外围设备(Arduino),137
外围设备,4
PGA(可编程增益放大器),313–314
Photon(Particle)总线,118
pigpio 库,173
Pi HATs,148
PINE64,172
PINE64 SBC 单板计算机,109
PiOS I²C 编程,158
PiOS 内核函数调用的 I²C,162
PJRC,104
PlatformIO 集成开发环境,190
极性(ADC 输入),302
轮询,176
掉电模式(ADS1x15),315
基于优先级的系统(实时操作系统),178
进程,177
可编程增益放大器(PGA),313–314
可编程上拉电阻器(MCP230xx),270
上拉电阻,270
MCP230xx,269
大小,8
Q
quickselect 算法(中位数计算),331
Qwiic 总线,103,112,115
Qwiic Pro Micro,103
R
竞争条件,284
Raspberry Pi,105,147
DSI 接口,148
I²C 总线激活,151
I²C 时钟频率,152
可加载内核模块,151
Raspberry Pico,107
控制器读取确认,18
从 MCP230xx读取,266
读取 GPIO 引脚(MCP230xx),274
读取/写入 I²C 总线控制,19
准备运行队列(RTOS),177
RedBoard(SparkFun),103
可重入性,178
寄存器级外围设备编程,349
重复启动条件,19
重置并设置外围设备可编程地址,22
重置引脚(MCP230xx),264
外围设备上的重置引脚,26
重置 I²C 总线,26
电阻温度检测器(RTD),304
电阻器大小(I²C 总线上的上拉电阻),8
振铃,274
ROCKPro 64,172
旋转(轴)编码器,286
轮询调度策略(RTOS),178
RTD(电阻温度检测器),304
RTOS(准备运行队列),177
S
安全关键系统,179
Saleae 逻辑分析仪,75
采样频率,300,304
扫描 I²C 总线,26
Seeed Studio,105
Seeed Studio Grove 总线,125
信号量,179
SEQOP 位(MCP230xx),267
顺序寄存器操作(MCP230xx),271
设置外围设备可编程地址,22
停止条件的设置时间,18
扩展板,95,116
信号调理,306
单端输入,300
ADC,303
从属设备,4
上升/下降速率控制(MCP230xx),273
上升/下降速率限制,234
SMBAlert,83
SMBSuspend,83
SMBus,82,112,158,162
SMBus 主机,82
使用 R/W 作为数据值的 SMBus 外围设备,27
SMD, 316
SPAN 调整 (增益), 306
SparkFun Atto84, 349
SparkFun I²C Mux, 259
SparkFun Qwiic 总线, 122
SparkFun SBCs, 103
特殊 I²C 地址, 21
自旋循环, 175
标准模式, 9, 14
启动字节, 23
启动条件, 133, 165, 227
启动序列 (I²C), 12
启动信号, 16
启动时 SCL 线的保持时间, 17
饥饿 (多任务处理), 178
Stemma QT, 115
STEMMA/QT 总线, 123
STM32F746G-Disco SBC, 111
STM32F767/Nucleo-144, 111
STM32 SBCs, 110
STMicroelectronics, 110
停止条件, 133, 135, 136, 165, 232
停止信号, 16
拉伸, 10, 13
同步, 10
T
TCA9543A
地址分配, 255
总线扩展器, 253–254
I²C 总线上的电容负载, 256
选择寄存器, 255
Teensy 2.0, 104
Teensy 3.x/4.x SBCs, 104
Teensy 裸机
begin, 216
beginTransmission, 226
read, 230
requestFrom, 230
write, 230
Teensyduino, 190
Teensy LC, 104
Teensy 线程, 175, 194
测试中断 (MCP230xx), 285
热电偶, 304
Thing Plus, 118
Thing Plus SBCs (SparkFun), 103
线程, 177
阈值寄存器 (ADS1x15), 315
时间
时间片/时间量子, 178
SCL 和 SDA 之间的时序关系, 16
传输错误, 17
Trinket, 116
Trinket M0, 102
TWI (双线接口), 89, 350
TWSI (双线串行接口), 89
TXB0104 双向电平转换器, 107
U
单极 ADCs, 302
上游 I²C 设备, 254
USI (通用串行接口), 234, 350
ATtiny84 时钟源选择, 350
溢出中断服务程序, 360
V
VESA DDC 和 E-DDC,89,112,148
VGA,89
电压转换,7
VRTX(RTOS),203
W
等待状态,20
窗口平均值,331
Wire.available(Arduino),135
Wire.begin函数(Arduino),134
Wire.beginTransmission(Arduino),136
有线与(wired-AND)运算,11
Wire.endTransmission(Arduino),136
Wire.h头文件(Arduino),134
Wire 库,134
Wire.onReceive(Arduino),137
Wire.onRequest(Arduino),137
Wire.read函数(Arduino),135
Wire.requestFrom函数(Arduino),135
Wire.SetClock函数(Arduino),134
Wire.write(Arduino),136
向 I²C 总线写入数据,19
写入 GPIO 引脚(MCP230xx),275
向 MCP230xx写入数据,266
向 IOCON 寄存器写入数据(MCP230xx),268
Z
零点调整(偏移),306


浙公网安备 33010602011771号