[教學] 用MicroPython網路對時NTP 學習解開網路封包

ESP32 NTP 網路對時

由於 NodeMCU (ESP8266) 或 ESP32 的MCU 裡面帶有實時時鐘 (Real-time clock,RTC),在RTC 被校正之後,能用於時間戳章(Time Stamp)的相關應用。比方說,可以把溫度感測器的測量數值,跟當前的時間,一起存進去 SD 卡的 log 紀錄檔,這樣讀取歷史資料時,就知道某個溫度,是在何時被量測紀錄的。

但由於MCU模塊,不像電腦主機板有安裝 CR2032 鋰電池,去維持RTC不間斷運作,一旦 MCU電源中斷,之前網路校準好的時間就沒了,重新上電後,RTC會回到預設的 2000年1月1日0點。好在 NodeMCU 跟 ESP32 都內建 WiFi,因此透過網路對時伺服器 NTP Server,便能隨時校正RTC。對於價格便宜的 MCU 來說,更頻繁地執行網路校時,可以彌補 RTC 不準的缺陷。

詢問 NTP Server 目前時間

NTP Server是使用 UDP 通訊協定,並使用 123 這個連接埠來通訊,台灣有以下幾個常用的 NTP Server,可就近對時,縮短回應時間:

tick.stdtime.gov.tw (118.163.81.62)

tock.stdtime.gov.tw (211.22.103.157)

watch.stdtime.gov.tw (118.163.81.63)

clock.stdtime.gov.tw (211.22.103.158)

詢問 NTP Server 當前時間,需要設定 UDP 通訊協定 socket.socket(socket.AF_INET, socket.SOCK_DGRAM),並送出以下這一長串位元組串 (bytes) 給 NTP Server,就會得到 NTP Server 回覆時間:

b'\x23\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

這個詢問用的封包,第一個位元組可用\0x23,然後接上 47 個 0x00 ,總共48個Bytes,下面會解釋這個詢問封包的意義。要製造這個封包,你可以先產生字串: '\x23' + 47 * '\0',然後丟給字串編碼函式 encode('ascii') ,就能得到 ByteArray格式的資料封包:ntpquery.encode('ascii')。

送出以上封包給 NTP Server 後,它會回覆一樣格式、長度 48 bytes 的封包,裡面包含你要對實用的時間。要解開這串封包,我們可以用 Python內建的 struct 的 unpack 方法,把封包的 ByteArray,直接轉成實際的數字。

NTP 48-Byte 封包格式解析

我們先來看一下NTP Server 這 48個 bytes 的意義,傳送、接收都是一樣的格式:

NTP Server Response 48 bytes in Total (blue color)

我們詢問 NTP Server ,或它回覆我們的 48個bytes的封包,就只有上面藍色的部份,每一排 4個 byte (32-bit)。灰色的部份,目前NTP協定還沒有用到、也不會傳送。為了讓你更容易了解48byte封包的架構,我把上面網路上找到的封包格式,重新畫一個用 Python 角度思考的的表格:

從 Python ByteArray角度來看封包結構

比對我們前面傳送過去 NTP Server 的封包,只有第一個Byte、即上表最前面那 4bytes 檔頭的第1個byte,也就是最前面那八個bit有意義,剩下3個byte都用0x00填滿即可。之前告訴你那個詢問封包的第一個位元組 0x23 ,轉成二進位表示,就是 00100011 ,說明一下這個二進位串的意義:

(註:二進位跟十進位之間的轉換:00 = 0; 01 = 1; 10 = 2; 11 = 3; 100 = 4; 101 = 5; 110 = 6; 111 = 7)

LI (2bit) 閏秒:00 無閏秒調整

0: No leap second adjustment
1: Last minute of the day has 61 seconds
2: Last minute of the day has 59 seconds
3: Clock is unsynchronized>

VN (3bit) NTP 版本:100 NTP通訊協定版本 4

版本4主要針對 IPv6的支援並更正版本3的一些Bug,對資料結構無影響,所以填入100。網路上有一些範例,填入011,也是可以。

Mode (3bit) 通訊模式:011宣告我這邊是 Client

0: Reserved
1: Symmetric active
2: Symmetric passive
3: Client
4: Server
5: Broadcast
6: NTP control message
7: Reserved for private use

因為你的角色是用戶Client端,要去詢問 NTP Server 當前時間,所以這三個 bit,一定要用 011 (3)。也因為你是Client,所以後面那三個byte,Stratum, Poll, Precision,也就不用管了,那是給 Server 用的。然後接下來的三組4個Byte,Root Delay, Root Dispersion, Reference Identifier 也都是Server間的對時才用的到,全部都填入0x00。所以,48-byte 的檔頭才會長成這樣:

b'\x23\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

NTP Server 封包裡的時間戳記 Timestamp

