Most of what a processor does is arithmetic and logic on values in registers: adding, subtracting, comparing, shifting and combining bits. In RV32I these operations are a small, regular group of instructions, each available with two register operands or with a register and a constant. This article goes through them all, explains how a 12-bit constant field can still produce any 32-bit value, shows the pseudo-instructions that assembly listings are full of, and follows a compiled RISC-V loop that replaces five comparisons with one clever constant, a shift and a mask.
13 min read | Beginner
What You'll Learn
- List the RV32I arithmetic, logic, shift and comparison instructions and their immediate forms
- Build any 32-bit constant with lui and addi, including the carry correction
- Recognize common pseudo-instructions and the real instructions behind them
- Explain how RISC-V handles overflow and comparisons without condition flags
Two Forms of Every Operation
RV32I integer operations come in two forms. The register-register form (R-type) takes both operands from registers: add a0, a1, a2 computes a1 + a2. The register-immediate form (I-type) takes the second operand from a 12-bit constant in the instruction: addi a0, a1, 5 computes a1 + 5. The immediate is sign-extended to 32 bits, so it can be anything from -2,048 to +2,047. RISC-V Instruction Formats and Encoding shows where the fields sit.
| Operation | Register form | Immediate form | Result |
|---|---|---|---|
| add | add | addi | rs1 + operand |
| subtract | sub | (addi with a negative constant) | rs1 - rs2 |
| bitwise AND, OR, XOR | and, or, xor | andi, ori, xori | bit by bit |
| shift left logical | sll | slli | rs1 shifted left, zeros shifted in |
| shift right logical | srl | srli | rs1 shifted right, zeros shifted in |
| shift right arithmetic | sra | srai | rs1 shifted right, copies of the sign bit shifted in |
| set if less than | slt | slti | 1 if rs1 < operand as signed numbers, else 0 |
| set if less than unsigned | sltu | sltiu | 1 if rs1 < operand as unsigned numbers, else 0 |
A few details are worth knowing. There is no subi: subtracting a constant is addi with the negative constant, so the ISA saves an encoding. Shifts use only the low 5 bits of the shift amount, because a 32-bit value can only be shifted by 0 to 31 places; the immediate shifts carry the amount in a 5-bit field. And every operation works on all 32 bits at once and writes all 32 bits of the destination.
Logical and Arithmetic Shifts
Shifting left by n multiplies by 2n; shifting right by n divides by 2n. For an unsigned number, the bits that come in from the left are zeros: that is a logical shift. For a signed number, they must be copies of the sign bit, or a negative number would turn positive: that is an arithmetic shift. -16 is 0xFFFF_FFF0; srai by 2 gives 0xFFFF_FFFC, which is -4, while srli by 2 gives 0x3FFF_FFFC, a large positive number. Numbers in Hardware works through more examples.
No Flags: Overflow and Comparisons
Many processors, including x86 and Arm, keep a set of condition flags: after an addition, extra bits record whether the result was zero, negative, or overflowed. Later instructions test those flags. RISC-V has no flags at all. An add simply produces the low 32 bits of the sum; if the true sum does not fit, it wraps around, and nothing records that.
The designers left flags out because they create hidden connections between instructions: every arithmetic instruction writes them, and a later branch reads them, which complicates a pipeline that runs instructions out of order. Instead, results that would go into flags are computed explicitly into ordinary registers when a program needs them. A comparison writes 0 or 1 into a register with slt or sltu. Unsigned overflow of an addition is detected with one extra instruction: after add t0, a0, a1, the sum wrapped exactly when it is smaller than an operand, so sltu t1, t0, a0 sets t1 to the carry. Programs that need overflow detection pay for it; the large majority, which do not, pay nothing.
Branches also compare registers directly, as Branches and Jumps describes, so a test and a branch never need a flag in between.
Quick Check
Q: a0 holds 0xFFFF_FFFF. What do slti t0, a0, 1 and sltiu t1, a0, 1 produce?
Show Answer
As a signed number a0 is -1, which is less than 1, so t0 = 1. As an unsigned number it is 4,294,967,295, which is not less than 1, so t1 = 0. The same bits compare differently depending on the instruction.
Constants Wider Than 12 Bits
A 12-bit immediate covers small constants, which are by far the most common: loop steps, array offsets, character codes. Larger ones need two instructions. lui (load upper immediate) writes a 20-bit constant into bits 31 to 12 of a register and zeros into bits 11 to 0. An addi then adds the low 12 bits. A compiled vowel test builds a 32-bit constant exactly this way:
1c: 00104637 lui a2,0x104 # a2 = 0x0010_4000 38: 11160613 addi a2,a2,273 # a2 = 0x0010_4000 + 0x111 = 0x0010_4111
There is one trap. The addi immediate is signed: if bit 11 of the low part is 1, addi adds a negative number. To build 0x1234_5FFF, the low 12 bits are 0xFFF, which addi reads as -1. The upper part must therefore be one larger to make up for it: lui t0, 0x12346 gives 0x1234_6000, and addi t0, t0, -1 gives 0x1234_5FFF. The assembler does this correction automatically when it expands the pseudo-instruction li t0, 0x12345FFF.
auipc (add upper immediate to PC) is the companion of lui. It adds the 20-bit constant, shifted into the upper bits, to the address of the instruction itself. Combined with an addi or a load offset, it reaches any address within 2 GB of the code, without knowing where the program was placed in memory. That makes it the basis of position-independent code, which works wherever it is loaded.
Pseudo-Instructions
Assembly listings are full of names that are not real RV32I instructions. They are pseudo-instructions: readable aliases that the assembler translates into one or two real instructions, and that the disassembler shows again when it recognizes the pattern. Even a very small program contains several:
| Pseudo-instruction | Real instruction | Meaning |
|---|---|---|
| li a0, 0 | addi a0, x0, 0 | load a small constant |
| mv a2, a0 | addi a2, a0, 0 | copy a register |
| zext.b a5, a5 | andi a5, a5, 255 | keep only the low byte (encoded as 0x0FF7F793) |
| neg a0, a0 | sub a0, x0, a0 | negate |
| not a0, a0 | xori a0, a0, -1 | invert all bits |
| seqz a0, a1 | sltiu a0, a1, 1 | 1 if a1 is zero |
| snez a0, a1 | sltu a0, x0, a1 | 1 if a1 is not zero |
| nop | addi x0, x0, 0 | do nothing |
Most of them rely on register x0, which always reads as zero and ignores writes. Copying is adding zero, negating is subtracting from zero, and a do-nothing instruction is an addition whose result goes to x0. The ISA stays small, and the readability comes from the assembler.
How the Compiler Uses Them: Two Real Loops
Strength Reduction in sum
One small program fills an array with a[i] = 3 * i + 1. RV32I has no multiply instruction, yet the compiled loop contains no multiplication and no call to a multiply routine:
2c: 00100693 li a3,1 # a3 = 3*0 + 1 30: 00d7a023 sw a3,0(a5) # loop: a[i] = a3 34: 00478793 addi a5,a5,4 # next element 38: 00368693 addi a3,a3,3 # a3 = a3 + 3, the value for i + 1 3c: fec79ae3 bne a5,a2,30
The compiler noticed that each value is the previous one plus 3, so it replaced the multiplication by an addition per iteration. This is called strength reduction: replacing an expensive operation by a cheaper one that gives the same results. Multiplication by a constant is also often done with shifts and adds: 10 x n is (n shifted left by 3) + (n shifted left by 1).
A Vowel Test Without Branches in hello
Another program counts the vowels in a message with the C expression c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u'. Written naively, that is five comparisons and five branches. The compiler produced this instead:
1c: lui a2,0x104 # (before the loop) a2 = 0x0010_4111, built in two steps 34: li a6,20 # a6 = 20 38: addi a2,a2,273 ... 48: addi a5,a5,-97 # a5 = c - 'a' 4c: zext.b a5,a5 # keep 8 bits: letters a..z become 0..25, anything below 'a' becomes 128 or more 50: srl a3,a2,a5 # shift the constant right by c - 'a' 54: andi a3,a3,1 # take its lowest bit 58: bltu a6,a5,60 # if c - 'a' > 20, it is not a vowel: skip the add 5c: add a0,a0,a3 # count += that bit
The constant 0x0010_4111 is a bit mask with a 1 at the position of each vowel counted from 'a': bit 0 for 'a', bit 4 for 'e', bit 8 for 'i', bit 14 for 'o' and bit 20 for 'u'. In binary, 1 0000 0100 0001 0001 0001 is exactly 0x104111. Shifting the mask right by c - 'a' moves the bit for the current letter to position 0, and andi keeps only that bit: 1 for a vowel, 0 for any other letter. The single bltu filters out characters beyond 'u' and below 'a' (which became large numbers after the zero extension), because the shift uses only 5 bits of the amount and would otherwise wrap around. Five comparisons became one subtraction, a mask, a shift, an AND and one branch.
Tricks like this explain why simple instructions are enough. A compiler combines them into whatever the program needs, and the hardware only has to make each simple step fast.
Where Does This Matter?
Bit masks, shifts and logical operations are the daily tools of firmware and driver code: setting and testing fields in device registers, packing data for communication, computing checksums. In one test, a checksum of the kind used in storage and networking was over 80 percent arithmetic and logic instructions, mostly shifts and XORs. Bit-manipulation extensions (Zba, Zbb) add instructions for the most frequent patterns, such as counting leading zeros or rotating, to shorten such code further.
How Much of a Program Is Arithmetic?
How much of a program is arithmetic? One test took six small programs and counted every executed instruction. What was left after removing loads, stores, branches and jumps counts as arithmetic and logic, including lui and auipc. The checksum was at the top, at over 80 percent. Copying memory, multiplying matrices, walking a linked list and adding up an array came between about two thirds and one half. A sort was the lowest, at about a fifth.
So in five of the six programs, arithmetic and logic made up half or more of all instructions. Other programs would give other shares, but this pattern is common. The sort is the exception: sorting is mostly loading values, comparing them in branches and storing them back. This is why the ALU is busy in almost every cycle and why its speed, and the speed of getting operands to it, matters so much in processor design.
Quick Check
Q: Which two instructions does li t0, 0x00012800 expand to?
Show Answer
The low 12 bits are 0x800, which addi reads as -2,048. So the upper part must be 0x12 + 1 = 0x13: lui t0, 0x13 gives 0x0001_3000, and addi t0, t0, -2048 gives 0x0001_3000 - 0x800 = 0x0001_2800.
Common Mistakes
Mistake #1: Forgetting the carry correction in lui/addi
When bit 11 of the low part is 1, addi subtracts. Building 0x1234_5FFF as lui 0x12345 plus addi 0xFFF gives 0x1234_4FFF. Add 1 to the upper part, or let the assembler's li do it.
Mistake #2: Expecting overflow to be detected
RISC-V arithmetic silently wraps around. Code that must detect overflow, for example in safety-critical arithmetic, has to test for it explicitly, such as with sltu after an unsigned add.
Mistake #3: Using srl for signed division by a power of two
srl shifts zeros into a negative number and makes it a large positive one. Signed values need sra, and even then a negative odd number rounds toward minus infinity, unlike C division, which rounds toward zero; compilers add a correction for that.
Practice Exercise
Challenge: Masks and Constants
(a) Using the vowel-test technique, compute the mask constant that would count the letters 'a', 'b' and 'z' instead. Which two instructions build it? (b) Write the two instructions that load 0xDEAD_BEEF into t0. (c) Assume a checksum program executes 72,000 instructions, of which 60,000 are arithmetic and logic. If a bit-manipulation extension let 20 percent of those be replaced by half as many instructions, how many instructions would it execute?
Hint
(a) The bit position is the letter minus 'a'. (b) The low 12 bits of 0xDEADBEEF are 0xEEF, whose bit 11 is 1. (c) 20 percent of 60,000 instructions shrink to half their count.
Solution
(a) 'a' is bit 0, 'b' bit 1, 'z' bit 25: mask = 225 + 2 + 1 = 0x0200_0003. The low 12 bits 0x003 are positive, so lui t0, 0x2000 and addi t0, t0, 3. The range check would then be c - 'a' > 25. (b) 0xEEF has bit 11 set, so addi reads it as 0xEEF - 4,096 = -273; the upper part becomes 0xDEADB + 1 = 0xDEADC: lui t0, 0xDEADC then addi t0, t0, -273, which gives 0xDEAD_C000 - 0x111 = 0xDEAD_BEEF. (c) 20 percent of 60,000 is 12,000 instructions, replaced by 6,000, saving 6,000: 72,000 - 6,000 = 66,000 instructions, 8.3 percent fewer.
Summary
- RV32I has add, sub, and, or, xor, three shifts and two set-less-than comparisons, almost all in register and 12-bit immediate forms.
- There are no condition flags: comparisons write 0 or 1 into a register, and overflow must be tested explicitly.
- lui plus addi build any 32-bit constant, with the upper part increased by 1 when bit 11 of the low part is set; auipc builds addresses relative to the PC.
- Pseudo-instructions like li, mv, neg, not, seqz and zext.b are aliases built on real instructions and register x0.
- Arithmetic and logic were half or more of the instructions in five of six small programs in one test; compilers combine them cleverly, as in the vowel test with the mask 0x104111.