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.

OperationRegister formImmediate formResult
addaddaddirs1 + operand
subtractsub(addi with a negative constant)rs1 - rs2
bitwise AND, OR, XORand, or, xorandi, ori, xoribit by bit
shift left logicalsllsllirs1 shifted left, zeros shifted in
shift right logicalsrlsrlirs1 shifted right, zeros shifted in
shift right arithmeticsrasrairs1 shifted right, copies of the sign bit shifted in
set if less thansltslti1 if rs1 < operand as signed numbers, else 0
set if less than unsignedsltusltiu1 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-instructionReal instructionMeaning
li a0, 0addi a0, x0, 0load a small constant
mv a2, a0addi a2, a0, 0copy a register
zext.b a5, a5andi a5, a5, 255keep only the low byte (encoded as 0x0FF7F793)
neg a0, a0sub a0, x0, a0negate
not a0, a0xori a0, a0, -1invert all bits
seqz a0, a1sltiu a0, a1, 11 if a1 is zero
snez a0, a1sltu a0, x0, a11 if a1 is not zero
nopaddi x0, x0, 0do 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.
Did you find what you were looking for?

Quiz

No quiz questions available for this article.