资源与支持

SiFive 博客

来自 RISC-V 专家的最新洞察与深度技术解析

March 12, 2026

All Aboard, Part 4: The RISC-V Code Models (2026 Edition)

This article was originally published in 2017, but revamped and updated for 2026. This edition covers the latest toolchain implementations and introduces the new large code model.


The RISC-V ISA was designed to be both simple and modular. In order to achieve these design goals, RISC-V minimizes one of the largest costs in implementing complex ISAs: addressing modes. Addressing modes are expensive both in small designs (due to decode cost) and large designs (due to implicit dependencies).

RISC-V only has three addressing modes:

  • PC-relative, via the auipc, jal and br* instructions.
  • Register-offset, via the jalr, addi and all memory instructions.
  • Absolute, via the lui instruction (though arguably this is just x0-offset).

These addressing modes have been carefully selected in order to allow for efficient code generation with a minimum of hardware complexity. We achieve this simplicity by relying on modern toolchains to optimize addressing in software -- this stands in stark contrast to traditional ISAs, which implement a plethora of addressing modes in hardware instead. Studies have shown that the RISC-V approach is sound: we are able to achieve similar code size in benchmarks while having vastly simpler decoding rules and a significant amount of free encoding space.

All these hardware complexity reductions come at the cost of increased software complexity. This article introduces another bit of software complexity in RISC-V: the concept of a code model. Just like relocations and relaxations, code models are not specific to RISC-V -- in fact, the RISC-V toolchain has fewer code models than most popular ISAs, largely because we rely on software optimizations instead of wacky addressing modes, which allows our addressing modes to be significantly more flexible.

What is a Code Model?

