A practical guide to bit manipulation in C programming — covering every operator, embedded system register configuration, bit-field structs, and the patterns I use daily in production firmware code.
C gives you six bitwise operators that work on any integer type: & (AND), | (OR), ^ (XOR), ~ (NOT), << (left shift), and >> (right shift). These operators act on each individual bit of their operands. I use them constantly in embedded work because they map directly to the hardware registers I'm configuring.
Here is what each operator does at the bit level — this is the same truth table you will see in microcontroller datasheets:
I have used bitwise operations in C extensively when programming microcontrollers — setting and clearing individual GPIO pins with PORTB |= (1 << 3) and PORTB &= ~(1 << 3) is the canonical embedded C pattern that every firmware developer learns early.
| A | B | A & B | A | B | A ^ B | ~A |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 1 |
| 0 | 1 | 0 | 1 | 1 | 1 |
| 1 | 0 | 0 | 1 | 1 | 0 |
| 1 | 1 | 1 | 1 | 0 | 0 |
C also provides compound assignment forms: &=, |=, ^=, <<=, and >>=. These modify the left operand in place, which is both shorter and expresses intent more clearly. I almost never write x = x & mask — x &= mask is the standard idiom.
// Compound assignment examples uint8_t flags = 0b00000000; flags |= (1 << 3); // set bit 3 flags &= ~(1 << 5); // clear bit 5 flags ^= (1 << 1); // toggle bit 1
In my experience writing firmware for ARM Cortex-M microcontrollers, about 80 percent of bit manipulation boils down to just four patterns. If you memorize these, you can handle almost any register configuration task:
// Set bit N: OR with a mask that has a 1 at position N
reg |= (1 << N);
// Clear bit N: AND with a mask that has 0 at position N
reg &= ~(1 << N);
// Toggle bit N: XOR with a mask that has 1 at position N
reg ^= (1 << N);
// Check bit N: AND and test for nonzero
if (reg & (1 << N)) { /* bit N is set */ }
When you need to operate on multiple bits at once, combine masks with OR. For example, to set bits 0, 2, and 4 in one operation: reg |= (1 << 0) | (1 << 2) | (1 << 4);. The compiler folds the constant expression at compile time, so there is zero runtime cost for the combined mask.
volatile for Hardware RegistersWhen you are writing to memory-mapped hardware registers in C, always declare the pointer as volatile. The compiler will otherwise optimize away repeated reads or writes that are actually required by hardware. I have debugged more than one bug where a register write vanished because the variable was not marked volatile and the compiler thought the write was dead code.
Let me walk through a concrete example from my own STM32 work. Most microcontroller GPIO ports are configured through a set of 32-bit registers: MODER (mode), OTYPER (output type), OSPEEDR (speed), PUPDR (pull-up/down), and ODR (output data). Each pin occupies a specific range of bits within these registers.
Here is how I configure PA5 as an output push-pull at high speed with no pull-up. PA5 means pin 5 in port A. The MODER register uses 2 bits per pin, so pin 5 occupies bits 10-11. General purpose output is the value 0b01 in those bits:
// Configure PA5 as output (STM32 LL-style) #define PIN5 5 #define GPIO_MODER_MASK (0x03 << (PIN5 * 2)) // mask for bits 10-11 #define GPIO_MODER_OUT (0x01 << (PIN5 * 2)) // output mode value // Step 1: clear the mode bits for pin 5 GPIOA->MODER &= ~GPIO_MODER_MASK; // Step 2: set output mode GPIOA->MODER |= GPIO_MODER_OUT; // Step 3: set output type to push-pull (bit 5 = 0) GPIOA->OTYPER &= ~(1 << PIN5); // Step 4: set speed to very high GPIOA->OSPEEDR |= (0x03 << (PIN5 * 2)); // Step 5: no pull-up, no pull-down GPIOA->PUPDR &= ~(0x03 << (PIN5 * 2)); // Step 6: write the output high GPIOA->BSRR = (1 << PIN5);
The pattern is always the same: clear the relevant bits with AND-NOT, then set the desired values with OR. Doing it in two steps prevents a read-modify-write race condition if you tried to set bits that were not already zero. This is the standard idiom you will see in every STM32 HAL, CMSIS, and bare-metal codebase.
C lets you declare struct members with an explicit bit width using the colon syntax. Bit-fields are useful when you want the compiler to handle the packing for you, particularly for hardware register maps or memory-constrained data structures.
// Bit-field struct for an RTC time register
struct rtc_time {
uint32_t seconds : 6; // bits 0-5 (0-59)
uint32_t minutes : 6; // bits 6-11 (0-59)
uint32_t hours : 5; // bits 12-16 (0-23)
uint32_t day : 5; // bits 17-21 (1-31)
uint32_t month : 4; // bits 22-25 (1-12)
uint32_t year : 6; // bits 26-31 (0-63, offset from 2000)
};
// Access is clean and readable
struct rtc_time tm;
tm.seconds = 45;
tm.minutes = 30;
tm.hours = 14;
That said, I have a rule of thumb: use bit-fields for readability inside your own code, but never use them for serialization or cross-compiler binary formats. The C standard leaves the memory layout implementation-defined. Bit-field ordering (big-endian vs little-endian), alignment padding, and whether a field can cross a byte boundary all depend on the compiler. For anything that leaves your process — a file format, a network packet, or a shared memory region — use explicit masks and shifts instead.
// Same RTC register using explicit masks (portable)
uint32_t pack_rtc_time(int s, int m, int h, int d, int mo, int y) {
uint32_t reg = 0;
reg |= (s & 0x3F); // bits 0-5
reg |= ((m & 0x3F) << 6); // bits 6-11
reg |= ((h & 0x1F) << 12); // bits 12-16
reg |= ((d & 0x1F) << 17); // bits 17-21
reg |= ((mo & 0x0F) << 22); // bits 22-25
reg |= ((y & 0x3F) << 26); // bits 26-31
return reg;
}
C's bitwise operators have a few gotchas that I have learned the hard way. The most important one is that right shift of a signed integer is implementation-defined. On GCC and Clang, it performs an arithmetic shift (sign-extending), but the C standard does not guarantee this. If you need a logical shift that always fills with zeros, cast to the unsigned type first:
int16_t x = -8; // 0xFFF8 in 16-bit two's complement
int16_t y = x >> 2; // GCC: -2 (1111 1111 1111 1110)
// Standard: implementation-defined!
uint16_t z = (uint16_t)x >> 2; // always 0x3FFE (16382)
Another trap: when you left-shift a signed integer and the result overflows, that is undefined behavior. The compiler can legally do anything. Shifting into the sign bit of a signed int is UB, even though the same operation is perfectly well-defined on an unsigned int:
int x = 1 << 31; // UNDEFINED BEHAVIOR on 32-bit int
// (shifting into the sign bit)
unsigned int y = 1u << 31; // OK: 0x80000000, value 2147483648u
My rule: prefer unsigned types for all bitwise manipulation. Use uint32_t, uint16_t, uint8_t from <stdint.h> rather than plain int when you are doing bit work. This avoids sign-extension surprises, undefined behavior from overflow, and makes your intent explicit.
sizeof TrapA common bug: 1 << 31 on a 16-bit int produces 0 because 1 is an int and shifting a 16-bit value by 31 is undefined. Always use suffixed constants: 1u << 31 on a 32-bit type, or 1ULL << 63 for 64-bit shifts. Our 64-bit programmer calculator uses BigInt and avoids this class of bug entirely.
Enter any two 32-bit integers in our interactive bitwise calculator and see the binary, decimal, and hex results for AND, OR, XOR, and shifts. The live display makes it easy to verify your C code.
C supports six bitwise operators: AND (&), OR (|), XOR (^), NOT (~), left shift (<<), and right shift (>>). These operators work on integer types (char, short, int, long, long long) and their unsigned variants. C also provides compound assignment operators like &=, |=, ^=, <<=, and >>= for modifying variables in place.
To set bit N: value |= (1 << N). To clear bit N: value &= ~(1 << N). To toggle bit N: value ^= (1 << N). To check bit N: if (value & (1 << N)). These four patterns are the foundational idioms of C bit manipulation and appear in virtually every embedded C codebase.
A bit-field is a struct member that occupies a specified number of bits. Declared with a colon followed by the bit width, e.g., 'unsigned int flags : 3'. Bit-fields are useful for packing multiple small values into a single machine word, modeling hardware registers, or reducing memory usage in large arrays of structs. However, the exact memory layout is implementation-defined, so they are not suitable for serialization or cross-platform binary formats.
For unsigned integers, right shift (>>) is always a logical shift — zeros are shifted in from the left. For signed integers, the behavior is implementation-defined, but on virtually all modern compilers, right shift of a signed integer is an arithmetic shift that preserves the sign bit. This means negative numbers remain negative after right shift because 1-bits are shifted in from the left.
Using uint32_t and other fixed-width types from <stdint.h> avoids several classes of bugs: undefined behavior from shifting into the sign bit, implementation-defined right-shift behavior, and unexpected sign extension. Fixed-width types also make your code portable across platforms where int may be 16 or 64 bits. I always use uint32_t for bit manipulation in my firmware projects.