← Back to list

Protocol Header Design 101

We cover some theory and elicit some recommendations for networking protocol design especially with regards to protocol headers

Tom Herbert · 2025-10-12 16:55 · 69 claps · 9.1 min read
#network-protocols #data-type #endianness #protocol-design
Open on Medium ↗

Protocol Header Design 101

This post does not reflect the views of current, past, or future employers. The opinions in this article are my own.

Sally and Bob have entered the “Design the Next Greatest Networking Protocol” contest. By happenstance, both of them submit protocols that do the exact same thing and carry the same information in their protocol headers. The only difference is how the header fields are formatted — while the content is the same, the structure is different. Is there an objective way for the judges to determine which protocol is better? Let’s see if we can give the judges something to work with!

Building blocks

The basic unit of a protocol header is a byte consisting of eight bits (more formally known as an octet). We can construct larger units as multiples of bytes like half words (two bytes), words (four bytes), and double words (eight bytes). We’ll refer to these units based on bytes as ordinal types. A protocol header is composed of some number of fields. Fields may be ordinal types like bytes or halfwords, or they may be non-ordinal types as bit-fields that are sequences of bits of some length.

The IPv4 header. This is the canonical example of a protocol header. Most fields are ordinal types of 32-bit words (e.g. the source IP address), 16-bit half words (e.g. the checksum), or 8-bit bytes (e.g. time to live). There are four bit-fields (.e.g version and flags) with a size that is not a multiple of eight bits.

The IPv4 header. This is the canonical example of a protocol header. Most fields are ordinal types of 32-bit words (e.g. the source IP address), 16-bit half words (e.g. the checksum), or 8-bit bytes (e.g. time to live). There are four bit-fields (.e.g version and flags) with a size that is not a multiple of eight bits.

To implement a protocol we need to define a protocol data structure in the programming language being used for the implementation. Below is an example that shows the data structure for the IPv4 header in P4, on the left, and in C, on the right.

header ipv4_t {                     struct iphdr {
    bit<4>    version;                  #if defined(__LITTLE_ENDIAN_BITFIELD)
    bit<4>    ihl;                          __u8    ihl:4,
    bit<8>    diffserv;                             version:4;
    bit<16>   totalLen;                 #elif defined (__BIG_ENDIAN_BITFIELD)
    bit<16>   identification;               __u8    version:4,
    bit<3>    flags;                                ihl:4;
    bit<13>   fragOffset;               #endif
    bit<8>    ttl;                          __u8    tos;
    bit<8>    protocol;                     __u16   id;
    bit<16>   hdrChecksum;                  __u16   tot_len;
    ip4Addr_t srcAddr;                      __u16   frag_off;
    ip4Addr_t dstAddr;                      __u8    ttl;
}                                           __u8    protocol;
                                            __u16   check;
                                            __u32   saddr;
                                            __u32   daddr;
                                    }

It’s interesting that everything’s a bit-field in P4, but in C we use ordinal types where possible. For instance, the total length field is defined with type “bit<16>” in P4, but as type “__u16” in C. It’s the same effect, but personally I wish P4 used ordinal types since they’re based on bytes which is the fundamental data unit of all of networking protocols and computers.

Protocol Field Types

To derive some meaningful guidance, let’s start by categorizing protocol field structure. We can base this on three dimensions: whether a field is an ordinal type (i.e. a byte, half word, word, etc.), the bit size of the field, and alignment of the field. Considering these dimensions, we can classify protocol header fields with a Protocol Field Type that gives a hint as to how much effort is needed to process a field.

Type I: Ordinal types

The simplest protocol fields are those that are bytes, half words, words, double words. In a CPU, accessing these ordinal types requires no special operations, simple memory loads and stores suffice.

Type II: Odd multiple of bytes

A field may be composed of multiple bytes where the number of bytes is not a power of two (i.e. not an ordinal type). Perhaps the most common case of this in protocols is a 24-bit field within a 32-word. One could conceive of others like 48-bits in a 64-bit word. These are straightforward to deal with. For a 24-bit field we can load the whole word and then either shift or mask out the extra eight bits depending on alignment.

Type III: Bit-fields in a byte

