We have identified algorithm implementations from lookup tables, recovered struct layouts from offset arithmetic, and compared three analysis tools. Now we face a different challenge: the firmware talks to hardware.

With memory-mapped I/O (MMIO), loads and stores access peripheral registers through addresses in the processor’s address space. In this sample, a store to virtual address 0xB8000000 writes UART transmit data. A load from 0xB8020000 reads the timer counter. The nearby address 0xB8020004 holds the timer’s compare value.

The addresses alone do not tell us those meanings. We need to follow the loads, stores, and surrounding control flow.

Follow along: Use firmware.bin from the sample bundle, and run the examples from more_complex_firmware/. The assembly excerpts below were disassembled from that binary with Capstone. Comments explain the instructions; gaps between address ranges omit unrelated instructions. The companion source provides a cross-check after analysis.


How MMIO Looks in MIPS Assembly

In the conventional MIPS32 segment layout used here, KSEG1 is the uncached, unmapped virtual address range 0xA0000000–0xBFFFFFFF. “Unmapped” means that accesses bypass the translation lookaside buffer (TLB). The physical address is virtual_address & 0x1FFFFFFF, so 0xB8000000 accesses physical address 0x18000000.

KSEG1 can address RAM as well as peripherals. For example, 0x80080000 in KSEG0 and 0xA0080000 in KSEG1 alias the same physical RAM at 0x00080000, with different cache attributes. An address in KSEG1 is therefore a clue about access policy, not proof of MMIO. The MIPS32 privileged architecture manual, Section 4.6, describes this mapping.

Our sample’s peripheral accesses cluster around 0xB8000000–0xB8060000. The compiler often constructs a base with lui and then uses it in lw or sw. Here is the actual UART transmit store:

80002140:  lui   v0, 0xb800
80002144:  sw    a0, (v0)           # write character to 0xB8000000

The status read uses a separate register for the base:

80002130:  lui   v1, 0xb800
80002134:  lw    v0, 8(v1)          # read 0xB8000008

A lui 0xB8xx instruction gives us a candidate address construction. We still need to find the load or store that uses it.


Step 1: Find Candidate Peripheral Bases

Start with the executable ranges of the bundled binary. The companion ELF identifies .vectors at file offsets [0x0000, 0x02AC) and .text at [0x02B0, 0x7290). For an unknown image, identifying code ranges is itself part of the analysis; these bounds are specific to this build.

The following scan looks for lui instructions in those ranges whose upper immediate falls in our candidate peripheral window:

from collections import Counter
import struct

data = open("firmware.bin", "rb").read()
code_ranges = [(0x0000, 0x02AC), (0x02B0, 0x7290)]
counts = Counter()

for start, end in code_ranges:
    for offset in range(start, end, 4):
        word = struct.unpack_from("<I", data, offset)[0]
        # LUI opcode, with its required zero rs field.
        if (word & 0xFFE00000) != 0x3C000000:
            continue
        upper = word & 0xFFFF
        if 0xB800 <= upper <= 0xB8FF:
            counts[upper << 16] += 1

for base, count in sorted(counts.items()):
    print(f"0x{base:08X}  {count:2d} LUI instructions")
print(f"Total: {sum(counts.values())} LUI instructions")
0xB8000000   8 LUI instructions
0xB8010000  12 LUI instructions
0xB8020000   6 LUI instructions
0xB8030000   7 LUI instructions
0xB8040000   4 LUI instructions
0xB8050000   3 LUI instructions
0xB8060000   5 LUI instructions
Total: 45 LUI instructions

Seven candidate blocks, with 45 base constructions. This is not a count of register accesses: one base can serve several loads and stores, and a loop can execute one instruction repeatedly.

A broad scan that checks only the LUI opcode and an immediate in 0xA000–0xBFFF across the entire flat file produces 51 candidates across 12 upper-address groups. Some are numeric constants in code, including SHA-256 initial-state values; others are data bytes mistaken for instructions. Restricting the scan to code removes the data candidates, and checking the required zero rs field rejects invalid LUI encodings. Following the constructed values into memory accesses distinguishes addresses from constants.


