Register Maps, MMIO Blocks, and Other Hardware Buried in Code
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.binfrom the sample bundle, and run the examples frommore_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 0xB8000000The status read uses a separate register for the base:
80002130: lui v1, 0xb800
80002134: lw v0, 8(v1) # read 0xB8000008A 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 instructionsSeven 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 = 7The 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: nopThe 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 = 0xFFFFFFFFDirection 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 DIRThis 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 enableThe 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: nopThere 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: nopThe runtime-start phase writes a different register:
800002f8: lui v0, 0xb803
800002fc: ori v1, zero, 0xbeef
80000300: sw v1, 0x14(v0) # BOOT_STATUSThat 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 enableSubmission 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 controllerNames 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 maskOnce 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 3The 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) # kickRuntime 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.
Automating the Search
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
-
Trace the timer handler: Find the function at
0x800026E4that writes1to0xB802000Cand calls0x80000CB8. Does the exception handler ever dispatch to it? Distinguish a present ISR body from an active interrupt path. -
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_pollstop, and which MMIO index does submission write? -
Trace boot markers: Follow writes to system-control offsets
+0x0C,+0x10, and+0x14. What do0xB007, the phase/error encoding, and0xBEEFtell us about execution? -
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.