When the code gets long, you cannot cram everything into one file. Firmware is usually divided into modules such as the 'GPIO handling part,' 'UART handling part,' and 'application logic.' The tools for dividing into modules are functions (units of operation), separating headers (.h) and source (.c) (Interface/Implementation), and preprocessors (#include, #define, header guards).
In this lesson, as the last part of the basics, we separate the bit manipulation logic into the ledbank module (ledbank.h + ledbank.c) and create a structure where main.c/pc_test.c use it by looking only at its interface. We also cover header guards and macro pitfalls (parentheses!). The sense of dividing into modules is a key habit for expanding firmware, continuing through lesson 23 (HAL abstraction).
Learning Objectives
- Separate function declaration (prototype) and definition, and split operations into functions.
- Divide headers (.h, interface) and source (.c, implementation), and build multiple files together.
- Prevent duplicate inclusion with header guards (
#ifndef/#define/#endif). - Distinguish between
#defineconstants and function-like macros, and avoid macro parenthesis traps. - Understand the concept of branching Platform/Option with conditional compilation (
#ifdef).
Target Environment
- Compiler: Either Keil C51 / Keil MDK-ARM (this edition is pure logic).
- Device: Pure logic — PC + gcc or μVision simulator (actual board not required).
- Verification:
gccon PC (main.c/pc_test.c+ledbank.c).
Key Concepts
1) Functions — Declaration and Definition
Functions give a name to an operation to make it easy to reuse and test.
- Declaration (Prototype): Only promises the name, return type, and parameter types.
uint8_t led_set(uint8_t bank, uint8_t pos); - Definition: The actual body.
uint8_t led_set(...) { return ...; }
If you put the declaration in a header and the definition in the source file, other files can call the function by just looking at the header (they don't need to know the implementation).
2) Separation of Header (.h) and Source (.c)
| File | Role | Contains |
|---|---|---|
ledbank.h |
Interface | #define, types, function declarations |
ledbank.c |
Implementation | function definitions (body) |
main.c |
Usage | include #include "ledbank.h" and then call functions |
When building, compile the .c files you are using together.
gcc main.c ledbank.c -o main
In μVision, just add both main.c and ledbank.c to the project Source Group (.h does not need to be added because it can be found through #include).
3) Header Guard — Preventing Duplicate Inclusion
If the same header is included twice via different paths, types and macros get redefined causing errors. The idiom to prevent this is a header guard.
#ifndef LEDBANK_H /*If not defined yet*/
#define LEDBANK_H /*Mark as defined*/
... header content ...
#endif /* LEDBANK_H */ /* From the second one, skip entirely */
#pragma once does the same, but not all compilers guarantee it, so #ifndef guards are recommended for portability.
4) #define — Constant Macro vs Function-like Macro
The preprocessor replaces text before compilation.
#define LED_COUNT 8u /*Constant Macro*/
#define BIT(n) (1u << (n)) /*Function-style Macro*/
In function-style macros, it is an absolute rule to enclose both the arguments and the entire expression in parentheses. If you don't, it breaks due to precedence.
#define SQ(x) x*x /*Bad: SQ(a+1) → a+1*a+1 (= a + a + 1)*/
#define SQ(x) ((x)*(x)) /*Good: SQ(a+1) → ((a+1)*(a+1))*/
Macros don't have type checks and evaluate arguments multiple times (SQ(i++) risk), so if it's not a simple constant or simple expression, the static function (or inline) is safer.
5) Conditional Compilation — #ifdef
Select code to compile according to On-platform/option. This is used when branching the same source for 8051, STM32, and PC.
#ifdef USE_UART
uart_send(msg); /*Included only in UART build*/
#else
/*Does nothing*/
#endif
Viewing in μVision (This time, a PC is sufficient)
Pure logic is fine on a PC. In μVision, add main.c and ledbank.c to the project and build with F7; the two files will link together. Module separation works the same way in any IDE.
Examples
Example 1 — ledbank.h / ledbank.c / main.c: Module Separation
Move bit manipulation to the ledbank module, and main expresses the intention just with the function name.
ledbank.h (Interface):
#ifndef LEDBANK_H
#define LEDBANK_H
#include <stdint.h>
/*
*ledbank.h — 'interface' of a module that handles 8 LED banks (8 bits).
*The implementation is in ledbank.c, and users only need to #include this header.
*
*Header guard (#ifndef ~ #define ~ #endif): prevents the content from being processed more than once even if the same header is included multiple times.
*If omitted, a duplicate definition error occurs.
*/
/*Function-like macro — the arguments must be enclosed in parentheses (to prevent precedence issues).*/
#define BIT(n) (1u << (n))
#define LED_COUNT 8u
/*Function declaration (prototype). Only the return and argument types are promised, and the implementation is in the .c file.*/
uint8_t led_set(uint8_t bank, uint8_t pos);
uint8_t led_clear(uint8_t bank, uint8_t pos);
uint8_t led_toggle(uint8_t bank, uint8_t pos);
uint8_t led_count_on(uint8_t bank);
#endif /* LEDBANK_H */
ledbank.c (Implementation):
#include "ledbank.h"
/*
*ledbank.c — Implementation of the functions declared in ledbank.h.
*Wrapped bit manipulation idioms (Part 3) into functions with meaningful names.
*/
uint8_t led_set(uint8_t bank, uint8_t pos)
{
return (uint8_t)(bank | BIT(pos));
}
uint8_t led_clear(uint8_t bank, uint8_t pos)
{
return (uint8_t)(bank & ~BIT(pos));
}
uint8_t led_toggle(uint8_t bank, uint8_t pos)
{
return (uint8_t)(bank ^ BIT(pos));
}
uint8_t led_count_on(uint8_t bank)
{
uint8_t pos;
uint8_t count = 0;
for (pos = 0; pos < LED_COUNT; pos++) {
count = (uint8_t)(count + ((bank >> pos) & 1u));
}
return count;
}
main.c (Usage):
#include <stdio.h>
#include "ledbank.h" /*Our module's interface*/
/*Print an 8-bit value as 8-digit binary.*/
static void print_bin8(uint8_t v)
{
int i;
for (i = 7; i >= 0; i--) {
putchar(((v >> i) & 1u) ? '1' : '0');
}
}
/*
*Lesson 05 Example 1 — the side that uses the module (ledbank).
*Main expresses the intention just from the function name, even if it doesn't know the 'method' of bit operations.
*Build: gcc main.c ledbank.c -o main
*/
int main(void)
{
uint8_t bank = 0x00;
bank = led_set(bank, 1);
bank = led_set(bank, 4);
bank = led_set(bank, 6);
bank = led_toggle(bank, 4); /*Turns number 4 on and then off again*/
printf("bank = ");
print_bin8(bank);
printf("\n");
printf("LED on = %u\n", (unsigned)led_count_on(bank));
return 0;
}
Example 2 — pc_test.c: Separate Module for Verification
Verify the same module with the test harness.
#include <stdio.h>
#include "ledbank.h"
/*
*Lesson 05 Example 2 — Unit test the ledbank module functions.
*You can test the module alone on a PC without hardware.
*Build: gcc pc_test.c ledbank.c -o pc_test
*/
int main(void)
{
uint8_t b = 0x00;
b = led_set(b, 0);
b = led_set(b, 3);
printf("set 0,3 = 0x%02X\n", b); /* 0x09 */
b = led_clear(b, 0);
printf("clear 0 = 0x%02X\n", b); /* 0x08 */
b = led_toggle(b, 7);
printf("toggle 7 = 0x%02X\n", b); /* 0x88 */
printf("count on = %u\n", (unsigned)led_count_on(b)); /* 2 */
return 0;
}
Verification on a PC
This time, since the modules are separated, compile the .c you are using together.
gcc main.c ledbank.c -o main && ./main
Expected output (stdout):
bank = 01000010
LED on = 2
gcc pc_test.c ledbank.c -o pc_test && ./pc_test
Expected output (stdout):
set 0,3 = 0x09
clear 0 = 0x08
toggle 7 = 0x88
count on = 2
Common Mistakes
Q. I get errors in multiple definition / redefinition.
A. If you put the definition (body) of a function in a header and include it in multiple .c files, the definition will appear multiple times. Only put the declaration in the header, and put the definition in the .c file. Also, if you forget the header guard (#ifndef), errors will occur due to duplicate inclusion even within a single .c file.
Q. I'm getting a undefined reference to led_set link error.
A. This is because ledbank.c was not built together. Since the header only provides the "declaration," include the implementation-containing .c together in Needs-compile/link: gcc main.c ledbank.c -o main. If using μVision, add both .c files to the project.
Q. A macro created with #define SQ(x) x*x gives strange values.
A. It's because there are no parentheses. SQ(a+1) expands to a+1*a+1. Enclose both the arguments and the entire expression in parentheses for function-like macros: #define SQ(x) ((x)*(x)). For arguments with side effects (SQ(i++)), they will be evaluated multiple times, so use a function instead.
Q. Which should I use for constants, #define or const?
A. Both are possible. #define is a preprocessor substitution, so it has no type and may not be visible in the debugger. const has a type and is advantageous for debugging. Values needed at the preprocessor stage for bit Like-location/address should use #define; for general constants, const is also a good choice.
Summary
- Split actions into functions, place declarations in headers (.h), and definitions in source files (.c).
- The .c files used must be built together to link (
gcc main.c ledbank.c). - Use a header guard (
#ifndef/#define/#endif) to prevent duplicate inclusion. - Functional macros enclose both arguments and expressions in parentheses. If it's complex, functions are safer.
- Select and compile By-Platform/Option code using
#ifdef.
Try it yourself
Solve the assignment below. The reference solution is shown below.