Skip to content

Part 1 · The C Language29 min read

Testing and Debugging C on the Host

Flashing a board, reproducing a bug by hand and squinting at a UART log is the slowest possible way to find a mistake in C. Most firmware logic (parsers, state machines, ring buffers, filters, protocol handling) does not need the hardware at all, and on your PC you have tools the microcontroller cannot offer: sanitizers that catch undefined behavior the moment it happens, a debugger with unlimited watchpoints, and a test run that takes a second. This lesson builds that workflow: the warning flags that act as your first test, a complete gdb session hunting down a memory corruption bug, sanitizers and Valgrind, unit tests with Unity for the ring buffer from Memory: where everything lives, and how to structure code so the hardware-dependent part is small and replaceable.

Every warning the compiler can give you is a test you did not have to write. Turn on far more than -Wall:

FlagWhat it catches
-Wallthe core set: uninitialized use, unused variables, format mismatches, missing returns, suspicious parentheses
-Wextraunused parameters, signed/unsigned comparison, missing field initializers, implicit fallthrough
-Wpedanticanything that is not ISO C, such as GNU extensions used by accident
-Wshadowa local that hides another variable with the same name
-Wconversionimplicit conversions that can change a value: uint8_t x = u32;, int to float
-Wdouble-promotiona float silently promoted to double, which is software arithmetic on the Cortex-M4F
-Wformat=2stricter printf-format checks, including non-literal format strings
-Wundef#if CONFIG_X where CONFIG_X is not defined (silently evaluates as 0)
-Wstrict-prototypesvoid f();, which in C before C23 declares a function with unknown parameters, not none
-Wmissing-prototypesa non-static function with no prototype in a header: either make it static or declare it

A few of these deserve a concrete example, because they catch real firmware bugs:

#include <stdint.h>
#define CONFIG_USE_DMA 1
uint8_t scale(uint32_t raw); /* prototype in "the header" */
uint8_t scale(uint32_t raw)
{
return raw >> 4; /* -Wconversion: may change value (uint32_t to uint8_t) */
}
float half(float x)
{
return x * 0.5; /* -Wdouble-promotion: x promoted to double */
}
#if CONFIG_USE_DAM /* -Wundef: typo, evaluates to 0 without warning */
void dma_start(void);
#endif

-Wconversion is the noisiest of the set, and the first time you enable it on existing code you will get hundreds of warnings. Each one is either a real truncation bug or a place where an explicit cast documents that the truncation is intended. On new code, keep it on from the first line.

Use -Werror in continuous integration so a new warning fails the build. Locally, many teams leave it off, because a compiler upgrade adds new warnings and you do not want that to block a debugging session. Since some warnings only appear with optimization (uninitialized-variable and array-bounds analysis need it), compile the CI warning check at the optimization level you ship.

Two kinds of assertions, for two kinds of facts.

static_assert (C11 spells it _Static_assert; assert.h provides the static_assert macro) checks a condition at compile time. It costs nothing at runtime and fails the build if a layout or configuration assumption breaks:

#include <assert.h>
#include <stdint.h>
typedef struct {
uint8_t type;
uint8_t len;
uint16_t crc;
uint32_t timestamp;
} msg_header_t;
static_assert(sizeof(msg_header_t) == 8, "wire header must be 8 bytes");
static_assert((64 & (64 - 1)) == 0, "ring buffer size must be a power of two");

assert checks a condition at runtime and stops the program if it is false. Use it for programmer errors, conditions that can only be false if the code is wrong: a null pointer passed where the API forbids it, an index beyond a table. Do not use it for conditions that can legitimately happen at runtime, like a malformed packet from the outside world. Those need error handling.

Two rules. Never put a side effect inside assert, because defining NDEBUG removes the whole expression: assert(rb_put(&rb, c)); stops putting bytes in release builds. And on the host, a failed assert gives you a file, line and core dump, which is exactly what you want in a unit test. On the target, route it to your own handler as shown in The C standard library on a microcontroller.

gdb is the same tool on the host and on the target (as arm-none-eabi-gdb talking to OpenOCD, covered in Debugging and tracing on real hardware). Learning it on the host, where everything is fast and nothing can brick, pays off directly. These are the commands you will use constantly:

