EthicsComputerCourses
Join training +91 90532 10052

Project: Sensor Ring Buffer Library

C · Lesson 44 of 44 Advanced 9 min read Updated

In this project you will build the kind of code that embedded and systems teams write every day: a small, reusable library with a clear API, a program that uses it, automated tests, and a build that turns on every warning and sanitizer. The subject is a ring buffer (also called a circular buffer), a fixed-size queue that keeps the most recent values and overwrites the oldest when it is full. Ring buffers sit inside UART drivers, audio pipelines, network stacks and data loggers.

The scenario: a fruit cold store near Nashik must stay at or below 4.0 °C. A sensor reports the temperature once a minute. Single readings jump around, so the logger keeps the last five readings, computes their minimum, maximum and average, and raises an alarm when the average goes above 4.0 °C. The library must also be usable on a microcontroller, so it must not call malloc.

This project uses Memory Ownership, Error Handling, CMake for C Projects, Unit Testing in C and Embedded C. The queue idea itself is explained in Queues & Deques in the DSA course.

Step 1: Plan the design

Decisions first, code second. Writing these down is what separates a project from a homework program:

DecisionChoiceReason
Value typeint16_t, tenths of a degreeFixed-point integers: no floating point needed on a small chip; range -3276.8 to 3276.7 °C
StorageArray supplied by the callerNo heap: works with a static array on a microcontroller
When fullOverwrite the oldest valueA logger always wants the latest readings
ErrorsStatus enum plus output pointers, [[nodiscard]]Explicit and impossible to ignore silently
Struct visibilityVisible in the header, not opaqueThe caller must be able to declare a RingBuf without malloc; fields are documented as private

The last row is a real trade-off. An opaque type hides fields but needs dynamic allocation or a fixed internal pool; a visible struct can live on the stack or in static memory. Embedded libraries usually choose the visible struct and ask users not to touch the fields.

The project layout:

Text
ringlog/
  CMakeLists.txt
  include/ringbuf.h
  src/ringbuf.c
  app/logger.c
  tests/test_ringbuf.c
  third_party/unity/unity.c, unity.h, unity_internals.h
  readings.txt

Step 2: The public header

The header is the contract. Every function says who owns what and what each status means:

C
// include/ringbuf.h: fixed-capacity ring buffer of int16_t values, no heap allocation
#ifndef RINGBUF_H
#define RINGBUF_H

#include <stddef.h>
#include <stdint.h>

typedef struct {
    // Private fields: use the rb_ functions only.
    int16_t *data;       // storage owned by the caller; must outlive the buffer
    size_t capacity;     // number of slots in data
    size_t head;         // index where the next value will be written
    size_t count;        // number of values currently stored
} RingBuf;

typedef enum {
    RB_OK = 0,
    RB_EMPTY,            // no values stored
    RB_BAD_ARG,          // null pointer, zero capacity or index out of range
} RbStatus;

typedef struct {
    int16_t min;
    int16_t max;
    int16_t average;     // rounded to the nearest unit, halves away from zero
} RbStats;

[[nodiscard]] RbStatus rb_init(RingBuf *rb, int16_t *storage, size_t capacity);
void rb_push(RingBuf *rb, int16_t value);                    // overwrites the oldest when full
[[nodiscard]] RbStatus rb_pop(RingBuf *rb, int16_t *out);     // removes the oldest value
[[nodiscard]] RbStatus rb_get(const RingBuf *rb, size_t i, int16_t *out);  // i = 0 is the oldest
[[nodiscard]] RbStatus rb_stats(const RingBuf *rb, RbStats *out);
size_t rb_count(const RingBuf *rb);
bool rb_is_full(const RingBuf *rb);

#endif

Every public name starts with rb_ or Rb, because C has one global namespace, as explained in Object Files, Static & Shared Libraries.

Step 3: The implementation

The trick of a ring buffer is the index arithmetic. head is where the next value goes; after writing, it moves one step and wraps to 0 at the end with %. The oldest value is count steps behind head, also wrapping around:

C
// src/ringbuf.c
#include "ringbuf.h"

RbStatus rb_init(RingBuf *rb, int16_t *storage, size_t capacity)
{
    if (rb == nullptr || storage == nullptr || capacity == 0) {
        return RB_BAD_ARG;
    }
    *rb = (RingBuf){ .data = storage, .capacity = capacity, .head = 0, .count = 0 };
    return RB_OK;
}

