본문 바로가기
Hardware/MCU(Arduino,ESP8266)

ESP8266, NTP로 인터넷 시간 동기화 (feat. UDP, NTP 패킷 구조)

by lovey25 2020. 10. 20.
반응형

NTP를 이용해서 현재시간을 동기화하는 예제입니다. 시간에 따라 동작 조건을 다르게 적용하고 싶은 프로젝트가 있을 때 현재시간 확인이 필요한데요. 그럴 때 사용할 수 있는 여러 가지 방법 중 하나입니다.

예제 코드는 이전 포스팅에서 소개한 적 있었던 "Beginners guide of esp8266"에서 가져왔고 가이드북에서 설명이 생략되어 있는 부분에 약간의 살을 더해서 NTP를 처음 접했던 제가 이해한 내용을 정리하였습니다. 

시작하기 전에 먼저 알아두면 도움이 될 내용

코드에 상세하게 주석을 달아두어서 바로 코드를 보면서 이해해도 되겠지만 몇 가지 알아두면 이해하는데 도움이 될 내용부터 먼저 건들어 보겠습니다.

UTC(협정 세계시)

UTC는 쉽게 말하면 우리가 사용하는 시간의 표준으로 국제 기준입니다. 1일은 24시간, 1시간은 60분으로 하는 우리가 알고 있는 시간의 표준입니다. 대신 1분은 59~61초로 가변적인 윤초를 적용하는데 이렇게 깊이 있는 내용은 여기서 필요 없으니, UTC의 기준은 어디인지만 알고 넘어가겠습니다. UTC는 전 세계에서 공통으로 사용한다고 했는데, 나라마다 시차가 존재합니다. 그래서 UTC에서 9시라고 하면 어느 나라가 기준일까요? 바로 그리니치 표준시와 동일하게 영국 런던입니다. 우리나라(서울)는 런던과 9시간의 시차가 있습니다. 그래서 UTC를 알고 있다면 9시간을 더해서 우리나라의 시간을 구할 수 있습니다.

UNIX time

UNIX time은 컴퓨터에서 시간을 표현하는 방식중 하나입니다. 32비트로 숫자를 사용하며 기준시인 1로부터 얼마나 시간이 경과했는지를 초로 표시합니다. 기준 시는 1970년 1월 1일 0시입니다. 참고로 32비트 표현의 한계 때문에 2038년까지만 사용 가능합니다.

UDP(User Datagram Protocol)

컴퓨터 간 통신을 위한 약속의 한 종류입니다. UDP 외에 TCP라는 용어를 많이 들어봤는데 둘 다 통신 약속이고 차이점은 TCP는 보낸 정보가 잘 도착했는지 확인을 하는 메커니즘이 적용되고 UDP는 받든말든 그냥 보내버리는 매커니즘이 적용됩니다. 바로 다음으로 얘기할 "NTP time"이 UDP를 사용합니다.

NTP(Network Time Protocol) time

NTP는 컴퓨터 간의 시간을 동기화 하기 위한 프로토콜입니다. 컴퓨터는 자체 클럭을 이용해서 동작 후 경과된 시간을 시간을 계산할 수 있습니다. 한마디로 시계가 내장되어 있다는 거죠 이 시간을 시스템 시간이라고 하는데 시스템 시간을 실제 시간으로 맞추어 주려면 기준이 필요한데 정확한 시간을 알고 있는 어떤 서버와 시간 정보를 주고받으면서 시간을 동기화시키는데 이때 주고받는 통신 프로토콜이 바로 NTP입니다. NTP에 대한 좀 더 상세한 내용은 뒤에서 계속 다루겠습니다.

이제 기본 가락을 익혔으니 바로 코드를 살펴보겠습니다. ^^

더보기
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>

const char *ssid = "[SSID]";
const char *password = "[PASSWORD]";

WiFiUDP UDP;
const char *NTPServerName = "kr.pool.ntp.org";
const int NTP_PACKET_SIZE = 48;  // 패킷 메시지의 첫 48 bytes가 timestamp
byte NTPBuffer[NTP_PACKET_SIZE]; // 주고받는 패킷 메시지를 저장할 버퍼

ESP8266WebServer server(80); // server on port 80
IPAddress timeServerIP;

unsigned long intervalNTP = 3600000; // NTP 시간을 반복 요청할 간격 (1시간)
unsigned long prevNTP = 0;
unsigned long lastNTPResponse = millis();
unsigned long prevActualTime = 0;
uint32_t timeUNIX = 0; // Unixtime 저장 변수