CommandShortDoes
break file.c:42, break funcbstop when execution reaches a line or function
runrstart the program (restart if running)
nextnexecute one line, stepping over calls
stepsexecute one line, stepping into calls
finishfinrun until the current function returns, show the return value
continuecrun until the next breakpoint or watchpoint
print exprpevaluate and print any C expression
p/x exprprint in hex (/d decimal, /t binary, /c char)
x/16xb addrexamine memory: 16 units, hex, bytes (w for 32-bit words)
btbacktrace: the call stack
frame Nfselect stack frame N to inspect its locals
info locals, info argsall locals / arguments of the selected frame
watch exprstop when the value of expr changes
display exprprint expr automatically every time execution stops
info registersi rCPU registers

Always build for debugging with -g (debug information) and, for a first look, -O0 or -Og so that variables are not optimized away.

Here is a small program with a bug. It configures a sensor and prints its settings:

cfg.c
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef struct {
char name[8];
uint16_t sample_rate_hz;
uint8_t channel;
} sensor_cfg_t;
static void cfg_set_name(sensor_cfg_t *cfg, const char *name)
{
size_t n = strlen(name);
memcpy(cfg->name, name, n + 1u); /* copy including the NUL */
}
int main(void)
{
sensor_cfg_t cfg = { .name = "", .sample_rate_hz = 1000u, .channel = 3u };
cfg_set_name(&cfg, "temp_ext");
printf("name=%s rate=%u channel=%u\n",
cfg.name, (unsigned)cfg.sample_rate_hz, (unsigned)cfg.channel);
return 0;
}
Terminal window
gcc -std=c11 -Wall -Wextra -g -O0 cfg.c -o cfg
./cfg
name=temp_ext rate=768 channel=3

The sample rate was set to 1000 and comes out as 768. Nothing in the code writes sample_rate_hz after initialization. This is the classic firmware symptom of memory corruption, so let us find the writer.

$ gdb -q ./cfg
Reading symbols from ./cfg...
(gdb) break main
Breakpoint 1 at 0x11d1: file cfg.c, line 19.
(gdb) run
Starting program: /home/me/cfg
Breakpoint 1, main () at cfg.c:19
19 sensor_cfg_t cfg = { .name = "", .sample_rate_hz = 1000u, .channel = 3u };
(gdb) next
20 cfg_set_name(&cfg, "temp_ext");
(gdb) print cfg
$1 = {name = "\000\000\000\000\000\000\000", sample_rate_hz = 1000, channel = 3 '\003'}
(gdb) p/x cfg.sample_rate_hz
$2 = 0x3e8

break main stops at the first line of main. next executes the initialization. print shows the whole struct, and the value is correct at this point. p/x shows it in hex, 0x3e8, which will matter in a moment.

Now step into the suspect function and let it run to completion:

(gdb) step
cfg_set_name (cfg=0x7fffffffe0d4, name=0x555555556004 "temp_ext") at cfg.c:13
13 size_t n = strlen(name);
(gdb) next
14 memcpy(cfg->name, name, n + 1u); /* copy including the NUL */
(gdb) print n
$3 = 8
(gdb) finish
Run till exit from #0 cfg_set_name (cfg=0x7fffffffe0d4, name=0x555555556004 "temp_ext") at cfg.c:14
0x00005555555551f0 in main () at cfg.c:20
20 cfg_set_name(&cfg, "temp_ext");
(gdb) p/x cfg.sample_rate_hz
$4 = 0x300

step entered cfg_set_name and gdb printed its arguments. After finish the value is 0x300: the low byte 0xe8 became 0x00, the high byte survived. Something inside cfg_set_name wrote one zero byte over it. In this tiny program you may already see why, but in a real code base the write can come from anywhere, so here is the tool that finds it no matter where it is. Restart and set a watchpoint:

