Low-memory footprint, scheduler-friendly NTP client

This is an NTP client with two main features: it uses very little memory and it can easily be adapted to cooperative scheduling environments. If you do not need sub-second accuracy, and especially if you already use the Client class in your sketch (EthernetClient or WiFiClient) using an alternative method, such as accessing a web server for getting the date, can be more efficient in terms of code size and memory occupancy.

Memory usage

Instead of allocating a 48-byte buffer for incoming and outgoing packets, only four bytes are used for transmission and four for reception. This 8-byte figure could be lowered still to four bytes only. Additional space is needed to store the name of the NTP server and the WiFiUdp object.

Packet transmission

The packet sent contains a fixed 32-bit header 44 bytes of garbage, which is the contents of the memory allocated after the header. There are two possible pitfalls here: whether the NTP servers requires any specific contents in those 44 bytes and if there are in fact 44 bytes of memory to be read after the header.

As far as the first pitfall is concerned, an analysis of the NTP standard shows that servers should not care about any of those bytes and a practical test shows that none of the many servers tried has problems with those bytes.

As far as access to memory is concerned, the header is allocated into the .data segment, which is allocated in low memory and certainly has at least 44 bytes of following memory, because at least the stack space is there.

Packet reception

The reply is read one byte at a time and only a four-byte variable is needed to hold the received time. One consequence is that the overall time needed to read a packet is increased by about 10 ms.

Cooperative scheduling

Putting a yield() call after each WiFiUdp.method() call will make this code friendly to cooperative scheduler environments.

Latency

There are two issues here: maximum latency and average latency, where latency is the time spent between two subsequent yield() calls. In the following code, latency is essentially due to time spent in WiFiUdp library calls, provided a yield() is added after each one.

When using code using read() and write() into a 48-byte buffer, the maximum latency is given by exactly those calls. Using one-byte-at-a-time reading and writing increases the elapsed time but reduces both the maximum and the average latency.

In the current code, however, only read() is done one byte at a time; this reduces the average latency but not the maximum latency, which is the latency of the write() call. This may change in a future version, once bugs #1642 and #1644 are solved.

Invocation

Because of bug #1637, when a WiFiUdp object is passed, it must be statically allocated. Once the bug is solved, it would make more sense to allocate it on the stack and release it upon exit.

For WiFi shield:

 {
   static WiFiUDP udp;
   unsigned long unixTime = ntpUnixTime(udp);
 }

For Ethernet shield:

 {
     EthernetUDP udp;
     unsigned long unixTime = ntpUnixTime(udp);
 }

Code

/*
 * © Francesco Potortì 2013 - GPLv3 - Revision: 1.13
 *
 * Send an NTP packet and wait for the response, return the Unix time
 *
 * To lower the memory footprint, no buffers are allocated for sending
 * and receiving the NTP packets.  Four bytes of memory are allocated
 * for transmision, the rest is random garbage collected from the data
 * memory segment, and the received packet is read one byte at a time.
 * The Unix time is returned, that is, seconds from 1970-01-01T00:00.
 */
unsigned long inline ntpUnixTime (UDP &udp)
{
  static int udpInited = udp.begin(123); // open socket on arbitrary port

  const char timeServer[] = "pool.ntp.org";  // NTP server

  // Only the first four bytes of an outgoing NTP packet need to be set
  // appropriately, the rest can be whatever.
  const long ntpFirstFourBytes = 0xEC0600E3; // NTP request header

  // Fail if WiFiUdp.begin() could not init a socket
  if (! udpInited)
    return 0;

  // Clear received data from possible stray received packets
  udp.flush();

  // Send an NTP request
  if (! (udp.beginPacket(timeServer, 123) // 123 is the NTP port
	 && udp.write((byte *)&ntpFirstFourBytes, 48) == 48
	 && udp.endPacket()))
    return 0;				// sending request failed

  // Wait for response; check every pollIntv ms up to maxPoll times
  const int pollIntv = 150;		// poll every this many ms
  const byte maxPoll = 15;		// poll up to this many times
  int pktLen;				// received packet length
  for (byte i=0; i<maxPoll; i++) {
    if ((pktLen = udp.parsePacket()) == 48)
      break;
    delay(pollIntv);
  }
  if (pktLen != 48)
    return 0;				// no correct packet received

  // Read and discard the first useless bytes
  // Set useless to 32 for speed; set to 40 for accuracy.
  const byte useless = 40;
  for (byte i = 0; i < useless; ++i)
    udp.read();

  // Read the integer part of sending time
  unsigned long time = udp.read();	// NTP time
  for (byte i = 1; i < 4; i++)
    time = time << 8 | udp.read();

  // Round to the nearest second if we want accuracy
  // The fractionary part is the next byte divided by 256: if it is
  // greater than 500ms we round to the next second; we also account
  // for an assumed network delay of 50ms, and (0.5-0.05)*256=115;
  // additionally, we account for how much we delayed reading the packet
  // since its arrival, which we assume on average to be pollIntv/2.
  time += (udp.read() > 115 - pollIntv/8);

  // Discard the rest of the packet
  udp.flush();

  return time - 2208988800ul;		// convert NTP time to Unix time
}

Share