Skip to content
VLSI Mentor

C Part II · Module 14 · C Through a Hardware Engineer's Eyes

Buffers, Packets and Byte Arrays

Three writes into an eight-byte buffer. Through a bare pointer they put twelve bytes into it, exit cleanly, and draw no warning from any flag this curriculum recommends. Through a buffer that carries its own length the third write is refused and the caller is told. The difference is not that the second version detects the overflow — it is that the second version cannot commit it. Chapter 13.11 named this as one of two habits that remove an entire bug class; here it stops being a recommendation and becomes the shape of the type, with a complete byte-buffer API built and measured, including the attempt at the demonstration that measured nothing at all.

Intermediate22 min readCPart IIHardwareCorrectness

Part II · Module 14 · Chapter 14.7 · Buffers, Packets and Byte Arrays

1. A Promise From Part I, Now Due

Chapter 13.11 listed six habits that remove defects rather than detecting them. Two of the six removed whole categories rather than single bugs, and one of those was: carry a pointer and its length together. That chapter closed by saying the habit would return in this one as a requirement rather than a recommendation. This is that chapter.

The reason it is a requirement here and merely excellent advice in Part I is the data. A Part I buffer usually had a length you could see — an array declared three lines up, a string with a terminator, an allocation whose size you had just written. A payload has none of that. It arrives from a bus, a socket, a DMA engine or a testbench; its length is a separate fact delivered alongside it; and there is nothing in the bytes themselves that marks the end. If the length is not carried, it is lost, and §5 measures what that costs.

2. The Thing That Does Not Work

Chapter 7.7 established the rule and it is worth restating in this setting, because it is the root of everything here: an array passed to a function becomes a pointer, and the length does not go with it.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
void process(uint8_t payload[64]) {
    printf("%zu\n", sizeof payload);   /* 8 on this machine: it is a pointer */
}

The 64 in the parameter declaration is documentation for the reader and nothing to the compiler. sizeof payload is the size of a pointer. Writing uint8_t payload[] or uint8_t *payload declares exactly the same parameter.

So every function that receives bytes needs the length as a second thing, and the question is only whether it is passed as a separate argument or carried in the value. Both work; one of them cannot be got wrong.

3. The Buffer Type

Three members, and each earns its place.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#include <stddef.h>

struct buf {
    unsigned char *data;   /* where the bytes are                       */
    size_t         len;    /* how many are currently valid              */
    size_t         cap;    /* how many the storage can hold             */
};

len and cap are different numbers and conflating them is the usual source of off-by-one failures. cap is a property of the storage and never changes while the buffer refers to it. len is how much of that storage is meaningful and grows as you write. Reading needs len; writing needs cap.

unsigned char rather than uint8_t is deliberate for the storage pointer. A character type may alias any object — the rule Chapter 14.3 §6 relied on — and unsigned char is guaranteed to exist with no padding bits, whereas uint8_t is optional. Use uint8_t for the values you put in and out, which is what Chapter 14.2 recommended, and unsigned char for the bytes themselves.

Two constructors, so a buffer is never assembled by hand:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static struct buf buf_writer(unsigned char *storage, size_t cap) {
    struct buf b = { storage, 0, cap };    /* empty, ready to be filled */
    return b;
}
static struct buf buf_reader(const unsigned char *storage, size_t len) {
    /* a reader never writes, so cap == len */
    struct buf b = { (unsigned char *)storage, len, len };
    return b;
}

4. The Operations

Writing, with the check in the function rather than at the call site.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#include <stdbool.h>
#include <stdint.h>

static bool put_u8(struct buf *b, uint8_t v) {
    if (b->len + 1 > b->cap) return false;
    b->data[b->len++] = v;
    return true;
}

static bool put_be32(struct buf *b, uint32_t v) {
    if (b->len + 4 > b->cap) return false;
    b->data[b->len++] = (unsigned char)(v >> 24);
    b->data[b->len++] = (unsigned char)(v >> 16);
    b->data[b->len++] = (unsigned char)(v >>  8);
    b->data[b->len++] = (unsigned char)(v      );
    return true;
}

Every multi-byte value goes in with shifts, which is Chapter 14.3's technique: no host test, no #ifdef, correct byte order on any machine, and Chapter 14.4 measured that it compiles to the same instruction a cast would have produced.

Reading is the mirror, and needs one extra thing — a cursor, because a reader consumes:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
struct reader { const unsigned char *data; size_t len; size_t pos; };

static bool get_u8(struct reader *r, uint8_t *out) {
    if (r->pos + 1 > r->len) return false;
    *out = r->data[r->pos++];
    return true;
}

