Optimizing Bitmask Evaluations in Embedded C: Sequential Ifs vs. For Loops vs. Function Pointers
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
-O2or-Osoptimize these effectively. On ARM, testing a single bit can often be done with a single bit-test/mask instruction (likeTST) 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_CASEStimes even if only one bit is set. - Double Dispatch: You first check
if (CHECK_BIT(my_flags, i))and then immediately passiinto aswitch. Under the hood, a denseswitchgenerates a jump table, while a sparseswitchgenerates 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
ifchecks.
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?
- Stick with sequential
ifstatements if the number of flags is small (< 16) or if each branch contains only 1 to 2 instructions. Compilers optimize this scenario remarkably well. - Use
__builtin_ctzllwith aswitchif you have many flags (> 16), need tight execution speed, and want to avoid evaluating unset bits. - Avoid a flat
forloop with aswitch, as it creates unnecessary loop and branching overhead without providing binary size benefits.