# 以 Arduino + ESP8266 物聯網模組重作 NTP 實驗 (二)
在這篇之前的實驗 (一) 裡, 我嘗試使用從 AllAboutEE 找到的函式, 直接操作序列埠緩衝器來取得 NTP 伺服器的回應訊息. 但是由於對 NTP 協定結構與序列埠函式庫的用法不甚熟悉, 無法像書中所說那樣從緩衝器 Byte 40~43 取出 NTP 回應的時戳.
後來搜尋網路找到線索, 有人說 NTP 的回應訊息會超過 64 Bytes, 建議將序列埠緩衝器從預設值放大為 128 Bytes. 我依其建議將軟體序列埠從預設之 64 改為 128 後, 將序列埠緩衝器的內容一個個讀入長度為 128 Bytes 的 byte 陣列中以便從中擷取時戳, 序列埠緩衝器 dump 結果如下 :
E3 00 06 EC 00 00 00 00 00 00
00 00 31 4E 31 34 00 00 00 00
00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 0D 0A
CR LF
53 45 4E 44 20 4F 4B 0D 0A 0D
S E N D SP O K CR LF CR
0A 2B 49 50 44 2C 34 38 3A 24
LF + I P D , 4 8 :
02 06 ED 00 00 00 09 00 00 05
E4 75 36 C1 B9 DB 07 26 8D 43
9E EB 5D 00 00 00 00 00 00 00
00 DB 07 29 7B 21 82 5F 16 DB
07 29 7B 21 93 F1 95 0D 0A 4F
CR LF O
4B 0D 0A
K CR LF
從上面 dump 結果可以發現一次 NTP 要求會讓軟體序列埠緩衝器先後塞進 123 bytes 資料 (包含送收), 其中前 48 個 bytes 是我們傳送給 NTP 伺服器的要求封包 (紅色部分), 隨後是一個跳行 (CRLF) 與 ESP8266 回應的 "SEND OK" 與兩個跳行. 然後 ESP8266 回應 "+IPD,48:", 表示傳送 48 個 bytes 資料, 接下來的 48 個 bytes 便是 NTP 伺服器傳回來的訊息了, 收完後 ESP8266 回應一個跳行與 "OK" 最後再一個跳行, 所以總共是 48+21+48+6=123 bytes.
而要從 NTP 回應的 48 bytes 訊息裡擷取時戳必須了解 NTP 協定格式, 請參考 RFC 958 文件 :
# Network Time Protocol (NTP)
這份文件最後面的 "Appendix B. NTP Data Format" 描述了 NTP 協定的結構, 原來 NTP 回應的 48 個 bytes 裡面, 前 16 bytes 包括閏秒 (LI), 預估誤差, 預估漂移率, 以及參考時鐘 (名稱或網址)等; 而後面 32 個 bytes 則是四組時戳, 每一組各 8 個 bytes, 我將其重繪如下 :
每一組時戳代表從 1900 年 1 月 1 日以來的秒數, 其前四個 bytes 為整數部分, 後四個 bytes 為小數部分, 通常不要求極高精確度的話, 只要擷取整數部分就可以了. 第一組是參考時戳 (reference timestamp), 這是由伺服器參考時鐘所建立的時戳. 第二組時戳為源頭時戳 (Originate timestamp), 此為客戶端主機所設定, 用來標示請求訊息發出時的客戶端當地時間. 第三組為接收時戳 (receive timestamp), 由 NTP 伺服器建立, 標示 NTP 伺服器收到請求時的當地時間. 第四組為傳送時戳 (transmit timestamp), 這是 NTP 伺服器送出回應訊息的時間. 從上面 dump 的資料來看, 第三組與第四組通常都是一樣的, 表示 NTP 伺服器一收到請求便立即送出回應, 所以擷取這兩組任何一組都可以. 另外, 因為我們發出 Request 時並未在索引 24~32 設定源頭時戳, 其值仍是預設的 8 bytes 的 00, 所以 NTP 回應訊息裡也是 8 bytes 的 00.
了解 NTP 的時戳結構後, 就能明白為何在 "Arduino Cookbook 錦囊妙計" 這本書的 15.14 節中要從陣列索引 40~43 擷取時戳了, 因為若從回應訊息開頭的 + IPD 起算的話, 接收時戳的位置就剛好是索引 40~43. 在實驗 (二) 裡我完全 dump 軟體序列埠的 123 Bytes 到陣列, 接收時戳的位置就變成在索引 101~104 這四個 bytes.
用 dump 全部 NTP 回應資料來從索引 101~104 取得時戳的方式雖然會耗掉 6.25% (128/2048) 的記憶體, 但是不必用到序列埠函式如 find() 尋找標記, 只要在迴圈中用 available() 偵測是否有回應, 直接 dump 序列埠收到的 NTP 回應並存入陣列中即可.
充分了解序列埠緩衝器內容以及 NTP 回應訊息的結構後, 我發現其實只要用 find() 函數找到回應標記 "+IPD,48:" 後再開始將緩衝器讀取到 byte 陣列中, 這樣就不必用到 128 Bytes 那麼大的陣列來儲存軟體序列埠收到的 NTP 回應訊息, 只要 48 bytes 就剛剛好. 我依此想法將實驗 (二) 的程式修改如下 :
#include <SoftwareSerial.h>
#define DEBUG true
SoftwareSerial esp8266(7,8); //(RX,TX)
const String ssid="H30-L02-webbot";
const String pwd="1234567890";
void setup() {
Serial.begin(9600);
esp8266.begin(9600);
sendData(F("AT+RST\r\n"),2000,DEBUG); // reset ESP8266
while (!connectWifi(ssid, pwd)) {
Serial.println(F("Connecting WiFi ... failed"));
delay(2000);
}
sendData(F("AT+GMR\r\n"),1000,DEBUG);
delay(3000); //wait for wifi connection to get local ip
sendData(F("AT+CIFSR\r\n"),1000,DEBUG); //get ip address
}
void loop() {
Serial.println(F("Sending request to NTP server ..."));
Serial.println(getTaiwanTime(getUnixTime()));
delay(5000);
}
unsigned long getUnixTime() {
//NTP Server candidates : 91.226.136.136 82.209.243.241 192.5.41.40
sendData(F("AT+CIPSTART=\"UDP\",\"91.226.136.136\",123\r\n"),5000,DEBUG);
byte packetBuffer[48]; //packet buffer : send/recv data to/from NTP
memset(packetBuffer,0,48); //clear packet buffer
//configure packet buffer for requesting NTP server
packetBuffer[0]=0xE3; //LI, Version, Mode
packetBuffer[1]=0x00; //Stratum, or type of clock
packetBuffer[2]=0x06; //Polling Interval
packetBuffer[3]=0xEC; //Peer Clock Precision
packetBuffer[12]=0x31; //reference ID (4 bytes)
packetBuffer[13]=0x4E;
packetBuffer[14]=0x31;
packetBuffer[15]=0x34;
//send 48-bytes request to NTP Server
sendData(F("AT+CIPSEND=48\r\n"),1000,DEBUG); //send data length
for (byte i=0; i < 48; i++) { //sending byte by byte
esp8266.write(packetBuffer[i]);
delay(1); //wait for writing
}
//processing NTP response
memset(packetBuffer,0,48); //clear buffer to store NTP response
Serial.println();
if (esp8266.available() > 0) { //if receive NTP response
if (esp8266.find("+IPD,48:")) { //search for IPD marker
Serial.println(F("'+IPD,48:' found, NTP server answered :"));
Serial.println();
for (byte i=0; i<48; i++) { //read following 48 bytes from serial buffer
byte ch=esp8266.read(); //read one byte each time
packetBuffer[i]=ch; //store byte packet buffer
//show receving bytes in hex
if (ch < 0x10) {Serial.print(F("0"));} //prefix with "0" if byte value 0~9
Serial.print(ch, HEX);
Serial.print(F(" ")); //space between each byte
if ((((i+1) % 10) == 0)) {Serial.println();} //newline if exceeds 10 bytes
delay(1); //wait for next incoming byte
if ((i < 48) && (esp8266.available() == 0)) { //wait if receiving lags
//time packets not complete but no response : wait 1.5 seconds
byte wcount=0; //waiting counter
while (esp8266.available() == 0) { //loop until timeout (1.5 seconds)
Serial.print(F("!")); //show ! means waiting for response packet
delay(100);
wcount += 1; //increment waiting counter
if (wcount >= 15) {break;} //waiting timeout : quit loop
} //end of while
} //end of if
} //end of for
} //end of if
} //end of if
sendData(F("AT+CIPCLOSE\r\n"),1000,DEBUG); //close session
Serial.println();
Serial.println();
//Show time stamp (locates at index 32~35 of the response packets)
Serial.print(F("NTP time stamp packets (byte 32~35)="));
Serial.print(packetBuffer[32],HEX);
Serial.print(F(" "));
Serial.print(packetBuffer[33],HEX);
Serial.print(F(" "));
Serial.print(packetBuffer[34],HEX);
Serial.print(F(" "));
Serial.print(packetBuffer[35],HEX);
Serial.println();
//combine 4 bytes time packets into words
unsigned long highWord=word(packetBuffer[32],packetBuffer[33]);
unsigned long lowWord=word(packetBuffer[34],packetBuffer[35]);
//shift high word 16 bits left, OR with low word to form a double word
//the result is a NTP time stamp (seconds since Jan 1 1900):
unsigned long secsSince1900=highWord << 16 | lowWord;
Serial.print(F("NTP time stamp (seconds since 1900-01-01)="));
Serial.println(secsSince1900);
//convert NTP time stamp to Unix time stamp :
//Unix time starts on Jan 1 1970=2208988800 seconds since 1900-01-01
//subtract seventy years to get Unix time stamp
unsigned long unix_time=secsSince1900 - 2208988800UL;
Serial.print(F("Unix time stamp (seconds since 1970-01-01)="));
Serial.println(unix_time); //print Unix time
return unix_time; //return seconds since 1970-01-01
}
String getTaiwanTime(unsigned long epoch) {
//Convert Unix time to Taiwan Standard Time hour:minute:second
String tst=F("");
byte hour=(epoch % 86400L) / 3600 + 8; //hour (86400 secs per day)
if (hour > 24) {hour -= 24;}
if (hour < 10) {tst += F("0");} //prefix with "0" if single digit
tst.concat(hour);
tst.concat(F(":"));
byte min=(epoch % 3600) / 60; //minute (3600 secs per minute)
if (min < 10) {tst.concat(F("0"));} //prefix with "0" if single digit
tst.concat(min);
tst.concat(F(":"));
byte sec=epoch % 60; //second
if (sec < 10) {tst.concat(F("0"));} //prefix with "0" if single digit
tst.concat(sec);
return tst;
}
boolean connectWifi(String ssid, String pwd) {
String res=sendData("AT+CWJAP=\"" + ssid + F("\",\"") + pwd + F("\"\r\n"),8000,DEBUG);
res.replace("\r\n",""); //remove all line terminator
if (res.indexOf("OK") != -1) {return true;}
else {return false;}
}
String sendData(String command, const int timeout, boolean debug) {
String response=F("");
esp8266.print(command); // send the read character to the esp8266
long int time=millis();
while ((time+timeout) > millis()) {
while(esp8266.available()) {
// The esp has data so display its output to the serial window
char c=esp8266.read(); // read the next character.
response += c;
}
}
if (debug) {Serial.print(response);}
return response;
}
這裡我將取 Unix 時戳以及製作台灣時間字串的功能拆分為 getUnixTime() 與 getTaiwanTime() 這兩個函數, 前者會從 NTP 取得的 4 個 bytes 時戳加以運算, 計算出自 Unix 誕生 (1970-01-01 00:00:00) 後至今的秒數傳回 (GMT 時間); 而 getTaiwanTime() 則將 GMT 的 Unix 時間加上 8 小時後得到台灣時區時間, 以 "HH:MM:SS" 格式傳回.
另外為了節省動態記憶體空間, 利用 F() 函數將所有字串常數放到程式記憶體中, 加上只需宣告 48 bytes 的區域變數, 使得編譯後的全域變數耗用比僅僅 18% :
草稿碼使用了 9,952 bytes (32%) 的程式存儲空間。最大值為 30,720 bytes。
全域變數使用了 387 bytes (18%) 的動態記憶體,剩餘 1,661 bytes 供局部變數。最大值為 2,048 bytes 。
當應用程式功能較複雜時, 減省記憶體耗用對於 Arduino 這種小腦袋是非常重要的.
上傳執行後序列埠監控視窗擷取訊息如下 :
AT+RST
OK
bB�鑭b禔S��"愃L�侒��餾�
[System Ready, Vendor:www.ai-thinker.com]
AT+CWJAP="EDIMAX-tony","1234567890"
OK
AT+GMR
0018000902
OK
AT+CIFSR
192.168.43.151
OK
Sending request to NTP server ...
AT+CIPSTART="UDP","91.226.136.136",123
OK
AT+CIPSEND=48
>
'+IPD,48:' found, NTP server answered :
24 02 06 ED 00 00 00 27 00 00
06 5C 6B DC 0B 87 DB 4E 6E 13
96 18 19 11 00 00 00 00 00 00
00 00 DB 4E 71 BE DA 79 44 08
DB 4E 71 BE DA 81 BB 7E
NTP time stamp packets (byte 32~35)=DB 4E 71 BE
NTP time stamp (seconds since 1900-01-01)=3679351230
Unix time stamp (seconds since 1970-01-01)=1470362430
OK
AT+CIPCLOSE
OK
Unlink
10:00:30
經過上面實驗與閱讀 NTP 協定文件, 這才真正搞懂 NTP 的原貌, 之前雖然實驗成功, 但都只知其然不知其所以然. 所以寫程式是一回事, 搞懂協定的原理也非常重要. NTP 屬於第四層應用層協定, 需要程式員自己解讀協定內容, 它透過 ESP8266 提供底層的 UDP/IP 協定來尋徑, 幫封包找到目的地. UDP 屬於第三層傳輸層, 這部分已經由 ESP8266 的 AT 指令包辦了.
最後還有一點值得一提, 那就是之前將軟體序列埠緩衝區從預設 64 bytes 放大到 128 bytes 是不需要的, 我將緩衝區改回 64 bytes 後仍能正常執行上面的程式, 以前我對序列埠緩衝區的理解似乎不正確, 緩衝區滿了當然無法再塞進去, 但我們的程式會不斷讀取緩衝區, 那速度很快, 而且每讀完一個 byte, 該位置就會被清空, 根本不用怕 9600 bps 通訊速率的緩衝區會被塞爆.
參考 :最後還有一點值得一提, 那就是之前將軟體序列埠緩衝區從預設 64 bytes 放大到 128 bytes 是不需要的, 我將緩衝區改回 64 bytes 後仍能正常執行上面的程式, 以前我對序列埠緩衝區的理解似乎不正確, 緩衝區滿了當然無法再塞進去, 但我們的程式會不斷讀取緩衝區, 那速度很快, 而且每讀完一個 byte, 該位置就會被清空, 根本不用怕 9600 bps 通訊速率的緩衝區會被塞爆.
# 到底是 GMT+8 還是 UTC+8 ?
# https://www.arduino.cc/en/Reference/Serial
20190530 補充 :
有熱心網友指出上面代碼與協定分析 (1985 年版) 不一致 (參考下方留言), 要另找時間研究研究. 2000 年的第四版 NTP 參考 :
# https://tools.ietf.org/html/rfc5905
7 則留言 :
你太厲害了,竟然這麼省。
從節省記憶體的觀點來看,似乎連48個BYTE都不用,只要讀到36個BYTE就可以離開了。
在你這裡獲得很多有用資訊,謝謝你了。
感謝您留言, 大家多交流, 功力必大增.
从这里学习到了很多。谢谢!
我用了您发表的两个程序,但应从从服务器返回的包含时间数据的bytes全都为0,程序计算后时间永远为14:28:16,想请教一下是哪里出了问题?
這可能是服務器無回應之故, 換別的 ip 看看
你的代碼是用2010年的NTPv4協議,但是你的分析卻用1985年的NTP協議。
NTPv4協議在這裏
https://tools.ietf.org/html/rfc5905
感謝您! 我再找時間更新.
張貼留言