static bool get_be32(struct reader *r, uint32_t *out) {
    if (r->pos + 4 > r->len) return false;
    *out = (uint32_t)r->data[r->pos    ] << 24
         | (uint32_t)r->data[r->pos + 1] << 16
         | (uint32_t)r->data[r->pos + 2] <<  8
         | (uint32_t)r->data[r->pos + 3];
    r->pos += 4;
    return true;
}

Note (uint32_t) on each byte before the shift. Without it the operand is an int after promotion and << 24 on a byte of 0x80 or more reaches the sign bit — Chapter 13.4's undefined shift, and Chapter 14.2 §6's point that the declared type is not the type the arithmetic uses.

5. The Measurement

The same three writes into the same eight bytes, twice.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
unsigned char storage[8];
struct buf b = buf_writer(storage, sizeof storage);

printf("put_u8   version : %s\n", put_u8(&b, 0x01)        ? "ok" : "REFUSED");
printf("put_u8   flags   : %s\n", put_u8(&b, 0x80)        ? "ok" : "REFUSED");
printf("put_be32 address : %s\n", put_be32(&b, 0x40021000u) ? "ok" : "REFUSED");
printf("len = %zu of %zu\n", b.len, b.cap);
printf("put_be32 again   : %s\n", put_be32(&b, 0xDEADBEEFu) ? "ok" : "REFUSED");
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
put_u8   version : ok
put_u8   flags   : ok
put_be32 address : ok
len = 6 of 8
put_be32 again   : REFUSED   <- the buffer refused rather than overflowed
bytes: 01 80 40 02 10 00

Six bytes of eight used, the fourth write refused, and 0x40021000 serialised as 40 02 10 00 — big-endian, as asked for, with no host test anywhere.

Now the bare-pointer version of the same job:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static unsigned char *put_be32_bare(unsigned char *p, uint32_t v) {
    p[0]=(unsigned char)(v>>24); p[1]=(unsigned char)(v>>16);
    p[2]=(unsigned char)(v>>8);  p[3]=(unsigned char)v;
    return p + 4;
}

unsigned char storage[8];
unsigned char *p = storage;
p = put_be32_bare(p, 0x01800000u);
p = put_be32_bare(p, 0x40021000u);
p = put_be32_bare(p, 0xDEADBEEFu);     /* bytes 8..11 of an 8-byte buffer */
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -O2 unchecked2.c -o u2 && ./u2
wrote 12 bytes into an 8-byte buffer; first 8: 01 80 00 00 40 02 10 00
exit=0

Twelve bytes into eight. Clean exit. No diagnostic from any flag. The function is not even wrong in isolation — put_be32_bare writes exactly four bytes wherever it is told to, which is all it can do, because it was never given anything to check against.

Under AddressSanitizer at the -O1 Chapter 13.10 recommends:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
==73515==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016b7c66a8
WRITE of size 1 at 0x00016b7c66a8 thread T0
    #0 0x0001046389fc in main unchecked2.c:13
The same three writes attempted against an eight-byte buffer, twice: once through a bare pointer and once through a pointer that carries its length. Through the bare pointer, writing two single bytes succeeds, writing four more succeeds, and writing a further four runs off the end: twelve bytes were written into eight and the program exited normally with every recommended warning flag silent, the fault visible only under AddressSanitizer. Through the length-carrying buffer the first two writes succeed and the buffer reaches six bytes of its eight, and the third write is refused and returns false, so nothing is overwritten and the caller is told. The difference is not that the second version detects the error; it is that the second version cannot commit it.The writebare pointerunsigned char *ppointer + lengthdata, len, capWhyput_u8 x22 byteswritesoklen 2 of 8room for itput_be324 byteswritesoklen 6 of 8room for itput_be32 again4 more bytesruns off the end12 bytes into 8refusedreturns false6 + 4 > 8the buffer can tell12
Figure 1 — the same three writes attempted twice against the same eight bytes. The first two rows are identical in both columns because there is room, and nothing distinguishes a safe API from an unsafe one while the data fits. The last row is the whole chapter: through the bare pointer the write lands four bytes past the end of the buffer and the program carries on normally, while through the length-carrying buffer the write is refused and the caller gets a false back. The right-hand column is not detecting the error — detection is what AddressSanitizer did, at runtime, in a build you have to remember to make. The buffer simply cannot perform the write, because the comparison in the final column is available to it and is not available to a bare pointer at all.

6. Using It on a Real Payload

The pieces assemble into something you would actually ship. Here is a complete round trip for the header from Chapter 14.5, with no struct overlay, no packing, and no host byte-order test.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
struct hdr { uint8_t version; uint8_t flags; uint16_t length; uint32_t address; };