NTP Server回覆的封包,我們感興趣的部份,是後面那幾個 Timestamp時間戳記,每個 Timestamp用 八個 bytes (64 bits) 表示時間 ,前面4個bytes (32bits),是從1900年1月1日0時起算到現在的秒數。而後四個 byte 是亞秒Subsecond,代表2^32分之1秒 (1/4294967296),這樣算起來NTP Server 的時間解析度,可到 233皮秒 (pico second)這麼精密。然而對 ESP8266、ESP32來說,亞秒Subsecond 只有 Microsecond 微秒等級。因此,NTP 得到的亞秒讀值除以2^32乘以1000000就是微秒了 (等同於NTP回覆的亞秒值,除以4294.967296後,取整數,2^32=4294967296)
從封包位址16 開始的那一連串時間戳記,各有不同的意義,Reference Time Stamp是 Server上次校正的時間,Originate (T0) 是你送出詢問封包 (b'\x1b\x00\.....x47)的時間 ,Receive (T1) 是 NTP Server收到的時間,Transmit (T2)則是NTP Server送出回覆封包給你的時間。還有一個 T3,是你收到回覆封包的時間,T0 跟 T3可以自行讀系統時間。然後用 Clock synchronization algorithm 演算法,算出比較準的對時時間,這個演算法的目的,就是要估計封包來回傳送途中耽誤的時間,然後得到單趟傳送的時間。這樣一來,NTP Server 送出時間,加上單趟傳送延誤的時間,就是你要對時的目前時間了。
但因為 MCU 並不是精密的計時器,甚至可以說很不準。加上你的指令,還經過 MicroPython 這層才到 ESP32底層硬體,並非直接對ESP32的暫存器操作,所以中間還有其他的延遲。需不需要搞到這麼講究,那就見仁見智了。

解開 NTP 回覆封包 (48 bytes)

講太細了,回到主題來。所以說,整串 48-byte 的封包,我們真正需要的,其實只有封包位址 40 跟 44 這兩個 4-byte 整數。

使用 struct.unpack() 解開位元組封包

由於網路Socket傳回的是一長串8位元(8bits = 1byte) 的ByteArray封包packet。你必須把封包解開,才能獲得這兩個整數時間值。如同一般解開網路封包的方式,你可以用 Python內建的 struct 的unpack 方法,來把網路封包解開。首先我們先把這48-byte 的封包,擷取出 40到47這段。Python 要擷取某段資料很簡單,用List的語法即可:

msg, address = ntpclient.recvfrom(48)#讀取NTP Server 回覆的 48-byte 封包 ttransmit = struct.unpack("!2I", msg[40:])#從位址第40byte開始到結尾第47byte,一共8個Byte

然後,解開這個封包所使用的格式是"!2I"。第一個 ! 指定解開的是網路封包的位元組排列方式,等同於 > 符號。網路封包都是使用big-endian 大端序的位元組排列方式解碼,也就是高位先到的原則,解網路封包,用驚嘆號就對了。

什麼是 big-endian 大端序?

舉例來說,收到一組 4個byte的封包放在變數 byteobj = b'\x01\x02\x03\x04'。其中,0x01從socket先收到,0x04最後收到,在Python的ByteArray物件byteobj中,byteobj[0]=0x01代表的是高位,byteobj[3]=0x04代表是低位,轉成整數就是0x01020304 = 1690906的十進位,跟我們在10進位系統,右邊是低位、左邊是高位的概念一樣。不過,以byteobj 這個 ByteArray所代表的 List來說,你會容易搞混,因為正好相反 [0] 是高位,而不是低位。

所以說,big-endian 大端序,以ByteArray印出來看的格式 b'\x01\x02\x03\x04',跟我們對10進位的理解一樣。然而,用ByteArray的List來看,則是倒過來,反而是小的在前面:byteobj[0] byteobj[1] byteobj[2] byteobj[3],別搞混了。你可以自己進去Python的互動視窗,拿一台工程計算機試驗一下。

struct.unpack() 解碼格式定義

抱歉,又離題了。主要我之後想些一篇關於 MCU 跟 Server 之間通訊的教學,《替 Arduino Nano 開無線外掛 (二) 使用 WiFiEsp 函式庫》有提到一些概念,但不是很完整,之後有時間再寫一篇Client Server 兩端,都是用 MicroPython 通訊的範例。用Python寫網路通訊程式,有很多優勢,你寫了之後,就不想回去C++了。

格式定義 "!12I"裡面的12I,則是代表把兩組4-byte封包,解開成2個整數,然後存進去 ttransmit 這個List,ttransmit[0] 是秒數,ttransmit[1]是亞秒。要知道更多 struct.unpack 格式的意義,你可以參考:unpact 官方說明書

如何將NTP傳回的秒數值,轉換成正確的年月日時間?

秒數的部份,NTP Server是從 1900年1月1號起算,而MicroPython的ESP32改系統時鐘的方法RTC.datetime(),是從2000年1月1號起算,之間差了3155673600秒。自己打開電腦 Python 互動視窗,寫個程式就能得到,不用Google。實在太喜歡Python了,真方便:

from datetime import date
(date(2000,1,1) - date(1900,1,1)).days*24*3600

另由於台灣處於 +8 時區,比格林威治天文台所處的本初子午線那裡多8小時,也就是8*3600秒,所以別忘了要加上(範例中的寫法,負負得正)。而亞秒,前面有提到,他的單位是 1/2^32,算出來實際亞秒數(單位也是秒,只是亞秒是小數點)以後,必須乘上1000000,得到的才是 us 微秒。 

下面這幾行,讓你可以得到校正用的秒數與微秒數:

    epoch = ttransmit[0]
    epoch -= TIME2000 - timezone*3600

    micros = int(ttransmit[1]//4294.967296)

執行時間校準

MicroPython是用 machine.RTC 模組裡面的 datetime 指令來讀取或更新時間。在函式裡面加入以下時間參數,就能更改RTC時間:
RTC.datetime: (year, month, mday, weekday, hour, minute, second, micros)
所以你需要先把 epoch 秒數,先拆開成年,月,日,期,時,分,秒,微秒這8個數字,可以用 time.gmtime() 方法獲得前7項,只是因為它傳回的排列順序,跟RTC.datetime不一樣,所以要重排一下,然後把上面已經得到的 micros 加上去就可以了:
tm = time.gmtime(epoch)
tm = tm[0:3] + (tm[6],) + tm[3:6] + (micros,)
前面有提到對時演算法(Clock synchronization algorithm ),把 NTP Server 詢問、答覆,來回的這段時間考慮進去的話,你可以在發出詢問字串後,跟收到詢問字串後,讀取兩個時間,這兩個時間,就是演算法中的t0跟t3。而t1跟t2呢,我們可以假設NTP Server的反應速度超快收到你的詢問,一瞬間就回覆你了,那就可以忽略不計。不信的話,你可以自己讀出來看看,這兩個時間有多久的差異,直接把T0, T1, T2 共6個整數時間讀出來:

ttransmit = struct.unpack("!6I", msg[24:])

如果你不講究,那就像我下面範例程式那樣,假裝沒有傳送時間這回事,直接把 Transmit Timestamp拿來對時。
如果你想稍微講究一下,那就把往返時間的單趟算進去:
ntpclient.sendto(ntpquery.encode('ascii'), ntpaddr)
t0 = time.ticks_ms()
.....
msg, address = ntpclient.recvfrom(48)
t3 = time.ticks_ms()
duration = int((t3 - t0) // 2)
如果你是個非常講究的人,那就按照維基百科對時演算法的那個公式,把 48-byte 封包裡面的 T1 跟 T2 一起算進去就能滿足了 :)

後記

本篇教學,大多參考這篇 NTP官方文件,以及參考多位在 Githup 提供C++範例程式的網友,不一一贅述。以下是完整的範例程式。你可以用 ampy put ntpsync.py 以及 ampy run ntpsync.py 執行看看。關於 ampy 的教學,看《這裡》。

MicroPython 程式範例 ntpsync.py

import socket

import machine

import time

import struct

from micropython import const


ntpsync(host = "211.22.103.158", port = 123, timezone=8):

ntpaddr = (host, port)

ntpquery = '\x23' + 47 * '\0'#詢問用的字串

# connect to NTP server

ntpclient = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

ntpclient.sendto(ntpquery.encode('ascii'), ntpaddr)#先用encode把字串包成ByteArray封包,才能送出

msg, address = ntpclient.recvfrom(48)

TIME2000 = const(3155673600)# 1900-01-01 00:00:00 to 2000-01-01 00:00:00 seconds從1900年算到2000年的秒數,要扣除這個數字

ttransmit = struct.unpack("!2I", msg[40:])

epoch = ttransmit[0]

epoch -= TIME2000 - timezone*3600

micros = int(ttransmit[1]//4294.967296)

tm = time.gmtime(epoch)

#gmtime format (year, month, mday, hour, minute, second, weekday, yearday)

#RTC datetime format: (year, month, mday, weekday, hour, minute, second, micros)

tm = tm[0:3] + (tm[6],) + tm[3:6] + (micros,)

return(tm)

if __name__ == "__main__":

#以下這段,是把 NodeMCU、ESP32連線上去 WiFi AP

wlan_sta = network.WLAN(network.STA_IF)

wlan_sta.active(True)

wlan_sta.connect("ssid", "password")#SSID就是無線路由器名稱,跟密碼要自己更改

tmstart = time.ticks_ms()

while not wlan_sta.isconnected():

if time.ticks_diff(time.ticks_ms(), tmstart) > 10000:#10 seconds timeout wlan_sta.disconnect()

print("WiFi AP Connection Failed!")

break

else:

print("WiFi AP Connected!")

#連線無線AP之後,開始對時,校正板子裡面的RTC時鐘

rtc = machine.RTC()

timenow = ntpsync(timezone=8)

rtc.datetime(timenow)

沒有留言:

張貼留言