张高兴的 .NET IoT 入门指南:(八)基于 GPS 的 NTP 时间同步服务器
时间究竟是什么?这既可以是一个哲学问题,也可以是一个物理问题。古人对太阳进行观测,利用太阳的投影发明了日晷,定义了最初的时间。随着科技的发展,天文观测的精度也越来越准确,人们发现地球的自转并不是完全一致的,这就导致每天经过的时间是不一样的。这点误差对于基本生活基本没有影响,但是对于股票交易、火箭发射等等要求高精度时间的场景就无法忍受了。科学家们开始把观测转移到了微观世界,找到了一种运动高度稳定的原子——铯,最终定义出了准确的时间:铯原子电子跃迁 9192631770 个周期所持续的时间长度定义为 1 秒。基于这个定义制造出了高度稳定的原子钟。
时间在计算机中又是如何定义的呢?通常使用 Unix 时间戳进行表示,记录的是自公元 1970 年 1 月 1 日 0 时 0 分 0 秒以来的秒数。计算机为了维持时钟的走时,硬件层面使用晶体振荡器保障时钟的精确性(也是石英钟的原理),操作系统层面使用时钟中断去更新时间的流逝。现代计算机的硬件设计通常有独立的时钟(RTC),这源于 Intel 和微软创立的标准 High Precision Event Timer(HPET),标准指定了 10 MHz 的时钟速度,因此时钟可以获得 100 纳秒的分辨率。这也是 .NET 时间有关的类型中 Ticks 属性的由来,1秒 = 10000000 Ticks。虽然计算机的时钟已经足够精准,但也会受到环境温度的影响造成过快或者过慢的问题。为了对计算机的时钟进行校准,通常使用 NTP 协议与网络中的时间服务器进行同步。时间服务器的时间又会使用 GPS 接收机、无线电或者是原子钟进行校准。
本文将从 GPS 时间的获取、NTP 报文的编写实现一个“玩具”级别的时间同步服务器,使用 .NET 6 编写一个控制台应用程序,通过本文你可以学到:
- 串口
SerialPort类的使用; - 使用
Socket类实现 UDP 的监听与回复; - 在程序中使用
Process类执行命令行指令; - 了解 GPS 数据报文的
NMEA-0183协议; - 了解 NTP 协议报文。
- https://github.com/ZhangGaoxing/gps-ntp
项目结构
创建一个控制台应用和类库,项目结构如下:
项目依赖
添加如下 NuGet 包引用:
配置串口读取 GPS 数据
绝大部分 GPS 模块每秒会通过串口输出 NMEA-0183 协议报文,因此我们只需要通过串口读取需要的时间数据即可。此环节包含 3 个步骤:
- 初始化串口;
- 读取
$GPRMC数据帧的内容,提取时间信息; - 更新系统时间。
初始化串口
使用串口时最重要的属性是波特率,请查阅对应 GPS 模块的数据手册,这里使用的 NEO-6M 模块的波特率是 9600。串口的名称取决于你的连接方式,在 Linux 中串口对应的驱动文件在
/dev目录下,使用内置串口可能的文件名称为ttySx,使用 USB 串口可能的文件名称为ttyUSBx,在 Windows 中串口的名称为COMx,其中x表示的是数字编号。// 使用的串口名称 const string SERIAL_NAME = "/dev/ttyUSB0"; using SerialPort gps = new SerialPort(SERIAL_NAME) { BaudRate = 9600, Encoding = Encoding.UTF8, ReadTimeout = 500, WriteTimeout = 500, };从串口中获取数据
从串口中读取数据时使用的是
SerialPort类中的DataReceived事件。事件(event)可以理解为一种广播,当完成某种操作后向外发送通知。即串口接收到数据后,触发数据处理事件。gps.DataReceived += GpsFrameReceived; ////// GPS 报文处理 /// void GpsFrameReceived(object sender, SerialDataReceivedEventArgs e) { // TODO:读取 `$GPRMC` 数据帧;提取时间;更新系统时间 }由于 GPS 模块输出的不只有
$GPRMC数据帧,因此需要在处理事件中判断帧头以及帧的有效性。void GpsFrameReceived(object sender, SerialDataReceivedEventArgs e) { string frame = gps.ReadLine(); if (frame.StartsWith("$GPRMC")) { // $GPRMC,UTC 时间,定位状态,纬度,纬度半球,经度,经度半球,速度,航向,UTC 日期,磁偏角,磁偏角方向,指示模式*校验和 // $GPRMC,013717.00,A,3816.57392,N,10708.73951,E,0.467,,050722,,,A*78 string[] field = frame.Split(','); // 帧数据有效 if (!field[12].StartsWith("N")) { // TODO:提取时间;更新系统时间 } } }在验证
$GPRMC数据帧有效后,根据帧解析提取对应字段的时间信息。void GpsFrameReceived(object sender, SerialDataReceivedEventArgs e) { string frame = gps.ReadLine(); if (frame.StartsWith("$GPRMC")) { string[] field = frame.Split(','); if (!field[12].StartsWith("N")) { // 获取 GPS 时间 string time = field[1][0..6]; string date = field[9]; DateTime utcNow = DateTime.ParseExact($"{date}{time}", "ddMMyyHHmmss", CultureInfo.InvariantCulture); // TODO:更新系统时间 } } }更新系统时间
由于 .NET 并不提供修改系统时间的操作,因此我们要使用间接的方式修改系统时间。一种方式是使用 P/Invoke 调用 C++ 的函数,这种方式可以精确的修改时间,但涉及引用、数据类型转换,过于复杂,和本入门指南不符。这里使用的是运行命令行指令的方式修改系统的时间,但修改时间的精度只能精确到秒。在 Windows 中使用
PowerShell的Set-Date命令,在 Linux 中使用date命令。////// 更新系统时间 /// void UpdateSystemTime(DateTime time) { ProcessStartInfo processInfo; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { processInfo = new ProcessStartInfo { FileName = "powershell.exe", Arguments = $"Set-Date \"\"\"{time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")}\"\"\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; } else { processInfo = new ProcessStartInfo { FileName = "date", Arguments = $"-s \"{time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")}\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; } var process = Process.Start(processInfo); process.WaitForExit(); }最终报文处理事件由以下代码构成:
void GpsFrameReceived(object sender, SerialDataReceivedEventArgs e) { string frame = gps.ReadLine(); if (frame.StartsWith("$GPRMC")) { string[] field = frame.Split(','); if (!field[12].StartsWith("N")) { string time = field[1][0..6]; string date = field[9]; DateTime utcNow = DateTime.ParseExact($"{date}{time}", "ddMMyyHHmmss", CultureInfo.InvariantCulture); UpdateSystemTime(utcNow); // 记录时钟最后一次被更新的时间 lastUpdatedTime = utcNow; } } }使用
gps.Open();打开串口后就可以获取时间数据了。实现 NTP 服务
下面使用
Socket类实现一个简单的 UDP 服务器,用于监听和回复 NTP 报文。初始化 UDP 服务
// NTP 服务初始化 using Socket ntpServer = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); IPEndPoint ip = new IPEndPoint(IPAddress.Any, 123); ntpServer.Bind(ip);监听和回复 NTP 报文
在后台新建一个进程用于监听 NTP 请求报文:
new Thread(NtpFrameReceived) { IsBackground = true }.Start(); ////// NTP 报文接收与回复 /// void NtpFrameReceived() { // 存储接收到的 NTP 请求报文 SpanreceiveFrame = stackalloc byte[48]; while (true) { // 接收请求报文 EndPoint clientPoint = new IPEndPoint(IPAddress.Any, 0); ntpServer.ReceiveFrom(receiveFrame, ref clientPoint); DateTime receiveTime = DateTime.UtcNow; // TODO:回复 NTP 报文 } } 根据帧解析生成 NTP 回复报文:
////// 生成 NTP 报文 /// SpanGenerateNtpFrame(Span receivedFrame, DateTime receiveTime) { Span ntpFrame = stackalloc byte[48] { 0x1c, 0x01, 0x11, 0xe9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, }; // Client Transmit Timestamp => Server Origin Timestamp for (int i = 0; i < 8; i++) { ntpFrame[24 + i] = receivedFrame[40 + i]; } // 本机时钟最后更新时间 long referenceTicks = (lastUpdatedTime - ntpStart).Ticks; uint referenceTimeInt = (uint)(referenceTicks / TICK_2_SECOND); uint referenceTimeFract = (uint)(referenceTicks % TICK_2_SECOND); var referenceTimeIntByte = BitConverter.GetBytes(referenceTimeInt); var referenceTimeFractByte = BitConverter.GetBytes(referenceTimeFract); // 接收报文时间 long receiveTicks = (receiveTime - ntpStart).Ticks; uint receiveTimeInt = (uint)(receiveTicks / TICK_2_SECOND); uint receiveTimeFract = (uint)(receiveTicks % TICK_2_SECOND); var receiveTimeIntByte = BitConverter.GetBytes(receiveTimeInt); var receiveTimeFractByte = BitConverter.GetBytes(receiveTimeFract); // 发送报文时间 long transmitTicks = (DateTime.UtcNow - ntpStart).Ticks; uint transmitTimeInt = (uint)(receiveTicks / TICK_2_SECOND); uint transmitTimeFract = (uint)(receiveTicks % TICK_2_SECOND); var transmitTimeIntByte = BitConverter.GetBytes(receiveTimeInt); var transmitTimeFractByte = BitConverter.GetBytes(receiveTimeFract); if (BitConverter.IsLittleEndian) { for (int i = 0; i < 4; i++) { ntpFrame[19 - i] = referenceTimeIntByte[i]; ntpFrame[23 - i] = referenceTimeFractByte[i]; ntpFrame[35 - i] = receiveTimeIntByte[i]; ntpFrame[39 - i] = receiveTimeFractByte[i]; ntpFrame[43 - i] = transmitTimeIntByte[i]; ntpFrame[47 - i] = transmitTimeFractByte[i]; } } else { for (int i = 0; i < 4; i++) { ntpFrame[16 + i] = referenceTimeIntByte[i]; ntpFrame[20 + i] = referenceTimeFractByte[i]; ntpFrame[32 + i] = receiveTimeIntByte[i]; ntpFrame[36 + i] = receiveTimeFractByte[i]; ntpFrame[40 + i] = transmitTimeIntByte[i]; ntpFrame[44 + i] = transmitTimeFractByte[i]; } } return ntpFrame.ToArray(); } 最终报文请求与回复由以下代码构成:
void NtpFrameReceived() { SpanreceiveFrame = stackalloc byte[48]; while (true) { EndPoint clientPoint = new IPEndPoint(IPAddress.Any, 0); ntpServer.ReceiveFrom(receiveFrame, ref clientPoint); DateTime receiveTime = DateTime.UtcNow; // 回复 NTP 报文 Span sendFrame = GenerateNtpFrame(receiveFrame, DateTime.UtcNow); ntpServer.SendTo(sendFrame, clientPoint); DateTime sendTime = DateTime.UtcNow; } } 将上述代码进行整合就构成了基于 GPS 的 NTP 时间同步服务器。
部署应用
发布到文件
- 切换到
GpsNtp项目运行发布命令:
dotnet publish -c release -r linux-x64 --no-self-contained- 将发布后的文件通过 FTP 等方式复制到 Linux 开发板;
- 为
GpsNtp文件增加可执行权限
sudo chmod +x GpsNtp- 运行程序
sudo ./GpsNtp构建 Docker 镜像
- 在项目的根目录中创建
Dockerfile,并将整个项目复制到 Linux 开发板中:
FROM mcr.microsoft.com/dotnet/core/sdk:6.0-focal AS build WORKDIR /app # publish app COPY src . WORKDIR /app/GpsNtp RUN dotnet restore RUN dotnet publish -c release -r linux-arm -o out # run app FROM mcr.microsoft.com/dotnet/core/runtime:6.0-focal AS runtime WORKDIR /app COPY --from=build /app/GpsNtp/out ./ ENTRYPOINT ["dotnet", "GpsNtp.dll"]- 切换到项目目录,构建镜像:
docker build -t gps-ntp -f Dockerfile .- 运行镜像:
docker run --rm -it --device /dev/ttySx gps-ntp程序运行后,使用 Windows 时间同步服务进行一下测试。