I have seen firmware projects where everything — sensor drivers, communication protocols, application logic, hardware initialization — was crammed into a single 3,000-line main.c file. Adding a new feature meant scrolling through thousands of lines trying to figure out where to put it. Fixing a bug meant hoping you found all the places where the same logic was duplicated.
Good structure is not about following rules. It is about making your future self's life easier.
The layered approach
The most effective way to organize firmware is in layers. Each layer only talks to the layer directly below it.
At the bottom is the hardware — the actual microcontroller and its peripherals.
Above that is the Hardware Abstraction Layer (HAL). This wraps the hardware-specific code. Instead of writing GPIOA->BSRR = (1 << 5) everywhere, you write GPIO_Set(LED_PORT, LED_PIN). The HAL hides the hardware details.
Above the HAL are drivers — code that controls specific components like sensors, displays, and communication modules.
Above drivers are services — higher-level functionality like sensor management, data logging, and communication protocols.
At the top is the application — the business logic that makes your device do what it is supposed to do.
A practical folder structure
project/
├── app/ # Application logic, state machines
├── drivers/ # Component drivers (sensor, display, etc.)
├── services/ # Higher-level services
├── hal/ # Hardware abstraction layer
├── lib/ # Third-party libraries (FreeRTOS, etc.)
├── tests/ # Unit tests
└── docs/ # DocumentationThe hardware abstraction layer in practice
Here is why the HAL matters. Suppose you write this in your application:
// Without HAL — hardware-specific code in application
GPIOA->BSRR = (1 << 5); // Set PA5 highNow you need to port to a different microcontroller. You have to find every place you wrote GPIOA->BSRR and change it.
With a HAL:
// hal/gpio.h
void GPIO_Set(GPIO_Port port, uint8_t pin);
void GPIO_Clear(GPIO_Port port, uint8_t pin);
bool GPIO_Read(GPIO_Port port, uint8_t pin);
// Application code
GPIO_Set(LED_PORT, LED_PIN); // works on any hardwareWhen you port to new hardware, you only change the HAL implementation. The application code stays the same.
State machines for complex behavior
When your device has complex behavior — different modes, sequences of operations, error handling — a state machine makes it manageable:
typedef enum {
STATE_IDLE,
STATE_WARMING_UP,
STATE_MEASURING,
STATE_TRANSMITTING,
STATE_ERROR
} DeviceState;
static DeviceState currentState = STATE_IDLE;
void RunStateMachine(void) {
switch (currentState) {
case STATE_IDLE:
if (IsTimeToMeasure()) {
PowerOnSensor();
currentState = STATE_WARMING_UP;
}
break;
case STATE_WARMING_UP:
if (SensorReady()) {
currentState = STATE_MEASURING;
}
break;
// ... other states
}
}State machines make behavior explicit and easy to test. You can test each state transition independently.
One config file for all settings
Put all your configuration constants in one place:
// config.h
#define SAMPLE_INTERVAL_MS 10000 // 10 seconds
#define SENSOR_I2C_ADDRESS 0x48
#define UART_BAUD_RATE 115200
#define MAX_RETRY_COUNT 3When a client asks you to change the sample rate, you change one number in one file. Not hunt through the codebase.
Good structure is an investment. It takes a bit more time upfront. But the first time you need to add a feature or fix a bug six months later, you will be glad you did it.



