Kampis Elektroecke

NTP-Client

Das Network Time Protocol (NTP) wird genutzt um Uhrzeiten in verteilten Systemen zu synchronisieren und synchron zu halten. In diesem Artikel möchte ich zeigen, wie ein einfacher NTP-Client (Version 3) auf einem Particle Argon realisiert werden kann, um so eine sehr genaue Systemzeit bereitzustellen, die dann z. B. für Datenlogger verwendet werden kann.

Für NTP wird ein hierarchisches Netzwerk aus Zeitgebern verwendet, wobei jede einzelne Ebene Stratum genannt wird.

Ebene Zeitgeber
Stratum 0 z. B. Atomuhren oder GPS
Stratum 1 Computersysteme, die durch Stratum 0 auf ein paar Mikrosekunden genau synchronisiert wurden.
Stratum 2 Computersysteme, die durch Stratum 1 synchronisiert wurden.
Stratum 3 Computersysteme, die durch Stratum 2 synchronisiert wurden.

Wichtig:

NTP synchronisiert das System gegen eine externe Zeitquelle (z. B. GPS). Es findet kein Abgleich gegen andere Systeme statt, sprich es wird nicht ermittelt “um wie viel” System A ungenauer ist als System B.

Zudem beherrscht das Protokoll keine Mechanismen um Konflikte bzgl. verschiedenen “richtigen” Zeiten zu lösen.


NTP unterstützt verschiedene Modi:

Modus Beschreibung
Symmetric Active Mode
(0x01)
Der Host gibt seine Zeitinformationen weiter und möchte zudem Zeitinformationen anderer Server empfangen.
Symmetric Passive Mode
(0x02)
Der Host gibt seine Zeitinformationen ausschließlich weiter.
Client Mode
(0x03)
Der Host fragt einen definierten Server nach der Zeitinformation. Diese Anfrage erfolgt periodisch.
Server Mode
(0x04)
Der Host bedient die Anfrage von Clients.
Broadcast Mode
(0x05)
Ein Server schickt regelmäßig Pakete mit Zeitinformationen in das Netzwerk.

Daten von einem NTP-Server werden unverschlüsselt übertragen und als 64-bit Sekundenzähler (32-bit Ganzzahl) dargestellt. Somit kann NTP einen Zeitraum von bis zu 136 Jahren abdecken. Die Zeitrechnung beginnt bei NTP am 01.01.1900 und eventuelle Überläufe im Sekundenzähler müssen vom Client abgefangen werden, da der Client aus den empfangenen Sekunden das korrekte Datum errechnen muss. Das NTP beinhaltet keine Informationen über Tage, Jahre oder Jahrhunderten.

Eine minimale NTP-Nachricht besteht aus 48 Byte und ist in die folgenden Felder aufgeteilt:

Bitfeld Offset Beschreibung
LV 0 – Byte 0 Leap Indicator.

0 Keine Warnung
1 Letzte Minute enthält 61 Sekunden
2 Letzte Minute enthält 59 Sekunden
3 Uhren laufen nicht synchron.
VN 2 – Byte 0 Version des verwendeten NTP.
Mode 5 – Byte 0 Verwendeter NTP-Modus.
Stratum 8 – Byte 1 Stratum Level der lokalen Uhr.

