MODULE 8 · LESSON 8.1 · THEORY
Functions in Embedded C: Prototypes, Parameters, Return Values, the Call Stack and static inline
Function anatomy, declaration versus definition, headers, parameters and return values, the AAPCS call mechanism on the Cortex-M4, f() versus f(void), dangling locals, static and inline functions and firmware design rules. Keep this in mind whenever you write functions in embedded C.
What You Will Learn
✓ The parts of a function: return type, name, parameters, body
✓ Declaration (prototype) versus definition, and why headers exist
✓ Parameters and arguments, return values and void
✓ What really happens at a call on the Cortex-M4: registers, link register and stack
✓ The difference between f() and f(void)
✓ Why returning the address of a local variable is a bug
✓ static and inline functions
✓ Design rules for small, testable firmware functions
Key Terms
FunctionPrototypeDefinitionDeclarationParameterArgumentReturn valuevoidCall stackStack frameAAPCSLink registerstatic functioninlineHeader file
1. Why Functions?
A function is a named block of code that you can run from many places. Without functions a firmware project would be one huge main with the same twenty lines pasted in every place that needs to blink an LED or send a byte. Functions in embedded C give you four practical benefits: Many bugs in functions in embedded C start here.
| Benefit | Meaning in firmware |
|---|
| Reuse | Write uart_send_byte() once and call it from every module |
| Abstraction | The caller says led_on(GREEN) and does not care which register bit it is |
| Testing | A small function with clear inputs and outputs can be tested on a PC |
| Maintenance | A fix or an optimisation in one place applies everywhere |
The cost is small but real: a call needs a few instructions and some stack. On a Cortex-M4 a call-and-return pair of a tiny function costs about 4 to 8 cycles, which is why very hot code is sometimes inlined (section 9). This is the core rule for functions in embedded C.
2. Anatomy of a Function
General form of a function definition
return_type function_name(parameter_list)
{
local declarations;
statements;
return value; /* omitted when the return type is void */
}
Parts of uint16_t adc_scale(uint16_t raw, uint16_t full_scale)
uint16_t raw, uint16_t full_scale
parameters
function_first.c
#include <stdio.h>
#include <stdint.h>
/* definition: placed before it is used */
static uint16_t adc_to_percent(uint16_t raw)
{
return (uint16_t)((raw * 100u) / 4095u);
}
static void print_level(const char *name, uint16_t raw)
{
printf(“%-8s raw=%4u -> %3u %%\n”, name, raw, adc_to_percent(raw));
}
int main(void)
{
print_level(“empty”, 0);
print_level(“half”, 2048);
print_level(“full”, 4095);
return 0;
}
Output (verified by compiling and running)
empty raw= 0 -> 0 %
half raw=2048 -> 50 %
full raw=4095 -> 100 %
| Term | Meaning | Example |
|---|
| Parameter | A variable in the function definition, receiving a value | raw in adc_to_percent(uint16_t raw) |
| Argument | The value you supply in the call | 2048 in adc_to_percent(2048) |
| Return value | The value the function gives back to the caller | return ... ; |
| void | No value returned, or no parameters | void print_level(...) |
3. Declaration, Definition and Prototype
The compiler reads a file from top to bottom. When it meets a call such as adc_to_percent(x) it must already know the function’s return type and parameter types, to check the arguments and to generate the right call. There are two ways to tell it:
- Put the definition above the first call (the earlier examples do this).
- Put a declaration (also called a prototype) above the first call and the definition anywhere, even in another file:
uint16_t adc_to_percent(uint16_t raw);
A prototype is the header line followed by a semicolon. It allocates nothing and generates no code; it is a promise that the function exists with exactly this signature. Compare what happens when there is no declaration at all:
nodecl.c
#include <stdio.h>
int main(void)
{
int r = twice(21);
printf(“%d\n”, r);
return 0;
}
int twice(int x) { return 2 * x; }
A call without a declaration (C99 and later)
nodecl.c: In function ‘main’:
nodecl.c:5:13: warning: implicit declaration of function ‘twice’ [-Wimplicit-function-declaration]
5 | int r = twice(21);
| ^~~~~
C99 removed implicit declarations. The GCC used here still accepts the call with a warning and assumes int twice(), which silently mishandles arguments of other types; GCC 14 and later reject it as an error. Always write prototypes.
Headers and source files
In a real project the prototypes live in a header file (.h) that every caller includes, and the definitions live in one .c file. The header is the public interface of a module; the source file is its implementation.
mathutil.h
#ifndef MATHUTIL_H
#define MATHUTIL_H
int clamp_int(int value, int lo, int hi);
int map_range(int x, int in_lo, int in_hi, int out_lo, int out_hi);
#endif
mathutil.c
#include “mathutil.h”
int clamp_int(int value, int lo, int hi)
{
if (value < lo) return lo;
if (value > hi) return hi;
return value;
}
int map_range(int x, int in_lo, int in_hi, int out_lo, int out_hi)
{
return out_lo + (x – in_lo) * (out_hi – out_lo) / (in_hi – in_lo);
}
main.c
#include <stdio.h>
#include “mathutil.h”
int main(void)
{
printf(“clamp(150, 0, 100) = %d\n”, clamp_int(150, 0, 100));
printf(“map 2048 of 0..4095 to 0..3300 mV = %d\n”, map_range(2048, 0, 4095, 0, 3300));
return 0;
}
Output (verified by compiling and running)
clamp(150, 0, 100) = 100
map 2048 of 0..4095 to 0..3300 mV = 1650
The include guard (#ifndef MATHUTIL_H … #endif) stops the header from being processed twice when several files include each other.
4. Parameters, Arguments and Return Values
Arguments are matched with parameters by position, not by name. The number and types must agree with the prototype; the compiler converts arguments to the parameter types when it can. A function returns at most one value directly; use pointers or a struct for more (Lesson 8.2).
params_return.c
#include <stdio.h>
#include <stdint.h>
static int add(int a, int b) { return a + b; }
static void say(const char *msg) { printf(“%s\n”, msg); } /* returns nothing */
static int is_even(unsigned n) { return (n & 1u) == 0; }
static uint8_t low_byte(uint32_t v) { return (uint8_t)v; } /* return value is converted */
static double half(double x) { return x / 2; }
int main(void)
{
printf(“add(3, 4) = %d\n”, add(3, 4));
say(“a void function prints directly”);
printf(“is_even(10) = %d, is_even(7) = %d\n”, is_even(10), is_even(7));
printf(“low_byte(0x1234ABCD) = 0x%02X\n”, low_byte(0x1234ABCDu));
printf(“half(5) = %.1f (the int argument 5 became 5.0)\n”, half(5));
add(1, 2); /* result ignored: legal, but often a bug for error codes */
return 0;
}
Output (verified by compiling and running)
add(3, 4) = 7
a void function prints directly
is_even(10) = 1, is_even(7) = 0
low_byte(0x1234ABCD) = 0xCD
half(5) = 2.5 (the int argument 5 became 5.0)
Returning more than one value
multi_return.c
#include <stdio.h>
typedef struct { int quotient; int remainder; } divmod_t;
static divmod_t divmod(int a, int b) /* struct return: both results together */
{
divmod_t r = { a / b, a % b };
return r;
}
static void minmax(const int *a, int n, int *min, int *max) /* output parameters */
{
*min = *max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] < *min) *min = a[i];
if (a[i] > *max) *max = a[i];
}
}
int main(void)
{
divmod_t d = divmod(17, 5);
printf(“17 / 5 = %d remainder %d\n”, d.quotient, d.remainder);
int v[] = { 7, 3, 9, 1, 8 };
int lo, hi;
minmax(v, 5, &lo, &hi);
printf(“min=%d max=%d\n”, lo, hi);
return 0;
}
Output (verified by compiling and running)
17 / 5 = 3 remainder 2
min=1 max=9
5. What a Call Does on the Cortex-M4
Understanding the call mechanism helps you to estimate stack use and to read the debugger. ARM defines it in the AAPCS (Procedure Call Standard for the Arm Architecture). The rules for the STM32F407 (Cortex-M4, Thumb-2):
| Item | Rule |
|---|
| Arguments | The first four 32-bit arguments go in r0, r1, r2, r3; further arguments go on the stack |
| Return value | r0 (r0:r1 for 64-bit values). With -mfloat-abi=hard a float is returned in s0 |
| Return address | The BL (branch with link) instruction puts the address of the next instruction into the link register LR (r14); the function returns with BX LR or by popping PC |
| Callee-saved registers | r4–r11 must be preserved by the called function; r0–r3 and r12 may be overwritten |
| Stack | Full descending, 8-byte aligned at calls to public functions |
A call in four steps
1. Caller
puts arguments in r0–r3
➜
2. BL func
LR = return address, PC = func
➜
3. Callee
PUSH {r4,lr} … compute … result in r0
➜
4. POP {r4,pc}
return to the caller
➜
A function that calls another function must save LR first (push {r4, lr}), otherwise the inner BL would overwrite the return address. A leaf function (one that calls nothing) can keep LR in the register and return with bx lr, which makes it cheaper.
Stack growth with nested calls main() → f() → g()
stack top is at the highest address; the stack grows downward
main: locals, saved registers
f: return address, saved registers, locals
g: return address, locals
SP points here
each call adds a frame; each return removes it
stack_depth.c
#include <stdio.h>
#include <stdint.h>
static uintptr_t first_local;
static void dive(int level)
{
char marker; /* a local variable lives in this call’s frame */
if (level == 0) first_local = (uintptr_t)▮
printf(“call depth %d: local is %ld bytes below the first call’s local\n”, level, (long)(first_local – (uintptr_t)&marker));
if (level < 3) dive(level + 1);
}
int main(void)
{
dive(0);
return 0;
}
Output (verified by compiling and running)
call depth 0: local is 0 bytes below the first call’s local
call depth 1: local is 48 bytes below the first call’s local
call depth 2: local is 96 bytes below the first call’s local
call depth 3: local is 144 bytes below the first call’s local
Each deeper call places its locals at a lower address: the stack grows downward. The frame size printed here belongs to this PC build; on the Cortex-M4 a small function typically uses 8 to 32 bytes per call, depending on how many registers it saves and how many locals it needs.
Why it matters: Every active call keeps a frame on the stack. The STM32F407 has 128 KB of ordinary SRAM, but a default startup file may reserve only 1 KB (_Min_Stack_Size = 0x400) for the stack. Deep recursion or large local arrays overflow it silently into the heap or the .bss section.
6. f() versus f(void)
In C, an empty parameter list in a declaration means “unspecified arguments” (old K&R style), not “no arguments”. The compiler then cannot check the calls. Write (void) when a function takes nothing (C23 changes this, but embedded code is mostly C99/C11).
void_check.c
void old_style();
void new_style(void);
void old_style() {}
void new_style(void) {}
int main(void)
{
old_style(1, 2, 3); /* compiles, arguments silently ignored */
new_style(1, 2, 3); /* error: too many arguments */
return 0;
}
f() accepts anything; f(void) is checked
void_check.c: In function ‘main’:
void_check.c:10:5: error: too many arguments to function ‘new_style’
10 | new_style(1, 2, 3); /* error: too many arguments */
| ^~~~~~~~~
void_check.c:5:6: note: declared here
5 | void new_style(void) {}
| ^~~~~~~~~
GCC’s -Wstrict-prototypes flags every old-style declaration. MISRA C requires full prototypes (rule 8.2).
7. Never Return the Address of a Local Variable
A local (automatic) variable lives in the function’s stack frame, which is released at return. A pointer to it then points to memory that the next call will overwrite. The compiler warns, but the code compiles.
dangling_local.c
#include <stdio.h>
#include <string.h>
static char *bad_name(void)
{
char name[16] = “sensor1”;
return name; /* BUG: name dies when the function returns */
}
static const char *good_name(void)
{
static const char name[] = “sensor1”; /* static storage lives for the whole program */
return name;
}
static void fill_name(char *dst, size_t size)
{
strncpy(dst, “sensor1”, size – 1); /* the caller owns the buffer */
dst[size – 1] = ‘\0’;
}
int main(void)
{
const char *g = good_name();
char buf[16];
fill_name(buf, sizeof buf);
printf(“good_name : %s\n”, g);
printf(“fill_name : %s\n”, buf);
(void)bad_name; /* not called: its result would be undefined */
return 0;
}
Output (verified by compiling and running)
good_name : sensor1
fill_name : sensor1
badret.c
char *bad_name(void)
{
char name[16] = “sensor1”;
return name;
}
GCC detects the bad return
badret.c: In function ‘bad_name’:
badret.c:4:12: warning: function returns address of local variable [-Wreturn-local-addr]
4 | return name;
| ^~~~
| Safe way to return data | Cost |
|---|
Caller passes a buffer (char *dst, size_t size) | None; caller decides where it lives |
static local or global | RAM for the whole run; not reentrant |
| Return a struct by value | Copy of the struct (small structs only) |
| Return a pointer to constant data in flash | None |
8. static Functions and Module Interfaces
By default a function has external linkage: its name is visible to every file in the program. A function declared static has internal linkage: only code in the same file can call it. Use static for every helper that is not part of the module’s public interface. It keeps the global namespace clean, prevents name clashes between modules, lets the compiler inline or remove unused functions, and documents the intent.
sensor.c
#include <stdint.h>
static int16_t offset_correct(int16_t raw) /* private helper */
{
return (int16_t)(raw – 12);
}
int16_t sensor_read(int16_t raw) /* public function */
{
return offset_correct(raw);
}
main.c
#include <stdio.h>
#include <stdint.h>
int16_t sensor_read(int16_t raw);
int main(void)
{
printf(“sensor_read(100) = %d\n”, sensor_read(100));
return 0;
}
Output (verified by compiling and running)
sensor_read(100) = 88
sensor.c
#include <stdint.h>
static int16_t offset_correct(int16_t raw) { return (int16_t)(raw – 12); }
int16_t sensor_read(int16_t raw) { return offset_correct(raw); }
other.c
#include <stdint.h>
int16_t offset_correct(int16_t raw);
int main(void) { return offset_correct(5); }
Calling a static function from another file does not link
/usr/bin/ld: file1.o: in function `main’:
other.c:(.text+0xe): undefined reference to `offset_correct’
collect2: error: ld returned 1 exit status
Each file compiles on its own, but the linker cannot find offset_correct for other.c: the only definition is private to sensor.c, so it is not exported.
9. inline Functions
The keyword inline (C99) is a hint that the compiler may paste the body at the call site and skip the call overhead. For very small functions, such as one that sets a GPIO bit, the call would cost more than the work. In headers write static inline: each file gets its own private copy, so there are no linker conflicts.
inline_demo.c
#include <stdio.h>
#include <stdint.h>
static uint32_t GPIOD_BSRR;
static inline void led_on(unsigned pin) { GPIOD_BSRR = 1u << pin; }
static inline void led_off(unsigned pin) { GPIOD_BSRR = 1u << (pin + 16); }
int main(void)
{
led_on(12); printf(“led_on(12) -> BSRR = 0x%08X\n”, (unsigned)GPIOD_BSRR);
led_off(12); printf(“led_off(12) -> BSRR = 0x%08X\n”, (unsigned)GPIOD_BSRR);
return 0;
}
Output (verified by compiling and running)
led_on(12) -> BSRR = 0x00001000
led_off(12) -> BSRR = 0x10000000
| Approach | Type checking | Debuggable | Typical use |
|---|
| Function-like macro | None | Poor | Compile-time constants, token tricks |
static inline function | Full | Good (at -O0) | Preferred for small helpers |
__attribute__((always_inline)) | Full | Medium | Time-critical paths |
Compiler decides: At -O2 GCC inlines small static functions even without the keyword, and may refuse to inline a large one even with it. Use -Winline to see refusals.
10. Designing Good Firmware Functions
- One job per function. If you need “and” in the name, split it.
- Name = module + verb:
uart_send(), adc_read(), led_toggle(). - Keep it short. A screenful is a good limit; long functions are hard to test.
- Few parameters. More than four suggests a struct (and the extra arguments go on the stack).
- const-correct pointers:
const char * for input you do not modify. - Check arguments at module boundaries: NULL pointers, ranges.
- Return a status (0 = OK, negative or enum for errors) and the data through an output parameter.
- Avoid hidden globals. A function whose result depends only on its parameters is easy to test.
- Document the contract: units, ranges, who owns buffers, whether it may be called from an ISR.
status_codes.c
#include <stdio.h>
#include <stdint.h>
typedef enum { ST_OK = 0, ST_ERR_NULL = –1, ST_ERR_RANGE = –2 } status_t;
/* converts a raw 12-bit ADC value to millivolts through *mv; returns a status */
static status_t adc_to_mv(uint16_t raw, uint16_t vref_mv, uint16_t *mv)
{
if (mv == NULL) return ST_ERR_NULL;
if (raw > 4095) return ST_ERR_RANGE;
*mv = (uint16_t)((uint32_t)raw * vref_mv / 4095u);
return ST_OK;
}
int main(void)
{
uint16_t v;
printf(“status %d, “, adc_to_mv(2048, 3300, &v)); printf(“value %u mV\n”, v);
printf(“status %d (range)\n”, adc_to_mv(5000, 3300, &v));
printf(“status %d (null)\n”, adc_to_mv(100, 3300, NULL));
return 0;
}
Output (verified by compiling and running)
status 0, value 1650 mV
status -2 (range)
status -1 (null)
11. Common Mistakes
Mistake 1 — calling a function before declaring it: Implicit declaration; arguments may be passed with the wrong types.
Mistake 2 — f() instead of f(void): The compiler cannot catch calls with arguments.
Mistake 3 — returning the address of a local: The pointer is dangling the moment the function returns.
Mistake 4 — ignoring a returned error code: The failure goes unnoticed. Cast to (void) only when deliberately ignoring.
Mistake 5 — defining a non-static function in a header: Every file that includes the header gets a definition: multiple-definition link error.
Mistake 6 — forgetting the return statement: Falling off the end of a non-void function and using the value is undefined behavior (-Wreturn-type).
Mistake 7 — mismatched prototype and definition: In C a mismatch across files compiles and links, then crashes. Include the header in the defining .c file so the compiler checks it.
Mistake 8 — huge local arrays: A 4 KB buffer on a 1 KB stack overflows silently.
12. Best Practices for Functions in Embedded C
- Declare every function in a header with a full prototype, and include that header in the .c file that defines it.
- Mark file-local functions
static. - Use
(void) for empty parameter lists. - Pass large data by pointer (
const when read-only). - Keep stack use small and know the worst-case call depth.
- Use
static inline in headers, not macros, for tiny helpers. - Compile with
-Wall -Wextra -Wstrict-prototypes -Wmissing-prototypes. - Unit-test pure functions on the PC before putting them on the target.
Key Takeaways — Lesson 8.1
- Functions in embedded C have a return type, a name, parameters and a body; arguments match parameters by position.
- A prototype declares the interface; the definition provides the code; headers share prototypes.
- On the Cortex-M4 the first four arguments travel in r0–r3, the result returns in r0 and LR holds the return address.
- Use
f(void), never f(). - Never return a pointer to a local variable.
static makes a function private to its file; static inline suits tiny helpers.- Small, single-purpose functions with clear status returns are the foundation of testable firmware.
Frequently Asked Questions
What is the difference between a function declaration and a definition?
A declaration (prototype) tells the compiler the name, return type and parameter types so that calls can be checked. A definition also contains the body and generates the code. A function can be declared many times but defined only once.
What is the difference between a parameter and an argument?
A parameter is the variable named in the function definition. An argument is the value passed in a particular call. Arguments are matched to parameters by position.
Why use void in an empty parameter list?
In C, f() means unspecified arguments, so calls with arguments are not rejected. f(void) says the function takes none and lets the compiler check every call.
How are arguments passed on the Cortex-M4?
The AAPCS puts the first four 32-bit arguments in registers r0 to r3 and the rest on the stack. The return value comes back in r0 (r0 and r1 for 64-bit values).
What does static mean on a function?
Internal linkage: the function can be called only from the file that defines it. Use it for helpers that are not part of the module interface.
Is it safe to return a pointer to a local array?
No. The array lives in the stack frame, which is released on return, so the pointer dangles. Return a pointer to static or caller-owned storage, or fill a buffer supplied by the caller.
Ready to practise?
Next: worked examples with a module split, status codes, LED helpers, clamp and map functions on real output.
2 Comments