(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/me/cfg
Breakpoint 1, main () at cfg.c:19
19 sensor_cfg_t cfg = { .name = "", .sample_rate_hz = 1000u, .channel = 3u };
(gdb) next
20 cfg_set_name(&cfg, "temp_ext");
(gdb) watch cfg.sample_rate_hz
Hardware watchpoint 2: cfg.sample_rate_hz
(gdb) continue
Continuing.
Hardware watchpoint 2: cfg.sample_rate_hz
Old value = 1000
New value = 768
__memmove_avx_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:...
(gdb) bt
#0 __memmove_avx_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:...
#1 0x00005555555551b4 in cfg_set_name (cfg=0x7fffffffe0d4, name=0x555555556004 "temp_ext") at cfg.c:14
#2 0x00005555555551f0 in main () at cfg.c:20

The watchpoint fired inside the C library’s memcpy implementation (its internal name varies between systems). The backtrace shows who called it: cfg_set_name, line 14. Select that frame and look at the memory:

(gdb) frame 1
#1 0x00005555555551b4 in cfg_set_name (cfg=0x7fffffffe0d4, name=0x555555556004 "temp_ext") at cfg.c:14
14 memcpy(cfg->name, name, n + 1u); /* copy including the NUL */
(gdb) print n + 1
$5 = 9
(gdb) print sizeof(cfg->name)
$6 = 8
(gdb) x/12xb cfg
0x7fffffffe0d4: 0x74 0x65 0x6d 0x70 0x5f 0x65 0x78 0x74
0x7fffffffe0dc: 0x00 0x03 0x03 0x00

There it is. "temp_ext" is 8 characters, so copying it with its terminator writes 9 bytes into an 8-byte array. The memory dump makes it visible: bytes 0 to 7 are t e m p _ e x t, byte 8 is the terminating 0x00, sitting exactly where the low byte of sample_rate_hz belongs. The struct is little-endian, so 1000 = 0x03e8 is stored as e8 03; overwrite the e8 and you get 00 03, which is 0x0300 = 768. Byte 10 is channel = 3, and byte 11 is padding.

The fix is a bounds check that refuses names that do not fit:

#include <stdbool.h>
#include <string.h>
static bool cfg_set_name(sensor_cfg_t *cfg, const char *name)
{
size_t n = strlen(name);
if (n >= sizeof cfg->name) {
return false; /* would not fit with its NUL */
}
memcpy(cfg->name, name, n + 1u);
return true;
}

Three more commands round out the toolkit. display cfg.sample_rate_hz prints the expression every time execution stops, which is ideal while stepping through a loop. info registers shows the CPU state; on an x86-64 host you see rax, rsp, rip, and on a Cortex-M target the same command shows r0 to r12, sp, lr, pc and xPSR. And x/4xw $sp dumps four 32-bit words at the stack pointer, which you will use constantly when analyzing faults.

Undefined behavior, volatile, and what the optimizer assumes showed what UBSan and ASan reports look like. In a test workflow, build every host test binary with both:

Terminal window
gcc -std=c11 -g -O1 -fsanitize=address,undefined -fno-sanitize-recover=undefined \
-fno-omit-frame-pointer test_ringbuf.c ringbuf.c unity.c -o test_ringbuf

-fno-sanitize-recover=undefined turns each UBSan report into a test failure instead of a message that scrolls past. -O1 keeps the instrumented code reasonably fast while preserving accurate line numbers; -fno-omit-frame-pointer gives better stack traces. ASan slows code down roughly twofold and uses more memory, which on a PC does not matter.

Valgrind (Linux, and partly macOS) is the alternative when you cannot recompile, or when you want to catch reads of uninitialized memory, which ASan does not do. It runs the unmodified binary on a simulated CPU, 20 to 50 times slower. Here uninit.c holds the pick() function from Undefined behavior, volatile, and what the optimizer assumes, which leaves result uninitialized when its argument is false, plus a main that calls pick(false) and branches on the result:

Terminal window
gcc -std=c11 -g -O0 uninit.c -o uninit
valgrind --leak-check=full --track-origins=yes ./uninit
==5150== Conditional jump or move depends on uninitialised value(s)
==5150== at 0x109159: pick (uninit.c:7)
==5150== by 0x109189: main (uninit.c:13)
==5150== Uninitialised value was created by a stack allocation
==5150== at 0x109139: pick (uninit.c:4)
ToolCatchesMissesCost
ASanout-of-bounds on heap, stack and globals; use-after-free; double free; leaksuninitialized reads; overflows inside a structrecompile; about 2x slower
UBSansigned overflow, bad shifts, misaligned access, null deref, out-of-bounds array indexingmemory errors in generalrecompile; small slowdown
Valgrindheap errors, uninitialized reads, leaks, without recompilingmost stack and global overflows20 to 50x slower; Linux mainly

Unity is a unit test framework written in plain C for embedded developers. It is three files (unity.c, unity.h, unity_internals.h) with no dependencies, so it compiles both on your PC and, if you want, on the target. Download a release of Unity from the ThrowTheSwitch project (linked from the Resources page) and copy those three files from its src/ directory into tests/unity/.

A Unity test is a void function with no parameters that calls TEST_ASSERT_* macros. When an assertion fails, Unity records the file, line and message and abandons that test (using setjmp/longjmp), then moves on to the next. setUp runs before every test and tearDown after, so each test starts from a known state.

As a reminder, here is the ring buffer API from Memory: where everything lives. It stores bytes in caller-provided storage whose size must be a power of two, uses free-running head and tail indices, and rejects new bytes when full:

typedef struct {
uint8_t *buf;
size_t mask; /* capacity - 1 */
size_t head; /* total bytes written */
size_t tail; /* total bytes read */
} ringbuf_t;
bool rb_init(ringbuf_t *rb, uint8_t *storage, size_t capacity);
bool rb_put(ringbuf_t *rb, uint8_t byte);
bool rb_get(ringbuf_t *rb, uint8_t *out);
size_t rb_count(const ringbuf_t *rb);
bool rb_is_empty(const ringbuf_t *rb);
bool rb_is_full(const ringbuf_t *rb);
size_t rb_write(ringbuf_t *rb, const uint8_t *src, size_t len);
size_t rb_read(ringbuf_t *rb, uint8_t *dst, size_t len);

The tests: three core behaviors (empty when new, first in first out, rejects when full), plus two that guard against the subtle bugs this design invites (index wraparound and invalid capacity).

tests/test_ringbuf.c
#include <stdint.h>
#include "ringbuf.h"
#include "unity.h"
static uint8_t storage[8];
static ringbuf_t rb;
void setUp(void)
{
TEST_ASSERT_TRUE(rb_init(&rb, storage, sizeof storage));
}
void tearDown(void)
{
}
static void test_new_buffer_is_empty(void)
{
uint8_t out = 0u;
TEST_ASSERT_TRUE(rb_is_empty(&rb));
TEST_ASSERT_EQUAL_UINT(0u, rb_count(&rb));
TEST_ASSERT_FALSE(rb_get(&rb, &out));
}
static void test_bytes_come_out_in_order(void)
{
const uint8_t in[3] = { 0x11u, 0x22u, 0x33u };
uint8_t out[3] = { 0u };
TEST_ASSERT_EQUAL_UINT(3u, rb_write(&rb, in, sizeof in));
TEST_ASSERT_EQUAL_UINT(3u, rb_count(&rb));
TEST_ASSERT_EQUAL_UINT(3u, rb_read(&rb, out, sizeof out));
TEST_ASSERT_EQUAL_UINT8_ARRAY(in, out, 3);
TEST_ASSERT_TRUE(rb_is_empty(&rb));
}
static void test_put_fails_when_full(void)
{
for (uint32_t i = 0u; i < 8u; i++) {
TEST_ASSERT_TRUE(rb_put(&rb, (uint8_t)i));
}
TEST_ASSERT_TRUE(rb_is_full(&rb));
TEST_ASSERT_FALSE(rb_put(&rb, 0xFFu));
uint8_t out = 0xEEu;
TEST_ASSERT_TRUE(rb_get(&rb, &out));
TEST_ASSERT_EQUAL_UINT8(0u, out); /* oldest byte kept, newest rejected */
}
static void test_indices_wrap_around(void)
{
rb.head = SIZE_MAX - 2u; /* white-box: start just below the wrap */
rb.tail = SIZE_MAX - 2u;
for (uint32_t i = 0u; i < 6u; i++) {
TEST_ASSERT_TRUE(rb_put(&rb, (uint8_t)(0xA0u + i)));
}
TEST_ASSERT_EQUAL_UINT(6u, rb_count(&rb));
for (uint32_t i = 0u; i < 6u; i++) {
uint8_t out = 0u;
TEST_ASSERT_TRUE(rb_get(&rb, &out));
TEST_ASSERT_EQUAL_UINT8(0xA0u + i, out);
}
TEST_ASSERT_TRUE(rb_is_empty(&rb));
}
static void test_init_rejects_bad_capacity(void)
{
uint8_t odd[6];
ringbuf_t bad;
TEST_ASSERT_FALSE(rb_init(&bad, odd, sizeof odd));
TEST_ASSERT_FALSE(rb_init(&bad, odd, 0u));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_new_buffer_is_empty);
RUN_TEST(test_bytes_come_out_in_order);
RUN_TEST(test_put_fails_when_full);
RUN_TEST(test_indices_wrap_around);
RUN_TEST(test_init_rejects_bad_capacity);
return UNITY_END();
}

