Embedded C track

Lesson 23 · Part 4: Applications & Patterns

HAL Abstraction, Module Separation & Portability

Shared patterns, plus the Arduino Uno/AVR extension

Part 4: Applications & Patterns9 min readFree lesson

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.c for 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 is hal_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.c and insert hal_stm32.c with the board-specific main.c (e.g., calling app_blink_pattern), the same app.c turns 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 → Upload avr-objcopy -O ihex blink.elf blink.hex → avrdude.
  • Arduino IDE: In the .ino sketch, move the same logic from main to setup()/loop(), and place app.c and hal_arduino.c together 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.c did 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.c works 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.

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/app_hal_layer/app.cC
#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);
    }
}
src/app_hal_layer/app.hC header
#ifndef APP_H
#define APP_H

#include <stdint.h>

/*
 *Application logic. Uses only the HAL interface and does not depend on hardware.
 *Sets the LED sequentially according to each element (0/1) of the pattern.
 */
void app_blink_pattern(const uint8_t *pattern, uint16_t len);

#endif /* APP_H */
src/app_hal_layer/arduino_demo.cC
/*Build with arduino_demo: app.c + hal.h + hal_arduino.c (avr-gcc / Arduino).
 *Blink the Uno onboard LED (D13=PB5) according to a pattern. Use the same app.c as is.
 *Build example:
 *   avr-gcc -mmcu=atmega328p -DF_CPU=16000000UL -Os \
 *           arduino_demo.c app.c hal_arduino.c -o blink.elf
 */
#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*/
        }
    }
}
src/app_hal_layer/hal_arduino.cC
#include "hal.h"
#include <avr/io.h>

/*
 *Implementation of HAL on Arduino Uno (ATmega328P) — Direct control of AVR registers.
 *
 *The Arduino Uno onboard LED is 'digital pin 13' = PB5 (Port B, bit 5), active high (1 = on).
 *Manipulate the DDRB/PORTB registers directly without using Arduino APIs like digitalWrite.
 *— Apply the 'direct register control' philosophy from this track to AVR as well.
 *
 *Build: avr-gcc or Arduino IDE (AVR toolchain). Cannot build with Keil (AVR not supported).
 *app.c does not change at all — just replace the HAL implementation with this file to run on the Uno.
 */

#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 */
    }
}
src/app_hal_layer/hal_pc.cC
#include "hal.h"
#include <stdio.h>

/*
 *Implementation of HAL on PC. It shows operation using printf instead of hardware.
 *A 'fake' driver used for unit testing application logic without a board.
 */
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");
}
src/app_hal_layer/hal_stm32.cC
#include "hal.h"
#include "stm32f10x.h"

/*
 *Implementation of HAL for STM32F103. Onboard LED on PC13 (active low).
 *If you put only this file into a Keil project instead of hal_pc.c, you can run the same application logic on the actual board without changing a single line in app.c.
 *— This is portability.
 *(Not included in PC gcc build. For Keil μVision.)
 */

#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));   /*Push-pull output 2MHz*/
}

void hal_led_set(uint8_t on)
{
    if (on) {
        GPIOC->BRR  = (1u << LED_PIN);   /*Active low: ON = pin 0*/
    } else {
        GPIOC->BSRR = (1u << LED_PIN);   /*OFF = pin 1*/
    }
}
src/app_hal_layer/hal.hC header
#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 */
src/app_hal_layer/pc_test.cC
#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;
}

Assignment

Both problems are built in a single file with pure logic (PC gcc).

gcc main.c -o prog && ./prog

Problem 1 — Replacing Drivers with Function Pointers

  • File name: homework_01/main.c
  • Key concept: Function pointer-based HAL, runtime implementation replacement
  • Platform: Common (PC gcc)

Requirements

  • Define a driver structure with a void (*set)(int on) function pointer.
  • Create two implementations: A outputs A: ON/OFF, B outputs B: [*] (on)/B: [.] (off).
  • Run the same app_run pattern {1,0,1,1} with each of the two drivers.
  • The format must match the expected output character by character.

