2016年8月5日 星期五

★ 再探 NTP 協定

最近有網友詢問 NTP 伺服器問題, 所以我又重新審視了前陣子剛搞定的幾個跟 NTP 有關的實驗, 最近的突破點是下面這篇 6/12 的實驗紀錄 :

# 以 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 通訊速率的緩衝區會被塞爆.

參考 :

到底是 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就可以離開了。
在你這裡獲得很多有用資訊,謝謝你了。

小狐狸事務所 提到...

感謝您留言, 大家多交流, 功力必大增.

匿名 提到...

从这里学习到了很多。谢谢!

德国骨科302床 提到...

我用了您发表的两个程序,但应从从服务器返回的包含时间数据的bytes全都为0,程序计算后时间永远为14:28:16,想请教一下是哪里出了问题?

小狐狸事務所 提到...

這可能是服務器無回應之故, 換別的 ip 看看

匿名 提到...

你的代碼是用2010年的NTPv4協議,但是你的分析卻用1985年的NTP協議。

NTPv4協議在這裏
https://tools.ietf.org/html/rfc5905

小狐狸事務所 提到...

感謝您! 我再找時間更新.