Step 2: Register Access Pattern Analysis

Different access patterns suggest different register roles:

Observed pattern Working inference
Stores of configuration values during initialization Control/configuration register
Repeated loads followed by a bit test and backward branch Polled status register
Load, mask or OR, then store to the same MMIO address Register read-modify-write
Write a descriptor index after filling descriptors Submission notification
Periodic store of the same magic value Watchdog kick or similar trigger

These are hypotheses to check against callers, diagnostic strings, and other access sites. A write does not establish that a register is write-only, and a store of 1 does not prove write-one-to-clear semantics by itself.

The register tables below list accesses observed in this binary. Names come from instruction behavior and the diagnostic register-dump strings. The companion source confirms the intended bit meanings; it does not make unused hardware behavior visible in the binary.

Block 0: UART (0xB8000000)

The initialization function at 0x800023FC writes three configuration values:

800023fc:  lui   v0, 0xb800
80002400:  addiu v1, zero, 1
80002404:  sw    v1, 0x10(v0)       # BAUD = 1
80002408:  addiu v1, zero, 3
8000240c:  sw    v1, 0xc(v0)        # CTRL = 3
80002410:  addiu v1, zero, 7
80002414:  sw    v1, 0x14(v0)       # FIFO control = 7

The transmit routine at 0x80002130 gives us a stronger behavioral clue:

80002130:  lui   v1, 0xb800
80002134:  lw    v0, 8(v1)          # STATUS
80002138:  andi  v0, v0, 1          # test bit 0
8000213c:  bnez  v0, 0x80002134     # wait while busy
80002140:  lui   v0, 0xb800         # branch delay slot; restore TX base
80002144:  sw    a0, (v0)           # TX data
80002148:  jr    ra
8000214c:  nop

The lui in the branch delay slot executes on both outcomes. Keeping the base in v1 lets the loop overwrite v0 with status; the delay-slot instruction then prepares v0 for the transmit store.

Offset Name Observed access Interpretation
+0x00 TX W Transmit character
+0x04 RX R Receive character
+0x08 STATUS R Bit 0 polled before TX; bit 1 before RX
+0x0C CTRL R/W Written with 3; source names bits 0–1 TX/RX enable
+0x10 BAUD R/W Divisor/configuration value
+0x14 FIFO_CTRL R/W FIFO configuration

The diagnostic command reads CTRL, BAUD, and FIFO_CTRL, so calling them write-only would contradict the firmware. The exact baud rate still depends on a clock the register writes alone do not establish.

Block 1: GPIO (0xB8010000)

The GPIO initializer at 0x80002420 writes direction, pinmux, data, and interrupt registers. These excerpts retain the original addresses:

80002428:  lui   v0, 0xb801
8000242c:  sw    zero, 4(v0)        # DIR = 0

80002438:  sw    zero, 0x1c(v0)     # PINMUX = 0

80002444:  sw    zero, (v0)         # DATA = 0
80002448:  sw    zero, 8(v0)        # INT_EN = 0
8000244c:  addiu v1, zero, -1
80002450:  sw    v1, 0xc(v0)        # INT_STATUS = 0xFFFFFFFF

Direction changes use a RAM shadow. On the output branch of gpio_set_direction, a0 holds the pin number and v0 initially holds 1:

80002484:  sllv  a0, v0, a0         # mask = 1 << pin
80002488:  lui   v0, 0x8008
8000248c:  lw    v0, 0x6d4(v0)      # read RAM shadow at 0x800806D4
80002490:  or    v0, a0, v0
80002494:  lui   v1, 0x8008
80002498:  sw    v0, 0x6d4(v1)      # update RAM shadow
8000249c:  lui   v1, 0xb801
800024a0:  sw    v0, 4(v1)          # write shadow to GPIO DIR

