Bit manipulation is one of those skills that looks small until it starts controlling real hardware. One wrong shift can enable the wrong interrupt, one careless register write can clear a status flag before the firmware reads it, and one copied mask without a name can survive for years because nobody wants to touch the driver again. In embedded C, bits are register fields, protocol flags, GPIO pins, DMA options, error latches, resource maps, and debug evidence, not just compact storage. Good bit code makes those meanings visible.
The focus here is on operations that help firmware talk to real hardware: masks, register fields, packed protocol values, status flags, shared flag words, byte order, and target-specific helpers. Puzzle-style bit hacks can be interesting, but they are not the goal. The goal is code that an embedded developer can read, review, test, and trust on real hardware.
Guide to this article
This guide is split into two pages so it can be used as a reference instead of one long scroll.
- Page 1: C bit manipulation close to hardware. Start here for shifts, masks, set, clear, toggle, register fields, write-one-to-clear flags, protocol packing, byte order, shared flags, and target intrinsics.
- Page 2: C++ std::bitset. Use this page for fixed software flags, snapshots, register shaped conversions, host tests, and practical checks.
First see what shifting does
Think of one byte as eight small slots numbered from bit 7 down to bit 0. Bit 0 is the least significant bit, so it contributes the value 1. Bit 1 contributes 2, bit 2 contributes 4, bit 3 contributes 8, and so on. A left shift moves the whole bit pattern toward the more significant side. A right shift moves it toward bit 0, which is useful when a register field, protocol field, or status flag has to be aligned before it is tested or decoded.
The expression 1u << 0 starts with the unsigned value 1, then shifts it left by zero positions. Nothing moves, so the low bit stays set. The expression 1u << 3 moves that same one bit three positions left, creating a mask for bit 3. The same idea works with larger patterns. For example, 0b111u << 2 moves a three-bit field upward, while 0b11100000u >> 5 moves a high-position field down to the low bits.
Shifts are not only for turning 1u into a one-bit mask. In real firmware you also shift complete unsigned patterns, register values, packed protocol fields, and binary literals when your compiler supports that syntax. The diagram below keeps left-shift and right-shift examples side by side so it is clear what moves toward more significant bits, what moves down toward bit 0, and what stays unchanged when the shift count is zero.
Left and Right Shifts Move Bit Patterns
| expression | bit 7 | bit 6 | bit 5 | bit 4 | bit 3 | bit 2 | bit 1 | bit 0 | result |
|---|---|---|---|---|---|---|---|---|---|
1u << 0 |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | zero-position left shift leaves the mask unchanged |
1u << 3 |
0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | one-bit mask for bit 3 |
0b111u << 2 |
0 | 0 | 0 | 1 | 1 | 1 | 0 | 0 | moves bits 2, 1, and 0 to bits 4, 3, and 2 |
0b1011u << 1 |
0 | 0 | 0 | 1 | 0 | 1 | 1 | 0 | moves the full pattern left by one bit |
0b111u >> 0 |
0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | zero-position right shift leaves the value unchanged |
7u >> 0 |
0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | decimal form of 0b111u |
0b11100000u >> 5 |
0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | moves a high field down to bit 0 |
0b10110000u >> 4 |
0 | 0 | 0 | 0 | 1 | 0 | 1 | 1 | moves the upper nibble into the low nibble |
(0b10111000u >> 3) |
0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | aligns a field, then masks it down to three bits |
(1u << 5) |
0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 | OR combines non-adjacent one-bit masks |
(1u << 2) |
0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | OR builds an adjacent multi-bit mask |
What masks are and how to use them
Once you can move bits into the position you need, the next question is which bits an operation should touch. A mask is the value that answers that question. Its one bits select the field, flag, or group of flags you care about. Its zero bits protect everything else.
The operation gives the mask its meaning. With OR, the selected bits are set to one. With AND and an inverted mask, the selected bits are cleared. With AND by itself, the selected bits are tested or extracted. With XOR, the selected bits are toggled. The mask is the selection rule, and the operator decides what happens to the selected bits.
Most embedded masks are either single-bit masks or field masks. A single-bit mask represents one flag, such as an enable bit, an interrupt pending bit, or an error latch. A field mask represents several adjacent bits, such as a three-bit mode value or a four-bit channel number. Non-adjacent masks are useful when several independent flags should be handled together, but they should still represent one clear idea.
Good masks are named after the hardware meaning, not after the number. Names such as UART_STATUS_RX_READY_MASK, ADC_CTRL_ENABLE_MASK, and SPI_CFG_MODE_MASK tell the next reader what the bit means before they look at the datasheet. For multi-bit fields, keep the mask and shift together. The mask says which bits belong to the field, and the shift says where that field starts.
Masks are also a safety boundary. If a register contains unrelated fields, a mask lets the code update one part without overwriting the rest. Setting a feature usually means OR with a mask. Clearing a feature usually means AND with the inverted mask. Updating a field usually means clear the field first, then OR in the shifted new value. Writing a raw literal to the whole register should be a deliberate choice, not a shortcut.
Keep mask constants unsigned and width-aware. A signed shift can produce surprises, and ~mask can also surprise you after integer promotion if the type is not what you think it is. In low-level code, prefer masks that match the register width, such as UINT32_C(1) << bit for a 32-bit register. When reading flags, compare the masked result with zero. When reading fields, mask first, then shift the field down to bit 0.
The examples below keep the same 8 bit view as the shift diagram. Green cells show the bits selected by the mask or changed by the operation.
Set Bits With OR
| item | bit 7 | bit 6 | bit 5 | bit 4 | bit 3 | bit 2 | bit 1 | bit 0 | meaning |
|---|---|---|---|---|---|---|---|---|---|
| value | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0100 0010 |
| mask | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 | 0010 1001 |
| result | 0 | 1 | 1 | 0 | 1 | 0 | 1 | 1 | 0110 1011 |
Clear Bits With AND and NOT
| item | bit 7 | bit 6 | bit 5 | bit 4 | bit 3 | bit 2 | bit 1 | bit 0 | meaning |
|---|---|---|---|---|---|---|---|---|---|
| value | 1 | 1 | 1 | 0 | 1 | 1 | 1 | 1 | 1110 1111 |
| mask | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 | bits to clear |
~mask |
1 | 1 | 0 | 1 | 0 | 1 | 1 | 0 | 1101 0110 |
| result | 1 | 1 | 0 | 0 | 0 | 1 | 1 | 0 | 1100 0110 |
Test and Toggle Bits
Test with AND: value & mask
| item | bit 7 | bit 6 | bit 5 | bit 4 | bit 3 | bit 2 | bit 1 | bit 0 | byte |
|---|---|---|---|---|---|---|---|---|---|
| value | 1 | 0 | 1 | 0 | 1 | 1 | 0 | 1 | 1010 1101 |
| mask | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0010 1000 |
| result nonzero | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0010 1000 |
Toggle with XOR: value ^ mask
| item | bit 7 | bit 6 | bit 5 | bit 4 | bit 3 | bit 2 | bit 1 | bit 0 | byte |
|---|---|---|---|---|---|---|---|---|---|
| value | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 | 0010 1001 |
| mask | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 1 | 0000 1011 |
| result | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0010 0010 |
Use compiler intrinsics when they match the target
Before writing a clever bit loop, check what the compiler and MCU already support. Many embedded targets have instructions for reversing bits, reversing byte order, rotating, counting leading zeros, extracting fields, or atomically changing selected bits. The portable C version is still useful, but when the operation is hot or tied to a peripheral format, the intrinsic can be clearer and faster.
The important habit is to keep target assumptions in one place. Put intrinsics behind a small helper, give the helper a normal project name, and leave a portable fallback for host tests or a different compiler.
| Family or toolchain | Useful examples | Typical use |
|---|---|---|
| STM32 Cortex M with CMSIS | __RBIT, __REV, __CLZ, __ROR |
bit reversal, byte order conversion, priority bitmap scans, CRC style rotates |
| AURIX TriCore with TASKING or iLLD wrappers | __extru, __insert, __imaskldmst |
field extraction, field insertion, selected bit updates |
| GCC or Clang based embedded builds | __builtin_bswap32, __builtin_clz, __builtin_popcount |
protocol byte order, priority bitmaps, flag counts |
The names are not portable API names. CMSIS documents helpers such as __RBIT, __REV, __CLZ, and __ROR in the CMSIS Core instruction interface. TASKING documents TriCore intrinsics as compiler recognized functions that are inlined, and groups them by areas such as bit field insert and extract, atomic support, and miscellaneous operations in the TASKING TriCore intrinsic documentation. Infineon’s TriCore instruction set documentation also describes the underlying architecture instructions such as EXTR.U, INSERT, IMASK, and LDMST in the TriCore architecture manuals. GCC documents built-ins such as __builtin_clz, __builtin_popcount, and __builtin_bswap32 in its other built-in functions documentation.
A good example is bit reversal. If an STM32 project receives a bitstream in the opposite bit order, perhaps from a CRC path, display driver, software codec, or LSB first protocol adapter, a pure C loop works, but it is not always the clearest expression of the hardware capability. A mask shuffle is faster than a loop, but it is still several shifts and masks.
The next snippet shows the wrapper pattern I prefer for maintainable target code. The application calls one project helper. The STM32 build can route that helper to the CMSIS __RBIT intrinsic, while host tests and other targets still compile through a portable C fallback. The name EMBEDONIX_USE_CMSIS_RBIT is deliberately a project build flag, not a CMSIS macro. A real project would define it in the STM32 platform configuration or build system after the selected core header and compiler path are known. The #if defined(...) line means the preprocessor compiles the CMSIS branch only when that project flag exists. Otherwise, it compiles the fallback branch.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
#include <stdint.h> /* * Portable 32 bit bit reversal. * Each step swaps progressively larger groups until bit 0 moves to bit 31, * bit 1 moves to bit 30, and so on. */ static uint32_t reverse_bits32_portable(uint32_t value) { /* Swap adjacent 1 bit groups. */ value = ((value & UINT32_C(0x55555555)) << 1) | ((value >> 1) & UINT32_C(0x55555555)); /* Swap adjacent 2 bit groups. */ value = ((value & UINT32_C(0x33333333)) << 2) | ((value >> 2) & UINT32_C(0x33333333)); /* Swap adjacent 4 bit groups, then adjacent bytes. */ value = ((value & UINT32_C(0x0F0F0F0F)) << 4) | ((value >> 4) & UINT32_C(0x0F0F0F0F)); value = ((value & UINT32_C(0x00FF00FF)) << 8) | ((value >> 8) & UINT32_C(0x00FF00FF)); /* Finish by swapping the lower and upper 16 bit halves. */ return (value << 16) | (value >> 16); } uint32_t reverse_bits32_for_target(uint32_t value) { /* * EMBEDONIX_USE_CMSIS_RBIT is a project build flag, not a CMSIS name. * Define it only for STM32/CMSIS builds where this project is allowed * to call the CMSIS bit-reverse helper. */ #if defined(EMBEDONIX_USE_CMSIS_RBIT) /* Good: use the target bit-reverse helper on the STM32 build. */ return __RBIT(value); #else /* Good: host tests and non-ARM builds still have a defined fallback. */ return reverse_bits32_portable(value); #endif } |
The point is not that every STM32 build should define EMBEDONIX_USE_CMSIS_RBIT. The point is that the target-specific path is isolated. CMSIS has its own architecture and compiler checks around helpers such as __RBIT, but the application should not spread those details through random modules. In a real project, the build system or platform header decides whether this wrapper may use the intrinsic. The code review can then check one helper and the generated assembly, instead of searching for five different hand written bit reverse loops.
On AURIX, the useful examples are often field operations rather than whole-word bit reversal. TriCore has strong bit-field and selected-bit instructions, and AURIX projects often reach them through TASKING intrinsics or Infineon iLLD compiler wrapper headers. For example, a service request register may contain a priority field packed into a larger word, so a target helper can express that this is a real bit-field extract operation.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
#include <stdint.h> #define SRC_PRIORITY_SHIFT 0u #define SRC_PRIORITY_WIDTH 8u static uint32_t extract_field_u32(uint32_t value, uint8_t shift, uint8_t width) { const uint32_t mask = (UINT32_C(1) << width) - 1u; return (value >> shift) & mask; } uint32_t aurix_src_priority(uint32_t service_request_control) { #if defined(__TASKING__) /* Good: TASKING TriCore builds can request the target extract operation. */ return (uint32_t)__extru((int)service_request_control, SRC_PRIORITY_SHIFT, SRC_PRIORITY_WIDTH); #else /* Good: the same helper remains testable on the host. */ return extract_field_u32(service_request_control, SRC_PRIORITY_SHIFT, SRC_PRIORITY_WIDTH); #endif } |
For selected bit updates on AURIX, look for the project wrapper around the IMASK and LDMST style operation before writing a read-modify-write sequence by hand. That matters when a register or shared word must be updated atomically. The exact intrinsic name depends on the compiler and header set, so the maintainable pattern is still the same: hide it behind a project helper, document the target, and keep the plain C fallback honest.
Start from the hardware meaning
A mask such as 1u << 3 is only obvious while the datasheet is open. After a few months, it becomes a small guessing game. Name the bit from the register field, protocol flag, or board signal it represents.
In this UART status example, bit 0 means receive data is ready, bit 1 means transmit is empty, and bit 3 means overrun. The names keep the hardware meaning close to the operation.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <stdbool.h> #include <stdint.h> #define UART_STATUS_RX_READY_MASK (1u << 0) #define UART_STATUS_TX_EMPTY_MASK (1u << 1) #define UART_STATUS_OVERRUN_MASK (1u << 3) bool uart_rx_ready(uint32_t status) { /* Good: the tested bit is named from the status register meaning. */ return (status & UART_STATUS_RX_READY_MASK) != 0u; } |
That small naming habit pays off during review. The reader does not need to count bits in their head, and the code still makes sense when the datasheet is not on the second monitor.
Keep masks unsigned and width aware
Use unsigned values for masks and shifts. Signed shifts are a common source of compiler dependent behavior, especially when the high bit becomes involved. Also avoid shifting by the width of the type or more. That is undefined behavior in C and C++, not a clever way to create zero.
The helper below makes the bit position explicit and keeps the operation in a uint32_t domain. It is intentionally small, because helpers like this should make code more readable, not hide the register layout.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include <stdint.h> static uint32_t bit_u32(uint8_t position) { /* Note: callers must pass 0 through 31 for a 32 bit mask. */ return UINT32_C(1) << position; } uint32_t enable_error_irq(uint32_t control) { const uint32_t error_irq_mask = bit_u32(6u); /* Good: unsigned mask, named local meaning, one simple operation. */ return control | error_irq_mask; } |
For production driver code, prefer named masks from the register definition instead of calling bit_u32(6u) everywhere. The helper is useful when the bit position is itself data, for example in a bitmap allocator or diagnostic flag table.
Use the basic operations, but do not stop there
Set, clear, toggle, and test are the entry point. They are still worth writing carefully because these four operations appear everywhere in firmware.
The next snippet shows the basic shapes with a GPIO style output register. LED_RUN_MASK represents one board signal, not a generic bit number.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
#include <stdbool.h> #include <stdint.h> #define LED_RUN_MASK (UINT32_C(1) << 2) #define LED_FAULT_MASK (UINT32_C(1) << 5) static uint32_t set_bits(uint32_t value, uint32_t mask) { return value | mask; } static uint32_t clear_bits(uint32_t value, uint32_t mask) { return value & ~mask; } static uint32_t toggle_bits(uint32_t value, uint32_t mask) { return value ^ mask; } static bool any_bits_set(uint32_t value, uint32_t mask) { return (value & mask) != 0u; } void board_set_fault_led(volatile uint32_t *gpio_out, bool enabled) { uint32_t value = *gpio_out; if (enabled) { /* Good: the board signal is named at the call site. */ value = set_bits(value, LED_FAULT_MASK); } else { value = clear_bits(value, LED_FAULT_MASK); } *gpio_out = value; } |
This pattern is fine for ordinary memory mapped output registers where reading the register and writing it back is allowed. It is not automatically safe for every hardware register. Some status registers clear flags when read, and some command registers treat each written one as an action. The reference manual decides the rule, not the helper function.
Replace fields by clearing before writing
Many registers are not single flags. They contain fields. A field has a shift, width, mask, and set of allowed values. The safe pattern is to clear the field first, then insert the new value after it has been shifted and masked.
In this ADC configuration example, bits 5 through 4 select the input range. The value 2 means a 10 V range in this imaginary register map.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
#include <stdint.h> #define ADC_CFG_RANGE_SHIFT 4u #define ADC_CFG_RANGE_WIDTH 2u #define ADC_CFG_RANGE_MASK (UINT32_C(3) << ADC_CFG_RANGE_SHIFT) #define ADC_CFG_RANGE_2V5 0u #define ADC_CFG_RANGE_5V 1u #define ADC_CFG_RANGE_10V 2u static uint32_t field_prepare(uint32_t value, uint32_t mask, uint8_t shift) { return (value << shift) & mask; } uint32_t adc_cfg_set_range_10v(uint32_t reg) { /* Good: clear the old field before inserting the new field value. */ reg &= ~ADC_CFG_RANGE_MASK; reg |= field_prepare(ADC_CFG_RANGE_10V, ADC_CFG_RANGE_MASK, ADC_CFG_RANGE_SHIFT); return reg; } |
The common bug is to only OR the new value into the register. That works while the old field is zero, then fails later when the code tries to change from one nonzero range to another.
Extract fields with the same names
Packing is only half of the job. Firmware also reads status words, protocol headers, ADC sample metadata, and fault registers. Use the same shift and mask names when extracting fields.
Here the diagnostic status word stores a fault code in bits 11 through 8 and a channel number in bits 3 through 0. The extraction helper keeps the bit math in one obvious place.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include <stdint.h> #define DIAG_CHANNEL_SHIFT 0u #define DIAG_CHANNEL_MASK (UINT32_C(0x0F) << DIAG_CHANNEL_SHIFT) #define DIAG_FAULT_CODE_SHIFT 8u #define DIAG_FAULT_CODE_MASK (UINT32_C(0x0F) << DIAG_FAULT_CODE_SHIFT) static uint32_t field_get(uint32_t value, uint32_t mask, uint8_t shift) { return (value & mask) >> shift; } uint8_t diag_get_fault_code(uint32_t status) { /* Good: use the same mask and shift names used by the register map. */ return (uint8_t)field_get(status, DIAG_FAULT_CODE_MASK, DIAG_FAULT_CODE_SHIFT); } |
This is especially useful in protocol code. If the transmitter and receiver use the same field names, mistakes are easier to spot in code review and in logic analyzer notes.
Respect write-one-to-clear registers
Some status registers use a write-one-to-clear rule. Writing a 1 clears the flag, while writing a zero leaves it unchanged. These registers should not be handled with generic clear helpers, because a read-modify-write sequence can clear flags that arrived after the read.
The interrupt status register below has two latched flags. The handler snapshots the pending flags, handles the ones that were present, and then writes exactly those flags back to clear them.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#include <stdint.h> #define IRQ_STATUS_RX_DONE_MASK (UINT32_C(1) << 0) #define IRQ_STATUS_TX_DONE_MASK (UINT32_C(1) << 1) #define IRQ_STATUS_ERROR_MASK (UINT32_C(1) << 4) typedef struct { volatile uint32_t IRQ_STATUS; } UartRegisters; void uart_handle_irq(UartRegisters *uart) { const uint32_t pending = uart->IRQ_STATUS; const uint32_t handled = pending & (IRQ_STATUS_RX_DONE_MASK | IRQ_STATUS_TX_DONE_MASK | IRQ_STATUS_ERROR_MASK); if ((pending & IRQ_STATUS_RX_DONE_MASK) != 0u) { uart_service_rx_done(); } if ((pending & IRQ_STATUS_TX_DONE_MASK) != 0u) { uart_service_tx_done(); } if ((pending & IRQ_STATUS_ERROR_MASK) != 0u) { uart_service_error(); } /* Good: write one only for the flags this handler consumed. */ uart->IRQ_STATUS = handled; } |
This is one of the most important embedded bit manipulation habits. Do not assume that clearing a bit in memory and clearing a bit in hardware mean the same thing.
Pack protocol bytes deliberately
Bit fields are not only for registers. Many sensor, bootloader, and communication protocols pack small values into one byte or word. That saves bandwidth, but it also creates bugs when the field layout is not named.
In this command byte, bits 7 through 5 hold a command type, bit 4 requests an acknowledgement, and bits 3 through 0 hold a channel number. The sender validates each value before packing it.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
#include <stdbool.h> #include <stdint.h> #define CMD_TYPE_SHIFT 5u #define CMD_TYPE_MASK (UINT8_C(0x07) << CMD_TYPE_SHIFT) #define CMD_ACK_MASK (UINT8_C(1) << 4) #define CMD_CHANNEL_SHIFT 0u #define CMD_CHANNEL_MASK (UINT8_C(0x0F) << CMD_CHANNEL_SHIFT) bool command_pack_header(uint8_t type, uint8_t channel, bool ack, uint8_t *header) { if ((type > 7u) || (channel > 15u)) { /* Good: reject values that cannot fit before packing bits. */ return false; } uint8_t value = (uint8_t)((type << CMD_TYPE_SHIFT) & CMD_TYPE_MASK); value |= (uint8_t)((channel << CMD_CHANNEL_SHIFT) & CMD_CHANNEL_MASK); if (ack) { value |= CMD_ACK_MASK; } *header = value; return true; } |
Packed protocols should have tests around boundary values. Test channel 0, channel 15, invalid channel 16, every command type, and the acknowledgement flag both ways. That is much cheaper than finding the mistake in a field update.
Use bitmaps for resources and diagnostics
A bitmap is a compact way to track many yes or no states. It works well for resource allocation, active channels, failed self tests, enabled features, and event flags. The useful part is not only saving a few bytes. The useful part is making set membership cheap and easy to inspect.
The next example tracks which of 32 channels are active. It uses a bounds check before shifting, because 1u << 32 is not valid for a 32 bit value.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
#include <stdbool.h> #include <stdint.h> #define CHANNEL_COUNT 32u typedef struct { uint32_t active_mask; } ChannelMap; bool channel_mark_active(ChannelMap *map, uint8_t channel) { if (channel >= CHANNEL_COUNT) { return false; } /* Good: the shift is protected by a range check. */ map->active_mask |= (UINT32_C(1) << channel); return true; } bool channel_is_active(const ChannelMap *map, uint8_t channel) { if (channel >= CHANNEL_COUNT) { return false; } return (map->active_mask & (UINT32_C(1) << channel)) != 0u; } |
For larger maps, use an array of words and compute the word index and bit index separately. Keep both named. The moment you see repeated / 32 and % 32 in application code, it is time to hide that in a small bitmap module.
Use power of two masks only when the size really is a power of two
Bit masks are often used for fast wrapping in ring buffers. (index + 1) & (size - 1) works only when size is a power of two. If the buffer size changes from 128 to 150 later, the mask version becomes wrong.
The compile time check below keeps that assumption attached to the buffer definition.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <stdint.h> #define TRACE_BUFFER_SIZE 128u #define TRACE_BUFFER_MASK (TRACE_BUFFER_SIZE - 1u) #if (TRACE_BUFFER_SIZE == 0u) || ((TRACE_BUFFER_SIZE & TRACE_BUFFER_MASK) != 0u) #error TRACE_BUFFER_SIZE must be a power of two #endif static uint16_t trace_next_index(uint16_t index) { /* Good: the power of two assumption is checked next to the constant. */ return (uint16_t)((index + 1u) & TRACE_BUFFER_MASK); } |
This pattern is common in trace buffers, queues, and sample windows. It is a good optimization when the size is deliberately chosen, and a bad habit when it is copied into code where any size should be allowed.
Be careful with volatile, interrupts, and shared flags
volatile tells the compiler that a value can change outside the normal flow of the program. It does not make a read-modify-write operation atomic. If an interrupt and the main loop both modify the same flag word, one context can overwrite the other context’s update.
In the next snippet, the main loop clears events that were posted by an interrupt. On many MCUs this needs a short critical section around the shared flag update. The names enter_critical() and exit_critical() stand for your platform’s interrupt or RTOS protection primitive.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#include <stdint.h> #define EVENT_RX_READY_MASK (UINT32_C(1) << 0) #define EVENT_TX_DONE_MASK (UINT32_C(1) << 1) static volatile uint32_t event_flags; void uart_rx_isr(void) { /* Risky if the main loop modifies event_flags at the same time. */ event_flags |= EVENT_RX_READY_MASK; } uint32_t events_take_all(void) { enter_critical(); const uint32_t events = event_flags; event_flags = 0u; exit_critical(); return events; } |
Some targets support atomic bit set and clear registers. Some have bit banding or exclusive access instructions. Some projects use RTOS event groups. Use the mechanism that matches the target, but do not pretend volatile alone solves concurrency.
Watch byte order when bits cross a boundary
Bit operations inside one register are one problem. Bit operations across bytes are another. Protocols and file formats often define byte order explicitly, and a little endian MCU does not make the protocol little endian unless the protocol says so.
This example decodes a 12 bit ADC sample stored in two big endian bytes. The top 8 bits are in rx[0], and the lower 4 bits are in the high nibble of rx[1].
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <stdint.h> #define ADC12_LOW_NIBBLE_SHIFT 4u #define ADC12_VALUE_MASK UINT16_C(0x0FFF) uint16_t adc12_decode_big_endian(const uint8_t rx[2]) { const uint16_t high = (uint16_t)rx[0] << 4u; const uint16_t low = (uint16_t)rx[1] >> ADC12_LOW_NIBBLE_SHIFT; /* Good: mask the final value to the documented ADC width. */ return (uint16_t)((high | low) & ADC12_VALUE_MASK); } |
These decode helpers are worth unit testing on the host. Give them a few known byte pairs from the datasheet or a logic analyzer capture, then keep those tests around. Bit packing bugs are easy to reintroduce during cleanup.
At this point, the C side has covered the habits that matter closest to the hardware: name the bits, keep masks unsigned, respect register write rules, protect shared flags, and check target intrinsics before hand optimizing. The second page moves away from direct register writes and looks at where C++ std::bitset can make software flags and tests easier to read.
