The patched .NET runtime
Runtime — dotnet-riscv
bflat-riscv64 doesn't ship its own runtime. It downloads one — built by the sibling project dotnet-riscv — that takes upstream .NET and applies a focused set of patches to make it survive on a stripped-down RISC-V64 machine.
What it is
NethermindEth/dotnet-riscv
is the build pipeline that produces the runtime artifacts bflat-riscv64
links against. It does not maintain a runtime fork in the usual
sense — there is no parallel source tree to keep up to date.
20 numbered patches touch 70 of the 56,796 files in upstream
dotnet/runtime (≈ 0.12 %), with
+493 net lines out of 11.1 M (≈ 0.004 %). Bumping to a new .NET
version is a rebase, not a fork. Instead, the project:
- Pulls a specific upstream .NET VMR (
dotnet/dotnet) at a tagged release branch, e.g.release/10.0.100. - Applies a numbered series of patches under
patches/bflat-runtime/and one patch underpatches/sdk/. - Builds the runtime against a custom Alpine-based RISC-V64 cross rootfs.
- Packs the results into NuGet-compatible archives that bflat-riscv64’s
BuildCommand.csconsumes by URL.
When you run bflat build with --libc zisk or --libc zisk_sim,
that release archive is what gets unpacked into your lib/<arch>/<os>/<libc>
directory. The runtime libraries the linker pulls in (libSystem.Native,
the CoreLib reference assemblies, the AOT bootstrap objects, the GC
uGC.cpp.obj family, …) all originate here.
Why a patched runtime is necessary
zkVMs constrain the runtime far more aggressively than a normal Linux RISC-V64 host. Stock .NET assumes:
- the full
rv64gcISA (compressed instructions, hardware floating point); - a kernel underneath it for syscalls, signals, and randomness;
- non-deterministic constructs like security cookies, JIT addresses, and the system clock.
Each of those would either crash inside Zisk or make the proof
non-reproducible. The patches in
patches/bflat-runtime/
remove the assumption rather than add a workaround at the call site.
The patches, by purpose
There are 20 numbered patches. They divide cleanly into five groups.
RISC-V64 ISA constraints
| # | Patch | What it changes |
|---|---|---|
| 11 | riscv64_uncompress |
Replaces RV64C compressed instruction sequences in CoreCLR’s hand-written assembly thunks with full 32-bit encodings. Zisk’s prover only knows uncompressed instructions. |
| 13 | riscv64_unfloat |
Strips floating-point instruction emission from the runtime native build. The lp64d calling convention is preserved (matching Alpine and bflat-emitted user code), but the resulting objects are tagged with the lp64 (soft-float) marker in their ELF e_flags so ld.lld accepts them alongside the rest of the stack. |
| 15 | nofp_jit |
Strips floating-point opcode emission from the RISC-V64 JIT. Even though we run AOT, the JIT codegen path is reused by NativeAOT to lower IL, so any FP it emits would land in the final binary. |
| 14 | no_jump_tables_riscv64 |
One-line change in jit/lower.cpp: forces switch lowering to use a sequence of compares-and-branches instead of a jump table. Switch tables would otherwise land inside .text as data — exactly what Zisk’s instruction preprocessor cannot tolerate. |
| 20 | splitcodedata |
The structural fix that obviates the postprocessor’s old --split-code-data step. Adds two new RISC-V64 PC-relative relocation kinds (IMAGE_REL_RISCV64_PCREL_HI20 / _LO12_I) for paired auipc + addi/ld sequences and tells the JIT not to bundle method roData adjacent to hot code. As a result, AOT now emits roData as a separate MethodReadOnlyDataNode that lands cleanly in .rodata — code stays code, data stays data, no ELF surgery required. |
NativeAOT / runtime startup
| # | Patch | What it changes |
|---|---|---|
| 1 | no_publish |
Skips the publish step in Subsets.props so the build produces unpacked artifacts. |
| 2 | vxsort |
Disables the AVX2-only VXSort GC sort path on architectures that can’t compile it. |
| 3 | native_targets |
Adjusts Microsoft.NETCore.Native.Unix.targets so the AOT pipeline picks up the cross-compiled runtime libraries. |
| 4 | aot_eventing |
CMake fix for the AOT runtime’s eventing sources on the cross build. |
| 5 | private_core_lib |
Tweaks System.Private.CoreLib internals to make a couple of types reachable from outside their assembly — bflat’s link-time wrappers reference them. |
| 6 | coreclr_setting_tunnel |
Adds a hook in CompilerTypeSystemContext so bflat can pass extra knobs to the underlying ILC. |
| 7 | coreclr_startup |
Adjusts the NativeAOT runtime startup so it cooperates with ubootstrap rather than expecting a glibc-style entry path. |
| 18 | resolution |
Patches MetadataVirtualMethodAlgorithm to nudge virtual-method resolution into a path bflat handles correctly. |
Zerolib (the minimal core lib)
| # | Patch | What it changes |
|---|---|---|
| 8 | zerolib |
Adds the upstream zerolib minimal CoreLib variant to the NativeAOT CMake build. |
| 9 | zerolib_manifest |
Registers the zerolib output in the SFX manifest so it’s packaged in Microsoft.NETCore.App. |
bflat itself ships its own zerolib;
these patches make sure the parallel zerolib in the runtime tree builds
against the same configuration.
Determinism for proofs
| # | Patch | What it changes |
|---|---|---|
| 16 | tls_opt |
Disables the TLS-relaxation optimisation for musl + RISC-V64. Cherry-picked from upstream PR #121662. Without it the linker rewrites TLS sequences in a way our minimal TLS shim can’t follow. |
| 17 | bigint |
Tightens Number.BigInteger.cs parsing so the result is bit-identical across runs — the upstream code path picks up host-specific buffer sizes. |
Build infrastructure
| # | Patch | What it changes |
|---|---|---|
| 10 | riscv64.patch |
Base RISC-V64 toolchain config in eng/native/configurecompiler.cmake. |
| 12 | alpine_custom |
Allows the upstream eng/common/cross/build-rootfs.sh to build against our custom Alpine variant. The driver script tolerates this patch failing — useful when upstream removes a sentinel comment we keyed off. |
Plus one SDK-side patch:
| # | Patch | What it changes |
|---|---|---|
| — | patches/sdk/crossgen2.patch |
Tweaks the SDK’s crossgen2 invocation so cross-building under the bflat layout produces the right artifacts. |
The build pipeline
The repository top level is a numbered series of shell scripts. Each
one performs a self-contained packaging step and emits a tarball into
output/.
| Step | Script | Output |
|---|---|---|
| 0 | 00_build_rootfs.sh |
Custom Alpine RISC-V64 cross rootfs (used by every later script) |
| 1 | 01_pack_compiler_linux.sh |
x64-Linux–hosted bflat compiler binary |
| 2 | 02_pack_crossrootfs.sh |
Compressed cross rootfs as a release artifact |
| 3 | 03_pack_gnu_libs.sh |
Patched GNU runtime libraries needed at link time |
| 4 | 04_pack_libs.sh |
Built CoreCLR runtime libraries (libSystem.Native, etc.) |
| 6 | 06_pack_refs.sh |
Reference assemblies that 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 |
bflat compiler packed as a NuGet .nupkg |
| 9 | 09_pack_bflat_compiler_native_linux.sh |
Native-RISC-V64–hosted bflat compiler |
| ⋯ | xx_pack_whole_source.sh |
Source archive of the full patched tree |
patch_runtime.sh and patch_alpine.sh are the entry points that
apply the patch series in order. They are invoked by the numbered
scripts; they fail loudly on any patch except 12_alpine_custom,
which is allowed to be a no-op when upstream Alpine drifts.
The whole pipeline runs in CI under
build.yml.
Successful runs cut a GitHub release whose tag matches the bflat
release that consumes it.
How the artifact reaches bflat-riscv64
bflat-riscv64 doesn’t fetch the runtime ad-hoc. Each version of bflat embeds the URL of a specific dotnet-riscv release in its build scripts. At build time:
- The bflat layout build downloads the matching release archives.
- Their contents land in
lib/<arch>/<os>/<libc>next to the bflat binary. - When you run
bflat build, those files are whatBuildCommand.csfeeds to the linker — the<runtime libraries from dotnet-riscv>placeholder in the link command.
Bumping the runtime is therefore a two-repository operation: tag a new dotnet-riscv release, then update the URL constants in bflat-riscv64’s build scripts to point at the new tag.
License
dotnet-riscv ships under the MIT license. Patches the project carries
remain under their original authors’ licenses (most of upstream .NET is
also MIT). See the project’s
LICENSE.md.