Part 1 · The C Language36 min read
Arrays, Strings, and Tables
Arrays are the only data structure C gives you for free, and firmware uses them for everything: receive buffers, frame buffers, sample windows, command lines, lookup tables. Strings are just arrays of char with a convention attached, and that convention is responsible for more security bugs than any other feature of the language. This lesson covers how arrays are sized and laid out, what happens when you pass one to a function, why const tables save RAM on a microcontroller, how to copy, format, compare, parse and tokenize strings without overflowing anything, and how to design buffers that cannot be overrun. It builds directly on Pointers I: everything here is pointer arithmetic with a friendlier syntax.
Fixed-size arrays
Section titled “Fixed-size arrays”An array is a contiguous block of elements of one type, with a size fixed at compile time:
#include <stdint.h>
uint16_t samples[8]; /* 8 x 2 bytes = 16 bytes */uint8_t rx_buf[64] = {0}; /* all zero */uint32_t primes[] = {2u, 3u, 5u, 7u, 11u}; /* size deduced: 5 */uint8_t mask[8] = {0xFFu, 0x0Fu}; /* rest are zero */int16_t gains[4] = {[2] = 100}; /* designated: gains[2] = 100, others 0 */The rules for initialization:
- If you provide an initializer, every element you do not mention is zero-initialized.
= {0}is the idiom for “all zeros”. - If you leave out the size, the compiler counts the initializers.
- Designated initializers (
[index] = value, C99) set specific elements; the rest are zero. - Without any initializer, a global or
staticarray is zeroed (it goes in.bss), and a local array contains garbage.
Arrays cannot be assigned (a = b is a compile error) and cannot be returned from functions. If you need to copy one, use memcpy; if you need to pass or return one by value, wrap it in a struct (Structs, unions, enums, and memory layout).
uint32_t primes[5] at 0x20000100
+------------+0x20000100 | 2 | primes[0]0x20000104 | 3 | primes[1]0x20000108 | 5 | primes[2]0x2000010C | 7 | primes[3]0x20000110 | 11 | primes[4] +------------+0x20000114 one past the endThere is no length stored anywhere and no bounds check at runtime. primes[5] compiles and reads whatever is at 0x20000114. That is the root of most of the bugs in this lesson.
Counting elements: ARRAY_SIZE
Section titled “Counting elements: ARRAY_SIZE”sizeof on an array gives its total size in bytes. Divide by the size of one element to get the element count:
#include <stddef.h>#include <stdint.h>
#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))
static const uint16_t thresholds[] = {100u, 250u, 500u, 1000u};
size_t count_above(uint16_t value){ size_t n = 0; for (size_t i = 0; i < ARRAY_SIZE(thresholds); i++) { if (value > thresholds[i]) { n++; } } return n;}ARRAY_SIZE(thresholds) is a compile-time constant (4). Add a fifth threshold and every loop updates itself. Never write the number 4 in that loop: a hand-typed length is a future off-by-one.
The macro has one trap. Apply it to a pointer and it silently returns sizeof(pointer) / sizeof(element): 2 for a uint16_t * on Cortex-M, 4 on a 64-bit host, neither of which is meaningful. GCC 8 and later warn about this with -Wsizeof-pointer-div (part of -Wall). The Linux kernel’s version adds a compile-time check using the GCC builtin __builtin_types_compatible_p to reject pointers outright; that is worth copying if your codebase is GCC or Clang only.
Arrays as function parameters: sizeof lies
Section titled “Arrays as function parameters: sizeof lies”When you pass an array to a function, it decays to a pointer to its first element. That is true even if you write the parameter with array syntax:
#include <stdint.h>#include <stdio.h>
void dump(const uint8_t buf[16]) /* this parameter is really const uint8_t *buf */{ printf("sizeof buf = %zu\n", sizeof buf);}
int main(void){ uint8_t frame[16] = {0}; printf("sizeof frame = %zu\n", sizeof frame); dump(frame); return 0;}sizeof frame = 16sizeof buf = 8On the target the second line would say 4. The 16 in the parameter is ignored: the compiler adjusts const uint8_t buf[16] to const uint8_t *buf, and you can pass an array of any length, or a pointer to a single byte. GCC warns about the sizeof by default (-Wsizeof-array-argument): “‘sizeof’ on array function parameter ‘buf’ will return size of ‘const uint8_t *’”.
main's frame dump's frame +--------------------------------+ +----------------------+ | frame[16] @0x20001FE0 |<-------| buf = 0x20001FE0 | 4 bytes: just an address | 00 00 00 00 ... (16 bytes) | +----------------------+ +--------------------------------+The fix is the convention every C API follows: pass the length alongside the pointer.
#include <stddef.h>#include <stdint.h>#include <stdio.h>
void dump(const uint8_t *buf, size_t len){ for (size_t i = 0; i < len; i++) { printf("%02X ", (unsigned)buf[i]); } printf("\n");}Call it as dump(frame, sizeof frame). Writing the parameter as a pointer is more honest than array syntax because it does not pretend to carry a size.
C99 added const uint8_t buf[static 16] in parameters, which promises the caller passes at least 16 elements. Recent GCC versions use it to warn about calls with smaller arrays, but it does not change the type or let you use sizeof. It is rare in firmware code; know it when you see it.
Multidimensional arrays
Section titled “Multidimensional arrays”A two-dimensional array is an array of arrays. uint8_t fb[4][8] is an array of 4 elements, each of which is an array of 8 uint8_t. The rows are stored one after another, which is called row-major order:
#include <stdint.h>
#define ROWS 4#define COLS 8
static uint8_t fb[ROWS][COLS]; /* a tiny monochrome frame buffer, 32 bytes */ fb at 0x20000200, address of fb[r][c] = 0x20000200 + r * COLS + c
+----+----+----+----+----+----+----+----+0x20000200 | 0,0| 0,1| 0,2| 0,3| 0,4| 0,5| 0,6| 0,7| fb[0]0x20000208 | 1,0| 1,1| 1,2| 1,3| 1,4| 1,5| 1,6| 1,7| fb[1]0x20000210 | 2,0| 2,1| 2,2| 2,3| 2,4| 2,5| 2,6| 2,7| fb[2]0x20000218 | 3,0| 3,1| 3,2| 3,3| 3,4| 3,5| 3,6| 3,7| fb[3] +----+----+----+----+----+----+----+----+fb[2][5] is the byte at 0x20000200 + 2*8 + 5 = 0x20000215. The compiler computes this from the declared column count, which is why that count must be known whenever you index. A function that takes a 2D array must spell out every dimension except the first:
#include <stddef.h>#include <stdint.h>
#define COLS 8
void fb_fill(uint8_t fb[][COLS], size_t rows, uint8_t value){ for (size_t r = 0; r < rows; r++) { for (size_t c = 0; c < COLS; c++) { fb[r][c] = value; /* inner loop walks consecutive bytes */ } }}The parameter uint8_t fb[][COLS] is adjusted to uint8_t (*fb)[COLS], a pointer to an array of 8 bytes (the declarator reading is in Pointers II). Loop with the last index innermost so the accesses walk consecutive addresses. On a Cortex-M4 there is no data cache, but sequential access still lets the compiler use post-increment addressing and lets you replace the inner loop with a single memset per row.
When the dimensions are only known at runtime, firmware uses a flat 1D array and computes the index itself: buf[r * cols + c]. That is exactly what the compiler does for a 2D array, just written out.
VLAs and why firmware bans them
Section titled “VLAs and why firmware bans them”C99 allowed arrays whose size is a runtime value, variable-length arrays:
#include <stddef.h>#include <stdint.h>
void process(size_t n){ uint8_t scratch[n]; /* VLA: size decided at runtime, on the stack */ for (size_t i = 0; i < n; i++) { scratch[i] = (uint8_t)i; } /* ... */}Every call subtracts n from the stack pointer at runtime. On a host with a megabyte of stack and guard pages that is merely risky. On an MCU with a few kilobytes of stack and nothing below it but .bss and .data, it is a disaster waiting for a large n:
- There is no failure path. A VLA that does not fit does not return an error; it silently moves the stack pointer into your global variables, and the next push overwrites them.
- Stack usage becomes unbounded.
-fstack-usagereports the function as “dynamic”, and you can no longer prove the stack is big enough. - If
ncomes from outside, from a packet length field for example, an attacker controls how far the stack moves. sizeof scratchis computed at runtime, one of the few cases wheresizeofis not a compile-time constant.
C11 made VLAs an optional feature (implementations define __STDC_NO_VLA__ if they lack them), MISRA C forbids them, and the GCC flag -Wvla flags every one. Use a fixed-size buffer sized for the worst case and check the requested length against it.
const tables live in flash
Section titled “const tables live in flash”Here is a lookup table without const:
#include <stdint.h>
uint16_t gamma_table[256] = { 0u, 1u, 2u /* ... 253 more values ... */ };It is a writable initialized global, so it goes in .data. The linker must store its initial values in flash, and the startup code copies them into RAM before main runs (Memory: where everything lives walks through that). The table costs 512 bytes of flash and 512 bytes of RAM, plus the time to copy it at every boot.
Add const:
#include <stdint.h>
const uint16_t gamma_table[256] = { 0u, 1u, 2u /* ... 253 more values ... */ };Now it goes in .rodata, which the linker script places in flash. The CPU reads it directly from flash at runtime. RAM cost: zero. The size tool shows the move (illustrative numbers for a small program):
$ arm-none-eabi-size nonconst.elf const.elf text data bss dec hex filename 1240 524 28 1792 700 nonconst.elf 1752 12 28 1792 700 const.elfsize counts .rodata under text. The table moved 512 bytes from data to text. Flash use is text + data in both cases (1764 bytes), because .data initial values are stored in flash too. The difference is RAM, which is data + bss: 552 bytes versus 40. On a part with 128 KB of SRAM, every table you forget to make const is RAM you cannot use for buffers.
Three details catch people:
- Local
constarrays. Aconst uint8_t lut[] = {...}inside a function, withoutstatic, is an automatic variable. The compiler may build it on the stack on every call, copying it from flash each time. Writestatic constfor tables inside functions. - Arrays of pointers.
const char *names[] = {"idle", "run"};makes the strings const but not the array: the array of pointers is writable and lands in.data. Writeconst char *const names[]to put the whole table in flash. - Flash is slower than SRAM. At 168 MHz the STM32F407 needs 5 flash wait states; the ART accelerator and prefetch hide most of that for sequential code, but random table reads may stall. That rarely matters, and when it does, Performance and code size optimization covers moving hot tables to RAM.
Strings: arrays with a terminator
Section titled “Strings: arrays with a terminator”C has no string type. A string is a sequence of char ending with a zero byte, the NUL terminator ('\0'). Every string function finds the end by scanning for that zero. A string literal like "led" is a char array of 4 bytes: three characters plus the NUL that the compiler adds.
There are two very different ways to give a name to a string:
char greeting[] = "hi"; /* an array of 3 chars, initialized with a copy */const char *message = "hi"; /* a pointer to a string literal */ greeting (array, writable, 3 bytes in RAM: .data for a global, stack for a local) +-----+-----+-----+0x20000300 | 'h' | 'i' | \0 | +-----+-----+-----+
message (pointer, 4 bytes in RAM) string literal (.rodata, in flash) +------------+ +-----+-----+-----+0x20000304 | 0x08002A10 |--------------------->| 'h' | 'i' | \0 | 0x08002A10 +------------+ +-----+-----+-----+sizeof greeting is 3; sizeof message is 4 (a pointer on the target). strlen of either is 2: the length does not count the terminator.
You may modify greeting; it is your own array. You may not modify the literal message points to. Writing to a string literal is undefined behavior: on a host it usually segfaults because .rodata is mapped read-only, and on an MCU the literal is in flash, so the write is ignored or faults. For historical reasons a string literal’s type in C is char[], not const char[], so char *p = "hi"; compiles silently. Declare such pointers const char *, and consider adding -Wwrite-strings, which makes the compiler treat literals as const and catch the mistake.
Identical literals may share storage, so two pointers to "hi" may compare equal or not; never rely on either.
The standard copy functions and their traps
Section titled “The standard copy functions and their traps”strlen
Section titled “strlen”strlen(s) walks from s until it finds a zero byte and returns how many bytes it passed. It is O(n) on every call, so do not call it in a loop condition on a long string. If there is no terminator, it keeps reading past the end of your buffer into whatever follows, which is undefined behavior and on an MCU means “reads random RAM until it happens to hit a zero”.
strcpy and strcat
Section titled “strcpy and strcat”strcpy(dst, src) copies src including its terminator into dst. It does not know how big dst is. If src is longer, it writes past the end. strcat has the same problem, plus it must scan dst to find the end first. Neither belongs in firmware that handles any external input.
strncpy: not what it looks like
Section titled “strncpy: not what it looks like”strncpy(dst, src, n) looks like a bounded strcpy. It is not. It was designed for fixed-width, zero-padded fields in old Unix directory entries, and it has two behaviors that surprise everyone:
- If
srchasnor more characters, it copies exactlyncharacters and does not write a terminator. - If
srcis shorter thann, it pads the rest ofdstwith zeros, all the way ton.
#include <string.h>
void demo(void){ char name[8]; strncpy(name, "firmware-path", sizeof name); /* copies 8 chars, no NUL */} +----+----+----+----+----+----+----+----+ name | f | i | r | m | w | a | r | e | no terminator +----+----+----+----+----+----+----+----+ printf("%s", name) keeps reading past the end ---------> ...GCC 8 and later can see this case at compile time and warn with -Wstringop-truncation (part of -Wall). The first behavior produces an unterminated string that the next strlen or printf runs off the end of. The second turns strncpy(buf, "ok", 512) into a 512-byte write when you meant 3. If you must use it, the safe form is strncpy(dst, src, n - 1); dst[n - 1] = '\0';, but there are better tools.
strlcpy and writing your own
Section titled “strlcpy and writing your own”BSD introduced strlcpy(dst, src, size): it copies at most size - 1 characters, always terminates (if size > 0), and returns strlen(src) so you can detect truncation (ret >= size). It is not in ISO C11. Newlib provides it, glibc only since version 2.38, and it is now in POSIX. In portable firmware it is simplest to write a small function with the exact semantics you want:
#include <stdbool.h>#include <stddef.h>
/* Copies src into dst, which holds cap bytes. Always NUL-terminates when cap > 0. Returns true if all of src fit, false if it was truncated. */bool str_copy(char *dst, size_t cap, const char *src){ size_t i = 0;
if (cap == 0u) { return false; } while (src[i] != '\0' && i < cap - 1u) { dst[i] = src[i]; i++; } dst[i] = '\0'; return src[i] == '\0';}The loop stops either at the end of src or when only one byte of room is left, and that last byte always gets the terminator. If the loop stopped because of the capacity limit, src[i] is not the terminator, so the function reports truncation. cap - 1u would underflow for cap == 0, which is why that case is handled first.
snprintf: the formatting workhorse
Section titled “snprintf: the formatting workhorse”snprintf(buf, size, fmt, ...) formats into buf, writing at most size bytes including the terminator, and always terminates when size > 0. Its return value is the part people get wrong: it returns the number of characters that would have been written if the buffer were big enough, not counting the terminator. A negative return means an encoding error.
#include <inttypes.h>#include <stdio.h>
int main(void){ char buf[8]; int32_t temp = -1234;
int n = snprintf(buf, sizeof buf, "T=%" PRId32 "C", temp); printf("n=%d buf=\"%s\"\n", n, buf); if (n < 0 || (size_t)n >= sizeof buf) { printf("truncated\n"); } return 0;}n=8 buf="T=-1234"truncatedThe full text T=-1234C is 8 characters and needs 9 bytes; the buffer holds 8, so snprintf wrote 7 characters plus the terminator and returned 8. Truncation happened if and only if the return value is greater than or equal to the buffer size. You can also call snprintf(NULL, 0, fmt, ...) to measure how big a buffer you need.
The classic bug is building a string with several calls and a running offset:
off += snprintf(buf + off, sizeof buf - off, "%s,", field); /* BUG on truncation */After a truncated call, off is larger than sizeof buf, so sizeof buf - off wraps around to a huge size_t and the next call writes far past the end. Clamp the offset after every call, or use a buffer type that does it for you (the last section of this lesson). The cost of printf-family functions in code size and stack on an MCU is covered in The C standard library on a microcontroller.
Parsing numbers without atoi
Section titled “Parsing numbers without atoi”atoi("123") returns 123. atoi("12a") returns 12, atoi("abc") returns 0, and atoi("99999999999") is undefined behavior. There is no way to tell “the input was 0” from “the input was garbage”. In firmware that parses commands from a UART, that is unacceptable: set_speed abc should be rejected, not interpreted as speed 0.
strtoul and strtol report errors properly (through an end pointer and errno) and are covered in The C standard library on a microcontroller. But a strict parser for unsigned decimal numbers is short enough to write yourself, and writing it teaches the overflow check you will need everywhere:
#include <stdbool.h>#include <stddef.h>#include <stdint.h>
/* Parses a non-empty string of decimal digits into *out. Rejects signs, spaces, trailing characters, and values above UINT32_MAX. */bool parse_u32(const char *s, uint32_t *out){ uint32_t value = 0u;
if (s == NULL || out == NULL || *s == '\0') { return false; } for (; *s != '\0'; s++) { if (*s < '0' || *s > '9') { return false; /* not a digit */ } uint32_t digit = (uint32_t)(*s - '0'); if (value > (UINT32_MAX - digit) / 10u) { return false; /* value * 10 + digit would overflow */ } value = value * 10u + digit; } *out = value; return true;}Line by line:
*s - '0'converts a digit character to its value. The C standard guarantees'0'to'9'are consecutive, so this is portable (letters have no such guarantee).- The overflow check is done before the multiplication, because after it the value has already wrapped.
value * 10 + digitfits in 32 bits exactly whenvalue <= (UINT32_MAX - digit) / 10, rearranged so no intermediate result can overflow. *outis written only on success, so the caller’s variable is untouched on error.
Testing the edges:
#include <inttypes.h>#include <stdbool.h>#include <stdint.h>#include <stdio.h>
bool parse_u32(const char *s, uint32_t *out);
int main(void){ const char *inputs[] = {"0", "4294967295", "4294967296", "12a", "", "-5"};
for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) { uint32_t v; if (parse_u32(inputs[i], &v)) { printf("\"%s\" -> %" PRIu32 "\n", inputs[i], v); } else { printf("\"%s\" -> rejected\n", inputs[i]); } } return 0;}"0" -> 0"4294967295" -> 4294967295"4294967296" -> rejected"12a" -> rejected"" -> rejected"-5" -> rejectedThe two numbers at the edge of the range are the tests that matter: UINT32_MAX must be accepted and one more must be rejected. A parser that has not been tested at its limits has not been tested.
Comparing strings
Section titled “Comparing strings”== on two char * compares the pointers, not the text. if (cmd == "led") compares cmd with the address of a literal and is almost always false. GCC warns (“comparison with string literal results in unspecified behavior”, -Waddress). Use the library:
strcmp(a, b)returns 0 if equal, a negative value ifasorts beforeb, positive if after. The values are not guaranteed to be -1 and 1, so test== 0,< 0or> 0, never== 1.strncmp(a, b, n)compares at mostncharacters. Useful for prefixes:strncmp(line, "AT+", 3) == 0.memcmp(a, b, n)compares raw bytes, terminators included or not. Use it for binary data, never for structs (the padding trap is in Structs, unions, enums, and memory layout).
Tokenizing: strtok and a reentrant replacement
Section titled “Tokenizing: strtok and a reentrant replacement”A command shell needs to split led on 3 into words. The library offers strtok:
char *strtok(char *str, const char *delim);You call it once with the string and then repeatedly with NULL to get further tokens. It works by writing '\0' over each delimiter in your string and remembering where it stopped in a hidden static variable inside the library. That hidden state has consequences:
- It is not reentrant. If you tokenize commands separated by
;, and inside the loop you callstrtokagain to split each command into words, the inner call overwrites the outer call’s position and the outer loop goes wrong. - It is not safe to use from two execution contexts. If the main loop and an ISR (or two RTOS tasks) both tokenize, they corrupt each other’s state.
- It modifies the input, so you cannot use it on a string literal or on a buffer you still need intact.
POSIX strtok_r fixes the hidden state by making the caller store it, but it is not in ISO C. A tokenizer with explicit state is about twenty lines:
#include <stddef.h>#include <stdio.h>
/* Returns the next space-separated token in *cursor, or NULL if there are none. Writes a NUL after the token and advances *cursor past it. All state lives in the caller's cursor, so calls can be nested or concurrent. */static char *next_token(char **cursor){ char *s = *cursor;
while (*s == ' ') { s++; /* skip leading spaces */ } if (*s == '\0') { *cursor = s; return NULL; } char *start = s; while (*s != '\0' && *s != ' ') { s++; } if (*s == ' ') { *s = '\0'; /* terminate this token */ s++; } *cursor = s; return start;}
int main(void){ char line[] = " led on 3 "; char *cursor = line; char *tok;
while ((tok = next_token(&cursor)) != NULL) { printf("[%s] ", tok); } printf("\n"); return 0;}[led] [on] [3] Before: ' ' ' ' 'l' 'e' 'd' ' ' ' ' 'o' 'n' ' ' '3' ' ' \0 After: ' ' ' ' 'l' 'e' 'd' \0 ' ' 'o' 'n' \0 '3' \0 \0 ^ ^ ^ token 1 token 2 token 3The tokens point into the original buffer, no copies are made, and the cursor (a char * passed by address, as in the out-parameter section of Pointers I) carries all the state. Two loops with two cursors can run nested or in different contexts without interfering. The input must be a writable array, which line[] is.
Lookup tables and bounds checking
Section titled “Lookup tables and bounds checking”A lookup table trades flash for computation: precompute values once, at build time, and index them at runtime. Firmware uses tables for sine and other waveforms, gamma and linearisation curves, CRC calculation, and character classification.
A sine table indexed by a phase accumulator
Section titled “A sine table indexed by a phase accumulator”#include <stdint.h>
/* One full cycle, 16 points, Q15 format (32767 = 1.0) */static const int16_t sine16[16] = { 0, 12539, 23170, 30273, 32767, 30273, 23170, 12539, 0, -12539, -23170, -30273, -32767, -30273, -23170, -12539,};
_Static_assert(sizeof sine16 / sizeof sine16[0] == 16u, "index math assumes 16 entries");
/* phase covers one full cycle over the whole uint32_t range */int16_t sine_lookup(uint32_t phase){ return sine16[phase >> 28]; /* top 4 bits: always 0..15 */}phase >> 28 keeps the top 4 bits of a 32-bit value, so the index is between 0 and 15 by construction; there is no way to go out of bounds. To generate a frequency, add a fixed step to phase every sample and let it wrap naturally at 2^32 (unsigned overflow is well defined). The _Static_assert ties the index arithmetic to the table size, so shrinking the table without fixing the shift breaks the build instead of the device. (The preprocessor, used well covers _Static_assert.)
A CRC table
Section titled “A CRC table”CRC computation bit by bit costs a loop of 8 iterations per byte. A table of precomputed results for each possible input chunk removes the inner loop. A full 256-entry table processes a byte per lookup; a 16-entry table processes a nibble per lookup at a quarter of the flash. Here is CRC-8 with polynomial 0x07 using the small table:
#include <stddef.h>#include <stdint.h>
static const uint8_t crc8_nibble[16] = { 0x00u, 0x07u, 0x0Eu, 0x09u, 0x1Cu, 0x1Bu, 0x12u, 0x15u, 0x38u, 0x3Fu, 0x36u, 0x31u, 0x24u, 0x23u, 0x2Au, 0x2Du,};
uint8_t crc8(const uint8_t *data, size_t len){ uint8_t crc = 0u;
for (size_t i = 0; i < len; i++) { uint8_t b = data[i]; crc = (uint8_t)((uint8_t)(crc << 4) ^ crc8_nibble[(crc >> 4) ^ (b >> 4)]); crc = (uint8_t)((uint8_t)(crc << 4) ^ crc8_nibble[(crc >> 4) ^ (b & 0x0Fu)]); } return crc;}Over the ASCII bytes "123456789" this returns 0xF4, the standard check value for this CRC variant, which is how you verify a CRC implementation. Each index is the XOR of two 4-bit values, so it is always 0 to 15. CRC-32 with full tables, and the STM32’s hardware CRC unit, are in Flash, EEPROM, and non-volatile storage.
When the index comes from outside
Section titled “When the index comes from outside”Both tables above are safe because the index is masked or shifted into range by construction. When the index comes from a message, a sensor or a user, check it:
if (idx >= ARRAY_SIZE(table)) { return ERR_RANGE;}value = table[idx];Use an unsigned index so a single comparison covers negative values too. If the index arrives as a signed int, convert it to an unsigned type first or check both ends. And if the table size is a power of two and wrapping is the correct behavior (as with a ring buffer), idx & (N - 1u) is a bounds check that cannot fail.
Buffer overflow: the number one security bug
Section titled “Buffer overflow: the number one security bug”When a write runs past the end of an array, it overwrites whatever is next. For a local array, what is next is the rest of the stack frame, including the saved return address. Here is a function that reads a command from a UART line:
#include <string.h>
void handle_line(const char *line) /* line arrives from the UART */{ char cmd[16]; strcpy(cmd, line); /* no length check */ /* ... */}On Cortex-M the prologue of a function like this is typically push {r4, lr} followed by sub sp, sp, #16 for the array. The stack grows downward, so the saved registers sit above the array:
handle_line's frame (higher addresses at top)
+------------------------+0x20001FFC | saved lr (return addr) | <- popped into pc on return0x20001FF8 | saved r4 | +------------------------+0x20001FF4 | cmd[12..15] |0x20001FF0 | cmd[8..11] |0x20001FEC | cmd[4..7] |0x20001FE8 | cmd[0..3] | <- strcpy starts writing here, upward +------------------------+
A 24-byte line fills cmd, overwrites saved r4, then the saved lr. The epilogue "pop {r4, pc}" loads the attacker's bytes into pc.A line of 24 characters overwrites the saved lr (and its terminator lands in the caller’s frame), and when the function returns, pop {r4, pc} jumps to an address chosen by whoever sent the line. With accidental input the result is a HardFault (often INVSTATE, because random data has bit 0 clear) somewhere that makes no sense. With crafted input it is code execution. This is the stack buffer overflow, the most exploited bug class in the history of C, and it is just as real on a microcontroller connected to a network or a field bus. Compiler mitigations such as -fstack-protector and the broader secure-coding discipline are covered in Firmware security.
Designing fixed-capacity string buffers
Section titled “Designing fixed-capacity string buffers”The cure is to make it impossible to write a string without knowing the capacity. Bundle the storage, its capacity and its current length together, and write all appends through functions that enforce the limit:
#include <inttypes.h>#include <stdbool.h>#include <stddef.h>#include <stdint.h>#include <stdio.h>
struct strbuf { char *data; /* caller-provided storage */ size_t cap; /* total bytes, including room for the NUL */ size_t len; /* characters used, excluding the NUL */ bool truncated; /* set if anything was cut off */};
static void sb_init(struct strbuf *sb, char *storage, size_t cap) /* cap >= 1 */{ sb->data = storage; sb->cap = cap; sb->len = 0u; sb->truncated = false; sb->data[0] = '\0';}
static void sb_append(struct strbuf *sb, const char *s){ while (*s != '\0') { if (sb->len + 1u >= sb->cap) { /* keep the last byte for the NUL */ sb->truncated = true; break; } sb->data[sb->len++] = *s++; } sb->data[sb->len] = '\0';}
static void sb_append_u32(struct strbuf *sb, uint32_t v){ size_t room = sb->cap - sb->len; /* includes space for the NUL */ int n = snprintf(sb->data + sb->len, room, "%" PRIu32, v);
if (n < 0) { sb->truncated = true; } else if ((size_t)n >= room) { /* snprintf cut the output short */ sb->len = sb->cap - 1u; sb->truncated = true; } else { sb->len += (size_t)n; }}
int main(void){ char big[16]; char small[8]; struct strbuf a; struct strbuf b;
sb_init(&a, big, sizeof big); sb_append(&a, "temp="); sb_append_u32(&a, 2315u); sb_append(&a, " mV");
sb_init(&b, small, sizeof small); sb_append(&b, "temp="); sb_append_u32(&b, 2315u); sb_append(&b, " mV");
printf("[%s] truncated=%d\n", a.data, (int)a.truncated); printf("[%s] truncated=%d\n", b.data, (int)b.truncated); return 0;}[temp=2315 mV] truncated=0[temp=23] truncated=1 small[8] after the appends (cap = 8, len = 7) +-----+-----+-----+-----+-----+-----+-----+-----+ | 't' | 'e' | 'm' | 'p' | '=' | '2' | '3' | \0 | +-----+-----+-----+-----+-----+-----+-----+-----+ ^ the last byte is always the terminatorThe design properties are what matter, not the specific code:
- Capacity travels with the buffer. No function receives a
char *without also knowing how much room is behind it. - The buffer is always a valid string. Every operation leaves it terminated, even after truncation, so it is always safe to print.
- Truncation is recorded, not silent. The caller can check one flag after building a whole message and decide whether to send it, log an error or drop it.
- No allocation. Storage comes from the caller, usually a
staticarray, so the memory cost is visible at link time.
The same idea scales up: a receive line buffer that refuses characters once full and flags the line as overlong, or a byte buffer with data, cap and len for building binary frames.
Common mistakes
Section titled “Common mistakes”- Using
sizeofon an array parameter. It is a pointer; you get 4 or 8. Pass the length explicitly. - Applying
ARRAY_SIZEto a pointer. Silent wrong count. Keep-Wallon so-Wsizeof-pointer-divcatches it. - Forgetting room for the terminator. A 5-character string needs 6 bytes. Size buffers as “maximum length + 1”.
- Using
strncpyas a safestrcpy. It does not terminate on truncation and pads with zeros. Usesnprintf,strlcpyor your own copy function. - Ignoring the
snprintfreturn value. Truncation goes unnoticed and running offsets overflow. Checkret >= size. - Comparing strings with
==. Compares addresses. Usestrcmp(a, b) == 0. - Using
strtokin nested loops, ISRs or tasks. Hidden static state. Use a tokenizer with an explicit cursor. - Declaring lookup tables without
const, orconstarrays of non-const pointers. Wastes RAM. Usestatic constandconst char *const. - Using
atoion external input. No error reporting. Use a strict parser that checks every character and overflow.
Exercises
Section titled “Exercises”- sizeof three ways. Write a host program that prints
sizeofof auint32_t[10]array inmain, of the same array inside a function takinguint32_t a[10], andARRAY_SIZEof it inmain. Note the compiler warning for the second. Expected on a 64-bit host: 40, 8, 10. - Find the RAM. Create a file with a 256-entry
uint16_ttable at file scope, once asuint16_t gamma[256]and once asconst uint16_t gamma[256](leave outstatic, so the compiler keeps the unused table in the object file). Build each for Cortex-M4 (arm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -c) and runarm-none-eabi-sizeon the object files. Confirm 512 bytes move fromdatatotext. Then runarm-none-eabi-objdump -hand find which section (.dataor.rodata) holds the table in each case. - parse_i32. Extend
parse_u32intobool parse_i32(const char *s, int32_t *out)that accepts an optional leading-. It must accept"-2147483648"and"2147483647"and reject"2147483648","-2147483649","-"and"--1". Write a test table like the one in this lesson that covers all six cases. - Shell splitter. Using
next_token, writesize_t split(char *line, char *argv[], size_t max_args)that fillsargvwith token pointers and returns the count, never writing more thanmax_argsentries. Test with"led on 3"(3 tokens), an empty line (0) and a line with 10 tokens andmax_args = 4(returns 4, and does not writeargv[4]). - Overflow on purpose. Compile the
handle_lineexample on the host with-O0 -fno-stack-protectorand call it with a 64-character string. Observe the crash. Then compile with-fsanitize=addressand read the report: it should name a stack-buffer-overflow oncmdwith the exact write size. Finally fix the function usingstr_copyand confirm both builds run cleanly.
Check yourself
Section titled “Check yourself”Question 1:Inside void f(uint16_t buf[32]), what does sizeof buf evaluate to on a Cortex-M4?
Array parameters are adjusted to pointers, so buf is a uint16_t * and sizeof buf is the size of a pointer, 4 bytes on a 32-bit MCU. The 32 is ignored.
Question 2:char name[8]; strncpy(name, "temperature", sizeof name); What is in name afterwards?
When the source is at least n characters long, strncpy copies exactly n characters and does not terminate. Any later strlen or printf reads past the end of name.
Question 3:int n = snprintf(buf, 10, "%s", "0123456789ABC"); What is n, and was the output truncated?
snprintf returns the length the full output would have had, 13 characters. Since 13 is at least the buffer size of 10, the output was truncated to 9 characters plus the terminator.
Question 4:Which declaration puts both the strings and the array of pointers in flash on a typical Cortex-M build?
The literals themselves always go to .rodata; what decides where the array goes is whether its elements are const. With non-const pointers (or a writable char array) the table is writable and lands in .data, costing RAM. const char *const makes the whole table read-only.
Question 5:Why is strtok unsuitable for code that might run in both the main loop and an ISR?
strtok remembers where it stopped in a static variable inside the library. Any second user, whether a nested loop, an ISR or another task, overwrites that position and corrupts the first user's tokenizing.
Question 6:In parse_u32, why is the overflow test written as value > (UINT32_MAX - digit) / 10u instead of checking value * 10u + digit afterwards?
Unsigned arithmetic wraps silently, so value * 10u + digit can produce a small, plausible number. Rearranging the inequality checks the limit without ever computing an out-of-range intermediate.
Key takeaways
Section titled “Key takeaways”- Arrays have no stored length and no runtime bounds checks. Count elements with
ARRAY_SIZE, never with typed numbers. - An array passed to a function becomes a pointer;
sizeofthen gives the pointer size. Always pass a length with a buffer. - 2D arrays are row-major:
a[r][c]lives atbase + r * COLS + c. Loop with the last index innermost. - No VLAs in firmware: they have no failure path and make stack usage unbounded.
static consttables live in flash and cost no RAM; non-const initialized tables cost both flash and RAM. Useconst char *constfor string tables.- A C string is bytes ending in NUL.
strcpyandstrncpyare traps;snprintftruncation is detected byret >= size. - Every buffer receiving external data needs a length check. Keep capacity and length together with the storage so no code can write blindly.
Next: Structs, unions, enums, and memory layout shows how to group data into types, how the compiler pads and aligns them, and how to describe wire formats and hardware registers safely.