Electronics Design AU
Digital

Binary and Hexadecimal: Number Systems in Embedded Electronics

Last updated 28 June 2026 · 8 min read

Direct Answer

Binary (base 2) and hexadecimal (base 16) are the two number systems used throughout embedded electronics. Binary maps directly to digital circuit states (0 = low voltage, 1 = high voltage); hexadecimal compactly represents binary values — one hex digit exactly represents four binary bits, so an 8-bit byte becomes two hex digits. In embedded C, integers are stored in binary inside registers; two's complement is the universal scheme for representing negative integers: flip all bits and add one, giving a symmetric range (e.g. for 8 bits: -128 to +127). Bitwise operations (&, |, ^, ~, <<, >>) manipulate individual bits directly and are essential for peripheral register configuration, where each bit controls a distinct hardware setting.

Detailed Explanation

Decimal is natural for humans but arbitrary from a hardware perspective — ten fingers led to base-10, not any property of electronics. Digital hardware operates on binary (base 2), where each digit is either 0 or 1, directly mapping to a transistor being off or on, a voltage being low or high. Understanding binary and hexadecimal is foundational for reading datasheets, writing register access code, and debugging at the hardware level.

Binary (Base 2)

In binary, each digit position represents a power of 2. The rightmost bit is bit 0 (value 2^0 = 1), and each bit to the left doubles in value:

Binary:   1   0   1   1   0   1   0   0
Bit:      7   6   5   4   3   2   1   0
Value:  128   0  32  16   0   4   0   0  = 180 (decimal)

Converting binary to decimal: add the values of all positions where the bit is 1.
10110100₂ = 128 + 32 + 16 + 4 = 180₁₀

Converting decimal to binary: repeatedly divide by 2 and collect remainders:
180 ÷ 2 = 90 remainder 0
90 ÷ 2 = 45 remainder 0
45 ÷ 2 = 22 remainder 1
22 ÷ 2 = 11 remainder 0
11 ÷ 2 = 5 remainder 1
5 ÷ 2 = 2 remainder 1
2 ÷ 2 = 1 remainder 0
1 ÷ 2 = 0 remainder 1
Reading remainders bottom to top: 10110100₂

Hexadecimal (Base 16)

Hexadecimal uses 16 symbols: 0–9 and A–F (A=10, B=11, C=12, D=13, E=14, F=15). Each hex digit represents exactly four binary bits:

BinaryHexDecimal
000000
000111
001022
001133
010044
010155
011066
011177
100088
100199
1010A10
1011B11
1100C12
1101D13
1110E14
1111F15

Binary to hex: split into 4-bit groups from the right, then look up each group:
10110100₂1011 0100₂B 4₁₆ = 0xB4

Hex to binary: expand each hex digit to 4 bits:
0x4020000E0100 0000 0010 0000 0000 0000 0000 1110₂

This is why hex appears everywhere in embedded work: peripheral register addresses (like 0x40020000 for STM32 GPIOA), bit masks (0xFF, 0x0F), and data values (0xDEAD, 0xCAFEBABE). In C, hex literals are prefixed with 0x; binary literals are prefixed with 0b (available in GCC/Clang C99 extensions, but not standard C99 — use hex for portability).

Two's Complement: Representing Negative Integers

Two's complement is the near-universal scheme for representing signed integers in hardware. For an N-bit two's complement number:

  • If the MSB (most significant bit, bit N-1) is 0: the value is the same as for unsigned.
  • If the MSB is 1: the value is negative.

Computing two's complement (converting negative to binary):

  1. Write the magnitude as unsigned binary.
  2. Invert all bits (flip 0↔1).
  3. Add 1.

Example: -37 in 8-bit two's complement:
37 in binary: 00100101
Invert: 11011010
Add 1: 11011011
So -37 = 0xDB = 11011011₂.

Verify: 11011011₂ as unsigned = 219; 256 - 219 = 37 ✓ (for 8-bit: negative value = 256 - unsigned_value when MSB=1)

Ranges:

WidthUnsigned rangeSigned (two's complement) range
8-bit0 to 255-128 to +127
16-bit0 to 65535-32768 to +32767
32-bit0 to 4,294,967,295-2,147,483,648 to +2,147,483,647

In embedded C: always use <stdint.h> types:

  • uint8_t, uint16_t, uint32_t — unsigned 8/16/32-bit
  • int8_t, int16_t, int32_t — signed 8/16/32-bit (two's complement)

Avoid int and unsigned int in embedded code — their sizes depend on the architecture. On a 32-bit ARM Cortex-M, int is 32 bits; on an AVR, int is 16 bits.

Bitwise Operations in C

Bitwise operations manipulate individual bits of an integer value. They are essential for setting, clearing, and testing bits in hardware registers.

uint32_t reg = 0;

/* Set bit 3 (enable peripheral clock) */
reg |= (1UL << 3);        /* OR with a mask: sets bit 3, leaves others unchanged */

/* Clear bit 5 (disable interrupt) */
reg &= ~(1UL << 5);       /* AND with inverted mask: clears bit 5, leaves others */

/* Toggle bit 7 */
reg ^= (1UL << 7);        /* XOR with mask: flips bit 7 */

/* Test if bit 4 is set */
if (reg & (1UL << 4)) {
    /* bit 4 is 1 */
}

/* Extract bits 7:4 (a 4-bit field) */
uint8_t field = (reg >> 4) & 0x0F;    /* shift right, then mask lower 4 bits */

/* Set bits 7:4 to value 0xA without affecting other bits */
reg = (reg & ~(0x0F << 4)) | ((0x0A & 0x0F) << 4);
/* read-modify-write: clear the field, then OR in the new value */

The six bitwise operators:

OperatorNameEffect
&AND1 if both bits are 1; 0 otherwise
|OR1 if either bit is 1; 0 if both are 0
^XOR1 if bits differ; 0 if bits are equal
~NOT (complement)Flips all bits
<<Left shiftShifts bits left; fills with 0 from right; equivalent to multiply by 2 per shift
>>Right shiftShifts bits right; unsigned: fills with 0 from left (logical); signed: implementation-defined

Practical Example: Setting a GPIO Output in STM32

STM32 GPIO registers are 32-bit wide with specific bit fields. GPIOA ODR (Output Data Register) at 0x40020014: bit N controls pin N. To set pin 5 high:

#define GPIOA_ODR  (*(volatile uint32_t *)0x40020014)

GPIOA_ODR |= (1UL << 5);     /* set pin 5 high (bit 5 = 1) */
GPIOA_ODR &= ~(1UL << 5);    /* set pin 5 low (bit 5 = 0) */

The volatile keyword is essential here — it prevents the compiler from caching the register value and forces a re-read on every access. See how the memory map works in an embedded MCU for why volatile is required for hardware registers.

Design Considerations

  • Use UL suffix for 32-bit constants. (1 << 31) is undefined behaviour in C if int is 32-bit (left shift overflows into the sign bit). Write (1UL << 31) to ensure the constant is unsigned long, which is at least 32 bits. For 64-bit constants, use ULL.
  • Sign extension pitfalls. When assigning a narrower signed value to a wider type (e.g. int32_t x = (int8_t)some_byte), the compiler automatically sign-extends: if the 8-bit value is negative (MSB=1), the upper 24 bits of x are filled with 1s. This is usually correct, but when reading a signed register field from a hardware register, explicit sign extension may be needed if the field is not naturally aligned to a standard type width.
  • Prefer named constants over raw hex literals for registers. Writing RCC->AHB1ENR |= (1UL << 0) is correct but obscure. The CMSIS headers provide RCC_AHB1ENR_GPIOAEN which is self-documenting and validated against the SVD file. Use named constants from vendor headers instead of bare hex.

Common Mistakes

  • Mixing signed and unsigned in comparisons. If count is int32_t (signed) and max is uint32_t (unsigned), the comparison count < max promotes count to unsigned — if count is negative, it becomes a very large unsigned number and the comparison fails. Always compare values of the same signedness, or cast explicitly.
  • Shifting by the word width. (1U << 32) is undefined behaviour in C when int or unsigned int is 32 bits. The result is not 0; it is undefined. Use (uint64_t)1 << 32 for 64-bit masks.
  • Using int instead of uint32_t for registers. STM32 and other Cortex-M peripheral registers are 32 bits. Using int to hold a register value works on 32-bit targets but creates narrowing issues if the code is ever compiled for a 64-bit target or simulator. Use uint32_t explicitly.
  • Forgetting that ~ operates on the promoted type. ~(uint8_t)0x0F expands 0x0F to int (32-bit signed), then complements it, giving 0xFFFFFFF0 — not 0xF0. When masking to 8 bits, cast back: (uint8_t)(~(uint8_t)0x0F) = 0xF0.

Frequently Asked Questions

Why do embedded engineers use hexadecimal instead of decimal?
Decimal (base 10) does not divide cleanly into binary. Hexadecimal is base 16, and 16 = 2^4 — so each hex digit represents exactly four binary bits. This makes conversion between binary and hex trivial: split the binary number into groups of four bits and look up each group's hex digit independently. No arithmetic is needed. Decimal has no such clean relationship to binary. In practice: reading a peripheral register address like 0x40020014 is much cleaner than reading 1073872916, and mapping it to its binary bit pattern is easy. Octal (base 8) has the same advantage for 3-bit groups but is less common in modern embedded work.
What is the difference between signed and unsigned integers in C?
An unsigned integer uses all bits to represent a magnitude (0 to 2^N-1 for N bits). A signed integer uses two's complement, where the most significant bit is the sign bit: when it is 0 the value is positive (same as unsigned); when it is 1 the value is negative (computed as -(2^N - value)). For 8 bits: unsigned range is 0–255; signed range is -128 to +127. Both store the same 256 possible bit patterns — the interpretation changes. In embedded C, always use the explicitly sized types from <stdint.h> (uint8_t, int16_t, uint32_t, etc.) rather than int or unsigned int, whose sizes depend on the compiler and target architecture and can cause subtle portability bugs.
What does right-shifting a signed integer do in C?
The C standard leaves the behaviour of right-shifting a negative signed integer as implementation-defined. In practice, on most embedded ARM toolchains (GCC with arm-none-eabi), right-shifting a negative signed integer performs arithmetic right shift — the sign bit is replicated, so the value is divided by 2 and rounded toward negative infinity. But this is not guaranteed by the standard. For portable code, use unsigned right shift (cast to unsigned type first) when shifting right, or use explicit division. Left-shifting a signed integer is undefined behaviour when the result overflows the type — always left-shift unsigned values to set bits in registers.

References

Related Questions

Related Forum Discussions