我学到的使用 DS3231 实时时钟中断 Arduino 的技巧
您想要确定 Arduino 在草图中运行特殊代码段的精确时间,而不使用 Arduino 硬件中的计时器和计数器。您具有使用 Arduino IDE 编写代码的一些技能和经验,并且您希望避免使用诸如delay()
之类的代码语句。
相反,您希望连接一个非常精确的 DS3231 实时时钟模块作为外部中断源。即使 Arduino 暂时断电,DS3231 也可以使用电池来保持准确的时间,这对您来说可能很重要。
最后,您想了解如何使用 Andrew Wickert 的 DS3231.h 库,该库在 Arduino 在线参考中引用:https://www.arduino.cc/reference/en/libraries/ds3231/。它包含一些值得努力掌握的技巧。您可以使用库管理器(工具 > 管理库...)将此库导入到 Arduino IDE 中。
一步一步来。本教程演示了以下步骤:
如果您希望特殊代码按照您指定的时间间隔运行多次,您的代码可以再次执行步骤 3 并设置新的警报。本教程附带的示例草图以 10 秒的间隔重复中断 Arduino。
本教程取自“官方”参考资料,包括:
attachInterrupt()
函数的Arduino参考:https://www.arduino.cc/reference/en/language/functions/external-interrupts/attachinterrupt/。 本例中的特殊代码很简单:它只打印 DS3231 的当前时间。现实生活中的示例可能会做一些有用的事情,例如给植物浇水或记录温度测量结果。无论什么任务,代码都应该进入其自己的特殊块,仅当 DS3231 警报中断 Arduino 时才运行。
我认为将代码分解为短函数是一种很好的做法,其中每个函数仅处理一个任务或一组相关任务。函数的名称可以是任何名称;为什么不让它描述这个函数的作用呢?这是我的函数的一部分,用于运行本示例中的特殊代码。
void runTheSpecialCode() {
// get the current time
// using the DateTime and RTClib classes
// defined in DS3231.h
DateTime dt = RTClib::now();
// print the current time
Serial.print(dt.hour()); Serial.print(":");
if (dt.minute() < 10) Serial.print("0");
Serial.print(dt.minute()); Serial.print(":");
if (dt.second() < 10) Serial.print("0");
Serial.println(dt.second());
// There will be more to do here, as you will see.
// This is enough, for now, to illustrate the idea:
// put special code in its own, special function
}
您将在五对引脚之间布线。每对执行一个电气用途,并将 Arduino 上的引脚与 DS3231 上的相应引脚相匹配。慢慢地,连接每一对,然后检查两端,确保每根电线都在它应该的位置。该表按从左到右从 Arduino Uno 连接到 DS3231 的顺序列出了这些对。
目的 | DS3231 引脚 | Arduino 引脚 |
---|---|---|
警报 | 量子阱 | 3* |
SCL | SCL | SCL** |
SDA | SDA | SDA** |
5伏电源 | 电压控制电路 | 5V |
地面 | 接地 | 接地 |
就像我说的,慢慢来建立这些联系。缓慢而确定通常是正确完成任何事情的最快方法。
我们将通过 DS3231“对象”(一种带有名称的软件工具箱)与 DS3231 模块进行通信。 DS3231 库在盒子内部定义了许多功能——将它们视为工具。当我们想要使用一个函数时,我们写下工具箱的名称,后面跟一个点,然后是工具的名称。示例草图为此目的创建了一个“时钟”变量。然后草图就可以访问“时钟”框中的工具了。如上所述,所有工具均在 DS3231.h 文件中声明。
#include <DS3231.h>
DS3231 clock;
Serial.println(clock.getMinute()); // the current minute, 0..59
我们将使用“时钟”对象中的工具来设置闹钟。但首先我们需要计算闹钟时间。
此步骤假设您之前已在 DS3231 上设置了实际时间。示例草图包含可用于设置时钟时间的代码(如果您需要)。只需删除其周围的注释分隔符 /* 和 */ 即可。
本教程中的示例草图通过将间隔(以秒数表示)添加到当前时间来计算未来的闹钟时间。该示例增加了十秒。一分钟就会增加 60 秒。一小时将增加 3,600 秒。一天,86,400 秒。等等。
DS3231 库有一个隐藏的“技巧”,可以轻松添加以秒为单位的时间。您不会在 DS3231 库的 README 页面上的可用函数中找到此技巧。通过查看 DS3231.h 文件也不是完全显而易见。一些细节有待在 DS3231.cpp 代码文件中找到。以下是执行计算的步骤。
now()
函数,需要以特殊方式访问该函数。步骤 4 和步骤 5 可以合并。
const Uint32_t interval = 10; // number of seconds to add
DateTime currentTime; // default declaration
currentTime = RTClib::now(); // RTClib is defined in DS3231.h
uint32_t currentSeconds = currentTime.unixtime(); // express the date in seconds
DateTime alarmTime(currentSeconds + interval); // add 10 seconds and create a new date
尽管alarmTime对象是基于秒数创建的,但它在其工具箱中提供了工具来表达其年、月、日、小时、分钟和秒。我们在 DS3231 上设置闹钟时间,如下所述,使用 AlarmTime 对象作为我们需要的值的来源。
例如,假设 DS3231 模块报告的 currentTime 为 2021 年 10 月 27 日星期三上午 10:42 7 秒。上面计算的 AlarmTime 将是同一天的 10:42:17,十秒后。
DS3231 提供两种不同的警报:警报#1 (A1) 和警报#2 (A2)。两个警报都可以指定为日期和时间,小至一分钟。不同之处在于 A1 可以进一步指定为秒。每个闹钟在 DS3231 库中都有自己的一对函数,用于设置闹钟时间和读取该时间。这些功能都是通过 DS3231 对象访问的,例如我们命名为“clock”的对象:
时钟.setA1Time()、时钟.getA1Time()、时钟.setA2Time() 和时钟.getA2Time()
setA1Time() 函数采用八个参数,如 DS3231.h 文件中的引用中所列:
void setA1Time(byte A1Day, byte A1Hour, byte A1Minute, byte A1Second, byte AlarmBits, bool A1Dy, bool A1h12, bool A1PM);
仔细阅读DS3231.h头文件可以解释这些参数。接下来是我尝试用自己的话向自己重新解释它们。如果读者发现我的版本和头文件之间有任何差异,请假设头文件是正确的。
前五个参数的类型为“byte”。 cppreference 网站以这种方式定义字节类型 https://en.cppreference.com/w/cpp/types/byte:
std::byte 是一种独特的类型,它实现了 C++ 语言定义中指定的字节概念。
与 char 和 unsigned char 一样,它可用于访问其他对象(对象表示)占用的原始内存,但与这些类型不同的是,它不是字符类型,也不是算术类型。字节只是位的集合,为其定义的唯一运算符是按位运算符。
在这种特殊情况下,我们可以将日期和时间的字节类型变量视为无符号整数。它们可以保存 0 到 255 之间的整数值。 注意:代码编写者必须避免无意义的值。例如,值 102 对于这些参数中的任何一个都没有意义。作为代码编写者,您的工作就是提供合理的值。
让我们继续在上一步中创建的alarmTime:该月的第27天,上午10点42分17秒。下面的列表显示了如何将这些值提供给函数。我将每个参数列出在自己的行中,以使它们更容易被人类阅读并为注释留出空间。这里的例子并不完整;它仅演示日期和时间的字节类型值。该函数需要更多参数,如下所述,并且不会以此处显示的形式运行。
顺便说一下,请注意“clock”和“alarmTime”变量是对象,也就是说,它们是软件工具箱。如您所见,我们使用相应工具箱内的工具来访问对象包含的信息。
clock.setA1Time(
alarmTime.day(), // the day of the month: 27
alarmTime.hour(), // the hour of the day: 10
alarmTime.minute(), // the minute of the hour: 42
alarmTime.second(), // the second of the minute: 17
// ... the remaining parameters are explained below
);
下一个字节类型参数名为 AlarmBits,实际上只是位的集合。这些位的名称如 DS3231 数据表(第 11 页)中所定义。
位 7 | 位 6 | 位 5 | 位 4 | 位 3 | 位 2 | 位 1 | 位 0 |
---|---|---|---|---|---|---|---|
-- | -- | -- | 镝 | A1M4 | A1M3 | A1M2 | A1M1 |
这些位一起形成一个“掩码”或模式,告诉 DS3231 何时以及以何种频率发出警报信号。数据表第 12 页上的表格给出了不同位集合的含义。根据该表,本教程中的示例草图使用以下位集合:
-- | -- | -- | 镝 | A1M4 | A1M3 | A1M2 | A1M1 |
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 1 | 1 | 1 | 0 |
这种位的排列可以在代码中明确表达: 0x00001110
。它告诉 DS3231 在“秒匹配”时发出警报信号,即当警报设置的“秒”值与当前时间的“秒”值匹配时。
setA1Time()
函数的最后三个参数是布尔值或真/假值。它们告诉 DS3231 有关如何评估警报设置的更多信息。以下代码段显示了对 setA1Time() 的完整调用,继续上面开始的示例:
clock.setA1Time(
alarmTime.day(), // the day of the month: 27
alarmTime.hour(), // the hour of the day: 10
alarmTime.minute(), // the minute of the hour: 42
alarmTime.second(), // the second of the minute: 17
0x00001110, // AlarmBits = signal when the seconds match
false, // A1Dy false = A1Day means the date in the month;
// true = A1Day means the day of the week
false, // A1h12 false = A1Hour in range 0..23;
// true = A1Hour in range 1..12 AM or PM
false // A1PM false = A1Hour is a.m.;
// true = A1Hour is p.m.
);
在示例草图中,我们将闹钟设置为每 10 秒中断一次,只有 A1Second 和 AlarmBits 参数很重要。但是,当我们调用setA1Time()
函数时,我们需要提供所有这些。提供正确的值并不比提供垃圾值更困难;我们不妨照顾他们。
setA2Time()
函数的工作方式类似,但没有秒数参数。花一些时间查看库中 DS3231.h 文件的第 119 行到 145 行以及数据表中的第 11-12 页。耐心地阅读这些参考资料,直到您在其中找到设置闹钟时间所需的信息。
设置时间后,程序必须采取额外的操作来启用 DS3231 中的警报。我鼓励读者始终遵循三步顺序,即使某些步骤由于某种原因有时看起来不太必要。如果你的代码一定会出错,那就让它在确定性方面出错。
对于报警 A1,DS3231 库中的指令为:
turnOffAlarm(1); // clear the A1 enable bit in register 0Eh
checkIfAlarm(1); // clear the A1 alarm flag bit in register 0Fh
turnOnAlarm(1); // set the A1 enable bit in register 0Eh
对于报警A2,只需将参数更改为2即可。例如: checkIfAlarm(2); // clear A2 flag bit in register 0Fh
。
@flowmeter 在此存储库中描述的一个问题强调,必须先清除两个警报标志,然后 DS3231 才能发出警报信号。为了确定这一点,请考虑调用 checkIfAlarm() 两次,每个警报调用一次,即使您只使用其中一个警报:
checkIfAlarm(1);
checkIfAlarm(2);
为什么代码编写者会选择“检查”他们认为当前没有发送信号的警报?原因是checkIfAlarm()
函数有一个不明显的副作用。它清除报警标志位。我们使用函数checkIfAlarm()
,因为它是 DS3231 库中唯一执行必要操作的函数。
想一想。由于下面将要解释的原因,Arduino 中断感应硬件需要在警报发生之前 DS3231 的 SQW 引脚上的电压为高电平。报警事件改变了 DS3231 内部的两件事:
只要这些警报标志位之一保持置位,SQW 引脚就会保持低电平。只要 DS3231 内部的报警标志位将 SQW 引脚保持为低电平,Arduino 就无法再检测到任何报警。为了使 DS3231 恢复其 SQW 报警引脚上的高电压,必须清除报警标志位。请参阅 DS3231 数据表第 14 页“状态寄存器 (0Fh)”中对位 1 和 0 的讨论。
每个报警在 DS3231 内部都有自己的报警标志位。任一报警标志位都可以将 SQW 引脚保持为低电平。 DS3231 不会主动清除报警标志位。报警发生后清除报警标志位是代码编写者的工作。
您的主循环不需要测量时间。只需要检查一个标志就可以知道是否发生了警报。在示例草图中,此标志是一个名为“alarmEventFlag”的布尔变量:
if (alarmEventFlag == true) {
// run the special code
}
大多数时候,标志将为false ,循环将跳过特殊代码。草图是如何立flag的?三个步骤:
bool alarmEventFlag = false;
void rtcISR() {alarmEventFlag = true;}
attachInterrupt()
函数将所有这些整合在一起。以下示例告诉 Arduino 硬件在指定数字引脚上检测到“FALLING”信号时立即运行rtcISR()
函数。attachInterrupt(digitalPinToInterrupt(dataPin), rtcISR, FALLING);
出于深刻而神秘的原因,在指定中断的引脚号时,请始终使用特殊函数digitalPinToInterrupt()
。我把它作为一个练习,让读者发现我们为什么需要这个函数。
什么是下降信号?这意味着电压从高电平变为低电平的变化,由 Arduino 的数字引脚检测到。电压变化从何而来?它源自 DS3231 模块的报警引脚。该引脚标记为 SQW,大部分时间它会发出高电压,接近 VCC 电源电平(即 Uno 上的 5 伏)。警报导致 DS3231 将 SQW 引脚上的电压更改为低电平。 Arduino 感测来自 SQW 引脚的电压并注意到变化。我们告诉 Arduino 注意“FALLING”,因为每个警报只发生一次该事件,而低电平可能会持续存在并使 Arduino 感到困惑,从而触发许多中断。
哪个引脚可以感应到电压的下降变化?对于 Unos,您可以选择引脚 2 或 3 之一。对于 Leonardo,它可以是引脚 0、1 或 7 中的任何一个。(是的,我知道,Leonardo 也会在引脚 2 和 3 上感应到中断。但是,这些是Leonardo 的 I2C 引脚,这意味着 DS3231 模块将使用它们来实现 Leonardo 上的中断。)示例草图定义了一个 dataPin 变量并初始化其。在 Uno 上运行时将值设置为 3:
int dataPin = 3;
如果您想重复循环,特殊代码还可以设置新的闹钟时间。首先计算新的闹钟时间,如步骤 3 中所述,然后按照步骤顺序进行操作。
当示例草图在正确连接到 DS3231 模块的 Arduino Uno 上运行时(按照我在本教程中描述的方式),我希望示例草图能够产生类似于下图的输出。如果显示的时间不同,请不要感到惊讶。无论如何,你应该按照自己的时间工作。