UART Data Logger in C

Build a ring buffer and state machine to simulate embedded UART data logging.

Introduction

30 Second Summary

Every device that reads sensor data, from fitness trackers to car dashboards, needs a way to catch incoming bytes without losing any. Two patterns make this possible: a ring buffer that stores data as it arrives, and a state machine that makes sense of it.

In this project, you will build a simulated UART data logger in C that receives sensor packets through a ring buffer and parses them with a finite state machine. These are the two most commonly tested patterns in embedded systems interviews.

What You'll Build

A simulated UART sensor data logger that demonstrates how real embedded systems receive, buffer, and parse incoming data streams using only static memory allocation.

By the end of this project, you'll have:

  • A ring buffer module using power-of-two sizing and bitmask wrapping for O(1) read and write operations.
  • A state machine parser that decodes a sensor protocol using the enum+switch pattern.
  • A complete UART data logger simulation that connects the ring buffer to the state machine, demonstrating the ISR-to-main data flow used in real firmware.
  • Secret Mission: Add bit manipulation to simulate hardware register access for enabling and disabling the UART peripheral.

Do I need any prerequisites?

You need Visual Studio with the Desktop development with C++ workload installed. This project walks you through confirming that setup and configuring your project to compile as pure C.

Set Up Your C Project in Visual Studio

Before you can write embedded C code, you need a project configured to compile pure C. Visual Studio creates C++ projects by default, so you will switch the compiler mode to treat your files as C.

In this step, you will create a Console App project, rename the default file to .c, set the compiler to C mode, and scaffold the file structure for the ring buffer and parser modules.

In this step, get ready to:
  • Create a new Console App project named UARTDataLogger.
  • Configure the project to compile as C code and verify it builds.
  • Add the ring buffer and parser source and header files.
Create a new C console project

Visual Studio's Console App template generates a C++ file by default. You will create the project, then switch it to compile as pure C.

  • In Visual Studio, click Create a new project on the start window.
  • At the top of the dialog, set Language to C++, Platform to Windows, and Project type to Console.
  • Select Console App from the filtered list, then click Next.
  • Set the Project name to UARTDataLogger.
  • Click Create.

Visual Studio generates a project with a default .cpp file. You need to rename it to .c and configure the compiler to treat it as C code.

  • In Solution Explorer, find the generated .cpp file (it may be named UARTDataLogger.cpp).
  • Right-click the file and select Rename.
  • Change the extension from .cpp to .c (for example, rename it to main.c).
  • Right-click the UARTDataLogger project name in Solution Explorer and select Properties.
  • In the left pane, expand Configuration Properties.
  • Expand C/C++, then select Advanced.
  • Find the Compile As dropdown and set it to Compile as C Code (/TC).
  • Click OK to close the Properties dialog.

Why compile as C instead of C++?

Embedded systems use pure C because it maps directly to hardware with minimal overhead. The /TC flag tells MSVC to enforce C language rules, which catches C++ features that would not compile on an embedded target.

Don't see the C/C++ section in Properties?

The C/C++ properties only appear after the project contains at least one recognized C or C++ source file. Make sure you renamed the file to .c before opening Properties. If it still does not appear, close Properties, build the project once with Build > Build Solution, then reopen Properties.

Help me find the C/C++ settings in my project Properties.

Verify the setup compiles

Now confirm that the project builds and runs as a C program.

  • Replace everything in main.c with the following code:
#include <stdio.h>

int main(void) {
    printf("UART Data Logger ready\n");
    return 0;
}

What does this code do?

This is a minimal C program that prints a startup message. #include <stdio.h> brings in the standard I/O library for printf. The int main(void) signature is the standard C entry point (as opposed to C++'s int main()).

  • Build the project by clicking Build in the menu bar, then selecting Build Solution.
  • Run the program by clicking Debug in the menu bar, then selecting Start Without Debugging (Ctrl+F5).
  • Confirm you see UART Data Logger ready printed in the console window that appears.

Seeing build errors?

Make sure you deleted all the original C++ template code before pasting. If you see an error about iostream or cout, the old code is still present. Select all text in main.c with Ctrl+A, delete it, and paste the code above.

Help me fix build errors in my C project.

Create the project file structure

The project will have two modules: a ring buffer and a parser. Each module needs a header file (.h) and an implementation file (.c).

  • In Solution Explorer, right-click the Source Files folder.
  • Select Add, then New Item.
  • Select C++ File (.cpp) from the template list.
  • In the Name field, type ring_buf.c and click Add.
  • Right-click the Header Files folder.
  • Select Add, then New Item.
  • Select Header File (.h) from the template list.
  • Name it ring_buf.h and click Add.
  • Repeat the same process to create parser.c in the Source Files folder.
  • Repeat again to create parser.h in the Header Files folder.
  • Verify your Solution Explorer now shows these files: main.c, ring_buf.h, ring_buf.c, parser.h, parser.c.

