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

Why LB and LBU produce different register values

Trace LB and LBU on byte 0xE2, verify all extension cases, and connect assembly signedness to parser and language type choices.

Illustrated circuit board resting on drafting paper with faint technical sketches

The RISC-V instructions LB and LBU read the same byte but extend it differently when placing it in a wider register. LB sign-extends the byte; LBU zero-extends it. If you are debugging a parser or an integer conversion, that difference explains how memory byte 0xE2 can become either negative 30 or positive 226.

Use RV32I for the first calculation. Its integer registers are 32 bits wide, while the load reads eight bits from memory. Assume the effective address is accessible and the memory access succeeds. The RV32I load and store specification defines the extension rules independently of the byte’s application meaning.

Expand one byte in two ways

The byte 0xE2 is binary 11100010. Read as an unsigned eight-bit integer, it is 226. Read as two’s complement signed eight-bit data, it is 226 - 256, or negative 30. The top bit determines which extension preserves each interpretation.

LBU fills the upper 24 register bits with zeros. The resulting register pattern is 0x000000E2. LB copies the byte’s sign bit, one in this case, into those upper positions. Its result is 0xFFFFFFE2, which represents negative 30 in signed 32-bit two’s complement interpretation.

Memory byteLB result in RV32LBU result in RV32
0x000x000000000x00000000
0x7F0x0000007F0x0000007F
0x800xFFFFFF800x00000080
0xE20xFFFFFFE20x000000E2
0xFF0xFFFFFFFF0x000000FF

The two instructions agree whenever the stored byte’s high bit is zero. That makes an ASCII-only test set poor at exposing an accidental signed load. A parser can seem correct until it sees bytes at or above 0x80.

The byte 11100010 becomes FFFFFFE2 under sign extension and 000000E2 under zero extension in a 32-bit registerView full-size image ↗

Preserve the value across widths

Sign extension works because adding leading ones preserves the two’s complement negative value. For an eight-bit pattern b with its high bit set, the mathematical value is b - 256. Representing that same integer modulo 2 to the power of 32 yields the 32-bit pattern produced by LB.

Zero extension preserves the unsigned value directly. It simply embeds the byte into a wider field without introducing any higher set bits. The following Python model checks all possible bytes and explicitly separates a signed mathematical value from its stored register bits.

for byte in range(256):
    signed_value = byte if byte < 128 else byte - 256
    lb_bits = signed_value & 0xffffffff
    lbu_bits = byte
    decoded_lb = lb_bits if lb_bits < 0x80000000 else lb_bits - 2**32
    assert decoded_lb == signed_value
    assert 0 <= lbu_bits <= 255
assert ((0xe2 - 256) & 0xffffffff) == 0xffffffe2
print("256 extension cases passed")

This is an arithmetic model of the documented instruction behavior. It does not emulate page faults, device accesses, or processor timing. Those details belong to the environment in which a load executes, while the model isolates the result’s width and interpretation.

The address calculation does not choose signedness

Both instructions calculate an effective address from a base register and a sign-extended immediate offset. That address calculation is separate from sign-extending the byte that comes back. A negative offset does not imply a signed data load, and a positive offset does not imply an unsigned one.

For example, lb x5, -1(x10) and lbu x5, -1(x10) refer to the same address, one byte before the value in x10. Their differing mnemonics determine how the byte fills x5. Keeping address arithmetic separate from data interpretation prevents a surprisingly common reading error in assembly.

Endianness does not change a single-byte load’s result because there is only one byte to order. It matters when several bytes form a larger value. A parser that reads one byte at a time can choose signedness explicitly and assemble a larger integer according to the protocol’s byte order.

Follow the programming-language type

In C, plain char may be signed or unsigned depending on the implementation. A byte read through plain char can therefore promote differently on different targets. If a protocol specifies octets, an explicitly unsigned byte representation makes its zero-to-255 domain visible. Signed eight-bit sensor data needs the corresponding signed interpretation.

This distinction becomes important when a byte is used as an array index or widened before shifting. Promoting 0xE2 to negative 30 and then using it as an index is a different operation from using 226. Casting after an earlier invalid index calculation cannot repair the access. Choose the type at the point where external bytes acquire meaning.

Compilers may use LB, LBU, or a wider load followed by shifts and masks, depending on optimization and context. Correct source semantics do not require one particular instruction sequence. Inspect generated code when the instruction choice is the question, but verify the source-level type contract before treating a different sequence as a compiler error.

Trace a bounds check after the load