What each test is really checking:

  • test_new_buffer_is_empty pins down the starting state, including that rb_get on an empty buffer fails and leaves out alone instead of returning stale data.
  • test_bytes_come_out_in_order is the FIFO contract. TEST_ASSERT_EQUAL_UINT8_ARRAY compares element by element and reports the first index that differs.
  • test_put_fails_when_full checks the full condition and the overflow policy: the eighth byte fits, the ninth is rejected, and the oldest byte is still at the front. If someone “simplifies” rb_is_full to the one-slot-empty design, this test fails.
  • test_indices_wrap_around is a white-box test. It reaches into the struct to start the free-running counters just below SIZE_MAX, so that six operations cross the wrap. Without it, the wraparound arithmetic would only be exercised after 2^32 bytes on the target or 2^64 on a 64-bit host, which no test ever reaches.
  • test_init_rejects_bad_capacity guards the precondition the mask trick depends on.

Test names describe behavior, not functions: when one fails at 2 a.m., test_put_fails_when_full: FAIL already tells you what broke.

tests/Makefile
CC := gcc
WARN := -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wdouble-promotion \
-Wformat=2 -Wundef -Wstrict-prototypes -Wmissing-prototypes
SAN := -fsanitize=address,undefined -fno-sanitize-recover=undefined
CFLAGS := -std=c11 -g -O1 -fno-omit-frame-pointer $(SAN)
INCLUDES := -I../src -Iunity
BUILD := build
.PHONY: test clean
test: $(BUILD)/test_ringbuf
./$(BUILD)/test_ringbuf
$(BUILD)/test_ringbuf: $(BUILD)/test_ringbuf.o $(BUILD)/ringbuf.o $(BUILD)/unity.o
$(CC) $(CFLAGS) $^ -o $@
$(BUILD)/test_ringbuf.o: test_ringbuf.c | $(BUILD)
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
$(BUILD)/ringbuf.o: ../src/ringbuf.c | $(BUILD)
$(CC) $(CFLAGS) $(WARN) $(INCLUDES) -c $< -o $@
$(BUILD)/unity.o: unity/unity.c | $(BUILD)
$(CC) $(CFLAGS) -Iunity -c $< -o $@
$(BUILD):
mkdir -p $(BUILD)
clean:
rm -rf $(BUILD)

The code under test gets the full warning set; Unity itself and the test file, which use macros that trip some of the stricter warnings such as -Wmissing-prototypes, are compiled without it. Every object is built with the sanitizers, and the link line repeats $(CFLAGS) so the sanitizer runtime libraries are linked. | $(BUILD) is an order-only prerequisite: the directory must exist, but its timestamp does not trigger rebuilds.

Terminal window
cd tests
make test
test_ringbuf.c:80:test_new_buffer_is_empty:PASS
test_ringbuf.c:81:test_bytes_come_out_in_order:PASS
test_ringbuf.c:82:test_put_fails_when_full:PASS
test_ringbuf.c:83:test_indices_wrap_around:PASS
test_ringbuf.c:84:test_init_rejects_bad_capacity:PASS
-----------------------
5 Tests 0 Failures 0 Ignored
OK

With hand-written RUN_TEST calls, the line number in each PASS line is the line of the RUN_TEST call. Now prove the tests can fail. Break rb_is_full in ringbuf.c so it compares with > instead of ==, and run again:

test_ringbuf.c:80:test_new_buffer_is_empty:PASS
test_ringbuf.c:81:test_bytes_come_out_in_order:PASS
test_ringbuf.c:43:test_put_fails_when_full:FAIL: Expected TRUE Was FALSE
test_ringbuf.c:83:test_indices_wrap_around:PASS
test_ringbuf.c:84:test_init_rejects_bad_capacity:PASS
-----------------------
5 Tests 1 Failures 0 Ignored
FAIL

