Skip to content

Implement RISC-V dynamic linking - #323

Open
DrXiao wants to merge 8 commits into
sysprog21:masterfrom
DrXiao:feat/dynlink-for-rv32
Open

Implement RISC-V dynamic linking#323
DrXiao wants to merge 8 commits into
sysprog21:masterfrom
DrXiao:feat/dynlink-for-rv32

Conversation

@DrXiao

@DrXiao DrXiao commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

The proposed changes primarily improve the ELF generation and the RISC-V backend, enabling the build system to generate a dynamically linked shecc targeting the RISC-V architecture.

Although the current changes allow both bootstrapping and test suite to complete successfully, this is still a work in progress. The TODO items are listed as follows:

  • Enhance code quality, comments and commit messages.
  • Confirm the RISC-V ABI compliance.
  • Add a new script (riscv-abi.sh) to validate the RISC-V ABI.
  • Improve the documentation and README.
  • Consolidate arm.mk and riscv.mk into a common build logic (e.g.: configure RUNNER_LD_PREFIX).
  • (Any new requirements ...)

Summary by cubic

Adds RV32 dynamic linking with RELA using an ABI-compliant PLT/GOT and a __libc_start_main entry. Introduces ELF_MACHINE_ARM32/ELF_MACHINE_RV32 to select REL (Arm) vs RELA (RISC‑V). CI now builds and tests static and dynamic targets for both arm and riscv, and a new ABI suite validates RV32 calling, stack, and external-call behavior.

  • New Features

    • RV32 dynamic linking: .rela.plt; PLT0=32B, stubs=16B; per‑arch RESERVED_GOT_NUM (RV32=2, ARM=3); DYN_LINKER /lib/ld-linux-riscv32-ilp32d.so.1; correct DT_RELA*/DT_REL*, DT_PLTREL, DT_PLTRELSZ, DT_JMPREL.
    • RISC‑V backend: ABI‑compliant PLT generation; internal calls direct, externs via PLT; entry calls __libc_start_main and returns via saved ra; 16‑byte stack alignment; syscall trampoline kept for static.
    • ELF/Codegen: .rela.plt support with use_relaplt; arch‑driven section/program headers; arch macros (ELF_MACHINE_*) used throughout.
    • Tests/CI: RISC‑V ABI suite; RV32 dynamic snapshots (hello, fib); CI runs static+dynamic for arm/riscv.
  • Dependencies

    • Build/CI: shared toolchain detection in mk/common.mk with per‑arch TOOLCHAIN_CANDIDATES and auto RUNNER_LD_PREFIX; GitHub Actions downloads a riscv32 glibc toolchain and updates PATH; README/docs updated for Arm/RISC‑V dynamic linking.

Written for commit 4a556ea. Summary will update on new commits.

Review in cubic

@DrXiao

DrXiao commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

As the apt package manager only provides a 64-bit RISC-V GNU cross-compilation toolchain, I utilize riscv-gnu-toolchain , which is a 32-bit variant, and leverage its artifacts for RISC-V dynamic linking development and validation.

The updated GitHub Actions also downloads the 32-bit variant to validate RISC-V dynamic linking.

@jserv

jserv commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

As the apt package manager only provides a 64-bit RISC-V GNU cross-compilation toolchain, I utilize riscv-gnu-toolchain , which is a 32-bit variant, and leverage its artifacts for RISC-V dynamic linking development and validation.
The updated GitHub Actions also downloads the 32-bit variant to validate RISC-V dynamic linking.

Evaluate Run 32-bit applications on 64-bit Linux kernel, which is exactly RV32-on-RV64 userspace compatibility, not emulation.

@DrXiao

DrXiao commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator Author

Evaluate Run 32-bit applications on 64-bit Linux kernel, which is exactly RV32-on-RV64 userspace compatibility, not emulation.

I'm not sure whether I understand correctly. Do you mean that the proposed changes should be verified on a RISC-V machine?