Most programs do not fill the entire address space available to them with symbols (most don't fill it at all, but those that do tend to fill their address space with heap). ISAs tend to take advantage of this locality by implementing shorter addressing modes in hardware and relying on software to provide larger address modes. The code model determines which software addressing mode is used, and, therefore, what constraints are enforced on the linked program. Software addressing modes determine how the programmer sees addresses, as opposed to hardware addressing modes which determine how address bits in instructions are handled.

Code models are necessary due to the split between the compiler and the linker: when generating an unlinked object, the compiler doesn't know the absolute address of any symbol but it still must know what addressing mode to use as some addressing modes may require scratch registers to operate. As the compiler cannot generate actual addressing code, it generates addressing templates (known as relocations) that the linker can then fix up once it knows the actual addresses of each symbol. The code model determines what these addressing templates look like, and thus which relocations are emitted.

This is probably best explained with an example. Imagine the following C code:

long global_symbol[2];

int main() {
  return global_symbol[0] != 0;
}

Even though a single GCC invocation can produce a binary for this simple case, under the covers the GCC driver script is actually running the preprocessor, then the compiler, then the assembler and finally the linker. The --save-temps argument to GCC allows users to see all these intermediate files, and is a useful argument for poking around inside the toolchain.

$ riscv64-unknown-linux-gnu-gcc cmodel.c -o cmodel -O3 --save-temps

Each step in this run of the GCC wrapper script generates a file:

  • cmodel.i: The preprocessed source, which expands any preprocessor directives (things like #include or #ifdef).
  • cmodel.s: The output of the actual compiler, which is an assembly file (a text file in the RISC-V assembly format).
  • cmodel.o: The output of the assembler, which is an unlinked object file (an ELF file, but not an executable ELF).
  • cmodel: The output of the linker, which is a linked executable (an executable ELF file).

In order to understand why the code model exists, we must first examine this toolchain flow in a bit more detail. Since this is a simple source file with no preprocessor macros, the preprocessor run is pretty boring: all it does is emit some directives to be used if debugging information is later generated:

$ cat cmodel.i
# 1 "cmodel.c"
# 1 "built-in"
# 1 "command-line"
# 31 "command-line"
# 1 "/scratch/palmer/work/upstream/riscv-gnu-toolchain/build/install/sysroot/usr/include/stdc-predef.h" 1 3 4
# 32 "command-line" 2
# 1 "cmodel.c"
long global_symbol;

int main() {
  return global_symbol != 0;
}

The preprocessed output is then fed through the compiler, which generates an assembly file. This file is plain text that contains RISC-V assembly code and therefore is easy to read:

$ cat cmodel.s
main:
  lui   a5,%hi(global_symbol)
  ld    a0,%lo(global_symbol)(a5)
  snez  a0,a0
  ret

The generated assembly contains a pair of instructions to address global_symbol: lui and then ld. This imposes a constraint on the address that global_symbol can take on: it must be addressable by a 32-bit signed absolute constant (not 32-bit offset from some register or the PC, but actually a 32-bit address). Note that the restriction on symbol addresses is not related to the size of a pointer on this architecture: specifically pointers may still be 64 bits here, but all global symbols must be addressable by a 32-bit absolute address.

After the compiler generates assembly, the GCC wrapper script calls the assembler to generate an object file. This file is an ELF binary, which can be read with a variety of tools provided by Binutils. In case we'll use objdump to show the symbol table, disassemble the text section and show the relocations generated by the assembler:

$ riscv64-unknown-linux-gnu-objdump -d -t -r cmodel.o

cmodel.o:     file format elf64-littleriscv

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 cmodel.c
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 l    d  .data  0000000000000000 .data
0000000000000000 l    d  .bss   0000000000000000 .bss
0000000000000000 l    d  .text.startup  0000000000000000 .text.startup
0000000000000000 l    d  .comment       0000000000000000 .comment
0000000000000000 g     F .text.startup  000000000000000e main
0000000000000010       O *COM*  0000000000000008 global_symbol

Disassembly of section .text.startup:

0000000000000000 main:
   0:   000007b7                lui     a5,0x0
                        0: R_RISCV_HI20 global_symbol
                        0: R_RISCV_RELAX        *ABS*
   4:   0007b503                ld      a0,0(a5) # 0 main
                        4: R_RISCV_LO12_I       global_symbol
                        4: R_RISCV_RELAX        *ABS*
   8:   00a03533                snez    a0,a0
   c:   8082                    ret

At this point we have an object file, but we still don't know the actual addresses of any global symbols. This is where there's a bit of overlap in the roles of each component of the toolchain: it's the assembler's job to convert textual instructions into bits, but in the cases where those bits depend on the address of a global symbol (like the lui in the code above, for example) the assembler can't know what those bits should actually be. In order to allow the linker to fill out these bits in the final executable object file, the assembler generates entries in a relocation table for every bit range the linker is expected to fill out. Relocations define a bit range that the linker is meant to fill out when linking the code together. The specific definition of any relocation type present in the text section is ISA-specific, the RISC-V definitions can be found in our ELF psABI document.

After assembling the program, the GCC wrapper script runs the linker to generate an executable. This is another ELF file, but this time it's a full executable. Since this contains lots of C library code, I'm going to show only the relevant fragments of it here:

$ riscv64-unknown-linux-gnu-objdump -d -t -r cmodel
cmodel:     file format elf64-littleriscv

SYMBOL TABLE:
0000000000012038 g     O .bss    0000000000000010              global_symbol
...

Disassembly of section .text:

0000000000010330 main:
 10330:       67c9                    lui     a5,0x12
 10332:       0387b503                ld      a0,56(a5) # 12038 global_symbol
 10336:       00a03533                snez    a0,a0
 1033a:       8082                    ret

There are a few interesting things to note here:

  • The symbol table contains symbols with actual, absolute values. This is the whole point of the linker.
  • The text section contains the correct bits to actually reference the global symbols, as opposed to just a bunch of 0s.
  • The relocations against global symbols have been removed, as they're no longer necessary. Some relocations may still exist in executables to allow for things like dynamic linking, but in this simple case there are none.

Until now, this example has been using RISC-V's default code model medlow. In order to demonstrate a bit more specifically what a code model is it's probably best to contrast this with another code model, medany. The difference can be summed up with a single example output:

0000000000000000 main:
   0:   00000797                auipc   a5,0x0
                        0: R_RISCV_PCREL_HI20   global_symbol
                        0: R_RISCV_RELAX        *ABS*
   4:   0007b503                ld      a0,0(a5) # 0 main
                        4: R_RISCV_PCREL_LO12_I .LA0
                        4: R_RISCV_RELAX        *ABS*
   8:   00a03533                snez    a0,a0
   c:   8082                    ret

Specifically, the medany code model generates auipc/ld pairs to refer to global symbols, which allows the code to be linked at any address; while medlow generates lui/ld pairs to refer to global symbols, which restricts the code to be linked around address zero. They both generate 32-bit signed offsets for referring to symbols, so they both restrict the generated code to being linked within a 2GiB window.

What does -mcmodel=medlow mean?

This selects the medium-low code model, which means the program and its statically defined symbols must lie within a single 2 GiB address range and must lie between absolute addresses -2 GiB and +2 GiB. Addressing for global symbols uses a lui instruction to form the upper bits of the address, followed by a load/store (e.g. lui+ld for data access) or addi (for address materialization), which emit the R_RISCV_HI20/R_RISCV_LO12_I relocation pair. Here's an example of some generated code using the medlow code model:

$ cat cmodel.c
long global_symbol[2];

int main() {
  return global_symbol[0] != 0;
}

$ riscv64-unknown-linux-gnu-gcc cmodel.c -o cmodel -O3 --save-temps -mcmodel=medlow

$ cat cmodel.s
main:
        lui     a5,%hi(global_symbol)
        ld      a0,%lo(global_symbol)(a5)
        snez    a0,a0
        ret

$ riscv64-unknown-linux-gnu-objdump -d -r cmodel.o
cmodel.o:     file format elf64-littleriscv

Disassembly of section .text.startup:

0000000000000000 main:
   0:   000007b7                lui     a5,0x0
                        0: R_RISCV_HI20 global_symbol
                        0: R_RISCV_RELAX        *ABS*
   4:   0007b503                ld      a0,0(a5) # 0 main
                        4: R_RISCV_LO12_I       global_symbol
                        4: R_RISCV_RELAX        *ABS*
   8:   00a03533                snez    a0,a0
   c:   8082                    ret

$ riscv64-unknown-linux-gnu-objdump -d -r cmodel
Disassembly of section .text:

0000000000010330 main:
   10330:       67c9                    lui     a5,0x12
   10332:       0387b503                ld      a0,56(a5) # 12038 global_symbol
   10336:       00a03533                snez    a0,a0
   1033a:       8082                    ret

What does -mcmodel=medany mean?

This selects the medium-any code model, which means the program and its statically defined symbols must lie within any single 2 GiB address range. Addressing for global symbols uses an auipc instruction to form a PC-relative base, followed by a load/store (e.g. auipc+ld for data access) or addi (for address materialization), which emit the R_RISCV_PCREL_HI20/R_RISCV_PCREL_LO12_I relocation pair. Here's an example of some generated code using the medany code model:

$ cat cmodel.c
long global_symbol[2];

int main() {
  return global_symbol[0] != 0;
}

$ riscv64-unknown-linux-gnu-gcc cmodel.c -o cmodel -O3 --save-temps -mcmodel=medany

$ cat cmodel.s
main:
        .LA0: auipc     a5,%pcrel_hi(global_symbol)
        ld      a0,%pcrel_lo(.LA0)(a5)
        snez    a0,a0
        ret

$ riscv64-unknown-linux-gnu-objdump -d -r cmodel.o
cmodel.o:     file format elf64-littleriscv

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 cmodel.c
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 l    d  .data  0000000000000000 .data
0000000000000000 l    d  .bss   0000000000000000 .bss
0000000000000000 l    d  .text.startup  0000000000000000 .text.startup
0000000000000000 l       .text.startup  0000000000000000 .LA0
0000000000000000 l    d  .comment       0000000000000000 .comment
0000000000000000 g     F .text.startup  000000000000000e main
0000000000000010       O *COM*  0000000000000008 global_symbol

Disassembly of section .text.startup:

0000000000000000 main:
   0:   00000797                auipc   a5,0x0
                        0: R_RISCV_PCREL_HI20   global_symbol
                        0: R_RISCV_RELAX        *ABS*
   4:   0007b503                ld      a0,0(a5) # 0 main
                        4: R_RISCV_PCREL_LO12_I .LA0
                        4: R_RISCV_RELAX        *ABS*
   8:   00a03533                snez    a0,a0
   c:   8082                    ret

$ riscv64-unknown-linux-gnu-objdump -d -r cmodel
Disassembly of section .text:

0000000000010330 main:
   10330:       00002797                auipc   a5,0x2
   10334:       d087b503                ld      a0,-760(a5) # 12038 global_symbol
   10338:       00a03533                snez    a0,a0
   1033c:       8082                    ret
        ...

What does -mcmodel=large mean?

This selects the large code model, which allows the program and its statically defined symbols to span the entire 64-bit address space. This code model is only available on RV64 -- on RV32, the medlow and medany code models can already address the entire 4 GiB address space, so a large code model is unnecessary. Note that the large code model cannot be used together with position-independent code (-fPIC) or GCC's -mexplicit-relocs option.

The large code model was introduced in GCC 14 and LLVM 20. The specification is defined in Section 5.4 of the psABI.

Unlike medlow and medany, which encode symbol addresses directly in instruction immediates, the large code model uses a constant pool (also known as a literal pool) approach: the full 64-bit address of each symbol is stored in a constant pool entry embedded near the code, and the code loads the address from the pool using a PC-relative auipc/ld pair. This means the constant pool entry must be within ±2 GiB of the accessing instruction, but the target symbol itself can be located anywhere in the 64-bit address space.

To support very large text sections, a function's constant pool is kept in the text section adjacent to the function. So, the large code model requires the text section to be both readable and executable. GCC already implements this by emitting constant pool entries directly within the text section. LLVM, however, currently places constant pool entries in a separate section due to its internal architecture, which may limit scalability for very large text sections.

The large code model does not require any new relocation types. It reuses existing relocations:

  • R_RISCV_PCREL_HI20 + R_RISCV_PCREL_LO12_I on the auipc/ld pair that loads from the constant pool.
  • R_RISCV_64 on the constant pool entry itself, which holds the full 64-bit address of the target symbol.

For function calls, the large code model also uses the constant pool approach -- see the Function Calls and Code Models section below for details.

Here's an example of some generated code using the large code model (note that -fno-PIC is required):

$ cat cmodel.c
long global_symbol[2];

int main() {
  return global_symbol[0] != 0;
}

$ riscv64-unknown-linux-gnu-gcc cmodel.c -o cmodel -O3 --save-temps -mcmodel=large -fno-PIC

$ cat cmodel.s
main:
        ld      a5,.LC0
        ld      a0,0(a5)
        snez    a0,a0
        ret

        .align  3
.LC0:
        .dword  .LANCHOR0

Notice that the compiler generates a ld a5,.LC0 pseudo-instruction to load the address of global_symbol from a constant pool entry (.LC0), which contains the full 64-bit address. The assembler expands this into an auipc/ld pair to perform a PC-relative load from the constant pool:

$ riscv64-unknown-linux-gnu-objdump -d -t -r cmodel.o

cmodel.o:     file format elf64-littleriscv

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 cmodel.c
0000000000000000 l    d  .text.startup  0000000000000000 .text.startup
000000000000000e g     F .text.startup  0000000000000010 main
0000000000000000 g     O .bss   0000000000000010 global_symbol

Disassembly of section .text.startup:

0000000000000006 <.LC0>:
        ...
                        6: R_RISCV_64   .LANCHOR0

000000000000000e <main>:
   e:   00000797                auipc   a5,0x0
                        e: R_RISCV_PCREL_HI20   .LC0
                        e: R_RISCV_RELAX        *ABS*
  12:   ff87b783                ld      a5,-8(a5) # 6 <.LC0>
                        12: R_RISCV_PCREL_LO12_I .L0
                        12: R_RISCV_RELAX       *ABS*
  16:   6388                    ld      a0,0(a5)
  18:   00a03533                snez    a0,a0
  1c:   8082                    ret

There are some key differences compared to the medlow and medany code models:

  • The constant pool entry .LC0 is placed in the text section near the code, containing the full 64-bit address of global_symbol with an R_RISCV_64 relocation.
  • The auipc/ld pair (at offsets 0xe and 0x12) loads the address from the constant pool, not the value of the symbol directly.
  • A second ld instruction (at offset 0x16) then uses the loaded address to access the actual data.

After linking, the constant pool entry is resolved to the actual address of global_symbol:

$ riscv64-unknown-linux-gnu-objdump -d cmodel

Disassembly of section .text:

00000000000104a0 <main>:
   104a0:       00000797                auipc   a5,0x0
   104a4:       ff87b783                ld      a5,-8(a5) # 10498
   104a8:       6388                    ld      a0,0(a5)
   104aa:       00a03533                snez    a0,a0
   104ae:       8082                    ret

The constant pool entry at 0x10498 now contains the resolved address of global_symbol (0x858d8), and the auipc/ld pair loads this address at runtime.

Code Model Summary

Code Model Addressing Range Instruction Sequence Relocations Architectures
medlow (default) Absolute (lui) -2 GiB ~ +2 GiB lui + ld/sd R_RISCV_HI20 + R_RISCV_LO12_I/S RV32, RV64
medany PC-relative (auipc) Any 2 GiB window auipc + ld/sd R_RISCV_PCREL_HI20 + R_RISCV_PCREL_LO12_I/S RV32, RV64
large Constant pool Full 64-bit space auipc + ld (pool) + ld/sd R_RISCV_PCREL_HI20 + R_RISCV_PCREL_LO12_I + R_RISCV_64 RV64 only

Function Calls and Code Models

The summary table above describes how each code model addresses data symbols. Function calls, however, are handled differently. Both medlow and medany use the same PC-relative auipc+jalr sequence (with the R_RISCV_CALL_PLT relocation) for function calls, regardless of which code model is selected. The compiler may optimize this into auipc+jr (i.e. jalr x0) for tail calls. This means even under medlow, function calls are not restricted to absolute addresses between -2 GiB and +2 GiB -- they can reach any target within PC ±2 GiB.

Here's a function call compiled with medlow/medany:

$ cat cmodel_call.c
extern void bar(void);

void foo() {
  bar();
}

$ riscv64-unknown-linux-gnu-objdump -d -r cmodel_call.o

Disassembly of section .text:

0000000000000000 <foo>:
   0:   00000317                auipc   t1,0x0
                        0: R_RISCV_CALL_PLT     bar
                        0: R_RISCV_RELAX        *ABS*
   4:   00030067                jr      t1 # 0 <foo>

This design is based on a reasonable assumption: all functions in a program are typically placed together in the same text section, so the distance between any caller and callee will not exceed 2 GiB. This assumption holds true in the vast majority of real-world programs.

Under the large code model, function calls are handled through the constant pool instead, since the callee may be located anywhere in the 64-bit address space:

$ riscv64-unknown-linux-gnu-objdump -d -r cmodel_call_large.o

Disassembly of section .text:

0000000000000006 <.LC0>:
        ...
                        6: R_RISCV_64   bar

000000000000000e <foo>:
   e:   00000317                auipc   t1,0x0
                        e: R_RISCV_PCREL_HI20   .LC0
                        e: R_RISCV_RELAX        *ABS*
  12:   ff833303                ld      t1,-8(t1) # 6 <.LC0>
                        12: R_RISCV_PCREL_LO12_I .L0
                        12: R_RISCV_RELAX       *ABS*
  16:   1141                    addi    sp,sp,-16
  18:   e406                    sd      ra,8(sp)
  1a:   9302                    jalr    t1
  1c:   60a2                    ld      ra,8(sp)
  1e:   0141                    addi    sp,sp,16
  20:   8082                    ret

The compiler stores the callee's full 64-bit address in a constant pool entry .LC0, loads it into t1 via an auipc/ld pair, and then performs an indirect call with jalr t1. This requires three instructions plus an 8-byte constant pool entry, compared to the two-instruction auipc+jalr sequence used by medlow/medany. This overhead adds up quickly in call-heavy code, so the large code model should only be used when functions truly need to span more than a 2 GiB range.

The Difference Between a Code Model and an ABI

One commonly misunderstood distinction is the difference between a code model and an ABI. The ABI determines the interface between functions, while the code model determines how code is generated within a function. For example, the medlow and medany code models limit the code that addresses symbols to 32-bit offsets, but on RV64I-based systems they still encode pointers as 64-bit.

Since the code model doesn't affect the layout of structures in memory or how arguments are passed between functions, it's largely transparent to programs. As a consequence, functions compiled with different code models can generally call each other. In particular, objects using the medlow, medany, and large code models can be linked together, provided that the restrictions placed on symbol addressing by each code model are all met. This constraint will usually be satisfied automatically, and the linker verifies it for safety.

Contrast this to linking code generated for two different ABIs, which is invalid. Imagine a function that contains a double argument. A function compiled for lp64d will expect this argument in a register. When called by a function compiled for lp64 that places the argument in an X register the program won't work correctly.

Code Models and Linker Relaxation

Up until this point we haven't discussed how code models interact with linker relaxation, largely because the answer is now fairly simple: it all just works. All the necessary patches have been upstreamed into the various toolchain components, including both GNU ld (binutils) and LLVM's lld (which gained RISC-V linker relaxation support starting from LLVM 15).

Linker relaxation is actually an important enough optimization that it affected the RISC-V ISA significantly: linker relaxation allows RISC-V to forgo an addressing mode that would otherwise be required to get reasonable performance on many codebases. On RISC-V targets, the following addressing modes are available:

  • Symbols within a 7-bit offset from 0 (or from __global_pointer$): 2 bytes.
  • Symbols within a 12-bit offset from 0 (or from __global_pointer$): 4 bytes.
  • Symbols within a 17-bit offset from 0: 6 bytes.
  • Symbols within a 32-bit offset from 0: 8 bytes. On RV32I this is the entire address space.
  • Symbols beyond a 32-bit offset (large code model): via constant pool, 16 bytes for address computation (8-byte auipc+ld pair + 8-byte constant pool entry; the final ld/sd/jalr access instruction is excluded, consistent with the sizes above).

Within the 2 GiB range, linker relaxation can transparently select the shortest addressing sequence for each symbol access, effectively achieving variable-length address encoding using a single code model and a single hardware addressing mode (register+offset) via eight instruction formats (U, I, S, CI, CR, CIW, CL, and CS) without any mode bits. For programs that need to address the full 64-bit address space, the large code model provides an alternative approach via constant pools, which is designed to work independently of linker relaxation. Contrast this behavior with the ARMv8 GCC port, which requires selecting a different code model for each of the address generation sequences it can emit.

However, linker relaxation support for the large code model is currently quite limited. Even when the target symbol is close enough to be addressed with a shorter sequence, the linker cannot relax the constant pool load sequence (auipc + ld + ld/jalr) down to the two-instruction sequences used by medlow/medany (such as lui + ld or auipc + ld). This means every global symbol access and function call under the large code model incurs at least one extra instruction of overhead, and each constant pool entry consumes an additional 8 bytes of data space. For programs that do not need to address beyond a 2 GiB range, medlow or medany should be preferred for better code size and performance.

Achieving variable-length addressing sequences is usually something reserved for CISC processors, which achieve this by implementing a plethora of addressing modes in hardware and opportunistically shrinking addressing sequences at assembly time when possible. The RISC-V method of using fusible multi-instruction addressing sequences and linker relaxation has the advantages of both allowing simple implementations and resulting in similar code size.

Authors

Palmer Dabbelt was one of the original authors, as an employee at SiFive. He currently is an Engineer for Meta, having prior experience as an Engineer at Rivos and Software Engineer at Google. Prior to that he worked at SiFive as Director of Software Engineering and as an Engineer.

Kito Cheng is a Principal Engineer at SiFive and Chair of the RISC-V psABI Task Group. He maintains and contributes to the RISC-V backends in GCC and LLVM, driving ABI evolution and compiler support for new ISA extensions such as vector and hardware control-flow integrity.


Read more of the All Aboard blog series: