Preprocessor directives

Introduction

Most C developers are already familiar with preprocessor directives—often from header files:

#ifndef PROJECT_FILE_H
#define PROJECT_FILE_H
// contents of the file are here
#endif

My own appreciation for macros comes largely from my favorite data structures library STC, which uses them for templating:

#define i_type MyStructsList
#define i_key struct MyStruct
#include <stc/list.h>

Need a list for a custom type? Just define a few macros and include the right header.

In this article, we’ll walk through the most common preprocessor directives—also known as macros.

Conditional macros

Whenever you need to create portable C code, conditional macros are your biggest friend. They allow you to customize the compilation process according to the compiler’s environment.

Let’s say you have a C codebase that needs to support multiple operating systems, but the code that works on Windows doesn’t work on Linux, and you can’t detect the OS at runtime. So what to do?

Thanks to conditional macros, you can branch the code and decide about the target operating system during compilation.

Let’s examine conditional macros on an example:

#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>

int main(void) {
  // We have two variations of this program:
  //  gcc conditional.c
  //  gcc -DMACRO conditional.c

// Check variable value
#if MACRO == 1 // If no MACRO declared MACRO automatically evaluates to 0
  printf("MACRO_EXISTS=%s", "true");
#elif MACRO == 0
  printf("MACRO_EXISTS=%s", "false");
#else
  printf("MACRO_EXISTS=%s", "undefined");
#endif
  puts("");

  // Check variable definition
#ifdef MACRO
  printf("MACRO_EXISTS=%s", "true");
#else
  printf("MACRO_EXISTS=%s", "false");
#endif
  puts("");

  // Check absence of variable definition
#ifndef MACRO
  printf("MACRO_EXISTS=%s", "false");
#else
  printf("MACRO_EXISTS=%s", "true");
#endif
  puts("");

  return 0;
}

We have two types of conditional macros, one checking macro existence and one checking macro value. To check macro existence use #ifdef or #ifndef. To check macro value use #if, this also checks whether the variable exists, and if it does not, it assumes it is 0.

Replacing macros

Macros created using the #define keyword work similarly to templates. Whenever the compiler finds you wrote the macro’s name, it will be replaced with the macro’s value.

Let’s consider the following example:

#include <stdio.h>

#define MACRO "some string"
int main(void){
    char *value = MACRO;

  printf("value=%p\n", value);
  printf("MACRO=%p\n", MACRO);  
}

which outputs:

❯ ./a.out
value=0x64119129e004
MACRO=0x64119129e004

We have the same address, so one could ask maybe it is just like a normal global variable?

❯ objdump -t a.out | grep MACRO

Not exactly, in the symbol table there is no entry pointing to MACRO. This makes macro definitions a unique way to add meaning to normally obscure numbers and strings. Writing something like HTTP_STATUS_OK is much more readable than writing 200. Becuase macros are resolved during compilation and not during runtime there is no possibility for names collision as long as code compiled without an error.

Besides replacing variables, C also allows replacing functions:

#include <stdio.h>

#define MACRO_FUNC() "dudududu"
#define MACRO_FUNC_ARGS(a, b) a + b

int main(void){
   int a = 5, b = 10;

   printf("MACRO_FUNC()=%s\n", MACRO_FUNC());
   printf("MACRO_FUNC_ARGS(a, b)=%d\n", MACRO_FUNC_ARGS(a, b));
}

Functions have the advantage over variables that they can take arguments, hence they allow manipulating multiple elements.

Each argument additionally can be stringified, which according to C23 means:

a # operator before an identifier in the replacement-list runs the identifier
 through parameter replacement and encloses the result in quotes, effectively
 creating a string literal. In addition, the preprocessor adds backslashes to
 escape the quotes surrounding embedded string literals, if any, and doubles
 the backslashes within the string as necessary. All leading and trailing
 whitespace is removed, and any sequence of whitespace in the middle of the
 text (but not inside embedded string literals) is collapsed to a single
 space. This operation is called "stringification". If the result of
 stringification is not a valid string literal, the behavior is undefined.

