C Part II · Module 15 · Bit-Level Programming
Thinking in Bits, Nibbles and Fields
A control register reads 531005. That number is true and completely useless: what the hardware is telling you is that the divisor is 419, the mode is 2, the device is enabled, and a reserved bit is set that no field in the datasheet claims. The same thirty-two bits, read two ways. This chapter is about learning to read them the second way by default — why hex gets you only part way there, why the datasheet's bit ranges are the real type of the value, and why a reserved bit nobody named is going to matter three chapters from now.
Intermediate20 min readCPart IIHardwareBit Manipulation
Part II · Module 15 · Chapter 15.1 · Thinking in Bits, Nibbles and Fields
1. The Same Thirty-Two Bits, Read Two Ways
A UART control register is read and contains 0x00081A3D.
As a number that is 531005, and 531005 is a fact about the register that has no use whatsoever. Nobody designed the hardware so that 531005 would mean anything. The number is an artefact of printing in base ten a value that was never a quantity.
The datasheet describes the same register like this:
bit 0 ENABLE
bits 2:1 MODE (0 = 8N1, 1 = 8E1, 2 = 8O1, 3 = reserved)
bit 3 reserved
bits 15:4 DIVISOR (12 bits)
bits 23:16 THRESH (8 bits)
bits 31:24 reservedRead that way, the register says: the device is enabled, the mode is 8O1, the baud divisor is 419, and the FIFO threshold is 8. Those are four facts a person can act on.
The whole of Module 15 is the skill of moving between those two readings fluently, in both directions, without making the mistakes that live at the edges. This chapter establishes the frame; the rest of the module is the mechanics.
2. Reading the Value
Here is the decomposition, measured:
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
static void print_bits32(uint32_t v) {
for (int i = 31; i >= 0; i--) {
putchar((v >> i) & 1u ? '1' : '0');
if (i % 4 == 0 && i != 0) putchar('_');
}
}
int main(void) {
uint32_t reg = 0x00081A3Du;
printf("as a number : %" PRIu32 "\n", reg);
printf("as hex : 0x%08" PRIx32 "\n", reg);
printf("as bits : "); print_bits32(reg); putchar('\n');
printf("\n");
printf("ENABLE (0) = %" PRIu32 "\n", reg & 0x1u);
printf("MODE (2:1) = %" PRIu32 "\n", (reg >> 1) & 0x3u);
printf("DIVISOR (15:4) = %" PRIu32 "\n", (reg >> 4) & 0xFFFu);
printf("THRESH (23:16) = %" PRIu32 "\n", (reg >> 16) & 0xFFu);
return 0;
}as a number : 531005
as hex : 0x00081a3d
as bits : 0000_0000_0000_1000_0001_1010_0011_1101
ENABLE (0) = 1
MODE (2:1) = 2
DIVISOR (15:4) = 419
THRESH (23:16) = 8Every extraction has the same shape — shift the field down to bit 0, then mask off everything above it — and Chapter 15.4 is about doing that properly and in reverse. For now, notice only that the shift amount is the field's position and the mask is determined by its width. Those two numbers are what the datasheet gives you, and they are the only two numbers a field has.
3. The Vocabulary, Fixed Once
Three words get used loosely and the looseness causes real confusion when reading a datasheet aloud.
A bit is one binary digit. In C, bit n of a value is the one worth 2ⁿ — so bit 0 is the least significant, and that numbering is fixed by the shift operators and is the same on every machine. Chapter 14.3 established the related point: byte order affects where bytes sit in memory and has no effect on which bit 1u << 5 names.
A nibble is four bits, and its only real significance is that it is exactly one hexadecimal digit. That correspondence is why hex is used for register values at all.
A field is a run of adjacent bits that the hardware designer gave a meaning and a name. A field has exactly two properties: a position (the bit number of its lowest bit) and a width (how many bits). DIVISOR is position 4, width 12. Everything in this module is a function of those two numbers.
Fields have no obligation to align with nibbles, and the running register demonstrates it: MODE is at position 1 with width 2, straddling the boundary between the first and second hex digits, and DIVISOR runs from bit 4 to bit 15, which is three whole nibbles — the only one in the register that happens to line up.
4. Bit Ranges and How to Read Them
15:4 is read "bits fifteen down to four" and means the twelve bits from bit 15 to bit 4 inclusive, high end first. The convention comes from hardware description languages and is universal in datasheets, so it is worth reading fluently.
Two arithmetic facts follow and both are used constantly:
Width from a range. A range hi:lo has width hi − lo + 1. The + 1 is the mistake everyone makes once: 15:4 is 12 bits, not 11. 3:3 is one bit, which is why a single bit is sometimes written as a degenerate range.
Position is the low number. The shift amount to bring the field down to bit 0 is lo, never hi.
| Datasheet | Position | Width | Mask once shifted down |
|---|---|---|---|
0 | 0 | 1 | 0x1 |
2:1 | 1 | 2 | 0x3 |
15:4 | 4 | 12 | 0xFFF |
23:16 | 16 | 8 | 0xFF |
31:31 | 31 | 1 | 0x1 |
The right-hand column is a mask of width ones, and constructing it from the width — including at the edges, where the obvious construction is undefined behaviour — is Chapter 15.2's entire subject.
5. Printing Bits, and Why You Write It Yourself
Reading a register value as bits is something you will do constantly while debugging, and C17 has no conversion specifier for binary. The function in §2 is nine lines and is worth keeping in a header:
static void print_bits32(uint32_t v) {
for (int i = 31; i >= 0; i--) {
putchar((v >> i) & 1u ? '1' : '0');
if (i % 4 == 0 && i != 0) putchar('_');
}
}The _ every four bits is not decoration. It restores the nibble grouping that hex gives you for free, which is what makes a 32-bit string countable by eye — 0000_0000_0000_1000_0001_1010_0011_1101 can be checked against a datasheet; the same thirty-two characters unbroken cannot.
C23 adds %b to printf for exactly this purpose. It is worth knowing that it is coming and worth not relying on yet, for a reason that is measured rather than assumed:
$ cc -std=c17 -Wall -Wextra -Wformat=2 pb.c -o pb && ./pb
b
$ cc -std=c2x -Wall -Wextra -Wformat=2 pb.c -o pb && ./pb
bThe program is printf("%b\n", 5). Under both standards it printed the letter b — the library on this platform does not implement the specifier, so it had nothing to do with it. And -Wformat=2, which exists to check conversion specifiers against arguments, produced no diagnostic in either mode.
Under C17 that program has undefined behaviour: %b is not a valid conversion specifier, so the result is not "it prints a b", it is "anything may happen". The useful conclusion is not about %b specifically but about the gap between a feature existing in a standard and existing on your machine — write the nine-line function.
6. The Misconception: Hex Is Already Thinking in Bits
"I read register values in hex, so I am already reading them as bits."
Hex is a large improvement on decimal and it is not the same thing, because hex groups bits in fours and fields are not required to be four bits wide or to start at a multiple of four.
The running register makes the point exactly. 0x00081A3D, digit by digit, is 0 0 0 8 1 A 3 D. Now try to answer the datasheet's questions from those digits:
THRESH, bits 23:16. Position 16 and width 8 are both multiples of 4, so this field is exactly two hex digits — the08in the middle. Reading it off the hex works, and gives 8.DIVISOR, bits 15:4. Position 4 and width 12 are both multiples of 4, so this is exactly the three digits1A3. That is 419 in decimal — and notice you still had to convert, because the hardware's divisor is a quantity even though the register's value is not.MODE, bits 2:1. Position 1 is not a multiple of 4. This field is two bits sitting inside the final digitD, and not even the bottom two: it is bits 2 and 1 of0xD=1101, which is10= 2. No hex digit shows it.ENABLE, bit 0. The bottom bit of0xD. Odd means set — which is a real trick, and it is the only field here you can read at a glance, because it is the one at position 0.
So of four fields, hex gave two directly, one required looking inside a digit, and one required knowing that odd means enabled. A field boundary coincides with a hex digit only when both its position and its width are multiples of four, and in real register maps that is the minority of fields, because designers pack fields to fit rather than to be convenient to read.
The thing hex is genuinely good for is being a compact, unambiguous transcription of a bit pattern — far better than decimal, which destroys the structure entirely. Write register values in hex, always. Just do not mistake the transcription for the decomposition.
7. The Habit This Module Is Building
Everything that follows rests on one discipline, which is worth adopting now because it costs nothing and removes a class of error on its own.
Name the position and the width, once, where the datasheet is open in front of you.
/* UART control register, per datasheet rev C table 12-4 */
#define UART_ENABLE_POS 0u
#define UART_ENABLE_W 1u
#define UART_MODE_POS 1u
#define UART_MODE_W 2u
#define UART_DIVISOR_POS 4u
#define UART_DIVISOR_W 12u
#define UART_THRESH_POS 16u
#define UART_THRESH_W 8uTranscribing the table is boring, reviewable against the document line by line, and happens once. Every magic >> 4 and & 0xFFF scattered through a driver is a separate chance to transpose a digit, and none of them can be checked without going back to the datasheet.
This is Chapter 14.6 §5's discipline applied one level down: there, an address map was transcribed as bases and offsets; here, a register is transcribed as positions and widths. Both are the same idea — make the code look like the document, so that checking one against the other is reading two columns.
Chapter 15.2 turns those pairs into masks, and Chapter 15.4 turns them into field accessors you can use in both directions.
8. What You Should Be Able to Say Now
- A register value is not a quantity.
0x00081A3Dis 531005 and that number has no meaning; the meaning is ENABLE = 1, MODE = 2, DIVISOR = 419, THRESH = 8. - A field has exactly two properties: position and width. Position is the bit number of its lowest bit; width is how many bits. Everything in this module is a function of those two.
- A range
hi:lohas widthhi − lo + 1and positionlo.15:4is 12 bits, not 11, and you shift by 4, not 15. - Bit n is the bit worth 2ⁿ, on every machine. Byte order does not affect it (14.3), and MSB-0 documentation conventions are a reading problem, not a C problem.
- Extraction is always shift-then-mask: bring the field down by its position, then mask off the bits above its width.
- Hex groups bits in fours. A field aligns with hex digits only when its position and width are both multiples of 4 — measured on the running register, that was two of four fields.
- Write your own bit printer. Measured:
printf("%b", 5)prints the letterbunder both-std=c17and-std=c2xon this platform, with no-Wformat=2diagnostic, and under C17 it is undefined behaviour. - Transcribe the field map once, as named position and width constants, where the datasheet is in front of you.
9. Exercises
- Write
print_bits32and use it on0x00081A3D,0xFFFFFFFF,0x80000000and0. Check each against what you expect before running. - For the running register, write down the position and width of every field including both reserved regions. Confirm the widths sum to 32.
- Take
0x00081A3Dand work outMODEby hand from the hex digitD, showing the binary. Then confirm with the program in §2. - A datasheet lists a field as bits
21:14. Give its position, its width, the shift amount needed to bring it to bit 0, and the mask to apply afterwards. - Write the
#defineblock from §7 for a register of your choice from any datasheet you have access to, and have someone else check it against the document. Record whether they found anything. print_bits32inserts_every four bits. Modify it to insert a space every eight bits instead, and say which grouping you find easier to check against a byte-oriented datasheet and why.- The running register has bit 3 set, and no field claims it. Write down two different things that bit could be, and what you would do about each when writing a value back. Keep your answer — Chapter 15.4 measures the consequence.
10. Summary
- A register value is a bit pattern, not a quantity.
0x00081A3D= 531005, which means nothing; the fields mean everything. - A field is a position and a width.
hi:lo→ widthhi − lo + 1, positionlo.15:4is 12 bits. - Bit n is worth 2ⁿ everywhere. Byte order is irrelevant; MSB-0 datasheets are a reading convention, converted once, in a comment.
- Extraction is shift-then-mask, always, using exactly the field's two numbers.
- Hex is a transcription, not a decomposition — it aligns with a field only when position and width are both multiples of 4.
- Measured:
printf("%b", 5)printsbon this libc under C17 and C23 mode, with-Wformat=2silent. Write the nine-line printer. - Transcribe the field map once, as named constants, against the open datasheet.
Every extraction in this chapter used a mask written by hand — 0x3, 0xFFF, 0xFF — read off a table that was itself computed from the field widths. Those are fine for four fields transcribed carefully, and they are exactly the constants that get mistyped in a register map with sixty. The next chapter builds the mask from the position and the width instead, which removes the transcription step and introduces one genuinely sharp edge at the widest field a word can hold.
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.
