← BlogMISTAKES — 10 Jul 2026

The Off-By-One That Made My Ring Buffer Lie About Being Empty

A fixed-size circular buffer has exactly one hard part: telling 'empty' apart from 'full.' I found out the hard way which one I'd gotten wrong.

CategoryMistakes
Published10 Jul 2026
Reading time5 min
embeddedcdata-structures

Before I trust any C library on real hardware, I write it standalone first and throw a torture test at it on my laptop, where a bug just prints wrong numbers instead of bricking a board. Good thing, too — this one would have been miserable to debug on-target.

The setup

A ring buffer for UART RX needs two indices, head and tail, and a fixed backing array. The textbook version I started from used the classic shortcut: head == tail means empty, and you sacrifice one slot of capacity so that "full" never produces the same condition.

#define CAP 64  /* usable capacity is CAP - 1, by design */

bool rb_push(ring_t *rb, uint8_t byte) {
    size_t next = (rb->head + 1) % CAP;
    if (next == rb->tail) return false; /* full */
    rb->buf[rb->head] = byte;
    rb->head = next;
    return true;
}

That part I got right, from the textbook, on the first try. The bug was in the function I didn't copy from anywhere: the one that reports how many bytes are currently buffered, which the UART interrupt handler used to decide whether it was safe to keep filling the buffer during a burst.

What went wrong

I wrote the count function the way it feels like it should work:

size_t rb_count(ring_t *rb) {
    return rb->head - rb->tail;   /* wrong: no modulo, no wraparound handling */
}

That's correct exactly as long as head never wraps around past tail in the underlying size_t arithmetic — which, on a buffer that's been running for more than a few thousand bytes, it eventually always does. Once head wrapped past zero while tail hadn't caught up yet, the subtraction underflowed into a huge unsigned number. The interrupt handler read that as "buffer is nowhere near full" and kept writing — straight past the point where rb_push should have started returning false, corrupting bytes that rb_push's own bounds check should have protected.

The torture test caught it, but only because I was logging the reported count alongside a manually-tracked reference count and diffing them. If I'd only tested "does push/pop work," it would have passed — the bug was entirely in a function that measures the buffer, not in the buffer logic itself.

The fix

size_t rb_count(ring_t *rb) {
    return (rb->head - rb->tail + CAP) % CAP;
}

Adding CAP before the modulo turns a possible negative-wrapped-to-huge result back into the correct small positive one, regardless of which index has wrapped around the backing array more times than the other.

The lesson

The "sacrifice one slot" trick is the well-known, well-tested part of a ring buffer, so I trusted it and moved fast. The bug was in the one piece of arithmetic I invented myself without checking it against the same wraparound invariant the rest of the structure was built around. Any derived quantity from a circular structure — count, remaining space, distance between two indices — has to go through the same modulo logic as the indices themselves, or it will be correct 99% of the time and catastrophically wrong exactly when the buffer has been running long enough to matter.

← The Same Precedence Mistake, Four Times, Two Months ApartThe BST Deletion Bug That Took Three Days →

Notes from readers

Comments — via GitHub