Skip to content

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.

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 static array 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 end

There 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.

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 = 16
sizeof buf = 8

On 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.

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.

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-usage reports the function as “dynamic”, and you can no longer prove the stack is big enough.
  • If n comes from outside, from a packet length field for example, an attacker controls how far the stack moves.
  • sizeof scratch is computed at runtime, one of the few cases where sizeof is 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.

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.elf

size 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 const arrays. A const uint8_t lut[] = {...} inside a function, without static, is an automatic variable. The compiler may build it on the stack on every call, copying it from flash each time. Write static const for 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. Write const 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.

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(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(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(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:

  1. If src has n or more characters, it copies exactly n characters and does not write a terminator.
  2. If src is shorter than n, it pads the rest of dst with zeros, all the way to n.
#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.

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(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"
truncated

The 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.

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 + digit fits in 32 bits exactly when value <= (UINT32_MAX - digit) / 10, rearranged so no intermediate result can overflow.
  • *out is 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" -> rejected

The 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.

== 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 if a sorts before b, positive if after. The values are not guaranteed to be -1 and 1, so test == 0, < 0 or > 0, never == 1.
  • strncmp(a, b, n) compares at most n characters. 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 call strtok again 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 3

The 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.

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.)

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.

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 return
0x20001FF8 | 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.

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 terminator

The 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 static array, 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.

  • Using sizeof on an array parameter. It is a pointer; you get 4 or 8. Pass the length explicitly.
  • Applying ARRAY_SIZE to a pointer. Silent wrong count. Keep -Wall on so -Wsizeof-pointer-div catches it.
  • Forgetting room for the terminator. A 5-character string needs 6 bytes. Size buffers as “maximum length + 1”.
  • Using strncpy as a safe strcpy. It does not terminate on truncation and pads with zeros. Use snprintf, strlcpy or your own copy function.
  • Ignoring the snprintf return value. Truncation goes unnoticed and running offsets overflow. Check ret >= size.
  • Comparing strings with ==. Compares addresses. Use strcmp(a, b) == 0.
  • Using strtok in nested loops, ISRs or tasks. Hidden static state. Use a tokenizer with an explicit cursor.
  • Declaring lookup tables without const, or const arrays of non-const pointers. Wastes RAM. Use static const and const char *const.
  • Using atoi on external input. No error reporting. Use a strict parser that checks every character and overflow.
  1. sizeof three ways. Write a host program that prints sizeof of a uint32_t[10] array in main, of the same array inside a function taking uint32_t a[10], and ARRAY_SIZE of it in main. Note the compiler warning for the second. Expected on a 64-bit host: 40, 8, 10.
  2. Find the RAM. Create a file with a 256-entry uint16_t table at file scope, once as uint16_t gamma[256] and once as const uint16_t gamma[256] (leave out static, 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 run arm-none-eabi-size on the object files. Confirm 512 bytes move from data to text. Then run arm-none-eabi-objdump -h and find which section (.data or .rodata) holds the table in each case.
  3. parse_i32. Extend parse_u32 into bool 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.
  4. Shell splitter. Using next_token, write size_t split(char *line, char *argv[], size_t max_args) that fills argv with token pointers and returns the count, never writing more than max_args entries. Test with "led on 3" (3 tokens), an empty line (0) and a line with 10 tokens and max_args = 4 (returns 4, and does not write argv[4]).
  5. Overflow on purpose. Compile the handle_line example on the host with -O0 -fno-stack-protector and call it with a 64-character string. Observe the crash. Then compile with -fsanitize=address and read the report: it should name a stack-buffer-overflow on cmd with the exact write size. Finally fix the function using str_copy and confirm both builds run cleanly.

Question 1:Inside void f(uint16_t buf[32]), what does sizeof buf evaluate to on a Cortex-M4?

Question 2:char name[8]; strncpy(name, "temperature", sizeof name); What is in name afterwards?

Question 3:int n = snprintf(buf, 10, "%s", "0123456789ABC"); What is n, and was the output truncated?

Question 4:Which declaration puts both the strings and the array of pointers in flash on a typical Cortex-M build?

Question 5:Why is strtok unsuitable for code that might run in both the main loop and an ISR?

Question 6:In parse_u32, why is the overflow test written as value > (UINT32_MAX - digit) / 10u instead of checking value * 10u + digit afterwards?

  • 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; sizeof then gives the pointer size. Always pass a length with a buffer.
  • 2D arrays are row-major: a[r][c] lives at base + r * COLS + c. Loop with the last index innermost.
  • No VLAs in firmware: they have no failure path and make stack usage unbounded.
  • static const tables live in flash and cost no RAM; non-const initialized tables cost both flash and RAM. Use const char *const for string tables.
  • A C string is bytes ending in NUL. strcpy and strncpy are traps; snprintf truncation is detected by ret >= 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.