Bit manipulation is one of those skills that looks small until it starts controlling real hardware. It is also a favorite interview topic for embedded hiring managers and senior engineers, because a few shifts and masks quickly reveal whether a candidate understands values, side effects, and the hardware shape behind a line of C. One wrong shift can enable the wrong interrupt. One careless register write can clear a status flag before the firmware reads it. One copied mask without a name can survive for years because nobody wants to touch the driver again.
In embedded C, bits are not just compact storage. They are register fields, protocol flags, GPIO pins, DMA options, error latches, resource maps, and debug evidence. Good bit code makes those meanings visible.
This article starts with C because most low level firmware still reaches the hardware through C shaped operations. Page 2 then looks at C++ std::bitset where fixed software flags or host tests need a clearer representation.
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 bit pattern toward the more significant side. A right shift moves it back toward the less significant side.
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. If we draw only the low 8 bits, the result is 0000 0001. The expression 1u << 3 moves that same single one bit three positions left, so the byte view becomes 0000 1000. That value is 8 in decimal, but in firmware the binary shape is usually more important than the decimal number.
A Shift Builds a One Bit Mask
| 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 | selects bit 0 |
1u << 3 |
0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | selects bit 3 |
(1u << 5)| (1u << 3)| (1u << 0) |
0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 | selects bits 5, 3, and 0 |
(1u << 2)| (1u << 1)| (1u << 0) |
0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | selects adjacent bits 2, 1, and 0 |
A mask is just a value whose one bits select the bits you care about. Firmware uses masks because hardware registers usually pack many unrelated meanings into one byte or word, and the mask lets the code say, "work only on this field, leave everything else alone."
The next diagrams use the same 8 bit view as the shift example. Green cells are 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 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 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 the implementation of helpers such as __RBIT, but the application should not spread those details through random modules. In the 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. A portable helper is fine, but 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. 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. 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 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. It is 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. 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.
