C Part II · Module 15 · Bit-Level Programming
How Bit Masks Work
A mask is a value with ones where you care and zeros where you do not, and every bit operation in this module is that value combined with a register. This chapter starts from what AND actually does to each bit individually — because the formula for building a mask is unmemorable until you can see why it works — then derives one expression that produces the mask for any field from its position and width. It ends at the edge where that expression breaks: the field as wide as the word itself, where the obvious construction is undefined behaviour, no warning flag mentions it, and only the sanitizer will tell you.
Intermediate20 min readCPart IIHardwareBit Manipulation
Part II · Module 15 · Chapter 15.2 · How Bit Masks Work
1. What a Mask Is
A mask is an ordinary integer value, chosen so that its bits mark a field: 1 in every bit position belonging to the field, 0 everywhere else.
For MODE at bits 2:1 of the register from Chapter 15.1, the mask is:
0000_0000_0000_0000_0000_0000_0000_0110 = 0x00000006
^^
bits 2 and 1That is the whole concept. What makes masks worth a chapter is that the useful properties come from how &, | and ^ treat each bit independently — and once those are clear, every operation in Chapter 15.3 and every accessor in Chapter 15.4 follows without memorisation.
2. The Hand-Written Form
For a field you are transcribing once, with the datasheet open, the direct form is clear and correct:
#define MODE_MASK (0x3u << 1) /* bits 2:1 */
#define DIVISOR_MASK (0xFFFu << 4) /* bits 15:4 */
#define THRESH_MASK (0xFFu << 16) /* bits 23:16 */
#define ENABLE_MASK (0x1u << 0) /* bit 0 */Read each one as two separate statements: 0xFFFu is twelve ones — the field's width — and << 4 moves them to the field's position. Written that way the constant is checkable against the datasheet without evaluating anything, which the equivalent 0x0000FFF0u is not.
That is the main argument for the shifted form over a pre-computed literal, and it holds even before any formula appears. (0xFFFu << 4) and 0x0000FFF0u are the same value; only one of them says "twelve bits starting at bit 4".
Note the u on each literal. Chapter 14.2 §5 established why, and Chapter 15.5 will measure it: an unsuffixed literal is int, and a mask for the top bit built from a plain int is undefined behaviour.
3. What Masking Actually Does, Bit by Bit
The three operators used with masks each have a different identity, and those identities are the entire reason the operations in Chapter 15.3 work.
0 in the mask | 1 in the mask | |
|---|---|---|
reg & mask | result bit is 0 — forced off | result bit is the register's bit — preserved |
reg | mask | result bit is the register's bit — preserved | result bit is 1 — forced on |
reg ^ mask | result bit is the register's bit — preserved | result bit is flipped |
Read the table by column and the pattern is immediate. For every operator there is one mask value that preserves and one that acts. For & the preserving value is 1; for | and ^ it is 0. That is why clearing a bit uses & ~mask rather than & mask — you need zeros where you want to act and ones everywhere else, which is exactly the complement.
Read it by row and you get what each operation is for: & keeps a chosen subset and discards the rest, | forces a chosen subset on, ^ flips a chosen subset.
The property that makes all of this usable on hardware is in the "preserved" cells: every bit outside the mask comes through untouched. A register has other fields in it that other code is relying on, and a masked operation is a promise not to disturb them. Chapter 15.3 is built entirely on that promise.
Worked through one position, for reg = 0x…3D and MODE_MASK = 0x6:
bit: 7 6 5 4 3 2 1 0
reg (0x3D) : 0 0 1 1 1 1 0 1
MODE_MASK : 0 0 0 0 0 1 1 0
reg & MASK : 0 0 0 0 0 1 0 0 <- bits 2,1 kept; everything else forced to 04. Building the Mask From Position and Width
A field gives you two numbers. The mask is a function of both, and the function has two steps that correspond exactly to the two halves of §2's hand-written form.
Step one: make width ones. The identity is that 2ʷ − 1 is a run of w ones:
1u << 3 = 0b1000 = 8
(1u << 3) - 1 = 0b0111 = 7 three onesStep two: shift them to pos.
#define MASK(pos, width) ((((UINT32_C(1) << (width)) - 1u)) << (pos))Measured against the running register's field map:
MASK(0,1) = 0x00000001
MASK(1,2) = 0x00000006
MASK(4,12) = 0x0000fff0
MASK(16,8) = 0x00ff0000
MASK(31,1) = 0x80000000
MASK(0,31) = 0x7fffffffEvery one matches the hand-written constants from §2, which is the check worth doing when you introduce the macro into an existing driver: compute both and compare, rather than deleting the literals and hoping.
UINT32_C(1) rather than 1 or 1u is deliberate and is Chapter 14.2 §5's rule: the literal must be at least as wide as the mask you are building, and UINT32_C says so without depending on how wide int happens to be. MASK(31,1) = 0x80000000 is the case that proves it — with a plain 1, that shift is undefined.
5. The Edge: A Field as Wide as the Word
There is exactly one input for which §4's macro is wrong, and it is wrong in the way that matters most — silently.
unsigned w = 32;
uint32_t m = ((UINT32_C(1) << w) - 1u);
printf("mask = 0x%08x\n", m);$ cc -std=c17 -Wall -Wextra -Wpedantic w32.c -o w32
(no diagnostic)$ cc -std=c17 -fsanitize=undefined -fno-sanitize-recover=all -g w32.c -o w32u && ./w32u
w32.c:5:32: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior w32.c:5:32No value is printed here, because the program has none to print.
The rule, which Chapter 15.5 covers in full, is that shifting by an amount greater than or equal to the width of the promoted left operand is undefined behaviour. A uint32_t is 32 bits, so the legal shift counts are 0 through 31. A width of 32 asks for 1 << 32 and falls off the end.
This is not a theoretical edge. A 32-bit register with one 32-bit field is completely ordinary — a timer count, a cycle counter, a plain data register — and a generic accessor macro will be handed width = 32 the first time someone uses it on one.
The guarded form handles the whole range:
#define MASK32(pos, width) \
((uint32_t)((width) >= 32 ? UINT32_MAX \
: ((UINT32_C(1) << (width)) - 1u)) \
<< (pos))MASK32(0,32) = 0xffffffff
MASK32(0,12) = 0x00000fff
MASK32(4,12) = 0x0000fff0Clean under UBSan, and the narrower cases are unchanged. The conditional costs nothing at runtime when the width is a compile-time constant, which for a register map it always is.
6. The Misconception: A Mask Selects Bits
"
reg & MASKselects the field, so that is the field's value."
It is not, and the gap is the single most common bug in hand-written register code.
A mask does not select in the sense of extracting a smaller thing. & is an ordinary binary operator: both operands are 32 bits wide, the result is 32 bits wide, and the bits outside the mask are still present — they have simply been set to zero. The field's bits are still sitting at their original positions.
Measured on the running register:
reg = 0x00081A3D
DIVISOR_MASK = 0x0000FFF0
reg & DIVISOR_MASK = 0x00001A30 <- not 419
(reg & DIVISOR_MASK) >> 4 = 419 <- 4190x1A30 is 6704. The field's value is 419. They differ by exactly the factor 2⁴ that the field's position contributes, which is the whole point: masking isolates the field; shifting gives it its value.
So extraction needs both, and the order matters only for clarity — (reg >> pos) & width_mask and (reg & field_mask) >> pos give the same answer, and Chapter 15.4 argues for one of them.
The one case where the misconception is harmless is a field at position 0, where the shift is by zero and the masked value is the field's value. ENABLE behaves that way, which is exactly why it is the field people generalise from.
7. Which Form to Use
Both forms in this chapter are correct and they are for different situations.
The hand-written literal — (0xFFFu << 4) — is right when you are transcribing a register map by hand. It is checkable against the datasheet by eye, it has no macro to misread, and the width and position are both visible in the expression. For a driver with a few dozen fields written once, this is the better choice, and it is what most published register headers contain.
The computed macro — MASK32(pos, width) — is right when the position and width are themselves parameters: a generic field accessor, a register map generated from a description, a test harness that sweeps field widths. It also removes one transcription step, which matters when the same two numbers already appear in named constants as Chapter 15.1 §7 recommended:
#define UART_DIVISOR_POS 4u
#define UART_DIVISOR_W 12u
#define UART_DIVISOR_MASK MASK32(UART_DIVISOR_POS, UART_DIVISOR_W)Now the two numbers from the datasheet appear exactly once each, and the mask cannot disagree with them.
What is not a reasonable choice is a pre-computed literal with no structure — #define DIVISOR_MASK 0x0000FFF0u. It is the same value and it has thrown away both of the numbers that came from the document, so checking it requires converting hex to binary and counting.
8. What You Should Be Able to Say Now
- A mask is a value with 1 where the field is and 0 elsewhere. Nothing more.
- Every operator has a preserving mask value and an acting one.
&preserves on 1 and forces 0;|preserves on 0 and forces 1;^preserves on 0 and flips on 1. That is why clearing uses& ~mask. - Bits outside the mask come through untouched — the promise that lets you write one field of a shared register.
- Build a mask in two steps:
(1 << width) - 1makeswidthones, then<< posmoves them. Measured correct for all six fields of the running register. - Use
UINT32_C(1), not1.MASK(31,1)=0x80000000is undefined if the literal is a plainint. - Width 32 is undefined:
1u << 32. No warning flag mentions it; UBSan names it. Guard withwidth >= 32 ? UINT32_MAX : …. ~0u >> (32 - width)is not the fix — it moves the undefined case towidth == 0.- A mask isolates; a shift gives the value. Measured:
reg & DIVISOR_MASKis0x1A30, not 419. Only a field at position 0 escapes this. - Comparing against zero needs no shift; using the number does.
9. Exercises
- Write
MASK(pos, width)and print it for every field in Chapter 15.1's register. Compare each against the hand-written constants in §2 and confirm they agree. - Build the width-32 case and run it under
-fsanitize=undefined. Record the diagnostic, then fix it withMASK32and confirm the sanitizer is silent. - Test
MASK32(0, 0). What should a zero-width mask be? Confirm your macro produces it, and say whether the~0u >> (32 - width)form would have. - Write the per-bit table from §3 out for
^by hand, then verify it by XOR-ing0x3Dwith0x06and checking which bits moved. - Compute
(reg & THRESH_MASK)and(reg & THRESH_MASK) >> 16forreg = 0x00081A3D. Explain the ratio between them without computing it. - Write the 64-bit version,
MASK64(pos, width), correct for widths 0 through 64. Say where its edge is. - Take a register header you have seen, or write one of ten fields. Express every mask three ways — pre-computed literal, shifted literal, computed macro — and argue for one. A defensible answer may pick different forms for different fields.
10. Summary
- A mask marks a field: 1 inside, 0 outside.
- Each operator preserves on one mask value and acts on the other —
&preserves on 1,|and^preserve on 0. Hence& ~maskto clear. - Bits outside the mask are untouched — the promise that makes shared registers writable.
((1 << width) - 1) << pos, in two readable steps. UseUINT32_C(1), not1.- Width 32 is undefined behaviour — silent at every warning level, named by UBSan. Guard it;
~0u >> (32 - width)just moves the edge to width 0. - A mask isolates, a shift values.
reg & DIVISOR_MASK=0x1A30, not 419. - Hand-written shifted literals for a transcribed map; a computed macro when position and width are parameters. A bare pre-computed literal throws away both numbers.
The mask is now a thing you can build for any field without a table. On its own it still only answers questions — which bits are these, and are any of them set? Writing to a register is a different problem, because every write to one field is also a promise about every field you did not name, and there are exactly four operations that keep it.
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.
