Embedded C skill for Codex

volatile, ISRs and memory budgets done right. Codex reviews and writes microcontroller C with the rules that matter on hardware: volatile only where it helps, atomic access to ISR-shared state, single-producer ring buffers with barriers, no allocation in steady state, and explicit integer widths. A bundled script reads the ELF and fails the build when flash or RAM passes the budget.

  • Embedded & IoT
  • $embedded-c
  • 189 lines
  • 3 files
  • ~2,000 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- embedded-c

Installs to ~/.agents/skills/embedded-c/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $embedded-c, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and reviews bare-metal and RTOS C for microcontrollers - volatile and memory-mapped registers, ISR-shared data and atomicity, critical sections, lock-free ring buffers, fixed-width types and integer promotion, static allocation, linker map and stack usage analysis, and host-side unit tests for drivers. Use when reviewing firmware C, a variable changes "by itself" or only works with -O0, an ISR and main loop disagree, flash or RAM is nearly full, or a driver needs tests without hardware.

Use it when

  • Reviewing or writing C for a microcontroller
  • Code behaves differently at higher optimisation levels
  • Data shared between an interrupt and the main loop gets corrupted
  • Flash or RAM is nearly full and you need to find the growth

Use something else when

  • The problem is task priorities, queues or RTOS deadlocks: use freertos
  • You are hunting heap misuse in desktop or server C: use c-memory-safety

How to check the result

Build at the release optimisation level with -Wall -Wextra -Wconversion clean, run host tests under ASan and UBSan, and compare scripts/mem_budget.py output against the baseline.

The mistake it prevents

Treating volatile as a synchronisation primitive: it does not make read-modify-write atomic and does not protect data shared between RTOS tasks.

~/.agents/skills/embedded-c/SKILL.md

--- name: embedded-c description: Writes and reviews bare-metal and RTOS C for microcontrollers - volatile and memory-mapped registers, ISR-shared data and atomicity, critical… license: MIT ---

Done well means: every variable shared with an ISR or hardware is correctly qualified and accessed atomically, nothing allocates after initialisation, the build is warning-free at -Wall -Wextra -Wconversion, flash, RAM and stack use are measured against a budget, and the hardware-independent logic has tests that run on the host.

Inspect first

  1. Read AGENTS.md, the build system (CMakeLists.txt, Makefile, IDE project) and the compiler flags actually used. -O0 debug builds hide most of the bugs below.
  2. Find the linker script (*.ld) for memory regions, stack and heap size.
  3. Build once and keep the .map file and arm-none-eabi-size output as the baseline.
arm-none-eabi-size -A -d build/app.elf
python3 scripts/mem_budget.py build/app.elf --flash 0x08000000:512K --ram 0x20000000:128K

scripts/mem_budget.py reads the ELF program headers directly (standard library only), so it works without binutils. It counts initialised .data against both flash and RAM, lists the largest sections, and exits 1 when usage is over --max-flash-pct (default 90) or --max-ram-pct (default 85). Take region bases and sizes from the linker script.

volatile: what it does and does not do

SituationNeeds volatileAlso needs
Memory-mapped peripheral registerYes (vendor headers already do it)Correct access width
Variable written in an ISR, read in main/taskYesAtomic access if wider than the bus or read-modify-write
Flag set by DMA completionYesCache maintenance on cores with D-cache
Variable shared between two RTOS tasksNo, it is not a synchronisation toolA mutex, queue or atomic
Loop counter for a busy-wait delayTechnically yesUse a hardware timer instead

volatile stops the compiler from caching or removing accesses. It does not make x++ atomic, does not order non-volatile accesses around it, and does not flush caches.

ISR-shared data

#include <stdint.h>
#include <stdbool.h>

static volatile uint32_t g_ticks;          /* written only in the ISR */

void SysTick_Handler(void) { g_ticks++; }  /* ISR is the single writer: no lock needed */

uint32_t ticks_now(void) {
    return g_ticks;                         /* 32-bit aligned read is atomic on Cortex-M */
}

When main code must read-modify-write shared state, or read a 64-bit value on a 32-bit core, use a critical section that restores the previous state (nesting-safe):

static inline uint32_t irq_save(void) {
    uint32_t primask = __get_PRIMASK();
    __disable_irq();
    return primask;
}
static inline void irq_restore(uint32_t primask) { __set_PRIMASK(primask); }

uint32_t take_events(void) {
    uint32_t s = irq_save();
    uint32_t e = g_events;                  /* g_events is volatile, set by ISRs */
    g_events = 0;
    irq_restore(s);
    return e;
}
  • Keep critical sections to a few instructions; measure worst-case interrupt latency.
  • Under an RTOS use its API (taskENTER_CRITICAL, irq_lock) instead of raw PRIMASK.
  • On AVR and other 8-bit cores even 16-bit reads tear; wrap them.
  • C11 <stdatomic.h> works on Cortex-M3 and above (LDREX/STREX); on Cortex-M0 atomics wider than a plain load or store fall back to library calls or do not link.

Single-producer single-consumer ring buffer