bool hdr_write(struct buf *b, const struct hdr *h) {
    return put_u8  (b, h->version)
        && put_u8  (b, h->flags)
        && put_be16(b, h->length)
        && put_be32(b, h->address);
}

bool hdr_read(struct reader *r, struct hdr *h) {
    return get_u8  (r, &h->version)
        && get_u8  (r, &h->flags)
        && get_be16(r, &h->length)
        && get_be32(r, &h->address);
}

Measured, with version = 1, flags = 0x80, length = 1500 and address = 0x40021000, against an 8-byte buffer and then a 6-byte one:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
hdr_write (8-byte buffer): ok   len=8
bytes: 01 80 05 dc 40 02 10 00
hdr_read : ok
round trip OK  (v=1 f=0x80 len=1500 addr=0x40021000)

hdr_write (6-byte buffer): REFUSED   len=4
hdr_read from 6 bytes    : REFUSED

The whole thing is also clean under -fsanitize=address,undefined.

Two details in that output are worth reading carefully. 1500 serialised as 05 dc and 0x40021000 as 40 02 10 00 — big-endian both, on a little-endian machine, with no host test in the source. And the 6-byte case reports REFUSED with len=4: the first three fields fit and were written, the put_be32 did not and was refused. Nothing went past the end, and the buffer honestly reports how far it got.

The && chain is doing real work, and it is worth noticing why it is correct rather than merely terse. && short-circuits, so the first failure stops the sequence and nothing after it runs. A partially written buffer is left with its len at whatever succeeded, and the caller gets false — so the failure is reported once, at the top, instead of four times or not at all.

Two further points about this shape.

The payload's length is checked once, at the boundary. hdr_read does not validate the buffer; each get_ validates its own read against the length the reader was constructed with. That is Chapter 13.6's conclusion — check once at the boundary and trust afterwards — with the check pushed into the primitive so that "afterwards" is enforced rather than remembered.

The in-memory struct is free to be whatever the compiler likes. Its padding is irrelevant because nothing is overlaid, which is Chapter 14.5 §5's point arriving as a property of this design rather than as advice.

7. The Misconception: The Check Is a Cost You Can Skip

"Bounds checks on every byte are a luxury. On the fast path I know the buffer is big enough."

Two separate claims, and both are worth taking apart.

On the cost. if (b->len + 4 > b->cap) is an add, a compare and a predictable branch on two values that are in the same cache line as the pointer you are about to use and are almost certainly already in registers. Against the memory traffic of writing the bytes, it is noise. And the comparison is per field, not per byte — put_be32 checks once and writes four. A packet with a dozen fields pays a dozen compares.

More importantly, the measurement to make is not "how fast is the check" but "what is on the other side of removing it", and Chapter 13.6 answered that: an out-of-bounds write has no defined victim, and the neighbours it appeared to hit moved between -O0 and -O2. The alternative to a compare is not speed, it is a bug whose symptom relocates when you change optimisation level.

On "I know the buffer is big enough". This is the claim that actually fails, because it is true when written and not a property that survives. The buffer gets reused for a different message. A field is widened from 16 to 32 bits. A loop that wrote one record now writes a list. Each of those is a reasonable change made by someone who never read the function that assumed otherwise, and none of them produces a diagnostic.

There is a subtler version worth correcting at the same time:

"I do not need to carry the length — sizeof gives it to me."

sizeof gives you the length of an array that is in scope as an array. The moment it is a function parameter it is a pointer, and sizeof gives you 8 — §2's measurement. The habit is not "carry a length because checking is good". It is: the length is information that the type system throws away at the first function call, so it has to be carried by hand or it is gone.

8. What You Should Be Able to Say Now

  1. A payload has no self-describing length. Unlike a Part I array, nothing in the bytes marks the end, and the length arrives as a separate fact that must be kept.
  2. An array parameter is a pointer. sizeof payload inside the function is 8 here; uint8_t p[64], uint8_t p[] and uint8_t *p declare the same parameter (7.7).
  3. len and cap are different numbers. cap is the storage; len is what is valid. Writing checks cap, reading checks len.
  4. Use unsigned char for storage and uint8_t for values. A character type may alias anything and always exists; uint8_t is optional (14.2).
  5. Write the bound as len + n > cap, never cap - len < n — the subtraction is unsigned and wraps, which is 13.5's bug class in the one line meant to prevent an overflow.
  6. Cast each byte to the wide type before shifting it in, or << 24 on a byte ≥ 0x80 is 13.4's undefined shift.
  7. Measured: twelve bytes into eight, clean exit, no warning from any flag. Only ASan saw it, and only in a build you remembered to make. The length-carrying version refused the write at every optimisation level with no tool at all.
  8. A minimal reproducer can optimise its own bug away. With the buffer never read, -O1 left one store instruction in the file and ASan reported nothing.
  9. The check is not the cost. It is one predictable compare per field; the alternative is 13.6's bug whose symptom moves between -O0 and -O2.