This is a read-modify-write of the shadow, followed by an MMIO write. The interrupt-enable routines do read-modify-write the hardware register itself. Tracking the address at each access distinguishes the two cases.

Offset Name Observed access Interpretation
+0x00 DATA R/W Read pin data; initialize output data
+0x04 DIR R/W Direction; setters write a RAM shadow
+0x08 INT_EN R/W Interrupt-enable mask
+0x0C INT_STATUS R/W Pending bits; single-bit/all-ones writes suggest W1C
+0x10 SET W Write a pin mask to set output
+0x14 CLR W Write a pin mask to clear output
+0x18 TOGGLE W Write a pin mask to toggle output
+0x1C PINMUX R/W Two configuration bits per pin

The all-ones write during initialization and single-bit writes in gpio_int_clear support the write-one-to-clear interpretation. The companion source states that intent explicitly.

Block 2: Timer (0xB8020000)

The timer initializer at 0x80002610 stops the timer before configuring it:

80002628:  lui   v0, 0xb802
8000262c:  sw    zero, 8(v0)        # stop timer
80002630:  addiu v1, zero, 1
80002634:  sw    v1, 0x10(v0)       # prescaler = 1
80002638:  addiu a0, zero, 0x3e8
8000263c:  sw    a0, 4(v0)          # compare = 1000
80002640:  sw    zero, (v0)         # reset counter
80002644:  sw    v1, 0xc(v0)        # acknowledge pending match
80002648:  addiu v1, zero, 7
8000264c:  sw    v1, 8(v0)          # enable, auto-reload, interrupt enable

The source names the three control bits and intends a 1 ms tick. Establishing the actual period would also require the timer input clock and divider semantics.

The handler at 0x800026E4 acknowledges immediately:

800026ec:  lui   v0, 0xb802
800026f0:  addiu v1, zero, 1
800026f4:  sw    v1, 0xc(v0)        # acknowledge without reading STATUS
800026f8:  lui   v0, 0x8008
800026fc:  lw    v1, 0x6e4(v0)      # software tick counter
80002700:  addiu v1, v1, 1
80002704:  sw    v1, 0x6e4(v0)

80002720:  jal   0x80000cb8         # rtos_tick
80002724:  nop

There is no status read before acknowledgment. Also, the sample’s exception handler never dispatches to this ISR. Finding a handler body and finding a working interrupt path are separate steps.

Offset Name Observed access Interpretation
+0x00 COUNT R/W Counter, reset to zero during initialization
+0x04 COMPARE R/W Compare value
+0x08 CTRL R/W Source-defined enable, auto-reload, and interrupt bits
+0x0C STATUS R/W Read by diagnostics; written with 1 to acknowledge
+0x10 PRESCALER R/W Divider configuration

Block 3: System Control (0xB8030000)

Seven lui instructions construct this base. Their uses include startup markers, version reads, reset, error reporting, and diagnostics.

The reset command writes 1 and then loops:

80004bb4:  lui   v0, 0xb803
80004bb8:  addiu v1, zero, 1
80004bbc:  sw    v1, (v0)
80004bc0:  b     0x80004bc0
80004bc4:  nop

The runtime-start phase writes a different register:

800002f8:  lui   v0, 0xb803
800002fc:  ori   v1, zero, 0xbeef
80000300:  sw    v1, 0x14(v0)       # BOOT_STATUS

That marker means execution reached runtime start. Part 5 will show why it does not prove that all earlier phases succeeded.

Offset Diagnostic name Observed access Interpretation
+0x00 RESET R/W Written with 0 during init and 1 by reset command
+0x04 CLOCK R Read by register dump; no clock configuration write
+0x08 VERSION R Logged during hardware init
+0x0C SCRATCH0 R/W Startup writes 0xB007
+0x10 SCRATCH1 R/W Init failure writes an encoded phase/error
+0x14 BOOT_STAT R/W Runtime-start marker 0xBEEF

The “configuring system clock” log string does not establish a clock-register write. Following its instructions reveals a version read and reset handling instead.

Block 4: DMA Controller (0xB8040000)