Expected output

-- driver A --
A: ON
A: OFF
A: ON
A: ON
-- driver B --
B: [*]
B: [.]
B: [*]
B: [*]

Hint

  • typedef struct { void (*set)(int); } led_driver_t;
  • app_run(const led_driver_t *drv) calls drv->set(pat[i]).
  • Behavior changes just by swapping drivers in the same app_run (runtime version of portability).

Problem 2 — Portable map() Utility

  • File name: homework_02/main.c
  • Key concept: Hardware-independent utility function, range conversion
  • Platform: Common (PC gcc)

Requirements

  • Implement map() to linearly transform a value x from input range [in_lo, in_hi] to output range [out_lo, out_hi].
  • map(x) = (x - in_lo) * (out_hi - out_lo) / (in_hi - in_lo) + out_lo.
  • Convert ADC range 0~4095 to percentage 0~100 and output it for x = {0,1024,2048,3072,4095}.
  • The format map N -> P must match the expected output exactly, character by character.

Expected output

map 0 -> 0
map 1024 -> 25
map 2048 -> 50
map 3072 -> 75
map 4095 -> 100

Hint

  • Multiply first, divide later (minimizing integer error). Use long if overflow is a concern.
  • Such pure utilities are chip-agnostic and can be reused anywhere in HAL Up/Down.

Check your answers

After solving it yourself, compare it with the reference solution below. The solution files include comments on key points and common mistakes.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

homework_01/main.cC
#include <stdio.h>

/*
 *Answer 1 — Replacing drivers with function pointers
 *
 *Key Points:
 *- If you abstract a driver as a function pointer structure, you can change its implementation in the same app_run
 *and replace its operation (runtime version of portability).
 *- Both compile-time replacement (file link) and runtime replacement (function pointer) are methods of HAL.
 *
 *Common mistake:
 *- Calls can fail due to function pointer signature mismatches (arguments/return type).
 */

typedef struct {
    void (*set)(int on);
} led_driver_t;

static void drvA_set(int on)
{
    printf("A: %s\n", on ? "ON" : "OFF");
}

static void drvB_set(int on)
{
    printf("B: [%c]\n", on ? '*' : '.');
}

static void app_run(const led_driver_t *drv)
{
    int pat[4] = { 1, 0, 1, 1 };
    int i;
    for (i = 0; i < 4; i++) {
        drv->set(pat[i]);
    }
}

int main(void)
{
    led_driver_t a = { drvA_set };
    led_driver_t b = { drvB_set };

    printf("-- driver A --\n");
    app_run(&a);
    printf("-- driver B --\n");
    app_run(&b);

    return 0;
}
homework_02/main.cC
#include <stdio.h>

/*
 *Answer 2 — Portable map() utility
 *
 *Key Points:
 *  - map(x) = (x-in_lo)*(out_hi-out_lo)/(in_hi-in_lo) + out_lo.
 *- Multiplication should be done first, and division later to reduce integer truncation error.
 *- It's a pure utility independent of the chip, so it can be reused on any platform.
 *
 *Common mistake:
 *- If division is done first, it can result in 0. If (x-in_lo)/(in_hi-in_lo) is done first, it can be 0.
 *- Overflow from large values — calculate with long if needed.
 */

static long map_val(long x, long in_lo, long in_hi, long out_lo, long out_hi)
{
    return (x - in_lo) * (out_hi - out_lo) / (in_hi - in_lo) + out_lo;
}

int main(void)
{
    int samples[5] = { 0, 1024, 2048, 3072, 4095 };
    int i;

    for (i = 0; i < 5; i++) {
        long p = map_val(samples[i], 0, 4095, 0, 100);
        printf("map %d -> %ld\n", samples[i], p);
    }
    return 0;
}

Learn this lesson with a mentor

Work through the Embedded C track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now