Bit Fields and Flags

Binary File Formats in Practice

Chapter 7 ยท Bit Fields and Flags

Every field so far has been a whole number of bytes. Real formats are thriftier: a single byte often carries several yes/no answers, or a small number squeezed in beside them, because a header that is read millions of times is worth keeping short. This chapter opens up the byte. It covers the bitwise operators, the standard recipes for testing, setting, clearing and extracting bits, and then reads two real packed fields. It builds on Binary File Formats in Practice 2 and Binary File Formats in Practice 4.

What was run
Every snippet and solution in this chapter was executed, and the output shown in the comments is what Python printed. Everything is done in memory, so nothing is left behind.

Bits Are Numbered From the Right

A byte has eight bits. Bit 0 is the least significant, the rightmost one, worth 1; bit 7 is the leftmost, worth 128. Format documents nearly always number bits this way, so "bit 3" means the value 8 (1 << 3). Do not confuse this with byte order from Binary File Formats in Practice 2: endianness is about the order of whole bytes in a larger number, while bit numbering inside one byte is fixed by place value.

The Operators

OperatorNameEffect on each bit
a & bAND1 only where both are 1: keeps bits
a | bOR1 where either is 1: turns bits on
a ^ bXOR1 where they differ: flips bits
~aNOTFlips every bit
a << nShift leftMoves bits up n places (multiplies by 2n)
a >> nShift rightMoves bits down n places, dropping the low ones
b = 0b10110010 print(bin(b), b) # 0b10110010 178 print(bin(b & 0b00001111)) # 0b10 (low four bits kept) print(bin(b | 0b00000001)) # 0b10110011 (bit 0 turned on) print(bin(b ^ 0b11111111)) # 0b1001101 (every bit flipped) print(bin(b >> 4), bin(1 << 3)) # 0b1011 0b1000

bin() drops leading zeros, which is why 0b00001111 masked with 178 prints as 0b10. The value is the same; only the display is shorter. Use format(n, "08b") when you want all eight digits.

A Trap: NOT in Python

In C, ~ on a byte stays within a byte. Python integers have no fixed width, so ~b is defined as -b - 1, a negative number. To get the byte you meant, mask it back down:

print(~b) # -179 print(~b & 0xFF) # 77

This matters mostly when clearing a bit: flags & ~MASK works in Python without the extra & 0xFF, because a negative number behaves as if it had endless leading 1 bits and the AND with a small positive number gives the right small answer. It is only when you store or print a bare ~x that the negative number surprises you.

Flags: Test, Set, Clear, Toggle

A flags byte gives each bit its own meaning. Name each bit as a one-bit mask, then four recipes cover everything:

READONLY, HIDDEN, COMPRESSED, ENCRYPTED = 1 << 0, 1 << 1, 1 << 2, 1 << 3 f = 0 f |= READONLY | COMPRESSED # set two flags at once print(bin(f), bool(f & HIDDEN), bool(f & COMPRESSED)) # 0b101 False True f &= ~READONLY # clear one print(bin(f)) # 0b100 f ^= HIDDEN # toggle: on print(bin(f)) # 0b110 f ^= HIDDEN # toggle: off again print(bin(f)) # 0b100
GoalRecipeWhy it works
Testf & MASKNon-zero only if that bit is on
Setf | MASKOR can only turn bits on
Clearf & ~MASKThe inverted mask keeps everything except that bit
Togglef ^ MASKXOR with 1 flips, XOR with 0 leaves alone

Fields Wider Than One Bit

A bit field may be several bits wide: a 2-bit version number, a 4-bit length. To read one, shift it down to bit 0, then mask to its width. A mask of width w is (1 << w) - 1: width 2 gives 0b11, width 4 gives 0b1111. To write one, clear its old bits and OR in the new value shifted up:

def get_field(value, shift, width): return (value >> shift) & ((1 << width) - 1) def set_field(value, shift, width, new): mask = ((1 << width) - 1) << shift if new >> width: raise ValueError("value does not fit") return (value & ~mask) | (new << shift) v = 0b00101101 print(get_field(v, 4, 2), get_field(v, 0, 4)) # 2 13 w = set_field(v, 4, 2, 3) print(bin(w), get_field(w, 4, 2)) # 0b111101 3 try: set_field(v, 4, 2, 4) except ValueError as e: print(e) # value does not fit

The new >> width check refuses a value too big for the field. Without it, the extra bit would silently spill into the neighbouring field, which is the classic bug with packed data. Reading is forgiving, since the mask trims anything extra; writing needs the guard.

A Real Flags Byte: gzip

The gzip file format starts with a 10-byte header whose fourth byte, FLG, is a flags byte. Per the gzip specification (RFC 1952) (check): bit 0 is a text hint, bit 1 a header checksum, bit 2 an extra-data field, bit 3 a stored file name and bit 4 a comment; bits 5 to 7 are reserved and must be zero. Python's gzip module will make a sample for us in memory:

import gzip, io buf = io.BytesIO() with gzip.GzipFile(filename="notes.txt", fileobj=buf, mode="wb", mtime=0) as g: g.write(b"hello") raw = buf.getvalue() print(raw[:10].hex(" ")) # 1f 8b 08 08 00 00 00 00 02 ff print(raw[3], bin(raw[3])) # 8 0b1000 print(bool(raw[3] & 8), bool(raw[3] & 4)) # True False

