When to use / when not to use
- Use: RV32/RV64 architecture binaries - Linux user-space programs, bare-metal firmware, RISC-V MCU firmware (ESP32-C3 class RV32IMC)
- Use: compressed instruction (RVC) mixed code streams, gp-relative addressing recovery, RISC-V ABI identification, ecall system call/environment call boundaries
- Use: toolchain and library function fingerprints (glibc/musl/newlib family, instruction extension features)
- Don't use: other architectures such as ARM / x86 / MIPS (go through the [[re-binary-core]] general foundation; this skill only supplements RISC-V-specific semantics)
- Don't use: when the toolchain is extremely customized and there's no lead, first go through [[re-fw-emulate]] emulation as a fallback (if it can run, observe behavior first, then return to static)
- Don't use: when you only need to unpack firmware to see contents ([[re-fw-extract]]) / analyze filesystem and configuration ([[re-fw-rootfs]]) / boot firmware as a whole ([[re-fw-emulate]])
Tool preparation
Verify all tools before use. Static analysis may skip the sandbox; qemu dynamic execution defaults to sandbox + network isolation ([[re-analyze/platform-tips]] highest principle).
Decompiler (Ghidra / IDA, choose one)
- Ghidra (built-in RISC-V processor module, RV32/RV64 + I/M/A/F/D/C extensions, automatically handles RVC compressed instructions):
- Linux: official release package (requires JDK); some distribution repositories have
apt install ghidra/pacman -S ghidra - macOS:
brew install --cask ghidra; Windows: official zip - Verify:
analyzeHeadless -help(headless mode) or import a RISC-V ELF via the GUI
- Linux: official release package (requires JDK); some distribution repositories have
- IDA: commercial edition includes a RISC-V module; Freeware edition architecture support scope is subject to the official page
- When importing, confirm RV32/RV64 and endianness - standard RISC-V is little-endian, big-endian variants are rare, and choosing wrong makes everything misaligned
readelf / file - architecture and byte order confirmation (binutils)
- Linux: included with binutils (
apt install binutilsetc.); macOS:brew install binutilsor LLVM-based readelf; Windows: inside WSL - Verify:
readelf --version
binutils cross toolchain - RISC-V disassembly/symbol table (objdump)
- Debian/Ubuntu:
apt install binutils-riscv64-linux-gnu(providesriscv64-linux-gnu-objdump; add to the compile chaingcc-riscv64-linux-gnu, gcc dependencies pull in the binutils package) - Fedora:
dnf install binutils-riscv64-linux-gnu gcc-riscv64-linux-gnu(cross-binutils / cross-gcc source packages, providesriscv64-linux-gnu-objdump; the official repositories have no riscv64-unknown-elf bare-metal toolchain, so for bare-metal disassembly use riscv64-linux-gnu-objdump, RVC support is consistent) - Arch:
pacman -S riscv64-elf-binutils riscv64-elf-gcc(bare-metal, providesriscv64-unknown-elf-objdump) orpacman -S riscv64-linux-gnu-binutils riscv64-linux-gnu-gcc(user-space) - macOS:
brew install riscv64-elf-binutils riscv64-elf-gcc(homebrew-core, providesriscv64-unknown-elf-objdump); Windows/WSL: the Linux version inside WSL - Verify: one-to-one correspondence per installed package -
binutils-riscv64-linux-gnu→riscv64-linux-gnu-objdump --version(Debian/Ubuntu/Fedora);binutils-riscv64-unknown-elf→riscv64-unknown-elf-objdump --version(Arch/brew; Debian/Ubuntu need that package listed separately when a bare-metal triplet is required); objdump automatically recognizes RVC mixed streams (2/4 bytes)
qemu-riscv64 / qemu-riscv32 - RISC-V user-space emulation (dynamic verification)
- Linux:
apt install qemu-user/dnf install qemu-user/pacman -S qemu-user - macOS:
brew install qemu(includes user-space); Windows/WSL: the Linux version inside WSL - For 64-bit use
qemu-riscv64, for 32-bit useqemu-riscv32;-L <rootfs>to specify the dynamic library/linker source,-stracetracks system calls - Verify:
qemu-riscv64 --version、qemu-riscv32 --version - Bare-metal RISC-V (no OS) qemu-user is not applicable — use
qemu-system-riscv64/32(Debian trixie onwards / Ubuntu 25.10 onwards standalone packageapt install qemu-system-riscv, Ubuntu 24.04 and earlier, Debian bookworm withinqemu-system-misc; Fedora / Archdnf install qemu-system-riscv/pacman -S qemu-system-riscv)-machine virtor board-level models (-s -Sconnect to gdb), or [[re-emulation]] with Unicorn instruction-by-instruction
gdb-multiarch — cross debugging
- Linux: Debian/Ubuntu
apt install gdb-multiarch(built-in for riscv32/riscv64 targets); Fedoradnf install gdb(official gdb supports multiple targets); Archpacman -S gdb(built-in multiarch) - macOS:
brew install gdb(requires Developer Tools authorization, see [[re-analyze/platform-tips]] macOS branch) or the Linux version inside WSL; Windows/WSL: the Linux version inside WSL - Verify: Debian/Ubuntu
gdb-multiarch --version;Fedora/Archgdb --version; after loading the firmware,set architecture riscv:rv64(or riscv:rv32) theninfo registersto confirm
Procedure
Execute in order, archive the result of each step; dynamic execution defaults to sandbox.
-
Architecture confirmation (RV32/RV64 + endianness):
file target.bin # "RISC-V" + 32-bit/64-bit + little-endian 提示 file target.elf readelf -h target.elf # Machine=RISC-V (243);Data=little-endian;Flags 含 RVC/浮点 ABI 位 readelf -A target.elf | head # Tag_RISCV_arch 属性,如 rv64imafdc- e_flags (readelf -h Flags field):
EF_RISCV_RVC(0x1)=contains compressed instructions; floating-point ABI bitmask 0x6 (0=soft float, 2=single-precision F, 4=double-precision D, 6=quad-precision Q);EF_RISCV_RVE(0x8)=RV32E reduced registers - Standard RISC-V is little-endian; big-endian variants are rare, go by the actual output of file/readelf
- Bare-metal .bin (no ELF header) is determined by the reset vector context and instruction form from step 2 (RV32 fixed-width 4 bytes vs RVC mixed 2/4 bytes)
- e_flags (readelf -h Flags field):
-
Entry point location (reset / startup code):
- Linux user space: the entry point is ELF e_entry (
readelf -h's Entry point) =_start; dynamically linked programs go through ld.so (PT_INTERP) first, breakpoints/analysis start from _start - Bare metal: RISC-V has no fixed vector table (unlike ARM Cortex-M), the entry point = the reset vector from the SoC datasheet; the QEMU virt machine's reset vector is in MROM
0x1000(the reset stub then jumps to start_addr, by default DRAM0x80000000, for pflash firmware scenarios it is0x20000000)——don't mistake the DRAM base address for the reset entry; some MCU flash base addresses are of the form 0x08000000; the exception/interrupt vector base address is in the mtvec (M-mode) / stvec (S-mode) CSR, trap handling code is located by the CSR value - The startup flow is generally: set up the stack (
la sp, ...) → copy .data / zero .bss → jump to main (bare metal) or __libc_start_main (Linux) - After locating the entry point, annotate it in the decompiler, expand the main logic along the call chain
- Linux user space: the entry point is ELF e_entry (
-
RVC compressed instruction recognition (2/4-byte mixed, do not cut at fixed width):
- RVC makes code 2-byte aligned (halfword), addresses are all even; 32-bit instructions can land at a 2 mod 4 odd halfword position — you cannot cut at a fixed 4-byte stride
- Wrong starting point → the entire instruction stream is misaligned into garbage; the correct approach: use a disassembler that supports RVC (Ghidra / riscv64-*-objdump recognize it automatically), align from the entry point/jump target, and when valid compressed instructions appear (c.addi/c.li/c.j/c.lw etc.) the alignment is correct
- Recognition signals: e_flags RVC bit (0x1), objdump output contains 2-byte instructions, bare-metal firmware compiled with -Os
- Interleaving of 16/32-bit instructions within the same function is the norm, there is no fixed stride
-
gp-relative addressing recovery (lui+addi pairs, .sdata/.sbss):
- gp(x3)=
__global_pointer$, generally falls within the .sdata section, with a 12-bit signed offset (±2048) covering the .sdata/.sbss small data area - Access pattern: at startup
lui gp, %hi(...)+addi gp, gp, %lo(...)set once → subsequentlylw/ld aN, off(gp)small-offset data access (global variables within -msmall-data-limit, default 8 bytes, go through gp) - After symbol stripping: compute the actual gp value from the lui+addi sequence in the _start/crt0 prologue → annotate in the decompiler → recover data references; when ELF is present
readelf -slook up__global_pointer$symbol value - When gp is missing, these accesses resolve to "unknown global" and the data flow is scattered—determine gp first before analyzing (see pitfall 2)
- gp(x3)=
-
ABI identification (integer/float ABI, a0-a7 conventions):
- Integer conventions: arguments a0-a7 (x10-x17, the 9th onward passed on the stack), return value a0, callee-saved s0-s11 (x8-x9, x18-x27); ra=x1, sp=x2, gp=x3, tp=x4, fp=s0(x8); sp is 16-byte aligned at call boundaries
- ABI names: RV32 is ilp32/ilp32f/ilp32d, RV64 is lp64/lp64f/lp64d—the f/d suffix indicates float arguments go in floating-point registers (F/D extension)
- Float conventions: arguments fa0-fa7 (f10-f17), return value fa0, callee-saved fs0-fs11 (f8-f9, f18-f27); hard-float ABI passes float arguments in fa0-fa7, soft-float passes them in a0-a7
- Determination: rely on ELF
e_flags & EF_RISCV_FLOAT_ABI(mask 0x6) as authoritative—it is theentire object's Float ABI field.You cannot infer soft/hard-float from whether floating-point instructions appear:-march=rv64ifd -mabi=lp64is a legal combination; the artifact still uses hardware floating-point instructions such as fld/fmul, but function arguments still go through integer registers (measured: lp64 artifacts usefmv.d.x fa5, a1to move a0/a1 into floating-point registers, while lp64d artifacts directly usefsd fa0to receive floating-point registers). Floating-point instruction density can only corroborate that the ISA includes the F/D/Q extension, not the argument ABI - Objects with different Float ABIs cannot be linked together: the linker directly reports
cannot link object files with different floating-point ABI, and under the standard workflow there is no soft/hard mixed artifact that is "confirmed per unit". If during reverse engineering you observe differing local calling conventions, first consider hand-written assembly, non-standard ABI,STO_RISCV_VARIANT_CC, FFI/thunk, or incorrectly identified function boundaries
-
System call boundary (ecall + a7 number):
- Linux user space:
ecalltriggers a system call, with the number in a7 (x17), arguments a0-a5, return a0; RISC-V uses asm-generic numbering (read=63, write=64, openat=56, exit=93, mmap=222)—different from x86/ARM numbering, so don't apply numbers from other architectures - Locate: objdump search for
ecall(0x73), then search backward for theli a7, immimmediate; identify system call sites in Ghidra decompilation - Bare metal (no OS): ecall has custom semantics—M-mode firmware/SBI calls (a7=extension number, a6=function number) or vendor monitor, and must not be interpreted using Linux numbers; consult SBI/vendor SDK documentation according to the runtime environment
- Behavior verification:
qemu-riscv64 -stracedirectly print the system call sequence (linked with [[re-tracing]])
- Linux user space:
-
Ecosystem fingerprint (toolchain/library function characteristics):
- User space: glibc (dynamic, PT_INTERP, __libc_start_main) / musl (commonly static, small entry) / target board distribution characteristics (busybox, etc.)
- Bare metal: newlib/picolibc (crt0: _start → __libc_init_array → main); vendor SDK boot chain (ROM → bootloader → app) and partition table structure
- Instruction extensions: vector instructions (RVV, v0-v31 register file) suggest a newer toolchain; high density of compressed instructions mostly indicates -Os compilation
- Feature strings locate library function versions (same idea as [[re-imports]]), cross-library fingerprints assist recovery
Cross-domain combination
- [[re-binary-core]]: general RISC-V ELF initial survey/decompilation/debugging foundation ([[re-ghidra]], [[re-ida]], [[re-gdb]], [[re-radare2]] used as usual)
- [[re-firmware]]: firmware sample gateway path (re-firmware → re-fw-extract → re-fw-rootfs → this skill)
- [[re-fw-extract]] / [[re-fw-rootfs]]: prerequisites for firmware unpacking and filesystem/configuration analysis
- [[re-fw-emulate]]: boot the whole firmware or run RISC-V programs in user mode (qemu series)
- [[re-emulation]]: when qemu is unavailable, use Unicorn/Qiling for emulated execution (bare-metal instruction-by-instruction verification)
- [[re-rtos]]: after locating RTOS task tables/TCBs in MCU firmware, decompile task by task
- [[re-hardware-io]]: JTAG/SWD on-device debugging and flash reading (OpenOCD + gdb)
- [[re-cpp-abi]]: RTTI/exception/vtable recovery for C++ artifacts on RISC-V
- [[re-variant]]: multi-version firmware comparison and patch diff
- [[re-tracing]]: qemu -strace / strace syscall tracing to verify ecall boundaries
- [[re-sandbox]]: mandatory prerequisite for all dynamic execution (default sandbox principle in [[re-analyze/platform-tips]])
- Companions: [[re-patching]] (RVC-aware byte patching), [[re-triage]] (prerequisite for initial survey/architecture identification)
Common pitfalls and traps
- Splitting a compressed instruction stream at 4-byte boundaries is completely wrong: Symptom—disassembling at a fixed 4-byte stride yields garbled instructions and all references misaligned; Cause—RVC mixed streams interleave 2/4 bytes at 2-byte alignment, so fixed-width splitting can start mid-instruction; Countermeasure—use a disassembler that supports RVC (Ghidra/objdump auto-detect it), align start points from the entry/jump targets, and when valid compressed instructions (c.addi/c.j/c.lw) appear, alignment is correct; during patching, instruction length may change between 2↔4 bytes
- Missing gp global pointer makes data access scattered: Symptom—a large number of
lw/ld off(gp)data accesses whose addresses cannot be resolved, global variables not linked to each other, fragmented logic; Cause—after symbol stripping__global_pointer$is lost, or the decompiler fails to infer gp; Countermeasure—compute the actual gp value from thelui gp+addi gpsequence in _start/crt0 and annotate it in the decompiler; when an ELF is available,readelf -scheck__global_pointer$ - la/li pseudo-instruction expansion forms vary: Symptom—the same logic has different instruction sequences in different functions (single addi / lui+addi / auipc+addi) and is misjudged as different logic; Cause—the assembler chooses the expansion form based on immediate size and relocation type, and after linker relax it may even shrink to a single instruction; Countermeasure—rely on machine-code semantics (the same target address means the same reference), cross-check across tools, and verify critical logic back at the instruction level
- ecall syscall numbers are bound to the runtime environment; without an OS they have custom semantics: Symptom — in bare-metal firmware, ecall is interpreted as a Linux system call, so the semantics are completely wrong; Cause — ecall is merely an "environment call" instruction, and the meaning of the number is defined by the runtime environment (Linux: a7 = system call number; bare metal: SBI extension/vendor monitor); Countermeasure — first confirm the runtime environment (whether there is an OS, dynamic libraries, the form of _start), and when there is no OS, check the SBI extension number or the vendor SDK documentation against a7/a6
- Linker relax (R_RISCV_RELAX) affects disassembly boundaries: Symptom — the disassembly of the .o intermediate product does not match the final binary instruction stream, or addresses found according to the unrelaxed layout are misaligned; Cause — linker relax shrinks auipc+addi to addi, shortens jal to c.j, etc., and the size of sequences marked with R_RISCV_RELAX becomes smaller after linking; Countermeasure — analyze the final linked product (not the .o), and base address references on the final binary; before modifying bytes, first confirm whether they are affected by relax (relink or compare disassembly)