A failure reports the line of the failing assertion (here, TEST_ASSERT_TRUE(rb_is_full(&rb))). The program’s exit status is non-zero, so make and any CI system treat it as a failed build. A test you have never seen fail has not yet proven it tests anything.

Designing code that can be tested on the host

Section titled “Designing code that can be tested on the host”

The ring buffer was easy to test because it touches no hardware. Most modules are not so pure: they read a pin, write a register, wait for a timer. The design rule that makes them testable is: keep the logic separate from the hardware, and reach the hardware only through an interface you can replace.

There are two common ways to make that replacement.

Dependency injection through function pointers. The module receives a struct of function pointers (an interface) and never names a register. Here is a GPIO interface and a tiny pump controller that uses it:

gpio_if.h
#ifndef GPIO_IF_H
#define GPIO_IF_H
#include <stdbool.h>
#include <stdint.h>
typedef struct {
void (*write)(void *ctx, uint32_t pin, bool level);
bool (*read)(void *ctx, uint32_t pin);
void *ctx; /* passed back to every call */
} gpio_if_t;
#endif
pump_ctrl.c
#include "pump_ctrl.h"
#define PIN_LEVEL_LOW 3u /* input: high when the tank is nearly empty */
#define PIN_PUMP 7u /* output: high runs the pump */
void pump_ctrl_step(const gpio_if_t *gpio)
{
bool tank_low = gpio->read(gpio->ctx, PIN_LEVEL_LOW);
gpio->write(gpio->ctx, PIN_PUMP, !tank_low); /* never run the pump dry */
}

Its header needs nothing but the interface type:

pump_ctrl.h
#ifndef PUMP_CTRL_H
#define PUMP_CTRL_H
#include "gpio_if.h"
void pump_ctrl_step(const gpio_if_t *gpio);
#endif

On the target, the board provides a gpio_if_t whose functions write BSRR and read IDR. In a test, you provide a fake: a working, simplified implementation that keeps its state in memory:

tests/fake_gpio.h
#ifndef FAKE_GPIO_H
#define FAKE_GPIO_H
#include <stdbool.h>
#include <stdint.h>
#include "gpio_if.h"
#define FAKE_GPIO_PINS 16u
typedef struct {
bool level[FAKE_GPIO_PINS];
uint32_t writes;
} fake_gpio_t;
static inline void fake_gpio_write(void *ctx, uint32_t pin, bool level)
{
fake_gpio_t *f = ctx;
f->level[pin] = level;
f->writes++;
}
static inline bool fake_gpio_read(void *ctx, uint32_t pin)
{
const fake_gpio_t *f = ctx;
return f->level[pin];
}
static inline gpio_if_t fake_gpio_if(fake_gpio_t *f)
{
gpio_if_t g = { fake_gpio_write, fake_gpio_read, f };
return g;
}
#endif
tests/test_pump_ctrl.c
#include "fake_gpio.h"
#include "pump_ctrl.h"
#include "unity.h"
static fake_gpio_t fake;
static gpio_if_t gpio;
void setUp(void)
{
fake = (fake_gpio_t){ 0 };
gpio = fake_gpio_if(&fake);
}
void tearDown(void)
{
}
static void test_pump_runs_when_tank_has_water(void)
{
fake.level[3] = false; /* level switch: not low */
pump_ctrl_step(&gpio);
TEST_ASSERT_TRUE(fake.level[7]);
}
static void test_pump_stops_when_tank_low(void)
{
fake.level[7] = true; /* pump currently running */
fake.level[3] = true; /* tank goes low */
pump_ctrl_step(&gpio);
TEST_ASSERT_FALSE(fake.level[7]);
TEST_ASSERT_EQUAL_UINT32(1u, fake.writes);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_pump_runs_when_tank_has_water);
RUN_TEST(test_pump_stops_when_tank_low);
return UNITY_END();
}

