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.
Warnings are the first test
Section titled “Warnings are the first test”Every warning the compiler can give you is a test you did not have to write. Turn on far more than -Wall:
| Flag | What it catches |
|---|---|
-Wall | the core set: uninitialized use, unused variables, format mismatches, missing returns, suspicious parentheses |
-Wextra | unused parameters, signed/unsigned comparison, missing field initializers, implicit fallthrough |
-Wpedantic | anything that is not ISO C, such as GNU extensions used by accident |
-Wshadow | a local that hides another variable with the same name |
-Wconversion | implicit conversions that can change a value: uint8_t x = u32;, int to float |
-Wdouble-promotion | a float silently promoted to double, which is software arithmetic on the Cortex-M4F |
-Wformat=2 | stricter printf-format checks, including non-literal format strings |
-Wundef | #if CONFIG_X where CONFIG_X is not defined (silently evaluates as 0) |
-Wstrict-prototypes | void f();, which in C before C23 declares a function with unknown parameters, not none |
-Wmissing-prototypes | a 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.
assert and static_assert
Section titled “assert and static_assert”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 essentials
Section titled “gdb essentials”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:
| Command | Short | Does |
|---|---|---|
break file.c:42, break func | b | stop when execution reaches a line or function |
run | r | start the program (restart if running) |
next | n | execute one line, stepping over calls |
step | s | execute one line, stepping into calls |
finish | fin | run until the current function returns, show the return value |
continue | c | run until the next breakpoint or watchpoint |
print expr | p | evaluate and print any C expression |
p/x expr | print in hex (/d decimal, /t binary, /c char) | |
x/16xb addr | examine memory: 16 units, hex, bytes (w for 32-bit words) | |
bt | backtrace: the call stack | |
frame N | f | select stack frame N to inspect its locals |
info locals, info args | all locals / arguments of the selected frame | |
watch expr | stop when the value of expr changes | |
display expr | print expr automatically every time execution stops | |
info registers | i r | CPU registers |
Always build for debugging with -g (debug information) and, for a first look, -O0 or -Og so that variables are not optimized away.
A worked gdb session
Section titled “A worked gdb session”Here is a small program with a bug. It configures a sensor and prints its settings:
#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;}gcc -std=c11 -Wall -Wextra -g -O0 cfg.c -o cfg./cfgname=temp_ext rate=768 channel=3The 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 ./cfgReading symbols from ./cfg...(gdb) break mainBreakpoint 1 at 0x11d1: file cfg.c, line 19.(gdb) runStarting program: /home/me/cfg
Breakpoint 1, main () at cfg.c:1919 sensor_cfg_t cfg = { .name = "", .sample_rate_hz = 1000u, .channel = 3u };(gdb) next20 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 = 0x3e8break 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) stepcfg_set_name (cfg=0x7fffffffe0d4, name=0x555555556004 "temp_ext") at cfg.c:1313 size_t n = strlen(name);(gdb) next14 memcpy(cfg->name, name, n + 1u); /* copy including the NUL */(gdb) print n$3 = 8(gdb) finishRun till exit from #0 cfg_set_name (cfg=0x7fffffffe0d4, name=0x555555556004 "temp_ext") at cfg.c:140x00005555555551f0 in main () at cfg.c:2020 cfg_set_name(&cfg, "temp_ext");(gdb) p/x cfg.sample_rate_hz$4 = 0x300step 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) runThe program being debugged has been started already.Start it from the beginning? (y or n) yStarting program: /home/me/cfg
Breakpoint 1, main () at cfg.c:1919 sensor_cfg_t cfg = { .name = "", .sample_rate_hz = 1000u, .channel = 3u };(gdb) next20 cfg_set_name(&cfg, "temp_ext");(gdb) watch cfg.sample_rate_hzHardware watchpoint 2: cfg.sample_rate_hz(gdb) continueContinuing.
Hardware watchpoint 2: cfg.sample_rate_hz
Old value = 1000New 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:20The 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:1414 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 cfg0x7fffffffe0d4: 0x74 0x65 0x6d 0x70 0x5f 0x65 0x78 0x740x7fffffffe0dc: 0x00 0x03 0x03 0x00There 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.
Sanitizers and Valgrind
Section titled “Sanitizers and Valgrind”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:
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:
gcc -std=c11 -g -O0 uninit.c -o uninitvalgrind --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)| Tool | Catches | Misses | Cost |
|---|---|---|---|
| ASan | out-of-bounds on heap, stack and globals; use-after-free; double free; leaks | uninitialized reads; overflows inside a struct | recompile; about 2x slower |
| UBSan | signed overflow, bad shifts, misaligned access, null deref, out-of-bounds array indexing | memory errors in general | recompile; small slowdown |
| Valgrind | heap errors, uninitialized reads, leaks, without recompiling | most stack and global overflows | 20 to 50x slower; Linux mainly |
Unit testing with Unity
Section titled “Unit testing with Unity”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).
#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_emptypins down the starting state, including thatrb_geton an empty buffer fails and leavesoutalone instead of returning stale data.test_bytes_come_out_in_orderis the FIFO contract.TEST_ASSERT_EQUAL_UINT8_ARRAYcompares element by element and reports the first index that differs.test_put_fails_when_fullchecks 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_fullto the one-slot-empty design, this test fails.test_indices_wrap_aroundis a white-box test. It reaches into the struct to start the free-running counters just belowSIZE_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_capacityguards 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.
Building and running with a Makefile
Section titled “Building and running with a Makefile”CC := gccWARN := -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wdouble-promotion \ -Wformat=2 -Wundef -Wstrict-prototypes -Wmissing-prototypesSAN := -fsanitize=address,undefined -fno-sanitize-recover=undefinedCFLAGS := -std=c11 -g -O1 -fno-omit-frame-pointer $(SAN)INCLUDES := -I../src -IunityBUILD := 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.
cd testsmake testtest_ringbuf.c:80:test_new_buffer_is_empty:PASStest_ringbuf.c:81:test_bytes_come_out_in_order:PASStest_ringbuf.c:82:test_put_fails_when_full:PASStest_ringbuf.c:83:test_indices_wrap_around:PASStest_ringbuf.c:84:test_init_rejects_bad_capacity:PASS
-----------------------5 Tests 0 Failures 0 IgnoredOKWith 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:PASStest_ringbuf.c:81:test_bytes_come_out_in_order:PASStest_ringbuf.c:43:test_put_fails_when_full:FAIL: Expected TRUE Was FALSEtest_ringbuf.c:83:test_indices_wrap_around:PASStest_ringbuf.c:84:test_init_rejects_bad_capacity:PASS
-----------------------5 Tests 1 Failures 0 IgnoredFAILA 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:
#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#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:
#ifndef PUMP_CTRL_H#define PUMP_CTRL_H
#include "gpio_if.h"
void pump_ctrl_step(const gpio_if_t *gpio);
#endifOn 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:
#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#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.
| Technique | Runtime cost | Flexibility | Typical use |
|---|---|---|---|
| function-pointer interface | one indirect call | swap per instance, at runtime | drivers with several back ends, bus abstractions |
| link-time substitution | none | one implementation per binary | replacing the HAL or register layer in tests |
preprocessor (#ifdef TEST) | none | ugly, spreads through code | avoid |
Static analysis: cppcheck and clang-tidy
Section titled “Static analysis: cppcheck and clang-tidy”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:
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:
clang-tidy src/*.c -checks='-*,bugprone-*,cert-*,clang-analyzer-*,misc-*' -- -std=c11 -Iincludesrc/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.
Compiler Explorer workflow
Section titled “Compiler Explorer workflow”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:
- 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. - Choose an
ARM GCCcompiler (thearm-none-eabibuilds) and set the flags you ship with:-O2 -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16. - Hover over a source line: its instructions are highlighted in the same color. Lines with no color were optimized away.
- Change one thing at a time: add
volatile, switch-O2to-Os, add-fno-strict-aliasing, and compare. Open a second compiler pane, or the diff view, to see two variants side by side. - 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.
Common mistakes
Section titled “Common mistakes”- 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,
-Werrorin CI. - Side effects inside
assert. They vanish withNDEBUG. Fix: do the work, then assert on the result. - Debugging at
-O2and seeing<optimized out>. Fix:-Ogor-O0for the session, or temporarily make the variablevolatile. - 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.
Exercises
Section titled “Exercises”- 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.
- Reproduce the gdb session with
cfg.c. Then change the overflow so it hitschannelinstead (hint: make the name longer) and use a watchpoint oncfg.channelto find it. Write down the bytesx/12xb cfgshows after the corruption and explain each one. - 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.cone at a time (wrong mask,headnot incremented,rb_is_emptycomparingheadwithmask) and confirm that at least one test fails for each. If a bug survives, write the missing test. - Write
test_pump_ctrl.cagainst the fake GPIO, then extendpump_ctrlwith a minimum off-time: once stopped, the pump may not restart for 30 seconds (passnow_msintopump_ctrl_step). Write the tests first, including one that crosses the 32-bit millisecond wraparound. - 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.
Check yourself
Section titled “Check yourself”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?
A watchpoint stops execution at the instruction that changes the value, wherever it is. The backtrace at that point names the function and line responsible.
Question 2:Why would AddressSanitizer not report the cfg_set_name overflow in this lesson?
ASan detects accesses outside an object by placing poisoned zones between objects. A write from one member into the next stays inside the struct, so no guard zone is touched.
Question 3:assert(uart_send(buf, len) == len); passes every test. What happens in the release build compiled with -DNDEBUG?
With NDEBUG defined, assert(expr) expands to nothing, so any side effect in expr disappears. Call the function first, then assert on the stored result.
Question 4:What does the white-box test test_indices_wrap_around protect against?
Starting head and tail just below SIZE_MAX forces the unsigned wraparound to happen within six operations, exercising arithmetic that would otherwise need billions of bytes.
Question 5:A pump controller calls gpio_write() directly. You want to test it on the host without changing its source. Which technique fits?
Link-time substitution replaces the implementation behind an ordinary function call at link time, with no change to the code under test and no runtime cost.
Key takeaways
Section titled “Key takeaways”- The compiler’s warnings are free tests: build with the full set (
-Wshadow -Wconversion -Wdouble-promotion -Wformat=2 -Wundef -Wstrict-prototypes -Wmissing-prototypeson top of-Wall -Wextra -Wpedantic) and-Werrorin CI. static_assertchecks layout and configuration at compile time for free;assertchecks programmer errors at runtime and must never contain side effects.- In gdb,
break,step/next/finish,print/p/x,x/andbt/framecover 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/tearDownper 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,
-fanalyzerand 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.