Suppose a byte selects an entry in a 256-element classification table. Under the unsigned interpretation, 0xE2 is index 226 and lies inside the table. Under the signed interpretation, it is negative 30. A test that checks only whether the index is less than 256 accepts that negative value too. The intended domain needs both a lower and an upper bound unless the type already excludes negative values.

This is a source-language question as well as an instruction question. In C, an out-of-bounds negative subscript can violate the array’s bounds. Python deliberately permits negative indices, so a superficially similar test can select from the end instead of failing. For a table of exactly 256 elements, negative 30 even selects the same position as 226, hiding the representation mistake until another table size or operation exposes it. A passing lookup is not proof that the intended unsigned contract was followed.

The byte E2 follows either an unsigned index 226 path or a signed index minus 30 path; an upper-only comparison accepts both, while a full interval check rejects the negative indexView full-size image ↗

unsigned_index = 0xe2
signed_index = unsigned_index - 256
assert unsigned_index < 256 and signed_index < 256
assert 0 <= unsigned_index < 256
assert not 0 <= signed_index < 256
assert list(range(256))[signed_index] == unsigned_index

missed_lower_bounds = 0
for byte in range(256):
    value = byte if byte < 128 else byte - 256
    if value < 256 and not (0 <= value < 256):
        missed_lower_bounds += 1
assert missed_lower_bounds == 128
print("128 signed-byte cases pass an upper-only check incorrectly")

The count describes the chosen index contract, not 128 hardware faults. All 256 byte loads can execute correctly while software interprets half their results in the wrong domain. An explicit unsigned byte type expresses that domain early; a later mask should not become a substitute for understanding whether the input was intended as a signed sensor measurement or an unsigned protocol octet.

Widen into the right type before assembling fields

Consider the two protocol bytes E2 01 in big-endian order. Their unsigned field value is 226 times 256 plus one, or 57,857. The extension step for each byte should preserve its unsigned zero-to-255 value before the parser shifts it into a wider field. This choice is independent of the host’s byte order.

The C11 integer-promotion and shift rules, sections 6.3.1.1 and 6.5.7, matter here: a small unsigned integer can still undergo integer promotion before a shift. For a four-byte field, shifting a promoted byte into bit 31 can exceed a signed int’s range even though the original byte type was unsigned. Cast to a sufficiently wide unsigned result type before shifting. The explicit-width C types are available only when the implementation provides the corresponding exact-width representation; the following exercise assumes uint8_t and uint32_t exist.

#include <assert.h>
#include <stdint.h>

static uint32_t assemble_be16(uint8_t high, uint8_t low) {
    return ((uint32_t)high << 8) | (uint32_t)low;
}

int main(void) {
    for (unsigned high = 0; high < 256; ++high) {
        for (unsigned low = 0; low < 256; ++low) {
            assert(assemble_be16((uint8_t)high, (uint8_t)low)
                   == high * 256u + low);
        }
    }
    assert(assemble_be16(0xe2, 0x01) == 57857u);
    assert(((uint32_t)0xe2 << 24) == UINT32_C(0xe2000000));
    return 0;
}

This exhaustive two-byte assembly check does not execute LB or LBU. It verifies a portable source-level interpretation on the selected C implementation, alongside the earlier ISA arithmetic model. Extend it to four bytes with shifts by 24, 16, 8 and zero, keeping the casts before the shifts. Then test an accessible byte buffer whose values cross 0x7F to 0x80 so the compiler-generated load and promotion choices have a meaningful boundary to preserve.

Repeat the calculation for RV64

On RV64, the destination register is 64 bits wide. LB applied to 0xE2 produces 0xFFFFFFFFFFFFFFE2; LBU produces 0x00000000000000E2. The RV64I specification describes the wider register model and additional load behavior.

The number of extension bits changes, but the signed mathematical value remains negative 30. Width-sensitive test output should print enough hexadecimal digits to reveal that extension. A debugger showing only the low byte would make the two results appear identical.

A compact regression set includes 0x00, 0x7F, 0x80, and 0xFF, then adds bytes from the actual protocol. Those boundary values exercise both sides of the sign transition. When a decoded field suddenly becomes negative, inspect the load and promotion path before blaming the byte order or the data source.

Sources & further reading

  1. RISC-V unprivileged ISA: RV32I base integer instruction set
  2. RISC-V RV64I base integer instruction set
  3. ISO C11 committee draft N1570: integer promotions and shifts
← Back to the journal
All notes

Illustration

100%