Embedded HandbookApps
All sections › IX. The C language: from basics to advanced
STM32F103, STM32F407, STM32G431 · level basic · 14 min read
Open in the interactive handbook

71. Multi-module programs, the preprocessor, command-line arguments

A microcontroller project is dozens of files: main.c, interrupt handlers, peripheral drivers, the vendor library, the startup file. This section is about how they fit together: what goes into a header and what goes into the implementation file, how a variable becomes shared between several files, and why an interrupt handler is picked up simply by its name — and why a typo in that name produces not a single error.

The second half is the preprocessor: macros, their famous traps and conditional compilation, which the headers of every STM32 family rely on. And at the end — command-line arguments. The chip has none, but PC programs that work with the chip cannot do without them.

Terms used in the documentation

Term Meaning
translation unit, module one source file after preprocessing; a module
header, include guard a header file; protection against including it twice
external linkage, extern the name is visible to other files; a declaration without a definition
internal linkage, static the name is visible only inside its file
multiply defined symbol a symbol defined more than once
weak symbol a definition that a normal one can replace
map file the linker's report on where everything went
makefile, target, prerequisite the build rules file, what to build, what it depends on
object-like, function-like macro a constant macro, a macro with parameters
stringizing #, token pasting ## turning a parameter into a string, gluing tokens
conditional compilation including or excluding code before compilation
command-line arguments words passed to a program when it starts

71.1 Why split a program into files

One reason is order: the UART driver lives in its own file, the device logic in its own. The second is separate compilation: each file is compiled into its own object file (section 63.6), and the linker combines them into one image (section 63.7). Change one file — and only that file is recompiled.

A project created by the code generator is already organized this way (section 5): main.c, stm32xx_it.c with the interrupt handlers, the peripheral initialization files, the vendor library, the startup file in assembly.


71.2 Header and implementation

A module is a pair of files:

/* uart.h */
#ifndef UART_H
#define UART_H
#include <stdint.h>
void uart_init(uint32_t baud);
void uart_send(const uint8_t *data, uint16_t len);
#endif
/* uart.c */
#include "uart.h"
static uint32_t tx_count;                 /* видна только здесь */
void uart_init(uint32_t baud) { ... }
void uart_send(const uint8_t *data, uint16_t len) { ... }

A frequent question is why does uart.c include its own header? So that the compiler checks the declarations against the definitions. If the prototype in the header and the function header in the .c file disagree, you get a compilation error, and not strange behavior for whoever calls the function.

A header must not contain definitions — neither variables nor function bodies. Why — in 71.4 and 71.5.


71.3 Include guards

Headers include each other, and the same file easily ends up in a translation unit twice. If it defines a type, the second inclusion is the error "redefinition of 'cfg'".

The guard is conditional compilation around the whole file:

#ifndef UART_H
#define UART_H
/* … содержимое … */
#endif

The first inclusion defines UART_H, the second sees that it is already defined and skips everything up to #endif. The name must be unique for each header — usually it is the file name.

Shorter — #pragma once on the first line. Both armclang and GCC understand it, but formally it is not part of the standard. The headers of CMSIS and the vendor libraries use the classic guard.


71.4 A global variable for several files

The variable is defined in exactly one .c file, and the header only declares it with the word extern:

/* ticks.h */
extern volatile uint32_t ticks;           /* объявление: есть такая где-то */

/* systick.c */
volatile uint32_t ticks;                  /* определение: вот она */

If you define the variable in two files, the linker stops:

Error: L6200E: Symbol counter multiply defined (by b.o and a.o).

And this holds even for int m; without an initial value. Older compilers silently merged such "tentative" definitions into one, and many projects lived for years with a variable defined right in the header. Modern ones — armclang and recent versions of GCC — do not do this, and when you move to them such a project stops building. GCC restores the old behavior with the -fcommon option, but the right thing is to fix the header.

A variable that an interrupt changes is declared volatile — both in the extern and in the definition (section 3.4).

Better still, do not make the variable shared at all: static in its own file and access functions. Then it is clear who changes it.


71.5 static: hide it inside the file

static on a function or a file-level variable makes the name invisible to the linker (sections 3.6 and 66.4). Two files can each have their own static function helper — there is no conflict.

Hence the trap: a static function defined in a header becomes a separate copy in every file that includes it. Verified with the map file: a header with such a function was included in two files — the image contains two identical copies.

The exception is short static inline functions: they are put into headers on purpose, and the optimizer inlines them at the call site anyway (71.11).