void rb_push(RingBuf *rb, int16_t value)
{
    rb->data[rb->head] = value;
    rb->head = (rb->head + 1) % rb->capacity;
    if (rb->count < rb->capacity) {
        rb->count++;                 // when full, count stays and the oldest is overwritten
    }
}

static size_t oldest_index(const RingBuf *rb)
{
    return (rb->head + rb->capacity - rb->count) % rb->capacity;
}

RbStatus rb_pop(RingBuf *rb, int16_t *out)
{
    if (rb->count == 0) {
        return RB_EMPTY;
    }
    *out = rb->data[oldest_index(rb)];
    rb->count--;                     // the next oldest is now one step further on
    return RB_OK;
}

RbStatus rb_get(const RingBuf *rb, size_t i, int16_t *out)
{
    if (i >= rb->count) {
        return RB_BAD_ARG;
    }
    *out = rb->data[(oldest_index(rb) + i) % rb->capacity];
    return RB_OK;
}

RbStatus rb_stats(const RingBuf *rb, RbStats *out)
{
    if (rb->count == 0) {
        return RB_EMPTY;
    }
    RbStats s = { .min = INT16_MAX, .max = INT16_MIN, .average = 0 };
    int64_t sum = 0;                 // wide enough for any number of int16_t values
    size_t start = oldest_index(rb);
    for (size_t i = 0; i < rb->count; i++) {
        int16_t v = rb->data[(start + i) % rb->capacity];
        if (v < s.min) {
            s.min = v;
        }
        if (v > s.max) {
            s.max = v;
        }
        sum += v;
    }
    int64_t n = (int64_t) rb->count;
    int64_t rounded = (sum >= 0 ? sum + n / 2 : sum - n / 2) / n;
    s.average = (int16_t) rounded;   // an average always lies between min and max, so it fits
    *out = s;
    return RB_OK;
}

size_t rb_count(const RingBuf *rb)
{
    return rb->count;
}

bool rb_is_full(const RingBuf *rb)
{
    return rb->count == rb->capacity;
}

Trace a buffer of capacity 3 after pushing 1, 2, 3, 4, 5. The pushes write slots 0, 1, 2, 0, 1, so the array holds {4, 5, 3}, head is 2 and count is 3. The oldest index is (2 + 3 - 3) % 3 = 2, which holds 3: correct, because 1 and 2 were overwritten. Reading from there gives 3, 4, 5 in order.

Two details are worth noticing. The sum uses int64_t, so adding many large readings cannot overflow, and integer division in C truncates towards zero, so the code adds or subtracts half of n first to round to the nearest value. For -137 / 3 (true average -45.67), that gives (-137 - 1) / 3 = -46.

Step 4: The logger program

The program reads one reading per line from a file (or standard input), skips bad lines with a message on stderr, and prints the window's statistics once five readings have arrived. Its exit status is 2 when any alarm occurred, so a monitoring script can react without parsing the text.

C
// app/logger.c
#include <errno.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ringbuf.h"

constexpr size_t WINDOW = 5;
constexpr int16_t ALARM_ABOVE = 40;      // 4.0 C, in tenths

static int16_t storage[WINDOW];          // static storage: no heap needed

static void tenths_text(char *buf, size_t size, int t)
{
    snprintf(buf, size, "%s%d.%d", t < 0 ? "-" : "", abs(t) / 10, abs(t) % 10);
}

static bool parse_reading(const char *line, int16_t *out)
{
    char *end;
    errno = 0;
    double v = strtod(line, &end);
    while (*end == ' ' || *end == '\r' || *end == '\n') {
        end++;
    }
    if (end == line || *end != '\0' || errno == ERANGE || v < -50.0 || v > 80.0) {
        return false;                    // not a number, or outside the sensor's range
    }
    *out = (int16_t) lround(v * 10.0);
    return true;
}

int main(int argc, char *argv[])
{
    FILE *in = stdin;
    if (argc > 1) {
        in = fopen(argv[1], "r");
        if (in == nullptr) {
            fprintf(stderr, "logger: %s: %s\n", argv[1], strerror(errno));
            return EXIT_FAILURE;
        }
    }

    RingBuf rb;
    if (rb_init(&rb, storage, WINDOW) != RB_OK) {
        return EXIT_FAILURE;
    }

    char line[64];
    int line_no = 0, valid = 0, skipped = 0, alarms = 0;
    while (fgets(line, sizeof line, in) != nullptr) {
        line_no++;
        int16_t t;
        if (!parse_reading(line, &t)) {
            fprintf(stderr, "line %d: not a valid reading, skipped\n", line_no);
            skipped++;
            continue;
        }
        valid++;
        rb_push(&rb, t);

        char now[16];
        tenths_text(now, sizeof now, t);
        if (!rb_is_full(&rb)) {
            printf("%5s C  collecting %zu/%zu\n", now, rb_count(&rb), WINDOW);
            continue;
        }
        RbStats s;
        if (rb_stats(&rb, &s) != RB_OK) {
            continue;
        }
        char avg[16], lo[16], hi[16];
        tenths_text(avg, sizeof avg, s.average);
        tenths_text(lo, sizeof lo, s.min);
        tenths_text(hi, sizeof hi, s.max);
        bool alarm = s.average > ALARM_ABOVE;
        alarms += alarm;
        printf("%5s C  avg %4s  min %4s  max %4s  %s\n", now, avg, lo, hi, alarm ? "ALARM" : "ok");
    }
    if (in != stdin) {
        fclose(in);
    }
    printf("%d readings, %d skipped, %d alarms\n", valid, skipped, alarms);
    return alarms > 0 ? 2 : EXIT_SUCCESS;
}

tenths_text prints fixed-point values without floating point, handling the sign separately so -4.5 is not printed as "-4.-5". Floating point appears only once, at the input boundary, where strtod and lround turn text into tenths.

Step 5: Unity tests

The tests pin down the behaviour that matters: bad arguments, the empty buffer, first-in-first-out order, overwriting, statistics with negative values, and out-of-range indexes. setUp gives every test a fresh buffer of capacity 3:

C
// tests/test_ringbuf.c
#include "unity.h"
#include "ringbuf.h"

static int16_t store[3];
static RingBuf rb;

void setUp(void)
{
    TEST_ASSERT_EQUAL_INT(RB_OK, rb_init(&rb, store, 3));
}

void tearDown(void) {}

void test_init_rejects_bad_arguments(void)
{
    RingBuf other;
    TEST_ASSERT_EQUAL_INT(RB_BAD_ARG, rb_init(&other, nullptr, 3));
    TEST_ASSERT_EQUAL_INT(RB_BAD_ARG, rb_init(&other, store, 0));
}

void test_new_buffer_is_empty(void)
{
    int16_t v;
    RbStats s;
    TEST_ASSERT_EQUAL_INT(0, (int) rb_count(&rb));
    TEST_ASSERT_EQUAL_INT(RB_EMPTY, rb_pop(&rb, &v));
    TEST_ASSERT_EQUAL_INT(RB_EMPTY, rb_stats(&rb, &s));
}

void test_pop_returns_values_in_fifo_order(void)
{
    int16_t v;
    rb_push(&rb, 10);
    rb_push(&rb, 20);
    TEST_ASSERT_EQUAL_INT(RB_OK, rb_pop(&rb, &v));
    TEST_ASSERT_EQUAL_INT(10, v);
    TEST_ASSERT_EQUAL_INT(RB_OK, rb_pop(&rb, &v));
    TEST_ASSERT_EQUAL_INT(20, v);
    TEST_ASSERT_EQUAL_INT(RB_EMPTY, rb_pop(&rb, &v));
}

void test_full_buffer_overwrites_oldest(void)
{
    int16_t v;
    for (int16_t x = 1; x <= 5; x++) {
        rb_push(&rb, x);
    }
    TEST_ASSERT_TRUE(rb_is_full(&rb));
    TEST_ASSERT_EQUAL_INT(RB_OK, rb_get(&rb, 0, &v));
    TEST_ASSERT_EQUAL_INT(3, v);
    TEST_ASSERT_EQUAL_INT(RB_OK, rb_get(&rb, 2, &v));
    TEST_ASSERT_EQUAL_INT(5, v);
}

