Loading header...

Headers, Modules, Macros, and Conditional Compilation

Small embedded programs often begin as one main.c file. That is useful for a first LED blink, but it stops scaling as soon as the firmware has drivers, board variants, tests, interrupts, and generated configuration. At that point correctness depends on project structure as much as on individual statements.

In embedded C, headers define the contract that other modules may use. Source files own the implementation and private state. Macros and conditional compilation configure code before the compiler sees it, so they must be used deliberately.

Learning Objectives

By the end of this lesson, you should be able to:

  • separate public interfaces from private implementation details;
  • write include guards and function prototypes correctly;
  • use macros for constants and compile-time configuration;
  • apply conditional compilation without making code unreadable.

Header Files Define Interfaces

A header should describe what other modules are allowed to use.

#ifndef GPIO_H
#define GPIO_H

#include <stdbool.h>
#include <stdint.h>

typedef enum {
    GPIO_DIRECTION_INPUT = 0,
    GPIO_DIRECTION_OUTPUT
} gpio_direction_t;

void gpio_configure(uint8_t pin, gpio_direction_t direction);
void gpio_write(uint8_t pin, bool level);
bool gpio_read(uint8_t pin);

#endif

The implementation belongs in gpio.c.

A good public header usually contains:

  • include guards;
  • required standard headers;
  • public types;
  • public constants;
  • function prototypes;
  • brief comments only where the contract is not obvious.

It should not contain private helper functions, hardware register addresses that only one driver needs, or storage definitions for global variables.

Source Files Own State

#include "gpio.h"

static uint32_t configured_outputs;

void gpio_configure(uint8_t pin, gpio_direction_t direction)
{
    if (direction == GPIO_DIRECTION_OUTPUT) {
        configured_outputs |= (1u << pin);
    }
}

static at file scope keeps implementation details private to that source file.

This matters for firmware review. When a variable is file-local, a reviewer only needs to inspect one module to find every write. When a variable is global and declared in many headers, any file can silently change it.

Declarations, Definitions, and Storage

C separates declarations from definitions.

extern uint32_t system_ticks; /* declaration: storage exists elsewhere */
uint32_t system_ticks;        /* definition: storage is allocated here */

Put extern declarations in headers only when a shared object is truly part of the interface. Put exactly one definition in a source file. For most embedded drivers, prefer accessor functions over public globals:

uint32_t systick_get_ms(void);

This keeps the module free to change how the value is stored, protected, or derived.

Include Guards

Include guards prevent duplicate definitions when a header is included through multiple paths.

#ifndef MODULE_NAME_H
#define MODULE_NAME_H

/* declarations */

#endif

#pragma once is common, but include guards are portable and explicit.

Use a unique guard name derived from the project and path, for example TECHARYA_DRIVERS_GPIO_H. Avoid short names such as CONFIG_H in large projects because two third-party libraries can accidentally choose the same guard.

Include What You Use

A header should include the standard or project headers needed for its own declarations.

/* uart.h */
#ifndef TECHARYA_UART_H
#define TECHARYA_UART_H

#include <stddef.h>
#include <stdint.h>

void uart_write(const uint8_t *data, size_t length);

#endif

Do not rely on the caller including <stdint.h> first. That creates include-order bugs that appear only in some builds.

Macros for Constants

Prefer typed constants when possible:

enum {
    UART_RX_BUFFER_SIZE = 128
};

Use macros when the preprocessor is required:

#define FEATURE_LOGGING_ENABLED 1

Avoid function-like macros unless there is a strong reason. Inline functions are safer.

When a macro is necessary, fully parenthesize arguments and the result:

#define BIT_U32(n) (UINT32_C(1) << (n))
#define ARRAY_COUNT(a) (sizeof(a) / sizeof((a)[0]))

Do not write macros that evaluate an argument more than once:

#define BAD_SQUARE(x) ((x) * (x)) /* BAD: BAD_SQUARE(i++) increments twice */

Use a static inline function when a type-safe expression is enough:

static inline uint32_t clamp_u32(uint32_t value, uint32_t max)
{
    return (value > max) ? max : value;
}

Conditional Compilation

#if FEATURE_LOGGING_ENABLED
void log_byte(uint8_t value);
#endif

Conditional compilation is useful for target variants, board revisions, debug builds, and optional features. It becomes dangerous when every function contains many nested #if blocks.

Prefer a small number of named feature switches:

#if TECHARYA_BOARD_HAS_EXTERNAL_EEPROM
void eeprom_probe(void);
#endif

Avoid negative or ambiguous names such as NO_UART or NEW_MODE. Positive names make review easier.

Configuration Header Pattern

flowchart LR APP["application code"] --> DRIVER["driver module"] CFG["board_config.h"] --> DRIVER DRIVER --> HAL["MCU register layer"]

Keep board-specific choices in one configuration header instead of scattering pin numbers and feature switches across the codebase.

Example:

/* board_config.h */
#define BOARD_LED_GPIO_PORT GPIOA
#define BOARD_LED_PIN       5u
#define BOARD_UART_BAUD     115200u

The application should ask for BOARD_LED_PIN, not hard-code 5u in multiple files. If the board changes, the change stays local.

Worked Review Example

Suppose a project has this header:

/* bad_adc.h */
#include "adc.c"
uint16_t adc_last_sample;
#define READ_ADC(ch) adc_read(ch++)

Three problems are present:

  • including adc.c duplicates implementation when several files include the header;
  • adc_last_sample is a definition in a header and can create multiple-definition or accidental shared-state bugs;
  • READ_ADC(ch) modifies ch because the macro argument has a side effect.

A safer shape is:

/* adc.h */
#ifndef TECHARYA_ADC_H
#define TECHARYA_ADC_H

#include <stdint.h>

void adc_init(void);
uint16_t adc_read_channel(uint8_t channel);
uint16_t adc_last_sample(void);

#endif

The storage and helper code stay inside adc.c.

Common Mistakes

  • Defining global variables in headers.
  • Including implementation files with #include "file.c".
  • Letting headers depend on include order.
  • Hiding side effects inside macros.
  • Creating many build variants that are never tested.
  • Letting a header compile only when another unrelated header was included first.
  • Using #if 0 as long-term source control instead of deleting dead code.

Summary

Headers are contracts. Source files are implementations. Include guards make headers safe to reuse. Macros and conditional compilation are powerful but should remain small, named, and testable. A clean module boundary makes firmware easier to reuse, review, debug, and port to another board.

Further Reading

Mind Map

mindmap root((C Modules)) Core idea Headers are contracts Source files implement Static hides private state Extern declares shared objects Applications Driver APIs Board configuration Unit tests Feature variants Rules Include what you use One definition only Unique include guards Prefer static inline Keep config centralized Macro checks Parenthesize args Avoid side effects Use unsigned constants Test every variant Common mistakes Globals in headers Include file dot c Include order dependency Nested ifdefs Dead code in if zero