張高興的 .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 Process NMEA-0183
- 硬體需求
- 電路
- GPS 資料報文的 NMEA-0183 協議
- NTP 協議報文
- 編寫程式碼
- 專案結構
- 專案依賴
- 配置串列埠讀取 GPS 資料
- 實現 NTP 服務
- 部署應用
- 釋出到檔案
- 構建 Docker 映象
- 後續工作
硬體需求
名稱 | 描述 | 數量 |
---|---|---|
計算機 | 可以是執行 Linux 的開發板,也可以是執行 Windows 的電腦 | x1 |
NEO-6M | GPS 模組 | x1 |
USB 串列埠 | 可選,使用 USB 串列埠將 GPS 模組與計算機相連 | x1 |
杜邦線 | 感測器與開發板的連線線 | 若干 |
電路
感測器 | 介面 | 開發板介面 |
---|---|---|
NEO-6M | TX | 開發板或 USB 串列埠的RX |
RX | 開發板或 USB 串列埠的TX | |
VCC | 5V | |
GND | GND |
GPS 資料報文的 NMEA-0183 協議
NMEA-0183 是 GPS 裝置輸出資訊的標準格式,是由美國國家海洋電子協會(National Marine Electronics Association)定製的標準。NMEA-0183 有多種不同的資料報文,每種都是獨立的 ASCII 字串,使用逗號隔開資料,資料流長度從 30-100 字元不等,通常以每秒間隔選擇輸出。NMEA-0183 協議定義的語句非常多,但是常用的或者說相容性最廣的語句只有 $GPGGA
、 $GPGSA
、 $GPGSV
、 $GPRMC
、 $GPVTG
等。下面給出這些常用 NMEA-0183 語句的解釋。
幀名稱 | 說明 | 最大幀長 |
---|---|---|
$GPGGA | 全球定位資料 | 72 |
$GPGSA | 衛星 PRN 資料 | 65 |
$GPGSV | 衛星狀態資訊 | 210 |
$GPRMC | 推薦最小資料 | 70 |
$GPVTG | 地面速度資訊 | 34 |
由於我們只需要從 GPS 中獲取時間資訊,選擇包含時間資訊的 “ $GPRMC
推薦最小資料”幀進行解析:
$GPRMC | <1> | <2> | <3> | <4> | <5> | <6> | <7> | <8> | <9> | <10> | <11> | <12>*<13> |
---|---|---|---|---|---|---|---|---|---|---|---|---|
幀頭 | UTC 時間 | 定位狀態 | 緯度 | 緯度半球 | 經度 | 經度半球 | 地面速率 | 地面航向 | UTC 日期 | 磁偏角 | 磁偏角方向 | 模式 * 校驗和 |
下面以一個真實的資料幀為例 $GPRMC,013717.00,A,3816.57392,N,10708.73951,E,0.467,,050722,,,A*78
:
$GPRMC | 013717.00 | A | 3816.57392 | N | 10708.73951 | E | 0.467 | 050722 | A*78 | |||
---|---|---|---|---|---|---|---|---|---|---|---|---|
幀頭 | UTC 時間 01:37:17 | A=有效定位,V=無效定位 | 緯度 38 度 16.57392 分 | 北緯 | 經度 107 度 8.73951 分 | 東經 | 地面速率 0.467 節 | 航向 度 | UTC 日期 2022/07/05 | 磁偏角 度 | 磁偏角方向 | A=自主定位,N=資料無效 |
因此,通過串列埠讀取 $GPRMC
資料幀後,需要解析 <1>
和 <9>
欄位的值,並將其轉換為 UTC 時間。
細心的你也許會發現獲取到的時間資訊只精確到秒,GPS 明明使用的是原子鐘,這是為什麼?仔細觀察手中的 GPS 模組,還有一個 PPS 針腳沒有使用。PPS(Pulse Per Second)是秒脈衝,一般是由 GPS 接收機或原子鐘按秒發出的、寬度小於1秒、有著急升或突降邊沿的脈衝訊號,通常用於精確計時和測量時間。PPS 訊號能精確地(亞毫秒級)指示每一秒的開始時間,但不能指示對應現實時間的哪一秒,因此只能作為輔助訊號,與衛星導航資訊組合使用,提供低延遲、低抖動的授時服務。很遺憾,.NET 目前沒法直接操作 PPS 引腳,我們只能實現一個“玩具”級的時間同步伺服器了。
NTP 協議報文
NTP(Network Time Protocol),網路時間協議,是一種使用 UDP 的計算機之間進行時間同步的網路協議,位於 OSI 7 層網路模型中的應用層,預設使用的埠為 123。那麼使用 NTP 是如何進行時間同步的呢?簡單的說將傳送的報文打上本機的時間戳,配合報文來回傳輸的時延修正本機的時間。如下圖所示,可以計算出網路傳輸時延 \(\delta\) ,以及客戶端與服務端的時間偏移 \(\theta\) :
\(\delta=(t_3-t_0)-(t_2-t_1)\)
\(\theta=\frac{(t_1-t_0)+(t_2-t_3)}{2}\)
其中, \(t_0\) 是請求報文傳輸的客戶端時間戳, \(t_1\) 是請求報文接收的伺服器時間戳, \(t_2\) 是回覆報文傳輸的伺服器時間戳, \(t_3\) 是回覆報文接收的客戶端時間戳。客戶端和服務端都有一個時間軸,分別代表著各自系統的時間,當客戶端想要同步服務端的時間時,客戶端會構造一個 NTP 報文傳送到服務端,客戶端會記下此時傳送的時間 \(t_0\) ,經過一段網路延時傳輸後,伺服器在 \(t_1\) 時刻收到報文,經過一段時間處理後在 \(t_2\) 時刻向客戶端返回報文,再經過一段網路延時傳輸後客戶端在 \(t_3\) 時刻收到伺服器報文。這樣客戶端就可以校準自己的本機時間了。
在瞭解 NTP 同步時間的過程後,下面解析 NTP 報文具體包含的欄位,一般的 NTP 報文長度為 48 位元組:
欄位 | 說明 |
---|---|
LI | 閏秒指示,2bit |
Version | NTP 版本,3bit |
Mode | 工作模式,3bit ,客戶端=0b011,伺服器=0b100 |
Stratum | 時鐘層數,8bit,層數為 0 的裝置為高精度的時鐘(如原子鐘),層數為 1 的裝置與層數 0 的裝置直接相連,…… |
Poll Interval | 輪詢時間,8bit,連續 NTP 報文之間的最大時間間隔 |
Precision | 時鐘精度,8bit |
Root Delay | 根時延,32bit,表示在主參考源之間往返的總共時延 |
Root Dispersion | 根離散,32bit,相對於主參考源的標稱誤差 |
Reference ID | 參考源的標識,32bit,4 個字元或 IP 地址 |
Reference Timestamp | 參考時間戳,64bit,本地時鐘最後一次被更新的時間 |
| Originate Timestamp | 原始時間戳 \(t_0\) ,64bit,客戶端傳送的時間 |
| Receive Timestamp | 接受時間戳 \(t_1\) ,64bit,服務端接受到的時間 |
| Transmit Timestamp | 傳送時間戳
\(t_2\)
,64bit,服務端傳送的時間 |
其中要注意的是 NTP 時間戳的起始時間是 1900-01-01 00:00:00
,而不是 Unix 時間戳的起始時間 1970-01-01 00:00:00
。
下面是使用 Wireshark 抓取的 Windows 時鐘同步的 NTP 報文:
編寫程式碼
專案地址: https://github.com/ZhangGaoxing/gps-ntp
專案結構
建立一個控制檯應用和類庫,專案結構如下:
專案依賴
新增如下 NuGet 包引用:
<ItemGroup> <PackageReference Include="System.IO.Ports" Version="6.0.0" /> </ItemGroup>
配置串列埠讀取 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; /// <summary> /// GPS 報文處理 /// </summary> 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
命令。
/// <summary> /// 更新系統時間 /// </summary> 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(); /// <summary> /// NTP 報文接收與回覆 /// </summary> void NtpFrameReceived() { // 儲存接收到的 NTP 請求報文 Span<byte> receiveFrame = stackalloc byte[48]; while (true) { // 接收請求報文 EndPoint clientPoint = new IPEndPoint(IPAddress.Any, 0); ntpServer.ReceiveFrom(receiveFrame, ref clientPoint); DateTime receiveTime = DateTime.UtcNow; // TODO:回覆 NTP 報文 } }
根據幀解析生成 NTP 回覆報文:
/// <summary> /// 生成 NTP 報文 /// </summary> Span<byte> GenerateNtpFrame(Span<byte> receivedFrame, DateTime receiveTime) { Span<byte> 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() { Span<byte> receiveFrame = stackalloc byte[48]; while (true) { EndPoint clientPoint = new IPEndPoint(IPAddress.Any, 0); ntpServer.ReceiveFrom(receiveFrame, ref clientPoint); DateTime receiveTime = DateTime.UtcNow; // 回覆 NTP 報文 Span<byte> 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
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 時間同步服務進行一下測試。
- 記一次批量更新整型型別的列 → 探究 UPDATE 的使用細節
- 編碼中的Adapter,不僅是一種設計模式,更是一種架構理念與解決方案
- 執行緒池底層原理詳解與原始碼分析
- 30分鐘掌握 Webpack
- 線性迴歸大結局(嶺(Ridge)、 Lasso迴歸原理、公式推導),你想要的這裡都有
- Django 之路由層
- 【前端必會】webpack loader 到底是什麼
- day42-反射01
- 中心化決議管理——雲端分析
- HashMap底層原理及jdk1.8原始碼解讀
- 詳解JS中 call 方法的實現
- 列印 Logger 日誌時,需不需要再封裝一下工具類?
- 初識設計模式 - 代理模式
- 設計模式---享元模式
- 密碼學奇妙之旅、01 CFB密文反饋模式、AES標準、Golang程式碼
- [ML從入門到入門] 支援向量機:從SVM的推導過程到SMO的收斂性討論
- 從應用訪問Pod元資料-DownwardApi的應用
- Springboot之 Mybatis 多資料來源實現
- Java 泛型程式設計
- CAS核心思想、底層實現