0 Unspezifiziert
1 Primäre Referenz (z. B. Atomuhr)
2 – 255 Sekundäre Referenz (via NTP)
Poll 16 – Byte 2 Maximale Zeit zwischen zwei Nachrichten in Sekunden.
Signed 8-bit, Potenz von 2
Precision 24 – Byte 3 Genauigkeit der lokalen Uhr.
Signed 8-bit, Potenz von 2
Root Delay 32 – Byte 4 Maximale Laufzeitverzögerung gegenüber der Referenz in Sekunden.
Signed 32-bit, Fixed-Point
Root Dispersion 64 – Byte 8 Maximaler Fehler gegenüber der Referenz in Sekunden.
Signed 32-bit, Fixed-Point
Reference Clock Identifier 96 – Byte 12 Dient zur Identifizierung der Referenzuhr. Im Falle von Stratum 0 oder 1 handelt es sich um einen vier Byte langen, null-terminierten ASCII-String.
Reference Timestamp 128 – Byte 16 Lokaler Zeitstempel. Ist vor der ersten Synchronisation 0.
Orginate Timestamp 192 – Byte 24 Wird von NTP-Daemons genutzt um die lokale Zeiten untereinander abzugleichen. Wird auf 0 gesetzt, wenn kein Peer erreicht werden kann.
Receive Timestamp 256 – Byte 32 Von einem Peer empfangener Zeitstempel. Wird auf 0 gesetzt, wenn kein Peer erreicht werden kann.
Transmit Timestamp 320 – Byte 40 Zeitstempel des NTP-Servers beim Absenden der Nachricht.

Der Nachrichtenblock wird über die nachfolgende Struktur in der Software abgebildet.

typedef struct
{
        uint8_t Mode:3;
        uint8_t VN:3;
        uint8_t LI:2;
        uint8_t Strat;
        int8_t Poll;
        int8_t Prec;
        uint32_t RootDelay;
        int32_t RootDispersion;
        int32_t RefIdentifier;
        uint32_t RefTimestamp_S;
        uint32_t RefTimestamp_F;
        uint32_t OriginateTimestamp_S;
        uint32_t OriginateTimestamp_F;
        uint32_t ReceiveTimestamp_S;
        uint32_t ReceiveTimestamp_F;
        uint32_t TransmitTimestamp_S;
        uint32_t TransmitTimestamp_F;
} __attribute__((packed)) NTP_Packet;

Die Initialisierung des NTP-Clients erfolgt über den Konstructor, der Parameter wie die DNS-Adresse und den Port des Servers, sowie eine Updaterate und eine Zeit für den Timeout in Sekunden erwartet:

NTP::NTP(const char* Server)
{
    this->_init(Server, NTP_DEFAULT_PORT, NTP_DEFAULT_TIME, NTP_DEFAULT_TIMEOUT);
}

NTP::NTP(const char* Server, uint16_t Port)
{
    this->_init(Server, Port, NTP_DEFAULT_TIME, NTP_DEFAULT_TIMEOUT);
}

NTP::NTP(const char* Server, uint16_t Port, uint16_t Update)
{
    this->_init(Server, Port, Update, NTP_DEFAULT_TIMEOUT);
}

NTP::NTP(const char* Server, uint16_t Port, uint16_t Update, uint16_t Timeout)
{
    this->_init(Server, Port, Update, Timeout);
}

NTP::~NTP(void)
{
}

void NTP::_init(const char* Server, uint16_t Port, uint16_t UpdateTime, uint16_t Timeout)
{
    this->_mServer = Server;
    this->_mPort = Port;
    this->_mUpdateTime = UpdateTime;
    this->_mTimeout = Timeout * 1000;
    this->_mLastUpdate = 0x00;
}

NTP basiert auf UDP und dem entsprechend wird für die Kommunikation mit dem NTP-Server ein UDP-Client benötigt. Bei ethernetfähigen Mikrocontrollermodulen (wie z. B. dem Particle Argon) ist i. d. R. bereits eine entsprechende Bibliothek hinterlegt.

Ein neuer Zeitstempel wird über die Funktion Update abgefragt.

NTP::Error NTP::Update(uint32_t* Seconds, uint32_t* Millis, NTP::Leap* Leap)
{
    if((millis() - this->_mLastUpdate) > (this->_mUpdateTime * 1000))
    {
        return this->ForceUpdate(Seconds, Millis, Leap);
    }

    return WAIT;
}

Die Funktion überprüft ob seit dem letzten Update genug Zeit vergangen ist und fragt dann einen neuen Zeitstempel beim Server an, indem sie die Funktion ForceUpdate aufruft.

