Feature Flags in Firmware: Compile-Time vs Runtime Switches in Embedded Systems

Feature Flags in Firmware: Compile-Time vs Runtime Switches in Embedded Systems

In modern embedded platforms, the ability to selectively enable or disable features is more than a convenience, it's an essential tool for safe rollouts, rapid iteration, and predictable behavior across hardware variants. Firmware today powers ecosystems spanning sensors, edge compute, wireless stacks, camera modules, and cloud-connected workflows, and in that environment feature flags become a foundational technique for managing complexity.

At Hoomanely, where our pet-care ecosystem includes sensing Trackers, behavior-aware EverBowls, and edge-compute EverHubs, all built on modular SoM-based designs, feature flags let us evolve firmware while keeping the ecosystem stable, testable, and predictable. This post covers compile-time flags and runtime switches, why they matter in embedded systems, how they shape architecture, and where each fits into a scalable IoT pipeline.

What feature flags are in firmware

A feature flag is a conditional control determining whether a piece of functionality gets included, activated, or configured differently. In firmware this takes two broad forms.

Compile-time flags are controlled by your build system, CMake, Bazel, Make, Kconfig, and decide what gets compiled into the binary: optimizing size, removing branches, changing drivers or behaviors, selecting hardware variants. Runtime switches are configurable after deployment, from flash, EEPROM, NVM, or cloud-downloaded configs, and adjust behavior without reflashing firmware, enabling experiments, staged rollouts, A/B tests, or environment-specific tuning.

Both serve the same purpose: control complexity, reduce risk, and allow fast iteration without destabilizing the device.

Why feature flags matter in embedded and IoT systems

Embedded systems live under constraints, limited compute, tight power budgets, deterministic timing, field deployment realities, and feature flags help solve several problems at once.

Runtime switches allow safe rollouts without a full reflash, letting devices already in the field get toggled behavior, especially important in a multi-product ecosystem where Trackers, EverBowls, and EverHubs update independently but work as a unified fleet. Compile-time flags let hardware variants share the same SoM, so one codebase supports multiple carrier boards, sensors, and connectivity options. Flags also prevent conditional logic from scattering across the codebase as if (variant) checks everywhere, keeping layering clean. Runtime toggles enable progressive experimentation, testing new motion-processing pipelines, audio classifiers, or telemetry compression strategies on a subset of devices without risk. And compile-time flags ensure deterministic behavior in safety-critical flows, guaranteeing no unexpected paths get taken in ISR-heavy or safety-sensitive code.

Compile-time vs runtime, how each works

Compile-time flags get decided during the build. A classic example:

#if ENABLE_BAROMETER
    init_barometer();
#endif

These influence what modules and drivers make it into the final binary, which peripherals get initialized, whether a SoM variant uses I2C or SPI for a sensor, which RTOS tasks exist, which memory pools compile in, and what telemetry types get gathered or compressed. They give zero runtime overhead since the code simply isn't present, they keep the binary small, they enforce architectural guardrails for anything unsafe to enable in the field, and they maintain determinism for timing-sensitive loops, ISR paths, DMA, and control logic. Across our SoM base, they let us include motion and barometer pipelines for Trackers, temperature and weight acquisition for EverBowls, and edge-compute paths with multi-radio coordination for EverHubs, keeping binary builds clean and tailored while sharing common architecture.

Runtime feature switches are toggles stored in persistent memory or fetched from cloud configs, letting you shape behavior without reflashing:

if (cfg.enable_motion_filtering) {
    apply_motion_filter();
}

Typical uses: tuning a classifier's sensitivity, enabling or disabling experimental telemetry, adjusting intervals or thresholds, rolling out new behavior models gradually, toggling debug logs, selecting compression strategies, changing camera capture behavior, or switching between fallback wireless profiles. These matter for field experimentation, testing new algorithms on only a portion of deployed Trackers, for adaptive behavior, letting a gateway like EverHub adopt different local-decision policies based on environment, for real-world safety, disabling a problematic pipeline instantly if an anomaly appears, and for cloud-driven orchestration, updating hundreds of devices with a config push instead of an OTA flash. The trade-offs are real too: every flag adds branching, which matters in timing-sensitive loops, devices can diverge unless flags travel through a synchronized config protocol, and validation gets harder since you need tests for every meaningful flag combination.

When to use which

Use compile-time flags when the feature affects binary size, memory layout, or ISR paths, when behavior must be deterministic, when it's hardware-specific like sensor presence, when the impact is safety-critical, or when you want build-time enforcement of strong boundaries. Sensor presence, driver selection, RTOS task topology, compression pipeline choices, and watchdog configuration style all belong here.

Use runtime flags when you want dynamic behavior changes, when orchestrating staged rollouts, when tuning algorithms in the field, when switching fallback mechanisms during errors, or when you want cloud-configurable behavior without reflashing. Motion sensitivity tuning, picture-capture frequency, telemetry interval adjustments, and debug toggles fit naturally here.

Designing a clean feature-flag system

A robust system needs a global feature registry, a central table like:

typedef struct {
    bool enable_motion;
    bool enable_smart_audio;
    bool enable_edge_decisions;
    bool enable_debug_logs;
} features_t;

This prevents flags from scattering across modules. Layering matters too: modules should read flags, not define them, following config source, then feature registry, then module behavior. Cloud-device synchronization for runtime flags needs versioning, validation, a rollback strategy, default-safe fallback values, and atomic NVM updates. Flags should be categorized, safety-critical stays compile-time only, performance-sensitive depends on impact, experimental and operational and diagnostic flags stay runtime. And flag gatekeeping through a review process prevents unnecessary flags from accumulating.

Lessons from a multi-device fleet

Across the Hoomanely ecosystem, Trackers focus on motion and environmental sensing, EverBowls process weight, temperature, sound, and image capture, and EverHubs handle edge decisions, network coordination, and telemetry batching. Feature flags keep uniform architecture across these products through compile-time flags on a shared firmware base, safe field experiments through runtime toggles for new detection models or weight-processing heuristics without a binary update, consistent cloud governance through a central configuration system, and reduced firmware fragmentation, one codebase, many roles, curated through feature gates. These patterns generalize to any multi-device IoT ecosystem.

Common pitfalls

Too many flags create combinatorial behavior explosion, fixed by maintaining a flag budget and requiring architectural approval for new ones. Runtime flags inside tight loops can introduce jitter through branching, fixed by snapshotting flags at boot or each pipeline epoch and using cached static values. Using runtime flags for safety-critical behavior, like configurable watchdog paths or ISR timing, is dangerous, fixed by making those compile-time or locking them behind gated OTA flows. And a mismatch between cloud config and firmware expectations, fields changing names, types, or structure, gets fixed by versioning both sides and validating against schemas.

Takeaways

Feature flags are architecture tools, not just convenience, shaping how embedded systems scale, evolve, and stay safe. Compile-time flags control binary composition, determinism, safety, and hardware-specific behavior. Runtime switches enable distributed experimentation, tuning, and cloud-driven orchestration. A central registry, clean layering, and validation rules make the whole system maintainable. In complex IoT ecosystems, flags keep consistency across diverse device roles while minimizing firmware fragmentation. Thinking of feature flags as a governance framework, rather than just #ifdef or config toggles, is what elevates this from ad hoc decisions to scalable architecture.