Singleton Pattern in Embedded C: Writing ‘Globally Unique’ More Stably

In embedded projects, some resources are inherently unique: watchdog timers, RTCs, system clocks, debug serial ports, loggers, system configuration managers, CRC modules… If these modules are carelessly piled up with global variables, issues such as initialization order chaos, difficult-to-trace write access, and ISR/task concurrency collisions will inevitably arise.

The goal of the singleton pattern is simple: to make ‘globally unique’ resources into ‘controlled globals’. With a single entry point, clear initialization, and constrained access, the system becomes more stable and maintainable.

When is it More Appropriate to Use a Singleton?

  • • Physically unique hardware resources: WDT, RTC, SysTick, debug UART, etc.
  • • Global services: loggers, system configuration/parameter loading, alarm/event reporting, power management.
  • • State machines that must be centrally controlled: system mode management, upgrade processes, fault protection.

Not suitable for: modules that require multiple instances to run in parallel (multi-channel UART/I2C/SPI, etc.). For these, prioritize ‘manager + multiple instances’ instead of forcing a singleton.

How to Implement in C

Without classes, don’t get caught up in ‘object-oriented’ concepts; the essence is ‘controlled global object + controlled access entry’. Two common implementations:

  • • Eager initialization (recommended): static object persists, initialized once during startup <span>init()</span>. Simple, predictable, no dynamic memory, best suited for embedded systems.
  • • Lazy initialization: initialized upon first use. This path is more complex and requires consideration of concurrency and interrupt safety; it is not recommended to call it for the first time in an ISR.

Practical preference: most low-level singletons use eager initialization, completing initialization early in the system; for upper-level services that need lazy loading, pay attention to concurrency protection and boundaries.

Design Points (A Small Model)

  • • Internal state: <span>static</span> visible within the module, global writable handles are not exported.
  • • External entry: only provide functions or read-only interface tables (function pointer collections).
  • • Idempotent initialization: <span>init()</span> can be called multiple times, but only takes effect once.
  • • Concurrency and ISR: clearly define which APIs can be used in interrupts; provide <span>FromISR</span> versions if necessary.

Example 1: Eager Singleton (Generic for Bare Metal/RTOS)

Taking the ‘logger’ as an example, the low-level implementation uses a UART for transmission. Avoid dynamic memory, <span>init()</span> is idempotent, and optional critical section protection can be applied.

// log.h
#ifndef LOG_H
#define LOG_H

#include 
#include 

void Log_Init(void);                // Idempotent
void Log_Write(const char *s);      // Thread safety depends on implementation
void Log_WriteHex(const void *buf, size_t len);

// Optional: expose only read-only interfaces to reduce writable globals
typedef struct {
    void (*write)(const char *);
    void (*writeHex)(const void *, size_t);
} LogIface;

const LogIface *Log_Get(void);

#endif
// log.c
#include "log.h"

// -- Platform-specific HAL (example, fill in according to project) --
static void hal_uart_init(void) {
    // Configure baud rate, GPIO, multiplexing, etc.
}
static void hal_uart_write_blocking(const char *s) {
    // Write byte by byte to TX register and wait for completion
}

// -- Critical section (bare metal/RTOS adaptation) --
static inline void crit_enter(void) {
    // Bare metal: __disable_irq();  RTOS: taskENTER_CRITICAL();
}
static inline void crit_exit(void) {
    // Bare metal: __enable_irq();   RTOS: taskEXIT_CRITICAL();
}

// -- Singleton body --
typedef struct {
    volatile int initialized;  // 0 not initialized, 1 initialized
} Log_t;

static Log_t g_log = {0};      // Eager: static storage duration object

void Log_Init(void) {
    if (g_log.initialized) return;
    crit_enter();
    if (!g_log.initialized) {  // Double-check to reduce contention overhead
        hal_uart_init();
        g_log.initialized = 1;
    }
    crit_exit();
}

void Log_Write(const char *s) {
    if (!g_log.initialized) {
        // Team agreement: silently discard or auto-initialize, choose one
        Log_Init();
    }
    hal_uart_write_blocking(s);
}

void Log_WriteHex(const void *buf, size_t len) {
    static const char hex[] = "0123456789ABCDEF";
    const uint8_t *p = (const uint8_t *)buf;
    char out[3] = {0};
    for (size_t i = 0; i < len; ++i) {
        out[0] = hex[(p[i] >> 4) & 0xF];
        out[1] = hex[p[i] & 0xF];
        hal_uart_write_blocking(out);
        hal_uart_write_blocking(" ");
    }
}

static const LogIface kIface = {
    .write = Log_Write,
    .writeHex = Log_WriteHex,
};

const LogIface *Log_Get(void) {
    Log_Init();
    return &kIface
}

Usage example:

int main(void) {
    // Other board-level initializations...
    Log_Init();                     // Clear initialization point
    Log_Write("boot ok\r\n");

    const LogIface *log = Log_Get();
    log->write("run...\r\n");
    return 0;
}

