An official build, not a fork

The runtime bflat links against is .NET itself: the upstream VMR (dotnet/dotnet) at a release branch, built by upstream’s own build system, for a target upstream already supports. The delta is a handful of riscv64 patches, each written to be sent upstream.

That is possible because every adaptation a zkVM needs is applied around the runtime — ILC-stage substitutions, link-time modules, ELF postprocessing — rather than inside it (see Architecture). An adaptation belongs to the earliest of those layers that can express it; only what none of them can reach is allowed to touch .NET itself.

The patches we carry

Each one is a riscv64 correctness fix meant for upstream, not a private zkVM hack. They are tracked as per-version fixup profiles, so what is carried, and against which .NET, stays legible:

minimal
Correctness fixes for riscv64 code generation only — the kind that belongs upstream.
perf
Optional. Adds riscv64 code-quality work on top of minimal; pairs with the RyuJIT knobs bflat passes in optimized builds.

Profiles are selected when the runtime is built (patch_runtime.sh minimal|perf) and are versioned per .NET major, so a new runtime release never silently reuses fixups written against the previous one.

Counts differ between majors — .NET 11 needs one fixup .NET 10 does not, because only its JIT can emit compressed instructions — so the roster is the directory, not this page: fixup/<major>/profile/<profile>/.

The minimal profile, in full

Fixup What it changes Why it can’t move out
11_riscv64_honor_isa_mode_asm Guards CoreCLR’s hand-written riscv64 assembly with #if __riscv_flen != 0 and friends, so it honours the ISA it was configured for Hand-written .S is not reachable from any bflat layer
14_riscv64_honor_isa_mode_atomic_asm The same for the atomic sequences (#ifdef __riscv_atomic) in exception handling As above
20_…splitcodedata Stops the JIT folding read-only data chunks into the code chunk on riscv64, so data with code pointers lands in .rodata A codegen decision; no post-hoc rewrite is safe
22_riscv64_stubdispatch (.NET 10) / 22_riscv64_dispatchresolve_tail (.NET 11) The tail pseudo-instruction expands to auipc + jalr, clobbering the dispatch-cell address the resolver needs Assembly thunk, same reason
24_riscv64_gate_compressed_emission (.NET 11) Adds EnableRiscV64Compressed, gating the one place the JIT emits a compressed encoding A JIT decision; .NET 10’s RyuJIT has no RVC support to gate
25_riscv64_gate_atomic_emission Adds EnableRiscV64Atomic; with it off, Interlocked lowers to plain load/modify/store Register lifetimes change with the lowering, so it cannot be a post-hoc rewrite

What the build produces

NethermindEth/dotnet-riscv is the pipeline that produces the artifacts bflat links against. It:

  1. Pulls a specific upstream .NET VMR (dotnet/dotnet) at a tagged release branch — release/10.0.1xx or release/11.0.1xx-preview7.
  2. Applies the fixup profile for that .NET major.
  3. Builds against an Alpine RISC-V64 cross rootfs produced by upstream’s own eng/common/cross/build-rootfs.sh — there is no rebuilt distribution of our own any more. Two packages are the exception: Alpine’s feed ships musl and libatomic built for rv64gc, so both are rebuilt for rv64im and the stock libc.a, crt objects and libatomic.a are overwritten with them. A guest that decodes only the base ISA cannot link against compressed or atomic instructions, and no marker patching hides that.
  4. Packs the results into archives that bflat downloads by URL.

Keeping the native runtime on the base ISA

The fixups above guard hand-written assembly with #ifdef __riscv_flen and #ifdef __riscv_atomic, which only helps if those macros are actually undefined — that is, if the code is compiled for the base ISA. Nothing in upstream’s CMake sets a -march for a riscv64 cross target, so the build would otherwise inherit the toolchain default and the guards would never fire.

A compiler wrapper (tools/clang, pointed at by CLR_CC/CLR_CXX) supplies it. Compilations bound for the client — the NativeAOT runtime tree, the GC and llvm-libunwind objects it pulls in, and System.Native — get -march=rv64im -mabi=lp64; everything SDK-side (the CoreCLR PAL, the savannah libunwind it uses, corehost) keeps the rootfs-native rv64imafd, because it runs on real hardware where atomics matter. Objects are retagged to the double-float ABI afterwards so lld does not refuse to mix them.

Classification is by path, and that is the part to be careful with: a NativeAOT target whose sources live outside the nativeaot/ tree is recognised by its CMake target directory rather than by the source path, because make runs the compiler from inside the target’s build directory and the object path carries no nativeaot/ component. A client-bound target that slips through is compiled with F/D and A, and the emulator rejects the resulting guest with an opaque invalid-instruction panic. After adding one, check it: build a guest with --error-on-float-binary --error-on-atomic, which names the offending symbol.

Step Script Output
0 00_build_rootfs.sh GNU and musl RISC-V64 cross rootfs, both from upstream tooling
1 01_pack_compiler_linux.sh x64-Linux–hosted bflat driver binary
2 02_pack_crossrootfs.sh Compressed cross rootfs as a release artifact
3 03_pack_gnu_libs.sh GNU runtime libraries needed at link time
4 04_pack_libs.sh Built CoreCLR runtime libraries (libSystem.Native, …)
6 06_pack_refs.sh Reference assemblies bflat consumes
7 07_pack_bflat_libs_linux.sh bflat-side static libraries (uGC.cpp.obj, the AOT bootstrap, …)
8 08_pack_bflat_compiler_nupkg.sh ILCompiler (the AOT compiler bflat drives) packed as a NuGet .nupkg
9 09_pack_bflat_compiler_native_linux.sh Native-RISC-V64–hosted driver
xx_pack_whole_source.sh Source archive of the full tree

How the artifact reaches bflat

bflat does not fetch the runtime ad-hoc. Each build resolves a release tag from two properties in bflat.variant.props:

  • DotnetVersion (10 or 11) selects the .NET line;
  • Variant (perf or min) selects which runtime build gets bundled — the performance-oriented one or the minimal one, mirroring the fixup profiles of the same names.

Together they form the release tag (v10.0.0.p3, v11.0.0.x8, …). Tags move as releases are cut and the blob cache is keyed by them, so switching variant or .NET version re-downloads.

At build time the archives land in lib/<os>/<arch>/<libc> next to the bflat binary; when you run bflat build, those files are what the driver feeds to the linker.

Because the CoreLib that guests compile against is already on disk at that point, bflat’s own build re-checks its ILC-stage substitutions against it — a runtime bump that moves a substituted method fails the build immediately instead of at the first guest compilation.

License

dotnet-riscv ships under the MIT license. Fixups carried against upstream remain under their original authors’ licenses (most of .NET is MIT). See the project’s LICENSE.md.