The DMA initializer at 0x80003960 prepares a 16-entry descriptor ring. Here a0 already holds the ring address, 0x80081314:

800039bc:  lui   v0, 0xb804
800039c0:  sw    zero, (v0)         # disable during setup
800039c4:  sw    a0, 8(v0)          # descriptor ring address
800039c8:  addiu v1, zero, 0x10
800039cc:  sw    v1, 0xc(v0)        # 16 descriptors
800039d0:  sw    zero, 0x10(v0)     # HEAD_IDX = 0
800039d4:  sw    zero, 0x14(v0)     # TAIL_IDX = 0
800039d8:  addiu v1, zero, -1
800039dc:  sw    v1, 0x18(v0)       # clear interrupt status
800039e0:  addiu v1, zero, 3
800039e4:  sw    v1, (v0)           # enable + interrupt enable

Submission fills a descriptor, advances the producer index, and writes HEAD_IDX at +0x10:

80003a34:  sw    a0, (v1)           # descriptor source
80003a38:  sw    a1, 4(v1)          # destination
80003a3c:  sw    a2, 8(v1)          # length
80003a40:  lui   a0, 0x9000
80003a44:  sw    a0, 0xc(v1)        # OWN | IRQ flags
80003a48:  addiu v0, v0, 1
80003a4c:  andi  v0, v0, 0xf        # producer index wraps modulo 16
80003a50:  sw    v0, 0x1310(t1)     # g_dma_head in RAM

80003a60:  lui   v1, 0xb804
80003a64:  sw    v0, 0x10(v1)       # publish new head to DMA controller

Names alone would have led us astray. In this driver, head is the producer’s next submission slot, and tail is software’s next completion slot. dma_poll reads descriptor flags from RAM:

80003b58:  lw    fp, 0x130c(s1)     # g_dma_tail; s1 = 0x80080000
80003b5c:  sll   v0, fp, 4          # 16-byte descriptor stride
80003b60:  addu  v0, s0, v0         # s0 = descriptor ring base
80003b64:  lw    v0, 0xc(v0)        # descriptor flags
80003b68:  bltz  v0, 0x80003ba4     # bit 31: still owned by DMA, stop
80003b6c:  and   v0, v0, s4         # delay slot; s4 = error-bit mask

Once ownership returns, the routine checks descriptor error/completion bits, clears the descriptor, and advances g_dma_tail in RAM. It does not use DMA_STATUS or the MMIO TAIL_IDX register to detect completion.

This is the same broad producer/consumer idea used by queue doorbells, but head/tail names depend on the interface. In NVMe, host software advances the submission queue’s tail and the completion queue’s head. Here the submission producer is called head. Our firmware initializes the MMIO tail at +0x14 and reads it in diagnostics; it contains no device implementation from which to establish subsequent hardware tail updates.

Offset Name Observed access Interpretation
+0x00 CTRL R/W Disable during setup; then enable + interrupt enable
+0x04 STATUS R Diagnostic read; not used by completion polling
+0x08 DESC_BASE R/W Ring address written as 0x80081314
+0x0C DESC_COUNT W Set to 16
+0x10 HEAD_IDX R/W Software writes producer head on submission
+0x14 TAIL_IDX R/W Initialized to zero; read by diagnostics
+0x18 INT_STATUS W All-ones acknowledgment writes

The ring-address store passes the linked KSEG0 address directly. The sample has no address conversion or cache-maintenance code here, so these instructions do not establish a working physical DMA mapping.

Block 5: Interrupt Controller (0xB8050000)

Hardware initialization masks all sources:

80000410:  lui   v0, 0xb805
80000414:  sw    zero, (v0)

Runtime start selects three sources:

800002d0:  lui   v0, 0xb805
800002d4:  addiu v1, zero, 0xb
800002d8:  sw    v1, (v0)           # bits 0, 1, and 3

The source names those bits UART, timer, and DMA. The mask write configures the controller; it does not establish CPU interrupt enablement or dispatch to a peripheral handler.

