为 Raspberry Pi 上的 Windows IoT 添加缺失的实时时钟






4.83/5 (17投票s)
Windows IoT 在 Raspberry Pi 上尚未原生支持硬件实时时钟。我创建了一个解决方案,用于在启动时让 Windows IoT 从 RTC 初始化其时钟,这样其他解决方案就不需要修改以直接从 RTC 读取。
引言
Windows IoT 在 Raspberry Pi 等设备上运行。虽然 Raspberry Pi 没有内置实时时钟,但 Windows 会连接到互联网获取当前时间并设置一个假的硬件时钟。这在很多情况下都足够了。但是,当处理一个未连接到互联网但需要当前时间而此类解决方案无法正常工作时,情况就不同了。Raspberry Pi 的实时时钟价格不高,但 Windows IoT 在 Raspberry Pi 上尚未原生支持它们。查看互联网上其他使用 Raspberry Pi 上的 I2C 实时时钟的解决方案,我看到的大多数解决方案都要求程序在每次需要时间时直接从 RTC 获取时间,或者记住系统时钟和附加的实时时钟之间的时间差。虽然这些解决方案有效,但我个人觉得它们有点复杂。我在这里提出的解决方案旨在在每次启动时初始化系统时钟。与我见过的其他解决方案不同,一旦设置好,就不需要修改现有程序来使用它。可以像往常一样使用获取时间的 API,例如 DateTime.Now
。
要求
我是在已经从事 Windows IoT 开发的人员的基础上进行编写的。您需要了解如何使用 Visual Studio、C# 制作程序,并熟悉 C/C++ 语言才能理解这里的内容。您还需要了解如何通过 power shell 远程登录到您的 Windows IoT 设备。
设置系统时间
Windows IoT 在 Raspberry Pi 上运行 UWP(通用 Windows 平台)应用程序。UWP 应用程序运行在沙箱环境中;它们通常无法对系统进行任何会影响系统中其他程序的更改。如果您查看 UWP 类,您将找不到任何可以用来设置系统时间的类。尽管如此,您可能会在互联网上看到一些帖子声称您可以使用 [DllImport]
调用原生 Win32 函数来更改系统时间。这是不正确的。虽然您可以编译并运行此 Win32 调用代码,但它对系统没有任何影响。我仅在下面的代码中展示它,以便您能够识别它。
[DllImport("kernelbase.dll", SetLastError = true)]
static extern bool SetSystemTime(ref SystemTime time);
此函数在 SystemTime
参数中接收一个日期和时间。时间必须是 UTC 时间。请记住,在填充要传递给此函数的信息时,需要进行必要的 UTC 调整。此参数的布局需要在 UWP 中定义才能使用此函数。
[StructLayout(LayoutKind.Sequential)]
public struct SystemTime
{
[MarshalAs(UnmanagedType.U2)]
public short Year;
[MarshalAs(UnmanagedType.U2)]
public short Month;
[MarshalAs(UnmanagedType.U2)]
public short DayOfWeek;
[MarshalAs(UnmanagedType.U2)]
public short Day;
[MarshalAs(UnmanagedType.U2)]
public short Hour;
[MarshalAs(UnmanagedType.U2)]
public short Minute;
[MarshalAs(UnmanagedType.U2)]
public short Second;
[MarshalAs(UnmanagedType.U2)]
public short Milliseconds;
public SystemTime(DateTime dt)
{
Year = (short)dt.Year;
Month = (short)dt.Month;
DayOfWeek = (short)dt.DayOfWeek;
Day = (short)dt.Day;
Hour = (short)dt.Hour;
Minute = (short)dt.Minute;
Second = (short)dt.Second;
Milliseconds = (short)dt.Millisecond;
}
}
当我第一次编写代码来使用它时,我认为我的工作已经完成了一半。直到测试时,我才发现这段代码根本不起作用。在尝试了其他一些无效的方法后,我最终找到的工作解决方案是使用一个 power shell 脚本。您不需要太多了解 power shell 就能遵循此解决方案。powershell 脚本只有两行。
Power shell 中的 set-date
命令可用于设置日期和时间。我设置时间的解决方案将涉及让一个程序将当前时间写入文件,然后让一个 power shell 脚本从该文件中提取时间并将其传递给 set-date
。UWP 应用程序只能在文件系统的特定区域写入,主要是应用程序特定的位置。(有关 UWP 的数据存储及其限制的更多信息,请 此处 查看)。与其处理文件写入的限制和要求,我决定创建一个控制台应用程序,该应用程序将输出时间,并将其输出捕获以转发给 set-date
。测试此目的只需要一个输出所需格式日期的程序。目前它不需要是正确的日期。
int main (Platform::Array<Platform::String^>^ args)
{
cout << "2020/03/04 01:23" << endl;
return 0;
}
根据个人喜好,我在电脑上有一个名为 shares
的文件夹,其中包含许多用于各种用途的其他文件夹。我决定在我的 Raspberry Pi 上也这样做。从我的台式电脑,我打开文件浏览器并输入路径 \\IPADDRESS\c$\
(其中 IPADDRESS
应为您的 Raspberry Pi 的名称或 IP 地址),然后创建文件夹路径 c:\shares\boot
。您可以创建不同的文件路径,这只是我的个人偏好。我将我的可执行文件(名为 SetTimeTool.exe
)复制到此文件夹,并启动了设备上的远程 power shell 会话。通过切换到此路径(使用与命令提示符相同的命令),键入以下两行就足以将可执行文件输出的时间应用于系统时间。
$CurrentDate = ( c:\shares\boot\SetTimeTool.exe ) |Out-String set-date $CurrentDate
现在我们知道了如何从命令提示符更改时间,我们的工作就完成了一半。上述命令将被计划在启动时运行。我们稍后会回到这一点。需要更新可执行文件,以便它能够从实时时钟附件中拉取实际时间。我将使用的时钟芯片是 DS3231。如果您使用不同的时钟,则需要相应地更改代码。
关于 DS3231
DS3231 的 breakout 板售价约 10 美元。我偶然获得的那一块是通过 Amazon 购买的。我收到的单元有一个电池焊接到触点上,以在不使用时为时钟供电。我的单元上的电池已损坏,但更换后单元工作正常。该单元以二进制编码的十进制(BCD)格式处理时间信息。这种格式的便利之处在于,如果我用这个系统驱动 LCD,将时间转换为可以显示的内容将需要更少的电子设备;我可以直接将信息发送到硬件显示十六进制数,而无需任何转换。我没有用它来构建硬件时钟。我需要一个函数来转换二进制补码(普通)编码和 BCD 编码。DS3231 还可以以 12 小时格式(带 AM 和 PM 位)或 24 小时格式格式化时间。我将只使用 24 小时格式。DS3231 还有一个温度传感器。这对于本帖来说不是一个感兴趣的功能。但我会包含读取它的代码。如果需要精确读取温度,我建议仔细考虑 DS3231 的放置位置或使用不同的组件进行温度传感。Raspberry Pi 本身在运行时会发热,如果芯片离 Pi 太近,感应到的温度将高于环境温度。与 DS3231 的通信是通过 I2C 进行的。其 I2C 地址是 0x68
。
BCD 编码
BCD 和二补码编码之间的转换很容易。当以十六进制格式显示 BCD 时,很容易看到它代表的十进制数。数字 59 在十六进制中写为 0x3B。如果我们想以人类可读的格式显示它,则需要进行一些转换。但是,如果待处理的值是 BCD 格式,那么当我们以十六进制显示 BCD 编码的 59 时,我们得到 0x59。对于值 12,它将是 0x12;对于 2,它将是 0x02。给定一个二补码编码的数字,可以通过一些数学运算找到转换为 2 位 BCD 的方法。将值模 10(value % 10
)的结果与值除以 10(整数除法)乘以 16 的结果相加。BCD 转换回二补码可以通过一些位运算和乘法完成。将 BCD 值的高 4 位(nibble)乘以 10,然后加上低 4 位(nibble)。
static int BcdToInt(byte bcd)
{
int retVal = (bcd & 0xF) + ((bcd >> 4) * 10);
return retVal;
}
static byte IntToBcd(int v)
{
var retVal =(byte)( (v % 10) | (v / 10) << 0x4);
return retVal;
}
初始化 I2C
要通过 I2C 与时钟通信,我们需要获取一个提供 I2C 控制器接口的对象的引用。DeviceInformation
类可用于获取控制其他硬件的对象的引用。在下面,我查询 I2C 控制器的 ID,然后请求在第一个 I2C 控制器上的地址为 0x68
的设备引用。理论上,一个设备可以有多个 I2C 控制器。Raspberry Pi 只有一个;第一个控制器将是唯一的控制器。_device
对象可用于与时钟的所有通信。
async void Init(int address)
{
var advancedQuerySyntaxString = I2cDevice.GetDeviceSelector();
var controllerDeviceIds = await DeviceInformation.FindAllAsync(advancedQuerySyntaxString);
I2cConnectionSettings connectionSettings = new I2cConnectionSettings(address);
connectionSettings.BusSpeed = I2cBusSpeed.StandardMode;
_device = await I2cDevice.FromIdAsync(controllerDeviceIds[0].Id, connectionSettings);
_initComplete = true;
}
I2C 总线上可能有多个设备。我不会详细介绍与 I2C 设备通信时发生的电气过程。从代码的角度来看,一个编码为字节数组的消息被传输到一个 I2C 设备,然后作为一个原子操作接收其响应。在操作之前必须分配一个缓冲区来保存响应。对于读/写操作,我们将使用一个名为 ReadWrite
的方法,它接受两个参数;要发送到设备的信息的字节数组,以及用于保存响应的字节数组。在设置时间时,我们还将执行一个仅写入操作。Write
函数接受一个包含要写入数据的字节数组。要知道我们可以写入什么,有必要查看 D3231 中的寄存器。
DS3231 寄存器布局
DS3231 通过一系列寄存器保存当前日期和时间、上次温度读数以及其他一些信息。根据数据手册(来自 此页面),寄存器布局如下。
地址 | MSB 位 7
|
位 7 | 位 5 | 位 4 | 位 3 | 位 2 | 位 1 | LSB 位 0
|
---|---|---|---|---|---|---|---|---|
00h | 0 | 10 秒 | seconds | |||||
01h | 10 分钟 | 分钟 | ||||||
02h | 0 | 12/!24 小时 | AM/!PM 或 20 点 | 10 小时 | hours | |||
03h | 0 | 0 | 0 | 0 | 0 | 日 | ||
04h | 0 | 0 | 10 日期 | 日期 | ||||
05h | 世纪 | 0 | 0 | 10 月 | 月 | |||
06h | 10 年 | 年份 | ||||||
07h | 闹钟 | |||||||
08h | ||||||||
09h | ||||||||
0Ah | ||||||||
0Bh | ||||||||
0Ch | ||||||||
0Dh | ||||||||
0Eh | ||||||||
0Fh | ||||||||
10h | 偏移量 | |||||||
11h | 整数温度 | |||||||
12h | 温度小数 | 0 |
我在上面简化的寄存器布局中省略了一些细节。该芯片支持闹钟;我将不使用它。如果我们想从寄存器读取,我们传递一个单字节数组。该字节的值是要开始读取的寄存器的偏移量。我们还需要传递将用于保存响应的数组。如果为响应传递了多字节数组,它将用偏移量指定的寄存器填充,直到数组填满。如果传递一个至少 6 字节的字节数组,则可以在一次操作中读取所有时间寄存器。如果我们想写入寄存器(设置时间),则必须填充一个字节数组,其中第一个元素是要开始写入的寄存器偏移量,数组中其余的字节是要写入寄存器中的值。我只以 24 小时格式写入时间。
温度被分成两个寄存器。第一个寄存器包含摄氏度温度的整数部分。第二部分存储在一个寄存器的 2 位中,增量为 0.25 摄氏度。如果我们只想读取温度,可以这样做。
public float ReadTemperature()
{
byte[] buffer = new byte[2];
_device.WriteRead(new byte[] { 0x11 }, buffer);
float temperature = (float)buffer[0] + ((float)(buffer[1]>>6) / 4f);
return temperature;
}
这里分配了一个 2 字节数组来接收温度。ReadWrite
方法使用一个包含 011h
的数组调用,表示我们要获取偏移量为 0x11(十进制 17)的寄存器信息。2 字节缓冲区将用寄存器 11h 中的整数温度填充到数组的第一个字节,寄存器 12h 中的小数部分将填充到第二个字节。使用位移来仅获取最重要的 2 位,并使用除法将它们缩放到小数部分。
读取时间需要更多的位操作,但与组件的交互是相同的。我们将让它填充一个字节数组,然后我们将操作位以获取我们所需格式的时间。DS3121 的前 7 个字节包含时间组件。所以我们将在几行代码中获取前 8 个字节。
sbyte[] readBuffer = new byte[0x7h];
_device.WriteRead(new byte[] { 0x00 }, readBuffer);
在将日期和时间解码到单独的变量后,它们被组合成一个单独的 DateTime
对象。由于 DS3231 只记录两位数的年份,因此有必要在年份上加 2000 才能得到实际年份。
public DateTime? ReadTime()
{
if (!_initComplete)
return null;
byte[] readBuffer = new byte[0x7h];
_device.WriteRead(new byte[] { 0x00 }, readBuffer);
int seconds = BcdToInt(readBuffer[0]);
int minutes = BcdToInt(readBuffer[1]);
bool is24HourCock = (readBuffer[2] >> 0x6) != 1;
int hours;
if (is24HourCock)
hours = (readBuffer[2] & 0xF) + ((readBuffer[2] >> 4) & 0x1) * 10 + ((readBuffer[2] >> 0x5) * 20);
else
hours = (readBuffer[2] & 0xF) + ((readBuffer[2] >> 4) & 0x1) * 10 + ((readBuffer[2] >> 0x5) * 12); ;
int day = BcdToInt(readBuffer[3]);
int date = BcdToInt(readBuffer[4]);
int months = BcdToInt((byte)(readBuffer[5]&(byte)0x3f));
int year = BcdToInt(readBuffer[6]);
return new DateTime(2000+year, months, date, hours, minutes, seconds);
}
设置时间是解码操作的逆过程。我将时间的组件转换回 BCD 值并将它们放入一个数组中。数组的第一个元素是要写入的第一个寄存器的偏移量。数组中其余的值是要写入的数据。我只以 24 小时格式写入时间。
public void WriteTime(DateTime dateTime)
{
byte[] buffer = new byte[8];
int offset = 0;
buffer[offset++] = 0;
buffer[offset++] = IntToBcd(dateTime.Second);
buffer[offset++] = IntToBcd(dateTime.Minute);
buffer[offset++] = IntToBcd(dateTime.Hour);
buffer[offset++] = (byte)dateTime.DayOfWeek;
buffer[offset++] = IntToBcd(dateTime.Day);
buffer[offset++] = IntToBcd(dateTime.Month);
buffer[offset++] = IntToBcd(dateTime.Year % 100);
_device.Write(buffer);
}
设置时间
现在我们有了一个可以保留时间的时钟,我们需要设置它。需要另一个初始时间源。这个其他时间源可以是初始启动,以便可以使用 NTP 设置系统时钟。它也可以来自用户或其他传感器(例如:GPS 接收当前时间)。无论时间来源是什么,我们现在都拥有设置实时时钟所需的所有操作。演示应用程序将每秒一次从 RTC 和系统(假硬件)时钟读取,并显示已读取的值。它还显示日期和时间选择器,以便用户可以设置 RTC 中的时间。如果您想查看 RTC 是否真正工作,您可以将此应用程序设置为默认应用程序,并在断开网络连接的情况下重启 Raspberry Pi。让我们学习到的知识应用到控制台模式应用程序中。
完成 SetTimeTool 程序
Set Time Tool 是一个控制台模式程序。到目前为止,控制台模式程序只能用 C++ 编写。我希望这个程序能够做两件事。主要的是,这个程序需要从实时时钟读取时间并将其打印到其输出。我还希望能够使用该程序来设置实时时钟的时间。为了保持简单,在设置实时时钟时,此程序将采用系统时间并将其复制到 RTC。程序的 main 方法如下。
int main (Platform::Array<Platform::String^>^ args)
{
//Check whether the user has specified the argument for setting the real time clock.
//otherwise assume that we are only outputting the time.
bool setTime = false;
if (args->Length > 1)
{
setTime = (args->get(1)->Equals(ref new String(L"set-time")));
}
//Get a reference to an I2C controller. If non is found have the program print
//an error message and exit immediately
String^ aqs = I2cDevice::GetDeviceSelector();
auto controllerList = concurrency::create_task(DeviceInformation::FindAllAsync(aqs)).get();
if (controllerList->Size < 1) {
cout << "no i2c controller found " << endl;
return -1;
}
//The DS3231 has an I2C address of 0x68. Create an I2cConnectionSettings object
//that contains this information
I2cConnectionSettings^ settings = ref new I2cConnectionSettings(0x68);
settings->BusSpeed = I2cBusSpeed::StandardMode;
//Create an I2cDevice object associated with the controller that we found for interacting
//with the real time clock
String^ controllerId = controllerList->GetAt(0)->Id;
auto realTimeClock = concurrency::create_task(I2cDevice::FromIdAsync(controllerId, settings)).get();
//If the time were being set then call the SetTime function. Otherwise call the ShowTime method
if (setTime) SetTime(realTimeClock);
else ShowTime(realTimeClock);
return 0;
}
我们仍然需要定义显示时间和设置时间的方法,以及移植 BCD/整数转换函数。C++ 中的转换函数与 C# 版本几乎相同。
byte BcdToInt(byte bcd)
{
byte retVal = (bcd & 0xF) + ((bcd >> 4) * 10);
return retVal;
}
byte IntToBcd(int v)
{
byte retVal = (byte)((v % 10) | (v / 10) << 0x4);
cout << v << " - " << (int) retVal << endl;
return retVal;
}
void SetTime(I2cDevice^ realTimeClock)
{
SYSTEMTIME systemTime;
//Get the system time. This will be in UTC
GetSystemTime(&systemTime);
//Copy the UTC time into the Real Time chip
std::vector<BYTE> setTimeCommand;
setTimeCommand.push_back((BYTE)0x00);
setTimeCommand.push_back(IntToBcd(systemTime.wSecond));
setTimeCommand.push_back(IntToBcd(systemTime.wMinute));
setTimeCommand.push_back(IntToBcd(systemTime.wHour));
setTimeCommand.push_back(IntToBcd(systemTime.wDayOfWeek));
setTimeCommand.push_back(IntToBcd(systemTime.wDay));
setTimeCommand.push_back(IntToBcd(systemTime.wMonth));
setTimeCommand.push_back(IntToBcd(systemTime.wYear % 100));
for (int i = 0; i < 10; ++i)
setTimeCommand.push_back(0);
realTimeClock->Write(ArrayReference<BYTE>(setTimeCommand.data(), static_cast<unsigned int>(setTimeCommand.size())));
}
显示时间需要更多的思考。请注意,在上面我请求系统时间时,我使用的是 GetSystemTime()
方法。还有一个 GetLocalTime()
。使用系统时间更容易,因为我不需要担心不同地理位置夏令时规则的复杂性。但是,power shell set-date
命令使用本地时间。所以我需要将从芯片读取的 UTC 时间转换回本地时间。Win32 中没有直接将 UTC 时间转换为本地时间的功能。但是,如果使用多种方法,我们可以进行转换。我在 Microsoft 页面 这里 上找到了需要调用的函数系列,其中显示了使用 3 个函数来执行此转换。
SystemTimeToFileTime(&time, &FileTime);
FileTimeToLocalFileTime(&FileTime, &LocalFileTime);
FileTimeToSystemTime(&LocalFileTime, &LocalTime);
有了这些知识,我们就可以完成显示时间的最后一个函数了。代码看起来与 C# 版本相似。
void ShowTime(I2cDevice^ realTimeClock)
{
std::vector<BYTE> readCommand;
Array<BYTE>^ resultBuffer = ref new Array<BYTE>(0x7);;
readCommand.push_back((BYTE)0x00);
realTimeClock->WriteRead(ArrayReference<BYTE>(readCommand.data(), static_cast<unsigned int>(readCommand.size())), resultBuffer);
SYSTEMTIME time;
ZeroMemory(&time, sizeof(time));
time.wSecond = BcdToInt(resultBuffer[0]);
time.wMinute = BcdToInt(resultBuffer[1]);
bool is24HourCock = (resultBuffer[2] >> 0x6) != 1;
if (is24HourCock)
time.wHour = (resultBuffer[2] & 0xF) + ((resultBuffer[2] >> 4) & 0x1) * 10 + ((resultBuffer[2] >> 0x5) * 20);
else
time.wHour = (resultBuffer[2] & 0xF) + ((resultBuffer[2] >> 4) & 0x1) * 10 + ((resultBuffer[2] >> 0x5) * 12); ;
time.wDayOfWeek = BcdToInt(resultBuffer[3]);
time.wDay = BcdToInt(resultBuffer[4]);
time.wMonth = BcdToInt((byte)(resultBuffer[5] & (byte)0x3f));
time.wYear = BcdToInt(resultBuffer[6]) + 2000;
//https://support.microsoft.com/en-us/kb/245786
FILETIME FileTime, LocalFileTime;
SYSTEMTIME LocalTime;
SystemTimeToFileTime(&time, &FileTime);
FileTimeToLocalFileTime(&FileTime, &LocalFileTime);
FileTimeToSystemTime(&LocalFileTime, &LocalTime);
std::cout << LocalTime.wMonth << "/" << LocalTime.wDay << "/" << LocalTime.wYear
<< " " << setfill('0') << setw(2) << LocalTime.wHour << ":" << LocalTime.wMinute << ":" << LocalTime.wSecond;
}
随着程序源代码的完成,所有剩余的工作是确保我之前输入的 power shell 命令在每次启动时都能运行。使用记事本,我将 两行脚本 的文本保存到一个名为 UpdateTime.ps1
的文件中,并将其复制到 Raspberry Pi 的 c:\shares\boot
路径。为了测试,我通过设置系统时间为正确时间然后运行 .\SetTimeTool.exe set-time
来确保 RTC 具有正确的时间。然后我故意将系统时钟设置为错误的日期和时间,方法是键入 Set-date "2010/01/02 15:16"
(设置时间),然后运行 date
让系统显示其日期。正如预期的那样,系统现在显示了错误的日期。我运行了 .\UpdateTime.ps1
,然后再次运行 date
,看到正确的时间现在显示出来了。最后一次测试,我创建了一个 UWP 应用程序,它除了每秒更新一次系统时间外,什么也不做,并将其设置为默认程序。我使用以下 power shell 命令将我的 UpdateTime.ps1
脚本安排在启动时运行。
schtasks /create /tn "Update Time Script" /tr C:\shares\boot\UpdateTime.ps1 /sc onstart /ru SYSTEM
这是必要的,因为要确保程序正常工作,它将需要从网络启动(这将使我们无法远程调用程序),这样脚本将是唯一对时钟进行更改的。断开设备的电源,等待几分钟,然后重新打开。您应该看到时间设置正确。
其他时钟实现
有几种其他芯片可用作实时时钟。一些 GPS 接收器带有电池备份,因此一旦它们获取到时间,即使没有 GPS 信号,它们也可以继续提供时间。此代码不直接适用于这些其他芯片,但可以轻松改编,以便在启动时使用相同的技术从实时源初始化时钟。
历史
- 2016 年 7 月 20 日 - 首次发布