Skip to content

Part 1 · The C Language33 min read

Functions and the Calling Convention

A function call looks free in C source. On the Cortex-M4 it is a precise protocol: arguments go into specific registers, the return address goes into the link register, some registers must be preserved and others may be destroyed, and the stack pointer must stay aligned to 8 bytes. That protocol, the Arm Architecture Procedure Call Standard (AAPCS), is what lets code from different compilers, assembly files and libraries call each other, and it is what you will be reading when you debug a HardFault from a stack dump. This lesson covers how to write functions well in C (prototypes, value semantics, static, inline, variadic functions, recursion) and then follows a call all the way down to the registers and the stack, finishing with the GCC attributes that firmware relies on.

A prototype is a function declaration that lists the parameter types:

int32_t adc_to_millivolts(uint16_t raw, uint32_t vref_mv);

Before the compiler can generate a correct call, it must see a prototype. It uses it to check the number of arguments, convert each argument to the parameter type (so passing the int constant 3300 to a uint32_t parameter is fine), and know the return type.

What happens without one? In C89, calling an undeclared function was allowed: the compiler assumed it returned int and passed the arguments after the default argument promotions (char and short to int, float to double). C99 removed implicit declarations, and modern GCC rejects them (GCC 14 makes it an error by default; earlier versions warn, and you should use -Werror=implicit-function-declaration as recommended in Hello, C: the compilation pipeline).

There is a subtler hole that still exists in C11. An empty parameter list in a declaration does not mean “no parameters”. It means “parameters unspecified”, which is not a prototype at all:

gain.c
#include <stdio.h>
void set_gain(float g)
{
printf("gain = %f\n", (double)g);
}
main.c
void set_gain(); /* old-style declaration: no parameter information */
int main(void)
{
set_gain(0.5f); /* the float is promoted to double, because no prototype is visible */
return 0;
}

This compiles and links without a single warning under -Wall -Wextra. The call passes a double; the function expects a float. On the Cortex-M4 with the hard-float ABI, a prototyped call would put the float in register s0. Without a prototype the caller passes a double (in d0, the register pair s0:s1), and the callee reads s0: the low half of the double’s bit pattern, which for 0.5 is zero. The program prints gain = 0.000000. On other ABIs it prints other garbage. The linker cannot catch this because it matches names, not types.

The defenses:

  • Write (void) for functions with no parameters: void led_init(void);. (C23 finally makes () mean the same as (void), but C11 code must be explicit.)
  • Put every public prototype in a header and include that header in the file that defines the function, so the compiler checks the definition against the declaration.
  • Enable -Wstrict-prototypes (warns about declarations without parameter types) and -Wmissing-prototypes (warns when a non-static function is defined with no previous prototype, which catches both a forgotten header and a function that should have been static).

C passes every argument by value: the function receives a copy, and assigning to a parameter never affects the caller’s variable.

#include <stdint.h>
void clamp_in_place(int32_t v, int32_t max)
{
if (v > max) {
v = max; /* modifies the local copy only */
}
}

Calling clamp_in_place(x, 100) leaves x unchanged. To modify a caller’s variable, the function must receive its address, which is the out-parameter pattern:

#include <stdbool.h>
#include <stdint.h>
bool parse_digit(char c, uint8_t *out)
{
if (c < '0' || c > '9') {
return false; /* *out is untouched on failure */
}
*out = (uint8_t)(c - '0');
return true;
}

The caller writes uint8_t d; if (parse_digit(ch, &d)) { ... }. &d is the address of d, and *out = ... writes through it. This is the idiomatic way to return a status and a value at the same time. Pointers I: addresses, arithmetic, and const explains the mechanics properly; for now, recognize the shape.

A struct argument is copied in full, every call:

#include <stdint.h>
typedef struct {
uint32_t baud;
uint8_t data_bits;
uint8_t stop_bits;
uint8_t parity;
uint8_t flow_control;
uint32_t rx_timeout_ms;
uint32_t tx_timeout_ms;
uint8_t reserved[48];
} uart_config_t; /* 64 bytes */
void uart_configure(uart_config_t cfg); /* copies 64 bytes per call */
void uart_configure_ref(const uart_config_t *cfg); /* passes one 4-byte address */

For the by-value version, the AAPCS passes the first 16 bytes of the struct in r0 to r3 and the remaining 48 bytes on the stack, so every call copies the struct into registers and the stack, and the callee usually spills it to memory again. For large structs, pass a pointer to const: it is one register, and the const documents and enforces that the function does not modify the caller’s data. Small structs (8 bytes or so) passed by value are fine and often clearer. Structs, unions, enums, and memory layout covers struct layout and size.

A function can also return a struct by value, which is a clean way to return several values:

#include <stdint.h>
typedef struct {
int16_t centidegrees_c; /* temperature in 0.01 degree C steps */
uint8_t status;
} temp_reading_t; /* 4 bytes with padding */
typedef struct {
int32_t x_mg;
int32_t y_mg;
int32_t z_mg;
} accel_sample_t; /* 12 bytes */
temp_reading_t temp_read(void);
accel_sample_t accel_read(void);

How the value comes back depends on its size. Under the AAPCS, a struct of at most 4 bytes is returned in r0, like an integer. Anything larger is returned through memory: the caller reserves space (usually in its own stack frame) and passes its address as a hidden first argument in r0, shifting the real arguments along by one register; the callee writes the result there. So accel_read() is really compiled as if it were void accel_read(accel_sample_t *result). It costs a few stores, not a disaster, but it is worth knowing when you read the disassembly and see an unexpected address in r0.

As Variables, scope, lifetime, and storage classes explained, static on a function gives it internal linkage: it is invisible outside its .c file. Beyond encapsulation, this unlocks optimization. When a static function is called from only one place, GCC inlines it and emits no separate copy. When all its calls are inlined, the function disappears from the object file entirely. A non-static function must always be emitted, because some other file might call it. Make helper functions static by default.

Calling a three-instruction function costs more than the three instructions, as the calling convention section below shows. Inlining replaces the call with a copy of the function body, removing the call overhead and letting the optimizer specialize the body for the arguments at that call site.

The compiler inlines on its own at -O2, for any function whose body it can see, according to its cost model. The inline keyword is a hint, and in C its main effect is on linkage, with semantics that surprise people coming from C++:

mathx.h
inline int32_t sq(int32_t x) { return x * x; } /* C99 inline definition */

Include this header and call sq in main.c, compile with -O0, and the link fails with undefined reference to 'sq'. In C, a function declared inline without static or extern provides an inline definition, which is not an external definition. The compiler may use it to inline the call, but if it decides to emit a call instead (and at -O0 GCC never inlines), it calls an external symbol sq that nobody defined. The standard fix is to add, in exactly one .c file, the declaration extern inline int32_t sq(int32_t x);, which makes that file emit the external definition.

That is fiddly, so firmware headers almost universally use static inline:

mathx.h
#ifndef MATHX_H
#define MATHX_H
#include <stdint.h>
static inline int32_t sq(int32_t x)
{
return x * x;
}
#endif

Every file that includes the header gets its own private copy, used for inlining. If a file calls it without inlining, that file emits a local (t in nm) copy, which is harmless apart from code size. Unused static inline functions produce no code and no warning.

The compiler may ignore inline when:

  • optimization is off (-O0), unless the function is also marked always_inline;
  • the body is too large for its cost model, or inlining would bloat code at -Os;
  • the function is recursive, or variadic;
  • the function’s address is taken (it still inlines direct calls, but must also emit a copy for the pointer).

The reverse is also true: the compiler inlines functions you never marked inline. Treat inline as “it is fine to put this definition in a header”, and control inlining precisely with the attributes at the end of this lesson when it truly matters.

Function-like macros versus inline functions

Section titled “Function-like macros versus inline functions”

Before C99, the only way to avoid call overhead was a macro:

#define SQ(x) ((x) * (x))

The macro is text substitution, which brings two classic problems. SQ(i++) expands to ((i++) * (i++)), which modifies i twice without a sequence point, undefined behavior, as you saw in Operators, expressions, and implicit conversions. And there is no type checking: SQ(read_sensor()) calls the sensor twice. A static inline function evaluates each argument exactly once, has real parameter types, appears in the debugger and in stack traces, and generates the same code when inlined. Prefer it. Macros remain the right tool when you need things a function cannot do: work for any type, stringify an argument, paste tokens, or capture __FILE__ and __LINE__ at the call site. The preprocessor, used well covers those.

A variadic function takes a variable number of arguments, written with ... after at least one named parameter. printf is the famous one. The macros in stdarg.h access the extra arguments:

MacroPurpose
va_list ap;A cursor over the variable arguments
va_start(ap, last_named)Initialize the cursor, given the last named parameter
va_arg(ap, type)Fetch the next argument, which must have exactly this (promoted) type
va_end(ap)Clean up; required before returning
va_copy(dst, src)Duplicate a cursor, to traverse the arguments twice

The function has no way to know how many arguments were passed or their types. Some convention must tell it: a count, a sentinel value at the end, or a format string. A minimal example with a count:

sum.c
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <inttypes.h>
static uint32_t sum_u32(uint32_t count, ...)
{
va_list ap;
uint32_t total = 0;
va_start(ap, count);
for (uint32_t i = 0; i < count; i++) {
total += va_arg(ap, uint32_t);
}
va_end(ap);
return total;
}
int main(void)
{
uint32_t t = sum_u32(3, UINT32_C(10), UINT32_C(20), UINT32_C(30));
printf("%" PRIu32 "\n", t);
return 0;
}
60

The type passed to va_arg must match the type of the argument after default promotion. Two consequences:

  • va_arg(ap, float), va_arg(ap, char), va_arg(ap, uint8_t) and va_arg(ap, int16_t) are all wrong, because those types never arrive: floats arrive as double, small integers as int. Fetch double or int and convert.
  • The caller must pass exactly the expected type. Writing sum_u32(3, 10u, 20u, 30u) passes unsigned int, which happens to be uint32_t on your PC but not on arm-none-eabi, where uint32_t is unsigned long. Both are 32 bits so it would work there by accident, but the types do not match. UINT32_C(10) produces a constant of the right type everywhere. The compiler cannot check any of this for you, which is the fundamental weakness of variadic functions.

The most common variadic function you will write wraps vsnprintf, the version of snprintf that takes a va_list instead of ...:

log.h
#ifndef LOG_H
#define LOG_H
typedef enum { LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG } log_level_t;
void log_set_level(log_level_t level);
void log_msg(log_level_t level, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
#endif
log.c
#include <stdarg.h>
#include <stdio.h>
#include "log.h"
void console_write(const char *s, size_t len); /* UART transmit, provided by the board layer */
static log_level_t log_threshold = LOG_INFO;
void log_set_level(log_level_t level)
{
log_threshold = level;
}
void log_msg(log_level_t level, const char *fmt, ...)
{
static const char tag[] = "EWID"; /* one letter per level */
char line[96];
va_list ap;
if (level > log_threshold) {
return; /* filtered out: no formatting cost */
}
line[0] = '[';
line[1] = tag[level];
line[2] = ']';
line[3] = ' ';
va_start(ap, fmt);
int len = vsnprintf(&line[4], sizeof line - 4u, fmt, ap);
va_end(ap);
if (len < 0) {
return; /* formatting error */
}
size_t total = 4u + (size_t)len;
if (total > sizeof line - 1u) {
total = sizeof line - 1u; /* message was truncated to fit */
}
console_write(line, total);
}

A call such as log_msg(LOG_WARN, "battery low: %u mV\r\n", (unsigned)mv); sends [W] battery low: 3312 mV. Points worth noting:

  • It is named log_msg, not log. log is the natural logarithm in math.h, and names of standard library functions are reserved for the implementation even if you do not include the header.
  • __attribute__((format(printf, 2, 3))) tells GCC that parameter 2 is a printf-style format string and the variable arguments start at parameter 3. With it, -Wall checks every call to log_msg exactly as it checks printf, recovering most of the type safety variadic functions lose. Put it on every printf-like function you write.
  • vsnprintf never writes past sizeof line - 4 bytes, always NUL-terminates, and returns the length the full message would have had. That is why the code clamps total. Arrays, strings, and tables covers the snprintf family in depth.
  • The level check comes first, so filtered messages cost only a compare. Removing them at compile time, and the costs of printf-style formatting on a microcontroller, are covered in The preprocessor, used well and The C standard library on a microcontroller.

On the Cortex-M, arguments to a variadic function travel just like ordinary ones: the first four words in r0 to r3, the rest on the stack. The callee’s prologue then pushes r0 to r3 onto the stack, directly below the caller’s stack arguments, so that all the variable arguments sit in one contiguous block of memory. A va_list is little more than a pointer into that block, and va_arg reads at the pointer and advances it. One important detail: variadic calls always use the base (soft-float) convention, so even with -mfloat-abi=hard, a double passed to printf travels in a pair of core registers (an even-numbered one first, such as r2:r3), never in the FPU registers.

A recursive function calls itself. It is natural for recursive data structures, and firmware avoids it almost entirely. Each call consumes a new stack frame, and the depth depends on the input:

#include <stdint.h>
/* Sum of decimal digits, recursively: depth equals the number of digits */
uint32_t digit_sum_rec(uint32_t n)
{
if (n < 10u) {
return n;
}
return (n % 10u) + digit_sum_rec(n / 10u);
}
/* The same computation with a loop: constant stack use */
uint32_t digit_sum(uint32_t n)
{
uint32_t sum = 0;
while (n != 0u) {
sum += n % 10u;
n /= 10u;
}
return sum;
}

For digit sums the depth is at most 10, which is harmless. The problem is that in general you cannot know the maximum depth at compile time, and neither can the tools. On a microcontroller the stack is a fixed region (often 1 to 8 KB per RTOS task) with no guard page: overflowing it silently overwrites whatever lies below, usually .bss or another task’s stack, and the failure appears somewhere unrelated. GCC’s -fstack-usage reports each function’s frame size, and static analysis tools combine those numbers along the call graph to compute a worst case, but a cycle in the call graph defeats them. That is why MISRA C forbids recursion and most firmware standards follow suit.

GCC may turn a self-call in tail position into a loop at -O2 (tail-call optimization), but this is an optimization, not a guarantee: it does not happen at -O0, and the digit_sum_rec call above is not in tail position because the addition happens after it returns. (GCC can still turn simple cases like this one, where the pending work is an addition, into a loop by introducing an accumulator, but nothing guarantees it.) Rewrite recursion as a loop, or as a loop with an explicit, fixed-size stack array when the algorithm genuinely needs backtracking. Memory: where everything lives shows how to measure real stack usage.

Now look underneath. The AAPCS defines how the 16 core registers of the Cortex-M are used across a call:

RegisterAliasRole in a callPreserved across a call?
r0-r3a1-a4Arguments, return value, scratchNo (caller-saved)
r4-r8v1-v5Local variablesYes (callee-saved)
r9v6Platform register; an ordinary callee-saved register in bare-metal GCC buildsYes
r10, r11v7, v8Local variablesYes
r12ipScratch (intra-procedure call)No
r13spStack pointerYes (must be restored)
r14lrLink register: return addressNo (overwritten by every call)
r15pcProgram countern/a
s0-s15d0-d7FPU arguments and return value (hard-float ABI), scratchNo
s16-s31d8-d15FPU localsYes

The rules, condensed:

  • Arguments. The first four 32-bit argument words go in r0, r1, r2, r3, in order. A 64-bit argument (int64_t, or double under soft-float) takes an even/odd register pair (r0:r1 or r2:r3), skipping a register if necessary. Everything beyond that goes on the stack, the first stack argument at the address in sp when the function is entered. With the hard-float ABI used in this course, float and double arguments go in s0-s15 / d0-d7 instead, except in variadic calls.
  • Return values. Up to 32 bits in r0; 64-bit values in r0:r1; float in s0 and double in d0 with hard-float; composite types larger than 4 bytes via the hidden pointer described earlier.
  • Preservation. A function may freely destroy r0-r3, r12, lr and s0-s15. If it wants to use r4-r11 or s16-s31, it must save them first and restore them before returning. A caller that needs a value to survive a call must therefore keep it in a callee-saved register or on the stack.
  • The stack grows downward (toward lower addresses) and sp points at the last item pushed (a “full descending” stack). sp must always be 4-byte aligned, and 8-byte aligned at every call boundary, so that 64-bit values and double on the stack are naturally aligned.
  • Calls and returns. bl func branches to func and puts the return address in lr (with bit 0 set, marking Thumb state). A function returns with bx lr, or by popping the saved return address directly into pc.
#include <stdint.h>
int32_t scale(int32_t raw, int32_t gain); /* defined in another file */
int32_t read_and_scale(int32_t raw, int32_t gain, int32_t offset)
{
int32_t v = scale(raw, gain);
return v + offset;
}

Compiled with -O2 -mcpu=cortex-m4 -mthumb:

read_and_scale:
push {r4, lr} @ save callee-saved r4 and our return address
mov r4, r2 @ offset must survive the call: move it to a preserved register
bl scale @ raw and gain are already in r0 and r1; lr = return address
add r0, r0, r4 @ v (in r0) + offset
pop {r4, pc} @ restore r4, and load the saved lr straight into pc: return

Every line follows from the rules:

  • offset arrived in r2, but scale may destroy r0-r3. The compiler moves it to r4, which scale must preserve.
  • Because this function now uses r4, it must save the caller’s r4. Because it calls scale, the bl will overwrite lr, so it must save its own return address too. push {r4, lr} does both, storing 8 bytes and keeping sp 8-byte aligned.
  • raw and gain are already in r0 and r1, exactly where scale expects its arguments, so no moves are needed.
  • pop {r4, pc} restores r4 and loads the saved return address into pc, which returns in the same instruction.

You will often see push {r3, lr} in functions that do not otherwise touch r3. The r3 is there only to make the push 8 bytes and keep the stack aligned. A leaf function (one that calls nothing), such as scale in Hello, C: the compilation pipeline, needs no push at all: it leaves the return address in lr and ends with bx lr.

Here is what the stack looks like when main calls sensor_task, which has a local array and calls the leaf function average4:

#include <stdint.h>
void adc_read_block(int16_t *dst, uint32_t n);
int32_t average4(const int16_t *s); /* leaf: calls nothing */
int32_t sensor_task(int32_t calibration)
{
int16_t samples[4]; /* 8 bytes, must live in memory */
adc_read_block(samples, 4u);
return average4(samples) + calibration;
}
higher addresses
+----------------------------------+
| main's frame |
| saved registers, saved lr |
| main's locals |
+----------------------------------+ <- sp at the call to sensor_task (8-byte aligned)
| sensor_task's frame |
| saved lr (return into main) |
| saved r4 (holds calibration)|
| samples[3] samples[2] |
| samples[1] samples[0] | <- address passed in r0 to adc_read_block
+----------------------------------+ <- sp at the calls to adc_read_block and average4
| average4: a leaf, typically |
| no frame at all; lr stays in |
| the register |
+----------------------------------+
lower addresses: the stack grows down, toward the end of .bss

calibration must survive two calls, so it lives in callee-saved r4, which is why r4 is saved. samples must have an address to pass to adc_read_block, so it cannot live in registers and gets 8 bytes of stack. The frame is 16 bytes in total, a multiple of 8. When a fault happens, a debugger reconstructs the call chain (“backtrace”) by walking exactly these saved lr values, using the unwind information the compiler records with -g.

There is one more reason the caller-saved set is what it is. When an interrupt fires, the Cortex-M hardware automatically pushes r0-r3, r12, lr, pc and xPSR: exactly the registers a C function is allowed to destroy, plus the state needed to resume. Because of that design, an interrupt handler on Cortex-M is an ordinary C function with no special keyword. The Cortex-M architecture covers exception entry in detail.

On the Cortex-M4, with code running from zero-wait-state memory:

StepCycles
Moving arguments into r0-r3about 1 each, often 0 if they are already there
bl1 + P
push of N registers1 + N
pop of N registers including pc1 + N + P
bx lr (leaf return)1 + P

P is the pipeline refill, 1 to 3 cycles. A call to a small non-leaf function therefore costs roughly 8 to 15 cycles of overhead, around 50 to 90 ns at 168 MHz, before the function does any useful work. Running from flash at 168 MHz adds wait states, mostly hidden by the STM32F4’s flash accelerator when the code is in its cache. For a function called a few times per millisecond this is irrelevant. For a three-instruction helper called inside a sample-processing loop a million times per second, it dominates, and that is when inlining matters. Measure before deciding; Performance and code size optimization shows how with the DWT cycle counter.

GCC’s __attribute__((...)) syntax attaches extra information to a declaration. It is not ISO C, but GCC, Clang and Arm’s compiler all support it, and -Wpedantic does not complain about it. (C23 adds a standard syntax, such as [[noreturn]] and [[gnu::weak]].) The ones you will meet constantly:

AttributeEffectTypical firmware use
noreturnThe function never returns; the compiler drops code after calls to it and warns if it can returnFatal error handlers, system_reset(), a scheduler start function
weakA definition that a normal (“strong”) definition elsewhere silently replacesDefault interrupt handlers, overridable board hooks
usedEmit the function or variable even if the compiler sees no reference to itCode referenced only from assembly, a linker script or a debugger
noinlineNever inline this functionKeeping a function breakpointable and visible in profiles, keeping rare code out of a hot path
always_inlineInline at every call, even at -O0, or fail with an errorTiny register-access helpers and intrinsics (CMSIS uses it)
nakedEmit no prologue or epilogue; the body must be basic assembly onlyFault handlers and context switches that need the exact register state on entry
format(printf, m, n)Check calls as printf-style, format at parameter m, arguments from nLoggers, as above

C11 also standardized noreturn as the keyword _Noreturn, with the friendlier macro noreturn in stdnoreturn.h:

#include <stdint.h>
#include <stdnoreturn.h>
#define SCB_AIRCR (*(volatile uint32_t *)0xE000ED0Cu)
#define AIRCR_VECTKEY (0x05FAu << 16) /* writes are ignored without this key */
#define AIRCR_PRIGROUP_MASK (7u << 8)
#define AIRCR_SYSRESETREQ (1u << 2)
noreturn void system_reset(void)
{
__asm volatile ("dsb" ::: "memory"); /* complete outstanding memory writes */
SCB_AIRCR = AIRCR_VECTKEY | (SCB_AIRCR & AIRCR_PRIGROUP_MASK) | AIRCR_SYSRESETREQ;
__asm volatile ("dsb" ::: "memory");
for (;;) {
/* the reset takes a few cycles to happen */
}
}

The function requests a system reset through the SCB AIRCR register (preserving the interrupt priority grouping field), and the trailing infinite loop is required: if a noreturn function could fall off its end, the compiler warns, and callers have been compiled assuming it never comes back. Clocks, reset, and timing discusses reset sources. (This example contains Arm instructions, so it builds only with arm-none-eabi-gcc.)

weak is the mechanism behind every startup file’s interrupt table. The startup code defines each handler as a weak alias of a Default_Handler that loops forever; when your code defines void SysTick_Handler(void), your strong definition replaces the weak one at link time. Startup code and the vector table builds this. The same mechanism produces one of the most confusing firmware bugs:

naked functions are rare but important. Because the compiler generates no prologue, the registers on entry are exactly as the hardware left them, which a fault handler needs in order to find the stacked exception frame:

__attribute__((naked)) void HardFault_Handler(void)
{
__asm volatile (
"tst lr, #4 \n" /* EXC_RETURN bit 2: which stack was in use? */
"ite eq \n"
"mrseq r0, msp \n" /* main stack */
"mrsne r0, psp \n" /* process stack */
"b hardfault_c \n" /* C handler receives the frame address in r0 */
);
}

The body is only basic assembly: no C statements, no local variables, no use of parameters, because without a prologue the compiler has nowhere to put them. Watchdogs, faults, and recovering from them completes this handler.

Finally, functions can be stored in variables and called indirectly through function pointers, which is how callbacks, command tables and driver interfaces work. They have their own syntax and pitfalls, covered in Pointers II: function pointers, declarations, and bugs.

  • Declaring void f(); instead of void f(void);: no prototype, so argument types are never checked. Write (void) and enable -Wstrict-prototypes.
  • Not including a module’s own header in its .c file: the definition and declaration can drift apart silently. Always include it.
  • Passing large structs by value: copies on every call and grows the stack. Pass const T *.
  • Plain inline in a header: undefined reference at -O0. Use static inline.
  • Function-like macros with side-effecting arguments: arguments are evaluated more than once. Use a static inline function.
  • va_arg(ap, float) or va_arg(ap, uint8_t): those types are promoted before the call. Fetch double or int.
  • Variadic printf-style functions without format: no checking of arguments. Add __attribute__((format(printf, m, n))).
  • Naming a function after a standard library function (log, time, remove): collides with reserved names. Prefix your module name.
  • Recursion in firmware: unbounded stack depth. Use a loop or an explicit bounded stack.
  • Misspelled interrupt handler name: the weak default handler runs instead. Copy names from the startup file.
  • Writing C statements in a naked function: the compiler has no frame to work with. Use only basic asm and branch to a normal C function.
  1. Reproduce the missing-prototype bug on your host with the gain.c/main.c pair (old-style void set_gain();). Record what it prints. Then add -Wstrict-prototypes and read the warning, replace the declaration with a proper prototype in a gain.h included by both files, and confirm the output is gain = 0.500000.
  2. Compile read_and_scale with arm-none-eabi-gcc -std=c11 -O2 -mcpu=cortex-m4 -mthumb -S and match it to the listing above. Then add two more int32_t parameters p4 and p5, return v + offset + p4 + p5, and find where the function reads p5 from (look for ldr relative to sp). Explain the offset you see, given what push did.
  3. Create mathx.h with a plain inline int32_t sq(int32_t x) and call it from main.c. Build with -O0 and record the link error. Fix it two ways: with static inline, and with an extern inline declaration in a mathx.c. Use nm to see which object file defines sq in each case.
  4. Build log.c on your host with console_write implemented using fwrite to stdout. Verify that a 200-character message is truncated cleanly to 95 characters. Then write log_msg(LOG_INFO, "%s\n", 42); and confirm -Wall reports it; remove the format attribute and confirm the warning disappears.
  5. Compile digit_sum_rec, digit_sum and a function taking uart_config_t by value with arm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -fstack-usage -c. Open the generated .su file and record the stack bytes and qualifier (static, dynamic) reported for each. Then recompile the recursive function at -O0 and compare. Explain why a whole-program stack analysis cannot bound digit_sum_rec without knowing its inputs.

Question 1:On a Cortex-M4 (AAPCS), a function int32_t f(int32_t a, int32_t b, int32_t c, int32_t d, int32_t e) is called. Where is e?

Question 2:Why does a non-leaf function start with something like push {r4, lr}?

Question 3:A header defines inline uint32_t clamp8(uint32_t v) { return v > 255u ? 255u : v; }. A debug build at -O0 fails to link. Why?

Question 4:In a variadic function, what should you write to fetch an argument that the caller passed as a float?

Question 5:A 64-byte configuration struct is passed by value to uart_configure(cfg). What happens at each call?

Question 6:A developer writes void SysTick_handler(void) { ticks++; } (lowercase h). The startup file declares weak handlers aliased to a looping Default_Handler. What happens when SysTick is enabled?

  • Every call must see a prototype. Write (void) for empty parameter lists, include each module’s header in its own .c file, and enable -Wstrict-prototypes and -Wmissing-prototypes.
  • C passes everything by value, structs included. Pass large structs as const T *; return small results as structs or through out-parameters.
  • Put static inline functions in headers instead of function-like macros. The compiler inlines what it judges profitable, with or without the keyword.
  • va_arg must name the promoted type (double, int), and printf-style functions need the format attribute so the compiler can check them.
  • AAPCS: arguments in r0-r3 then the stack, results in r0 (or r0:r1, or s0/d0 with hard-float), r4-r11 callee-saved, lr holds the return address, sp 8-byte aligned at calls.
  • A call costs roughly 8 to 15 cycles of overhead on the Cortex-M4; recursion costs a stack frame per level with no bound, which is why firmware avoids it.
  • noreturn, weak, used, noinline, always_inline and naked each solve a specific firmware problem; a misspelled handler name next to a weak default fails silently.

Next: Pointers I: addresses, arithmetic, and const, where the & and * you have glimpsed in out-parameters become fully explained, with a memory diagram for every example.