Case Study on the Design Patterns for Embedded Systems using Flipper One's Firmware
This document was created by OpenCode Zen Big Pickle LLM with my guidance.
1. Architectural Patterns
1.1 Microkernel / Service-Oriented Architecture
Concept: A minimal core (the kernel) that loads and coordinates independent modules (services). Each service is an isolated FreeRTOS task responsible for one domain.
Implementation (applications/applications.h):
// All services, apps, and CLI commands are declared as static tables
// using a registry macro system:
typedef struct {
const char* name;
const char* appid;
uint32_t stack_size;
FuriThreadCallback app;
FlipperInternalApplicationFlag flags;
} FlipperInternalApplication;
// Services are long-lived daemon threads:
#define FLIPPER_SERVICES \
DEF(GUI, "GuiSrv", 4096, gui_srv, FlipperInternalApplicationFlagInsomniaSafe) \
DEF(INPUT, "InputSrv", 2048, input_srv, FlipperInternalApplicationFlagInsomniaSafe) \
DEF(POWER, "PowerSrv", 2048, power_srv, FlipperInternalApplicationFlagInsomniaSafe) \
DEF(HAPTIC, "HapticSrv", 2048, haptic_srv, FlipperInternalApplicationFlagInsomniaSafe) \
/* ... 18 services total */
// Main apps are user-launched:
#define FLIPPER_APPS \
DEF(CPU, "CPU App", 2048, cpu_app, FlipperInternalApplicationFlagDefault)
// Autorun apps run at boot:
#define FLIPPER_AUTORUN_APPS \
DEF(SELF_CHECK, "Self Check", 2048, self_check, FlipperInternalApplicationFlagDefault)
Why it matters: Every service is self-contained with its own thread, stack, and event loop. Adding a new feature means adding one entry to the table and writing the service module — no modification to existing code. This is the Open/Closed Principle in embedded C.
Lesson for your designs: Use a central registry of all tasks/modules. Make it trivial to add/remove features by editing one table.
1.2 Reactor / Event Loop
Concept: A single-threaded, non-blocking event dispatcher. The thread blocks until events arrive, then dispatches them to registered handlers. Inspired by epoll/kqueue.
Implementation (lib/furi/core/event_loop.h):
// Each service creates its own event loop:
FuriEventLoop* loop = furi_event_loop_alloc();
// Subscribe to various event sources:
furi_event_loop_subscribe_message_queue(loop, msg_queue,
FuriEventLoopEventIn, my_callback, ctx);
furi_event_loop_subscribe_timer(loop, timer, my_timer_cb, ctx);
furi_event_loop_subscribe_event_flag(loop, flag,
FuriEventLoopEventFlagEdge, my_flag_cb, ctx);
// Run forever:
furi_event_loop_run(loop); // Blocks, dispatches events
Key design insight: The event loop uses a Strategy pattern internally via FuriEventLoopContract:
// Each object type provides its own contract (vtable):
typedef struct {
const FuriEventLoopContractGetLink get_link;
const FuriEventLoopContractGetLevel get_level;
} FuriEventLoopContract;
// Message queues, event flags, stream buffers, semaphores each
// implement these differently
extern const FuriEventLoopContract furi_event_loop_message_queue_contract;
extern const FuriEventLoopContract furi_event_loop_event_flag_contract;
It supports level-triggered (fire while condition holds) and edge-triggered (fire once per state change) — directly modeled after Linux epoll.
Why it matters: Instead of each service having a custom polling loop, they all use the same event loop pattern. This gives:
- Uniform structure across all modules
- No busy-waiting (power efficient)
- Clean separation of event sources from event handlers
- Thread-safe deferral via
furi_event_loop_pend_callback()
Lesson for your designs: Build or adopt a lightweight reactor pattern. It eliminates the common firmware anti-pattern of ad-hoc polling loops and makes every module structurally consistent.
1.3 Layered Architecture
Concept: Strict separation into layers, each depending only on the layer below.
Implementation (entire project):
┌─────────────────────────────────────────┐
│ Applications Layer │
│ (gui, input, power, cli, desktop, ...) │
├─────────────────────────────────────────┤
│ Service Layer │
│ (thread lifecycle, IPC, records, ...) │
├─────────────────────────────────────────┤
│ FURI Framework Layer │
│ (event_loop, pubsub, thread, string) │
├─────────────────────────────────────────┤
│ HAL Abstraction Layer │
│ (furi_hal_i2c, gpio, spi, serial) │
├─────────────────────────────────────────┤
│ ┌──────────────────────────────────┐ │
│ │ Driver Layer │ │
│ │ (bq25792, drv2605l, iqs7211e) │ │
│ └──────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Raspberry Pi Pico SDK + Hardware │
└─────────────────────────────────────────┘
Each driver (e.g., BQ25792 charger) only calls furi_hal_i2c — it never touches the I2C registers directly. Each service (e.g., PowerSrv) only calls the driver API — it never touches furi_hal_i2c.
Why it matters: You can swap hardware (e.g., a different charger IC) by replacing only the driver layer. The service and HAL layers remain unchanged.
Lesson: Draw your layer diagram before writing code. Enforce strict dependency direction.
2. Creational Patterns
2.1 Factory Method (via Macro Registry)
Concept: A centralized function that creates objects (threads) from static descriptors.
Implementation (lib/furi/flipper.c):
void flipper_init() {
// Iterate service table, creating a thread for each:
for(size_t i = 0; i < COUNT_OF(FLIPPER_SERVICES); i++) {
const FlipperInternalApplication* app = &FLIPPER_SERVICES[i];
FuriThread* thread = furi_thread_alloc_service(
app->name, app->stack_size, app->app, NULL);
furi_thread_set_appid(thread, app->appid);
furi_thread_start(thread);
}
}
Why it matters: New services are added by declaring them in the table — no factory code changes.
2.2 Builder Pattern (Two-Phase Construction)
Concept: Object construction separated into allocation + configuration + start.
Implementation (lib/furi/core/thread.h):
// Phase 1: Allocate with required params
FuriThread* thread = furi_thread_alloc_ex("MyApp", 2048, my_callback, ctx);
// Phase 2: Optional configuration
furi_thread_set_appid(thread, "my_app");
furi_thread_set_priority(thread, FuriThreadPriorityHigh);
// Phase 3: Start
furi_thread_start(thread);
For services, a convenience alloc_service() variant skips the config phase (everything is fixed at compile time).
Why it matters: Avoids constructors with 8 parameters while still catching misconfiguration at compile time.
2.3 Singleton (via Record Registry)
Concept: Not your textbook Singleton. Uses a named global registry where services publish their interface pointer.
Implementation (lib/furi/core/record.h):
// Publisher (during service init):
furi_record_create(RECORD_GUI, gui_instance);
// Consumer (anywhere in the system):
Gui* gui = furi_record_open(RECORD_GUI);
// ... use gui ...
furi_record_close(RECORD_GUI);
// Records are reference-counted:
// furi_record_open() increments holders_count
// furi_record_close() decrements it
// Record can only be destroyed when holders_count == 0
Why this is better than a raw global:
- Reference counted (proper lifecycle tracking)
- Thread-safe (mutex-guarded)
- Blocking open: if a service hasn't created its record yet, the consumer waits on an event flag
- Discoverable by name (no header dependency for every cross-module reference)
Lesson: Use a service locator with reference counting rather than bare extern globals.
3. Structural Patterns
3.1 Facade
Concept: A single header that provides unified access to a complex subsystem.
Implementation (lib/furi/furi.h):
// A single #include gives access to the entire framework:
#include <furi.h>
// Now you have: furi_thread_*, furi_event_loop_*, furi_record_*,
// furi_pubsub_*, furi_log_*, FuriString, ...
// Similarly for HAL:
#include <furi_hal.h>
// Now you have: furi_hal_i2c_*, furi_hal_gpio_*, furi_hal_spi_*, ...
Why it matters: New developers don't need to hunt through 30+ headers — one include gets everything.
3.2 Adapter / Wrapper
Concept: Wrapping a low-level API (FreeRTOS/Pico SDK) into a higher-level, safer, idiomatically consistent API.
Implementation (lib/furi/core/thread.c):
// FuriThread wraps FreeRTOS TaskHandle_t:
struct FuriThread {
StaticTask_t container; // FreeRTOS TCB (at offset 0 for casting)
char* name;
char* appid;
FuriThreadState state;
FuriThreadCallback callback;
void* context;
// ... state management, heap tracking, stdio buffering, etc.
};
// The cast trick: FuriThread* can be used as TaskHandle_t
// because StaticTask_t is at offset 0:
static void furi_thread_body(void* context) {
FuriThread* thread = (FuriThread*)context;
// TLS setup, state transition, heap trace, then:
thread->callback(thread->context);
// cleanup, scrub enqueue, suspend
}
Why it matters: The adapter adds:
- State machine (Stopped → Starting → Running → Stopping → Stopped)
- Service thread protection (cannot be freed/joined)
- Heap balance tracking
- Thread-local storage association
- All with the same FreeRTOS primitives underneath
Lesson: When a vendor SDK is awkward or unsafe, wrap it. Don't let FreeRTOS' raw API leak into your application code.
3.3 Opaque Pointer / PIMPL
Concept: The struct definition lives in the .c file (or _i.h). The public header only has a forward declaration.
Implementation:
// In public header (bq25792.h):
typedef struct Bq25792 Bq25792;
Bq25792* bq25792_init(const FuriHalI2cBusHandle* handle, const GpioPin* pin_int);
Bq25792Status bq25792_enable_charging(Bq25792* instance);
// In private .c file:
struct Bq25792 {
const FuriHalI2cBusHandle* i2c_handle;
uint8_t address;
const GpioPin* pin_interrupt;
Bq25792CallbackInput callback;
void* context;
};
Why it matters:
- Consumers can't access internals (encapsulation enforced by the language)
- The struct can change without recompiling consumers
- No header dependency chain exposure
Lesson: Always make your driver instances opaque pointers. Consumers should never sizeof(YourDriver) or access fields directly.
3.4 Strategy Pattern (via Callback Injection)
Concept: Behavior is injected as function pointers rather than hardcoded.
Implementation (applications/services/gui/view.h):
typedef void (*ViewLayoutCallback)(View* view, Canvas* canvas);
typedef bool (*ViewInputCallback)(View* view, const InputEvent* event);
typedef bool (*ViewInputTouchCallback)(View* view, const InputTouchEvent* event);
View* view_alloc(void);
void view_set_layout_callback(View* view, ViewLayoutCallback callback);
void view_set_input_callback(View* view, ViewInputCallback callback);
void view_set_input_touch_callback(View* view, ViewInputTouchCallback callback);
// Each application provides its own callbacks:
view_set_layout_callback(view, my_app_layout);
view_set_input_callback(view, my_app_input);
Also in lib/furi/core/event_loop.h:
// Event loop objects provide contracts via vtable:
typedef struct {
const FuriEventLoopContractGetLink get_link;
const FuriEventLoopContractGetLevel get_level;
} FuriEventLoopContract;
// Each subscribable type implements this differently:
extern const FuriEventLoopContract furi_event_loop_message_queue_contract;
extern const FuriEventLoopContract furi_event_loop_event_flag_contract;
Why it matters: The GUI service has zero knowledge of what any specific app does — it just calls the callbacks. New apps don't modify the GUI.
3.5 Chain of Responsibility
Concept: An event passes through a chain of handlers until one consumes it.
Implementation (applications/services/gui/gui.c):
// Views are sorted by priority (Desktop < Application < Menu).
// Input routing walks from top priority downward:
static bool gui_input_events_glue_callback(void* context, const InputEvent* event) {
Gui* gui = context;
// Find highest-priority enabled view:
View* top = gui_view_find_any_from_top(gui, view_is_visible);
if(top) {
// Try the view's input callback:
if(view_input(top, event)) return true; // Consumed!
// If transparent and not consumed, try next view:
if(view_is_transparent(top)) {
View* next = gui_view_find_next_from_top(gui, view_is_visible, top);
if(next) return view_input(next, event);
}
}
return false;
}
Lesson: For event handling in a layered UI, Chain of Responsibility is cleaner than switch statements or if-else chains.
4. Behavioral Patterns
4.1 Observer / PubSub
Concept: One-to-many notification. Publishers don't know who's listening.
Implementation (lib/furi/core/pubsub.h):
// Publisher side (input service):
FuriPubSub* event_pubsub = furi_pubsub_alloc();
furi_record_create(RECORD_INPUT_EVENTS, event_pubsub);
// When a button is pressed:
furi_pubsub_publish(event_pubsub, &event);
// Subscriber side (GUI service):
FuriPubSub* input_pubsub = furi_record_open(RECORD_INPUT_EVENTS);
furi_pubsub_subscribe(input_pubsub, gui_input_events_glue_callback, gui);
Why it matters: The input service doesn't know (or care) that GUI, I2C negotiator, and desktop are all listening. New listeners can be added without modifying the input service.
Implementation caveat: The FURI PubSub is synchronous — all callbacks fire inside the publish call, under the mutex. This is by design: it avoids queuing and is simple. But it means callbacks must be fast.
Lesson: Use PubSub for one-to-many event distribution. Consider whether your system needs synchronous (simpler) or asynchronous (safer with slow handlers) delivery.
4.2 Command Pattern (Message Queue)
Concept: Operations are packaged as messages and queued for serial processing by a dedicated thread.
Implementation (applications/services/power/power.c):
// Each operation is a tagged message:
typedef enum {
PowerMessageTypeIna219GetVoltage,
PowerMessageTypeBq25792GetStatus,
PowerMessageTypeBq25792SetChargeCurrent,
// ...
} PowerMessageType;
typedef struct {
PowerMessageType type;
FuriApiLock* lock; // NULL = fire-and-forget
union {
float voltage_v; // result
Bq25792Status charger_status; // result
uint16_t charge_current_ma; // parameter
// ...
} as;
} PowerMessage;
// Public API wraps message send:
bool power_bq25792_get_status(Bq25792Status* status) {
PowerMessage msg = {
.type = PowerMessageTypeBq25792GetStatus,
.lock = api_lock_alloc_locked(), // Future: wait for completion
.as = {0}
};
power_send_message(msg); // Enqueue + block on lock
if(status) *status = msg.as.charger_status;
api_lock_wait_unlock_and_free(msg.lock);
return true;
}
Why it matters:
- All hardware access happens on one thread (no concurrent register access)
- Mutex-free driver design (serialized by queue)
- ISR-safe: enqueue from interrupt context, process in thread context
- Fire-and-forget vs synchronous-wait is caller's choice
Lesson: Any module that manages real hardware should use a command queue. This is the single most important pattern for thread-safe driver design.
4.3 Proxy (Synchronous Call over Async Queue)
Concept: The public API looks synchronous (returns a value), but internally it enqueues a message and blocks until the service thread processes it.
Implementation (applications/services/haptic/haptic.c):
// The API wrapper macro:
#define API_WRAPPER(dev, message_type, result_expr) \
if(!((instance)->devices & (dev))) { break; } \
PowerMessage msg = { .type = (message_type) }; \
if(!power_send_message(instance, msg)) { break; } \
(result_expr); \
return true;
bool power_ina219_get_voltage_v(float* voltage) {
API_WRAPPER_PARAM(
PowerDeviceIna219,
PowerMessageTypeIna219GetVoltage,
{ if(voltage) *voltage = msg.as.voltage_v; },
msg.as.voltage_v = 0.0f
);
return false;
}
The FuriApiLock implementation (lib/toolbox/api_lock.h):
typedef struct {
FuriEventFlag* flag;
uint32_t bit;
} FuriApiLock;
// Alloc locked (caller blocks immediately):
FuriApiLock* api_lock_alloc_locked(void) {
FuriApiLock* lock = malloc(sizeof(FuriApiLock));
lock->flag = furi_event_flag_alloc();
lock->bit = 1;
furi_event_flag_set(lock->flag, lock->bit);
return lock;
}
// Service unlocks when done:
void api_lock_unlock(FuriApiLock* lock) {
furi_event_flag_set(lock->flag, lock->bit);
}
// Caller waits:
void api_lock_wait_unlock_and_free(FuriApiLock* lock) {
furi_event_flag_wait(lock->flag, lock->bit, FuriFlagWaitAny, FuriWaitForever);
furi_event_flag_free(lock->flag);
free(lock);
}
Why it matters: The caller doesn't need to know about queues, threads, or async patterns. The API looks like a simple function call.
Lesson: Hide your threading model behind synchronous-looking APIs. Let the implementation use message queues internally.
4.4 State Machine (Explicit)
Concept: An enum of states + events that trigger transitions.
Implementation (applications/services/i2c_intercom/i2c_intercom.c):
typedef enum {
I2CIntercomStateIdle,
I2CIntercomStateStart,
I2CIntercomStateAddressSet,
I2CIntercomStateAddressNoSet,
I2CIntercomStateDataTransmitted,
} I2CIntercomState;
static void i2c_intercom_isr(FuriHalI2cBusHandle* handle,
FuriHalI2cBusSlaveEvent event, void* context) {
I2CIntercom* intercom = context;
switch(event) {
case FuriHalI2cBusSlaveEventStart:
intercom->state = I2CIntercomStateStart;
// Start timeout alarm
break;
case FuriHalI2cBusSlaveEventWrite:
if(intercom->state == I2CIntercomStateStart) {
intercom->addr_buf[intercom->addr_cnt++] = data;
if(intercom->addr_cnt == 2) {
intercom->state = I2CIntercomStateAddressSet;
// Look up register, prepare for data
}
}
break;
case FuriHalI2cBusSlaveEventStop:
intercom->state = I2CIntercomStateIdle;
break;
}
}
Why it matters: The I2C protocol is inherently stateful (Start → Address → Data → Stop). An explicit state machine makes the code provably correct.
Lesson: When your protocol or peripheral has distinct phases, model it as an enum state machine. Don't use flags + if-else chains.
4.5 State Machine (Counter-Based)
Concept: State is tracked by integer counters with thresholds.
Implementation (applications/services/input/input.c):
#define INPUT_DEBOUNCE_TICKS 4
#define INPUT_DEBOUNCE_TICKS_HALF (INPUT_DEBOUNCE_TICKS / 2)
#define INPUT_LONG_PRESS_COUNTS 25
typedef struct {
volatile uint8_t debounce; // 0-4 integration counter
volatile uint8_t press_counter; // 0-25+ press duration
FuriTimer* press_timer; // Periodic timer during press
bool current_state; // Current debounced state
} InputPinState;
// Debounce: integrate over multiple samples
void input_isr(void* context) {
// Wakes the polling thread via thread flags
furi_thread_flags_set(thread_id, INPUT_THREAD_FLAG_ISR);
}
void input_poll(Input* input) {
for each pin {
bool physical = read_gpio(pin);
if(physical) {
pin->debounce = MIN(pin->debounce + 1, INPUT_DEBOUNCE_TICKS);
} else {
pin->debounce = MAX(pin->debounce - 1, 0);
}
// Trigger on midpoint crossing:
bool new_state = pin->debounce > INPUT_DEBOUNCE_TICKS_HALF;
if(new_state != pin->current_state) {
pin->current_state = new_state;
if(new_state) furi_timer_start(pin->press_timer, period);
else furi_timer_stop(pin->press_timer);
}
}
}
// Timer callback: press duration tracking
void input_press_timer_callback(void* context) {
InputPinState* pin = context;
pin->press_counter++;
if(pin->press_counter == INPUT_LONG_PRESS_COUNTS)
emit(InputTypeLong);
else if(pin->press_counter > INPUT_LONG_PRESS_COUNTS)
emit(InputTypeRepeat);
}
Why it matters: This is a classic embedded debounce with integrated press-and-hold detection. No explicit state enum, but the behavior clearly has phases: idle → press → short → long → repeat.
4.6 Template Method
Concept: The skeleton of an algorithm is defined, with specific steps delegated to callbacks.
Implementation (lib/furi/core/thread.c):
// The "template" — fixed execution skeleton:
static void furi_thread_body(void* context) {
FuriThread* thread = (FuriThread*)context;
// 1. Set up thread-local storage
vTaskSetThreadLocalStoragePointer(NULL, 0, thread);
// 2. Transition state
furi_thread_set_state(thread, FuriThreadStateRunning);
// 3. Call the user's code (the "hook")
thread->callback(thread->context);
// 4. Post-execution cleanup
furi_thread_set_state(thread, FuriThreadStateStopping);
// 5. Enqueue for scrubbing
furi_message_queue_put(scrub_queue, &thread, 0);
// 6. Suspend self
vTaskSuspend(NULL);
}
Why it matters: Every thread gets consistent setup/teardown. Users can't forget to initialize TLS or clean up.
5. Concurrency Patterns
5.1 Actor / Active Object
Concept: Each service is an "actor" owning its thread, event loop, and message queue. External code communicates only via message passing.
Implementation: Every service in applications/services/ follows this:
// Every service looks like this:
typedef struct {
FuriEventLoop* event_loop; // Private event loop
FuriMessageQueue* queue; // Incoming command queue
// driver instances owned by this service
Bq25792* charger;
INA219* current_sense;
} Power;
// Public API: enqueue messages
bool power_bq25792_get_status(Bq25792Status* status) {
PowerMessage msg = { .type = PowerMessageTypeBq25792GetStatus, ... };
furi_message_queue_put(power->queue, &msg, timeout);
// optionally block on API lock for result
}
// Private handler: process one message
static void power_queue_callback(void* context) {
Power* power = context;
PowerMessage msg;
furi_message_queue_get(power->queue, &msg, 0);
switch(msg.type) {
case PowerMessageTypeBq25792GetStatus:
msg.as.charger_status = bq25792_get_status(power->charger);
if(msg.lock) api_lock_unlock(msg.lock);
break;
// ...
}
}
Why it matters: No mutexes needed in the drivers — all access is serialized by the queue. This is the single best pattern for multi-threaded embedded systems.
Lesson: Design every module as an actor. Give it a queue. Make the public API just message construction + enqueue.
5.2 Deferred Cleanup (Scrub Queue)
Concept: A thread cannot safely delete itself. So it enqueues its own termination for another thread to process.
Implementation (lib/furi/core/thread.c):
// Global scrub queue:
static FuriMessageQueue* furi_thread_scrub_message_queue = NULL;
// Thread body ends by enqueuing itself:
void furi_thread_body(void* context) {
// ... user callback runs ...
// Can't vTaskDelete() here (running on this stack!)
furi_message_queue_put(furi_thread_scrub_message_queue, &thread, 0);
vTaskSuspend(NULL);
}
// Background scrubber processes dead threads:
void furi_background(void) {
FuriThread* thread;
while(furi_message_queue_get(furi_thread_scrub_message_queue, &thread, timeout) == FuriStatusOk) {
furi_thread_free(thread); // Now safe to delete
}
}
Similarly in the event loop: if you unsubscribe from within a callback, the item is marked for deferred free.
Why it matters: You cannot free() a thread's stack from within that thread. The scrub queue pattern solves this cleanly.
5.3 Insomnia Reference Counting
Concept: A counter prevents the system from sleeping while a peripheral is in use.
Implementation (targets/f100/furi_hal/furi_hal_power.c):
static volatile FuriHalPower furi_hal_power = { .insomnia = 0 };
void furi_hal_power_insomnia_enter(void) {
FURI_CRITICAL_ENTER();
furi_check(furi_hal_power.insomnia < UINT8_MAX); // overflow guard
furi_hal_power.insomnia++;
FURI_CRITICAL_EXIT();
}
void furi_hal_power_insomnia_exit(void) {
FURI_CRITICAL_ENTER();
furi_check(furi_hal_power.insomnia > 0); // underflow guard
furi_hal_power.insomnia--;
FURI_CRITICAL_EXIT();
}
bool furi_hal_power_sleep_available(void) {
return furi_hal_power.insomnia == 0;
}
// Called by I2C transactions:
void furi_hal_i2c_acquire(const FuriHalI2cBusHandle* handle) {
furi_hal_power_insomnia_enter(); // Prevent sleep during transfer
furi_mutex_acquire(bus->mutex, FuriWaitForever);
// activate bus...
}
void furi_hal_i2c_release(const FuriHalI2cBusHandle* handle) {
// deactivate bus...
furi_mutex_release(bus->mutex);
furi_hal_power_insomnia_exit(); // Now sleep is allowed
}
Why it matters: An I2C transfer must not be interrupted by deep sleep. The reference count ensures sleep only happens when every peripheral agrees it's safe.
Lesson: Use a reference-counted sleep veto pattern instead of ad-hoc sleep control.
5.4 Two-Level Error Handling
Concept: Programming errors crash (assertions). Runtime errors return codes.
| Error Type | Mechanism | Example |
|---|---|---|
| Null pointer | furi_check(ptr) → crash |
Calling with NULL handle |
| Wrong thread | furi_check(owner == current) → crash |
Calling I2C without acquire |
| Invalid config | furi_crash() → crash |
SPI mode = 5 |
| I2C timeout | Returns PICO_ERROR_TIMEOUT |
Bus locked up |
| Charger fault | Returns Bq25792StatusError |
Overcurrent |
Implementation:
// Programming errors = crash (contract violation):
void furi_hal_i2c_acquire(const FuriHalI2cBusHandle* handle) {
furi_check(handle);
furi_check(handle->bus);
furi_check(handle->bus->current_handle == NULL ||
handle->bus->current_handle == handle);
// ...
}
// Runtime errors = return code:
Bq25792Status bq25792_read_register(Bq25792* inst, uint8_t reg, uint8_t* data) {
furi_check(inst); // Programming error → crash
furi_hal_i2c_acquire(inst->i2c_handle);
int ret = furi_hal_i2c_master_tx_blocking(inst->i2c_handle, addr, ®, 1, timeout);
furi_hal_i2c_release(inst->i2c_handle);
if(ret != PICO_OK) return Bq25792StatusError; // Runtime error → return
return Bq25792StatusOk;
}
Why it matters: You catch bugs fast (crash = immediate attention) while handling expected failures gracefully (timeout = retry).
6. Embedded-Specific Patterns
6.1 Memory-Mapped Register Emulation (Software MMIO)
Concept: Emulate a hardware register file over I2C, with callbacks on write.
Implementation (applications/services/i2c_intercom/i2c_registers.c):
// Register definition:
typedef struct {
uint16_t address;
uint16_t value;
I2CRegisterFlag flags; // R, W, RC (read-to-clear)
I2CRegisterCallback callback; // Fires on write
void* context;
} I2CReg;
// Register map (hash table):
static I2CRegMap_t reg_map; // I2CReg address → I2CReg
void i2c_register_set(I2CRegisters* i2c, uint16_t address, uint16_t value) {
with_i2c_register({
I2CReg* reg = i2c_register_get(address);
if(reg && (reg->flags & I2CRegisterFlagW)) {
reg->value = value;
if(reg->callback) reg->callback(reg->context);
}
});
}
Register map (i2c_registers_map.h):
// The CPU sees this layout over I2C:
#define I2C_REG_STATUS 0x0000 // MCU status
#define I2C_REG_INPUT_EVENTS 0x0002 // Button state bitmask
#define I2C_REG_TOUCH_X 0x0004 // Touchpad X
#define I2C_REG_TOUCH_Y 0x0006 // Touchpad Y
#define I2C_REG_TOUCH_PRESSURE 0x0008 // Touchpad pressure
#define I2C_REG_HEADPHONES 0x000A // Headphone status
#define I2C_REG_LED_BRIGHTNESS 0x000C // LED brightness
#define I2C_REG_LED_COLOR 0x000E // LED color
With interrupt signaling:
void i2c_registers_set_interrupt(I2CRegisters* i2c, uint16_t addr, uint16_t bit) {
with_i2c_register({
I2CReg* ireg = i2c_register_get(addr);
if(ireg) {
ireg->value |= bit;
// Check mask: if corresponding mask bit is 0, assert interrupt
I2CReg* mask = i2c_register_get(I2C_REG_INTERRUPT_MASK);
if(mask && !(mask->value & bit)) {
furi_hal_gpio_write(&gpio_cpu_int, false); // INT low = IRQ
}
}
});
}
Why it matters: This is how an MCU communicates with a main CPU over I2C without a custom protocol. The CPU just reads/writes "registers" like any memory-mapped peripheral.
Lesson: When designing inter-processor communication, define a register map. It's simpler and more debuggable than a message-based protocol.
6.2 GPIO Interrupt Registry
Concept: A centralized array mapping pin numbers to callbacks, with critical-section protection.
Implementation (targets/f100/furi_hal/furi_hal_gpio.c):
#define GPIO_NUMBER 48
typedef struct {
GpioExtiCallback callback;
void* context;
GpioCondition condition; // Rise, Fall, or Both
} GpioInterrupt;
static volatile GpioInterrupt gpio_interrupt[GPIO_NUMBER];
void furi_hal_gpio_add_int_callback(const GpioPin* gpio,
GpioExtiCallback cb, void* ctx) {
FURI_CRITICAL_ENTER();
furi_check(gpio->pin < GPIO_NUMBER);
furi_check(gpio_interrupt[gpio->pin].callback == NULL); // No double-reg
gpio_interrupt[gpio->pin].callback = cb;
gpio_interrupt[gpio->pin].context = ctx;
FURI_CRITICAL_EXIT();
}
// Single ISR handler dispatches to registered callbacks:
void gpio_irq_handler(void) {
uint32_t event = gpio_get_irq_event_mask(pin);
uint idx = pin;
GpioInterrupt* gi = &gpio_interrupt[idx];
if(gi->callback) {
gi->callback(gi->context, idx);
}
}
Why it matters: Centralized interrupt management prevents conflicts, enables debug, and provides a consistent API.
6.3 Vtable-based Polymorphism
Concept: C function pointer tables for runtime polymorphism, without C++.
Implementation (targets/f100/furi_hal/furi_hal_i2c.c):
// Each I2C bus implementation provides these operations:
typedef union {
struct {
FuriHalI2cBusEventCallback event;
} master;
struct {
FuriHalI2cBusSlaveEventCallback event;
} slave;
} FuriHalI2cBusAPI;
// The bus struct uses the API union:
struct FuriHalI2cBus {
void* data; // PIO instance or HW I2C peripheral
FuriMutex* mutex;
FuriHalI2cBusAPI api;
FuriHalI2cMode mode;
const FuriHalI2cBusHandle* current_handle;
};
// Three buses exist, each with different implementation:
// - Control bus: PIO-based I2C on GPIO20/21 (400kHz)
// - Main bus: PIO-based I2C on GPIO22/23 (400kHz)
// - CPU bus: Hardware I2C1 on GPIO10/11 (100kHz, slave-capable)
Why it matters: The I2C abstraction supports both PIO-based and hardware I2C with the same API. No conditional compilation at the call site.
6.4 ISR-Safe Dual-Path APIs
Concept: Functions that can be called from both task and ISR context check the mode and use the correct FreeRTOS API variant.
Implementation (scattered throughout the codebase):
void furi_thread_flags_set(FuriThread* thread, uint32_t flags) {
if(FURI_IS_IRQ_MODE()) {
// FromISR variant (no context switch):
xTaskNotifyFromISR(thread->container, flags, eSetBits, NULL);
} else {
// Task variant (can context switch):
xTaskNotify(thread->container, flags, eSetBits);
}
}
Why it matters: Same function works in both contexts. The caller doesn't need to know or care.
6.5 C11 _Generic for Function Overloading
Concept: C11's _Generic keyword dispatches to different implementations based on argument type.
Implementation (lib/furi/core/string.h):
// "Overloaded" set: works with both FuriString* and const char*:
#define furi_string_set(a, b) \
_Generic((b), \
FuriString*: furi_string_set, \
const char*: furi_string_set_str, \
char*: furi_string_set_str \
)(a, b)
// Usage:
FuriString* s = furi_string_alloc();
furi_string_set(s, other_string); // calls furi_string_set()
furi_string_set(s, "hello"); // calls furi_string_set_str()
Why it matters: Cleaner API without printf-style format strings. Type-safe at compile time.
6.6 Pool Allocation for Critical Paths
Concept: Pre-allocate memory from a fixed pool at init time for threads that must never fail allocation.
Implementation (lib/furi/core/thread.c):
FuriThread* furi_thread_alloc_service(const char* name,
uint32_t stack_size,
FuriThreadCallback callback,
void* context) {
// Allocate from pool — guaranteed to succeed:
FuriThread* thread = memmgr_alloc_from_pool(sizeof(FuriThread));
thread->stack = memmgr_alloc_from_pool(stack_size);
// Can't be freed, can't be joined, can't be modified
thread->is_service = true;
return thread;
}
Why it matters: Service threads must never fail to allocate, even if the heap is fragmented. Pool allocation guarantees this.
7. Pattern Usage Map
| Pattern | Module | What For |
|---|---|---|
| Microkernel | applications/applications.h |
Service/plugin registry |
| Reactor | lib/furi/core/event_loop.* |
Central event dispatch |
| Facade | lib/furi/furi.h, furi_hal.h |
Single-include API surface |
| Adapter | lib/furi/core/thread.* |
Wrapping FreeRTOS tasks |
| Opaque Pointer | All drivers (bq25792, drv2605l, etc.) | Encapsulation |
| Strategy (callback) | gui/view.*, event_loop.* |
Injected behavior |
| Observer | lib/furi/core/pubsub.* |
Input events, power events |
| Command | services/power/*, services/haptic/* |
Typed message queues |
| Proxy | services/power/* |
Sync API over async queue |
| Chain of Resp. | services/gui/gui.c |
Input routing through views |
| State Machine | services/input/*, i2c_intercom/* |
Debounce + I2C protocol |
| Template Method | lib/furi/core/thread.* |
Thread body skeleton |
| Singleton | lib/furi/core/record.* |
Service locator |
| Builder | lib/furi/core/thread.* |
Two-phase thread construction |
| Factory | lib/furi/flipper.c |
Thread creation from table |
| Actor | All services | Message-queue-per-thread |
| Scrub Queue | lib/furi/core/thread.* |
Deferred thread cleanup |
| Ref. Counting | furi_hal_power.* |
Sleep veto |
| Software MMIO | i2c_intercom/* |
Virtual register map |
| Pool Allocator | lib/furi/core/thread.* |
Guaranteed service allocation |
| PIMPL | lib/furi/core/string.* |
FuriString opacity |
| Vtable | furi_hal_i2c.* |
Polymorphic I2C backends |
| Fail-Fast | Everywhere with furi_check() |
Contract enforcement |
8. Key Takeaways for Your Embedded Designs
Do This:
-
Use a reactor (event loop) everywhere — never write a custom polling loop. Every module gets a
FuriEventLoop-like construct. -
Make every driver instance an opaque pointer — consumers should never see your struct internals.
-
Use typed message queues for hardware access — serialize all peripheral access through one thread. It eliminates most concurrency bugs.
-
Use a service locator (not globals) — give each module a named record. Reference-count it. Make
open()block until the service is ready. -
Use fail-fast assertions for contract violations — null pointers, invalid state, wrong-thread access should crash immediately. Don't return error codes for programmer mistakes.
-
Define a register map for inter-processor comms — it's simpler and more debuggable than a custom message protocol.
-
Use a sleep veto reference count — peripherals increment it during transactions, the idle loop checks it before sleeping.
-
Build a central service table — all tasks declared in one place. Adding a feature = adding one table entry.
-
Use callback-based injection — your GUI/service should call function pointers, not know about specific implementations.
-
Defer cleanup that can't happen in context — use a scrub queue for thread deletion, mark-for-defer for unsubscription during callback.
Avoid This:
- Raw
externglobals for inter-module access - Custom polling loops
- Driver functions with no error return (but also don't return error codes for null pointers — assert those)
- Shared mutable state without serialization
- Hard-coded thread priorities scattered across files
- Direct FreeRTOS API calls in application code