Skip to content

Bitfields ​

wirespec provides first-class support for sub-byte fields through bits[N] and bit. Consecutive bit fields are automatically grouped into a single wire read, and the compiler generates shift+mask extraction code for each field. No manual bit manipulation is required.

bits[N] ​

bits[N] declares an N-bit unsigned integer field. N can range from 1 to 32. The field is stored in the generated C struct as the smallest unsigned integer type that fits:

NC type
1–8uint8_t
9–16uint16_t
17–32uint32_t
wire
packet IPv4Header {
    version: bits[4],    # 4-bit unsigned, stored as uint8_t
    ihl: bits[4],        # 4-bit unsigned, stored as uint8_t
    dscp: bits[6],       # 6-bit unsigned, stored as uint8_t
    ecn: bits[2],        # 2-bit unsigned, stored as uint8_t
    total_length: u16,
    # ...
}

bit ​

bit is an alias for bits[1]. It is the natural type for boolean protocol flags:

wire
packet TcpSegment {
    # ...
    cwr: bit,
    ece: bit,
    urg: bit,
    ack: bit,
    psh: bit,
    rst: bit,
    syn: bit,
    fin: bit,
    # ...
}

In the generated C struct, bit fields become uint8_t (value is 0 or 1 after masking).

BitGroup Auto-Grouping ​

When wirespec encounters a run of consecutive bits[N] or bit fields, it automatically groups them into a single wire read. The total width of the group determines how many bytes are read:

  • Total ≤ 8 bits → read 1 byte (uint8_t)
  • Total ≤ 16 bits → read 2 bytes (uint16_t)
  • Total ≤ 32 bits → read 4 bytes (uint32_t)

A non-bit field (any u8, u16, bytes[...], etc.) breaks the current group and starts a new one on the next bits[N] field encountered.

Example: two 4-bit nibbles ​

wire
packet IPv4FirstByte {
    version: bits[4],   # \
    ihl: bits[4],       # / — grouped: 1 byte read
    # rest of packet
}

Generated C (parse):