1f 8b is the gzip magic number from Binary File Formats in Practice 4, 08 the compression method (deflate) and the next 08 is FLG: only bit 3 is on, so a file name follows the header. The last two bytes of the header (02 and ff) are extra flags and an operating-system code that this chapter does not need (check what they mean in the specification before relying on them).

Naming Bits With IntFlag

Hand-written masks are error-prone. The standard library's enum.IntFlag gives every bit a name and still behaves as an integer, so it reads and writes straight into a struct:

import enum class GzFlag(enum.IntFlag): TEXT = 1 HCRC = 2 EXTRA = 4 NAME = 8 COMMENT = 16 fl = GzFlag(raw[3]) print(int(fl), GzFlag.NAME in fl, GzFlag.EXTRA in fl) # 8 True False print(int(GzFlag.NAME | GzFlag.TEXT)) # 9

The text shown when you print an IntFlag differs between Python versions (check), so this chapter prints plain integers. Note too that older versions of IntFlag accept unknown bits silently, while newer ones may raise or keep them depending on the class's boundary setting (check), so a format reader should still test the reserved bits itself.

A Packed 16-Bit Field: the DOS Date

Some formats pack a whole date into two bytes. The classic MS-DOS layout, still used by the ZIP format for file times (check), gives the year 7 bits, the month 4 and the day 5:

BitsWidthField
15 to 97Year, counted from 1980
8 to 54Month, 1 to 12
4 to 05Day, 1 to 31
import struct dd = ((2026 - 1980) << 9) | (11 << 5) | 2 print(dd, hex(dd), bin(dd)) # 23906 0x5d62 0b101110101100010 print((dd >> 9) + 1980, (dd >> 5) & 15, dd & 31) # 2026 11 2 print(struct.pack("<H", dd).hex(" ")) # 62 5d

The whole date is one 16-bit integer, then stored little-endian like any other: bit packing decides the value, and the earlier chapters' byte-order rules decide how that value sits in the file. Reading is the reverse: struct.unpack("<H", ...) first, then shifts and masks. Because the year has only 7 bits, the format cannot represent a date after 2107 (1980 + 127).

IdeaByte-sized fieldsBit fields
Smallest unit1 byte, read with struct1 bit, read with shifts and masks
Order questionEndianness (Binary File Formats in Practice 2)Bit 0 is the least significant; fixed
Writing riskWrong size or signA value too big for its width spills into a neighbour
ValidationCheck lengthsCheck reserved bits are zero

Reserved Bits

Formats usually mark some bits "reserved, must be zero". A strict reader rejects a byte with those bits set; a lenient one ignores them. The choice has consequences: rejecting means an old reader refuses files from a newer writer that started using the bit, while ignoring means a corrupted byte can slip through unnoticed. This course's readers are strict, and say so in an error message that includes the offending bits.

Challenges

Exercise 1

Define four flags (READONLY, HIDDEN, COMPRESSED, ENCRYPTED) in bits 0 to 3 of a byte, with bits 4 to 7 reserved. Write decode_flags(byte) returning the list of names that are set, and encode_flags(names) returning the byte. Reject a byte with reserved bits set (including the bits in the message) and an unknown flag name. Show an encode and decode, the empty case, both failures, and prove that decoding then encoding returns the original for every value from 0 to 15.

๐Ÿ“„ View solution
Exercise 2

Write pack_date/unpack_date and pack_time/unpack_time for the DOS layouts (time: hour in bits 15 to 11, minute in bits 10 to 5, seconds divided by 2 in bits 4 to 0). Reject out-of-range values. Show 2026-11-02 and 14:30:45 packed as hex and as little-endian bytes, show that the seconds lose their odd digit on a round trip, show three rejections, and round-trip a spread of dates.

๐Ÿ“„ View solution
Exercise 3

Write read_header(raw) for the gzip header: check the magic number, the method (8) and the reserved bits of FLG, return the list of set flag names and the file name when the name flag is set (a zero-terminated string after the 10-byte header). Refuse the extra-data flag as not handled. Build files with gzip.GzipFile with and without a name, show both results, show five failures (magic, short, reserved, unterminated name, extra flag), and confirm gzip.decompress still works on the named file.

๐Ÿ“„ View solution

Quick Reference

  • Bit numbering: bit 0 is the least significant (value 1); bit n has value 1 << n
  • Test / set / clear / toggle: f & M, f | M, f & ~M, f ^ M
  • Read a field: (v >> shift) & ((1 << width) - 1)
  • Write a field: clear with v & ~mask, then OR in new << shift; reject values that do not fit
  • NOT: ~x is negative in Python; mask with & 0xFF to get a byte
  • Multi-byte fields: unpack the integer with struct first, then shift and mask
  • Reserved bits: decide on purpose whether to reject or ignore them
  • IntFlag: names for bits; print integers to avoid version differences
Coming next
Binary File Formats in Practice 8 moves to records whose size is not fixed in advance: length prefixes, type-length-value layouts, and records nested inside records.