C Part II · Module 15 · Bit-Level Programming
Setting, Clearing, Toggling and Testing Bits
Four operations, one line each, and they are the whole of register programming. What makes them worth a chapter is the promise they carry: every bit you did not name comes through untouched, which is the only reason two drivers can share a register. This chapter shows why that promise holds, measures it holding, and then measures the one expression that breaks it so thoroughly that a 64-bit register loses its entire upper half — with no diagnostic from any flag, including -Weverything. It ends on the clause people skip: the result of a mask is a value, not a boolean, and testing it against 1 works for exactly one bit.
Intermediate21 min readCPart IIHardwareBit Manipulation
Part II · Module 15 · Chapter 15.3 · Setting, Clearing, Toggling and Testing Bits
1. The Four Operations
Given a mask M built the way Chapter 15.2 described, there are four things you can want.
reg |= M; /* set — those bits become 1 */
reg &= ~M; /* clear — those bits become 0 */
reg ^= M; /* toggle — those bits flip */
if ((reg & M) != 0u) { ... } /* test — nothing changes */That is the complete vocabulary. Everything else in driver code is these four applied to masks you built from a datasheet.
What they have in common is the property that makes them usable at all, and it is worth stating as a promise rather than a side effect:
Every bit outside
Mis unchanged.
A control register is shared. The enable bit belongs to the driver's open routine, the interrupt mask to its interrupt setup, the divisor to its baud-rate code — and each of those runs at a different time without knowing what the others have configured. A read-modify-write that preserves everything it did not name is the only reason that works.
2. Why Each One Works
Chapter 15.2 §3 gave the per-bit table; these four operations are that table used deliberately.
Set: reg |= M. x | 0 is x and x | 1 is 1. So zeros in the mask preserve and ones force on.
Clear: reg &= ~M. x & 1 is x and x & 0 is 0. The preserving value for & is 1, so the mask has to be inverted first: ~M has ones everywhere you want to preserve and zeros exactly where M had ones.
Toggle: reg ^= M. x ^ 0 is x and x ^ 1 is ~x. Zeros preserve, ones flip.
Test: reg & M. Same identity as clear, used to discard rather than to keep: everything outside the mask becomes 0, so what is left is the field in its original position and nothing else.
3. The Promise, Measured
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
#define ENABLE (UINT32_C(1) << 0)
#define IRQ_EN (UINT32_C(1) << 5)
int main(void) {
uint32_t reg = 0x0000000Cu; /* bits 2 and 3 already set */
printf("start 0x%08" PRIx32 "\n", reg);
reg |= ENABLE; printf("set ENABLE 0x%08" PRIx32 "\n", reg);
reg |= IRQ_EN; printf("set IRQ_EN 0x%08" PRIx32 "\n", reg);
reg ^= IRQ_EN; printf("tog IRQ_EN 0x%08" PRIx32 "\n", reg);
reg &= ~ENABLE; printf("clr ENABLE 0x%08" PRIx32 "\n", reg);
printf("bits 2,3 untouched throughout: %s\n", (reg & 0xCu) == 0xCu ? "yes" : "NO");
return 0;
}start 0x0000000c
set ENABLE 0x0000000d
set IRQ_EN 0x0000002d
tog IRQ_EN 0x0000000d
clr ENABLE 0x0000000c
bits 2,3 untouched throughout: yesFour operations, and bits 2 and 3 — which nothing in the program named — came through every one of them. The register returned to its starting value because set and clear are inverses, and toggle applied twice would have been too.
Note that ^= applied once to a set bit cleared it. Toggle is not "set"; it is the only operation of the four whose result depends on the previous value, which makes it the right tool for a blinking output and the wrong tool for anything you want to be in a known state.
4. The Trap: A Mask Narrower Than the Register
reg &= ~M is the only one of the four that applies an operator to the mask before using it, and ~ is where the width of M stops being a detail.
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
int main(void) {
uint64_t reg = 0xFFFFFFFFFFFFFFFFull; /* every bit set */
uint32_t mask = 0x80000000u; /* clear bit 31 only */
uint64_t bad = reg & ~mask; /* ~mask is uint32_t */
uint64_t good = reg & ~(uint64_t)mask; /* widen BEFORE ~ */
printf("~mask = 0x%08" PRIx32 " (still 32 bits)\n", (uint32_t)~mask);
printf("reg & ~mask = 0x%016" PRIx64 "\n", bad);
printf("reg & ~(uint64_t)mask = 0x%016" PRIx64 "\n", good);
return 0;
}~mask = 0x7fffffff (still 32 bits)
reg & ~mask = 0x000000007fffffff <- upper 32 bits GONE
reg & ~(uint64_t)mask = 0xffffffff7fffffff <- only bit 31 clearedThe intent was to clear one bit. What happened was that thirty-three bits were cleared — bit 31 and the entire upper half.
The mechanism is ordinary and that is what makes it dangerous. mask is a uint32_t, so ~mask is computed in 32 bits and is 0x7FFFFFFF — a 32-bit value whose top bit is 0 and which has no upper half at all. Converting it to uint64_t for the & zero-extends it, producing 0x000000007FFFFFFF. Every bit above 31 in that value is a zero, and & with a zero clears.
Widening the mask to the register's type before complementing gives 0xFFFFFFFF7FFFFFFF, which preserves everything except bit 31.
The opposite case is worth a sentence because people conflate them. A mask wider than the register is harmless: uint8_t r &= ~B7 with B7 = 0x80 computes ~B7 as an int — 0xFFFFFF7F — and assigning the result back into a uint8_t truncates to 0x7F, which is the intended mask. Measured, that produces the correct answer and no warning. The dangerous direction is specifically mask narrower than register.
5. Why a Mask Result Is a Value, Not a Boolean
This is the clause people skip, and it produces a bug that passes testing.
uint32_t r2 = IRQ_EN; /* only bit 5 set */
printf("r2 & IRQ_EN = %" PRIu32 "\n", r2 & IRQ_EN);
printf("(r2 & IRQ_EN) == 1 = %s\n", (r2 & IRQ_EN) == 1u ? "true" : "false");
printf("(r2 & IRQ_EN) != 0 = %s\n", (r2 & IRQ_EN) != 0u ? "true" : "false");
printf("!!(r2 & IRQ_EN) = %d\n", !!(r2 & IRQ_EN));r2 & IRQ_EN = 32 (0x20)
(r2 & IRQ_EN) == 1 = false <- wrong test
(r2 & IRQ_EN) != 0 = true <- right test
!!(r2 & IRQ_EN) = 1The bit is set and (r2 & IRQ_EN) == 1 is false.
& is an arithmetic operator producing an integer, not a predicate producing a truth value. Chapter 15.2 §6 made the related point for multi-bit fields: masking isolates the bits and leaves them at their original positions. For a single bit at position n, "isolated and left in place" means the result is either 0 or 2ⁿ — here 32, not 1.
So the correct tests are != 0, or a plain if (reg & M) which uses C's own "non-zero is true" rule, or !!(reg & M) when you genuinely need a 0-or-1 integer to store.
The reason this bug survives is worth noticing, because it explains a whole category of near-misses: it works for bit 0. ENABLE is bit 0, (reg & ENABLE) == 1 is correct, and anyone who tested their macro on the enable bit saw it pass. The macro then fails for every other bit in the register, silently, by reporting that a set bit is clear.
6. Writing a Whole Field
The four operations name bits. A multi-bit field needs both a clear and a set, and the two must happen as one expression or the register passes through a state neither value calls for:
/* set MODE (bits 2:1) to `m`, leaving every other field alone */
reg = (reg & ~MODE_MASK) | ((m << MODE_POS) & MODE_MASK);Three things are going on and all three are necessary.
reg & ~MODE_MASK clears the field — §2's clear, applied to a two-bit mask rather than one bit.
m << MODE_POS moves the new value into position.
& MODE_MASK after the shift is the one people leave out. If m is larger than the field can hold, the shifted value has bits above the field, and | would set them — corrupting whichever neighbouring field they land in. Chapter 15.4 measures exactly that, and builds this expression into an accessor so it cannot be written any other way.
The single = matters too: writing the clear and the set as two statements means the register briefly holds the field as zero. For a plain memory variable that is invisible. For a hardware register, a zero in a mode field may be a valid and different mode that the device will act on. Chapter 16.5 is about read-modify-write on real registers, where this stops being hypothetical.
7. What You Should Be Able to Say Now
- Four operations:
|=set,&= ~clear,^=toggle,& … != 0test. - Every bit outside the mask is unchanged — measured across all four, with untouched neighbours — and that promise is why a shared register works at all.
- Each follows from an identity:
x|0=x,x&1=x,x^0=x. Clear needs~because the preserving value for&is 1. - Toggle is the only one whose result depends on the previous value, so it is wrong for anything that must end in a known state.
- A mask narrower than the register destroys the upper bits. Measured:
uint64_t & ~uint32_t_maskcleared 33 bits, not 1. No diagnostic from any flag, including-Weverything. - Build the mask in the register's own width —
UINT64_C(1) << 31— rather than remembering to cast at each use. - A mask result is a value, not a boolean. For bit 5 it is 32.
== 1is false for every bit except bit 0, which is why the bug passes testing. - Use
!= 0, a bareif, or!!to store a flag. Never== 1. - A multi-bit field needs clear-and-set in one expression, with the new value masked after shifting.
8. Exercises
- Reproduce §3 and add a second toggle of
IRQ_EN. Confirm the register returns to0x0000000dand explain why in terms of §2's identity. - Write
set_bit,clear_bit,toggle_bitandtest_bitasstatic inlinefunctions taking auint32_t *and a bit number. Decide whethertest_bitreturnsintorbool, and justify it from §5. - Reproduce §4 and confirm both outputs. Then write the version with
UINT64_Cand confirm no cast is needed at the point of use. - Build §5's wrong test as a macro
IS_SET(reg, m) ((reg & m) == 1)and write two test cases: one that passes and one that fails. Say which bit each uses. - Write the clear-and-set expression for
THRESH(bits 23:16) and verify on0x00081A3Dthat setting it to 0x5A leavesDIVISOR,MODE,ENABLEand bit 3 unchanged. - Split §6's single expression into two statements and name a device for which the intermediate state would be a real problem.
- Take the
~trap from §4 and construct the mirror case — a mask wider than the register. Confirm the measured claim that it is harmless, and say precisely why the two directions differ.
9. Summary
|=set ·&= ~clear ·^=toggle ·(reg & M) != 0test. That is the vocabulary.- Bits outside the mask are untouched — measured — which is the entire basis of sharing a register between unrelated code.
- The identities:
x|0=x,x&1=x,x^0=x. Clear inverts the mask because&preserves on 1. - Toggle alone depends on the prior value.
- A mask narrower than the register is the trap:
uint64_t & ~uint32_tcleared 33 bits, undiagnosed by-Weverything. Build masks in the register's width. - A mask wider than the register is harmless — measured — so the hazard has a direction.
- The masked result is a value: 32 for bit 5.
== 1is right only for bit 0, which is why it passes testing and fails in the field. - A field write is one expression: clear with
~mask, shift the value in, mask it again after shifting.
That last expression has three parts and the measured consequence of omitting the final mask has not yet been shown. It is also the first time in this module that a value is going into a register rather than coming out, and the two directions turn out to be one skill with the steps reversed — which is the only way to learn them without constantly getting the order backwards.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