Let’s consider this on an example:

#include <stdio.h>
#define MACRO_CONCAT(a, b) #a #b

int main(void){
  printf("MACRO_CONCAT(bagger, pinguin)=%s\n", MACRO_CONCAT(bagger, pinguin));
  printf("MACRO_CONCAT(\"bagger\", \"pinguin\")=%s\n", MACRO_CONCAT("bagger", "pinguin"));
}

Which outputs:

❯ ./a.out
MACRO_CONCAT(bagger, pinguin)=baggerpinguin
MACRO_CONCAT("bagger", "pinguin")="bagger""pinguin"

Even though we do not have any variables like bagger or pinguin, the code compiled and worked. This is possible because these identifiers were stringified.

Besides stringification, macro function arguments allow concatenating arguments, according to C23:

A ## operator between any two successive identifiers in the replacement-list
runs parameter replacement on the two identifiers and then concatenates the
result. This operation is called "concatenation" or "token pasting". Only
tokens that form a valid token together may be pasted: identifiers that form
a longer identifier, digits that form a number, or operators + and = that
form a +=.

Let’s examine this on an example:

#include <stdio.h>
#define MACRO_TOKEN_PASTE(prefix, name, value) prefix##_##name = value

int main(void){
  int MACRO_TOKEN_PASTE(fish, john, 5);
  printf("MACRO_TOKEN_PASTE(john, 5)=%d\n", fish_john);
}

Even though we never declared a fish_john variable, the macro took fish and john and concatenated them into a new variable name.

File inclusion macros

Inclusion macros are needed to be able to put code in different files together. We will go through two examples, one showing a classical header and source file and another one showing a private header.

The most common example regarding file inclusion macros is showing how one can create a header file for the source file. Let’s imagine we have main.c:

#include <stdio.h>

#include "lib.h"

int main(void) {
  lib_func();
}

and lib.h:

#ifndef LIB_H
#define LIB_H

void lib_func(void);

#endif

and lib.c:

#include <stdio.h>

#include "lib.h"

void lib_func(void) {
  puts(__func__);
}

Which outputs:

❯ ./a.out
lib_func

Thanks to the #include directive we can use functions among different source files.

The second example is a pretty common pattern showing how one can use a static function in a testable manner. You see, when you are declaring static functions in the .c file, during testing, tests need to include that .c file, which sometimes causes multiple definition errors and such. So the approach is to create a private header. Let’s imagine this layout:

❯ tree .
.
├── lib.c
├── lib.h
└── private.h

In lib.c we have the app’s logic. And whatever is testable but internal is just put in private.h, nice and tidy.

Diagnostic macros

Because macros give such powerful build customization features, over time there may come a need to discourage some configurations.

For example, you are supporting Linux and Windows, so you have two flags ENABLE_WINDOWS and ENABLE_LINUX which allow configuring the appropriate build. However, what if a build would be configured in a way that sets both these options to 1? Fortunately, C comes with the #error directive. Let’s consider it in an example:

#include <stdio.h>

#ifdef ENABLE_LINUX
#ifdef ENABLE_WINDOWS

#error "Choose Windows or Linux"

#endif
#endif

int main(void) {}

Which outputs:

❯ gcc -DENABLE_LINUX main.c

❯ gcc -DENABLE_WINDOWS main.c

❯ gcc -DENABLE_WINDOWS -DENABLE_LINUX main.c
main.c:6:2: error: #error "Choose Windows or Linux"
    6 | #error "Choose Windows or Linux"
      |  ^~~~~

As you can observe on my terminal’s output, as long as we enable Linux or Windows there is no compilation error. However, when both are defined we are artificially raising a compilation error.

Conclusions

Macros can be a very advanced tool in the hands of an experienced C developer. Use them wisely considering their biggest cons like readability and debugging issues.

Published:

Category: C

Comments

Leave a comment