Skip to content
MINH VO A working notebook
by an engineer in Vietnam
Foundation7 min read

Read and replace a bit field without losing nearby bits

Replace a three-bit field in an eight-bit word, verify unaffected bits, and see why device registers need a separate access contract.

Illustrated circuit board resting on drafting paper with faint technical sketches

Replacing a bit field takes two operations: clear the field’s old bits, then insert the new value at the same position. OR alone cannot clear a bit that was already one. For a programmer reading packed data, the safest starting point is an ordinary unsigned software word with an explicit width and a checked field value.

Suppose an eight-bit word contains a three-bit mode at positions two through four. Positions are numbered from zero at the least significant bit. The field’s unshifted mask is binary 111, or seven. Shifting that mask left by two produces 00011100, or 0x1C.

Replace one field by hand

Take the original word 0xAB, whose bits are 10101011. The mode occupies bits four, three, and two, so its current value is binary 010, or two. Extract it with (word >> 2) & 7.

To replace the mode with five, first preserve everything outside the field. XOR the field mask with the eight-bit all-ones mask to obtain 11100011, or 0xE3. AND that with the original word. The intermediate result is 10100011, or 0xA3. Shift five, binary 101, left by two to get 00010100. OR the two pieces to produce 10110111, or 0xB7.

The upper three bits and lower two bits are unchanged. Only positions two through four are allowed to differ. This is an invariant you can check separately from whether the new mode was encoded correctly.

An eight-bit word clears positions two through four before inserting mode five, changing 0xAB to 0xB7View full-size image ↗

A direct OR would produce 0xBF for this input. The old mode had a one where the new mode requires zero, so OR would retain that bit and encode seven instead of five. The mistake disappears for some initial values, which is why one successful example is weak evidence.

Check both promises

A field update promises that extraction returns the requested value and that all other bits retain their previous values. Exhaustive testing is small here: 256 original words multiplied by eight possible mode values gives 2,048 cases.

WIDTH_MASK = 0xff
SHIFT = 2
FIELD_MASK = 0b111 << SHIFT
OUTSIDE_MASK = WIDTH_MASK ^ FIELD_MASK

def replace_mode(word, mode):
    if not 0 <= word <= WIDTH_MASK:
        raise ValueError("word must fit in eight bits")
    if not 0 <= mode <= 7:
        raise ValueError("mode must fit in three bits")
    return (word & OUTSIDE_MASK) | (mode << SHIFT)

for word in range(256):
    for mode in range(8):
        result = replace_mode(word, mode)
        assert ((result >> SHIFT) & 7) == mode
        assert (result & OUTSIDE_MASK) == (word & OUTSIDE_MASK)
print("2048 field replacements passed")

Rejecting an out-of-range mode is a deliberate interface choice. Masking the mode with seven would instead reduce it modulo eight. For example, nine would silently become one. That can be appropriate when a protocol explicitly defines truncation, but it is usually unhelpful when the caller believes it supplied a valid enum value.

The word check also prevents accidental negative inputs from inheriting Python’s unlimited signed bitwise behavior. In C, use unsigned operands and ensure each shift count is smaller than the promoted operand width. An expression that appears to work with one compiler can still violate language rules if its shift or intermediate signed value is invalid. Section 6.5.7 of the C11 committee draft N1570 specifies the promoted operand and shift-count requirements.

Logical layout and byte order are separate

The field definition above concerns bit significance within an integer. Endianness determines how a multi-byte integer maps to byte addresses. A field at bit position 12 does not become a different mathematical field when the same integer is stored in little-endian memory; its byte location changes.

For a network packet, decode bytes according to the protocol’s byte order before applying integer masks, or address the specified bytes directly. Avoid assuming that a C bit-field structure has the wire layout you need. Allocation order, padding, and representation details can depend on the implementation and ABI. Explicit masks and byte assembly make the agreement inspectable.

The RV32I load, store, and logical instruction definitions show the lower-level operations a compiler can use for this kind of work. They describe register widths and memory accesses, while a packet specification supplies the actual field layout. Neither source can substitute for the other.

Generalize the field without hiding invalid inputs

For a word with N bits, a field of width W beginning at shift S must satisfy 1 <= W <= N, 0 <= S, and S + W <= N. These are structural requirements. A field that extends past the word boundary cannot be repaired by silently truncating the final value without changing the interface.

The unshifted field mask contains W low ones. Shift it left by S, clear that region in the word, and insert a value constrained to the range zero through 2**W - 1. The following arbitrary-precision Python helper states those checks before performing the operation.

