Bitwise NOT in C — Why ~ Clears Adjacent Permission Bits
The NOT operator flips all 32 bits, not just your flag byte.
20+ years shipping performance-critical C and C++ systems. Drawn from code that ran under real load.
- Bitwise operators manipulate individual bits of integer types in a single CPU cycle
- AND (&) for masking, OR (|) for setting bits, XOR (^) for toggling, NOT (~) for inverting
- Left shift (<<) multiplies by 2^N; right shift (>>) divides by 2^N on unsigned types
- Always use unsigned fixed-width types (uint32_t) to avoid undefined behavior on shifts
- Forgetting operator precedence (e.g., & vs ==) is the #1 production mistake
- Mask the result of NOT with the bit width: (~MASK) & 0xFF keeps it predictable
Imagine a row of 8 light switches on a wall — each one is either ON (1) or OFF (0). Bitwise operators are like instructions for flipping, checking, or combining those switches in bulk, all at once, in a single CPU instruction. Instead of 'is the number greater than 5?', you're asking 'is this specific switch flipped on?' That's it. Numbers are just patterns of switches, and bitwise operators let you manipulate those patterns with surgical precision.
Embedded systems, operating system kernels, game engines, network protocols, and cryptography libraries all share one thing in common — they rely heavily on bitwise operators. When a Linux kernel sets a file permission flag, it flips a bit. When a graphics engine packs RGBA color channels into a single 32-bit integer, it uses bit shifts. When a network driver extracts the IP header length from a raw packet, it masks bits. This isn't niche knowledge — it's the lingua franca of systems programming.
The problem bitwise operators solve is deceptively simple: speed and space. Storing 8 boolean flags as 8 separate int variables wastes memory and slows things down. Packing them into a single byte and using bitwise operators to read and write individual flags is both faster and leaner. The CPU handles bitwise operations in a single clock cycle — there's nothing faster in your toolbox.
By the end of this article you'll be able to read and write bit flags confidently, understand exactly how masking and shifting work under the hood, spot the classic bugs that trip up even experienced developers, and answer the bitwise questions that interviewers love to spring on candidates. Let's get into it.
What Bitwise NOT in C Actually Does — And Why It Breaks Permission Bits
Bitwise NOT (~) in C flips every bit in its operand: 0 becomes 1, 1 becomes 0. It operates on the full width of the integer type — for an unsigned char (8 bits), ~0x03 yields 0xFC. This is a unary operator, applied to a single integer expression, and it works on any integer type (char, int, long, etc.). The result is a one's complement of the operand.
Key property: ~ is not a logical negation. It inverts all bits, not just the truthiness of the value. For signed types, the result depends on the underlying two's complement representation — ~0 is -1, not 0. This catches many developers off guard when they expect a simple boolean flip. The operation is O(1) and compiles to a single machine instruction (NOT or XOR with all-ones).
Use ~ when you need to clear specific bits by ANDing with the complement of a mask: flags &= ~PERM_WRITE. This is the canonical pattern for clearing permission bits in systems programming — file access masks, memory page protections, or interrupt enables. Without ~, you'd have to know the exact bit pattern to clear, which is error-prone and unmaintainable.
The Six Bitwise Operators and What They Actually Do
C gives you six bitwise operators: AND (&), OR (|), XOR (^), NOT (~), left shift (<<), and right shift (>>). Each one operates on the individual bits of an integer — not the integer as a whole mathematical value, but the raw binary pattern sitting in memory.
Think of AND as 'both switches must be ON'. OR as 'at least one switch must be ON'. XOR (exclusive OR) as 'exactly one switch must be ON — but not both'. NOT flips every single switch. Left shift moves all switches to the left by N positions, filling vacated positions with zeros — which multiplies by powers of two. Right shift moves everything right, which divides by powers of two.
The key insight beginners miss: these operators don't care about the numeric value you intended. They only see the bit pattern. The number 5 (binary: 00000101) and the number 6 (binary: 00000110) ANDed together produce 4 (binary: 00000100) — not because 5 AND 6 means anything mathematically, but because only the third bit was ON in both patterns.
This distinction between 'value' and 'bit pattern' is everything. Once it clicks, the rest falls into place naturally.
print_binary() helper from this example into any project where you're doing bit manipulation. Seeing the raw bit pattern when debugging saves hours. Most bit bugs are immediately obvious the moment you print the binary representation.Bit Flags and Masking — The Real-World Pattern You'll Use Every Day
The single most common real-world use of bitwise operators is the bit flag pattern. Instead of storing eight separate boolean variables, you pack them all into one integer — each bit represents one flag. This is how file permissions work in Linux (rwxrwxrwx is nine bits), how TCP/IP headers store control flags (SYN, ACK, FIN), and how game engines store entity states.
Three operations drive the entire pattern:
Setting a bit (turning a flag ON): Use OR with a mask. The mask has a 1 only in the position you want to set. Everything else stays untouched because OR-ing with 0 changes nothing.
Clearing a bit (turning a flag OFF): Use AND with an inverted mask. The inverted mask has a 0 only in the target position, which forces that bit to 0. Everything else stays untouched because AND-ing with 1 changes nothing.
Checking a bit (reading a flag): Use AND with the mask. If the result is non-zero, the bit was set. If zero, it wasn't.
The mask itself is almost always defined using a left shift: 1 << N gives you a mask with exactly one 1 bit at position N. This pattern is so idiomatic in C that you'll see it in virtually every low-level codebase.
Bit Shifting for Fast Math and Data Packing
Bit shifts are doing two different jobs in real codebases, and conflating them leads to bugs. The first job is fast arithmetic: shifting left by N multiplies by 2^N, shifting right by N divides by 2^N (integer division). Modern compilers do this automatically for power-of-two constants, but you'll still see explicit shifts in performance-critical hot paths and in code that needs to be portable to compilers without optimisation.
The second job is data packing and unpacking — cramming multiple values into a single integer. This is everywhere in real protocols. A 32-bit RGBA color value packs four 8-bit channels. An IPv4 header packs the version number and IHL into a single byte. An audio sample format encodes bit depth, channel count, and sample rate into a single flags integer.
The formula is always the same: shift a value LEFT into its position to pack it, shift RIGHT and mask to unpack it. The mask (typically 0xFF for an 8-bit field, 0xF for a 4-bit field) ensures you only extract the bits that belong to that field and nothing bleeds in from adjacent fields.
This packing technique is also why bitfields in structs exist — they're syntactic sugar over exactly this pattern.
pack_rgba(). Without them, you're shifting an 8-bit uint8_t, which gets promoted to int (likely 32 bits, but signed). Shifting into the sign bit of a signed int is undefined behaviour in C. Always cast to the target type before a left shift — it costs nothing and prevents nasty surprises on different architectures.XOR Tricks and the Swap Without a Temp Variable
XOR has a remarkable mathematical property: it's its own inverse. If you XOR a value with a key to 'encode' it, XOR-ing the result with the same key gives you the original value back. This property powers simple (though not secure) data obfuscation, checksum algorithms, and one of the most famous C interview tricks: swapping two variables without a temporary.
The XOR swap works because of three applications of the inverse property. After a ^= b, the variable a holds the combination of both values. After b ^= a, b now holds the original a. After a ^= b, a now holds the original b. It reads like magic until you trace the bits.
But here's the honest truth a senior dev will tell you: don't use XOR swap in production. Modern compilers generate a three-instruction register swap that's faster, and the XOR swap silently corrupts both variables if you accidentally call it with the same memory address (i.e., xor_swap(&value, &value) zeroes the variable). It's a brilliant interview answer — and a terrible production choice.
Where XOR legitimately earns its keep is in parity checking, error detection (CRC algorithms use XOR extensively), and finding the unique element in an array where every other element appears twice — a classic algorithm problem that becomes a single-line solution with XOR.
Bitwise Operator Precedence and Common Gotchas
Bitwise operators have lower precedence than you think. The most dangerous trap is that ==, !=, and relational operators (<, >, <=, >=) all have higher precedence than &, ^, and |.
So if (flags & MASK == 0) is parsed as if (flags & (MASK == 0)), which compares MASK to 0 (always false, resulting in 0), then ANDs flags with 0 — always false. The fix is parentheses: if ((flags & MASK) == 0).
Similarly, if (flags & MASK != 0) is parsed as if (flags & (MASK != 0)). Since MASK is non-zero, MASK != 0 is true (1), so you effectively check flags & 1 — which is rarely what you want.
- Shift operators (
<<,>>) have higher precedence than AND, OR, XOR. Somask << 2 & 0xFFparses as(mask << 2) & 0xFF, which is usually correct, butx & mask << 1isx & (mask << 1), not(x & mask) << 1. - The NOT operator
~binds tightly, so~mask >> 1is(~mask) >> 1, not~(mask >> 1).
Production rule: When in doubt, parenthesize every bitwise expression. It costs nothing and prevents bugs that can take hours to find.
if (flags & MASK == 0x80). The parser passed all unit tests but never matched the flag. The fix: if ((flags & MASK) == 0x80). Parentheses would have saved half a day.((flags & MASK) != 0) — never skip the parentheses.Endianness and Bitwise Operators — Why Your Network Packet Just Corrupted
Bitwise operators work on the value, not the memory layout. That sounds obvious until your cross-platform serialisation corrupts every packet. Endianness — the order bytes are stored in memory — does not affect &, |, ^, or ~. They operate on the CPU register where the byte order is already resolved. Shift operators? Same. 0x0102 << 8 is always 0x0200, regardless of whether your machine stores the high byte first or last.
The trap: unions and pointer casts. If you overlay a uint32_t over a uint8_t array to extract bytes, you get different results on x86 (little-endian) vs ARM in big-endian mode. Write code that reads bytes using shifts and masks, not pointer arithmetic. Serialisation libraries like Protocol Buffers handle this for you — but when you roll your own packet parser, test on both endiannesses in CI. Production will find the one you didn't.
Hardware registers are another minefield. Many embedded peripherals document bit fields assuming big-endian storage. Your compiler might pack them differently. Always verify with a union and a known pattern like 0xAA55 before trusting bitfield structs.
memcpy() a struct with bitfields, your parser will fail on x86. Always use ntohl/htonl for wire protocol headers.Undefined Behaviour in Shifts — The Compiler Will Gaslight You
Shifting signed integers left into the sign bit is undefined behaviour. Shifting by more than the bit-width of the type? Also undefined. The compiler can assume you never do this — and optimise your error handling into a no-op.
Example: int32_t x = 1 << 31. On your machine, this might set the sign bit to 1. But the C standard says this is UB. A different compiler version might silently remove the line, or trap on ARM. Clang's UB sanitizer will catch it. Your production crash dump won't.
Right shift of negative signed integers is implementation-defined: arithmetic shift (sign-extending) vs logical shift (zero-fill). Most compilers do arithmetic shift, but if you rely on it for a signed-to-unsigned conversion trick, you're writing a time bomb. Always cast to unsigned before shifting if you care about the bit pattern.
Another footgun: << with negative shift count is UB. A junior might write x << (y - offset) where y < offset. That's a hard crash — or worse, silent corruption. Validate your shift counts. Every time.
Syntax: How Bitwise Operators Appear in Real Code
Bitwise operators in C look deceptively simple: they're single symbols like &, |, ^, ~, <<, and >>. But their syntax carries side effects that catch even senior engineers. The bitwise AND (&) is not the logical AND (&&); mixing them with conditionals silently breaks control flow. The bitwise OR (|) and XOR (^) follow infix notation, meaning they sit between two integer expressions. The NOT operator (~) is unary and flips every bit — but on signed integers, two's complement makes ~0 equal to -1, not 0xFFFFFFFF unless you cast to unsigned. Shift operators (<<, >>) have a gotcha: the right operand must be less than the width of the left operand's type, or the behavior is undefined. Assignments like &=, |=, ^=, <<=, >>= are compound operators that modify the left operand in place. Always parenthesize bitwise expressions inside conditionals: (flags & MASK) != 0, never (flags & MASK) — that assigns truthiness to the result instead of a boolean. Use unsigned types (uint32_t) for portability; signed shifts are implementation-defined for right shifts of negative values.
Examples: Bitwise Mastery Through Compilable Snippets
Theory fades fast without concrete examples that compile and produce output. Below are battle-tested patterns for bit twiddling in C. The first example shows how to toggle a permission bit in a Unix-style mode mask — note that ~ on a signed int flips all bits, including the sign, so always cast to unsigned or use a literal suffix. The second example packs two 16-bit values into a 32-bit integer for network packet headers, demonstrating how shifts avoid manual multiplication. The third example performs a XOR swap without a temp variable; it works only for distinct memory locations — swapping with itself zeroes the value. Each snippet uses uint32_t from <stdint.h> for guaranteed width. Run them to see the output; the compiler will not warn you about sign extension unless you enable -Wsign-conversion. Always test on your target architecture; endianness affects how packed bytes are interpreted. The XOR trick also finds duplicate items in arrays: XOR all elements with the expected range. These examples represent daily-use patterns in embedded systems and protocol parsing.
A Permission Flag That Silently Cleared Adjacent Bits
permissions &= ~PERMISSION_WRITE would only affect the write bit because they'd defined PERMISSION_WRITE as 1 << 1. They forgot that ~PERMISSION_WRITE on a 32-bit unsigned int becomes 0xFFFFFFFD, which clears every bit that isn't bit 1.PERMISSION_WRITE was defined as (1 << 1) which is an int constant. When used with unsigned int permissions = ..., the ~ operator promotes the mask to unsigned int but flips all 32 bits, not just the lower 8. The AND operation then clears all bits in positions 2-31, destroying other permissions.permissions &= (~PERMISSION_WRITE) & 0xFF; or use fixed-width types like uint8_t for small flag sets so the mask only has 8 bits.- NOT (~) flips ALL bits of the underlying type — not just the bits you care about.
- Always AND the result of NOT with a bitmask of your intended width.
- Use uint8_t for byte-sized flag variables so ~ behaves predictably.
if (flags & MASK == 0) is parsed as if (flags & (MASK == 0)). Add parentheses: if ((flags & MASK) == 0). Always parenthesize bitwise expressions in conditions.printf("mask = 0x%X\n", mask); printf("shifted = 0x%X\n", value << n);// Use a helper: see print_binary() from the article; compile with -DDEBUG to enableKey takeaways
if ((flags & MASK) != 0).Common mistakes to avoid
3 patternsConfusing & (bitwise AND) with && (logical AND)
if (permission_flags & is_logged_in) evaluates a bitwise AND of an integer and a boolean — it may pass or fail for completely wrong reasons.if (flags & MASK) is correct — but don't mix flag-checking with logical conditions in one expression without careful parentheses.Right-shifting a signed negative integer
int value = -8; value >> 1; gives -4, not 2147483644. The C standard calls this implementation-defined behaviour.(unsigned int)signed_value >> shift_amount. This guarantees logical (zero-filling) right shift behaviour.Applying NOT (~) without masking the result
~0xFF, you get 0xFFFFFF00 (4294967040), not 0x00 as beginners expect. This is the root cause of hundreds of permission-flag bugs.(~PERMISSION_WRITE) & 0xFF if working with byte-sized flags, or define your masks and variables as the same fixed-width type from the start.Interview Questions on This Topic
How would you check if a given integer is a power of two using bitwise operators? Explain why your solution works at the bit level.
n & (n - 1) clears the lowest set bit. If the result is zero, there was exactly one set bit. So n > 0 && (n & (n - 1)) == 0 correctly identifies powers of two. The n > 0 check is necessary because zero passes the n & (n - 1) test but is not a power of two.Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Drawn from code that ran under real load.
That's C Basics. Mark it forged?
10 min read · try the examples if you haven't