Key points:

  • <span>g_log</span> is not exposed in the header file; external access is only through the API.
  • • Initialization is idempotent; if concerned about interrupts being disabled in the first path, explicitly call <span>Log_Init()</span> early in the system.
  • • Keep critical sections short in bare metal; for RTOS, it is recommended to complete initialization during the power-up phase.

Example 2: Lazy Initialization + FreeRTOS Serial Transmission

When multiple tasks write logs and wish to serialize access to the serial port, a mutex can be added. Note: Do not use mutexes in ISRs; provide <span>FromISR</span> paths or only enqueue in ISRs.

// log_rtos.c (fragment)
#include "FreeRTOS.h"
#include "semphr.h"

extern void hal_uart_init(void);
extern void hal_uart_write_blocking(const char *s);

typedef struct {
    volatile int initialized;
} Log_t;
static Log_t g_log = {0};

static SemaphoreHandle_t s_uartMtx;    // Protects only sending, not initialization

void Log_Init(void) {
    if (g_log.initialized) return;
    taskENTER_CRITICAL();
    if (!g_log.initialized) {
        hal_uart_init();
        s_uartMtx = xSemaphoreCreateMutex();
        // In production, it is advisable to assert or handle creation failure
        g_log.initialized = 1;
    }
    taskEXIT_CRITICAL();
}

void Log_Write(const char *s) {
    if (!g_log.initialized) Log_Init();
    if (s_uartMtx) {
        if (xSemaphoreTake(s_uartMtx, portMAX_DELAY) == pdTRUE) {
            hal_uart_write_blocking(s);
            xSemaphoreGive(s_uartMtx);
        }
    } else {
        hal_uart_write_blocking(s);
    }
}

// For ISR usage:
// 1) Provide Log_WriteFromISR (avoid mutex, enqueue to circular buffer);
// 2) Background tasks retrieve for serial printing.

Concurrency and Interrupt Safety Considerations

  • • Initialization should not occur in an ISR. Services needed in ISRs should be initialized early in the system.
  • • Do not use mutexes in ISRs. In RTOS scenarios, use <span>FromISR</span> to enqueue, and tasks will consume.+- Keep critical sections short in bare metal; do not perform blocking peripheral operations within critical sections.
  • • Dependencies among multiple singletons should clearly define the initialization order (clock -> UART -> logger), do not rely on ‘luck’.

Common Pitfalls and Avoidance

  • • Dynamic memory: Avoid using <span>malloc</span> in low-level singletons to prevent fragmentation and ensure predictability.
  • • Implicit globals: Do not place writable structures in header files; expose functions or read-only interface tables.
  • • Testability: Use function pointer tables or weak symbols for interfaces, allowing HAL replacement during unit tests.
  • • Potential for expansion: If multiple instances may be needed in the future, do not hard-code the singleton. Implement as ‘manager + default instance 0’ for smoother migration.

A Reusable Small Template

// singleton_template.h
#ifndef SINGLETON_TEMPLATE_H
#define SINGLETON_TEMPLATE_H

typedef struct {
    int (*init)(void);
    void (*doWork)(int arg);
} ServiceIface;

void Service_Init(void);
const ServiceIface *Service_Get(void);

#endif
// singleton_template.c
#include "singleton_template.h"

// Platform-specific dependencies (example)
static int hal_dep_init(void) { return 0; }
static void hal_dep_work(int arg) { (void)arg; }

typedef struct {
    volatile int initialized;
} Service_t;

static Service_t g_service = {0};

void Service_Init(void) {
    if (g_service.initialized) return;
    // Optional: critical section
    // crit_enter();
    if (!g_service.initialized) {
        (void)hal_dep_init();
        g_service.initialized = 1;
    }
    // crit_exit();
}

static void Service_DoWork(int arg) {
    if (!g_service.initialized) Service_Init();
    hal_dep_work(arg);
}

static const ServiceIface kSvc = {
    .init = Service_Init,
    .doWork = Service_DoWork,
};

const ServiceIface *Service_Get(void) {
    Service_Init();
    return &kSvc
}

Implementation method: Replace the HAL parts with your peripheral/service implementation; replace critical section macros with your project’s bare metal/RTOS implementations.

When Not to Use a Singleton

  • • When multiple instances are needed (multi-channel peripherals, multiple connections, multiple sessions).
  • • Strongly dependent modules on testing isolation, concurrent expansion, hot-swapping, and other ‘complex lifecycle’ requirements.
  • • Business objects (like each connection/session) should have clear creation/destruction interfaces.

Summary

  • • The value of singletons in embedded C is to transform ‘globally unique’ into ‘controllable and testable’: a single entry point, clear initialization, and constrained access.
  • • Prefer eager initialization + static allocation; initialize early in the system.
  • • Clearly define thread/interrupt boundaries: tasks use mutexes, ISRs follow lock-free paths or queues.
  • • Reserve points for testing replacements; do not expose writable globals in header files.

Copy the above template into your project, replace HAL details, and you can quickly create a ‘handy and stable’ singleton module; if there are future multi-instance needs, you can smoothly upgrade to ‘manager + multiple instances’.

Leave a Comment