c
uint8_t _bitgroup_0;
if (ws_cursor_read_u8(cur, &_bitgroup_0) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->version = (_bitgroup_0 >> 4) & 0x0F;
out->ihl     = (_bitgroup_0     ) & 0x0F;

Example: TCP flags (8 single-bit fields) ​

wire
module net.tcp
@endian big

packet TcpSegment {
    src_port: u16,
    dst_port: u16,
    seq_num: u32,
    ack_num: u32,
    data_offset: bits[4],   # \
    reserved:    bits[4],   # / — group 0: 1 byte
    cwr: bit,               # \
    ece: bit,               #  |
    urg: bit,               #  |
    ack: bit,               #  | — group 1: 1 byte
    psh: bit,               #  |
    rst: bit,               #  |
    syn: bit,               #  |
    fin: bit,               # /
    window: u16,
    checksum: u16,
    urgent_pointer: u16,
    require data_offset >= 5,
    options: bytes[length: data_offset * 4 - 20],
}

The data_offset / reserved run (8 bits total) becomes one byte read. The eight flag fields (8 bits total) become a second byte read. Generated C for the flags group:

c
uint8_t _bitgroup_1;
if (ws_cursor_read_u8(cur, &_bitgroup_1) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->cwr = (_bitgroup_1 >> 7) & 0x01;
out->ece = (_bitgroup_1 >> 6) & 0x01;
out->urg = (_bitgroup_1 >> 5) & 0x01;
out->ack = (_bitgroup_1 >> 4) & 0x01;
out->psh = (_bitgroup_1 >> 3) & 0x01;
out->rst = (_bitgroup_1 >> 2) & 0x01;
out->syn = (_bitgroup_1 >> 1) & 0x01;
out->fin = (_bitgroup_1     ) & 0x01;

Example: mixed widths across 16 bits ​

wire
packet IPv4FlagsFragment {
    flags:           bits[3],    # \
    fragment_offset: bits[13],   # / — grouped: 2 bytes (uint16_t)
}

Generated C:

c
uint16_t _bitgroup_0;
if (ws_cursor_read_u16(cur, &_bitgroup_0) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->flags           = (_bitgroup_0 >> 13) & 0x0007;
out->fragment_offset = (_bitgroup_0      ) & 0x1FFF;

Example: 32-bit group ​

wire
packet ThirtyTwoBits {
    x: bits[4],
    y: bits[12],
    z: bits[16],
}

Total is 32 bits, so the group uses a single uint32_t read:

c
uint32_t _bitgroup_0;
if (ws_cursor_read_u32(cur, &_bitgroup_0) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->x = (_bitgroup_0 >> 28) & 0x0000000F;
out->y = (_bitgroup_0 >> 16) & 0x00000FFF;
out->z = (_bitgroup_0      ) & 0x0000FFFF;

Big-Endian Packing (default) ​

With @endian big (the default), fields are packed MSB-first. The first field in the group occupies the most significant bits of the group word.

For a one-byte group containing version: bits[4], ihl: bits[4]:

Bit:     7  6  5  4  3  2  1  0
         [  version  ][   ihl  ]

The first declared field (version) sits at the top (MSB) of the byte. Extraction uses a right-shift equal to the number of bits remaining to the right of that field.

Little-Endian Packing ​

With @endian little, fields are packed LSB-first. The first field in the group occupies the least significant bits of the group word.

wire
@endian little

packet LeBits {
    flag_a: bits[3],   # bits [2:0] of the wire byte
    flag_b: bits[5],   # bits [7:3] of the wire byte
}
Bit:     7  6  5  4  3  2  1  0
         [    flag_b    ][ fa ]

Generated C for LE packing:

c
uint8_t _bitgroup_0;
if (ws_cursor_read_u8(cur, &_bitgroup_0) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->flag_a = (_bitgroup_0     ) & 0x07;   // bits [2:0]
out->flag_b = (_bitgroup_0 >> 3) & 0x1F;   // bits [7:3]

The shift direction reverses: the first-declared field has the smallest shift (sits at LSB), and subsequent fields shift upward.

Full IPv4 Header Example ​

The complete IPv4 header from examples/ip/ipv4.wspec uses three separate bit groups interleaved with regular fields:

wire
module ip.v4
@endian big

packet IPv4Header {
    version: bits[4],           # \
    ihl: bits[4],               # / — group 0: 1 byte
    dscp: bits[6],              # \
    ecn: bits[2],               # / — group 1: 1 byte
    total_length: u16,          # — regular field, not grouped
    identification: u16,        # — regular field
    flags: bits[3],             # \
    fragment_offset: bits[13],  # / — group 2: 2 bytes (uint16_t)
    ttl: u8,                    # — regular field
    protocol: u8,               # — regular field
    @checksum(internet)
    header_checksum: u16,       # — regular field
    src_addr: u32,              # — regular field
    dst_addr: u32,              # — regular field
}

Each regular u8/u16/u32 field between the bit-field runs breaks the current group. The three groups are independent reads.

Using Bit Fields in Expressions ​

Bit fields extracted from groups are ordinary integer values in expressions. You can use them in require, let, if, and bytes[length:]:

wire
packet TcpSegment {
    # ...
    data_offset: bits[4],
    # ...
    require data_offset >= 5,
    options: bytes[length: data_offset * 4 - 20],
}

The data_offset * 4 - 20 expression computes the options length from the data offset field, which is fully resolved before the options field is parsed.

Tips ​

  • Group boundaries are automatic. You never declare groups explicitly — just write the bits[N] fields consecutively and wirespec groups them.
  • Non-bit fields break groups. If you need two separate byte reads, place a regular field between them.
  • Groups must not exceed 32 bits. A run of bit fields totalling more than 32 bits is a compile error.
  • The bit alias improves readability. Prefer syn: bit over syn: bits[1] for boolean flags.