void setup()
{
    Serial.begin(115200);

    setupCONNECTION(); // 사용자 함수: 함수정의 참조
    setupUDP();        // 사용자 함수: 함수정의 참조

    if (!WiFi.hostByName(NTPServerName, timeServerIP))
    {                                                    // NTP 서버의 IP확인
        Serial.println("DNS lookup failed. Rebooting."); // NTP 서버를 못찾은 경우 다시 시도
        Serial.flush();
        ESP.reset();
    }
    Serial.print("Time server IP:\t"); // NTP 접속정보 표시
    Serial.println(timeServerIP);
    Serial.println("\r\nSending NTP request ...");
    sendNTPpacket(timeServerIP); // NTP 시간 요청
}

void loop()
{
    unsigned long currentMillis = millis();
    if (currentMillis - prevNTP > intervalNTP)
    { // intervalNTP 간격으로
        prevNTP = currentMillis;
        Serial.println("\r\nSending NTP request ...");
        sendNTPpacket(timeServerIP); // NTP 시간 요청
    }
    uint32_t time = getTime(); // NTP서버가 회신한 시간이 있으면 시간값 가져오기 (UNIXtime)
    if (time)
    {                    // NTP의 응답이 없을경우 time은 "0"
        timeUNIX = time; // 응답이 있으면 반환된 시간을 전역변수에 저장
        Serial.print("NTP response:\t");
        Serial.println(timeUNIX);        // UNIXtime값 시리얼 출력
        lastNTPResponse = currentMillis; //  NTP 시간 동기화를 시도한 시점의 ESP 내부 시간 기록 (현재시간 계산용)
    }
    else if ((currentMillis - lastNTPResponse) > 36000000)
    {
        Serial.println("마지막 NTP 동기 후 10시간 경과하여 리셋합니다.");
        Serial.flush();
        ESP.reset();
    }
    uint32_t actualTime = timeUNIX + 9 * 3600 + (currentMillis - lastNTPResponse) / 1000; // NTP 응답을 기반으로 현재시간 계산 (한국은 UTC +9)
    if (actualTime != prevActualTime && timeUNIX != 0)
    { // 현재시간이 변했다면 (1초 경과)
        prevActualTime = actualTime;
        Serial.printf("\rUTC +9hour:\t%d:%d:%d ", getHours(actualTime), // HH:MM:SS 형식으로 시간 표시
                      getMinutes(actualTime),
                      getSeconds(actualTime));
    }
}

//*********************************
//***** 여기서부터 사용자 함수******
//*********************************
void setupCONNECTION()
{
    WiFi.begin(ssid, password); // WIFI네트워크에 접속 시작!
    Serial.println("\n");
    while (WiFi.status() != WL_CONNECTED)
    {
        delay(500);
        Serial.print(".");
    }
    Serial.println('\n');
    Serial.println("Connected!");
    Serial.print("IP address:\t");
    Serial.println(WiFi.localIP()); // 할당받은 IP주소 표시
}

void setupUDP()
{
    Serial.println("Starting UDP");
    UDP.begin(123); // UDP 메시지 모니터링 시작 (포트번호:123)
    Serial.print("Local port:\t");
    Serial.println(UDP.localPort());
    Serial.println();
}

uint32_t getTime()
{
    if (UDP.parsePacket() == 0)
    {             // NTP의 응답이 없으면
        return 0; // 0을 반환
    }
    UDP.read(NTPBuffer, NTP_PACKET_SIZE); // 패킷 메시지 읽어오기
    // Combine the 4 timestamp bytes into one 32-bit number
    uint32_t NTPTime = (NTPBuffer[40] << 24) | (NTPBuffer[41] << 16) | (NTPBuffer[42] << 8) | NTPBuffer[43];
    //  @ NTP time -> UNIX timestamp로 변환
    /// - Unix time starts on Jan 1 1970.
    /// - NTP time starts on Jan 1 1990.
    const uint32_t seventyYears = 2208988800UL; //2208988800 sec = 70 years
    uint32_t UNIXTime = NTPTime - seventyYears; // (변환계산)
    return UNIXTime;                            // UNIX time값 반환
}

void sendNTPpacket(IPAddress &address)
{
    memset(NTPBuffer, 0, NTP_PACKET_SIZE); // 버퍼 리셋
    NTPBuffer[0] = 0b11100011;             // NTP패킷 초기화 (LI=11, Version=100, Mode=011)
    UDP.beginPacket(address, 123);         // 123번 포트로 패킷 전송 시작
    UDP.write(NTPBuffer, NTP_PACKET_SIZE); // NTP메시지를 패킷에 포함
    UDP.endPacket();                       // 전송 완료
}
inline int getSeconds(uint32_t UNIXTime)
{
    return UNIXTime % 60; // 초
}
inline int getMinutes(uint32_t UNIXTime)
{
    return UNIXTime / 60 % 60; // 분
}
inline int getHours(uint32_t UNIXTime)
{
    return UNIXTime / 3600 % 24; // 시
}

