logo
NotesNotesResumeResume
© 2026 Kiet Nguyen
← All notes
Atompunk console — binary, hex, two's complement, and fixed-point for embedded

January 22, 2026·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:

  1. Binary and powers of two
  2. Hex as compressed binary
  3. Bit fields, masks, and shifts
  4. Two’s complement signed integers
  5. Fixed-point (Q-format) enough to implement
  6. Practice drills and failure patterns

C and common MCU practice (stdint.h, two’s complement hosts). Prefer explicit widths. This is engineering literacy—not a full computer-arithmetic course, not a claim that every DSP Q-format is covered.

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

ArtifactRepresentation
Memory addressHex (and binary inside the decoder)
GPIO / RCC registerBit fields in a word
Peripheral ID / enumSmall integers; often hex in docs
ADC / DACUnsigned or signed codes
Sensor fusion liteFixed-point or float
CAN payloadRaw bytes; multi-byte endianness
Fault logPacked 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
BitsUnsigned rangeCommon C type
80 … 255uint8_t
160 … 65535uint16_t
320 … 2³²−1uint32_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.

HexBinaryDec
000000
100011
………
910019
A101010
B101111
C110012
D110113
E111014
F111115

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 values 0 … 2^{n-1}−1
  • Bit patterns with MSB = 1 → negative values
  • Range: −2^{n-1} … +2^{n-1}−1
WidthTypeMinMax
8int8_t−128+127
16int16_t−32768+32767
32int32_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:

  1. If MSB is 1, value = unsigned_value − 2ⁿ
  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)

TopicRule of thumb
Prefer stdint.hint16_t not bare int when width matters
Unsigned wrapModular, defined
Signed overflow + - *Undefined behavior in C—don’t rely on wrap
Mixed signed/unsignedUsual arithmetic conversions surprise you—cast explicitly
Right shift on signedImplementation-defined/arithmetic shift often sign-extends—verify
Cast narrow → wide signedSign-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

NeedThinking
ResolutionLSB = 1/2^f in real units
RangeMax real ≈ (2^−1)/2^f for signed width w
HeadroomLeave 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

  1. Width of register? (8/16/32)
  2. Split hex into binary (nibbles).
  3. Mark bit numbers under the bits.
  4. Map set bits to datasheet fields.
  5. Write a one-line C mask that tests the fault you care about.

Recipe B — Pack a command byte

  1. List fields and bit ranges.
  2. For each field: (value & mask) << shift.
  3. OR them; keep type uint8_t/uint16_t.
  4. Unit-test with known vectors (table in the protocol doc).

Recipe C — Sensor to display units without float

  1. State: code range, reference, desired output unit.
  2. Use uint32_t intermediate: out = (code * scale) / div.
  3. Prove no overflow: max code * scale < 2³² (or use 64-bit).
  4. Document resolution and rounding.

Recipe D — Fixed-point gain

  1. Choose f fraction bits.
  2. Convert real gain G → G_q = round(G * 2^f).
  3. Multiply with wide type; shift by f.
  4. Saturate to output type.
  5. 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

SymptomNumber-system story
“Bit 3 didn’t work”Shift off-by-one; MSB/LSB confusion
Magic 0x400 works only on one chipHardcoded address; wrong peripheral base
Negative temperature shows as 65000Printed as unsigned
Filter explodes after gain changeFixed-point overflow; no wide intermediate
ADC “volts” jump in stepsTruncation: divided before multiply
Portability bugRelied 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

  1. 0x3F = 0b00111111 = 63; 0b11001010 = 0xCA = 202; 1000 = 0x3E8.
  2. 0xA5 = 1010 0101 → bit 5 is 1 (value 32). x = (uint8_t)(1u << 5); → 0x20.
  3. −1 = 0xFF; −128 = 0x80; +127 = 0x7F; −128 has no positive partner in int8.
  4. 3.25 → 3.25*256 = 832 (0x0340); −1.0 → −256 (0xFF00); product 832*(−256) then >> 8 → −832 → −3.25.
  5. 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.

Back to notes

Was this page helpful?