The 3 key points of this post
- The other half of RV32I is control flow and memory access. Six branches (
beqbnebltbgebltubgeu), two jumps (jaljalr), five loads, and three stores, plusfence,ecall, andebreak, complete the 40 RV32I instructions. jalrclears the least significant bit of the target. Jumping to0x80000021on QEMU landed at0x80000020. The specification leaves misaligned loads and stores to the execution environment, and QEMU handled them without trapping. On the other hand, on a CPU with the C extension disabled, a jump misaligned by 2 bytes raised an mcause 0 exception.- I compared 56 branch, load, store, and trap results against a Python reference model and GNU as encodings, and all of them matched. At the end, I summarize a checklist for the decoder and the load/store unit.
Part 3 covered what the ALU has to do (integer operations and lui/auipc). Part 4 covers the remaining RV32I instructions: branches and jumps that change program flow, loads and stores that read and write memory, fence, which sets memory ordering, and ecall/ebreak, which call the execution environment. By the end of this part we will have seen every RV32I instruction, completing the input table for the decoder we will build from part 15 on.
Once again I verified everything by running bare metal on the virt board of QEMU 11.1.3. To see the cases that trap, I attached an M-mode trap handler that prints mcause, mepc, and mtval. The machine is a Mac M1 Max, and the compiler is riscv64-elf-gcc 16.2.
1. Branches: compare two registers and jump directly
On x86 and ARM, a compare instruction sets flags and a branch instruction reads them. RISC-V has no flags (part 3). Instead, a single branch instruction compares two registers and jumps if the condition is true. The specification gives as reasons for this choice that it fits pipelines well, has no flag state, and makes code shorter.
| Instruction | Condition | Comparison | With -1, 1 |
|---|---|---|---|
beq |
rs1 == rs2 | Equality | Not taken |
bne |
rs1 ≠ rs2 | Equality | Taken |
blt |
rs1 < rs2 | Signed | Taken |
bge |
rs1 ≥ rs2 | Signed | Not taken |
bltu |
rs1 < rs2 | Unsigned | Not taken |
bgeu |
rs1 ≥ rs2 | Unsigned | Taken |
The right column is the result of actually feeding rs1=0xffffffff, rs2=1 into QEMU. Read as signed, −1 < 1, so blt is taken; read as unsigned, 4,294,967,295 ≥ 1, so bgeu is taken. Adding three more pairs, (1, −1), (0x80000000, 0x7fffffff), and (5, 5), I ran 6×4 = 24 combinations, and all matched the Python reference model.
There is no bgt, ble, bgtu, or bleu, because you can just swap the operands. The assembler turns bgt a0, a1, L into blt a1, a0, L. Things like beqz, bnez, and bltz are also pseudo-instructions that compare against x0. The specification also includes a neat idiom: a signed array bounds check (0 ≤ i < n) takes a single bltu, because a negative i read as unsigned is a very large number, larger than n.
Target and distance
Branches use the B format. The 12-bit immediate represents a distance in 2-byte units, so in effect it is a 13-bit signed value (the least significant bit is always 0). The target is the branch instruction's own address plus this distance. The range is −4096 to +4094 bytes, i.e. ±4 KiB.