@jserv

jserv commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator

Do you mean that the proposed changes should be verified on a RISC-V machine?

See sysprog21/kbox#18
The RISE RISC-V Runners is a managed GitHub Actions runner service that executes CI/CD workflows on real RISC-V hardware.

@DrXiao
DrXiao force-pushed the feat/dynlink-for-rv32 branch from 446b538 to 44f2f47 Compare May 9, 2026 03:52
@DrXiao

DrXiao commented May 9, 2026

Copy link
Copy Markdown
Collaborator Author

RISE RISC-V Runners' documentation explicitly states that binaries must be compiled for riscv64. According to the FAQ - What architectures are supported?.

RISC-V 64-bit (riscv64) only. All runners execute on physical RISC-V hardware. There is no RISC-V emulation. Binaries must be compiled for riscv64.

I created another branch (feat/dynlink-for-rv32-test-rv64-runner) to verify if these RISC-V runners could support riscv32 binaries. However, the test result indicates that the runner failed to execute statically linked shecc.

Based on both the documentation and my test, it appears that RISE RISC-V runners lack support for 32-bit executables.

@DrXiao

DrXiao commented May 28, 2026

Copy link
Copy Markdown
Collaborator Author

RISC-V calling convention:

  1. First eight arguments are passed to a0-a7, and the extra arguments are pushed onto the stack.
  2. stack should be 128-bit (16-byte) aligned. In RISC-V ABIs Specification document, the section 2-1 describes

    2-1. Integer Calling Convention

    The stack grows downwards (towards lower addresses) and the stack pointer shall be aligned to a
    128-bit boundary upon procedure entry.

  3. Caller/Callee saved registers:
    Register ABI Name Saver
    zero, gp, tp (None)
    ra Caller
    sp Callee
    a0 - a7 Caller
    s0 - s11 Callee
    t0 - t6 Caller

Item 1: is done by the register allocation phase.

The register allocator uses virtual registers (vreg0-vreg7) to allocate reigsters for arguments when encountering a function call. vreg0-vreg7 will be mapped to a0-a7, so first eight arguments are naturally passed to these registers.

Since the current shecc only supports up to 8 arguments, no extra arguments need to be passed to the stack. Thus, we can skip this handling.

Item 2: is ensured by the RISC-V code generator.

When handling the stack pointer, the code generator will guarantee that sp is always incremented or decremented by a multiple of 16 bytes.

Item 3:

  • ra and sp: are properly handled by the code generator.
  • a0 - a7: are implicitly handled in register allocation phase.
  • s0 - s11: are not necessary to be preserved or restored.
  • t0 - t6: are not necessary to be handled.

Therefore, this item can also be considered complete. Further details and explanations can be found in riscv-codegen.c.

@DrXiao
DrXiao force-pushed the feat/dynlink-for-rv32 branch from 2599d74 to ee9db4a Compare June 1, 2026 13:44
@DrXiao
DrXiao force-pushed the feat/dynlink-for-rv32 branch 2 times, most recently from de67942 to 1af9140 Compare June 11, 2026 15:13
@DrXiao
DrXiao requested review from ChAoSUnItY and jserv June 13, 2026 15:37
@DrXiao

DrXiao commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator Author

Since there may be additional requirements to address, I am still keeping this pull request as a draft currently, and will continue to make improvements if necessary.

@DrXiao
DrXiao force-pushed the feat/dynlink-for-rv32 branch from 5f8b272 to 9be9f62 Compare July 18, 2026 12:14
Comment thread .github/workflows/main.yml Outdated
DrXiao added 3 commits July 22, 2026 22:14
Introduce ELF_MACHINE_ARM32 (0x28) and ELF_MACHINE_RV32 (0xf3) to
support architecture-specific logic in future developments.
This commit primarily improves the ELF handling and code generator to
enable the compiler to produce a dynamically linked executable targeting
the RISC-V architecture.