Offset Diagnostic name Observed access Interpretation
+0x00 MASK R/W Written with 0 and 0x0B
+0x04 PENDING R Diagnostic read
+0x0C PRIORITY R Diagnostic read

The source header also defines ACK at +0x08, but the binary never accesses that address. Leave it out of the recovered map: its presence is knowledge from source.

Block 6: Watchdog (0xB8060000)

Hardware initialization sets a timeout, enables the watchdog, and kicks it:

800003f4:  lui   v0, 0xb806
800003f8:  ori   v1, zero, 0xffff
800003fc:  sw    v1, 8(v0)          # timeout
80000400:  addiu v1, zero, 1
80000404:  sw    v1, (v0)           # enable
80000408:  addiu v1, zero, 0x5a5a
8000040c:  sw    v1, 0xc(v0)        # kick

Runtime start and the main loop repeat the kick. The hidden $wdgtest command shortens the timeout:

80004b88:  lui   v0, 0xb806
80004b8c:  addiu v1, zero, 0x10
80004b90:  sw    v1, 8(v0)

That command returns to the main loop, which continues kicking the watchdog. A short timeout alone does not prove that the device will reset.

Offset Name Observed access Interpretation
+0x00 CTRL R/W Written with 1 to enable
+0x04 COUNT R Diagnostic read
+0x08 TIMEOUT R/W Boot timeout 0xFFFF; test writes 0x10
+0x0C KICK W Repeated writes of 0x5A5A

Step 3: The Observed Peripheral Map

Following the candidate bases into loads and stores gives this map:

Virtual base Block Distinct register addresses observed
0xB8000000 UART 6
0xB8010000 GPIO 8
0xB8020000 Timer 5
0xB8030000 System control 6
0xB8040000 DMA 7
0xB8050000 Interrupt controller 3
0xB8060000 Watchdog 4
Total 7 blocks 39 addresses

These are distinct addresses used by instructions, not 39 accesses at runtime or a complete hardware specification. Diagnostic strings provide names for many registers; the read/write patterns show how this firmware uses them. Bit semantics such as write-one-to-clear remain interpretations supported by the code and confirmed by the sample source.


The Step 1 script is a first pass. To turn its candidates into a register map, follow each constructed value through register copies and address arithmetic, then record the effective address of each load or store.

For this build, the distinction matters in several places: one UART base feeds repeated status reads; GPIO direction uses a RAM shadow; DMA completion flags live in RAM. A useful scanner reports instruction addresses and what it counted, so we can inspect those cases. A lui histogram alone cannot recover register ownership or side effects.

When adapting the scan to another firmware, replace the code ranges and candidate address window with evidence from that image. Broadening the search to all KSEG1 immediates is useful for triage, provided the output remains a list of candidates to verify.


Exercises

  1. Trace the timer handler: Find the function at 0x800026E4 that writes 1 to 0xB802000C and calls 0x80000CB8. Does the exception handler ever dispatch to it? Distinguish a present ISR body from an active interrupt path.

  2. Map the DMA descriptors: From initialization and submission, recover the ring address, entry count, and descriptor stride. Identify the source, destination, length, and flags fields. Which bit makes dma_poll stop, and which MMIO index does submission write?

  3. Trace boot markers: Follow writes to system-control offsets +0x0C, +0x10, and +0x14. What do 0xB007, the phase/error encoding, and 0xBEEF tell us about execution?

  4. Check the clock claim: Find every reference to 0xB8030004. Is it read or written? Compare the result with the “configuring system clock” log message. What evidence would be needed to recover actual clock configuration?


What’s Next

We’ve mapped the addresses the firmware uses, identified its peripheral access patterns, and seen where the available evidence stops. In the final post, we’ll combine that map with struct recovery and dispatch tables to examine the pool allocator, scheduler code, and rule state transitions during boot.


This is Part 4 of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.”

Next: Part 5 — Sentinel Values, Pool Modes, and Other Clues to Runtime Architecture.