What happens beyond the limit? I placed targets at +4094, +4096, −4096, and −4100 bytes and assembled with GNU as.
Distance Assembler output Machine code
+4094 beq as is 7eb50fe3
+4096 bne +8, j target 00b51463 0000106f
-4096 beq as is 80b50063
-4100 bne +8, j target 00b51463 ff9fe06f
When out of range, the assembler changed it into a branch with the inverted condition that skips over a single j. j reaches ±1 MiB, so the distance problem is solved. Extracting the immediates from the machine code again, 7eb50fe3 was +4094 and 80b50063 was −4096, the two extreme values the B format can hold.
The specification also leaves a recommendation about static prediction: lay out code assuming a branch seen for the first time is predicted as "backward taken, forward not taken." The end of a loop is a backward branch and is usually taken, and rare paths such as error handling are moved forward. A small core without a dynamic predictor can make this prediction from a single bit, the sign bit of the immediate (inst[31]).
2. jal and jalr: calls, returns, and tail calls
jal rd, target (jump and link) uses the J format, jumps within ±1 MiB of the pc, and leaves the address of the next instruction (pc+4) in rd. For a function call, rd is ra (x1). If no return address is needed, rd is x0. That is the pseudo-instruction j.
jalr rd, imm(rs1) (jump and link register) uses the I format and takes the target from a register.
It adds the sign-extended 12-bit immediate to rs1 and then clears the least significant bit to 0. The specification explains that this decision simplifies the hardware slightly and lets the least significant bit of a function pointer carry other information. Unlike branches, the jalr immediate is not multiplied by 2. This choice avoids creating yet another immediate format.
I checked on QEMU. I added 1 to the address of a small function tgt to make the odd address 0x80000021 and jumped with jalr ra, 0(t0). tgt is at 0x80000020 and returns the ra it received as is. The result was a normal return, and the returned ra was 0x80000290, the address of the instruction after jalr (a difference of 4). jalr ra, 1(t0), with the 1 in the immediate instead, also landed on tgt in the same way.
These two instructions produce every shape of function call.
| Pseudo-instruction | Actual instruction | Use |
|---|---|---|
j L |
jal x0, L |
Unconditional jump |
jal L |
jal ra, L |
Nearby function call |
ret |
jalr x0, 0(ra) |
Return |
jr t0 |
jalr x0, 0(t0) |
Indirect jump (switch tables, etc.) |
call f |
auipc ra, … + jalr ra, …(ra) |
Distant function call |
tail f |
auipc t1, … + jalr x0, …(t1) |
Tail call |
Let's look at actual compiler output. The function twice(x) returns add3(add3(x)).
twice:
addi sp,sp,-16
sw ra,12(sp) # save ra, since it will be overwritten
jal add3 # first call: ra = next address
lw ra,12(sp) # restore my return address
addi sp,sp,16
j add3 # last call: tail call
The second add3 is a j, not a jal. Since twice ends right after add3 ends anyway, it restores ra to the address of twice's caller and simply jumps. Then add3's ret goes straight back to the original caller. This is a tail call. indirect, which calls through a function pointer, became lw a5, … followed by jalr a5 (= jalr ra, 0(a5)). On QEMU, twice(10) gave 16 and indirect(10) gave 14, both correct.
In the object file (.o) right after compilation, both calls were two instructions, auipc ra + jalr ra, because at compile time it isn't known how far away add3 will be. Once the linker knew the actual distance and found it within ±1 MiB, it shrank it to a single jal (linker relaxation, covered in detail in part 6).
Which registers jal and jalr use is a hint to the hardware. The specification defines in a table the rules for pushing to and popping from the return address stack (RAS) when rd or rs1 is x1 or x5. jal ra is a push and ret is a pop. Part 18, on branch predictors, implements this table.
3. Loads and stores
RV32I is a load-store architecture. Only loads and stores touch memory, and the address is always rs1 + a sign-extended 12-bit immediate. Loads use the I format and stores use the S format.
There are five loads. lw reads 4 bytes, lh/lhu read 2 bytes, and lb/lbu read 1 byte. When a value narrower than 32 bits goes into a register, the presence or absence of u decides what fills the upper bits. Without u, the top bit of the value read is copied (sign extension); with u, the upper bits are filled with 0 (zero extension).

