Stack Check

make BOARD=<b> stack-check computes the worst-case stack depth of every FreeRTOS task in a build and fails when a task's deepest call chain doesn't fit its stack. tools/ci.sh runs it on each board's default and maximal configuration. It exists because an overflow is invisible elsewhere: the host tests run the same code on large pthread stacks, and on the radio FreeRTOS' overflow hook spins without a word. A 768-byte buffer in codeplug_contact_find once overflowed the start task at boot; the check reported that chain 424 bytes over.

On the radio, the debug link's tasks view section shows what each task has really used: its least free stack since it started (debuglink.md 3.7, CONFIG_TASK_STACKS), and tests/hil/test_task_stacks.py checks every task keeps 64 bytes. The check here is the worst case over every path; the view is what the code that ran so far needed. A task low in the view but fine here points at a call the check doesn't see (a table line).

How it works

Input From
Frames and direct calls GCC's -fcallgraph-info=su, always on (STACK_INFO in the Makefile): a .ci file next to each object, or one per LTO partition next to the .elf. It changes no code: the image is byte for byte the same without it
Tasks Every xTaskCreate(fn, "name", STACK, ...) in the compiled sources. STACK is evaluated with the build's own preprocessor and flags, so #if choices count. FreeRTOS' timer task is found the same way; its idle task is in the table
Calls through pointers The sources, below, then the table
Code without a call graph The disassembly of the .elf (objdump): the C library, the compiler's runtime, assembly. Every stack adjustment in a function is added up and direct calls are followed

For each task the tool takes the deepest chain from the entry function:

free = stack - (deepest chain + port overhead + margin)
Term Value
Port overhead What the RTOS port keeps on a task's stack besides the chain (STACK_OVERHEAD in platform.mk). STM32F4: 208 bytes, the exception frame with the FPU's lazy space and alignment (27 words) plus the context switch's r4-r11, lr and s16-s31 (25 words); interrupts then run on the main stack. HR_C7000: 68 bytes, the 17-word frame of an interrupt or a yield; handlers run on the interrupt stack and don't nest
Margin STACK_MARGIN, 128 bytes by default

A task over its stack prints OVER and its whole chain with each frame.

Pointer calls

A call through a function pointer has no target in GCC's graph. The tool reads the call site's source and works out what the pointer can hold:

Call Targets
Through a struct field, board.radio->tx(...), s->cb(...) The struct's type is followed from the variable's declaration (a parameter, a local, a file-scope or extern variable) through the members. Every function stored into that field of that type: designated or positional initializers, assignments, compound literals
Through a variable or parameter What is assigned to the variable; for a parameter, what callers pass in that position. Stores of one pointer into another are followed
Inside a macro, LOCK() The macro is expanded first
In an argument, f(x, p->g()) GCC files it at the outer call's position; the tool looks inside the arguments

A pointer the sources store into but that never holds a function of this build is not called: an optional board_t operation the radio leaves NULL, a callback whose setter nothing in the build calls. -v lists these. A pointer with no store in sight is reported as unresolved. Unresolved calls don't fail the check, but each one could hide a deeper chain: add it to the table.

The table

tools/stack_check_edges.txt holds what the sources and the graph can't show, for every board in one file:

Line Meaning
task FUNCTION STACK A task the scan can't find (FreeRTOS' idle task: its entry is a variable)
ptr NAME TARGET... Calls through a field, variable or parameter called NAME
call FUNCTION TARGET... Every pointer call FUNCTION makes
file PATH TARGET... Pointer calls in PATH the sources don't resolve
lib FUNCTION BYTES A fixed stack for a function without call graph; also cuts the disassembly walk there
extern NAME BYTES Code outside the build, a target written @NAME (the codec)
recursion FUNCTION... Recursion known to be bounded

The AMBE+2 codec runs on the audio task's stack: 4046 bytes measured on an STM32F4 radio (codec-<id>.md), so stm32_codec_call counts 4104 with the veneer. The HR_C7000 builds call the same library by address with 4096 bytes assumed (not measured on C-SKY).

A few C library paths need arguments RadioFoundry never passes and are cut with lib lines: newlib's strstr for needles of 32 characters or more, and the floating point conversions of printf. Check the comment in the table before adding a call that breaks the assumption (a long strstr needle, %f).

Reading the report

stack check <b>: port overhead 68 bytes, margin 128 bytes, 10 tasks (bytes)
  start    ok   stack  2560  deepest  1892  free   472  (startup_task, core/app/app.c:118)
      startup_task > radio_core_start > radio_sm_init > aprs_init > reload_cfg > load_identity > codeplug_aprs > flas...
  ...
  prvIdleTask ok   stack   512  deepest   140  free   176  (prvIdleTask, table)
      prvIdleTask > prvCheckTasksWaitingTermination > vPortFree > xTaskResumeAll > xTaskIncrementTick > vApplicationT...
  recursion (each cycle counted once): materialize, n_macs, n_receive, n_select, n_selected, n_send, n_tick, n_up, room
  21 functions without a call graph counted as 64 bytes each (-v lists them)
stack check <b>: every task fits, the least free: prvIdleTask 176 bytes
Option Shows
make ... stack-check STACK_CHECK_ARGS=-v Every task's chain with frames and files, the calls resolved by the field name alone (type unknown at the call site), the pointers never set, the library functions taken from the disassembly
STACK_MARGIN=256 A different margin
STACK_CHECK_REPORT_ONLY=1 Reports without failing (a board's config.mk may set it while its graph is incomplete; none does)

Limits

  • Recursion is counted once and listed. RadioFoundry has no deep recursion; the cycles found are mutual calls through callbacks (the network stack's n_*, the audio mixer).
  • Interrupt handlers aren't checked: they run on the main (STM32) or interrupt (HR_C7000) stack, not a task's.
  • The code before the scheduler starts (main) runs on the main stack and isn't checked.
  • A pointer call whose target is computed at run time (an address from a table the sources don't spell) needs a table line.
  • The disassembly walk adds every stack adjustment of a function, so a library function with several exit paths counts more than it uses. It doesn't see calls through registers in library code.

View the source of this page