Counting in hexadecimal¶
Level: 101 · for anyone starting from zero
One line: Hex is not a different way of counting. It is the same odometer with sixteen symbols on every wheel instead of ten, so the only rule to learn is that the wheel rolls over after F — and that 10 therefore means sixteen.
Sixteen symbols, because ten ran out¶
Decimal has ten symbols, 0 to 9. That is not a fact about numbers; it is a fact about how many symbols were drawn. Hex needs sixteen, and rather than invent six new shapes it borrowed the first six letters:
| value | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| digit | 0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
A |
B |
C |
D |
E |
F |
A is not a letter in 0x2A. It is the digit worth ten, exactly as 9 is the digit worth nine, and it is only drawn as a letter because the alphabet was lying around. Reading those six as letters is the single mistake that makes hex feel like a foreign notation instead of an ordinary one.
Carrying is the same thing you already do¶
In decimal you run out of symbols at 9. To count past it you reset that column to 0 and add one to the column on its left: 9 + 1 = 10. Nothing about that is decimal-specific — it is what a column does when it runs out.
Hex runs out at F. So:
E + 1 = F still symbols left in this column
F + 1 = 10 out of symbols: reset, carry left. This is SIXTEEN, read "one-zero"
10 + 1 = 11 seventeen
The place people slip is the second wheel, because the first wheel looks decimal for its first ten values:
19 + 1 = 1A twenty-six. NOT 20 -- that column still has A B C D E F to spend
1A + 1 = 1B twenty-seven
1F + 1 = 20 thirty-two. NOW that column has run out too, so now it carries
And it scales the way 99 + 1 = 100 scales — the carry runs left through however many Fs it meets, and stops at the first digit that is not one:
Which is the sentence worth keeping: 99 + 1 = 100 and FF + 1 = 100 are the same event. Both answers are written 100; one of them is a hundred and the other is two hundred and fifty-six, and only the base you were told tells them apart.
Verified output of counting_in_hex_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. SIXTEEN DIGITS. SIX OF THEM ARE DRAWN AS LETTERS
value 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
digit 0 1 2 3 4 5 6 7 8 9 A B C D E F
A is not a letter here. It is the digit worth ten, the way 9 is the digit worth nine.
Decimal ran out of symbols at 9 and had to start carrying. Hex has six more to spend first.
2. COUNTING. THE ONLY NEW RULE IS WHERE THE WHEEL ROLLS OVER
D + 1 = E ( 13 + 1 = 14 )
E + 1 = F ( 14 + 1 = 15 )
F + 1 = 10 ( 15 + 1 = 16 ) <- 1 wheel rolled over
10 + 1 = 11 ( 16 + 1 = 17 )
11 + 1 = 12 ( 17 + 1 = 18 )
...
18 + 1 = 19 ( 24 + 1 = 25 )
19 + 1 = 1A ( 25 + 1 = 26 )
1A + 1 = 1B ( 26 + 1 = 27 )
1B + 1 = 1C ( 27 + 1 = 28 )
...
1E + 1 = 1F ( 30 + 1 = 31 )
1F + 1 = 20 ( 31 + 1 = 32 ) <- 1 wheel rolled over
20 + 1 = 21 ( 32 + 1 = 33 )
19 + 1 is 1A, not 20: that column still has six symbols left in it.
3. THE SAME ODOMETER. THE ONLY DIFFERENCE IS WHICH SYMBOL IS LAST
decimal, last symbol 9 hex, last symbol F
9 + 1 = 10 F + 1 = 10 1 wheel reset
99 + 1 = 100 FF + 1 = 100 2 wheels reset
999 + 1 = 1000 FFF + 1 = 1000 3 wheels reset
Both right-hand answers are written '100'. One of them is 100 and the other is 256.
A carry stops at the first digit that is not the last symbol:
0x9F + 1 = 0xA0 (1 trailing F reset, then the digit to their left went up by one)
0x1F + 1 = 0x20 (1 trailing F reset, then the digit to their left went up by one)
0xAFF + 1 = 0xB00 (2 trailing Fs reset, then the digit to their left went up by one)
4. WHY THE WHEEL ROLLS AT F: EACH COLUMN IS WORTH SIXTEEN OF THE ONE ON ITS RIGHT
column ... 16^2 = 256 16^1 = 16 16^0 = 1
0x21 = 2x16 + 1x1 = 32 + 1 = 33
0xFF = 15x16 + 15x1 = 240 + 15 = 255
0x100 = 1x256 + 0x16 + 0x1 = 256 + 0 + 0 = 256
0x2A5 = 2x256 + 10x16 + 5x1 = 512 + 160 + 5 = 677
Nothing there is special to hex. Swap the 16 for a 10 and it is the arithmetic you already do,
which is the whole claim of this page: you are not learning to count, only where the wheel rolls.
5. 'MOST SIGNIFICANT' MEANS 'THE COLUMN WORTH THE MOST'. NOTHING ELSE
33 = 0x21 = 0b00100001
symbol its column is worth it contributes
most significant digit 2 16 32
least significant digit 1 1 1
most significant bit 0 128 0
least significant bit 1 1 1
The most significant bit of 33 is 0, and it is still the most significant bit: significance is
what the column is worth, not what happens to be sitting in it.
6. AND IT IS PLACE VALUE, NOT SCREEN POSITION
0x2A5 as a 2-byte number, written the two ways a file may store it:
big-endian 02 a5 most significant byte first -- reads like the number
little-endian a5 02 least significant byte first -- and 'a5' is the LEAST significant half
Same number, same significance, opposite order on screen. In a hex dump the leftmost byte
is not automatically the most significant one; that is a fact about the file, not about hex.
7. SO SAY IT OUT LOUD CAREFULLY
base 2 0b100001 thirty-three
base 8 0o41 thirty-three
base 10 33 thirty-three
base 16 0x21 thirty-three
0x21 is 'two-one', or 'hex twenty-one'. Bare 'twenty-one' is a different number, and mis-saying it
is how a conversion goes wrong out loud before it goes wrong on paper. The two characters '10'
are 2 in base 2, 8 in base 8, 10 in base 10 and 16 in base 16 -- four numbers, one spelling.
Where the wheel rolls inside one byte¶
Three carries in the 0–255 range are worth recognising on sight, because each one is a boundary something in this library cares about.
| carry | in bits | what just happened |
|---|---|---|
0F → 10 |
0000 1111 → 0001 0000 |
the low four bits filled up, so the second hex digit moves. Every 16th value does this |
7F → 80 |
0111 1111 → 1000 0000 |
the top bit turned on. 7F is the last ASCII character; everything from 80 up needs an agreement about what it means |
FF → 100 |
1111 1111 → 1 0000 0000 |
every wheel in the byte is full, and the carry needs a ninth bit that a byte does not have |
That last row is the interesting one, because what happens next depends on the type, not on hex. In Python the integer simply grows. In bash it grows to 64 bits and then wraps to the most negative number. In a Rust u8 there is nowhere for the carry to go at all, and the language makes you say in advance which of four answers you want. The odometer is the same everywhere; the width of the machine it is bolted into is not.
Verified output of counting_in_hex_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. printf IS THE ODOMETER. seq TURNS IT
$ seq 13 18 | while read -r n; do printf '%3d = %2X\n' "$n" "$n"; done
13 = D
14 = E
15 = F
16 = 10
17 = 11
18 = 12
D, E, F, then 10 -- and the decimal column is there to say that 10 is sixteen.
2. THE THREE PLACES A BYTE CARRIES, AND WHAT EACH ONE MEANS
0F + 1 = 10 ( 15 -> 16) 0000 1111 -> 0001 0000
7F + 1 = 80 (127 -> 128) 0111 1111 -> 1000 0000
FF + 1 = 100 (255 -> 256) 1111 1111 -> 1 0000 0000 <- a NINTH bit, and a byte has eight
0F -> 10 the low nibble is full, so the high one goes up: the second hex digit changes
7F -> 80 the top bit turns on -- that is where ASCII stops and chapter 2 starts
FF -> 100 every wheel in the byte is full, and the carry needs a column the byte does not have
3. THE SHELL DOES NOT STOP AT FF -- BUT IT DOES STOP
$ printf '%X\n' $(( 0xFF + 1 )) # not a byte, so nothing wrapped
100
$ printf '%d\n' $(( 0x7FFFFFFFFFFFFFFF + 1 )) # 63 bits and a sign bit: bash uses a signed 64-bit integer
-9223372036854775808
So the shell has a width too; it is just wide enough that you rarely meet it, and signed,
so the wheel past the end lands on the most negative number rather than on zero.
4. ARITHMETIC IS DECIMAL. THE BASE IS A DISPLAY CHOICE, MADE TWICE
$ echo $(( 0x19 + 1 )) # hex in, decimal out -- and 19 + 1 is not 20
26
$ printf '%X\n' $(( 0x19 + 1 )) # ask for the answer back in hex, and there it is
1A
$ echo $(( 16#FF )) $(( 2#11111111 )) $(( 8#377 )) 255
255 255 255 255
Four spellings, one number. $(( )) always answers in decimal; printf picks the spelling.
MSB and LSB: the abbreviation does not say which¶
A base converter will usually label the two halves of 0x21 twice, in two different vocabularies: once as High Nibble: 2 | Low Nibble: 1, and once as MSB: 0010 | LSB: 0001. The first pair is unambiguous. The second is three different things sharing two abbreviations:
| written | expands to | how big | where you meet it |
|---|---|---|---|
| MSB / LSB | most significant bit / least significant bit | one bit | the sign bit, "the top bit is set", x & 1 to test for odd |
| MSB / LSB | most significant byte / least significant byte | one byte | byte order — big-endian is defined as most significant byte first |
| MSB / LSB | most significant bits / least significant bits, plural | a group | informal: the high and low halves of something |
Nothing disambiguates them but context, and the two that matter are one bit and one whole byte. So when a converter writes MSB: 0010 over four bits it means the third row — the most significant bits — while a reader who takes the abbreviation at face value reads the first row and gets a different answer: the most significant bit of 0x21 is 0, not 0010. Both readings are defensible from the label, which is the problem with the label. Say "high nibble" and "low nibble" for the halves of a byte; nothing else is short, and nothing else can be misread.
The underlying idea is simpler than either abbreviation and is worth having instead of them. Significant means worth more: a digit's significance is the value of the column it sits in, so the leftmost digit of a written number is the most significant because changing it moves the value furthest. In 0x21 the 2 is worth 2 × 16 = 32 and the 1 is worth 1; in 0b0010 0001 the eight columns are worth 128, 64, 32, 16, 8, 4, 2, 1.
Two consequences fall straight out, and they are the whole reason the vocabulary exists:
- Significance is about the column, not about what is in it. The most significant bit of
0x21is bit 7, and it is0. It is still the most significant bit. ("Which is the highest bit that is actually set?" is a different question, and it is the oneleading_zerosanswers.) - Significance is not screen position. In a little-endian file the least significant byte is written first, so the leftmost byte of a hex dump is the low half of the number. That is a fact about the file's byte order and not about hex — see Byte order and the BOM.
Verified output of counting_in_hex_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. THE TWO HALVES OF A BYTE ARE A SHIFT AND A MASK
n = 33 = 0x21 = 0b00100001
high nibble n >> 4 = 2 the 16s digit
low nibble n & 0x0F = 1 the 1s digit
and back (hi << 4) | lo = 33
Those two digits are what a hex dump prints. Nothing is computed to get them;
the byte was already two hex digits, and the shift only picks which one you want.
2. std WILL NAME THE SIGNIFICANT BIT, BECAUSE IT IS A POSITION AND NOT A VALUE
0x01 = 0b00000001 leading_zeros 7 highest set bit is bit 0 (worth 1) ones 1 trailing_zeros 0
0x21 = 0b00100001 leading_zeros 2 highest set bit is bit 5 (worth 32) ones 2 trailing_zeros 0
0x80 = 0b10000000 leading_zeros 0 highest set bit is bit 7 (worth 128) ones 1 trailing_zeros 7
0xFF = 0b11111111 leading_zeros 0 highest set bit is bit 7 (worth 128) ones 8 trailing_zeros 0
Bit 7 is the most significant bit of a u8 whatever is in it -- 33 has a 0 there and
it is still the most significant bit. `leading_zeros` reports the highest bit that is SET,
which is a different question and the one you usually want.
3. FF + 1 IN A BYTE: FOUR ANSWERS, AND THE TYPE MAKES YOU PICK ONE
u8::MAX = 255 = 0xFF
max.checked_add(1) = None there is no answer, and the type says so
max.wrapping_add(1) = 0 the carry is discarded: the odometer rolls to 00
max.overflowing_add(1) = (0, true) the answer, and the fact that it overflowed
max.saturating_add(1) = 255 stop at the top rather than roll round to it
Plain `max + 1` is a fifth: it panics in a debug build and wraps in a release one, so it
is the only one of the five whose meaning depends on how the program was compiled.
Written as a literal it does not even compile -- `arithmetic_overflow` is deny-by-default.
4. AND 'MSB' MEANS TWO DIFFERENT THINGS, ONE BIT APART FROM ONE BYTE
w = 0x02A5 = 677
most significant BIT w >> 15 = 0 one bit, the 32768s column
most significant BYTE (w >> 8) as u8 = 0x02 one byte, the 256s column
w.to_be_bytes() = [02, A5] big-endian: most significant byte written first
w.to_le_bytes() = [A5, 02] little-endian: least significant byte written first
Same number, same significance, two orders on disk. Which is why 'the MSB' is only ever
clear from context: in a bit-twiddling sentence it is one bit, in a byte-order sentence
it is one byte, and the abbreviation is identical for both.
A note on the octal row¶
The same converters print an octal row, usually padded to three digits: 33 decimal is 0o041. Octal is the same trick at a different width — one digit per three bits — and three does not divide eight, so those three octal digits have room for nine bits while a byte has eight. The leading digit is therefore not a full digit: it runs 0–3 and no further, which is why 0o377 is the largest byte. That is one place where the two shorthands genuinely differ rather than merely looking different, and Hex is a shorthand has the argument in full, along with the field where octal is the base that fits and hex is the awkward one.
If you are coming from Python or ABAP¶
Python. Counting is the part you never have to write: f"{n:X}" renders any integer in hex and int(s, 16) reads one back, so the odometer is a formatting concern rather than an algorithm. Two habits are worth taking from this page anyway. Pad deliberately — f"{n:02X}" for a byte, because an unpadded F and an unpadded 0F are the same number and only one of them is a byte-shaped picture. And remember that a Python int has no width, so 0xFF + 1 is 256 and never 0; the wrap that a byte would perform has to be written by you, as & 0xFF.
ABAP. The counting itself is invisible for the same reason, but the width is not: a TYPE x LENGTH 1 field is a byte, so the arithmetic that Python performs on an unbounded integer has a ceiling here, and hex is the only spelling ABAP offers for the value — in source (VALUE 'FF'), in the debugger, and in a write-out, always two digits per byte with no decimal view to fall back on. Bit-level work goes through GET BIT / SET BIT, which number bits from 1 on the left, so GET BIT 1 reads what this page calls the most significant bit and what the x field's own hex spelling puts in the high nibble — an off-by-one waiting for anyone carrying a zero-indexed >> 7 across from C or Rust. (Not machine-checked — CI cannot run ABAP.)
Try it¶
cd 01_Bits_and_Bytes/counting_in_hex/examples
python3 counting_in_hex_py.py
bash counting_in_hex_sh.sh
rustc --edition 2024 counting_in_hex_rs.rs -o /tmp/counting && /tmp/counting
Then, out loud and without a converter — the point is to make the wheel turn in your head, so say each one before you check it:
- Count from
3Eto45. (Eight values, and exactly one carry.) - What comes after
4F? AfterFF? After1FF? - Which is bigger,
0x20or29? The trap is that only one of the two says what base it is in, and the other one has two answers. 0xBin decimal, and 12 in hex.- A byte holding
0x7Fis incremented. Give the new value in hex and in binary, and name what changed.
Check the lot in one line — printf will count for you, and disagreeing with it is the useful outcome:
for n in 3E 3F 40 4F FF 1FF B 20; do printf '%5s = %4d +1 = %X\n' "$n" $((16#$n)) $((16#$n + 1)); done
Then the same drill in the other direction: take three byte values you can see in a hex dump of any file (xxd -l 3 /bin/ls) and say each one in decimal before checking with printf '%d\n' 0x….
Practice¶
Turn the wheel in your head. Say each of these out loud before you check it — the point is the odometer, not the arithmetic.
- Count from
3Eto45. How many carries? - What comes after
4F? AfterFF? After1FF? - Which is bigger,
0x20or29? 0xBin decimal, and 12 in hex.- A byte holding
0x7Fis incremented. Give the new value in hex and in binary, and name what changed.
Answers
Verified output of counting_in_hex_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. COUNT FROM 3E TO 45
3E 3F 40 41 42 43 44 45
Eight values, and exactly ONE carry: 3F -> 40.
The low wheel rolled past F to 0 and the high wheel stepped 3 to 4.
Every other step in that run leaves the high digit alone -- which is
the thing worth feeling, because it is the same odometer you already
turn in decimal and the only new rule is where F sits.
2. WHAT COMES NEXT
4F -> 50
FF -> 100
1FF -> 200
FF -> 100 is the same carry twice in one step, which is why a byte
that holds FF and is incremented lands on 00 and takes the 1 with it
into a place a byte does not have.
3. WHICH IS BIGGER, 0x20 OR 29?
0x20 is 32. 29 has two answers: 29 if it is decimal, 41 if it is hex.
So 0x20 < 29 as decimal, and 0x20 < 0x29 as hex -- the comparison
goes the same way by luck, not by argument. Only one of the two
spellings says what base it is in, and that is the whole trap.
4. 0xB IN DECIMAL, AND 12 IN HEX
0xB = 11
12 = C (decimal twelve, so the digit after B)
5. A BYTE HOLDING 0x7F IS INCREMENTED
7F -> 80 binary 01111111 -> 10000000
Every bit changed. Seven ones fell to zero and the eighth rose --
one step on the odometer, and the byte's top bit is now set, which is
the bit that decides whether a signed reading calls this 128 or -128.
See also¶
- A byte is eight bits — the eight columns whose values this page is counting through
- Hex is a shorthand — the other reason hex is used: one digit is exactly four bits, so a byte is always two digits
- Reading a hex dump — where you meet these two-digit values sixteen at a time
- Byte order and the BOM — where "most significant byte" stops being a detail
- Writing a number down ↗ — the Rust library on literals in all four bases, and the width written on the number
- Anki: hexadecimal — the sixteen symbols and the carry as flashcards, alongside the rest of the chapter's hex