- Allow the ELF handling to generate RELA relocation table.
  - Use REL relocation when the target architecture is Arm. Othereise,
    use RELA relocation for RISC-V.
- Improve GOT generation process.
  - Arm: reserve three entries.
  - RISC-V: reserve two entries.
- Implement PLT generation for RISC-V.
  - The generation process follows the RISC-V ABI. The first PLT entry
    uses 8 instructions to call '_dl_runtime_resolve'. The subsequent
    entry uses 4 instructions to perform an indirect function call via
    GOT.
- Refine the function call handling for the RISC-V code generator.
  - Perform a direct call for internal functions
  - Otherwise, use PLT table to peform an indirect call for external
    functions.
- Enhance the build system:
  - Allow the build system to generate dynamically linked compilers when
    targeting the RISC-V architecture.
  - Detect the sysroot path of the RISC-V GNU toolchain automatically.
Modify the 'update-snapshots' and 'check-snapshots' make targets to
include generation and validation of new snapshots for the RISC-V
architecture using dynamic linking.
@DrXiao
DrXiao force-pushed the feat/dynlink-for-rv32 branch from 9be9f62 to 14220c1 Compare July 22, 2026 15:38
@DrXiao
DrXiao marked this pull request as ready for review July 28, 2026 13:48
cubic-dev-ai[bot]

This comment was marked as resolved.

@DrXiao

DrXiao commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

About LD_PRELOAD

The LD_PRELOAD mechanism allows specified libraries to be loaded before any other shared libraries (e.g.: libc.so) required by the running process, and the process can call a "wrapper" function.

This raises an interesting question: whether the dynamically linked executables generated by shecc can utilize LD_PRELOAD to perform "function interposition'.

Therefore, I prepared the following code and conducted an experiment, and it is pleasing that the generated executables can indeed leverage LD_PRELOAD and execute successfully.

Test library

The 'wrapped' malloc() can allocate memory and print the allocated size to standard output.

#include <stddef.h>
#include <string.h>
#include <stdio.h>
#include <dlfcn.h>
#include <unistd.h>
void *malloc(size_t size) {
    char buf[32];
    static void *(*real_malloc)(size_t) = NULL;
    if (real_malloc == NULL) {
        real_malloc = dlsym(RTLD_NEXT, "malloc");
    }
    sprintf(buf, "malloc called, size = %zu\n", size);
    write(2, buf, strlen(buf));
    return real_malloc(size);
}

Use arm-linux-gnueabihf-gcc to build libmcount.so for later use.

$ arm-linux-gnueabihf-gcc -D_GNU_SOURCE -shared -ldl -fPIC -o libmcount.so malloc_count.c

Test program

/* test.c */
int main()
{
    int *a[3];

    a[0] = malloc(sizeof(int));
    a[1] = malloc(sizeof(int) * 7);
    a[2] = malloc(sizeof(int) * 1024);

    free(a[0]);
    free(a[1]);
    free(a[2]);

    return 0;
}

Use shecc to compile a dynamically linked executable, and then run it in QEMU with LD_PRELOAD=./libmcount.so.

$ make DYNLINK=1
$ qemu-arm -L /usr/arm-linux-gnueabihf/ out/shecc-stage2.elf --dynlink -o test test.c
$ qemu-arm -L /usr/arm-linux-gnueabihf/ -E LD_PRELOAD=./libmcount.so ./test
malloc called, size = 4
malloc called, size = 28
malloc called, size = 4096

We can observe that test.c calls the wrapped malloc() three times. Each call successfully allocates memory and correctly prints the allocated size.


Function interposition is also supported on the RISC-V architecture:

$ riscv32-unknown-linux-gnu-gcc -D_GNU_SOURCE -shared -ldl -fPIC -o libmcount.so malloc_count.c
$ make DYNLINK=1 ARCH=riscv
$ qemu-riscv32 -L $(riscv32-unknown-linux-gnu-gcc --print-sysroot) out/shecc-stage2.elf --dynlink -o test test.c
$ qemu-riscv32 -L $(riscv32-unknown-linux-gnu-gcc --print-sysroot) -E LD_PRELOAD=./libmcount.so ./test
malloc called, size = 4
malloc called, size = 28
malloc called, size = 4096

I will conduct this experiment on real hardware (e.g.: BeagleBone Black) later.

@DrXiao
DrXiao requested a review from jserv July 28, 2026 15:31
DrXiao added 5 commits July 29, 2026 23:37
The update workflow now downloads a RISC-V GNU toolchain to provide
necessary dependencies and validate the dynamically linked compiler
targeting the RISC-V architecture.
Because two architecture-specific makefile fragments contain similar
snippets for locating the cross-compilation toolchain path, this commit
consolidates them into a shared build logic, thereby reducing code
duplication.
A new shell script is introduced to validate whether generated
executables targeting RISC-V correct comply with the RISC-V ABI.

The tests include:
- Parameter Passing: tests function calls with different numbers of
  arguments.
- Stack Alignment: validates whether the stack is always 16-byte
  aligned when calling a function.
- Return Values: confirms if the return value is correct after a
  function returns.
- External Calls: verifies whether dynamically linked programs can call
  external functions.
- Register Preservation: verify whether the contents of function
  argument registers are properly preserved when calling a function.
- Structure Passing: validates if a small structure object can be passed
  correctly.
- Expand instructions on utilizing dynamic linking for both the Arm and
  RISC-V architectures.
- Describe the stack frame layout for the RISC-V implementation.
- Explain caller and callee behaviors when targeting the RISC-V
  architecture.
- Illustrate the RISC-V PLT stub implementation, including assembly code
  snippets and design intentions.
- Add reference links about RISC-V.
  - glibc implementation of '__dl_runtime_resolve' for RISC-V.
  - RISC-V ABIs specifications.
- Improve the explanation of the runtime execution flow of a dynamically
  linked program.
- Correct the description of callee behavior for the Arm architecture.
  - Clarify that registers r4-r11 are callee-saved, not caller-saved.
  - Explain that the saved lr is loaded into pc to return to the caller.
Since the dynamic linking is now supported for the RISC-V architecture,
this updates the relevant introductions and usage guides.
@DrXiao
DrXiao force-pushed the feat/dynlink-for-rv32 branch from 14220c1 to 4a556ea Compare July 29, 2026 15:38

@jserv jserv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review notes on the RV32 dynamic linking implementation. Findings are inlined at their locations.

Comment thread src/riscv-codegen.c
* After the main function completes its execution, it must use
* the content of ra to transfer control back to __libc_start_main().
*/
emit(__addi(__t1, __a0, 0));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dynamic-startup wrapper stashes argc/argv in t1/t2, then calls GLOBAL_FUNC (global initializers) before restoring them to a0/a1 for main (lines 707-708). t1/t2 are caller-saved temporaries the backend uses freely (div/mod, address arithmetic), so any nontrivial global initializer clobbers argc/argv and main receives garbage. Save them in the wrapper's stack frame, or use callee-saved s registers with proper save/restore.

Comment thread src/riscv-codegen.c
/* Call __libc_start_main() via PLT[1] */
ofs = (dynamic_sections.elf_plt_start + PLT_FIXUP_SIZE) -
(elf_code_start + elf_code->size);
emit(__jal(__ra, ofs));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This jal to PLT[1] (and the extern calls at OP_call / OP_address_of_func) spans .text + .rodata + .rel(a).plt to reach .plt, so the offset is roughly code_size + rodata_size. rv_encode_J in src/riscv.c has no range check, unlike rv_encode_I/S/B which all fatal() on overflow. Past the J-type reach of ±1 MB the high bits are silently dropped and the jump lands at the wrong address, a silent miscompile the small hello/fib tests never approach. Add the same guard to rv_encode_J: if (imm > 1048575 || imm < -1048576) fatal("Offset too large"); (src/riscv.c is not part of this PR, so this comment is anchored on the call site.)