File not appearing in the right folder?

If you added a .c file but it ended up under Header Files, or a .h file under Source Files, you can drag the file to the correct folder within Solution Explorer. The folder placement is purely organizational and does not affect compilation.

Help me organize my project files in Solution Explorer.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files, then move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete main.c at this point. The other files (ring_buf.h, ring_buf.c, parser.h, parser.c) should be empty.

#include <stdio.h>

int main(void) {
    printf("UART Data Logger ready\n");
    return 0;
}

Your C development environment is ready. Next up, you will implement the ring buffer module with the power-of-two bitmask trick that embedded interviewers look for.

Implement the Ring Buffer

Your project compiles and runs. Now it is time to build the first core module: a ring buffer.

A ring buffer is a fixed-size array that wraps around from the end back to the beginning. In embedded systems, it sits between a data producer (like a UART interrupt) and a data consumer (your main loop). The producer writes bytes in, and the consumer reads them out in FIFO (first in, first out) order.

In this step, get ready to:
  • Define the ring buffer struct and constants in a header file.
  • Implement the ring buffer functions using bitmask wrapping.
  • Test the ring buffer by writing and reading bytes in FIFO order.
Define the ring buffer structure in ring_buf.h

Before writing any logic, you need to define the data structure and its constants. The ring buffer uses a power-of-two size so that index wrapping can be done with a fast bitmask instead of a slow modulo operation.

Why power-of-two sizing?

On microcontrollers without a hardware divider (like the Cortex-M0), a modulo operation can be 10-20x slower than a single AND instruction. By making the buffer size a power of 2 (like 64), you can wrap indices with (index + 1) & MASK instead of (index + 1) % SIZE. This is the pattern interviewers expect to see.

  • In Solution Explorer, double-click ring_buf.h to open it.
  • Replace any existing content with the following header definition:
#ifndef RING_BUF_H
#define RING_BUF_H

#include <stdint.h>

/* Buffer size must be a power of 2 for bitmask optimization */
#define RING_BUF_SIZE 64
#define RING_BUF_MASK (RING_BUF_SIZE - 1)

typedef struct {
    uint8_t buf[RING_BUF_SIZE];  /* Static byte array - no heap allocation */
    volatile uint16_t head;      /* Write index (producer/ISR side) */
    volatile uint16_t tail;      /* Read index (consumer/main loop side) */
} ring_buf_t;

/* Function prototypes */
void ring_buf_init(ring_buf_t *rb);
int ring_buf_put(ring_buf_t *rb, uint8_t byte);
int ring_buf_get(ring_buf_t *rb, uint8_t *byte);
int ring_buf_is_empty(ring_buf_t *rb);
int ring_buf_is_full(ring_buf_t *rb);

#endif

What does this code do?

RING_BUF_SIZE sets the buffer to 64 bytes. RING_BUF_MASK is 63 (binary 00111111), which lets you wrap indices with a single AND operation instead of modulo.

The volatile keyword on head and tail tells the compiler not to cache these values in a register. In a real embedded system, an ISR modifies head while the main loop reads it. Without volatile, the compiler might skip re-reading the value and your buffer would break.

The #ifndef / #define / #endif pattern is called a header guard. It prevents the file from being included twice during compilation.

  • Save ring_buf.h.

You will not see a visible change yet. The next file implements the functions you just declared.

✔️ Awesome, I've got everything!

Great. Double check you have saved ring_buf.h before moving on.

ⓧ I'd like to double check the full code

#ifndef RING_BUF_H
#define RING_BUF_H

#include <stdint.h>

#define RING_BUF_SIZE 64
#define RING_BUF_MASK (RING_BUF_SIZE - 1)

typedef struct {
    uint8_t buf[RING_BUF_SIZE];
    volatile uint16_t head;
    volatile uint16_t tail;
} ring_buf_t;

void ring_buf_init(ring_buf_t *rb);
int ring_buf_put(ring_buf_t *rb, uint8_t byte);
int ring_buf_get(ring_buf_t *rb, uint8_t *byte);
int ring_buf_is_empty(ring_buf_t *rb);
int ring_buf_is_full(ring_buf_t *rb);

#endif
Implement the ring buffer functions in ring_buf.c

With the structure defined, you can now write the logic. Each function uses the bitmask to wrap indices without ever calling modulo.

  • In Solution Explorer, double-click ring_buf.c to open it.
  • Add the following implementation code:
#include "ring_buf.h"

/* Reset both indices to zero */
void ring_buf_init(ring_buf_t *rb) {
    rb->head = 0;
    rb->tail = 0;
}

/* Buffer is empty when head and tail point to the same slot */
int ring_buf_is_empty(ring_buf_t *rb) {
    return (rb->head == rb->tail);
}