#define RB_SIZE 256u                          /* power of two */
_Static_assert((RB_SIZE & (RB_SIZE - 1u)) == 0u, "RB_SIZE must be a power of two");

typedef struct {
    uint8_t buf[RB_SIZE];
    volatile uint32_t head;                   /* written by producer (ISR) only */
    volatile uint32_t tail;                   /* written by consumer only */
} ringbuf_t;

bool rb_put(ringbuf_t *rb, uint8_t b) {       /* call from the ISR */
    uint32_t h = rb->head;
    if (h - rb->tail == RB_SIZE) return false; /* full; count drops */
    rb->buf[h & (RB_SIZE - 1u)] = b;
    __DMB();                                  /* data visible before index moves */
    rb->head = h + 1u;
    return true;
}

bool rb_get(ringbuf_t *rb, uint8_t *out) {    /* call from main/task */
    uint32_t t = rb->tail;
    if (rb->head == t) return false;
    *out = rb->buf[t & (RB_SIZE - 1u)];
    __DMB();
    rb->tail = t + 1u;
    return true;
}

Free-running unsigned indices make full and empty distinguishable without wasting a slot. This is only safe with exactly one producer and one consumer.

Integer and type traps

  • Use <stdint.h> types for anything that touches hardware or a wire format.
  • Integer promotion: uint8_t a = 200, b = 100; if (a + b > 255) is true because the sum is int. (uint8_t)(a + b) wraps. Be explicit.
  • ~ on a small unsigned type promotes to int: (uint8_t)~mask when storing back.
  • Shifting 1 << 31 is undefined for signed int; write 1u << 31 or UINT32_C(1) << 31.
  • Do not use C bit-fields to map registers or wire formats; layout and access width are implementation-defined. Use masks and shifts.
  • Do not cast a byte buffer to a struct pointer for parsing: alignment faults on M0 and strict-aliasing UB everywhere. memcpy into a local, or decode field by field with explicit endianness.
  • Floating point on cores without an FPU pulls in kilobytes of soft-float; check the map file for __aeabi_d* and use f suffixes (0.5f) on FPU cores that are single-precision.

Memory discipline

  • Allocate everything at init: static buffers, pools, malloc only before the scheduler starts if at all. Heap fragmentation is a field failure that takes weeks to appear.
  • const tables go to flash; check the map file that they did (missing const puts them in .data, costing flash and RAM).
  • Stack: compile with -fstack-usage (writes .su files) and -Wstack-usage=512 to flag large frames; paint the stack with a pattern at boot and check the watermark at runtime. Recursion and VLAs are banned in firmware for this reason.
  • Read the .map file sorted by size to find what grew: printf with floats, C++ exceptions pulled in by one file, or a debug table left in.

Compiler flags baseline

-std=c11 -Wall -Wextra -Wconversion -Wshadow -Wdouble-promotion -Wundef
-ffunction-sections -fdata-sections -Wl,--gc-sections -Wl,-Map=build/app.map
-Os (or -O2) -g3 -fno-common -fstack-usage

Treat warnings as errors in CI. Debug at -Og, not -O0, so behaviour matches release.

Host-side tests

Split each driver into a hardware access layer (register reads/writes behind a small interface) and logic (parsing, state machines, filters, protocol framing). Build the logic for the host with the system compiler and test it with Unity/Ceedling, CMocka or GoogleTest, mocking the access layer. Run under -fsanitize=address,undefined on the host to catch the overflow and UB that the target silently tolerates. Fuzz parsers of external input (fuzz-testing).

Review checklist

  • Every ISR-shared variable: volatile, single writer or protected, atomic width
  • No blocking, logging or allocation in ISRs; ISR clears its flag early
  • No unchecked return values from HAL/driver calls
  • Wire formats decoded with explicit endianness and length checks
  • Buffer indices bounded; sizeof used, not magic numbers
  • No recursion, VLAs or malloc in steady state
  • Builds warning-free with the baseline flags at the release optimisation level

Verification

Build release flags, run host tests with sanitizers, run mem_budget.py and compare with the baseline, and check .su output for the deepest call path. When a bug “only happens at -O2”, look for a missing volatile, a data race or UB before blaming the compiler.

Report

**Scope:** uart_rx.c, sensor_task.c, ringbuf.h (STM32G0, GCC 13.3, -Os)
**Findings:** 1 blocker (64-bit timestamp read torn between ISR and task), 2 major (struct cast
on unaligned buffer; 1 << 31 on int), 3 minor
**Memory:** flash 61.3 % -> 59.8 %, RAM 71.0 % -> 71.4 % (mem_budget.py)
**Tests:** 18 host tests for frame parser under ASan/UBSan, all passing

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Embedded C"
  short_description: "volatile, ISRs and memory budgets done right"
  default_prompt: "$embedded-c This counter works at -O0 but not -O2, find the bug in the ISR code"