코드의 흐름은 1시간 간격으로 NTP 서버에 기준시간을 요청하고 정상적으로 기준시가 도착하면 1초마다 기준시에서 경과한 시간을 더해서 현재시간을 시리얼 모니터로 표시해주도록 되어있습니다.

주석을 열심히 달았기 때문에 설명이 필요한 부분만 집고 넘어가겠습니다.

코드의 시작에 헤더 파일 2개가 포함되어 있습니다. 하나는 ESP8266 칩셋 사용에 필요한 기본 파일이고 나머지 하나는 UDP를 사용하기 위한 헤더 파일입니다. NTP 시간이 UDP로 동기화되기 때문에 추가되었습니다.

WiFiUDP UDP;
const char* NTPServerName = "kr.pool.ntp.org";
const int NTP_PACKET_SIZE = 48;     // 패킷 메시지의 첫 48 bytes가 timestamp
byte NTPBuffer[NTP_PACKET_SIZE];    // 주고받는 패킷 데이타를 저장할 버퍼

7행에서 UDP 객체를 생성하고 8행에서는 NTP 시간을 요청할 서버의 주소를 지정했습니다. "kr.pool.ntp.org"는 동적으로 요청시점에 가장 빠른 서버를 찾아서 연결시켜주는 주소라고 하네요.

9, 10행은 NTP 서버와 주고받는 패킷의 데이터를 저장할 변수입니다. 패킷 크기는 48바이트로 지정했는데 패킷에 대해서는 뒤에서 좀 더 자세히 다룰 예정이니 크기가 왜 48이어야 하는지 하는 의문은 잠시만 넣어두세요.

43행의 sendNTPpacket() 함수에서 서버에 시간을 요청하는 패킷을 발생시키고 45행의 getTime()에서 회신받은 패킷에서 시간 값을 추출합니다. 둘 다 사용자 함수로 정의된 내용을 살펴보겠습니다.

void sendNTPpacket(IPAddress& address) {
  memset(NTPBuffer, 0, NTP_PACKET_SIZE); // 버퍼 리셋
  NTPBuffer[0] = 0b11100011; // NTP패킷 초기화 (LI=11, Version=100, Mode=011)
  UDP.beginPacket(address, 123);          // 123번 포트로 패킷 전송 시작
  UDP.write(NTPBuffer, NTP_PACKET_SIZE);  // NTP메시지를 패킷에 포함
  UDP.endPacket();                        // 전송 완료
}

sendNTPPacket() 함수는 NTP 서버의 IP주소를 매개변수로 받습니다. 그리고 NTP 패킷의 데이터가 저장될 변수인 "NTPBuffer"의 모든 비트를 0으로 초기화합니다. 그리고 NTPBuffer의 시작을 "0b11100011"로 만들어 주었는데 여기서 NTP 패킷의 구조를 알 필요가 있습니다.

NTP패킷의 구조

서버에 NTP 페킷을 요청하면 서버는 다음과 같은 구조의 패킷을 회신해줍니다. 패킷 머리에는 누가 보낸 건지 누가 받을 건지에 대한 정보부터 IP, UDP 등 네트워크에서 패킷이 미아가 되지 않고 돌아다니는데 필요한 각종 꼬리표들이 달려있고 그 뒤에 아래 그림에서 "UDP DATA"라고 하는 부분에 우리가 필요한 NTP 서버가 보낸 메시지가 들어있습니다. 그리고 UDP DATA를 더 자세히 살펴보면 48바이트 기본 데이터와 추가적인 옵션 데이터로 이루어져 있습니다. 바로 여기서 NTP 패킷의 메시지 크기로 48을 지정한 이유를 알 수 있습니다.

