
11 min read
Number systems literacy for embedded engineering
How-to · Embedded · C · Math · Foundations · HSW
A practical how-to for hex/binary fluency, two’s complement, and fixed-point—so register maps, ADC codes, and resource-tight math stop being folklore.
Embedded work is full of numbers that are not the decimal you type in a notebook. A register is 0x4002_1000. A status bit is “bit 3.” An ADC reading is 1374 counts, not 1.374 V. A temperature filter on a small MCU is often fixed-point, not float. If those representations stay fuzzy, you will mis-set pinmux, mis-read fault flags, and ship silent overflow.
This how-to builds literacy you can use at the bench and in C:
- Binary and powers of two
- Hex as compressed binary
- Bit fields, masks, and shifts
- Two’s complement signed integers
- Fixed-point (Q-format) enough to implement
- Practice drills and failure patterns
Learning goals
After this how-to you should be able to:
- Convert fluently among binary ↔ hex ↔ decimal for 8/16/32-bit patterns
- Read a datasheet bit as “bit n” and write a correct mask
- Explain two’s complement and compute negations by hand for small widths
- Represent a real-world scale (e.g. volts, amps) as integer + implied fraction bits
- Spot overflow, sign-extension, and “I used float because I was scared” traps
Step 0 — Why embedded cares
| Artifact | Representation |
|---|---|
| Memory address | Hex (and binary inside the decoder) |
| GPIO / RCC register | Bit fields in a word |
| Peripheral ID / enum | Small integers; often hex in docs |
| ADC / DAC | Unsigned or signed codes |
| Sensor fusion lite | Fixed-point or float |
| CAN payload | Raw bytes; multi-byte endianness |
| Fault log | Packed bitmaps |
Rule: every number on a wire or in a register has width, signedness, and units (or “unitless counts”). Literacy means naming all three.
Step 1 — Binary fluency
Bits and weights
A bit is 0 or 1. An n-bit unsigned pattern represents:
value = b_{n-1}·2^{n-1} + … + b_1·2^1 + b_0·2^0
| Bits | Unsigned range | Common C type |
|---|---|---|
| 8 | 0 … 255 | uint8_t |
| 16 | 0 … 65535 | uint16_t |
| 32 | 0 … 2³²−1 | uint32_t |
Memorize powers of two (at least through 2¹⁶):
2^0=1 2^1=2 2^2=4 2^3=8
2^4=16 2^5=32 2^6=64 2^7=128
2^8=256 2^9=512 2^10=1024 (~1K)
2^12=4096 2^16=65536 2^20≈1M 2^30≈1G
How-to: decimal → binary (unsigned)
Repeatedly divide by 2; remainders are bits LSB first. Or subtract largest power of two that fits.
Example: 13₁₀
13 = 8+4+1 = 2^3 + 2^2 + 2^0 → 0b1101
How-to: binary → decimal
Sum the weights of 1-bits: 0b10110 = 16+4+2 = 22.
Grouping bits
Humans chunk bits in 4s (nibbles) because hex maps 1:1 to nibbles:
0b 1101 0110 = 0xD6
Practice writing binary with spaces every 4 bits. Your future self (and code reviews) will thank you.
Step 2 — Hex fluency
Hexadecimal digits: 0–9, A–F (values 10–15). One hex digit = 4 bits.
| Hex | Binary | Dec |
|---|---|---|
| 0 | 0000 | 0 |
| 1 | 0001 | 1 |
| … | … | … |
| 9 | 1001 | 9 |
| A | 1010 | 10 |
| B | 1011 | 11 |
| C | 1100 | 12 |
| D | 1101 | 13 |
| E | 1110 | 14 |
| F | 1111 | 15 |
How-to: binary → hex
Split into 4-bit groups from the right; map each group.
0b 0001 1110 1010 0011
1 E A 3 → 0x1EA3
How-to: hex → binary
Expand each digit to 4 bits.
0x5C → 0101 1100
How-to: hex ↔ decimal
Use place values base 16: 0x2F0 = 2·256 + 15·16 + 0 = 512 + 240 = 752.
In C:
uint32_t addr = 0x40021000u;
uint8_t mask = 0xA5u;
Prefer 0x prefix and u suffix on unsigned constants when it clarifies intent.
Why hex dominates datasheets
Addresses and 32-bit registers are painful in binary. Hex is compact and still bit-aligned. When a manual says “set bits 15:12 to 0b1010,” you should see 0xA000 style masks without panic.
Step 3 — Bits, masks, and shifts (daily C)
Numbering
Almost all MCU manuals number bits 0 = LSB. Bit 7 of a byte is weight 128.
Set / clear / toggle / test
#include <stdint.h>
/* Set bit n */
reg |= (1u << n);
/* Clear bit n */
reg &= ~(1u << n);
/* Toggle bit n */
reg ^= (1u << n);
/* Test bit n (nonzero if set) */
if (reg & (1u << n)) { /* ... */ }
Multi-bit field (e.g. bits 6:4 = value 0..7):
enum { FIELD_SHIFT = 4, FIELD_MASK = 0x7u };
/* Write field */
reg = (reg & ~(FIELD_MASK << FIELD_SHIFT))
| ((value & FIELD_MASK) << FIELD_SHIFT);
/* Read field */
uint32_t v = (reg >> FIELD_SHIFT) & FIELD_MASK;
How-to drill
Datasheet: “GPIO mode bits 3:2 for pin 5 are in MODER, shift = 5·2 = 10.”
Encode mode 0b10:
moder = (moder & ~(3u << 10)) | (2u << 10);
If you cannot derive the shift from pin index, stop and re-read the register map—do not copy a magic constant from a forum.
Endianness (short warning)
Multi-byte values on the wire (CAN, UART protocols, file formats) may be little-endian or big-endian. The bit numbering inside a byte is still usually LSB = bit 0 in C. Do not confuse byte order with bit order.
Step 4 — Two’s complement (signed integers)
The problem
Unsigned 8-bit goes 0…255. We also need negative numbers for errors, temperatures below zero, position deltas, PID terms.
The encoding
On virtually all modern CPUs and MCUs, signed integers use two’s complement:
- Fixed width n bits
- Bit patterns
0…2^{n-1}−1→ non-negative values0…2^{n-1}−1 - Bit patterns with MSB = 1 → negative values
- Range:
−2^{n-1}…+2^{n-1}−1
| Width | Type | Min | Max |
|---|---|---|---|
| 8 | int8_t | −128 | +127 |
| 16 | int16_t | −32768 | +32767 |
| 32 | int32_t | −2³¹ | +2³¹−1 |
How-to: negate in two’s complement
Rule: invert all bits, then add 1.
Example: +5 in 8-bit
+5 = 0000 0101
~ = 1111 1010
+1 = 1111 1011 → this is −5
Check: 1111 1011 as unsigned is 251; as int8, 251 − 256 = −5.
How-to: read a negative pattern
Either:
- If MSB is 1, value = unsigned_value − 2ⁿ
- Or: two’s complement negate to see magnitude
0b1111 1110 (int8) → unsigned 254 → 254 − 256 = −2
Why hardware likes it
Addition and subtraction use the same adder for signed and unsigned; only interpretation of the bit pattern changes. Overflow detection differs; the circuitry is shared.
C pitfalls (must-know)
| Topic | Rule of thumb |
|---|---|
Prefer stdint.h | int16_t not bare int when width matters |
| Unsigned wrap | Modular, defined |
Signed overflow + - * | Undefined behavior in C—don’t rely on wrap |
| Mixed signed/unsigned | Usual arithmetic conversions surprise you—cast explicitly |
| Right shift on signed | Implementation-defined/arithmetic shift often sign-extends—verify |
| Cast narrow → wide signed | Sign-extends on two’s complement hosts |
int8_t a = -5;
int32_t b = a; /* likely 0xFFFFFFFB */
uint32_t c = (uint8_t)a; /* 0x000000FB if you wanted the byte pattern */
How-to habit: when debugging, print values as hex and signed decimal:
printf("x=%ld (0x%08lX)\n", (long)x, (unsigned long)(uint32_t)x);
Step 5 — From ADC codes to “real” units (bridge)
An N-bit ADC returns an integer code, not volts.
V ≈ Vref · code / (2^N) /* ideal unipolar example */
Or with offset and scale from calibration. Keep code in an integer until you need SI—and when you convert, watch types.
/* 12-bit ADC, Vref = 3.3 V, millivolts out */
uint16_t code = read_adc(); /* 0..4095 */
uint32_t mv = ((uint32_t)code * 3300u) / 4095u;
Integer multiply before divide, with a wide intermediate, is a fixed-point cousin. Dividing first loses resolution.
Step 6 — Fixed-point representation
Why not always float?
- Some cores have no FPU (or slow soft-float)
- Deterministic timing and MISRA-ish environments prefer integers
- You only need a few fraction bits
- ISR budgets matter
Fixed-point = integer storage + agreed binary point (how many bits mean fraction).
Q-format (common naming)
Qm.n often means:
- n fraction bits
- m integer bits excluding sign in some conventions—read the local definition
A safer operational definition for this how-to:
real_value ≈ stored_integer / 2^f
where f is the number of fraction bits you chose. Stored integer is usually two’s complement if signed.
Example: Q8.8 as “8 fraction bits” in a 16-bit word (signed):
storage: int16_t x
real ≈ x / 256
1.5 → round(1.5 * 256) = 384 → 0x0180
−0.5 → round(−0.5 * 256) = −128 → 0xFF80
How-to: choose fraction bits
| Need | Thinking |
|---|---|
| Resolution | LSB = 1/2^f in real units |
| Range | Max real ≈ (2^−1)/2^f for signed width w |
| Headroom | Leave integer bits for peaks (PID, gains) |
Example: store current in amps with 1 mA resolution → f such that 1/2^f ≤ 0.001, or store milliamps as integer (f = 0 in amps, or think “fixed-point in mA”).
Often the simplest fixed-point is integer SI subunits: millivolts, milliamps, millidegrees. That is fixed-point with scale 10³, not power-of-two—still valid, multiply/divide carefully.
How-to: multiply two fixed-point numbers
If both have f fraction bits:
real_a ≈ a / 2^f
real_b ≈ b / 2^f
real_prod ≈ (a·b) / 2^{2f}
So integer product must be shifted down by f (or 2f depending on formats) with a wide intermediate:
int16_t a, b; /* Q8.8-style: f=8 */
int32_t prod = (int32_t)a * (int32_t)b;
int16_t out = (int16_t)(prod >> 8); /* keep 8 fraction bits */
Rounding: add half LSB before shift for round-nearest (sign-aware).
Saturation: clamp before casting to narrow types if overflow is possible.
How-to: add fixed-point
Same format → integer add (watch overflow width). Different formats → align by shifting first.
Worked mini-example: low-pass filter
/* y += alpha * (x - y), alpha in Q15 (0..1 ≈ 0..32767) */
int16_t lowpass_q15(int16_t y, int16_t x, int16_t alpha_q15)
{
int32_t diff = (int32_t)x - (int32_t)y;
int32_t step = (diff * alpha_q15) >> 15;
return (int16_t)(y + step);
}
You must document: input units, α meaning, and that >> 15 assumes arithmetic shift on signed (true on typical GCC ARM—still verify).
Step 7 — End-to-end how-to recipes
Recipe A — Decode a register dump
- Width of register? (8/16/32)
- Split hex into binary (nibbles).
- Mark bit numbers under the bits.
- Map set bits to datasheet fields.
- Write a one-line C mask that tests the fault you care about.
Recipe B — Pack a command byte
- List fields and bit ranges.
- For each field:
(value & mask) << shift. - OR them; keep type
uint8_t/uint16_t. - Unit-test with known vectors (table in the protocol doc).
Recipe C — Sensor to display units without float
- State:
coderange, reference, desired output unit. - Use
uint32_tintermediate:out = (code * scale) / div. - Prove no overflow: max
code * scale< 2³² (or use 64-bit). - Document resolution and rounding.
Recipe D — Fixed-point gain
- Choose f fraction bits.
- Convert real gain G →
G_q = round(G * 2^f). - Multiply with wide type; shift by f.
- Saturate to output type.
- Compare offline against float reference for a vector of inputs.
Step 8 — Practice drills (do these)
Drill 1. Convert:
0x3F → binary and decimal
0b11001010 → hex and decimal
1000₁₀ → hex
Drill 2. What is bit 5 of 0xA5? Write C to set bit 5 of a clear uint8_t.
Drill 3. 8-bit two’s complement: patterns for −1, −128, +127. Which pattern cannot be positive-negated in 8-bit two’s complement without overflow? (−128)
Drill 4. Q format with f=8: encode 3.25 and −1.0 as int16_t. Multiply them in integer math and shift to stay at f=8; compare to −3.25.
Drill 5. 12-bit ADC code 2048, Vref 3.3 V → millivolts with integer math only.
(Answers at the end.)
Failure gallery
| Symptom | Number-system story |
|---|---|
| “Bit 3 didn’t work” | Shift off-by-one; MSB/LSB confusion |
Magic 0x400 works only on one chip | Hardcoded address; wrong peripheral base |
| Negative temperature shows as 65000 | Printed as unsigned |
| Filter explodes after gain change | Fixed-point overflow; no wide intermediate |
| ADC “volts” jump in steps | Truncation: divided before multiply |
| Portability bug | Relied on signed overflow wrap |
| CAN value wrong by 256× | Endianness / wrong byte pick |
Quick reference card
hex digit ↔ 4 bits
0xFF = 255 = 0b11111111
set n: x |= (1u<<n)
clear n: x &= ~(1u<<n)
test n: x & (1u<<n)
two’s negate: ~x + 1 (in width n)
int8 range: -128 .. 127
fixed-point: real ≈ q / 2^f
mul same f: (a*b) >> f with wide type
Drill answers
0x3F=0b00111111= 63;0b11001010=0xCA= 202;1000=0x3E8.0xA5=1010 0101→ bit 5 is 1 (value 32).x = (uint8_t)(1u << 5);→0x20.- −1 =
0xFF; −128 =0x80; +127 =0x7F; −128 has no positive partner in int8. - 3.25 →
3.25*256 = 832(0x0340); −1.0 →−256(0xFF00); product832*(−256)then>> 8→ −832 → −3.25. mv = 2048 * 3300 / 4095≈ 1649 mV (about mid-scale for 0…3.3 V unipolar).
Closing
Number systems literacy is not trivia for interviews—it is how you speak the machine’s language. Binary is weight. Hex is binary in groups of four. Masks are how datasheets become C. Two’s complement is how signed math shares an adder. Fixed-point is how real units survive without an FPU story.
How-to loop: width → signedness → units → convert with a wide intermediate → prove ranges → print hex and decimal when debugging.
Practice the drills until conversion is muscle memory; the next wrong bit in a status register will take minutes, not days.
Was this page helpful?