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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- embedded-c Installs to ~/.agents/skills/embedded-c/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install embedded-c The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project embedded-c Installs to .agents/skills/embedded-c/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/embedded-c.zipunzip -o embedded-c.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) embedded-c PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.md, the build system (CMakeLists.txt,Makefile, IDE project) and the compiler flags actually used.-O0debug builds hide most of the bugs below. - Find the linker script (
*.ld) for memory regions, stack and heap size. - Build once and keep the
.mapfile andarm-none-eabi-sizeoutput 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
| Situation | Needs volatile | Also needs |
|---|---|---|
| Memory-mapped peripheral register | Yes (vendor headers already do it) | Correct access width |
| Variable written in an ISR, read in main/task | Yes | Atomic access if wider than the bus or read-modify-write |
| Flag set by DMA completion | Yes | Cache maintenance on cores with D-cache |
| Variable shared between two RTOS tasks | No, it is not a synchronisation tool | A mutex, queue or atomic |
| Loop counter for a busy-wait delay | Technically yes | Use 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 isint.(uint8_t)(a + b)wraps. Be explicit. ~on a small unsigned type promotes toint:(uint8_t)~maskwhen storing back.- Shifting
1 << 31is undefined for signedint; write1u << 31orUINT32_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.
memcpyinto 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 usefsuffixes (0.5f) on FPU cores that are single-precision.
Memory discipline
- Allocate everything at init: static buffers, pools,
malloconly before the scheduler starts if at all. Heap fragmentation is a field failure that takes weeks to appear. consttables go to flash; check the map file that they did (missingconstputs them in.data, costing flash and RAM).- Stack: compile with
-fstack-usage(writes.sufiles) and-Wstack-usage=512to 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
.mapfile 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;
sizeofused, not magic numbers - No recursion, VLAs or
mallocin 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.yaml
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.py
#!/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.