Skip to content

Part 1 · The C Language33 min read

Bit Manipulation

Firmware talks to hardware through bits. A GPIO mode is two bits in a 32-bit register, a status flag is one bit, an ADC result is 12 bits in the low end of a word, and a sensor sends a 24-bit signed number as three bytes. Getting these operations exactly right is a daily skill, and getting them slightly wrong produces bugs that look like hardware faults: an LED on the wrong pin, a peripheral that half-configures, a temperature reading that jumps from 25 degrees to 16 million. This lesson builds the toolkit: the operators and their traps, set/clear/toggle/test, extracting and inserting multi-bit fields, why read-modify-write is not atomic and how set/reset registers fix it, the classic bit tricks with their compiler builtins, endianness and portable serialization, Cortex-M bit-banding, and a complete worked example decoding a 24-bit two’s complement sensor reading.

C has six bitwise operators. They work on integers bit by bit:

OperatorNameResult bit is 1 when…Typical use
a & bANDboth bits are 1clear bits, test bits, extract fields
a | bOReither bit is 1set bits, combine fields
a ^ bXORthe bits differtoggle bits, compare, checksums
~aNOTthe bit is 0build “everything except” masks
a << nleft shiftbits move toward the MSB, zeros fillbuild masks, place fields
a >> nright shiftbits move toward the LSBextract fields, divide by 2^n
a = 1100 1010 (0xCA)
b = 1010 0110 (0xA6)
a & b = 1000 0010 (0x82)
a | b = 1110 1110 (0xEE)
a ^ b = 0110 1100 (0x6C)
~a = 0011 0101 (0x35, if only 8 bits are kept)
a << 2 = 0010 1000 (0x28, if only 8 bits are kept)
a >> 3 = 0001 1001 (0x19)

Three rules from Operators, expressions, and implicit conversions matter on every line of bit code:

  • Integer promotion. Operands narrower than int are promoted to int first. uint8_t m = 0x0F; ~m is not 0xF0; it is the int value 0xFFFFFFF0. So if (~m == 0xF0) is false. Cast the result back to the narrow type when you need it: (uint8_t)~m.
  • Shift limits. Shifting by a negative amount or by at least the width of the promoted type is undefined. x << 32 on a 32-bit value is not 0; it is UB, and on Arm the hardware happens to give 0 for register shifts while the compiler may assume it never happens.
  • Signedness. Left-shifting a 1 into or past the sign bit of a signed type is undefined (1 << 31 with 32-bit int). Right-shifting a negative value is implementation-defined (GCC uses an arithmetic shift that copies the sign bit). Do bit manipulation on unsigned types, always.

Also watch precedence: &, ^ and | bind more loosely than ==, so if (reg & MASK == 0) means reg & (MASK == 0). Parenthesize every bitwise subexpression in a comparison.

The four single-bit operations, for bit n of a variable x:

x |= (1u << n); /* set bit n */
x &= ~(1u << n); /* clear bit n */
x ^= (1u << n); /* toggle bit n */
if (x & (1u << n)) { } /* test bit n: nonzero if set */
n = 5, x = 0100 0001
set: mask = 0010 0000 x | mask = 0110 0001
clear: mask = 0010 0000, ~mask = 1101 1111
x & ~mask = 0100 0001 (bit 5 was already 0)
toggle: x ^ mask = 0110 0001
test: x & mask = 0000 0000 -> false

The u in 1u is not decoration. 1 is a signed int, so 1 << 31 is undefined behavior, and the result of ~(1 << n) is a negative int that is then converted when combined with an unsigned register. 1u << 31 is a well-defined 0x80000000u. For 64-bit values you need 1ull << n (or UINT64_C(1) << n), because 1u << 40 is still a 32-bit shift and undefined.

As macros, these are usually written once in a project header:

#define BIT(n) (1u << (n))
#define BIT_SET(x, n) ((x) |= BIT(n))
#define BIT_CLEAR(x, n) ((x) &= ~BIT(n))
#define BIT_TOGGLE(x, n) ((x) ^= BIT(n))
#define BIT_TEST(x, n) (((x) & BIT(n)) != 0u)

Every parameter use is parenthesized and the whole expansion is parenthesized, for reasons covered in The preprocessor, used well. BIT_TEST returns a clean 0 or 1 because of the != 0u; that matters if the result is stored in a bool or a uint8_t (a bare x & BIT(9) stored in a uint8_t is truncated to 0).