9. Exercises

  1. Write put_be16, put_le32, get_be16 and get_le32 to match §4's style. Round-trip a value with four distinct bytes through each.
  2. Add a put_bytes(struct buf *b, const unsigned char *src, size_t n). Decide whether it uses memcpy or a loop, and justify it with a measurement from Chapter 14.4.
  3. Deliberately write the bound as b->cap - b->len < 4 and construct a struct buf whose len exceeds its cap. Show the overflow, then explain which chapter measured this exact conversion.
  4. Reproduce §5's first attempt — the bare-pointer program with no final print — and confirm ASan reports nothing at -O1. Then count the store instructions at -O1 -S. Add the print and repeat.
  5. Build hdr_write and hdr_read from §6 with an 8-byte buffer, then with a 6-byte buffer. Confirm which call returns false and that nothing was written past the end in either case.
  6. hdr_read uses &&. Rewrite it with four separate if statements that each return false, and say which version you would rather review and why.
  7. Take a function in your own code that receives a pointer to bytes. Determine whether its length travels with it. If it does, say how the caller could still get it wrong; if it does not, add it.

10. Module 14 in One Page

The module asked one question — what changes when the bytes have to match hardware someone else designed? — and answered it by re-examining five things Part I was entitled to assume. Nothing here was a new language rule; every result came from a Part I mechanism meeting an external specification.

ChapterThe measured result
14.1Plain char is implementation-defined: 0x85 read as −123, the bit-7 test returned no, and -funsigned-char reversed it on the same chip
14.2Narrow types do not do narrow arithmetic: uint8_t 200 + 100 = 300, sizeof(a + b) = 4; -Wconversion is silent when both operands are already narrow
14.3memcpy of the object gives 44 33 22 11, shifts give the specified 11 22 33 44 — and the portable form costs one instruction (ldr vs ldr+rev)
14.4A misaligned cast returned the right value, did not fault, warned about nothing, and UBSan called it a misaligned load; all three access forms compile to the same single instruction
14.5The same four fields: 8 bytes at 0/1/2/4 in one declaration order, 12 bytes at 0/2/4/8 in another. Packing fixes offsets, not byte order
14.6A datasheet address dereferenced here: exit 139. Offsets are bytes, pointer arithmetic is elements — +1 moved 4 or 1 depending on the type
14.7Twelve bytes into eight, clean exit, no warning; the length-carrying buffer refused, with no tool present

Three threads run through all seven.

The compiler is not checking what you think it is. Every chapter in this module produced a silent wrong answer: no diagnostic for the signedness, the promotion, the byte order, the misalignment, the layout, the address or the overflow. Chapter 13.1's sentence held throughout — the compiler accepting your program is a statement about its syntax, not about its meaning — and in Part II the meaning is in a document the compiler has never read.

Correctness here is nearly free. It was measured three separate times. The portable byte order costs one rev. The defined unaligned read costs nothing at all. The bounds check costs one compare per field. In this module there was not a single case where the undefined or non-portable option bought anything measurable.

Part I's discipline did more work than Part II's novelty. The misaligned cast, the strict-aliasing pun and the vanished bug were all caught by Module 13's instruments and Module 13's habits. Nothing in Part II relaxed; the targets simply got stricter.

11. What Comes Next

Every field in this module has been a whole number of bytes. version was a byte, length two, address four — and the smallest thing anyone needed to name had its own address.

Hardware does not work that way. A 32-bit control register holds an enable bit, a three-bit mode, a twelve-bit divisor and nine bits of status, and none of them has an address. The datasheet describes them as bit positions and widths within a word, and that is the form in which a hardware engineer reads, discusses and debugs every register in the system.

Module 15, Bit-Level Programming, is how C says that. It starts by reading a 32-bit value as named fields rather than as a number (15.1), builds the mask for any field from its position and width (15.2), performs the four operations — set, clear, toggle, test — without disturbing the neighbours (15.3), and extracts and composes multi-bit fields in both directions (15.4). Then it spends three chapters on the places this goes wrong, which are exactly the places this module has been pointing at: the traps that appear only at the top bit, where an operand is promoted to signed int or a shift count reaches the width (15.5), and sign extension done deliberately rather than by accident (15.6).

Chapter 14.2 said the type you declare is not the type the arithmetic uses, and left that as a caution. Module 15 is where it becomes the subject.

Standards & specifications

Governing standard
ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)

Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C Programming curriculum.