Binary, Hex, and Bitwise Operations
Systems code lives at the bit level. Device registers, protocol headers, permission flags -- all of them demand that you read, set, and clear individual bits. This chapter gives you the vocabulary and the muscle memory for that work, first in C, then in Rust.
Number Representations
A byte is eight bits. How you display those bits is a matter of base.
Base 10 (decimal): 42
Base 2 (binary): 00101010
Base 8 (octal): 052
Base 16 (hex): 0x2A
Hex is the lingua franca of systems programming because one hex digit maps to exactly four bits. Two hex digits map to one byte. Clean, compact, no ambiguity.
Hex digit: 0 1 2 3 4 5 6 7 8 9 A B C D E F
Binary: 0000 0001 0010 0011 0100 0101 0110 0111 1000 1001 1010 1011 1100 1101 1110 1111
C Literals
#include <stdio.h>
int main(void) {
int dec = 42;
int oct = 052; /* leading zero = octal */
int hex = 0x2A;
int bin = 0b00101010; /* C23 / GCC extension */
printf("dec=%d oct=%o hex=0x%X bin(manual)=00101010\n", dec, oct, hex);
printf("All equal? %d\n", (dec == oct) && (oct == hex) && (hex == bin));
return 0;
}
Compile: gcc -std=c2x -o numrep numrep.c && ./numrep
Caution: A leading zero makes a literal octal in C. Writing
int x = 010;gives you 8, not 10. This has caused real bugs in real codebases.
Rust Literals
fn main() { let dec = 42; let oct = 0o52; // explicit 0o prefix -- no silent octal trap let hex = 0x2A; let bin = 0b00101010; println!("dec={dec} oct={oct} hex=0x{hex:X} bin=0b{bin:08b}"); println!("All equal? {}", dec == oct && oct == hex && hex == bin); }
Rust Note: Rust requires
0ofor octal. There is no silent leading-zero trap. Rust also lets you use underscores as visual separators:0b0010_1010,1_000_000.
Try It: Print the value
0xDEAD_BEEFin decimal, octal, and binary in both C and Rust. How many bits does it need?
Bitwise Operators
Six operators manipulate bits directly. They work on integer types only.
Operator C Rust Meaning
-------------------------------------------------------
AND & & 1 only if both bits are 1
OR | | 1 if either bit is 1
XOR ^ ^ 1 if bits differ
NOT ~ ! flip every bit (C: ~x, Rust: !x)
Left shift << << shift bits left, fill with 0
Right shift>> >> shift bits right (see below)
AND -- Masking
AND keeps only the bits that are 1 in both operands. Use it to extract bits.
#include <stdio.h>
int main(void) {
unsigned char val = 0b11010110;
unsigned char mask = 0b00001111; /* keep low nibble */
unsigned char result = val & mask;
printf("0x%02X & 0x%02X = 0x%02X\n", val, mask, result);
/* output: 0xD6 & 0x0F = 0x06 */
return 0;
}
1 1 0 1 0 1 1 0 val (0xD6)
AND 0 0 0 0 1 1 1 1 mask (0x0F)
= 0 0 0 0 0 1 1 0 result(0x06)
OR -- Setting Bits
OR forces bits to 1. Use it to set flags.
#include <stdio.h>
int main(void) {
unsigned char flags = 0b00000010; /* bit 1 already set */
unsigned char bit4 = 0b00010000; /* want to set bit 4 */
flags = flags | bit4;
printf("flags = 0x%02X\n", flags); /* 0x12 */
return 0;
}
XOR -- Toggling Bits
XOR flips bits where the mask is 1, leaves others untouched.
#include <stdio.h>
int main(void) {
unsigned char val = 0b11001100;
unsigned char tog = 0b00001111;
val ^= tog;
printf("After toggle: 0x%02X\n", val); /* 0xC3 = 0b11000011 */
return 0;
}
NOT -- Inverting All Bits
#include <stdio.h>
int main(void) {
unsigned char val = 0b00001111;
unsigned char inv = ~val;
printf("~0x%02X = 0x%02X\n", val, inv); /* ~0x0F = 0xF0 */
return 0;
}
Rust Note: Rust uses
!for bitwise NOT (not~). The!operator on a boolean gives logical NOT; on an integer, bitwise NOT. Context determines behavior.
Rust Equivalents -- All Operators at Once
fn main() { let a: u8 = 0b1100_1010; let b: u8 = 0b0011_1100; println!("AND: {:08b}", a & b); // 00001000 println!("OR: {:08b}", a | b); // 11111110 println!("XOR: {:08b}", a ^ b); // 11110110 println!("NOT: {:08b}", !a); // 00110101 println!("SHL: {:08b}", a << 2); // 00101000 (top bits lost) println!("SHR: {:08b}", a >> 2); // 00110010 }
Shifts: Arithmetic vs Logical
Left shift always fills with zeros. Right shift is where trouble lurks.
Logical right shift: fills the vacated high bits with 0. Arithmetic right shift: fills the vacated high bits with the sign bit.
Logical >> 2: 1100 0000 -> 0011 0000 (zero-filled)
Arithmetic >> 2: 1100 0000 -> 1111 0000 (sign-extended)
In C, the behavior of >> on signed integers is implementation-defined. Most
compilers do arithmetic shift, but it is not guaranteed.
#include <stdio.h>
int main(void) {
int signed_val = -128; /* 0xFFFFFF80 in 32-bit */
unsigned int unsigned_val = 0xFF000000u;
printf("signed >> 4 = 0x%08X\n", signed_val >> 4); /* likely 0xFFFFFFF8 */
printf("unsigned >> 4 = 0x%08X\n", unsigned_val >> 4); /* always 0x0FF00000 */
return 0;
}
Caution: Never right-shift a negative value in portable C code. Use unsigned types for bit manipulation. Always.
In Rust, the rules are explicit:
fn main() { let s: i8 = -128_i8; // 0x80 let u: u8 = 0x80; // Rust: >> is arithmetic on signed, logical on unsigned. Always. println!("signed >> 2 = {}", s >> 2); // -32 (arithmetic) println!("unsigned >> 2 = {}", u >> 2); // 32 (logical) }
Rust Note: Rust defines shift behavior precisely: arithmetic for signed, logical for unsigned. In debug mode, shifting by >= the bit width panics. In release mode, it wraps. No undefined behavior either way.
Common Bit Patterns
These are the bread and butter of driver and kernel code.
Check if Bit N is Set
#include <stdio.h>
#include <stdint.h>
int bit_is_set(uint32_t val, int n) {
return (val >> n) & 1;
}
int main(void) {
uint32_t reg = 0xA5; /* 1010 0101 */
for (int i = 7; i >= 0; i--)
printf("%d", bit_is_set(reg, i));
printf("\n");
return 0;
}
Set Bit N
val |= (1u << n);
Clear Bit N
val &= ~(1u << n);
Toggle Bit N
val ^= (1u << n);
All Four in Rust
fn main() { let mut val: u32 = 0b1010_0101; let n = 3; let is_set = (val >> n) & 1 == 1; println!("Bit {n} set? {is_set}"); val |= 1 << n; // set bit 3 println!("After set: 0b{val:08b}"); val &= !(1u32 << n); // clear bit 3 println!("After clear: 0b{val:08b}"); val ^= 1 << n; // toggle bit 3 println!("After toggle: 0b{val:08b}"); }
Driver Prep: Every hardware register you touch in a driver uses exactly these four operations. A typical register write looks like:
reg |= ENABLE_BIT; writel(reg, base + OFFSET);
Powers of Two
Bit shifts and powers of two are the same thing.
1 << 0 = 1 = 2^0
1 << 1 = 2 = 2^1
1 << 4 = 16 = 2^4
1 << 10 = 1024 = 2^10
1 << 20 = 1048576 = 2^20 (1 MiB)
A classic trick: check if a number is a power of two.
#include <stdio.h>
#include <stdbool.h>
bool is_power_of_two(unsigned int x) {
return x != 0 && (x & (x - 1)) == 0;
}
int main(void) {
unsigned int tests[] = {0, 1, 2, 3, 4, 15, 16, 255, 256};
int n = sizeof(tests) / sizeof(tests[0]);
for (int i = 0; i < n; i++)
printf("%3u -> %s\n", tests[i], is_power_of_two(tests[i]) ? "yes" : "no");
return 0;
}
Why does x & (x - 1) work?
x = 0001 0000 (16, a power of 2)
x - 1 = 0000 1111
x & (x-1) = 0000 0000 => zero, so it IS a power of 2
x = 0001 0100 (20, NOT a power of 2)
x - 1 = 0001 0011
x & (x-1) = 0001 0000 => non-zero, so it is NOT
fn is_power_of_two(x: u32) -> bool { x != 0 && (x & (x - 1)) == 0 } fn main() { for x in [0, 1, 2, 3, 4, 15, 16, 255, 256] { println!("{x:>3} -> {}", if is_power_of_two(x) { "yes" } else { "no" }); } }
Rust Note: Rust provides
u32::is_power_of_two()in the standard library. But knowing the bit trick matters -- you will seex & (x - 1)in kernel code.
Counting Set Bits (Population Count)
How many bits are 1 in a value? This operation is called popcount.
#include <stdio.h>
#include <stdint.h>
int popcount_naive(uint32_t x) {
int count = 0;
while (x) {
count += x & 1;
x >>= 1;
}
return count;
}
/* Brian Kernighan's trick: x & (x-1) clears the lowest set bit */
int popcount_kernighan(uint32_t x) {
int count = 0;
while (x) {
x &= x - 1;
count++;
}
return count;
}
int main(void) {
uint32_t val = 0xDEADBEEF;
printf("popcount(0x%X) = %d (naive)\n", val, popcount_naive(val));
printf("popcount(0x%X) = %d (kernighan)\n", val, popcount_kernighan(val));
/* GCC/Clang built-in -- compiles to a single POPCNT instruction */
printf("popcount(0x%X) = %d (builtin)\n", val, __builtin_popcount(val));
return 0;
}
fn main() { let val: u32 = 0xDEAD_BEEF; println!("popcount(0x{val:X}) = {}", val.count_ones()); // Also available: count_zeros, leading_zeros, trailing_zeros println!("leading zeros: {}", val.leading_zeros()); println!("trailing zeros: {}", val.trailing_zeros()); }
Try It: Write a function that returns the position of the highest set bit in a
u32. Test it with0x00000001(should return 0) and0x80000000(should return 31).
Extracting and Inserting Bit Fields
Registers often pack multiple values into a single word.
31 24 23 16 15 8 7 0
+----------+---------+--------+---------+
| field_d |field_c |field_b | field_a |
+----------+---------+--------+---------+
Extract field_b (bits 8..15):
#include <stdio.h>
#include <stdint.h>
int main(void) {
uint32_t reg = 0xAABBCCDD;
/* Extract bits [15:8] */
uint32_t field_b = (reg >> 8) & 0xFF;
printf("field_b = 0x%02X\n", field_b); /* 0xCC */
/* Insert new value 0x42 into bits [15:8] */
reg &= ~(0xFF << 8); /* clear the field */
reg |= (0x42u << 8); /* set new value */
printf("reg = 0x%08X\n", reg); /* 0xAABB42DD */
return 0;
}
fn main() { let mut reg: u32 = 0xAABB_CCDD; // Extract bits [15:8] let field_b = (reg >> 8) & 0xFF; println!("field_b = 0x{field_b:02X}"); // 0xCC // Insert 0x42 into bits [15:8] reg &= !(0xFFu32 << 8); reg |= 0x42u32 << 8; println!("reg = 0x{reg:08X}"); // 0xAABB42DD }
Driver Prep: The pattern
(reg >> SHIFT) & MASKto read andreg = (reg & ~(MASK << SHIFT)) | (val << SHIFT)to write is the single most common operation in Linux driver code. Burn it into memory.
No Implicit Conversions in Rust
In C, bitwise operators can silently promote or truncate types:
#include <stdio.h>
int main(void) {
unsigned char a = 0xFF;
/* ~a promotes a to int first, result is NOT 0x00 */
printf("~a = 0x%08X\n", ~a); /* 0xFFFFFF00 -- surprise! */
return 0;
}
Caution: In C,
~on acharpromotes tointfirst. The result has 32 (or 64) bits, not 8. This causes subtle bugs in mask comparisons.
Rust does not promote:
fn main() { let a: u8 = 0xFF; let b: u8 = !a; println!("!0xFF = 0x{b:02X}"); // 0x00 -- exactly 8 bits, no surprise }
Quick Knowledge Check
- What does
0x1F & 0x0Fevaluate to? Work it out in binary before running code. - You have a 32-bit register value. Bits [7:4] contain a 4-bit version number. Write the C expression to extract it.
- Why is
x & (x - 1) == 0not a correct power-of-two test whenxis 0?
Common Pitfalls
- Shifting by the type width.
1 << 32on a 32-bit int is undefined in C. Use1u << 31as the maximum, or1ULL << 32for 64-bit. - Signed operands in bit ops.
~(-1)is well-defined but confusing. Use unsigned. - Forgetting the
usuffix.1 << 31in C is signed overflow (UB on 32-bit int). Write1u << 31. - Comparing after NOT.
~(unsigned char)0xFFisint, notunsigned char. Cast or mask the result. - Rust shift panics.
1u32 << 32panics in debug. Design around it.