Comment thread mk/riscv.mk
\#define ELF_MACHINE 0xf3\n$\
\#define ELF_FLAGS 0\n$\
\#define DYN_LINKER \"/lib/ld-linux.so.3\"\n$\
\#define DYN_LINKER \"/lib/ld-linux-riscv32-ilp32d.so.1\"\n$\

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interpreter is ld-linux-riscv32-ilp32d.so.1 (double-float ABI) while ELF_FLAGS remains 0, which declares soft-float. The object's e_flags should carry EF_RISCV_FLOAT_ABI_DOUBLE (0x4) to match the ilp32d loader, or the target should select an ILP32 soft-float linker/toolchain. As written the ABI tag contradicts the chosen interpreter and may be rejected or misinterpreted by strict tools.

Comment thread src/riscv-codegen.c
*/
emit(__addi(__t1, __a0, 0));
emit(__addi(__t2, __a1, 0));
emit(__sw(__ra, __sp, -4));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sw ra, sp, -4 writes below sp before sp is decremented (same idiom as OP_define at line 298). RISC-V defines no stack red zone, so this stores outside the current frame; a signal or interrupt in that window corrupts the saved ra. Decrement sp first, then store ra at a positive in-frame offset.

Comment thread src/elf.c
*/

func_plt_ofs = PLT_FIXUP_SIZE + PLT_ENT_SIZE;
func_got_ofs = PTR_SIZE << 2;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

func_got_ofs = PTR_SIZE << 2 hardcodes the ARM GOT layout (first extern at GOT[4]). For RV32 (RESERVED_GOT_NUM=2) the first extern lives at GOT[3], so this value is wrong for RISC-V. It is latent today only because func->got_offset (assigned below) is write-only, the RV32 PLT computes its slot positionally. Use PTR_SIZE * (RESERVED_GOT_NUM + 1), or drop the dead got_offset field.

Comment thread src/elf.c
elf_write_int(dynamic_sections.elf_got, 0);
elf_write_int(dynamic_sections.elf_got, 0);
for (int i = PTR_SIZE * 3; i < dynamic_sections.got_size; i += PTR_SIZE)
switch (ELF_MACHINE) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GOT init relies on implicit ELF_MACHINE_ARM32 -> ELF_MACHINE_RV32 case fallthrough. It is correct for both arches (ARM writes GOT[0]=&.dynamic then two zeros; RV32 writes two zeros), but it emits -Wimplicit-fallthrough, and a stray break or a case reorder would silently corrupt ARM GOT init with no failure on the RV32 CI leg. Add a /* fall through */ annotation, or write the shared zero entries explicitly per case.

Comment thread tests/riscv-abi.sh
exit_code=$?

# Check result
if [[ $exit_code -eq 0 ]]; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A test is counted as passed on exit_code == 0 alone; an expected_output mismatch is only a warning, and only under VERBOSE. A broken external-call path that still exits 0 passes silently, which is exactly the failure mode this suite is meant to catch. Treat a non-matching expected_output as a failure.

if [ "${{ matrix.architecture }}" = "arm" ]; then
sudo apt-get install -q -y gcc-arm-linux-gnueabihf
else
sudo wget -q https://github.com/riscv-collab/riscv-gnu-toolchain/releases/download/2026.07.15/riscv32-glibc-ubuntu-24.04-gcc.tar.xz

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The riscv32 toolchain tarball is fetched with sudo wget and extracted with no checksum or signature verification, so a compromised or swapped release would run unchecked in CI. Pin and verify a SHA256 before extraction rather than trusting the download; also sudo wget into the workspace is unnecessary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants