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:
| Binary | Hex | Decimal |
|---|---|---|
| 0000 | 0 | 0 |
| 0001 | 1 | 1 |
| 0010 | 2 | 2 |
| 0011 | 3 | 3 |
| 0100 | 4 | 4 |
| 0101 | 5 | 5 |
| 0110 | 6 | 6 |
| 0111 | 7 | 7 |
| 1000 | 8 | 8 |
| 1001 | 9 | 9 |
| 1010 | A | 10 |
| 1011 | B | 11 |
| 1100 | C | 12 |
| 1101 | D | 13 |
| 1110 | E | 14 |
| 1111 | F | 15 |
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:
0x4020000E → 0100 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):
- Write the magnitude as unsigned binary.
- Invert all bits (flip 0↔1).
- 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:
| Width | Unsigned range | Signed (two's complement) range |
|---|---|---|
| 8-bit | 0 to 255 | -128 to +127 |
| 16-bit | 0 to 65535 | -32768 to +32767 |
| 32-bit | 0 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-bitint8_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:
| Operator | Name | Effect |
|---|---|---|
& | AND | 1 if both bits are 1; 0 otherwise |
| | OR | 1 if either bit is 1; 0 if both are 0 |
^ | XOR | 1 if bits differ; 0 if bits are equal |
~ | NOT (complement) | Flips all bits |
<< | Left shift | Shifts bits left; fills with 0 from right; equivalent to multiply by 2 per shift |
>> | Right shift | Shifts 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
ULsuffix for 32-bit constants.(1 << 31)is undefined behaviour in C ifintis 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, useULL. - 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 ofxare 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 provideRCC_AHB1ENR_GPIOAENwhich 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
countisint32_t(signed) andmaxisuint32_t(unsigned), the comparisoncount < maxpromotescountto unsigned — ifcountis 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 whenintorunsigned intis 32 bits. The result is not 0; it is undefined. Use(uint64_t)1 << 32for 64-bit masks. - Using
intinstead ofuint32_tfor registers. STM32 and other Cortex-M peripheral registers are 32 bits. Usingintto 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. Useuint32_texplicitly. - Forgetting that
~operates on the promoted type.~(uint8_t)0x0Fexpands0x0Ftoint(32-bit signed), then complements it, giving0xFFFFFFF0— not0xF0. 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
What Are Logic Gates and How Do They Work?
Logic gates are the building blocks of digital circuits. AND, OR, NOT, NAND, NOR, XOR: truth tables, Boolean algebra, CMOS implementation, and universal gates.
What Is the Difference Between Combinational and Sequential Logic?
How flip-flops differ from logic gates: sequential vs combinational logic, D flip-flop setup and hold time, registers, and clock domain crossing synchronisers.
What Is an FPGA and How Does It Work?
What is an FPGA, how do LUTs implement any logic function, when to choose FPGA vs MCU vs ASIC, and the basics of Verilog and VHDL for digital design.
How Do GPIO Pins Work on a Microcontroller?
GPIO (General Purpose Input/Output) pins let a microcontroller read digital signals and drive outputs. Learn how push-pull, open-drain, and pull resistors work.
How Does the Memory Map Work in an Embedded Microcontroller?
The Cortex-M memory map assigns flash, RAM, and peripherals to fixed address regions. Covers STM32 layout, volatile keyword, and how linker scripts map to it.
PWM Explained: Frequency, Duty Cycle, Dead-Time, and Hardware Timers
Learn how PWM works: duty cycle, frequency, resolution, dead-time for H-bridge drives, and when to use hardware timers versus software PWM on microcontrollers.