Preprocessor¶
Learning objectives: The student can use preprocessor directives in C language programs and understands their benefits in hardware-oriented programming.
So far, in the C code introduced during the course, we have already used some mysterious commands starting with
#. These are the preprocessor commands (directives) that control the C language compilation process. As part of preprocessing, header files can be included in the source code and custom constants and macros can be defined. Header files often provide the declarations needed to use libraries, while the actual library implementation is typically connected to the program later during linking.Generally, preprocessors have their own programming language, which doesn't have to be tied to the language being compiled.
Including Header Files¶
Libraries typically provide header files that contain declarations, types, constants, and macros needed to use the library. The
#include directive includes such a header file in the source code. The actual implementation of library functions is provided separately and is normally connected to the program during linking. We'll explore these more when discussing libraries, but let's take a look at the two different forms of the #include preprocessor directive.#include <headerfile.h>
#include "headerfile.h"
There are a couple of syntax details here. If
< > brackets are used, the compiler looks for the header file in the compiler environment's own libraries, such as the C standard library or the embedded device's programming environment libraries. If " " quotes are used, the header file is searched for in the same (project) directory structure where our own program code is located. The idea is that user libraries or externally imported libraries are accessed using quotation marks.The order of header files should ideally not matter if each header includes the dependencies it needs. However, in practice, the order can matter if one header depends on declarations, types, or macros provided by another header. If you look deeper into the contents of the header files, you'll notice that they often pull in other header libraries into the program. More on this in a moment...
For example, the prototypes for the familiar printing functions from the standard library like
printf and sprintf are found in the header file stdio.h:#include <stdio.h>
Constants¶
The preprocessor can also introduce constants, which are not the same as (constant) variables in C. The value of a constant can be anything, as long as it adheres to C's syntax.
Constants are handy for naming frequently occurring values. For example, many C implementations provide the constant
M_PI through math.h as an approximation of pi. However, M_PI is not required by the ISO C standard and may not be available in every development environment.Now, note that these preprocessor constants exist only in the preprocessed code. During the compilation process, the preprocessor replaces the constant in the code with its value, literally one-to-one. This means that a preprocessor constant does not occupy a memory location and, therefore, cannot be used like a variable.
A preprocessor constant is introduced in the program using the
#define directive. The syntax is as follows:#define PI 4.0 // Constants are generally written in uppercase letters.
float circumference = 2 * PI * radius;
This code would be preprocessed as follows:
float circumference = 2 * 4.0 * radius;
As noted, the constant's value is inserted as is in the code.
Of course, there are exceptions in the compiler's behavior; a constant cannot be placed inside quotation marks, nor can it be part of a word.
#define TRUE 1
...
printf("TRUE"); // this will not be interpreted as a constant, try it!
int x = TRUETRUE; // this will not be interpreted as one or two constants
A New enum Variable Type¶
There is another way to introduce constant values, using the
enum variable type. Now, constants introduced with enum are set incrementally, starting from the initialization value of the first constant. If no initialization value is given, it starts from zero.enum retro_computer { PET=1, VIC20, C64, C128, AMIGA };
// Introducing a variable of this type
enum retro_computer home_computer = C64; // Now home_computer = 3
Note! We can also use
typedef to give an enumeration type a shorter type name. In the following example, retro_computer is the enum tag and RetroComputer is the new type name:typedef enum retro_computer {
PET=1, VIC20, C64, C128, AMIGA
} RetroComputer;
RetroComputer home_computer = C64;
After defining the typedef, we can use
RetroComputer without writing the enum keyword.Naturally, we can create boolean values in our program using
enum. Now, the constant FALSE should be 0, and TRUE should be 1, meaning it is not equal to zero.enum boolean { FALSE=0, TRUE };
...
enum boolean is_true = FALSE;
Note! Often, different development environments have their own library (header file) where the constants
TRUE and FALSE are already predefined. It's better to use these rather than creating your own constants. This avoids the possibility of our own constant definitions interfering with the functioning of other libraries.Macros¶
Definitions created with
#define are macros. A macro without parameters, such as PI, is called an object-like macro. A macro that accepts parameters, such as INC(A), is called a function-like macro.#define PI 3.14159
#define INC(A) ((A)++)
In
INC(A), A is a macro parameter. The preprocessor performs textual token substitution; A is not a C variable.In the latter
INC macro, we pass the preprocessor variable A as a parameter to the macro, which increments its value by one. Now the programmer has to ensure that the macro is used correctly in the C code, considering variable types, etc. This can easily lead to bugs in the code if, for example, we don't enclose the macro in parentheses, which can cause the execution order to be different from what was intended. Or, if we define a semicolon ; at the end of the macro definition, we won't be able to use the INC macro as part of a for statement.#define INC(A) A++;
for (x=0; x < 100; INC(x)) {}
// the macro would be replaced exactly with its value, leading to
// an extra semicolon after the assignment statement x++.
for (x=0; x < 100; x++;) {}
If we omit the semicolon from the constant definition, it can be used elsewhere in the code, if we do not forget adding the
; when appropiate.We have this other example:
#define SQUARE(X) X * X // problematic, lacks parentheses
int result = SQUARE(4 + 1); // expands to 4 + 1 * 4 + 1 -> results in 9, not 25
that can be correcting using:
#define SQUARE(X) ((X) * (X)) // properly wrapped in parentheses
int result = SQUARE(4 + 1); // now expands correctly to ((4 + 1) * (4 + 1)) -> 25
Inline functions¶
The
inline keyword tells the compiler that a function is a candidate for inline substitution. If the compiler chooses to inline the function, the function body is placed at the call site instead of performing a normal function call.If a function is actually inlined, no separate stack frame is needed for that function call. This can reduce stack usage and call/return overhead. However, local data used by the inlined code may still require stack space in the caller. On the other hand, inserting the function body at several call sites may increase the size of the program code, which can be relevant in embedded systems with limited program memory.
For example:
static inline int square(int x) {
return x * x;
}
int result = square(5);
If the compiler decides to inline the function, the generated code can behave conceptually as if the call had been replaced with:
int result = 5 * 5;
However,
inline does not guarantee that this optimization will take place. The compiler may decide not to inline an inline function, and an optimizing compiler may also inline functions that were not declared inline.One advantage compared to macros is that inline functions are type-checked by the compiler, can return values normally, and are generally easier to debug.
Conditional Compilation¶
We can also define a constant without a value. Similarly, such a constant only exists during the compilation process.
This feature is typically used to control the compiler's behavior so that we can specify which (device) libraries or other code are selectively included in the program. Often, portable (i.e., working in multiple environments) code uses OS-specific constants to help with compilation. For example, the compiler environment constant
_WIN32 indicates that we want to compile our code for a Windows machine. Compiler environments usually automatically set these constants when you tell them which device you're working on. So there's no need to worry about them in this course.#define _WIN32
In different operating systems, we may have different libraries for using peripherals, even though the program logic works the same. Compiler environments usually define macros that describe the target platform. For example,
_WIN32 is typically predefined when compiling for Windows and __unix__ may be predefined on Unix-like systems. These macros can then be tested using conditional-compilation directives:#ifdef __unix__
// Unix-specific code
#elif defined(_WIN32)
// Windows-specific code
#endif
We can also define our own macros from the compiler command line using the
-D option. For example:#if VERSION < 190
#error versions below 1.9.0 not supported
#endif
gcc -DDEBUG -DVERSION=190 program.c
Here,
DEBUG and VERSION are application-specific macros defined by us; they are different from platform macros such as _WIN32, which are normally provided by the compiler environment.Thus, the preprocessor also implements a familiar
if-else if-else control structure. This conditional statement must end with the #endif directive. We also notice the preprocessor directive #error.Preprocessor conditions have an important use case in header files. The same header file can be included more than once while compiling a source file. This can happen directly, when the source file includes the same header itself, or indirectly, when another included header also includes that header.
For example,
main.c may include config.h directly and also include sensor.h, which itself includes config.h. Because #include inserts the contents of the header into the source being processed, the contents of config.h would then be inserted more than once.Include guards prevent this by ensuring that the contents of the header are included only once while compiling each source file.
We work around this problem with a preprocessor conditional statement by checking for the existence of a constant at the beginning of the header file.
#ifndef SENSOR_H
#define SENSOR_H
// Declarations in sensor.h
...
#endif
The include-guard macro is usually based on the name of the header file. Here,
SENSOR_H identifies the header file sensor.h.The first time the header is included,
SENSOR_H is not yet defined, so its contents are processed and SENSOR_H is defined. If the same header is encountered again while compiling the same source file, the condition is false and its contents are skipped.When another source file is compiled, the header can be processed again.
Here, we instruct the compiler by setting the constant
SENSOR_H during the first compilation, whose existence prevents the same code from being recompiled if the header file is reused in another module.Although the header guards using
#ifndef have been popular in embedded systems, nowadays most compilers accept #pragma once, which is a simpler and less error-prone alternative. The #pragma once directive substitutes the previous examples. Example¶
In embedded systems, conditional compilation is typically used to define device-specific I/O, so that the same code works on different devices/microcontrollers. Compiler environments use constants with the same names, whose values depend on the device.
Below, we create a constant to check if button 1 is pressed, regardless of which MCU we have (either ATmega128 or ATtiny13). We note that on different MCUs, the push button is connected to different I/O ports, for which the device libraries define their own constants
PINA, PAO, PINB, and PB2.#if defined(__AVR_ATmega128__)
#define BUTTON1_DOWN (!(PINA & (1 << PA0)))
#elif defined(__AVR_ATtiny13__)
#define BUTTON1_DOWN (!(PINB & (1 << PB2)))
#endif
...
//Later code
if (BUTTON1_DOWN == VAKIO) { ...
...
}
More on these constants related to embedded device I/O can be found in the hardware-related section of the course.
Conclusion¶
There is still much more to preprocessor directives and languages that we haven't covered here. In this course, there is no need to define your own macros, but the hardware-related libraries we use for programming our device have plenty of predefined macros. Well, those macros have been tested by thousands of coders over time.
Give feedback on this content
Comments about this material