Project: Sensor Ring Buffer Library
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:
| Decision | Choice | Reason |
|---|---|---|
| Value type | int16_t, tenths of a degree | Fixed-point integers: no floating point needed on a small chip; range -3276.8 to 3276.7 °C |
| Storage | Array supplied by the caller | No heap: works with a static array on a microcontroller |
| When full | Overwrite the oldest value | A logger always wants the latest readings |
| Errors | Status enum plus output pointers, [[nodiscard]] | Explicit and impossible to ignore silently |
| Struct visibility | Visible in the header, not opaque | The 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:
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.txtStep 2: The public header
The header is the contract. Every function says who owns what and what each status means:
// 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);
#endifEvery 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:
// 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.
// 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:
// 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:
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:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DRINGLOG_SANITIZE=ON
cmake --build build
ctest --test-dir build --output-on-failure
./build/test_ringbufTest 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
OKYour 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:
./build/logger readings.txt
echo "exit status: $?"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.txtagrees. - 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.ccompiles witharm-none-eabi-gccfor 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;
headwraps with%, and the oldest index iscountsteps behind it. - Caller-provided storage and a visible struct make the library work without
malloc, which suits embedded targets. - Fixed-point
int16_ttenths, anint64_tsum 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:
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;
}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?
Caller-provided storage removes dynamic allocation, which many embedded projects forbid.
2. In a buffer of capacity 3, after pushing 1, 2, 3, 4 and 5, what does rb_get(&rb, 0, &v) give?
Values 1 and 2 were overwritten, so the oldest remaining value is 3.
3. Why is the sum in rb_stats an int64_t?
A wide accumulator keeps the sum exact for any realistic number of readings.
4. Why does the logger exit with status 2 when an alarm occurred?
Exit codes let other programs react to the result automatically.
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