NTP::Error NTP::ForceUpdate(uint32_t* Seconds, uint32_t* Millis, NTP::Leap* Leap)
{
    if((Seconds == NULL) || (Millis == NULL) || (Leap == NULL))
    {
        return INVALID_PARAMETER;
    }

    memset(&this->_mPacket, 0x00, sizeof(NTP::NTP_Packet));
    this->_mPacket.VN = NTP_VERSION & 0x07;
    this->_mPacket.Mode = CLIENT;

    this->_mClient.begin(this->_mPort);
    this->_mClient.beginPacket(this->_mServer, this->_mPort);
    this->_mClient.write((const uint8_t*)&this->_mPacket, sizeof(NTP::NTP_Packet));
    this->_mClient.endPacket();
    this->_mSendTime = millis();

    do
    {
        if(millis() > (this->_mSendTime + this->_mTimeout))
        {
            return TIMEOUT;
        }

        delay(10);
    }while(!this->_mClient.parsePacket());

    this->_mClient.read((unsigned char*)&this->_mPacket, sizeof(NTP::NTP_Packet));
    this->_mClient.stop();
    this->_mRecTime = millis();

    for(uint8_t i = 0x01; i < (sizeof(NTP::NTP_Packet) / 0x04); i++)
    {
        *(((uint32_t*)(&this->_mPacket)) + i) = __SWAP32__(*(((uint32_t*)(&this->_mPacket)) + i));
    }

    if(this->_mPacket.Strat == 0x00)
    {
        return TIMEOUT;
    }

    uint32_t ServerPoll = this->_power(2, this->_mPacket.Poll);
    if(this->_mUpdateTime < ServerPoll)
    {
        this->_mUpdateTime = ServerPoll;
    }

    this->_mPacket.TransmitTimestamp_S -= 2208988800UL;

    *Millis = (uint32_t)(((double)this->_mPacket.TransmitTimestamp_F) / 0xFFFFFFFF * 1000);

    *Millis += this->_mRecTime - this->_mSendTime;
    if(*Millis >= 1000)
    {
        *Millis -= 1000;
        this->_mPacket.TransmitTimestamp_S++;
    }

    *Seconds = this->_mPacket.TransmitTimestamp_S;

    this->_mLastUpdate = millis();

    return NO_ERROR;
}

In der Funktion ForceUpdate wird zuerst der Nachrichtenpuffer gelöscht und eine neue Nachricht vorbereitet, indem die verwendete NTP-Version (hier Version 3) und der Betriebsmodus in die Nachricht geschrieben wird. Da ein Zeitstempel vom Server angefragt werden soll, wird hier der Betriebsmodus CLIENT gewählt.

memset(&this->_mPacket, 0x00, sizeof(NTP::NTP_Packet));
this->_mPacket.VN = NTP_VERSION & 0x07;
this->_mPacket.Mode = CLIENT;

Das fertige Paket wird anschließend über den UDP-Client an den Server gesendet und die Sendezeit gespeichert.

this->_mClient.begin(this->_mPort);
this->_mClient.beginPacket(this->_mServer, this->_mPort);
this->_mClient.write((const uint8_t*)&this->_mPacket, sizeof(NTP::NTP_Packet));
this->_mClient.endPacket();
this->_mSendTime = millis();

Nachdem das Paket versendet wurde, wird auf eine Antwort des Servers gewartet und bei Bedarf ein Timeout ausgelöst. Sobald eine Antwort empfangen wurde, werden die empfangenen Datenbytes ausgelesen und in den Nachrichtenpuffer geschrieben. Wenn alle Datenbytes kopiert wurden, wird der Client gestoppt und die Empfangszeit gespeichert.