/* Buffer is full when advancing head would collide with tail */
int ring_buf_is_full(ring_buf_t *rb) {
    return (((rb->head + 1) & RING_BUF_MASK) == rb->tail);
}

/* Write one byte at head, advance head with bitmask wrap */
int ring_buf_put(ring_buf_t *rb, uint8_t byte) {
    if (ring_buf_is_full(rb)) {
        return -1;
    }
    rb->buf[rb->head] = byte;
    rb->head = (rb->head + 1) & RING_BUF_MASK;
    return 0;
}

/* Read one byte from tail, advance tail with bitmask wrap */
int ring_buf_get(ring_buf_t *rb, uint8_t *byte) {
    if (ring_buf_is_empty(rb)) {
        return -1;
    }
    *byte = rb->buf[rb->tail];
    rb->tail = (rb->tail + 1) & RING_BUF_MASK;
    return 0;
}

What does this code do?

ring_buf_init resets both indices. When head equals tail, the buffer is empty. When advancing head by one would land on tail, the buffer is full. This means one slot is always unused as a sentinel. That is the standard trade-off for avoiding a separate count variable.

ring_buf_put writes a byte at the current head position, then advances head. ring_buf_get reads a byte from the current tail position, then advances tail. Both use & RING_BUF_MASK to wrap from index 63 back to 0 without modulo.

The return values 0 (success) and -1 (failure) let the caller detect overflow or underflow conditions.

  • Save ring_buf.c.
  • Build the project with Build > Build Solution (Ctrl+Shift+B).
  • Confirm you see Build: 1 succeeded, 0 failed in the Output window at the bottom.

Seeing build errors?

Check that ring_buf.h is saved. The .c file includes the header, so any typo in ring_buf.h will surface as a build error in ring_buf.c.

If you see an error about uint8_t or uint16_t being undefined, make sure #include <stdint.h> is present in ring_buf.h.

help me fix this ring buffer build error

✔️ Awesome, I've got everything!

Great. Double check you have saved ring_buf.c before moving on.

ⓧ I'd like to double check the full code

#include "ring_buf.h"

void ring_buf_init(ring_buf_t *rb) {
    rb->head = 0;
    rb->tail = 0;
}

int ring_buf_is_empty(ring_buf_t *rb) {
    return (rb->head == rb->tail);
}

int ring_buf_is_full(ring_buf_t *rb) {
    return (((rb->head + 1) & RING_BUF_MASK) == rb->tail);
}

int ring_buf_put(ring_buf_t *rb, uint8_t byte) {
    if (ring_buf_is_full(rb)) {
        return -1;
    }
    rb->buf[rb->head] = byte;
    rb->head = (rb->head + 1) & RING_BUF_MASK;
    return 0;
}

int ring_buf_get(ring_buf_t *rb, uint8_t *byte) {
    if (ring_buf_is_empty(rb)) {
        return -1;
    }
    *byte = rb->buf[rb->tail];
    rb->tail = (rb->tail + 1) & RING_BUF_MASK;
    return 0;
}
Test the ring buffer in main.c

The ring buffer module compiles. Now you need to prove it actually works by writing bytes in and reading them back out in the correct order.

  • Switch back to main.c in the editor.
  • Replace the entire file contents with the following test code:
#include <stdio.h>
#include "ring_buf.h"

int main(void) {
    ring_buf_t rb;
    ring_buf_init(&rb);

    /* Write 5 test bytes into the ring buffer */
    uint8_t test_bytes[] = {0x41, 0x42, 0x43, 0x44, 0x45};
    for (int i = 0; i < 5; i++) {
        ring_buf_put(&rb, test_bytes[i]);
    }

    /* Read bytes back out - should come out in FIFO order */
    uint8_t out;
    while (ring_buf_get(&rb, &out) == 0) {
        printf("Got byte: 0x%02X\n", out);
    }

    return 0;
}

What does this code do?

The test creates a ring buffer on the stack (no heap allocation), then pushes 5 bytes: 0x41 through 0x45 (the ASCII codes for A through E).

The while loop pulls bytes back out one at a time until ring_buf_get returns -1 (buffer empty). If the ring buffer is working correctly, the bytes will print in the same order they went in.

  • Save main.c.
  • Build with Build > Build Solution (Ctrl+Shift+B).
  • Run with Debug > Start Without Debugging (Ctrl+F5).
  • Confirm the console prints all 5 bytes in FIFO order:
Got byte: 0x41
Got byte: 0x42
Got byte: 0x43
Got byte: 0x44
Got byte: 0x45

Not seeing the expected output?

If you see 0 bytes printed, check that ring_buf_put is advancing head after writing. Without that line, ring_buf_get thinks the buffer is empty because head still equals tail.