scripts/mem_budget.py137 lines, 5.1 KB
#!/usr/bin/env python3
"""Report flash and RAM use of a firmware ELF against a budget.

Reads PT_LOAD program headers directly (no binutils needed):
  flash = file bytes of segments whose load address (LMA) is in the flash region
  ram   = memory bytes of segments whose run address (VMA) is in the RAM region
so initialised .data counts in both, as it does on the chip.

Usage:
  python3 mem_budget.py app.elf --flash 0x08000000:512K --ram 0x20000000:128K \
      [--max-flash-pct 90] [--max-ram-pct 85] [--sections 10]

Exit code 0 when within budget, 1 when over budget, 2 on bad input.
"""
import argparse
import struct
import sys

PT_LOAD = 1
SHF_ALLOC = 0x2
SHT_NOBITS = 8


def parse_region(text):
    try:
        base, size = text.split(":")
        mult = 1
        s = size.strip().upper()
        if s.endswith("K"):
            mult, s = 1024, s[:-1]
        elif s.endswith("M"):
            mult, s = 1024 * 1024, s[:-1]
        return int(base, 0), int(s, 0) * mult
    except ValueError:
        raise argparse.ArgumentTypeError(f"region must look like 0x08000000:512K, got {text!r}")


def read_elf(path):
    with open(path, "rb") as f:
        data = f.read()
    if data[:4] != b"\x7fELF":
        raise ValueError("not an ELF file")
    is64 = data[4] == 2
    end = "<" if data[5] == 1 else ">"
    if is64:
        hdr = struct.unpack_from(end + "HHIQQQIHHHHHH", data, 16)
        _, _, _, _, phoff, shoff, _, _, phentsize, phnum, shentsize, shnum, shstrndx = hdr
    else:
        hdr = struct.unpack_from(end + "HHIIIIIHHHHHH", data, 16)
        _, _, _, _, phoff, shoff, _, _, phentsize, phnum, shentsize, shnum, shstrndx = hdr

    segments = []
    for i in range(phnum):
        off = phoff + i * phentsize
        if is64:
            p_type, _, _, vaddr, paddr, filesz, memsz, _ = struct.unpack_from(end + "IIQQQQQQ", data, off)
        else:
            p_type, _, vaddr, paddr, filesz, memsz, _, _ = struct.unpack_from(end + "IIIIIIII", data, off)
        if p_type == PT_LOAD:
            segments.append((vaddr, paddr, filesz, memsz))

    sections = []
    if shoff and shnum:
        raw = []
        for i in range(shnum):
            off = shoff + i * shentsize
            if is64:
                name, stype, flags, addr, _, size = struct.unpack_from(end + "IIQQQQ", data, off)
            else:
                name, stype, flags, addr, _, size = struct.unpack_from(end + "IIIIII", data, off)
            raw.append((name, stype, flags, addr, size))
        strtab_off = None
        if shstrndx < len(raw):
            idx = shoff + shstrndx * shentsize
            strtab_off = struct.unpack_from(end + ("Q" if is64 else "I"), data, idx + (24 if is64 else 16))[0]
        for name, stype, flags, addr, size in raw:
            if not flags & SHF_ALLOC or size == 0:
                continue
            label = "?"
            if strtab_off is not None:
                start = strtab_off + name
                label = data[start:data.index(b"\0", start)].decode(errors="replace")
            sections.append((label, addr, size, stype == SHT_NOBITS))
    return segments, sections


def within(addr, region):
    base, size = region
    return base <= addr < base + size


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("elf")
    ap.add_argument("--flash", type=parse_region, required=True)
    ap.add_argument("--ram", type=parse_region, required=True)
    ap.add_argument("--max-flash-pct", type=float, default=90.0)
    ap.add_argument("--max-ram-pct", type=float, default=85.0)
    ap.add_argument("--sections", type=int, default=10, help="show the N largest sections")
    args = ap.parse_args()

    try:
        segments, sections = read_elf(args.elf)
    except (OSError, ValueError, struct.error) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    flash = sum(f for v, p, f, m in segments if within(p, args.flash))
    ram = sum(m for v, p, f, m in segments if within(v, args.ram))
    fpct = 100.0 * flash / args.flash[1]
    rpct = 100.0 * ram / args.ram[1]

    print(f"flash: {flash:>9,} / {args.flash[1]:,} B  ({fpct:5.1f} %, limit {args.max_flash_pct:.0f} %)")
    print(f"ram:   {ram:>9,} / {args.ram[1]:,} B  ({rpct:5.1f} %, limit {args.max_ram_pct:.0f} %)")
    print("       (ram is static only: .data + .bss + reserved stack/heap sections)")

    if sections and args.sections > 0:
        print(f"\nlargest {args.sections} allocated sections:")
        for label, addr, size, nobits in sorted(sections, key=lambda s: -s[2])[: args.sections]:
            where = "ram" if within(addr, args.ram) else "flash" if within(addr, args.flash) else "other"
            kind = "nobits" if nobits else "data"
            print(f"  {label:<24} 0x{addr:08x} {size:>9,} B  {where:<5} {kind}")

    over = []
    if fpct > args.max_flash_pct:
        over.append("flash")
    if rpct > args.max_ram_pct:
        over.append("ram")
    if over:
        print(f"\nOVER BUDGET: {', '.join(over)}")
        return 1
    print("\nwithin budget")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem