Skip to content

NTP Parsing the Header

Elbasiouny, Mahmoud edited this page Aug 5, 2026 · 5 revisions

The NTP header is the first 32 bits of every NTP packet. It packs six fields into a single 4-byte row, three of which are sub-byte (fewer than 8 bits). This makes it a good place to learn h_bits, h_int_range, and h_sequence.

Source file: ntp.c, lines 11-20

Hammer concepts: h_bits · h_int_range · h_sequence


NTP Header Layout

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|LI | VN  |Mode |    Stratum    |     Poll      |   Precision   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  2    3    3         8               8               8     (bits)
Field Bits Type Valid Range Description
LI (Leap Indicator) 2 unsigned 0-3 Leap second warning
VN (Version Number) 3 unsigned 1-4 NTP protocol version
Mode 3 unsigned 0-7 Operating mode (client, server, etc.)
Stratum 8 unsigned 0-16 Stratum level
Poll 8 signed any Log2 of poll interval in seconds
Precision 8 signed any Log2 of system clock precision

Step 1: Sub-Byte Fields with h_bits

The first byte contains three fields that together occupy 8 bits: LI (2), VN (3), and Mode (3). Standard parsers like h_uint8() read a minimum of 8 bits, so we need something finer-grained.

h_bits(len, signed)

Parameter Type Description
len size_t Number of bits to read
signed bool Whether to interpret the bits as a signed value

Hammer reads bits MSB-first (most significant bit first), so reading 2 bits followed by 3 bits followed by 3 bits correctly splits the first byte. h_bits returns a 64-bit integer token; it is best suited for sub-byte and other integer fields up to 64 bits. For wider fields, use h_bytes() or split the field into smaller integer parsers.

h_bits(2, false)   // Read 2 bits -> values 0-3
h_bits(3, false)   // Read 3 bits -> values 0-7

Step 2: Value Constraints with h_int_range

Not every bit pattern is valid. The NTP spec says Version Number must be 1-4 and Mode must be 0-7. h_int_range wraps a parser and rejects values outside a given range.

h_int_range(parser, lower, upper)

Parameter Type Description
parser HParser * The inner parser whose value is checked
lower int64_t Minimum acceptable value (inclusive)
upper int64_t Maximum acceptable value (inclusive)

If the parsed value falls outside [lower, upper], the parse fails, just as if the input didn't match at all. This lets you express validity rules within the grammar rather than checking values after parsing.

h_int_range(h_bits(3, false), 1, 4)   // 3 bits, must be 1-4

Step 3: Define Each Header Field

Now we define each header field as an H_RULE. From ntp.c:

// Header
H_RULE(leap, h_int_range(h_bits(2, false), 0, 3));    // 2 bits, must be between 0-3
H_RULE(version, h_int_range(h_bits(3, false), 1, 4)); // 3 bits, must be between 1-4
H_RULE(mode, h_int_range(h_bits(3, false), 0, 7));    // 3 bits, must be between 0-7
H_RULE(stratum, h_int_range(h_uint8(), 0, 16));       // 8 bits, must be between 0-16
H_RULE(poll, h_int8());                               // 8 bits
H_RULE(precision, h_int8());                          // 8 bits

The pattern:

  • Sub-byte fields use h_bits(n, false) wrapped in h_int_range.
  • Full-byte fields with constraints use h_uint8() wrapped in h_int_range.
  • Full-byte fields without constraints use h_int8() directly.

Tip: Always validate at the grammar level when the spec defines valid ranges. Invalid packets get rejected during parsing, so you don't need separate validation logic.


Step 4: Combine Fields with h_sequence

Six individual field parsers aren't useful on their own. We need them to run in order on consecutive bits.

h_sequence(p1, p2, ..., NULL)

Parses p1, then p2, then ... in order. The result is a sequence (array) of all parsed values. The parameter list must end with NULL.

H_RULE(header, h_sequence(leap, version, mode, stratum, poll, precision, NULL));

When header runs against input, it:

  1. Reads 2 bits -> leap
  2. Reads 3 bits -> version
  3. Reads 3 bits -> mode
  4. Reads 8 bits -> stratum
  5. Reads 8 bits -> poll
  6. Reads 8 bits -> precision

If any step fails (e.g., version is 7, which is outside 1-4), the entire header parse fails. Failure propagates automatically.


Complete Header Code

Putting it all together, from ntp.c:

H_RULE(leap, h_int_range(h_bits(2, false), 0, 3));
H_RULE(version, h_int_range(h_bits(3, false), 1, 4));
H_RULE(mode, h_int_range(h_bits(3, false), 0, 7));
H_RULE(stratum, h_int_range(h_uint8(), 0, 16));
H_RULE(poll, h_int8());
H_RULE(precision, h_int8());

H_RULE(header, h_sequence(leap, version, mode, stratum, poll, precision, NULL));

Worked Example

Given the hex byte 0xE3 as the first byte of input:

Binary: 1 1 1 0 0 0 1 1
        └─┘ └──┘ └──┘
        LI   VN  Mode
         3    4    3
  • leap reads 2 bits -> 3 (valid: 0-3)
  • version reads 3 bits -> 4 (valid: 1-4)
  • mode reads 3 bits -> 3 (valid: 0-7)

The header parser then continues reading stratum, poll, and precision from the next 3 bytes.


Summary

Concept Hammer Function When to Use
Parse fewer than 8 bits h_bits(n, signed) Protocol fields smaller than a byte
Constrain parsed values h_int_range(p, lo, hi) Spec defines a valid range
Parse fields in order h_sequence(a, b, ..., NULL) Multiple fields appear consecutively
Name a parser H_RULE(name, def) Always, it makes the grammar readable

Next: Parsing Data Fields

Previous: NTP Overview

Clone this wiki locally