If bytes print in the wrong order, verify that ring_buf_get reads from rb->tail (not rb->head). The tail is the read side. The head is the write side.

help me debug my ring buffer output

✔️ Awesome, I've got everything!

Great. Double check you have saved main.c before moving on.

ⓧ I'd like to double check the full code

#include <stdio.h>
#include "ring_buf.h"

int main(void) {
    ring_buf_t rb;
    ring_buf_init(&rb);

    uint8_t test_bytes[] = {0x41, 0x42, 0x43, 0x44, 0x45};
    for (int i = 0; i < 5; i++) {
        ring_buf_put(&rb, test_bytes[i]);
    }

    uint8_t out;
    while (ring_buf_get(&rb, &out) == 0) {
        printf("Got byte: 0x%02X\n", out);
    }

    return 0;
}

Your ring buffer is proven and working. Next up, you will build a state machine that knows how to parse sensor data out of raw bytes.

Implement the State Machine Parser

Your ring buffer handles the producer-consumer data flow. Now you need something to make sense of the bytes coming out of it.

Real sensor protocols send data as streams of bytes that follow a specific format. A finite state machine reads one byte at a time and decides what to do based on its current state. In this step, you will build a parser that recognizes sensor packets in the format $T25.5\n and extracts the sensor type and value.

In this step, get ready to:
  • Define the parser interface with enums and structs in parser.h.
  • Implement the state machine logic using the enum+switch pattern in parser.c.
  • Test the parser by feeding it a sensor packet one byte at a time.
Define the parser interface in parser.h

A state machine needs to track where it is in the parsing process. You will define this with an enum for the possible states and structs to hold the parsed data.

  • In Solution Explorer, double-click parser.h to open it.
  • Replace the empty file with this code:
#ifndef PARSER_H
#define PARSER_H

#include <stdint.h>

/* Parser states - tracks where we are in the packet */
typedef enum {
    STATE_IDLE,
    STATE_TYPE,
    STATE_DATA,
    STATE_COMPLETE
} parser_state_t;

/* Sensor types our protocol supports */
typedef enum {
    SENSOR_TEMP,
    SENSOR_HUMIDITY,
    SENSOR_UNKNOWN
} sensor_type_t;

/* Holds one parsed sensor reading */
typedef struct {
    sensor_type_t type;
    char data[16];
    uint8_t data_len;
} sensor_reading_t;

/* The parser itself - state plus current work-in-progress reading */
typedef struct {
    parser_state_t state;
    sensor_reading_t current_reading;
} parser_t;

void parser_init(parser_t *p);
void parser_feed_byte(parser_t *p, uint8_t byte);
int parser_has_reading(parser_t *p);
void parser_get_reading(parser_t *p, sensor_reading_t *reading);

#endif

What does this code do?

This header defines four things the state machine needs:

  • parser_state_t enum lists the four states the parser can be in. STATE_IDLE means waiting for a new packet. STATE_COMPLETE means a full reading is ready.
  • sensor_type_t enum identifies whether a reading is temperature, humidity, or unknown.
  • sensor_reading_t struct holds a parsed reading. The data[16] array stores the value string with no heap allocation needed.
  • parser_t struct bundles the current state with the reading being built. This is the parser instance.
  • Save parser.h.

You will not see a change yet. The implementation in parser.c comes next.

✔️ Awesome, I've got everything!

Great. Double check you have saved parser.h before moving on.

ⓧ I'd like to double check the full code

#ifndef PARSER_H
#define PARSER_H

#include <stdint.h>

typedef enum {
    STATE_IDLE,
    STATE_TYPE,
    STATE_DATA,
    STATE_COMPLETE
} parser_state_t;

typedef enum {
    SENSOR_TEMP,
    SENSOR_HUMIDITY,
    SENSOR_UNKNOWN
} sensor_type_t;

typedef struct {
    sensor_type_t type;
    char data[16];
    uint8_t data_len;
} sensor_reading_t;

typedef struct {
    parser_state_t state;
    sensor_reading_t current_reading;
} parser_t;

void parser_init(parser_t *p);
void parser_feed_byte(parser_t *p, uint8_t byte);
int parser_has_reading(parser_t *p);
void parser_get_reading(parser_t *p, sensor_reading_t *reading);

#endif
Implement the state machine in parser.c

The enum+switch pattern is the most common way to implement a state machine in C. A switch statement branches on the current state, and each case decides what to do with the incoming byte.

  • In Solution Explorer, double-click parser.c to open it.
  • Add the initialization and utility functions by pasting this code:
#include "parser.h"
#include <string.h>

/* Reset the parser to its initial state */
void parser_init(parser_t *p) {
    p->state = STATE_IDLE;
    p->current_reading.type = SENSOR_UNKNOWN;
    p->current_reading.data_len = 0;
    memset(p->current_reading.data, 0, sizeof(p->current_reading.data));
}