NTP 패킷 구조 (발췌: https://labs.apnic.net/?p=462)

NTP 패킷을 구성하는 각 필드의 의미를 알아보면서 구조를 좀 더 깊이 알아보겠습니다.

LI(Leap indicator): 윤초의 여부를 표시하는 부분입니다. 00: 윤초 없음, 01: 61초, 10: 59초, 11: 동기화되지 않음 4가지 상태를 나타냅니다.
VN(Version Number): NTP 버전을 나타냅니다. 현재 버전 4를 사용하고 있다고 합니다.
Mode: NTP 패킷의 모드로 다음 8가지 상태를 표현합니다. 000: Reserved, 001: Symmetric active, 010: Symmetric passive, 011: Client, 100: Server, 101: Broadcast, 110: NTP control message, 111: Reserved for private use
Stratum: NTP 서버의 계층을 표시합니다. 
Poll: 폴링 간격
Precision: 시계의 정확도
Root Delay: 서버에서부터 최상위 계층의 기준 서버까지 왕복하는데 걸리는 시간
Root Dispersion: 시계의 공차에 의한 최대 오차
Reference Identifier: 최상/차상위 계층 서버의 고유번호
Reference Timestamp: 시스템 시계가 마지막으로 동기화된 시간
Originate Timestamp: 클라이언트가 NTP 시간을 요청한 시간
Receive Timestamp: 클라이언트의 요청을 서버가 받은 시간
Transmit Timestamp: 서버가 NTP 시간을 회신한 시간

앞에서 NTPBuffer 변수의 첫 번째 바이트를 "11100011"로 시작하도록 했었는데요. 위 그림에 나와있는 NTP 패킷의 구조에 대입시켜보면 "11: 동기화되지 않은 상태이며, 100: NTP버전은 4인, 011: 클라이언트에서 보내는 패킷입니다"가 됩니다. NTP 시간을 요청하는 데는 여기까지의 헤더만 있으면 서버가 응답을 하는 것 같습니다.

이렇게 만들어진 패킷을 NTP 서버의 123번 포트로 전송을 하면 즉시 동일한 포맷으로 회신이 오게 되는데요. 앞서 살펴봤듯 돌아온 패킷에서 우리가 필요한 정보는 바로 가장 최근의 시간인 "Transmit Timestamp"입니다.

아래는 회신받은 NTP 패킷에서 시간 정보를 가져오는 함수입니다.

uint32_t getTime() {
  if (UDP.parsePacket() == 0) {         // NTP의 응답이 없으면
    return 0;                           // 0을 반환
  }
  UDP.read(NTPBuffer, NTP_PACKET_SIZE); // 패킷 메시지 읽어오기
  // Combine the 4 timestamp bytes into one 32-bit number
  uint32_t NTPTime = (NTPBuffer[40] << 24) | (NTPBuffer[41] << 16) | (NTPBuffer[42] << 8) | NTPBuffer[43];
  //  @ NTP time -> UNIX timestamp로 변환
  /// - Unix time starts on Jan 1 1970.
  /// - NTP time starts on Jan 1 1990.
  const uint32_t seventyYears = 2208988800UL; //2208988800 sec = 70 years
  uint32_t UNIXTime = NTPTime - seventyYears; // (변환계산)
  return UNIXTime;                            // UNIX time값 반환
}

getTime() 함수가 호출되면 UDP 패킷을 뒤져서 들어온 패킷이 있는지 없는지를 먼저 확인합니다. 만약 패킷을 받았으면 UDP 패킷에서 NTP DATA에 해당하는 영역의 정보를 읽어서 NTPBuffer 변수에 저장합니다. 

NTPBuffer변수는 위 그림과 같이 NTP 메시지 48바이트가 담겨있습니다. 이 중에서 우리가 관심 있는 Trasmit Timestamp를 읽어오면 되는데 40~47번째 바이트인 8바이트 구간입니다. 그리고 각 Timestamp는 정수 4바이트와 소수점 4바이트로 이루어져 있어서 실제로 우리가 필요한 부분은 40~43번째 바이트 구간이란 것을 알 수 있습니다.

Trasmit Timestamp 구조

이제 이 구간을  96행에서 쉬프트 연산을 통해서 32비트정수 값으로 변환하였습니다.

그리고 정수값으로 변환된 NTP time에서 70년에 해당하는 2208988800초를 빼서 UNIX time값으로 반환합니다. 왜냐하면 UNIX time과 NTP time은 기준점이 1970년과 1900년으로 70년의 차이가 나니까요.

마지막으로 57행에서 국제 표준시간과 동기화 한 UNIX time을 기준으로 동기화한 시점으로부터 경과한 초수를 합산해서 현재 시간을 계산합니다. 이때 우리나라의 시차 +9시간 보정도 같이해서 실제 시간을 구합니다.   

결과

간단하면서도 복잡한 과정을 거쳐서 정확한 시간을 알아내는 방법을 알아봤는데요. 문제없이 동작을 했다면 다음과 같은 결과를 시리얼 모니터로 확인하실 수 있습니다.

 

끝!

반응형

댓글