Most register settings are not single bits but fields: groups of adjacent bits that hold a number. A field is described by its position (the bit number of its least significant bit) and its width. Take the STM32F407 GPIO mode register MODER: every pin gets a 2-bit field at position 2 * pin, where 00 is input, 01 output, 10 alternate function and 11 analog.

GPIOD_MODER (0x40020C00 + 0x00), upper half
bits: 31:30 29:28 27:26 25:24 23:22
+--------+--------+--------+--------+--------+
| MODE15 | MODE14 | MODE13 | MODE12 | MODE11 | ...
+--------+--------+--------+--------+--------+
pin 12: position 24, width 2, mask 0x3 << 24 = 0x03000000

Shift the field down to bit 0, then mask off everything above it:

uint32_t mode12 = (moder >> 24) & 0x3u;

In general, for position POS and an unshifted mask MASK (the field’s width in ones, 0x3 for two bits):

value = (reg >> POS) & MASK;

Writing a field without disturbing its neighbours takes three steps: clear the field, place the new value, combine:

reg = (reg & ~(MASK << POS)) | ((val & MASK) << POS);
Set MODE12 = 01 (output) in a MODER value of 0xA8000000
reg = 1010 1000 0000 0000 ... (0xA8000000)
MASK << POS = 0000 0011 0000 0000 ... (0x03000000)
~(MASK << POS) = 1111 1100 1111 1111 ... (0xFCFFFFFF)
reg & ~(...) = 1010 1000 0000 0000 ... (field already 00 here)
(val & MASK) << POS = 0000 0001 0000 0000 ... (0x01000000)
result = 1010 1001 0000 0000 ... (0xA9000000)

The val & MASK protects the neighbours: if a caller passes 5 for a 2-bit field, without the mask the extra bit would spill into MODE13. Whether to silently truncate or to assert on an out-of-range value is a design choice; silently corrupting the neighbouring field is never acceptable.

As reusable functions:

#include <stdint.h>
static inline uint32_t field_get(uint32_t reg, uint32_t pos, uint32_t mask)
{
return (reg >> pos) & mask;
}
static inline uint32_t field_set(uint32_t reg, uint32_t pos, uint32_t mask, uint32_t val)
{
return (reg & ~(mask << pos)) | ((val & mask) << pos);
}

Applied to the real register (the cast-to-pointer register access is explained in Memory-mapped registers in C):

#include <stdint.h>
#define GPIOD_MODER (*(volatile uint32_t *)0x40020C00u)
void pd12_make_output(void)
{
uint32_t v = GPIOD_MODER; /* one read */
v = (v & ~(0x3u << 24)) | (0x1u << 24); /* MODE12 = 01 */
GPIOD_MODER = v; /* one write */
}

The Cortex-M4 has instructions for exactly these operations. (x >> 4) & 0xFFu compiles to a single ubfx r0, r0, #4, #8 (unsigned bit field extract: position 4, width 8), and an insert with a constant position typically compiles to bfi (bit field insert):

#include <stdint.h>
uint32_t get_byte1(uint32_t x) { return (x >> 8) & 0xFFu; }
uint32_t put_nibble(uint32_t r, uint32_t v) { return (r & ~(0xFu << 8)) | ((v & 0xFu) << 8); }
get_byte1:
ubfx r0, r0, #8, #8 @ extract 8 bits starting at bit 8
bx lr
put_nibble:
bfi r0, r1, #8, #4 @ insert low 4 bits of r1 at bit 8 of r0
bx lr

So the portable C idiom costs nothing over hand-written assembly. The compiler recognizes the pattern; your job is to write it correctly.

A mask of n ones is (1u << n) - 1u: for n = 4, 0x10 - 1 = 0xF. This breaks for n = 32 (shift by the full width is undefined). If a width can be 32, special-case it or use 0xFFFFFFFFu >> (32u - n) for n from 1 to 32.

Look again at pd12_make_output, or at a simple GPIOD_ODR |= (1u << 12);. In C it is one statement. On the CPU it is three instructions:

ldr r1, [r0] @ read ODR
orr r1, r1, #0x1000 @ set bit 12 in the register copy
str r1, [r0] @ write ODR back

An interrupt can arrive between any two of them. If the interrupt handler also modifies the same register, one of the updates is lost:

main loop (setting PD12) EXTI interrupt (setting PD13)
ldr r1, [ODR] r1 = 0x0000
---- interrupt ---->
ldr r1, [ODR] r1 = 0x0000
orr r1, r1, #0x2000 r1 = 0x2000
str r1, [ODR] ODR = 0x2000 (PD13 on)
<---- return -------
orr r1, r1, #0x1000 r1 = 0x1000 (stale copy, no PD13)
str r1, [ODR] ODR = 0x1000 (PD12 on, PD13 OFF again)

The ISR’s change to PD13 vanished. The bug is rare (the interrupt must land in a window of two instructions), so it survives testing and appears in the field. The same hazard applies to any variable shared between main code and an ISR, not just registers. The general solutions, critical sections and atomic operations, are in Interrupts done right and Concurrency, atomics, and memory ordering. For GPIO pins, the hardware provides a better answer.

Many peripherals provide registers where writing a 1 performs an action on one bit and writing 0 does nothing. The STM32 GPIO bit set/reset register, BSRR (offset 0x18), is the classic:

GPIOx_BSRR (write-only)
bit 31 ............... 16 15 ............... 0
+---------------------+---------------------+
| BR15 ......... BR0 | BS15 ......... BS0 |
+---------------------+---------------------+
write 1: reset pin n write 1: set pin n
write 0: no effect write 0: no effect
#include <stdint.h>
#define GPIOD_BSRR (*(volatile uint32_t *)0x40020C18u)
static inline void pd12_on(void) { GPIOD_BSRR = 1u << 12; } /* BS12 */
static inline void pd12_off(void) { GPIOD_BSRR = 1u << (12 + 16); } /* BR12 */

Each function is a single store. There is no read, so there is no stale copy, and no window for an interrupt to land in. Main code can drive PD12 while an ISR drives PD13 with no protection at all. The hardware performs the modification of ODR internally, in one bus transaction. If you write both the set and the reset bit for the same pin in one write, the set bit wins on STM32.

This is a general hardware design pattern worth recognizing in datasheets: separate set and clear registers (many Nordic and NXP peripherals have OUTSET/OUTCLR-style pairs), and write-1-to-clear status registers. When a peripheral offers them, use them instead of read-modify-write. GPIO in depth builds a driver on BSRR.

These come up in schedulers, allocators, protocol code and interviews. Each has a portable C form and, usually, a GCC/Clang builtin that maps to a single instruction on Cortex-M3/M4.

#include <stdint.h>
static inline uint32_t popcount32(uint32_t x)
{
uint32_t count = 0u;
while (x != 0u) {
x &= x - 1u; /* clears the lowest set bit */
count++;
}
return count;
}

x & (x - 1) clears the lowest set bit: subtracting 1 flips that bit to 0 and every zero below it to 1, and the AND removes all of them. The loop runs once per set bit. __builtin_popcount(x) does the same; Cortex-M4 has no popcount instruction, so GCC calls a small libgcc routine (__popcountsi2) or inlines a bit-parallel sequence. Popcount is used for parity, counting flags, and Hamming distance.

Count leading and trailing zeros, find first set

Section titled “Count leading and trailing zeros, find first set”
x = 0x00012000 = 0000 0000 0000 0001 0010 0000 0000 0000
|<------ 15 ------>| |<- 13 ->|
leading zeros trailing zeros
  • __builtin_clz(x): count leading zeros. 15 for the value above. Compiles to one clz instruction on ARMv7-M. 31 - __builtin_clz(x) is the index of the highest set bit, which is floor(log2(x)).
  • __builtin_ctz(x): count trailing zeros. 13 above. Compiles to rbit (reverse bits) followed by clz, two instructions.
  • __builtin_ffs(x): find first set, returns the 1-based index of the lowest set bit, or 0 if x is 0. It returns 14 above.

__builtin_clz(0) and __builtin_ctz(0) are undefined: the builtins assume a nonzero argument (even though the Arm clz instruction returns 32 for zero). Always check for zero first.

A real use: an RTOS keeps a 32-bit “ready” bitmap with one bit per priority level. Finding the highest-priority ready task is 31 - __builtin_clz(ready), a constant-time operation instead of a loop over 32 priorities.

#include <stdbool.h>
#include <stdint.h>
static inline bool is_pow2(uint32_t x)
{
return x != 0u && (x & (x - 1u)) == 0u; /* exactly one bit set */
}
static inline uint32_t round_up_pow2(uint32_t x) /* valid for 1 <= x <= 0x80000000 */
{
x--;
x |= x >> 1;
x |= x >> 2;
x |= x >> 4;
x |= x >> 8;
x |= x >> 16;
return x + 1u;
}

is_pow2 uses the same lowest-bit trick: a power of two has a single set bit, so clearing it leaves zero. round_up_pow2 smears the highest set bit of x - 1 into every bit below it, producing 2^k - 1, then adds 1. For x = 100: x - 1 = 99 = 0b1100011, smeared to 0b1111111 = 127, plus 1 is 128. (For x = 0 it returns 0, and above 0x80000000 the answer does not fit.) Power-of-two sizes matter because they let you replace % size with & (size - 1), which is how fast ring buffers wrap their indices.

Aligning an address or size to a power-of-two boundary a:

#include <stdint.h>
#define ALIGN_DOWN(x, a) ((x) & ~((a) - 1u))
#define ALIGN_UP(x, a) (((x) + ((a) - 1u)) & ~((a) - 1u))
a = 8: a - 1 = 0b0111, ~(a - 1) = ...1111 1000
ALIGN_DOWN(0x2000013D, 8) = 0x2000013D & ~7 = 0x20000138
ALIGN_UP (0x2000013D, 8) = (0x2000013D + 7) & ~7 = 0x20000144 & ~7 = 0x20000140
ALIGN_UP (0x20000140, 8) = (0x20000140 + 7) & ~7 = 0x20000140 (already aligned)

These only work when a is a power of two. For addresses, convert pointers to uintptr_t first (Pointers I). You will use them for DMA buffer alignment, flash write granularity and memory pool block sizes.

Some hardware and algorithms are LSB-first: reflected CRCs, some SPI devices, FFT index ordering. Reversing the 32 bits of a word portably takes five swap steps, each exchanging groups of half the previous size:

#include <stdint.h>
static inline uint32_t reverse32(uint32_t x)
{
x = ((x >> 1) & 0x55555555u) | ((x & 0x55555555u) << 1); /* swap adjacent bits */
x = ((x >> 2) & 0x33333333u) | ((x & 0x33333333u) << 2); /* swap bit pairs */
x = ((x >> 4) & 0x0F0F0F0Fu) | ((x & 0x0F0F0F0Fu) << 4); /* swap nibbles */
x = ((x >> 8) & 0x00FF00FFu) | ((x & 0x00FF00FFu) << 8); /* swap bytes */
return (x >> 16) | (x << 16); /* swap halfwords */
}

Cortex-M3/M4 have a single-cycle rbit instruction that does all of this; CMSIS exposes it as __RBIT(x), and Clang offers __builtin_bitreverse32. GCC may recognize the pattern above, but it does not always.

The builtins are GCC and Clang extensions. IAR and Keil have their own intrinsics. The usual arrangement is a small header that wraps each operation in a static inline function, uses the builtin when the compiler supports it and falls back to the portable C version otherwise. Conditional compilation on __GNUC__ (The preprocessor, used well) makes the selection. The rest of the code calls bit_clz32(x) and never mentions a builtin.

A uint32_t occupies four bytes, and the CPU must decide which byte goes at the lowest address. Little-endian stores the least significant byte first; big-endian stores the most significant byte first.

uint32_t v = 0x11223344 stored at address 0x20000100
address: 0x100 0x101 0x102 0x103
little-endian: 44 33 22 11 Cortex-M (as configured on STM32), x86, RISC-V
big-endian: 11 22 33 44 network byte order, many protocols and sensors

Cortex-M cores on STM32 run little-endian (the architecture allows big-endian, and a few vendors’ parts use it, but you will rarely meet one). Your host is little-endian too. The trouble is everything outside the CPU: internet protocols, CAN payload conventions, Modbus, many I2C and SPI sensors and most file formats define their multi-byte fields as big-endian. Every time bytes cross that boundary, you need to know the order on each side.

Reversing the byte order of a value with shifts and masks:

#include <stdint.h>
static inline uint32_t bswap32(uint32_t x)
{
return (x >> 24)
| ((x >> 8) & 0x0000FF00u)
| ((x << 8) & 0x00FF0000u)
| (x << 24);
}

GCC and Clang recognize this pattern and also provide __builtin_bswap16, __builtin_bswap32 and __builtin_bswap64. On Cortex-M3/M4 a 32-bit swap is one rev instruction, and rev16 swaps the bytes within each halfword.

The tempting way to put a uint32_t into a byte buffer is a cast:

*(uint32_t *)&buf[1] = value; /* DON'T */

That line has three independent problems. It writes in the CPU’s byte order, which may not be the protocol’s. &buf[1] may be misaligned, which faults on Cortex-M0 and with some instructions on M4 (Pointers I). And it accesses a byte array through a uint32_t lvalue, which violates strict aliasing. The portable way writes each byte explicitly with shifts:

#include <stdint.h>
static inline void put_be32(uint8_t *p, uint32_t v)
{
p[0] = (uint8_t)(v >> 24);
p[1] = (uint8_t)(v >> 16);
p[2] = (uint8_t)(v >> 8);
p[3] = (uint8_t)v;
}
static inline uint32_t get_be32(const uint8_t *p)
{
return ((uint32_t)p[0] << 24)
| ((uint32_t)p[1] << 16)
| ((uint32_t)p[2] << 8)
| (uint32_t)p[3];
}
static inline uint16_t get_be16(const uint8_t *p)
{
return (uint16_t)(((uint32_t)p[0] << 8) | (uint32_t)p[1]);
}
static inline uint16_t get_le16(const uint8_t *p)
{
return (uint16_t)(((uint32_t)p[1] << 8) | (uint32_t)p[0]);
}

This code produces the same bytes on every CPU regardless of its endianness, never makes a misaligned access (each access is one byte), and only uses character-type accesses, which may alias anything. Shifts operate on values, not on memory, so they have no endianness.

Note the (uint32_t) casts in get_be32. Without them, p[0] is promoted to int, and p[0] << 24 with p[0] >= 0x80 shifts a 1 into the sign bit of a 32-bit int, which is undefined behavior. This is a real bug in real code, and UBSan reports it.

Do not worry about speed. GCC at -O2 for Cortex-M4 typically turns get_be32 into a single (possibly unaligned) ldr followed by rev, because it can prove what the byte-by-byte code computes and knows the M4 permits unaligned ldr.

Here is a full parse of a small binary frame: a sync byte, a type, a big-endian 16-bit sequence number, a big-endian 32-bit timestamp, and a big-endian 16-bit value:

offset: 0 1 2 3 4 5 6 7 8 9
+------+------+------+------+------+------+------+------+------+------+
| sync | type | seq (BE16) | timestamp (BE32) | value (BE16)|
+------+------+------+------+------+------+------+------+------+------+
bytes: A5 01 00 2A 00 01 E2 40 09 0B
#include <inttypes.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
static uint16_t get_be16(const uint8_t *p)
{
return (uint16_t)(((uint32_t)p[0] << 8) | (uint32_t)p[1]);
}
static uint32_t get_be32(const uint8_t *p)
{
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16)
| ((uint32_t)p[2] << 8) | (uint32_t)p[3];
}
struct frame {
uint8_t type;
uint16_t seq;
uint32_t timestamp_ms;
uint16_t value;
};
static bool parse_frame(const uint8_t *buf, size_t len, struct frame *out)
{
if (len < 10u || buf[0] != 0xA5u) {
return false;
}
out->type = buf[1];
out->seq = get_be16(&buf[2]);
out->timestamp_ms = get_be32(&buf[4]);
out->value = get_be16(&buf[8]);
return true;
}
int main(void)
{
const uint8_t rx[] = {0xA5, 0x01, 0x00, 0x2A, 0x00, 0x01, 0xE2, 0x40, 0x09, 0x0B};
struct frame f;
if (parse_frame(rx, sizeof rx, &f)) {
printf("type=%u seq=%u t=%" PRIu32 " ms value=%u\n",
(unsigned)f.type, (unsigned)f.seq, f.timestamp_ms, (unsigned)f.value);
}
return 0;
}
type=1 seq=42 t=123456 ms value=2315

The wire layout and the in-memory struct are completely decoupled. The struct can have any member order and padding; the parser checks the length before touching a byte; nothing depends on alignment or the host’s byte order. This is the alternative to packed struct overlays mentioned in Structs, unions, enums, and memory layout, and it is what most production protocol code does.

Cortex-M3 and M4 offer an optional feature, implemented on the STM32F407, called bit-banding. Two 1 MB regions, the start of SRAM and the start of the peripheral space, each have a 32 MB alias region where every 32-bit word maps to a single bit of the original region. Writing 1 or 0 to the alias word sets or clears that one bit; reading it returns 0 or 1.

Bit-band regionAlias region
SRAM 0x20000000 to 0x200FFFFF0x22000000 to 0x23FFFFFF
Peripherals 0x40000000 to 0x400FFFFF0x42000000 to 0x43FFFFFF

The formula:

alias_address = alias_base + (byte_offset * 32) + (bit_number * 4)
where byte_offset = target_address - region_base

Each bit gets one 4-byte alias word, and each byte has 8 bits, so each byte of the original region occupies 32 bytes of alias space. Worked example, bit 12 of GPIOD_ODR at 0x40020C14:

byte_offset = 0x40020C14 - 0x40000000 = 0x00020C14
* 32 = 0x00418280
+ 12 * 4 = 0x00000030
+ alias_base = 0x42000000
alias address = 0x424182B0
#include <stdint.h>
#define BITBAND_PERIPH(addr, bit) \
(*(volatile uint32_t *)(0x42000000u + (((uint32_t)(addr) - 0x40000000u) * 32u) \
+ ((uint32_t)(bit) * 4u)))
#define PD12_ODR_BIT BITBAND_PERIPH(0x40020C14u, 12u) /* 0x424182B0 */
void led_green(int on)
{
PD12_ODR_BIT = on ? 1u : 0u;
}

The write to the alias address is turned by the bus into a locked read-modify-write of the real word, which cannot be interrupted. So bit-banding gives you atomic single-bit updates on any byte in those regions, including plain SRAM variables shared between main code and ISRs.

Know its limits before using it:

  • It only exists on Cortex-M3 and M4 (and only when the vendor implements it). Cortex-M0/M0+, M7, M23 and M33 have no bit-banding, so code built on it does not port.
  • It covers only the first megabyte of each region. All of the F407’s 128 KB of main SRAM qualifies; the CCM RAM at 0x10000000 and AHB2 peripherals at 0x50000000 do not.
  • It is still a read-modify-write internally. On a register with write-1-to-clear bits, writing one bit through the alias reads the whole register and writes it back, which writes 1 to every other flag that was set and clears them. Never bit-band a W1C status register.
  • It is one bit at a time. Multi-bit fields still need masks.

For GPIO pins, BSRR is simpler and portable across STM32 families. Bit-banding earns its place for atomic flags in SRAM on M3/M4 parts, and in legacy code you will maintain. On newer cores the equivalents are exclusive access instructions (LDREX/STREX) and C11 atomics, covered in Concurrency, atomics, and memory ordering.

Worked example: a 24-bit two’s complement sensor reading

Section titled “Worked example: a 24-bit two’s complement sensor reading”

Many precision ADCs and sensors (load-cell amplifiers, delta-sigma ADCs, some pressure and IMU parts) deliver a 24-bit signed result as three bytes, most significant first. Suppose a bipolar ADC with a 2.5 V reference returns its conversion over SPI as three bytes, and its datasheet says: “24-bit two’s complement, full scale 0x7FFFFF = +Vref, 0x800000 = -Vref”.

Step 1, assemble the bytes into an unsigned 32-bit value, with shifts:

uint32_t raw = ((uint32_t)b[0] << 16) | ((uint32_t)b[1] << 8) | (uint32_t)b[2];

raw now holds a value from 0x000000 to 0xFFFFFF in the low 24 bits of a 32-bit word. It is not yet the right number: 0xFFFFFF should be -1, but as a uint32_t it reads as 16,777,215. That is the “25 degrees became 16 million” bug.

Step 2, sign-extend from 24 to 32 bits. In two’s complement (Types, sizes, and how numbers are stored), bit 23 is the sign bit of a 24-bit number. Extending means copying bit 23 into bits 24 to 31:

raw = 0xFFF000 (24-bit -4096)
bit 31 24 23 0
0000 0000 1111 1111 1111 0000 0000 0000 raw as uint32_t: 16773120
1111 1111 1111 1111 1111 0000 0000 0000 sign-extended: -4096 as int32_t
^^^^ ^^^^
copies of bit 23

There are three common ways to write this, and they differ in how portable they are:

/* (a) Shift up, arithmetic shift down: relies on implementation-defined behavior */
int32_t v_a = (int32_t)(raw << 8) >> 8;
/* (b) OR in the top bits, then convert: the conversion is implementation-defined */
int32_t v_b = (int32_t)((raw & 0x800000u) ? (raw | 0xFF000000u) : raw);
/* (c) Flip the sign bit and subtract its weight: fully portable */
int32_t v_c = (int32_t)(raw ^ 0x800000u) - 0x800000;

(a) converts an out-of-range unsigned value to int32_t and right-shifts a negative number, both implementation-defined. (b) also converts an out-of-range value. GCC and Clang define both behaviors the way you want (modulo conversion, arithmetic shift), and you will see (a) everywhere, but (c) needs no implementation-defined behavior at all. It works because XOR with 0x800000 maps the 24-bit two’s complement range onto 0 to 0xFFFFFF in order (it adds the offset 2^23 modulo 2^24), and subtracting 2^23 shifts it back to the signed range. Every intermediate value fits in an int32_t.

Step 3, scale to physical units without floating point. Full scale is 2^23 counts for 2.5 V, so microvolts are counts * 2500000 / 8388608. The product needs up to 45 bits, so do it in 64-bit:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
static int32_t sign_extend24(uint32_t raw)
{
return (int32_t)(raw ^ 0x800000u) - 0x800000; /* raw in 0..0xFFFFFF */
}
static int32_t counts_to_uv(int32_t counts)
{
return (int32_t)(((int64_t)counts * 2500000) / 8388608);
}
int main(void)
{
const uint8_t samples[][3] = {
{0x00, 0x00, 0x01}, {0x7F, 0xFF, 0xFF}, {0x80, 0x00, 0x00},
{0xFF, 0xFF, 0xFF}, {0xFF, 0xF0, 0x00},
};
for (size_t i = 0; i < sizeof samples / sizeof samples[0]; i++) {
const uint8_t *b = samples[i];
uint32_t raw = ((uint32_t)b[0] << 16) | ((uint32_t)b[1] << 8) | (uint32_t)b[2];
int32_t counts = sign_extend24(raw);
printf("raw 0x%06" PRIX32 " -> %8" PRId32 " counts -> %8" PRId32 " uV\n",
raw, counts, counts_to_uv(counts));
}
return 0;
}
raw 0x000001 -> 1 counts -> 0 uV
raw 0x7FFFFF -> 8388607 counts -> 2499999 uV
raw 0x800000 -> -8388608 counts -> -2500000 uV
raw 0xFFFFFF -> -1 counts -> 0 uV
raw 0xFFF000 -> -4096 counts -> -1220 uV

The test vectors are chosen deliberately: smallest positive, largest positive, most negative, -1, and an ordinary negative value. Those five catch every common mistake: a missing sign extension (0xFFFFFF would print 16777215), sign-extending from the wrong bit, an overflow in the scaling (8388607 * 2500000 does not fit in 32 bits), and assembling the bytes in the wrong order. One LSB is about 0.3 uV, so single counts truncate to 0 uV; if that matters, scale to nanovolts or keep the raw counts further down the pipeline. Integer division in C truncates toward zero, so -1220.7 becomes -1220. Fixed-point scaling in more depth is in ADC, DAC, and analog basics.

  • Using 1 << n instead of 1u << n. 1 << 31 is undefined and ~(1 << n) is a negative int. Always shift unsigned constants, and use 1ull for 64-bit.
  • Forgetting precedence in tests. if (reg & MASK == 0) tests reg & 0. Write if ((reg & MASK) == 0u).
  • Applying ~ to a narrow type and comparing. ~(uint8_t)0x0F is 0xFFFFFFF0 after promotion. Cast back to the narrow type.
  • Inserting a field without masking the value. An oversized value spills into the neighbouring field. Use (val & MASK) << POS.
  • Read-modify-write on a register shared with an ISR. Updates get lost. Use set/reset registers like BSRR, or a critical section.
  • Calling __builtin_clz(0). Undefined. Check for zero first.
  • Casting a byte buffer to uint32_t * to serialize. Wrong byte order, misalignment, aliasing. Use put_be32/get_be32 with shifts.
  • Shifting a uint8_t left by 24 without a cast. It is promoted to int and overflows into the sign bit. Cast to uint32_t first.
  • Forgetting sign extension on narrow signed sensor data. -1 becomes 16 million. Sign-extend from the field’s top bit.
  • Bit-banding a write-1-to-clear register. The internal read-modify-write clears other flags. Write the W1C register directly with only the intended bit set.
  1. Field helpers. Implement field_get and field_set and test them on the host: set MODE12 to 01 and MODE15 to 10 in a variable starting at 0xFFFFFFFF, then at 0x00000000. Expected results: 0xBDFFFFFF and 0x81000000. Also call field_set with value 7 for a 2-bit field and verify the neighbouring fields are unchanged.
  2. Builtins versus portable. Write bit_clz32, bit_ctz32 and bit_popcount32 with portable loops, and compare them against __builtin_clz, __builtin_ctz and __builtin_popcount for 10,000 random nonzero inputs on the host. Then compile the builtin versions with arm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -S and identify the clz instruction, the rbit/clz pair, and what popcount becomes.
  3. Serialization round trip. Write put_le32, get_le32, put_be16 and the others missing from this lesson. Write a test that serializes 0x12345678 into a buffer at offset 1 with each function, prints the bytes, and reads them back. Expected bytes: little-endian 78 56 34 12, big-endian 12 34 56 78.
  4. Disassemble get_be32. Compile get_be32 for Cortex-M4 at -O2 and for Cortex-M0 at -O2 and compare the output with arm-none-eabi-objdump -d. On the M4 you should see an ldr and a rev; on the M0 you should see byte loads (M0 forbids unaligned loads) followed by shifts or a rev. Explain the difference.
  5. Bit-band in Renode or on the board. Use the BITBAND_PERIPH macro to toggle PD12 through the alias address 0x424182B0. In the debugger, set a watchpoint on GPIOD_ODR (0x40020C14), write 1 to the alias, and confirm that the real register changes to have bit 12 set while other bits are unchanged. Then compute by hand the alias address for bit 3 of an SRAM variable at 0x20000410 (answer: 0x2200820C) and verify it in the debugger.

Question 1:uint8_t flags = 0x0F; What does if (~flags == 0xF0) evaluate to?

Question 2:Which expression sets the 3-bit field at position 4 of reg to val without touching other bits?

Question 3:Main code does GPIOD_ODR |= 1u << 12; while an ISR does GPIOD_ODR |= 1u << 13;. What can go wrong, and what is the standard STM32 fix?

Question 4:A sensor returns the 24-bit value 0xFFFFFE. What is the correctly sign-extended int32_t result?

Question 5:Why is return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]; (with const uint8_t *p) a bug?

Question 6:Using bit-banding, what is the alias address for bit 0 of the peripheral register at 0x40000004?

  • Do all bit manipulation on unsigned types, shift unsigned constants (1u, 1ull), and parenthesize bitwise subexpressions in comparisons.
  • Extract a field with (reg >> POS) & MASK; insert with (reg & ~(MASK << POS)) | ((val & MASK) << POS). The Cortex-M4 compiles these to ubfx and bfi.
  • Read-modify-write is three instructions and can lose updates to an interrupt. Prefer hardware set/reset registers such as BSRR, which change bits with a single write.
  • x & (x - 1) clears the lowest set bit: it powers popcount and power-of-two tests. __builtin_clz/ctz map to clz and rbit+clz, and are undefined for zero.
  • Cortex-M is little-endian; most protocols and many sensors are big-endian. Serialize with shifts into bytes, never by casting buffers to wider pointer types, and cast bytes to uint32_t before shifting.
  • Bit-banding gives atomic single-bit access on M3/M4 only, via alias = base + offset * 32 + bit * 4; never use it on write-1-to-clear registers.
  • Sign-extend narrow signed fields from their top bit, and test decoders with the edge values: max positive, most negative, -1.

Next: The preprocessor, used well covers the macros, conditional compilation, X-macros and compile-time checks that wrap all of these techniques into clean, configurable firmware code.