do
{
    if(millis() > (this->_mSendTime + this->_mTimeout))
    {
        return TIMEOUT;
    }

    delay(10);
}while(!this->_mClient.parsePacket());
this->_mClient.read((unsigned char*)&this->_mPacket, sizeof(NTP::NTP_Packet));
this->_mClient.stop();
this->_mRecTime = millis();

NTP überträgt alle Daten im Big-endian-Format, wohingegen der verwendete Mikrocontroller die Daten im Little-endian-Format speichert. Daher müssen als nächstes die einzelnen Bytes der 32-Bit langen Datenfelder in der empfangenen Nachricht entsprechend gedreht werden:

#define __SWAP32__(x)     ((uint32_t)((((x) & 0xFF000000) >> 0x18) | (((x) & 0x00FF0000) >> 0x08) | (((x) & 0x0000FF00) << 0x08) | (((x) & 0x000000FF) << 0x18)))

for(uint8_t i = 0x01; i < (sizeof(NTP::NTP_Packet) / 0x04); i++)
{
    *(((uint32_t*)(&this->_mPacket)) + i) = __SWAP32__(*(((uint32_t*)(&this->_mPacket)) + i));
}

Hinweis:

Es müssen nur die 32-Bit langen Datenfelder der Nachricht gedreht werden, da sowas leider nicht in einer C-Struktur abgebildet werden kann. Die Drehung der 8-Bit langen Datenfelder ist bereits im Aufbau der Struktur berücksichtigt.


Jetzt kann die Nachricht ausgewertet und verarbeitet werden. Dazu wird als erstes geprüft ob die Nachricht gültig ist, indem das Feld Strat ausgewertet wird. Bei einem Wert von 0x00 ist die Nachricht ungültig.

if(this->_mPacket.Strat == 0x00)
{
    return TRANSMISSION_ERROR;
}

Ist die Nachricht gültig, wird als nächstes ein neues Updateintervall aus dem Feld Poll berechnet, sodass der Server nur mit dem minimal möglichen Polling-Intervall abgefragt werden kann:

uint32_t ServerPoll = this->_power(2, this->_mPacket.Poll);
if(this->_mUpdateTime < ServerPoll)
{
    this->_mUpdateTime = ServerPoll;
}

Abschließend wird die Zeit seit dem 01.01.1970 berechnet, indem die vergangenen Sekunden der Jahre 1900 – 1970 vom empfangenen Zeitstempel abgezogen werden. Zudem wird der empfangene Wert für die Millisekunden umgerechnet und die Laufzeit der Nachricht vom Server zum Client berücksichtigt. Die erhaltenen Werte werden dann gespeichert und die Funktion verlassen.

this->_mPacket.TransmitTimestamp_S -= 2208988800UL;
*Millis = (uint32_t)(((double)this->_mPacket.TransmitTimestamp_F) / 0xFFFFFFFF * 1000);
*Millis += this->_mRecTime - this->_mSendTime;
if(*Millis >= 1000)
{
    *Millis -= 1000;
    this->_mPacket.TransmitTimestamp_S++;
}
*Seconds = this->_mPacket.TransmitTimestamp_S;
this->_mLastUpdate = millis();
return NO_ERROR;

Damit wäre der NTP-Client fertig programmiert und kann eingesetzt werden:

#include "NTP/NTP.h"

NTP Server("pool.ntp.org");

uint32_t Seconds;
uint32_t Millis;
NTP::Leap Leap;

void setup() 
{
    Serial1.begin(9600);
}

void loop()
{
    NTP::Error Error = Server.Update(&Seconds, &Millis, &Leap);
    if(Error == NTP::NO_ERROR)
    {
        Serial1.printlnf("Time: %u,%u", Seconds, Millis);
        Serial1.printlnf("Leap: %u", Leap);
    }
    else if(Error == NTP::WAIT)
    {
        Serial1.println("Wait for update...");
    }
    else
    {
        Serial1.println("Error! Retry...");
    }

    delay(1000);
}

Den kompletten Quellcode findet ihr in meinem GitLab-Repository.