Bits, Bytes and Data Formats

Binary and hex, bit masks, two’s complement, byte order, packing fields, text encodings and reading binary data.

Binary, hexadecimal and decimal

A bit is 0 or 1; a byte is 8 bits (values 0 to 255, or 0x00 to 0xFF). Hexadecimal writes four bits per digit (0–9, A–F), so one byte is two hex digits and a 32-bit word is eight: 1011 0010 is 0xB2. Hardware registers, protocols, memory dumps and file formats are described this way because hex converts to bits digit by digit, which decimal does not.

DecimalHexBinaryOctal
100xA0000 101012
1780xB21011 0010262
2550xFF1111 1111377
10000x3E811111010001750
46600x1234100100011010011064
  • Place values: binary digits are worth 128, 64, 32, 16, 8, 4, 2, 1. 1011 0010 = 128 + 32 + 16 + 2 = 178. Hex digits are worth 16n: 0xB2 = 11 × 16 + 2 = 178.
  • Prefixes: 0x for hex and 0b for binary in C (C23), C++14 and Python; a leading 0 means octal in C (so 010 is 8, a classic trap).
  • Powers of two to know: 28 = 256, 210 = 1 024, 216 = 65 536, 220 = 1 048 576, 232 = 4 294 967 296.
  • Kilo, mega, giga: a kilobyte is 1 000 bytes (kB) and a kibibyte is 1 024 (KiB); memory sizes use the binary meaning, disk and data rates the decimal one.

Bit operations and masks

OperationC / PythonEffectCommon use
ANDa & b1 only where both bits are 1Test a bit; clear bits (with an inverted mask)
ORa | b1 where either bit is 1Set bits
XORa ^ b1 where the bits differToggle bits; simple checksums; swapping
NOT~aFlips every bitBuild an inverted mask (in Python it gives a negative number: mask the result)
Shift lefta << nMoves bits up n places (multiplies by 2ⁿ)Build a mask: 1 << n; pack fields
Shift righta >> nMoves bits down n places (divides by 2ⁿ, rounding down)Extract a field
76543210bitOriginal101100100xB2Set bit 2x | 0x04101101100xB6Clear bit 4x & ~0x10101000100xA2Toggle bit 7x ^ 0x80001100100x32The changed bit is outlined. Test a bit with (x & 0x02) != 0: for 0xB2 this is true, because bit 1 is set.
Setting, clearing and toggling single bits of 0xB2 (binary 1011 0010) with a mask.
#define BIT(n)  (1u << (n))

reg |=  BIT(2);            /* set bit 2    */
reg &= ~BIT(4);            /* clear bit 4  */
reg ^=  BIT(7);            /* toggle bit 7 */
if (reg & BIT(1)) { }      /* test bit 1   */

/* extract a 3-bit field that starts at bit 4 */
uint8_t field = (reg >> 4) & 0x07;

Worked example: decoding a status register

  • A device returns the status byte 0xB2 = 1011 0010. Bits set (counting from 0 at the right): 1, 4, 5, 7. So the bit tests reg & BIT(1) is true and reg & BIT(2) is false.
  • A 3-bit field at bits 6 to 4 is (0xB2 >> 4) & 0x07 = 0000 1011 & 0000 0111 = 3.
  • To write a value into the field, clear it with its mask and then OR in the shifted value: reg = (reg & ~MASK) | ((value << 4) & MASK) with MASK = 0x07 << 4 = 0x70. Writing 5 gives (0xB2 & ~0x70) | (5 << 4) = 0xD2.
  • The number of 1 bits (the population count) of 0xB2 is 4; many chips have an instruction for it (__builtin_popcount in GCC, int.bit_count() in Python 3.10).
  • Shifts as multiplication: 13 << 2 = 52 (× 4); shifting right is division by a power of two that rounds towards minus infinity for negative numbers: -20 >> 2 = -5. (In C, right-shifting a negative signed value is implementation-defined; in Python and Java it is arithmetic.)

For masks and register fields on a real chip see the Bitwise and Register Field Calculator.

Register fields and packing data

Hardware registers and compact protocols put several small values in one word. The recipe is always: mask to isolate bits, shift to move them, OR to combine.

Worked example: a 12-bit reading and a 4-bit status in 16 bits

  • Pack: reading 0xA5C (2 652) and status 0x9: word = (status << 12) | (reading & 0xFFF) = 0x9000 | 0xA5C = 0x9A5C.
  • Unpack: reading = word & 0xFFF = 0xA5C, status = word >> 12 = 0x9.
  • Always mask before you shift in, or a value that is too wide overwrites its neighbor. Use named constants for positions and widths (STATUS_SHIFT, STATUS_MASK), not bare numbers.
def pack_word(reading, status):
    return ((status & 0xF) << 12) | (reading & 0xFFF)

def unpack_word(word):
    return word & 0xFFF, (word >> 12) & 0xF       # (reading, status)

assert unpack_word(pack_word(0xA5C, 0x9)) == (0xA5C, 0x9)

Two’s complement and sign extension