void test_stats_round_negative_average(void)
{
    RbStats s;
    rb_push(&rb, -45);
    rb_push(&rb, -40);
    rb_push(&rb, -52);
    TEST_ASSERT_EQUAL_INT(RB_OK, rb_stats(&rb, &s));
    TEST_ASSERT_EQUAL_INT(-52, s.min);
    TEST_ASSERT_EQUAL_INT(-40, s.max);
    TEST_ASSERT_EQUAL_INT(-46, s.average);
}

void test_get_rejects_index_past_count(void)
{
    int16_t v;
    rb_push(&rb, 7);
    TEST_ASSERT_EQUAL_INT(RB_BAD_ARG, rb_get(&rb, 1, &v));
}

int main(void)
{
    UNITY_BEGIN();
    RUN_TEST(test_init_rejects_bad_arguments);
    RUN_TEST(test_new_buffer_is_empty);
    RUN_TEST(test_pop_returns_values_in_fifo_order);
    RUN_TEST(test_full_buffer_overwrites_oldest);
    RUN_TEST(test_stats_round_negative_average);
    RUN_TEST(test_get_rejects_index_past_count);
    return UNITY_END();
}

Step 6: The build

The CMake file builds the library, the logger, Unity and the tests, with strict warnings on your own code and an option for sanitizers:

Text
cmake_minimum_required(VERSION 3.21)
project(ringlog VERSION 1.0 LANGUAGES C)

set(CMAKE_C_STANDARD 23)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

option(RINGLOG_SANITIZE "Build with AddressSanitizer and UBSan" OFF)
if(RINGLOG_SANITIZE)
    add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer)
    add_link_options(-fsanitize=address,undefined)
endif()

set(STRICT_WARNINGS -Wall -Wextra -Wpedantic -Wconversion -Wshadow -Wformat=2)

add_library(ringbuf STATIC src/ringbuf.c)
target_include_directories(ringbuf PUBLIC include)
target_compile_options(ringbuf PRIVATE ${STRICT_WARNINGS})

add_executable(logger app/logger.c)
target_link_libraries(logger PRIVATE ringbuf m)
target_compile_options(logger PRIVATE ${STRICT_WARNINGS})

add_library(unity STATIC third_party/unity/unity.c)
target_include_directories(unity PUBLIC third_party/unity)

enable_testing()
add_executable(test_ringbuf tests/test_ringbuf.c)
target_link_libraries(test_ringbuf PRIVATE ringbuf unity)
add_test(NAME ringbuf COMMAND test_ringbuf)

The flags assume GCC or Clang, on Linux or WSL (where sanitizers work). Build with sanitizers on and run the tests:

Terminal
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DRINGLOG_SANITIZE=ON
cmake --build build
ctest --test-dir build --output-on-failure
./build/test_ringbuf
Output
Test project /home/meera/ringlog/build
    Start 1: ringbuf
1/1 Test #1: ringbuf ..........................   Passed    0.01 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) =   0.02 sec
/home/meera/ringlog/tests/test_ringbuf.c:78:test_init_rejects_bad_arguments:PASS
/home/meera/ringlog/tests/test_ringbuf.c:79:test_new_buffer_is_empty:PASS
/home/meera/ringlog/tests/test_ringbuf.c:80:test_pop_returns_values_in_fifo_order:PASS
/home/meera/ringlog/tests/test_ringbuf.c:81:test_full_buffer_overwrites_oldest:PASS
/home/meera/ringlog/tests/test_ringbuf.c:82:test_stats_round_negative_average:PASS
/home/meera/ringlog/tests/test_ringbuf.c:83:test_get_rejects_index_past_count:PASS

-----------------------
6 Tests 0 Failures 0 Ignored 
OK

Your paths and times will differ. CMake gives the compiler full paths, so Unity prints the full path of the test file. The build must also finish with zero warnings; treat any warning as a bug to fix before moving on.

Step 7: Run the logger

Create readings.txt with one reading per line: 2.5, 3.0, 3.5, 3.0, 4.0, 4.5, 5.0, 5.5, x (a corrupted line) and 3.5. Then run:

Terminal
./build/logger readings.txt
echo "exit status: $?"
Output
  2.5 C  collecting 1/5
  3.0 C  collecting 2/5
  3.5 C  collecting 3/5
  3.0 C  collecting 4/5
  4.0 C  avg  3.2  min  2.5  max  4.0  ok
  4.5 C  avg  3.6  min  3.0  max  4.5  ok
  5.0 C  avg  4.0  min  3.0  max  5.0  ok
  5.5 C  avg  4.4  min  3.0  max  5.5  ALARM