It’s quite common in protocols to pack bit-fields into a single byte. These are fairly easy to deal with. If the bit-field we’re interested in is aligned to the left of the byte (high order bits) then we can extract the value by a shift right, if the bit-field is aligned to the right then we would mask out the high order bits to extract the value. If the bit-field is in the middle of other bit-fields then we need both a shift and a mask:

bitfield_val = byte_val >> (8 -bit_field_num_bits) / Aligned left /

bitfield_val = byte_val & ~((1 << bit_field_num_bits) -1) / Aligned right /

bitfield_val = (byte_val >> (8 -bit_field_num_bits)) & ~((1 << bit_field_num_bits) -1) / Not aligned /

Type IV: Bit-fields spanning a byte boundary

Now this is where things get interesting. A Type IV field is a bit-field that spans a byte boundary. As we previously discussed, this has performance ramifications if the bit-field endianness of the architecture doesn’t match the data endianness (e.g. we’re accessing a bit-field of a big endian protocol from a little endian CPU). When there’s an endianness mismatch the field is split into two parts: high order bits and low order bits. Accessing the field then requires loading the two parts, left shifting the high order bits by the number of low order bits and then or’ing everything together.

If you looked closely at the data structures for the IPv4 header above, you may have noticed that the C structure does not have a flags field whereas the P4 one does. This could have been represented in the C structure by:

#if defined(__LITTLE_ENDIAN_BITFIELD)
    __u16 frag_offset1: 5;
    __u16 flags: 3;
    __u16 frag_offset2: 8;
#elif defined (__BIG_ENDIAN_BITFIELD)
    __u16 flags: 3;
    __u16 frag_offset: 13;
#endif

The reason they didn’t do that is probably because fragment offset is a Type IV field and it’s just as easy to hide the complexity to process the field in the fragmentation code — we only care about splitting frag_offset into sub-fields when a packet is fragmented, which is reasonably rare. There’s a more interesting example of this in the IPv6 structure:

struct ipv6hdr {
        ...
        __u8                    version:4,
                                priority:4;
        __u8                    flow_lbl[3];
        ...
}

The priority field is actually eight bits and the flow_lbl is twenty bits in the protocol, so the structure definition is incorrect! The comment before the structure plainly says that:

BEWARE, it is incorrect. The first 4 bits of flow_lbl are glued to priority now, forming “class”.

Hmm, the most deployed kernel on the planet admits to being incorrect about what may soon be the most used Layer 3 protocol. Interesting— just say’in :-). Obviously the code works, but yeah, Type IV protocol fields are a major pain in the !#?@&.

Type V: Bit-fields spanning two byte boundaries

If you’re thinking that if a bit-field spanning one byte boundaries is bad then spanning two byte boundaries is worse then you’re right! When there’s an endianness mismatch between the protocol and the CPU the field is split into three parts: high order bits, middle order bits, and low order bits. Accessing the field requires loading the three parts, left shifting the high order bits by the number of low order bits plus the number of middle order bits, left shifting the middle order bits by the number of low order bits, and then or’ing everything together. We show this misery below in code.

Protocol field types in the wild

Let’s take a look at some examples of the different Protocol Field Types and how implementations would process fields for each of our types. We’ll work with some example fields that are illustrated below:

Examples of fields for different Protocol Field Types. The fields of interest are in yellow. The Type I field is the Option Length field in a TCP header, the Type II field is the Virtual Network Identifier (VNI) in VXLAN, the Type III field is the header length from the IPv4 header, the Type IIIa field is the “request close” field from a UET PDS RUD/ROD ACK header, the Type IV field is the next_hdr field in a UET PDS request packet, and the Type V field is rue_info from Falcon Base Acknowledgement header.

Examples of fields for different Protocol Field Types. The fields of interest are in yellow. The Type I field is the Option Length field in a TCP header, the Type II field is the Virtual Network Identifier (VNI) in VXLAN, the Type III field is the header length from the IPv4 header, the Type IIIa field is the “request close” field from a UET PDS RUD/ROD ACK header, the Type IV field is the next_hdr field in a UET PDS request packet, and the Type V field is rue_info from Falcon Base Acknowledgement header.