One set of 256 bit patterns, two readings0x000 / 00xFF255 / −10x4064 / 640x7F127 / 1270x80128 / −1280xC0192 / −64unsigned / signedgoing clockwise adds 1To negate a value: invert all bits and add 1.50000 0101invert1111 1010+ 11111 1011 = −5127 + 1 = 128 overflows to −128 as a signed byte,and 255 + 1 = 0 as an unsigned byte.
The same 256 bit patterns read as unsigned (0 to 255) or as signed two’s complement (−128 to 127).

Signed integers are stored in two’s complement: the top bit has a negative weight. To negate a number invert all bits and add 1. This makes addition and subtraction work identically for signed and unsigned values, which is why every processor uses it. An n-bit signed number runs from −2n−1 to 2n−1 − 1: for 16 bits -32 768 to 32 767.

Worked example: sign-extending a 12-bit ADC reading

A 12-bit converter returns two’s complement values in the low 12 bits of a 16-bit word. If bit 11 is set, the number is negative, so subtract 212 = 4 096:

  • 0xFFE: bit 11 is set, so the value is 4 094 − 4 096 = -2.
  • 0x7FF: bit 11 is clear: value 2 047 (the largest positive value, 211 − 1).
  • 0x800: bit 11 set: 2 048 − 4 096 = -2 048 (the most negative value, −211).
def sign_extend(value, bits):
    sign = 1 << (bits - 1)
    return (value & (sign - 1)) - (value & sign)     # no branch needed

sign_extend(0xFFE, 12)        # -2
sign_extend(0x7FF, 12)        # 2047
  • Casting to a wider signed type extends the sign automatically; extracting a short field from a word does not, which is why you do it by hand.
  • Offset binary (used by many ADCs and DACs) stores 0 as the middle value (2048 for 12 bits); subtract the offset to get a signed number.
  • The most negative number has no positive partner: −(−128) overflows in 8 bits.

Byte order (endianness)

A multi-byte number is stored as bytes in some order. Big-endian puts the most significant byte first (the order of network protocols and of Modbus registers); little-endian puts the least significant byte first (x86, ARM Cortex-M in its usual setting, and most microcontrollers).

Worked example: two bytes, then a float

  • Two bytes arrive: 0x12 then 0x34. If the device sends the high byte first the 16-bit value is 0x1234 = 4 660. If it sends the low byte first the value is 0x3412 = 13 330. In code: value = (high << 8) | low.
  • The 32-bit float 123.456 is, in IEEE 754 big-endian, the bytes 42 F6 E9 79; in little-endian they are 79 E9 F6 42.
  • Modbus registers are 16-bit big-endian words, but a 32-bit value spread over two registers may come in any of four orders depending on the device: ABCD (42 F6 E9 79), CDAB (E9 79 42 F6, the “word swap” many PLCs use), DCBA (79 E9 F6 42) and BADC (F6 42 79 E9). Read the device manual, and if it does not say, try each order and see which gives 123.456-like values rather than 1038 or 10−30. (See Industrial Communication and the Modbus Frame and CRC Calculator.)
  • A float’s bit pattern is a 32-bit integer: 0.15625 is 0x3E200000 (sign 0, exponent 124, mantissa 0.25; see Numbers and Floating Point).
import struct
raw = bytes([0x42, 0xF6, 0xE9, 0x79])
struct.unpack(">f", raw)[0]        # big-endian float: 123.456...
struct.unpack("<f", raw)[0]        # the same bytes read little-endian: a different number
int.from_bytes(b"\x12\x34", "big")      # 4660
int.from_bytes(b"\x12\x34", "little")   # 13330

Other codes: BCD, Gray, parity and checksums

  • BCD (binary-coded decimal) stores each decimal digit in four bits: 59 is 0101 1001 = 0x59. Real-time-clock chips and some displays use it; convert with (v >> 4) * 10 + (v & 0x0F).
  • Gray code changes only one bit between neighboring values, so a rotary or absolute encoder never reads a wildly wrong value while it is between two positions: gray = n ^ (n >> 1).
  • Parity adds one bit so that the number of 1s is even (or odd): it detects any single-bit error.
  • Simple checksums (XOR or sum of all bytes, or the two’s complement “LRC” of Modbus ASCII) are cheap but weak: see the comparison with CRCs in Embedded Systems.
nBinaryGray code
0000000
1001001
2010011
3011010
4100110
5101111
6110101
7111100

For the bytes 01 03 00 00 00 02 (a Modbus request) the XOR checksum is 0x00, the sum modulo 256 is 0x06, and the Modbus ASCII LRC (two’s complement of the sum) is 0xFA; the 16-bit CRC used by Modbus RTU (0x0BC4, sent as C4 0B) catches far more errors.

Text: ASCII and UTF-8

ASCII assigns the numbers 0 to 127 to letters, digits, punctuation and control codes: “A” is 0x41, “a” is 0x61 (lower case is upper case + 0x20, one bit), “0” is 0x30 (so a digit character minus 0x30 is its value), newline is 0x0A and carriage return 0x0D. UTF-8 encodes every Unicode character in one to four bytes, keeping ASCII unchanged, and is the default for files, the web and Python 3.

CharacterCode pointBytes in UTF-8Bytes (hex)
AU+0041141
éU+00E92C3 A9
€U+20AC3E2 82 AC
°U+00B02C2 B0
😀U+1F6004F0 9F 98 80
  • Text is bytes plus an encoding. Reading a UTF-8 file as Latin-1 turns “°C” into “°C”. Always say the encoding when you open or decode (open(path, encoding="utf-8"), raw.decode("ascii", errors="replace")).
  • The length of a string in bytes is not its length in characters (“°C” is 2 characters and 3 bytes). Fixed-size protocol fields count bytes.
  • Line endings: Windows uses CR LF (0x0D 0x0A), Unix LF; many instruments end replies with CR, LF or both, so strip them (strip()).
  • Instruments and serial devices are usually plain ASCII; use decode("ascii") and treat anything else as an error.
  • Base64 and hex encodings carry binary data through text channels (JSON, e-mail, URLs): hex doubles the size, Base64 increases it by a third.

Reading and writing binary data

A binary file or message is a defined sequence of fields. Read it with the exact layout from the specification: field sizes, signedness, byte order and any padding. Python’s struct module and C’s explicit shifts do this.

struct codeTypeSize
B, bunsigned / signed byte1
H, hunsigned / signed 16-bit2
I, iunsigned / signed 32-bit4
Q, qunsigned / signed 64-bit8
f, d32-bit / 64-bit float4 / 8
< or > firstlittle / big-endian, no padding

Worked example: parsing a header

A device sends a 12-byte header: a 16-bit marker, a 16-bit message type, a 32-bit sample count and a 32-bit float scale, all little-endian: FE CA 03 00 E8 03 00 00 00 00 C0 3F. The format string "<HHIf" unpacks it: marker 0xCAFE, type 3, count 1 000, scale 1.5. The first check of any parser is the marker (and a length or checksum) before it trusts the rest.

import struct
HEADER = struct.Struct("<HHIf")                 # 12 bytes, little-endian, no padding
marker, kind, count, scale = HEADER.unpack_from(data, 0)
if marker != 0xCAFE:
    raise ValueError("bad marker: not a frame start")

/* C: do not cast a byte buffer to a struct; read each field explicitly */
uint16_t marker = (uint16_t)(p[0] | (p[1] << 8));
uint32_t count  = (uint32_t)p[4] | ((uint32_t)p[5] << 8) | ((uint32_t)p[6] << 16) | ((uint32_t)p[7] << 24);
  • Look at a hex dump when a format is unclear: xxd file.bin | head, hexdump -C, or a hex editor; the first bytes usually give a signature.
  • Validate: check lengths against the data you have before you read (a corrupt length field must not make you read past the end), and check a CRC.
  • Stream parsers must cope with partial frames: keep a buffer, search for the start marker, and wait for the rest (see Embedded Systems: serial buses).
  • NumPy reads arrays of binary values quickly: np.fromfile(path, dtype="<i2") for little-endian 16-bit integers; np.frombuffer for bytes in memory.

Bit-level pitfalls

PitfallWhat happensFix
Operator precedencea & b == c compares first: b == c is evaluated before the ANDParenthesize: (a & b) == c
Shifting too far1 << 32 on a 32-bit int is undefinedUse 1u, 1ULL for 64-bit masks, and keep shifts below the width
Signed shiftsRight-shifting a negative value, or left-shifting into the sign bit, is implementation-defined or undefinedDo bit work on unsigned types
~ promotes~(uint8_t)0x0F becomes a negative intCast back: (uint8_t)~x
Bit fields in structsLayout and order differ between compilersUse masks and shifts
Wrong byte orderValues look plausible but are scrambledCheck the specification; test with a known value
Octal by accidentA leading zero (010) is octal in CWrite 10, or 0x0A
Reading a register twiceA status read can clear flags on some hardwareRead once into a variable, then test the variable

Related

FAQ

Why is my sensor reading wrong by a large factor?

Most often byte order (high and low bytes swapped), a sign that was not extended, or a field that sits in only some of the bits. Print the raw bytes in hex and compare with the datasheet’s example.

How do I set one bit without changing the others?

OR with a mask to set (reg |= 1 << n), AND with an inverted mask to clear (reg &= ~(1 << n)), XOR to toggle. Never assign the whole register unless you mean to.

What is the difference between & and &&?

& is bitwise AND of the bits of two numbers; && is the logical AND of two truth values and stops early. Mixing them up compiles but gives wrong results.

Why does the same number have different bytes on two computers?

They use different byte orders. Network protocols and file formats define one, and code that reads or writes them must convert explicitly rather than copy memory.

References

  • Warren, H. S., Hacker’s Delight, Addison-Wesley (bit tricks and arithmetic).
  • IEEE 754-2019; Unicode Standard and RFC 3629 (UTF-8); Python documentation for struct and int.from_bytes.
  • Microcontroller reference manuals (register layouts); Modbus Organization, Modbus Application Protocol Specification (register and byte order).

Last reviewed: October 2026. Examples use common formats: always check the specification of your own device.