/* Check if a complete reading is available */
int parser_has_reading(parser_t *p) {
    return (p->state == STATE_COMPLETE);
}

/* Copy the completed reading out and reset the parser */
void parser_get_reading(parser_t *p, sensor_reading_t *reading) {
    reading->type = p->current_reading.type;
    reading->data_len = p->current_reading.data_len;
    memcpy(reading->data, p->current_reading.data, sizeof(reading->data));
    p->state = STATE_IDLE;
    p->current_reading.data_len = 0;
}

What does this code do?

  • parser_init zeroes everything out so the parser starts in STATE_IDLE. The memset clears the data buffer.
  • parser_has_reading is a one-liner that checks whether the parser has reached STATE_COMPLETE.
  • parser_get_reading copies the finished reading into the caller's struct, then resets the parser back to STATE_IDLE so it can parse the next packet.
  • Now add the core state machine function below parser_get_reading. Paste this code at the bottom of parser.c:
/* The core state machine - processes one byte at a time */
void parser_feed_byte(parser_t *p, uint8_t byte) {
    switch (p->state) {
    case STATE_IDLE:
        if (byte == '$') {
            p->state = STATE_TYPE;
            p->current_reading.data_len = 0;
            memset(p->current_reading.data, 0, sizeof(p->current_reading.data));
        }
        break;

    case STATE_TYPE:
        if (byte == 'T') {
            p->current_reading.type = SENSOR_TEMP;
            p->state = STATE_DATA;
        } else if (byte == 'H') {
            p->current_reading.type = SENSOR_HUMIDITY;
            p->state = STATE_DATA;
        } else {
            p->current_reading.type = SENSOR_UNKNOWN;
            p->state = STATE_IDLE;
        }
        break;

    case STATE_DATA:
        if (byte == '\n') {
            p->current_reading.data[p->current_reading.data_len] = '\0';
            p->state = STATE_COMPLETE;
        } else if (p->current_reading.data_len < 15) {
            p->current_reading.data[p->current_reading.data_len] = (char)byte;
            p->current_reading.data_len++;
        }
        break;

    case STATE_COMPLETE:
        break;
    }
}

How does the state machine work?

Each case handles one state. The byte decides what happens next:

  • STATE_IDLE: Waits for $ (the start-of-packet marker). Any other byte is ignored.
  • STATE_TYPE: Reads the next byte to determine if this is a temperature (T) or humidity (H) reading. An unrecognized byte resets to IDLE.
  • STATE_DATA: Collects value characters until it hits \n (end of packet). The bounds check (< 15) prevents buffer overflow.
  • STATE_COMPLETE: Does nothing. The parser waits here until the caller retrieves the reading with parser_get_reading.

Why enum+switch instead of if/else chains?

The enum+switch pattern is the standard way to implement state machines in embedded C. The enum gives each state a name (making your code self-documenting), and the switch lets you add new states without rewriting existing logic.

This is the pattern interviewers expect to see on a whiteboard. It is also the most debugger-friendly approach because you can inspect p->state at any breakpoint and immediately know where the parser is.

  • Save parser.c.
  • Build the project with Build > Build Solution (Ctrl+Shift+B).
  • Confirm the Output window shows no errors. You may see a warning about unused functions. That is fine.

