So far, the code to turn on an LED directly touched the registers like GPIOC->BRR = .... But what if the chip changes from STM32 to 8051? Or if you want to test the same operation on a PC? If the register code is embedded throughout the application logic, every place has to be rewritten. HAL (Hardware Abstraction Layer) is a design to solve this problem. It separates "what to do" (turn on the LED) in the application from "how to do it" (using specific registers) in the implementation.
In this section, we run the same application logic (app.c) with three HAL implementations — for PC (hal_pc.c, printf), for STM32 (hal_stm32.c, registers), and for Arduino Uno (hal_arduino.c, ATmega328P registers). Even if the chip architecture completely changes from ARM to AVR, the application code does not change a single line. This is the portability and testability provided by module separation.
Learning Objectives
- Explain the reason for dividing firmware into Application/HAL/Hardware layers.
- Separate interface (header) and implementation (source).
- Port the same application to multiple HAL implementations.
- Follow the dependency direction (application → HAL, reverse is forbidden).
- Unit test the application logic with a fake HAL for PC.
Target Environment
- Compilers: PC + gcc (application + PC HAL) / Keil MDK (application + STM32 HAL) / avr-gcc · Arduino IDE (application + Arduino HAL).
- Device: None (Application·HAL interface is independent). STM32 HAL is STM32F103C8, Arduino HAL is ATmega328P (Uno).
- Verification: PC
gcc(pc_test.c+app.c+hal_pc.c). For the board, build with each toolchain.
Note: Arduino Uno is AVR (ATmega328P), so it cannot be built with Keil (Keil is only for 8051·ARM).
hal_arduino.cfor Uno is built with avr-gcc or Arduino IDE. In contrast, the pure module (app.c) and PC HAL can be reused as is.
Key Concepts
1) Layering
Divide the firmware into layers according to responsibility. The upper layer only calls the lower layer, and the lower layer does not know the upper layer.
┌────────────────────┐
│ Application (app.c) │ "Control LED according to pattern" — Hardware unknown
├────────────────────┤
│ HAL Interface (hal.h) │ Contract like "led_set(on)"
├────────────────────┤
│ HAL Implementation │ hal_pc.c / hal_stm32.c / hal_arduino.c
├────────────────────┤
│ Hardware │ PC stdout / STM32 GPIO / AVR GPIO
└────────────────────┘
2) Separation of Interface and Implementation
The header (hal.h) is a contract — only function names, parameters, and meaning are defined. The source (hal_pc.c, hal_stm32.c) implements that contract in its own way. The application only includes the header #include and does not worry about which implementation is linked.
3) Portability — Replace Only the Implementation
PC Test: pc_test.c + app.c + hal_pc.c → Operation check with printf
STM32 Board: main.c + app.c + hal_stm32.c → PC13 LED operation (ARM)
Uno Board: main(.ino) + app.c + hal_arduino.c → PB5(D13) LED operation (AVR)
app.c is identical character by character across all three. Whether the chip is ARM or AVR, only the HAL implementation file changes.
4) Dependency Direction Rule
- Application → HAL: Allowed (upper calls lower)
- HAL → Application: Prohibited (if the lower knows the upper, separation is broken)
To maintain this direction, HAL can be separated and reused in other projects.
5) Ease of Testing
Even without hardware, by inserting a fake HAL for PC, application logic can be immediately verified with gcc. The big advantage is catching logic bugs quickly without a board (pc_test.c of this track uses this exact method).
6) Arduino Uno (ATmega328P) Register Mapping
The chip of the Arduino Uno is AVR ATmega328P (8-bit). While it has a different architecture from ARM and 8051, the principle of handling GPIO via registers is the same. The onboard LED of the Uno is 'digital 13' = PB5 (bit 5 of port B) and is active high (turns on when pin=1).
| Operation | AVR Register | STM32 Correspondence (Comparison) |
|---|---|---|
| Set pin as output | `DDRB | = (1<<5)` |
| Output High (Turn on) | `PORTB | = (1<<5)` |
| Output Low (Turn off) | PORTB &= ~(1<<5) |
GPIOC->BSRR/BRR |
DDRx (Direction), PORTx (Output), PINx (Input) are the three main registers of AVR GPIO. By using these registers directly, without Arduino APIs like digitalWrite(), you can implement the same hal.h contract while maintaining the 'direct register control' philosophy of this track even on AVR.
Key point: Even if the architecture changes entirely from ARM to AVR, the application (
app.c) remains the same, and the only new file written ishal_arduino.c.
Examples
Example 1 — hal.h: HAL contract
#ifndef HAL_H
#define HAL_H
#include <stdint.h>
/*
*Hardware Abstraction Layer (HAL) interface.
*
*Application code only calls these functions. It does not know "how" to turn it on or off (whether it is a register or printf).
*By swapping the implementation file (hal_pc.c ↔ hal_stm32.c),
*the same application logic works on both the PC and the actual board.
*/
void hal_led_init(void);
void hal_led_set(uint8_t on); /*1 = on, 0 = off*/
#endif /* HAL_H */
Example 2 — app.c: Hardware-independent application
#include "app.h"
#include "hal.h"
/*
*Application layer — Only calls HAL. There is not a single line of register code here.
*Therefore, this file does not need to be modified even if the chip changes (the core of portability).
*/
void app_blink_pattern(const uint8_t *pattern, uint16_t len)
{
uint16_t i;
hal_led_init();
for (i = 0; i < len; i++) {
hal_led_set(pattern[i] ? 1u : 0u);
}
}
Example 3 — Three HAL implementations
For PC (verification):
#include "hal.h"
#include <stdio.h>
void hal_led_init(void) { printf("[HAL-PC] led init\n"); }
void hal_led_set(uint8_t on) { printf("[HAL-PC] LED %s\n", on ? "ON" : "OFF"); }
For STM32 (real board, Keil): hal_stm32.c
#include "hal.h"
#include "stm32f10x.h"
#define LED_PIN 13u
void hal_led_init(void)
{
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
GPIOC->CRH &= ~(0xFu << ((LED_PIN - 8u) * 4u));
GPIOC->CRH |= (0x2u << ((LED_PIN - 8u) * 4u));
}
void hal_led_set(uint8_t on)
{
if (on) { GPIOC->BRR = (1u << LED_PIN); } /*Active Low: ON=0*/
else { GPIOC->BSRR = (1u << LED_PIN); } /* OFF=1 */
}
For Arduino Uno (real board, avr-gcc/Arduino IDE): hal_arduino.c
#include "hal.h"
#include <avr/io.h>
/*
*Implementation of HAL on Arduino Uno (ATmega328P) — Direct control of AVR registers.
*The Uno onboard LED is 'Digital 13' = PB5, active high (1 = on).
*Manipulate DDRB/PORTB directly without using Arduino APIs like digitalWrite.
*/
#define LED_BIT 5u /*PB5 = Digital 13*/
void hal_led_init(void)
{
DDRB |= (uint8_t)(1u << LED_BIT); /*Set PB5 as output*/
}
void hal_led_set(uint8_t on)
{
if (on) {
PORTB |= (uint8_t)(1u << LED_BIT); /*Active high: ON = 1*/
} else {
PORTB &= (uint8_t)~(1u << LED_BIT); /* OFF = 0 */
}
}
All three implementations satisfy the same hal.h. For the single line in hal_led_set, the PC uses printf, STM32 uses BRR/BSRR, and Uno uses PORTB — only adapted to the chip.
Example 4 — pc_test.c: Running the application on PC HAL
#include "app.h"
#include <stdint.h>
/*
*Lecture 23 PC Verification — Run the same app_blink_pattern on PC HAL (hal_pc.c).
*In Keil, if hal_stm32.c is used instead of hal_pc.c, the same code works on the real board.
*Build: gcc pc_test.c app.c hal_pc.c -o pc_test
*/
int main(void)
{
uint8_t pattern[6] = { 1, 0, 1, 1, 0, 0 };
app_blink_pattern(pattern, 6u);
return 0;
}
Verification on a PC
gcc pc_test.c app.c hal_pc.c -o pc_test && ./pc_test
Expected output (stdout):
[HAL-PC] led init
[HAL-PC] LED ON
[HAL-PC] LED OFF
[HAL-PC] LED ON
[HAL-PC] LED ON
[HAL-PC] LED OFF
[HAL-PC] LED OFF
In Keil, if you remove
hal_pc.cand inserthal_stm32.cwith the board-specificmain.c(e.g., callingapp_blink_pattern), the sameapp.cturns on the PC13 LED according to the pattern.
Running on Arduino Uno
UNO is AVR, so it builds not with Keil but with avr-gcc / Arduino IDE. You use app.c + hal_arduino.c as they are, with only the entry point set for the Uno (an example with delay so the pattern is visible).
/*Build with arduino_demo: app.c + hal.h + hal_arduino.c*/
#define F_CPU 16000000UL /*Uno 16MHz*/
#include <util/delay.h>
#include "app.h"
#include "hal.h"
int main(void)
{
uint8_t pattern[6] = { 1, 0, 1, 1, 0, 0 };
while (1) {
uint8_t i;
hal_led_init();
for (i = 0; i < 6; i++) {
hal_led_set(pattern[i]);
_delay_ms(300); /*For human visibility*/
}
}
}
- avr-gcc build example:
avr-gcc -mmcu=atmega328p -DF_CPU=16000000UL -Os arduino_demo.c app.c hal_arduino.c -o blink.elf→ Uploadavr-objcopy -O ihex blink.elf blink.hex→avrdude. - Arduino IDE: In the
.inosketch, move the same logic frommaintosetup()/loop(), and placeapp.candhal_arduino.ctogether in the sketch folder (direct register control works the same in Arduino IDE). - Simulation: If you don't have a board, you can check the D13 LED blink on UNO simulators like Wokwi or SimulIDE.
Key message: Even if the stage changes from PC → STM32 (ARM) → Uno (AVR),
app.cdid not change a single character. A well-drawn HAL boundary creates portability.
Common Mistakes
Q. I created a HAL, but there is still register code left in the application.
A. All register access must go down to the HAL implementation. If there is even a single line of GPIOC->... in the application (app.c), portability is broken.
Q. I called application functions from the HAL implementation.
A. This violates the dependency direction (HAL → application is forbidden). The HAL should not know the application to be reusable in another project. If necessary, pass callbacks as arguments.
Q. I get errors when linking multiple implementations (hal_pc.c, hal_stm32.c, hal_arduino.c) together.
A. The same functions are multiply defined. Link only one at a time (hal_pc.c for PC, hal_stm32.c for STM32, hal_arduino.c for Uno).
Q. I can't build the Uno code in Keil.
A. Arduino Uno uses the AVR (ATmega328P), so Keil does not support it (Keil is for 8051 and ARM only). For Uno, build with avr-gcc or Arduino IDE. Keep using app.c and hal.h as they are, and add only hal_arduino.c.
Q. My abstraction is too excessive, making it slow and complicated.
A. Abstraction is not free. Place HALs only at boundaries that frequently change or require portability/testing, and don’t stack too many layers (balance).
Summary
- Dividing firmware into Application/HAL/Hardware improves portability and testability.
- Headers are contracts (interfaces), sources are implementations — applications depend only on headers.
- When changing the chip, just replace the HAL implementation, keep the application the same.
- The same
app.cworks unchanged across PC, STM32 (ARM), and Uno (AVR) implementations. - Dependency direction is application → HAL only (reverse direction prohibited).
- With a fake HAL for PC, you can verify application logic without the board.
Try it yourself
Solve the assignment below. The reference solution is shown below.