The fake checks the outcome: after the step, the pump pin is low. A mock checks the interaction: it is programmed with the exact calls it expects, in order, and fails the test on any unexpected call. Mocks are the right tool when the sequence itself is the requirement, such as “assert chip select, send the command byte, then read two bytes, then release chip select”. Writing mocks by hand is tedious, which is why tools like CMock generate them from headers; Testing embedded software covers that and the fake/mock/stub distinction in depth. Here is the idea in miniature:

#include <stdbool.h>
#include <stdint.h>
#include "unity.h"
typedef struct {
uint32_t pin;
bool level;
} gpio_call_t;
static gpio_call_t expected[4];
static uint32_t n_expected;
static uint32_t n_seen;
static void mock_gpio_write(void *ctx, uint32_t pin, bool level)
{
(void)ctx;
TEST_ASSERT_TRUE_MESSAGE(n_seen < n_expected, "unexpected gpio write");
TEST_ASSERT_EQUAL_UINT32(expected[n_seen].pin, pin);
TEST_ASSERT_EQUAL(expected[n_seen].level, level);
n_seen++;
}

The test fills expected[], runs the code, then asserts n_seen == n_expected to catch calls that never happened.

Link-time substitution. Instead of passing function pointers, the module calls ordinary functions such as gpio_write(pin, level), declared in gpio.h. The target build links drivers/gpio.c, which writes registers; the test build links tests/fake_gpio.c, which defines the same functions over an array. No function pointers, no runtime cost, no change to the code under test. The drawback is that one test binary can only have one implementation of each function, so you cannot mix the real and fake versions in one program.