The data structures for our examples are defined in the code below. We assume this code will be compiled on a little endian CPU so we only list the bit-field format for little endian.

#include <linux/types.h>

union my_struct {
        struct {
                __u8 type;
                __u8 len; /* <<< Length */
        } type_i; /* Two byte TCP options header */
        struct {
                __u32 vni: 24; /* <<< Virtual Network Identifier */
                __u32 rsvd: 8;
        } type_ii; /* First four bytes of VXLAN header */
        struct {
                __u8 len: 4; /* <<< IPv4 header length */
                __u8 ver: 4;
        } type_iii; /* First byte of IPv4 header */
        struct {
                __u16 next_hdr1: 3;
                __u16 type: 5;

                __u16 rsvd2: 1;
                __u16 request: 2; /* <<< Request close */
                __u16 probe: 1;
                __u16 retrans: 1;
                __u16 ecn_marked: 1;
                __u16 rsvd1: 1;
                __u16 next_hdr2: 1;
                } type_iii_a; /* From UET PDS RUD/ROD ACK */
        struct {
                __u16 next_hdr_ctrl1: 3; /* <<< Next header */
                __u16 type: 5;
                __u16 flags: 7;
                __u16 next_hdr_ctrl2: 1; /* <<< Part 2 */
        } type_iv; /* From UET PDS prologue header */
        struct {
                __u32 rue_info1: 7; /* <<< RUE info */
                __u32 rsvd8: 1;
                __u32 rue_info2: 8; /* <<< Part 2 */
                __u32 oo_wind_notify: 2;
                __u32 rue_info3: 6; /* <<< Part 3 */
        } type_v; /* From Falcon Base ACK header */
};

/* FUnctions to add one to a field */

__u64 func_type_i(union my_struct *s) { return s->type_i.len + 1; }

__u64 func_type_ii(union my_struct *s) { return s->type_ii.vni + 1; }

__u64 func_type_iii(union my_struct *s) { return s->type_iii.len + 1; }

__u64 func_type_iii_a(union my_struct *s) { return s->type_iii_a.request + 1; }

__u64 func_type_iv(union my_struct *s)
{
        return ((s->type_iv.next_hdr_ctrl1 << 1) +
                                        s->type_iv.next_hdr_ctrl2) + 1;
}

__u64 func_type_v(union my_struct *s)
{
        return ((s->type_v.rue_info1 << 14) | (s->type_v.rue_info2 << 6) |
                s->type_v.rue_info3) << 4;
}

For today’s exercise we’re not going to run the program, we just want to compile to a .o and then disassemble it to see what the compiler did. I’m compiling for a RISC-V target.

$ /opt/riscv/bin/riscv64-unknown-linux-gnu-gcc -O3 -c -o test_fields.o test_fields.c

And to generate the disassembly:

$ /opt/riscv64/bin/riscv64-unknown-linux-gnu-objdump -S -d test_fields.o

test_fields.o:     file format elf64-littleriscv

Disassembly of section .text:

0000000000000000 <func_type_i>:
   0: 00154503           lbu a0,1(a0)
   4: 2505               addiw a0,a0,1
   6: 8082               ret

0000000000000008 <func_type_ii>:
   8: 4108               lw a0,0(a0)
   a: 0085551b           srliw a0,a0,0x8
   e: 2505               addiw a0,a0,1
  10: 8082               ret

0000000000000012 <func_type_iii>:
  12: 4108               lw a0,0(a0)
  14: 893d               andi a0,a0,15
  16: 2505               addiw a0,a0,1
  18: 8082               ret

000000000000001a <func_type_iii_a>:
  1a: 4108               lw a0,0(a0)
  1c: 0095551b           srliw a0,a0,0x9
  20: 890d               andi a0,a0,3
  22: 2505               addiw a0,a0,1
  24: 8082               ret

0000000000000026 <func_type_iv>:
  26: 411c               lw a5,0(a0)
  28: 0077f513           andi a0,a5,7
  2c: 00f7d79b           srliw a5,a5,0xf
  30: 8b85               andi a5,a5,1
  32: 0015151b           slliw a0,a0,0x1
  36: 9d3d               addw a0,a0,a5
  38: 2505               addiw a0,a0,1
  3a: 8082               ret

000000000000003c <func_type_v>:
  3c: 411c               lw a5,0(a0)
  3e: 00154703           lbu a4,1(a0)
  42: 07f7f513           andi a0,a5,127
  46: 0067171b           slliw a4,a4,0x6
  4a: 00e5151b           slliw a0,a0,0xe
  4e: 0127d79b           srliw a5,a5,0x12
  52: 8d59               or a0,a0,a4
  54: 03f7f793           andi a5,a5,63
  58: 8d5d               or a0,a0,a5
  5a: 0045151b           slliw a0,a0,0x4
  5e: 8082               ret

Now let’s count the number of instructions to process the field of each type:

Count of instructions to process protocol fields for different Protocol Field Types.

Count of instructions to process protocol fields for different Protocol Field Types.

Categorizing existing protocol headers

So now we can characterize protocol fields with a measure of their processing complexity, next we’ll survey various protocols and count the number of fields they have for each Protocol Field Type. This can be thought of as one measure of a protocol’s complexity (although certainly not the only one!).

Counts of fields of the various Protocol Fields Types for different protocols. The left column gives the protocol header and the right columns give the number of fields in the protocol header definition for the Protocol Field Types. * means that a Type I field is included that contains single bit flags; single bit flags are easily extracted or set in a word so we don’t need to consider them as their own type. ^ indicates the protocol header has reserved fields that are not counted under the field types.

Counts of fields of the various Protocol Fields Types for different protocols. The left column gives the protocol header and the right columns give the number of fields in the protocol header definition for the Protocol Field Types. means that a Type I field is included that contains single bit flags; single bit flags are easily extracted or set in a word so we don’t need to consider them as their own type. ^ indicates the protocol header has reserved fields that are not counted under the field types.*

Protocol header design principles

The data from our little experiment is clear and I believe is good input into deriving some guidelines for protocol header design. So for our judges of the protocol design contest they might want to consider:

  • The fundamental data unit of a protocol header is the byte. Specifically, an octet of eight bits. Ordinal types of a byte, two bytes, four bytes, and eight bytes make for good field sizes.
  • Protocol header length should generally be a multiple of four bytes (thirty-two bits). There are exceptions like when a header is effectively a sub-header as in the case of a two byte TCP option header. Typically the start of a protocol header is at a four or eight byte offset from the start of the packet (it’s common to insert two byte padding in front of the Ethernet header to properly align the following network header).
  • Protocol fields with a lower Protocol Field Type classification are generally better than higher numbered ones. For instance, a bit-field constrained to one byte (Type III) is almost certainly better than the bit-field spanning a byte boundary (Type IV). Or, if you’re using a Type V bit-field spanning two byte boundaries you might want to consider rearranging it so that it spans one byte boundary as a Type IV field.
  • Minimize use of reserved fields. Like about 99% of the time, reserved fields are never unreserved for real fields. It’s almost always better to only use reserved fields solely for padding.
  • Pack bit flags into one word (most protocols already do that anyway).
  • Mind cache locality. If your protocol spans multiple cache lines, think about grouping fields together to promote cache locality. Also, think about cache locality with respect to the whole packet.
  • If your protocol has a version field put it at the very beginning of the header in the first byte like in the IP header. That way we only need to decode the first byte to determine the format of all the following bytes. Side note: I’ve gone back and forth about whether protocols should have a version field. These days I’m inclined to believe they’re mostly a waste of precious protocol header space. For new protocols encapsulated in UDP, I think it’s just as easy to forego the version number and get a UDP port number for each version.
  • Fixed length headers or variable length headers?… Well that’s the existential question and a story for another day! :-)

Anyway, that’s it. Good luck to Sally and Bob and all would-be protocol designers!


메타데이터
post_id
1fcdb66582ba
slug
protocol-header-design-101-1fcdb66582ba
url
https://medium.com/@tom_84912/protocol-header-design-101-1fcdb66582ba
canonical_url
https://medium.com/@tom_84912/protocol-header-design-101-1fcdb66582ba
author_url
https://medium.com/@tom_84912
status
ok
fetched_at
2026-07-08 11:54:00