I placed 7f 80 ff 01 34 12 00 80 in memory and read it on QEMU. Reading 0x80 at +1 with lb gives 0xffffff80 (−128) because the top bit is 1, and reading it with lbu gives 0x00000080 (128). lw fills the low end of the register with the four bytes starting from the lowest address, giving 0x01ff807f. This is little endian. The specification says the execution environment determines endianness, but instruction fetch is always little endian and data is little endian by default (big endian is only enabled through optional bits such as mstatus.MBE in the Privileged spec).
There is no lwu because on RV32, lw fills the whole register, leaving nothing to extend. On RV64, lwu appears (part 5).
There are three stores: sw, sh, and sb. They write the low 4, 2, or 1 bytes of rs2 to memory. No sign or zero extension is needed, which is why there is no such thing as sbu. Writing 0xcafeabcd with sb to +1 of a word holding 0x11223344 put only the low byte 0xcd in, giving 0x1122cd44, and then writing 0xcafebeef with sh to +2 gave 0xbeefcd44.
There is one rule that is easy to miss in a design: a load with rd = x0 must still raise exceptions and produce side effects. The value is discarded, but if the address is an MMIO register where the read itself clears device state, the read must happen. An optimization like "skip the load if rd=x0" is a wrong implementation.
4. Misaligned accesses: the specification leaves them to the execution environment
If the address of an lw is not a multiple of 4, the access is "misaligned." The rules in the specification (section 2.6) are:
- A naturally aligned access must never raise an address-misaligned exception.
- The behavior of misaligned accesses is defined by the execution environment (EEI). Hardware may handle them, software may emulate them through an invisible M-mode trap, or a mix of the two; any is fine.
- If the execution environment does not guarantee them, an access either succeeds or raises an address-misaligned exception or an access-fault exception. In places that must not be emulated, such as regions with side effects, an access fault may be raised.
- Even when a misaligned access succeeds, it is not guaranteed to be atomic, and it may be very slow.
The specification also explains why it doesn't settle on one behavior. Dedicated instructions for misaligned access (like MIPS's lwl/lwr) complicate the ISA, so ordinary loads and stores are used and hardware support is left to the implementation. For reference, the application processor profiles (RVA22, RVA23) require support for misaligned accesses to cacheable main memory (Zicclsm). Speed is not guaranteed, though, so trap emulation is acceptable.
What does QEMU do? I read and wrote at several misaligned addresses in a 16-byte buffer 00 11 22 … ff.
| Access | Address | Result | Trap |
|---|---|---|---|
lw |
+1 | 44332211 |
None |
lw |
+2 | 55443322 |
None |
lh |
+1 | 00002211 |
None |
lw |
+6 | 99887766 |
None |
sw |
+5 | Written across two words | None |
I had the trap handler attached, but it was never called. QEMU 11.1.3's virt simply handled misaligned ordinary loads and stores. Both lw +6, which crosses an 8-byte boundary, and sw +5, which spans two words (split into b2c3d444 and bbaa99a1), were plain little endian. Real chips may differ. Small microcontroller cores usually raise an mcause 4 (load address misaligned) or 6 (store address misaligned) exception, and if needed, M-mode software emulates the access by splitting it into bytes. That's how code that ran fine on QEMU can stop with a trap on a real board.
Instruction address alignment is different
On the instruction side the rule is clear. If the target of jal, jalr, or a taken branch is not 4-byte aligned, an instruction-address-misaligned exception (mcause 0) is raised. However, on machines with 16-bit instructions, such as with the C extension, 2-byte alignment is legal, so this exception cannot occur. And this exception is raised on the jump instruction, not when fetching the target. A branch whose condition is false raises no exception even if its target is misaligned.
QEMU's default CPU has the C extension enabled (misa = 0x401411ad). On a CPU with C disabled (-cpu rv32,c=false, misa = 0x401411a9), I sent a jalr to target+2.
| Situation | mcause | mepc | mtval |
|---|---|---|---|
C off, jalr → +2 |
0 (instruction address misaligned) | The jalr itself |
Target 0x80000022 |
C on, jalr → +2 |
2 (illegal instruction) | 0x80000022 |
0 |
ecall (M-mode) |
11 | The ecall itself |
0 |
ebreak |
3 | The ebreak itself |
ebreak address |
With C off, mepc pointed to the jalr instruction (0x80000374) and mtval held the bad target. With C on, the jump itself was legal, so the upper half 0x0000 of a nop (0x00000013) was interpreted as a 16-bit instruction, and an all-zero 16-bit instruction is illegal, so mcause 2 was raised. The same mistake shows up as different exceptions depending on the CPU configuration.
5. fence, ecall, ebreak
fence sets the order of memory accesses. It states "these kinds of accesses before the fence must be seen before those kinds of accesses after it." For the predecessor and successor, 4 bits each select I (device input), O (device output), R (memory read), and W (memory write).
31..28 27..24 23..20 19..15 14..12 11..7 6..0
fm PI PO PR PW SI SO SR SW rs1 000 rd 0001111
I compared the machine code produced by the assembler with values computed by hand from this layout.
fence rw,rw 0330000f pred=RW succ=RW
fence 0ff0000f pred=IORW succ=IORW
fence w,r 0120000f pred=W succ=R
fence.tso 8330000f fm=1000, RW,RW
pause 0100000f pred=W succ=none
fence.tso is a fence rw,rw with the fm field set to 1000. It is a weaker fence that relaxes only write-then-read ordering, so the specification says it is correct to implement it as fence rw,rw, ignoring fm. pause (Zihintpause) is a HINT that reuses the fence encoding with an empty succ. A base implementation must ignore the rs1/rd fields and reserved fm/pred/succ combinations and treat them as an ordinary fence. That is why pause runs without problems even on old cores that don't know it. On QEMU, all five went through without trapping.
From the hardware point of view, for a simple core that accesses memory one at a time in order, fence is effectively a nop. The specification also states that a simple implementation may ignore pred/succ and always do a conservative fence. With a store buffer, waiting until it drains is a common implementation (my assumption). The exact meaning is covered in part 9, on the memory model.
ecall and ebreak use the SYSTEM opcode (1110011) and are distinguished by immediates 0 and 1. ecall requests a service from the execution environment (operating system, SBI firmware), and ebreak hands control to a debugger. GCC also generates ebreak for __builtin_trap(). Both cause precise exceptions, and as in the table in section 4, mepc is the address of the instruction itself. So if the trap handler doesn't add 4 to mepc, it returns to the same ecall and loops forever. The handler in this hands-on also does mepc += 4.
The mcause of ecall differs by the calling mode: 8 (U), 9 (S), 11 (M). For ebreak's mtval, the Privileged spec allows writing either 0 or the relevant address, and QEMU wrote the address of the ebreak. Privileged handling itself (mode switching, mret) is covered in part 25.
6. Hands-on: comparing results with a reference model
I wrote the trap handler with GCC's interrupt attribute. The compiler generates the register saves and the mret.
__attribute__((interrupt("machine"), aligned(4)))
void handler(void) {
uint32_t c, e, v;
__asm__ volatile("csrr %0, mcause\n"
"csrr %1, mepc\n"
"csrr %2, mtval"
: "=r"(c), "=r"(e), "=r"(v));
show(" trap", c, e, v);
ntrap++;
/* skip the trapping instruction (assumes no C) */
__asm__ volatile("csrw mepc, %0" : : "r"(e + 4));
}
/* first line of main: csrw mtvec, handler */
Branches were run with a macro that leaves "1 if taken, 0 if not."
#define BR(op, a, b) ({ uint32_t t_; \
__asm__ volatile("li %0,1\n " op " %1,%2,1f\n" \
" li %0,0\n1:" \
: "=&r"(t_) : "r"(a), "r"(b)); t_; })
Using csrr/csrw requires Zicsr, so I built with -march=rv32i_zicsr_zihintpause. The results were compared with a Python reference model (branch conditions, little-endian byte assembly and sign extension, store byte masks, fence field assembly, and B-format immediate reconstruction).
| Test | Count | Matches |
|---|---|---|
| 6 branches × 4 input pairs | 24 | 24 |
jalr least significant bit, ra = pc+4 |
3 | 3 |
| 9 loads + 3 stores | 12 | 12 |
| Misaligned load values, no trap | 5 | 5 |
| Trap mcause/mtval | 3 | 3 |
| Branch immediates, fence encodings, call results | 9 | 9 |
56 in total, all matching. When the misaligned jump was made on the CPU with C enabled, the handler skipped ahead with mepc += 4 and ran into one 16-bit fragment after another as illegal instructions (3 traps). That's because the handler assumed "instruction length 4." A real handler must determine the length from the low 2 bits of the instruction at mepc.
7. Design checklist: branch/jump and load/store units
| Item | Rule |
|---|---|
| Branch compare | Selected by funct3. blt/bge are signed, bltu/bgeu unsigned |
| Branch target | The branch's own pc + 13-bit signed distance. An adder separate from the comparison is common |
| Branch alignment exception | Only when the condition is true and the target is not 4-byte aligned. None with C |
jalr target |
(rs1 + imm) & ~1. Bit 1 is checked for alignment when C is absent |
jalr rd = rs1 |
jalr ra, 0(ra): rs1 must be read first, then pc+4 written to rd |
| Jump exception location | Alignment exceptions on the jump/branch instruction. Access faults on fetching the target occur at the target |
| Load size | funct3[1:0] = 0, 1, 2 → byte, half, word; zero extension if funct3[2] = 1 |
| Load rd = x0 | Only the value is discarded. Exceptions and MMIO side effects remain |
| Store lanes | 4-bit byte enable from the low 2 address bits and size. Data: replicate the low bytes of rs2 across lanes (common choice) |
| Misaligned access | Left to the execution environment. Small cores raise mcause 4/6 exceptions; large cores do two accesses in hardware (common choice) |
| Alignment exception mtval | The misaligned effective address (QEMU jump: the bad target) |
fence |
nop if accesses happen one at a time in order; drain the store buffer if there is one. Reserved combinations act as an ordinary fence |
ecall/ebreak |
mepc = the instruction itself. mcause 8/9/11, 3 |
The load size row simplifies the decoder. Since lb=000, lh=001, lw=010, lbu=100, and lhu=101, the low two bits of funct3 can be used directly as the size and the top bit as "disable sign extension." Stores use the same size encoding: sb=000, sh=001, sw=010. So the load aligner (which picks bytes from the word read and extends them) and the store lane generator share the same size signal.
Summary
- The six branches compare two registers directly. The distance is ±4 KiB, and when out of range, the assembler replaced it with an inverted-condition branch +
j(+4094 stayed as is, +4096 was changed). jalis pc-relative within ±1 MiB, andjalris rs1-relative and clears the least significant bit of the target (0x80000021→0x80000020). Both leave pc+4 in rd, andret,call,tail, and tail calls are all combinations of these.- Loads split into sign/zero extension by the presence of
u. Misaligned data accesses are left to the execution environment, and QEMU handled them without trapping. When an instruction target was misaligned without C, an mcause 0 exception was raised on the jump instruction. fencestates ordering with 4 bits each for pred and succ, andecall/ebreakleave their own address inmepc. All 56 execution results matched the reference model.
Next post: Part 5 moves on to RV64I. With 64-bit registers come ld, sd, and lwu, and W instructions such as addw, subw, and sllw sign-extend 32-bit results into 64-bit registers. Using QEMU and objdump, it checks why W instructions are needed (C's int), how lui and auipc sign-extend on 64-bit, and how many instructions it takes to build a 64-bit constant.
References
- The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture, Version 20240411. 2.5 Control Transfer Instructions (including the RAS hint table), 2.6 Load and Store Instructions (misaligned accesses), 2.7 Memory Ordering Instructions (FENCE), 2.8 Environment Call and Breakpoints. https://github.com/riscv/riscv-isa-manual
- The RISC-V Instruction Set Manual, Volume II: Privileged Architecture, Version 20240411. 3.1.14
mepc, 3.1.15mcause, 3.1.16mtval, 3.3.1 ECALL/EBREAK - RISC-V Profiles (RVA22, RVA23, Zicclsm). https://github.com/riscv/riscv-profiles
- D. Patterson, A. Waterman, The RISC-V Reader: An Open Architecture Atlas, 2017. Chapter 2, RV32I
- GCC RISC-V function attribute
interrupt. https://gcc.gnu.org/onlinedocs/gcc/RISC-V-Function-Attributes.html - QEMU
virtboard documentation. https://www.qemu.org/docs/master/system/riscv/virt.html