Seeing build errors?

  • Check that parser.c includes parser.h with quotes (#include "parser.h"), not angle brackets.
  • Verify that parser.h has the closing #endif at the bottom. A missing endif causes cascading errors.
  • Make sure the project is still set to Compile as C Code (/TC) in the project properties. C++ mode rejects some C-style casts.

Help me fix build errors in my parser.c state machine code.

✔️ Awesome, I've got everything!

Great. Make sure parser.c is saved before moving on to the test.

ⓧ I'd like to double check the full code

#include "parser.h"
#include <string.h>

void parser_init(parser_t *p) {
    p->state = STATE_IDLE;
    p->current_reading.type = SENSOR_UNKNOWN;
    p->current_reading.data_len = 0;
    memset(p->current_reading.data, 0, sizeof(p->current_reading.data));
}

void parser_feed_byte(parser_t *p, uint8_t byte) {
    switch (p->state) {
    case STATE_IDLE:
        if (byte == '$') {
            p->state = STATE_TYPE;
            p->current_reading.data_len = 0;
            memset(p->current_reading.data, 0, sizeof(p->current_reading.data));
        }
        break;

    case STATE_TYPE:
        if (byte == 'T') {
            p->current_reading.type = SENSOR_TEMP;
            p->state = STATE_DATA;
        } else if (byte == 'H') {
            p->current_reading.type = SENSOR_HUMIDITY;
            p->state = STATE_DATA;
        } else {
            p->current_reading.type = SENSOR_UNKNOWN;
            p->state = STATE_IDLE;
        }
        break;

    case STATE_DATA:
        if (byte == '\n') {
            p->current_reading.data[p->current_reading.data_len] = '\0';
            p->state = STATE_COMPLETE;
        } else if (p->current_reading.data_len < 15) {
            p->current_reading.data[p->current_reading.data_len] = (char)byte;
            p->current_reading.data_len++;
        }
        break;

    case STATE_COMPLETE:
        break;
    }
}

int parser_has_reading(parser_t *p) {
    return (p->state == STATE_COMPLETE);
}

void parser_get_reading(parser_t *p, sensor_reading_t *reading) {
    reading->type = p->current_reading.type;
    reading->data_len = p->current_reading.data_len;
    memcpy(reading->data, p->current_reading.data, sizeof(reading->data));
    p->state = STATE_IDLE;
    p->current_reading.data_len = 0;
}
Test the parser in main.c

Time to prove the state machine works. You will feed it the string $T25.5\n one byte at a time and confirm it extracts the temperature value.

  • Switch back to main.c in the editor.
  • Select all the existing code and replace it with:
#include <stdio.h>
#include <string.h>
#include "ring_buf.h"
#include "parser.h"

int main(void) {
    /* Create and initialize the parser */
    parser_t sensor_parser;
    parser_init(&sensor_parser);

    /* Simulate receiving a temperature packet one byte at a time */
    const char *test = "$T25.5\n";
    for (int i = 0; test[i] != '\0'; i++) {
        parser_feed_byte(&sensor_parser, (uint8_t)test[i]);
        if (parser_has_reading(&sensor_parser)) {
            sensor_reading_t reading;
            parser_get_reading(&sensor_parser, &reading);
            if (reading.type == SENSOR_TEMP) {
                printf("Temperature: %s\n", reading.data);
            }
        }
    }

    return 0;
}

What does this test do?

  • The for loop walks through the test string character by character, feeding each byte to the parser.
  • After every byte, it checks parser_has_reading. Most bytes return false because the packet is still incomplete.
  • When the parser hits \n (the 7th byte), it transitions to STATE_COMPLETE and the reading becomes available.
  • parser_get_reading copies the result out. The reading.data field now contains 25.5 as a null-terminated string.
  • Save main.c.
  • Build the project with Build > Build Solution (Ctrl+Shift+B).
  • Run with Debug > Start Without Debugging (Ctrl+F5).
  • Confirm the console window prints Temperature: 25.5.

Not seeing the expected output?

  • If you see no output at all, check that the test string includes \n at the end. Without it, the parser never reaches STATE_COMPLETE.
  • If the program crashes, verify that parser_init is called before parser_feed_byte. Uninitialized state causes undefined behavior.
  • If you get linker errors about unresolved symbols, confirm that both parser.c and ring_buf.c are listed under Source Files in Solution Explorer.

Help me debug why my parser test is not printing the expected output.

✔️ Awesome, I've got everything!

Great. Make sure all three files (parser.h, parser.c, main.c) are saved.

ⓧ I'd like to double check the full code

#include <stdio.h>
#include <string.h>
#include "ring_buf.h"
#include "parser.h"

int main(void) {
    parser_t sensor_parser;
    parser_init(&sensor_parser);

    const char *test = "$T25.5\n";
    for (int i = 0; test[i] != '\0'; i++) {
        parser_feed_byte(&sensor_parser, (uint8_t)test[i]);
        if (parser_has_reading(&sensor_parser)) {
            sensor_reading_t reading;
            parser_get_reading(&sensor_parser, &reading);
            if (reading.type == SENSOR_TEMP) {
                printf("Temperature: %s\n", reading.data);
            }
        }
    }

    return 0;
}

Your state machine correctly parses a sensor packet from raw bytes. Next up, you will connect the ring buffer to the parser and simulate a full UART data flow with multiple sensor readings.

Connect the Ring Buffer to the State Machine

Your ring buffer and state machine both work in isolation. In a real embedded system, these two pieces talk to each other constantly: an ISR (interrupt service routine) stuffs incoming bytes into the ring buffer, and the main loop pulls them out to feed the parser.

In this step, you will simulate that exact data flow. You will write a function that mimics the ISR pushing UART bytes into the ring buffer, and a processing loop that drains the buffer and feeds bytes to the state machine parser.

In this step, get ready to:
  • Create a simulated UART receive function that pushes bytes into the ring buffer.
  • Create a processing loop that pulls bytes and feeds them to the parser.
  • Wire everything together and run the full simulation.
Create a simulated UART receive function

In a real microcontroller, a UART interrupt fires every time a byte arrives on the serial line. The ISR has one job: push that byte into the ring buffer and get out fast.

You will simulate this behavior with a function that takes a string of bytes and pushes them one at a time into the ring buffer.

  • In main.c, select everything below the #include lines and delete it.
  • Replace it with the following simulate_uart_rx function:
void simulate_uart_rx(ring_buf_t *rb, const char *data) {
    /* Count how many bytes we successfully buffer */
    int count = 0;
    while (*data) {
        if (ring_buf_put(rb, (uint8_t)*data) == 0) {
            count++;
        } else {
            /* Buffer is full - warn about the dropped byte */
            printf("[WARN] Ring buffer full, dropped byte: 0x%02X\n", (uint8_t)*data);
        }
        data++;
    }
    printf("[UART RX] Received %d bytes into ring buffer\n", count);
}

What does this code do?

This function simulates what an ISR does in real firmware. It walks through each byte in the input string and pushes it into the ring buffer using ring_buf_put.

If the buffer is full, the byte is dropped and a warning prints. In real hardware, you would increment an overflow counter or set an error flag. The function also prints how many bytes were successfully received.

  • Save main.c.

You will not see a change yet. The next chunk adds the processing function that consumes bytes from the buffer.

Create the main processing loop

The main loop in an embedded system continually checks whether the ring buffer has data. When it does, the loop pulls bytes out one at a time and feeds them to the parser.

  • In main.c, add the following process_data function below simulate_uart_rx:
void process_data(ring_buf_t *rb, parser_t *p) {
    uint8_t byte;
    int bytes_processed = 0;

    /* Pull bytes from the ring buffer until it is empty */
    while (ring_buf_get(rb, &byte) == 0) {
        bytes_processed++;
        parser_feed_byte(p, byte);

        /* Check if the parser has completed a full reading */
        if (parser_has_reading(p)) {
            sensor_reading_t reading;
            parser_get_reading(p, &reading);

            switch (reading.type) {
            case SENSOR_TEMP:
                printf("  [SENSOR] Temperature: %s C\n", reading.data);
                break;
            case SENSOR_HUMIDITY:
                printf("  [SENSOR] Humidity: %s %%\n", reading.data);
                break;
            default:
                printf("  [SENSOR] Unknown type\n");
                break;
            }
        }
    }
    printf("[MAIN] Processed %d bytes from ring buffer\n", bytes_processed);
}

What does this code do?

This function is the consumer side of the producer-consumer pattern. It calls ring_buf_get in a loop until the buffer is empty. Each byte is fed to the parser.

When parser_has_reading returns true, a complete sensor packet has been decoded. The switch statement routes the reading to the correct print format based on whether it is temperature or humidity.

Why separate simulate_uart_rx and process_data?

In real firmware, the ISR and the main loop run at different times. The ISR fires unpredictably whenever a byte arrives. The main loop processes data at its own pace. The ring buffer decouples these two timing domains.

By keeping the producer and consumer as separate functions, you mirror this decoupled design. If you connected the UART directly to the parser with no buffer, you would lose bytes whenever the parser is busy.

  • Save main.c.

You will not see a change yet. The next chunk adds the main function that ties everything together.

Wire everything together in main

Now you will write the main function that initializes both modules, simulates two batches of incoming sensor data, and processes each batch.

  • Below process_data in main.c, add the following main function:
int main(void) {
    ring_buf_t rx_buffer;
    parser_t sensor_parser;

    printf("=== UART Data Logger ===\n\n");

    /* Initialize both the ring buffer and the parser */
    ring_buf_init(&rx_buffer);
    parser_init(&sensor_parser);

    /* First batch: three sensor packets */
    printf("--- Simulating UART reception ---\n");
    simulate_uart_rx(&rx_buffer, "$T25.5\n$H60.2\n$T31.0\n");

    printf("\n--- Processing buffered data ---\n");
    process_data(&rx_buffer, &sensor_parser);

    /* Second batch: two more sensor packets */
    printf("\n--- Simulating more data ---\n");
    simulate_uart_rx(&rx_buffer, "$H45.8\n$T19.3\n");

    printf("\n--- Processing second batch ---\n");
    process_data(&rx_buffer, &sensor_parser);

    printf("\n=== Logger complete ===\n");
    return 0;
}

What does this code do?

  • ring_buf_init(&rx_buffer) and parser_init(&sensor_parser) zero out both structures so they start in a clean state.
  • The first simulate_uart_rx call pushes three complete sensor packets into the buffer: $T25.5\n, $H60.2\n, and $T31.0\n.
  • process_data drains the buffer and prints each parsed reading.
  • A second batch demonstrates that the system handles multiple receive/process cycles cleanly.
  • Save main.c.
  • Build the project by clicking Build > Build Solution.
  • Run with Debug > Start Without Debugging (Ctrl+F5).

You should see five parsed sensor readings printed in the console: Temperature 25.5, Humidity 60.2, Temperature 31.0, Humidity 45.8, and Temperature 19.3.

Not seeing all five readings?

  • Check that you deleted ALL of the old main function from the previous step. Leftover code will cause duplicate symbol errors at build time.
  • Make sure each function is outside the others. If simulate_uart_rx or process_data ended up inside main, you will get compiler errors about nested functions.
  • Verify the escape sequences in the sensor strings. Each packet ends with \n (backslash-n), which is the newline character the parser uses to detect the end of a reading.

Why is my UART data logger not printing all five sensor readings?

✔️ Awesome, I've got everything!

Great. Double check you have saved main.c and the build completed without errors.

ⓧ I'd like to double check the full code

Here is the complete main.c file. Compare yours against it:

#include <stdio.h>
#include <string.h>
#include "ring_buf.h"
#include "parser.h"

void simulate_uart_rx(ring_buf_t *rb, const char *data) {
    int count = 0;
    while (*data) {
        if (ring_buf_put(rb, (uint8_t)*data) == 0) {
            count++;
        } else {
            printf("[WARN] Ring buffer full, dropped byte: 0x%02X\n", (uint8_t)*data);
        }
        data++;
    }
    printf("[UART RX] Received %d bytes into ring buffer\n", count);
}

void process_data(ring_buf_t *rb, parser_t *p) {
    uint8_t byte;
    int bytes_processed = 0;

    while (ring_buf_get(rb, &byte) == 0) {
        bytes_processed++;
        parser_feed_byte(p, byte);

        if (parser_has_reading(p)) {
            sensor_reading_t reading;
            parser_get_reading(p, &reading);

            switch (reading.type) {
            case SENSOR_TEMP:
                printf("  [SENSOR] Temperature: %s C\n", reading.data);
                break;
            case SENSOR_HUMIDITY:
                printf("  [SENSOR] Humidity: %s %%\n", reading.data);
                break;
            default:
                printf("  [SENSOR] Unknown type\n");
                break;
            }
        }
    }
    printf("[MAIN] Processed %d bytes from ring buffer\n", bytes_processed);
}

int main(void) {
    ring_buf_t rx_buffer;
    parser_t sensor_parser;

    printf("=== UART Data Logger ===\n\n");

    ring_buf_init(&rx_buffer);
    parser_init(&sensor_parser);

    printf("--- Simulating UART reception ---\n");
    simulate_uart_rx(&rx_buffer, "$T25.5\n$H60.2\n$T31.0\n");

    printf("\n--- Processing buffered data ---\n");
    process_data(&rx_buffer, &sensor_parser);

    printf("\n--- Simulating more data ---\n");
    simulate_uart_rx(&rx_buffer, "$H45.8\n$T19.3\n");

    printf("\n--- Processing second batch ---\n");
    process_data(&rx_buffer, &sensor_parser);

    printf("\n=== Logger complete ===\n");
    return 0;
}

Secret mission

Add Simulated Hardware Register Control

In real embedded systems, you don't just call functions to turn peripherals on and off. You flip individual bits inside hardware registers. This mission introduces the bit manipulation patterns used for hardware register access on real microcontrollers.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future practice or remove them from your machine.

Resources you used:

  • UARTDataLogger Visual Studio project (local files: main.c, ring_buf.h, ring_buf.c, parser.h, parser.c).

Keep everything running

No action needed. Choose this if you plan to keep practicing with the ring buffer and state machine code for embedded interview prep.

  • Your project stays in Visual Studio's solution folder, ready to open any time.
  • You can extend the project with new sensor types (pressure, light) or more complex packet formats.
  • Try implementing the ring buffer and state machine from memory as interview practice.

Pause - I'll come back to this later

Close Visual Studio to free up memory. Your project files remain on disk and you can reopen the solution at any time.

  • Close Visual Studio.
  • To return later, open Visual Studio and select the UARTDataLogger solution from your recent projects list.

Delete - I don't want to use this again

Remove the project folder entirely from your machine.

  • Close Visual Studio if it is still open.
  • Open File Explorer and navigate to your Visual Studio projects folder (typically C:\Users\<your-username>\source\repos\).
  • Delete the UARTDataLogger folder.
  • Right-click the folder and select Delete, then confirm.

Nice Work!

Nice Work!

Nice work! You just built a simulated UART data logger in C that demonstrates the two most commonly tested patterns in embedded systems interviews.

You've learned how to:

  • Build a ring buffer from scratch using power-of-two sizing and bitmask wrapping for O(1) operations.
  • Implement a finite state machine using the enum+switch pattern to parse a sensor protocol.
  • Connect the ring buffer to the state machine to simulate the ISR-to-main data flow pattern used in real embedded systems.
  • Secret Mission: Implemented bit manipulation for simulated hardware register control (set, clear, and read bits).

Ready to quiz yourself?