def replace_field(word, value, total_bits, shift, width):
    if total_bits < 1 or width < 1 or shift < 0 or shift + width > total_bits:
        raise ValueError("field must lie inside the word")
    all_bits = (1 << total_bits) - 1
    value_mask = (1 << width) - 1
    if not 0 <= word <= all_bits or not 0 <= value <= value_mask:
        raise ValueError("word or field value is out of range")
    field_mask = value_mask << shift
    return (word & (all_bits ^ field_mask)) | (value << shift)

assert replace_field(0xabcd, 0x12, 16, 4, 8) == 0xa12d
assert replace_field(0xffff, 0, 16, 0, 16) == 0

The first example crosses a byte boundary. Bits four through eleven of 0xABCD contain 0xBC. Replacing them with 0x12 produces 0xA12D, preserving the high nibble A and low nibble D. If the word is encoded as big-endian bytes, AB CD becomes A1 2D. The logical field calculation happens before that serialization choice.

Python can calculate 1 << 16 without narrowing. A C expression such as 1u << 32 is not a portable way to create a full-width 32-bit mask when unsigned int has 32 bits: shifting by the operand width violates the language’s shift requirements. A fixed-width C helper needs a separate full-width case or a construction whose shifts stay within range. Check intermediate types as well as the final variable declaration.

Useful tests include a one-bit field at each edge, the entire word, the highest legal value, zero, and each rejected boundary. The field’s outside-bit invariant should hold for all accepted cases. A test suite that exercises only a field comfortably inside the word can miss exactly the shift-width case that breaks a generic helper.

Work through a mixed-semantics register

Consider a hypothetical eight-bit device register. Bits zero through three are event flags cleared by writing one. Bits four through six hold an ordinary read/write mode. Bit seven is reserved and must be written as zero. These semantics are invented for the example and are not a specification for any particular board.

Suppose a read returns 0x25. The mode is two, and event flags zero and two are set. A generic read-modify-write update that changes the mode to five produces 0x55: it preserves the low status bits while replacing the mode bits. Writing 0x55 then clears both observed events because it writes one to their write-one-to-clear positions.

The intended mode-only write is 0x50 under this hypothetical contract. Its mode field contains five, its status field contains zeros so those flags are not cleared, and its reserved bit is zero. Arithmetic preservation of old bits was the wrong operation for these status positions.

A hypothetical mixed register read of 0x25 becomes a harmful write of 0x55 under generic read-modify-write; a documented mode-only write of 0x50 leaves write-one-to-clear event flags unchangedView full-size image ↗

The register manual must answer what zeros and ones do for every written field. It may provide dedicated set/clear aliases, require a particular access width, or forbid writes to selected bits. A software helper cannot infer those rules from the bit positions alone. The Linux device-access documentation supports the separate need for proper MMIO accessors and ordering, while the peripheral’s own manual supplies its register-specific write semantics.

Preserve two concurrent updates, not just two local masks

Suppose two threads update disjoint fields in an ordinary shared software byte. Both read zero. Thread A changes the low field to three, producing 0x03. Thread B changes the high field to five, producing 0x50. If B writes last, the final word is 0x50 and A’s update is lost, although both local mask calculations preserved the outside bits of the value each thread originally read.

The combined result should be 0x53 if the application intends both updates to survive. A mutex can protect the whole read-modify-write operation. A compare-and-exchange loop can also retry from an updated observed value where the language and storage object support atomic operations. The retry must recompute the desired word from the newly observed value; repeatedly attempting the original 0x50 does not merge A’s update.

Those mechanisms have their own contracts. A relaxed atomic update can protect the packed word’s arithmetic while providing no publication order for unrelated data. A lock-free implementation can still contend or starve under unsuitable assumptions. Applying a generic CPU compare-and-exchange operation to MMIO is not a substitute for a documented device protocol and may be unsupported.

This gives two distinct failure tests. The W1C example asks whether writing preserved bits changes device state. The two-thread example asks whether another update can occur between the read and write. Passing a field-mask test answers neither question, so record all three layers when a packed value crosses into a device or shared-memory interface.

Sources & further reading

  1. RISC-V unprivileged ISA: RV32I base integer instruction set
  2. Linux kernel: Bus-independent device accesses
  3. ISO C11 committee draft N1570: integer ranges, expressions and fixed-width types
← Back to the journal
All notes

Illustration

100%