line 9: not a valid reading, skipped
  3.5 C  avg  4.5  min  3.5  max  5.5  ALARM
9 readings, 1 skipped, 2 alarms
exit status: 2

Check one line by hand: after the sixth reading the window holds 3.0, 3.5, 3.0, 4.0 and 4.5, which add up to 18.0, so the average is 3.6. The first 2.5 has been overwritten, which is why the minimum rose to 3.0. An average of exactly 4.0 is still ok, because the alarm needs a value above 4.0. The line 9 message goes to stderr, so ./build/logger readings.txt > log.txt would still show it on the screen.

Step 8: Review it like a senior engineer

Before calling it done, run through the checks a reviewer would make:

  • Warnings and sanitizers: zero warnings with the strict set; tests pass under AddressSanitizer and UBSan.
  • Leaks: none possible, because nothing is allocated; valgrind ./build/logger readings.txt agrees.
  • Contract: every public function is documented in the header, including ownership of storage.
  • Portability: only fixed-width types, no floating point inside the library, no OS calls. The same ringbuf.c compiles with arm-none-eabi-gcc for a microcontroller.
  • Interrupt safety: not provided. If an ISR pushes while the main loop reads, protect the calls (briefly disable interrupts) or design a single-producer, single-consumer version. Document that limit in the header.

Ideas to extend the project:

  • Add rb_clear, and a test for it.
  • Add hysteresis: raise the alarm above 4.0 °C but clear it only below 3.5 °C, so it does not flicker.
  • Read the window size and alarm limit from command-line options with strtol.
  • Write a second test file that pushes 10,000 random values and compares the statistics with a simple array-based version.

Key points

  • Plan the data type, storage, full-buffer policy and error reporting before writing code.
  • A ring buffer stores values in a fixed array; head wraps with %, and the oldest index is count steps behind it.
  • Caller-provided storage and a visible struct make the library work without malloc, which suits embedded targets.
  • Fixed-point int16_t tenths, an int64_t sum and explicit rounding avoid overflow and floating point.
  • Unity tests cover bad input, the empty case, ordering, overwriting and negative rounding.
  • CMake builds the library, app and tests with strict warnings, an optional sanitizer build and CTest.

Exercise

Add a function RbStatus rb_peek_newest(const RingBuf *rb, int16_t *out) that returns the most recently pushed value without removing it, or RB_EMPTY. Write it, declare it in the header with [[nodiscard]], and add a Unity test that pushes 1, 2, 3, 4 into the capacity-3 buffer and checks that the newest value is 4 and the count is still 3.

Show solution

The newest value sits one slot before head, wrapping around at 0:

C
RbStatus rb_peek_newest(const RingBuf *rb, int16_t *out)
{
    if (rb->count == 0) {
        return RB_EMPTY;
    }
    size_t newest = (rb->head + rb->capacity - 1) % rb->capacity;
    *out = rb->data[newest];
    return RB_OK;
}
C
void test_peek_newest_does_not_remove(void)
{
    int16_t v;
    for (int16_t x = 1; x <= 4; x++) {
        rb_push(&rb, x);
    }
    TEST_ASSERT_EQUAL_INT(RB_OK, rb_peek_newest(&rb, &v));
    TEST_ASSERT_EQUAL_INT(4, v);
    TEST_ASSERT_EQUAL_INT(3, (int) rb_count(&rb));
}

Add [[nodiscard]] RbStatus rb_peek_newest(const RingBuf *rb, int16_t *out); to ringbuf.h and RUN_TEST(test_peek_newest_does_not_remove); to main. After four pushes into three slots, head is 1, so the newest index is (1 + 3 - 1) % 3 = 0, which holds 4. Adding + rb->capacity before subtracting keeps the unsigned arithmetic from wrapping below zero.

Quick Quiz

1. Why does this library take its storage array from the caller instead of calling malloc?

2. In a buffer of capacity 3, after pushing 1, 2, 3, 4 and 5, what does rb_get(&rb, 0, &v) give?

3. Why is the sum in rb_stats an int64_t?

4. Why does the logger exit with status 2 when an alarm occurred?

Learn C with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call