When programming for resource-constrained systems such as ARM Cortex-M microcontrollers, balancing code size, execution speed, and readability is a daily challenge. A common embedded design pattern is packing multiple flags into a bitmask (like a uint64_t) and executing specific logic when a bit is set.

A frequent refactoring dilemma arises: Should you use sequential if statements, a for loop containing a switch, or a completely different approach? Let's examine the trade-offs on an ARM Cortex-M4 architecture and discover the most efficient solution.

Option 1: Sequential if Statements

The straightforward approach evaluates each bit position sequentially:

#define CHECK_BIT(var, pos) (((var) >> (pos)) & 1ULL)

if (CHECK_BIT(my_flags, CASE_A)) {
    do_a();
}
if (CHECK_BIT(my_flags, CASE_B)) {
    do_b();
}
// ... and so on

Pros:

  • Zero loop overhead: No loop counters, jumps, or branch prediction failures associated with looping.
  • Compiler-friendly: Modern compilers like GCC or Clang with -O2 or -Os optimize these effectively. On ARM, testing a single bit can often be done with a single bit-test/mask instruction (like TST) followed by a conditional branch.
  • Dead Code Elimination: If some flags are compile-time constants, unused blocks are stripped automatically.

Cons:

  • Code Size: If you have 30 to 60 distinct cases, repeating the condition check and jump logic can bloat the binary size (Flash usage).

Option 2: for Loop with a switch Statement

Refactoring into a loop to minimize repetitive code:

for (int i = 0; i < NUM_OF_CASES; i++) {
    if (CHECK_BIT(my_flags, i)) {
        switch (i) {
            case CASE_A:
                do_a();
                break;
            case CASE_B:
                do_b();
                break;
            // ...
        }
    }
}

Why this usually hurts performance and code size:

  • Loop & Branch Overhead: The loop executes NUM_OF_CASES times even if only one bit is set.
  • Double Dispatch: You first check if (CHECK_BIT(my_flags, i)) and then immediately pass i into a switch. Under the hood, a dense switch generates a jump table, while a sparse switch generates binary search branches. You end up paying for testing the bit and jumping through the switch.
  • Higher Flash Footprint: In many cases, generating a jump table combined with loop counter operations uses more code space than clean sequential if checks.

Option 3: The Optimized Bit-Twiddling Approach (Count Trailing Zeros)

If execution speed is critical and the number of set bits is sparse, you can skip the empty bits entirely using hardware-accelerated instructions. The ARM Cortex-M4 features the CLZ (Count Leading Zeros) and RBIT (Reverse Bits) instructions, which calculate trailing zeros in a single clock cycle.

Instead of iterating through every bit, consume only the set bits using GCC/Clang built-ins:

uint64_t remaining = my_flags;

while (remaining != 0) {
    // Find the index of the lowest set bit (0-indexed)
    int bit_index = __builtin_ctzll(remaining);

    switch (bit_index) {
        case CASE_A:
            do_a();
            break;
        case CASE_B:
            do_b();
            break;
        default:
            break;
    }

    // Clear the lowest set bit: x &= (x - 1)
    remaining &= (remaining - 1ULL);
}

Why this shines: If my_flags has 64 possible flags but only 3 are active, this loop executes exactly 3 times. It combines the compactness of a switch jump table with optimal runtime speed.


Option 4: Function Pointer Lookup Table

If each action is relatively independent, an array of function pointers provides a clean separation of concerns:

typedef void (*action_fn)(void);

static const action_fn actions[NUM_OF_CASES] = {
    [CASE_A] = do_a,
    [CASE_B] = do_b,
    // Unused entries default to NULL or a no-op handler
};

uint64_t remaining = my_flags;
while (remaining != 0) {
    int idx = __builtin_ctzll(remaining);
    if (idx < NUM_OF_CASES && actions[idx] != NULL) {
        actions[idx]();
    }
    remaining &= (remaining - 1ULL);
}

Trade-offs:

  • Pros: Extremely clean, maintainable, and separates dispatch logic from business logic. Small code size if the dispatch loop is short.
  • Cons: Indirect function calls prevent compiler inlining and introduce a tiny function call overhead (saving registers on the Cortex-M4 stack). Additionally, storing 64 pointers on a 32-bit architecture consumes 256 bytes in Flash (ROM).

Conclusion: Which One Should You Choose?

  1. Stick with sequential if statements if the number of flags is small (< 16) or if each branch contains only 1 to 2 instructions. Compilers optimize this scenario remarkably well.
  2. Use __builtin_ctzll with a switch if you have many flags (> 16), need tight execution speed, and want to avoid evaluating unset bits.
  3. Avoid a flat for loop with a switch, as it creates unnecessary loop and branching overhead without providing binary size benefits.