71.6 Weak symbols: how interrupt handlers are hooked up

How does the linker know that your function TIM2_IRQHandler is an interrupt handler? It does not. The mechanism of weak symbols does the work.

In the F1 startup file there are three lines for each interrupt:

DCD     TIM2_IRQHandler            ; в таблице векторов — адрес этого имени
EXPORT  TIM2_IRQHandler  [WEAK]    ; имя слабое
TIM2_IRQHandler                    ; и указывает на обработчик по умолчанию

A weak symbol is a definition "in case no other one is found". If some object file contains an ordinary function with the same name, the linker takes it and discards the weak one. If not, the weak one stays, that is, the endless loop of the default handler (section 65.9).

And here is the price of this mechanism, verified by linking:

Function in the project Result
void TIM2_IRQHandler(void) your function is in the image, no stub
void TIM2_IRQhandler(void) — one letter no error, no warning; the stub is in the table

A typo in the handler name: nm shows the weak stub, the vector table holds Default_Handler

A typo in the handler name turns into a hang at the very first interrupt. You check this in the map file: it shows which object file each symbol was taken from — your handler must come from your file, not from the startup file.

You can also catch the typo at compile time. The -Wmissing-prototypes option (in GCC and armclang; it is not part of -Wall -Wextra) warns about a global function that had no prior declaration. If the handler prototypes come from a header — in a CubeMX project this is stm32f1xx_it.h — the correct name passes silently, and TIM2_IRQhandler gets "no previous prototype". The same stub remains if the handler is written in a C++ file without extern "C": the C++ compiler mangles the name — in the object file it became _Z15TIM2_IRQHandlerv — and the linker does not recognize it as the handler.

In C a weak function is declared like this:

__attribute__((weak)) void on_button(void) { }

Libraries use this: a callback function is declared weak and empty, and you override it if you need to (section 67.8).


71.7 Building: make and projects

By hand, a multi-file program is built like this:

gcc -c -o main.o main.c        # каждый файл — в объектный
gcc -c -o func.o func.c
gcc -o prog main.o func.o      # и компоновка

The make utility does this according to the rules in a Makefile and rebuilds only what has changed:

prog: main.o func.o
    gcc -o prog main.o func.o
main.o: main.c mylibrary.h
    gcc -c -o main.o main.c
func.o: func.c
    gcc -c -o func.o func.c
clean:
    rm -f *.o prog

A rule is "target: what it depends on", and the commands go on the following lines. Commands must start with a tab, not spaces — the most common mistake in a first Makefile.

Microcontroller IDEs do the same on their own: a Keil project stores the list of files and options, CubeIDE generates makefiles. The armclang command line for a single file is in section 4.8; the compilers themselves are covered in section 77.


71.8 The preprocessor: macros

The preprocessor substitutes text without understanding C (section 63.4). That is the source of all macro traps — they have been checked on a real compiler:

#define A B + 1
#define B 2
A + 2          /* 5 — как ожидалось, но случайно */
A * 2          /* 4, а не 6: получилось 2 + 1 * 2 */

#define HALF(x) x/2
HALF(a + 5)    /* при a = 5 — 7, а не 5: получилось a + 5/2 */

#define SQUARE(x) x*x
SQUARE(1 + 2)  /* 5, а не 9: получилось 1 + 2*1 + 2 */

The rule: put parentheses around the whole macro body and around every parameter:

#define HALF(x)   ((x) / 2)
#define SQUARE(x) ((x) * (x))

But parentheses do not save you from the second problem — a parameter is evaluated as many times as it is written:

#define MAX(a, b) ((a) > (b) ? (a) : (b))
int i = 5, j = 3;
int m = MAX(i++, j);   /* m = 6, а i стало 7: i++ выполнилось дважды */

And a third, less known one: the exclusive-OR swap #define SWAP(a, b) (a ^= b, b ^= a, a ^= b) works for different variables, but SWAP(z, z) zeroes z. In a sort where an element is sometimes swapped with itself, this corrupts the array.

A macro of several statements is wrapped in do { … } while (0):

#define LED_BLINK() do { led_on(); delay(); led_off(); } while (0)

Without this wrapper, a multi-statement macro under an if without curly braces works incorrectly: only the first statement ends up under the condition, the rest always execute, and it all builds silently. If the if is followed by an else, the build breaks: the else is left without its if. With the wrapper the macro behaves like a single statement.


71.9 #, ## and predefined names

# turns a parameter into a string, ## glues two pieces into one name:

#define STR(x) #x
#define XSTR(x) STR(x)
#define CAT(a, b) a##b
#define LIMIT 100

STR(LIMIT)       /* "LIMIT" — текст как есть */
XSTR(LIMIT)      /* "100" — сначала подстановка, потом строка */
int CAT(val, 42) = 7;    /* объявлена переменная val42 */

The two levels in XSTR are needed because # turns the argument into a string before it is substituted. This is the standard technique for printing the value of a constant in a message.

Expanding STR, XSTR and CAT step by step: what is left after the preprocessor

The preprocessor also knows ready-made names:

Name What it expands to
__FILE__ the file name
__LINE__ the line number
__DATE__, __TIME__ the build date and time
__STDC_VERSION__ the version of the language standard
__func__ the name of the current function (it is a variable, not a macro)

Debug messages and assert use __FILE__ and __LINE__ to say where the error happened. __DATE__ and __TIME__ are handy for stamping a build, but they make every build different from the previous one — sometimes that is undesirable.


71.10 Conditional compilation

#if, #ifdef, #ifndef, #elif, #else, #endif include or exclude pieces of text before compilation:

#ifdef DEBUG
#define LOG(msg) uart_puts(msg)
#else
#define LOG(msg) ((void)0)       /* в рабочей сборке — ничего */
#endif

Names for the conditions are set with the -D option on the compiler command line or in the defines field of a Keil project. This is exactly how the family header learns which chip you have — -DSTM32F10X_MD or -DSTM32F407xx (section 4.8).

#error stops the build with its own message — a way to guard against an unsupported configuration:

#if !defined(STM32F1) && !defined(STM32F4)
#error "unknown family"
#endif

And one more technique: #if 0 … #endif disables a large piece of code. You cannot close it off with a comment if there are already /* */ comments inside — they do not nest.


71.11 Macro or function

A macro is substituted as text: it has no types, its arguments are evaluated as many times as they are written, and the debugger cannot see it. A function has all of that — but in the past you paid for it with a call.

That is no longer so. A static inline function compiles into the same code as a macro: MAX as a macro and as a static inline gave identical instructions on Cortex-M3. But there is no trap — imax(k++, j) with k = 5 returns 5 and increments k once.

Hence the practice: computations are static inline functions in a header. Macros remain for what functions cannot do: constants for array sizes (section 64.12), conditional compilation, # and ##, register descriptions.


71.12 Command-line arguments

A program on a PC receives the words of the command line through the parameters of main:

int main(int argc, char *argv[])

argc is the number of words, argv is an array of pointers to them (section 67.7). Word zero is the name of the program itself: running prog hello world gives argc = 3.

A microcontroller has no command line, main is declared as int main(void), and parameters arrive from buttons, over a port or from Flash. But the utilities that talk to the chip — firmware loaders, data acquisition programs — parse their options on the PC exactly this way.

For parsing options there is getopt. The string "ab:C::d" means: a and d take no value, b takes a value, C takes an optional value. Here is how it works in practice:

Command line What is received
-a -b -d -C hello a; b = "-d"; C has no value; hello is an extra word
-a -b -d -Chello a; b = "-d"; C = "hello"
-a -b1 -C -d a; b = "1"; C has no value; d
-b1 -b2 -b 15 b three times: 1, 2, 15
-h -a unknown option — "?"; a

Two rows deserve attention. An option with a value takes the next word, even if it looks like an option: -b -d made -d the value of b. And an optional value is written only attached: -Chello works, -C hello does not.


71.13 Pitfalls

"redefinition" during the build. A header is included twice without a guard.

L6200E: symbol multiply defined. A variable is defined in a header or in two files; the header must contain only extern.

The project built with an old compiler and does not build with a new one. The same cause: int x; in a header is no longer merged.

The image grew although the code did not change. A static function in a header — a copy in every file.

An interrupt causes a hang although the handler is written. The handler name has a typo; the weak stub remained in the image. Check in the map file.

A macro computes the wrong result. No parentheses around the body or the parameters.

A variable was incremented twice. An argument with ++ was passed to a macro.

A sort zeroed some elements. An exclusive-OR swap of an element with itself.

else without if after a macro. A multi-statement macro without do { } while (0).

The Makefile does not work: "missing separator". Commands start with spaces instead of a tab.

A command-line option "ate" the next one. An option with a value takes the whole next word.

The optional value of an option is not read. It must be written attached.


71.14 Summary

Next: section 72 "Debugging and dynamic data structures".

Open in the interactive handbook