TechniqueRuntime costFlexibilityTypical use
function-pointer interfaceone indirect callswap per instance, at runtimedrivers with several back ends, bus abstractions
link-time substitutionnoneone implementation per binaryreplacing the HAL or register layer in tests
preprocessor (#ifdef TEST)noneugly, spreads through codeavoid

Static analyzers read the code without running it and find bugs along paths your tests never take. Two free tools are worth wiring into every project.

cppcheck is quick, has few false positives and understands embedded C reasonably well:

Terminal window
cppcheck --std=c11 --enable=warning,style,performance,portability \
--inline-suppr -Iinclude -Isrc src/
src/pick.c:10:12: error: Uninitialized variable: result [uninitvar]
return result;
^

clang-tidy runs Clang’s static analyzer plus hundreds of lint checks, selected by name patterns. A sensible starting set for C:

Terminal window
clang-tidy src/*.c -checks='-*,bugprone-*,cert-*,clang-analyzer-*,misc-*' -- -std=c11 -Iinclude
src/shell.c:31:20: warning: 'atoi' used to convert a string to an integer value, but function will not report conversion errors; consider using 'strtol' instead [cert-err34-c]
int32_t speed = atoi(arg);
^

Everything after -- is the compile command clang-tidy uses to parse the files. With a compile_commands.json from your build system, it can read the real flags for every file instead. GCC also has its own interprocedural analyzer, enabled with -fanalyzer. Treat analyzer output like warnings: fix or explicitly suppress each finding, so new ones stand out.

When the question is “what did the compiler do with this?”, Compiler Explorer (godbolt.org) answers it faster than any local setup. A workflow that works:

  1. Paste the smallest function that shows the behavior. Make its inputs parameters and its outputs return values or stores through pointers; otherwise the compiler computes the answer at compile time and you see movs r0, #42.
  2. Choose an ARM GCC compiler (the arm-none-eabi builds) and set the flags you ship with: -O2 -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16.
  3. Hover over a source line: its instructions are highlighted in the same color. Lines with no color were optimized away.
  4. Change one thing at a time: add volatile, switch -O2 to -Os, add -fno-strict-aliasing, and compare. Open a second compiler pane, or the diff view, to see two variants side by side.
  5. Use __attribute__((noinline)) on a helper to keep it as a separate function you can read, and declare external functions (void sink(uint32_t);) to give results somewhere to go so they are not deleted.

Every assembly listing in this course can be reproduced this way, and it is the quickest way to settle arguments about whether a construct is free.

  • Testing only on the target. Slow feedback means fewer tests. Fix: move logic into hardware-free modules and test them on the host first.
  • Warnings ignored because “it works”. Fix: the full warning set, zero warnings, -Werror in CI.
  • Side effects inside assert. They vanish with NDEBUG. Fix: do the work, then assert on the result.
  • Debugging at -O2 and seeing <optimized out>. Fix: -Og or -O0 for the session, or temporarily make the variable volatile.
  • A test suite that has never failed. Fix: break the code deliberately and confirm the right test fails.
  • Tests that depend on each other’s leftovers. Fix: reset all state in setUp.
  • Assuming ASan catches every overflow. It misses overflows inside a struct. Fix: watchpoints, bounds checks, and code review of every copy.
  • Hardware calls buried deep in logic. Untestable without a board. Fix: inject a GPIO or bus interface, or substitute at link time.
  1. Compile any C file you have written so far with the full warning set from this lesson. Fix every warning without disabling one. Report which flag found the most issues and whether any was a real bug.
  2. Reproduce the gdb session with cfg.c. Then change the overflow so it hits channel instead (hint: make the name longer) and use a watchpoint on cfg.channel to find it. Write down the bytes x/12xb cfg shows after the corruption and explain each one.
  3. Set up Unity and the Makefile from this lesson for the ring buffer and get all five tests passing. Then introduce three different bugs into ringbuf.c one at a time (wrong mask, head not incremented, rb_is_empty comparing head with mask) and confirm that at least one test fails for each. If a bug survives, write the missing test.
  4. Write test_pump_ctrl.c against the fake GPIO, then extend pump_ctrl with a minimum off-time: once stopped, the pump may not restart for 30 seconds (pass now_ms into pump_ctrl_step). Write the tests first, including one that crosses the 32-bit millisecond wraparound.
  5. Run cppcheck and clang-tidy over the code from lessons 13 to 16 of this part. Classify every finding as a real bug, a style issue, or a false positive, and suppress the false positives with an inline comment that explains why.

Question 1:A struct member is corrupted at runtime and you do not know which code writes it. Which gdb feature finds the writer most directly?

Question 2:Why would AddressSanitizer not report the cfg_set_name overflow in this lesson?

Question 3:assert(uart_send(buf, len) == len); passes every test. What happens in the release build compiled with -DNDEBUG?

Question 4:What does the white-box test test_indices_wrap_around protect against?

Question 5:A pump controller calls gpio_write() directly. You want to test it on the host without changing its source. Which technique fits?

  • The compiler’s warnings are free tests: build with the full set (-Wshadow -Wconversion -Wdouble-promotion -Wformat=2 -Wundef -Wstrict-prototypes -Wmissing-prototypes on top of -Wall -Wextra -Wpedantic) and -Werror in CI.
  • static_assert checks layout and configuration at compile time for free; assert checks programmer errors at runtime and must never contain side effects.
  • In gdb, break, step/next/finish, print/p/x, x/ and bt/frame cover most sessions; a watchpoint on a corrupted variable finds the writer wherever it is.
  • Build host tests with ASan and UBSan (-fno-sanitize-recover), and use Valgrind for uninitialized reads; none of them sees overflows inside a struct.
  • Unity tests are plain C functions with TEST_ASSERT_* macros, setUp/tearDown per test, and a non-zero exit code on failure; always watch each test fail once.
  • Keep logic separate from hardware and reach hardware through an injectable interface or link-time substitution, so fakes and mocks can stand in for GPIO, buses and timers.
  • cppcheck, clang-tidy, -fanalyzer and Compiler Explorer answer “is this code wrong?” and “what did the compiler do?” without touching a board.

Next: C standards, compiler extensions, and portable code, the last lesson of the C part, on which C to write and how to keep it compiling everywhere.