From 06c344688a349130c39e4f9644a1e912ec07b92c Mon Sep 17 00:00:00 2001 From: Peter Shrosbree Date: Sun, 11 Oct 2026 17:24:41 -0700 Subject: [PATCH 1/4] [RISC-V] Use the jump stub that recordRelocation allocates, and keep ra out of it The inner `PCODE jumpStubAddr` in the RISC-V case of CEEJitInfo::recordRelocation shadowed the outer one, so the stub that ExecutionManager::jumpStub returned was dropped, the method was recompiled with rel32 off, and later compilations in the process got no rel32 hint and emitted their direct calls in the absolute form. The stub itself used ra as its scratch register, so a callee reached through it would have returned to its own entry. Use t6, which no call contract passes across a call: t0/t1 carry the profiler-enter arguments, t2 the secret stub parameter and the dispatch token, t3/t4 the write barrier arguments and t5 the indirection cell. The profiler helpers' entry comment listed t6 as an input; it is overwritten before any read, so correct the comment. --- src/coreclr/vm/jitinterface.cpp | 2 +- src/coreclr/vm/riscv64/asmhelpers.S | 2 +- src/coreclr/vm/riscv64/cgencpu.h | 20 ++++++++++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/coreclr/vm/jitinterface.cpp b/src/coreclr/vm/jitinterface.cpp index 578958368d67f9..b79a628fa20691 100644 --- a/src/coreclr/vm/jitinterface.cpp +++ b/src/coreclr/vm/jitinterface.cpp @@ -12557,7 +12557,7 @@ void CEEJitInfo::recordRelocation(void * location, if (hiAddr < location) hiAddr = (BYTE*)UINT64_MAX; // overflow - PCODE jumpStubAddr = ExecutionManager::jumpStub(m_pMethodBeingCompiled, (PCODE)target, loAddr, hiAddr, nullptr); + jumpStubAddr = ExecutionManager::jumpStub(m_pMethodBeingCompiled, (PCODE)target, loAddr, hiAddr, nullptr); // Keep track of conservative estimate of how much memory may be needed by jump stubs. We will use it // to reserve extra memory on retry to increase chances that the retry succeeds. diff --git a/src/coreclr/vm/riscv64/asmhelpers.S b/src/coreclr/vm/riscv64/asmhelpers.S index 486fce7790ec56..1c9260ce600980 100644 --- a/src/coreclr/vm/riscv64/asmhelpers.S +++ b/src/coreclr/vm/riscv64/asmhelpers.S @@ -682,7 +682,7 @@ NESTED_ENTRY \helper\()Naked, _TEXT, NoHandler // On entry: // t0 = functionIDOrClientID // t1 = profiledSp - // t6 = throwable + // (t6 is scratch: it is overwritten below before any read, so a jump stub may use it) // // On exit: // Values of a0-a7, fa0-fa7, fp are preserved. diff --git a/src/coreclr/vm/riscv64/cgencpu.h b/src/coreclr/vm/riscv64/cgencpu.h index 1909270cfed74d..874db2d03eab75 100644 --- a/src/coreclr/vm/riscv64/cgencpu.h +++ b/src/coreclr/vm/riscv64/cgencpu.h @@ -287,14 +287,22 @@ inline void emitBackToBackJump(LPBYTE pBufferRX, LPBYTE pBufferRW, LPVOID target // We require 8-byte alignment so the LD instruction is aligned properly _ASSERTE(((UINT_PTR)pCode & 7) == 0); - // auipc ra, 0 - // ld ra, ra, 16 - // jalr x0, ra, 0 + // The stub sits between a call and its target, so it may only use a register that + // neither side expects to survive the call. The link register is not one: a call + // arrives here with ra holding the return address, and a tail jump with ra holding + // the caller's return address. Of the temporaries, t0/t1 carry the profiler enter + // arguments, t2 the secret stub parameter and the dispatch token, t3/t4 the write + // barrier arguments and t5 the indirection cell. t6 (REG_OPT_RSVD in the JIT) carries + // nothing across a call, and the write barrier already declares it trashed. + + // auipc t6, 0 + // ld t6, 16(t6) + // jalr x0, t6, 0 // nop //padding. - pCode[0] = 0x00000097; // auipc ra, 0 - pCode[1] = 0x0100b083; // ld ra, 16(ra) - pCode[2] = 0x00008067; // jalr x0, ra, 0 + pCode[0] = 0x00000f97; // auipc t6, 0 + pCode[1] = 0x010fbf83; // ld t6, 16(t6) + pCode[2] = 0x000f8067; // jalr x0, t6, 0 pCode[3] = 0x00000013; // padding nop. // Ensure that the updated instructions get updated in the I-Cache From 9d2a4ee20007e1797c64a5e004ca2eb53e0a1caa Mon Sep 17 00:00:00 2001 From: Peter Shrosbree Date: Sun, 11 Oct 2026 17:24:41 -0700 Subject: [PATCH 2/4] [RISC-V] Always emit direct calls PC-relative and let the VM insert jump stubs emitIns_Call consulted the relocation hint for direct calls and took an absolute li + jalr form when the hint said no. amd64 and arm64 always record a relocation for a direct call and rely on the VM to insert a jump stub when the target is out of reach; do the same here now that the RISC-V stub works. IsAddressInRange stays for the data-address emitters. This removes the retry's relocation-free fallback: before, a failed stub allocation recompiled the method with rel32 off and every call in the absolute form, so the second attempt always converged. Now the retry asks for stubs again with m_reserveForJumpStubs raised, as amd64 and arm64 do, and g_fAllowRel32 only affects data addressing. A contained constant call target no longer needs a register, so drop the internal register LSRA defined for it and the codegen read of it, and fix the lowering comment that described the old li + jalr form. Codegen leaves ireg unset for this direct call, since the emitter chooses the auipc and jalr registers itself, and emitIns_Call requires ireg only for an indirect call. --- src/coreclr/jit/codegenriscv64.cpp | 13 +++++++------ src/coreclr/jit/emitriscv64.cpp | 25 +++++++------------------ src/coreclr/jit/lower.cpp | 4 ++-- src/coreclr/jit/lsrariscv64.cpp | 6 ------ 4 files changed, 16 insertions(+), 32 deletions(-) diff --git a/src/coreclr/jit/codegenriscv64.cpp b/src/coreclr/jit/codegenriscv64.cpp index aa4abea743baca..f2f1c3904eafa9 100644 --- a/src/coreclr/jit/codegenriscv64.cpp +++ b/src/coreclr/jit/codegenriscv64.cpp @@ -5540,20 +5540,21 @@ void CodeGen::genCallInstruction(GenTreeCall* call) if (target->isContainedIntOrIImmed()) { + // A direct call: auipc + jalr with a relocation. No register is needed for the target, and ireg is + // left unset because the emitter chooses the auipc and jalr registers itself. params.callType = EC_FUNC_TOKEN; - params.ireg = internalRegisters.GetSingle(call); params.addr = (void*)target->AsIntCon()->IconValue(); } else { params.callType = EC_INDIR_R; params.ireg = target->GetRegNum(); - } - // We have already generated code for gtControlExpr evaluating it into a register. - // We just need to emit "call reg" in this case. - // - assert(genIsValidIntReg(params.ireg)); + // We have already generated code for gtControlExpr evaluating it into a register. + // We just need to emit "call reg" in this case. + // + assert(genIsValidIntReg(params.ireg)); + } genEmitCallWithCurrentGC(params); } diff --git a/src/coreclr/jit/emitriscv64.cpp b/src/coreclr/jit/emitriscv64.cpp index 9dcd3a29d7aaab..7b764c4724c07c 100644 --- a/src/coreclr/jit/emitriscv64.cpp +++ b/src/coreclr/jit/emitriscv64.cpp @@ -1901,7 +1901,7 @@ void emitter::emitIns_Call(const EmitCallParams& params) /* Sanity check the arguments depending on callType */ assert(params.callType < EC_COUNT); - assert(isGeneralRegister(params.ireg)); + assert((params.callType == EC_FUNC_TOKEN) || isGeneralRegister(params.ireg)); assert(params.callType < EC_INDIR_R || params.addr == nullptr); // RISCV64 never uses these @@ -1931,19 +1931,6 @@ void emitter::emitIns_Call(const EmitCallParams& params) } #endif - ssize_t jalrOffset = 0; - if (params.callType == EC_FUNC_TOKEN && !IsAddressInRange(params.addr)) - { - // Load upper bits of the absolute call address into a register: - // li ireg, addr_upper - // jalr zero/ra, ireg, addr_lo12 (emitted below) - assert(params.addr != nullptr); - ssize_t imm = (ssize_t)params.addr; - jalrOffset = (imm << (64 - 12)) >> (64 - 12); // low 12-bits, sign-extended - imm -= jalrOffset; - emitLoadImmediate(EA_PTRSIZE, params.ireg, imm); // upper bits - } - /* We need to allocate the appropriate instruction descriptor based on whether this is a direct/indirect call, and whether we need to @@ -2009,18 +1996,18 @@ void emitter::emitIns_Call(const EmitCallParams& params) id->idInsOpt(INS_OPTS_C); /* Record the address: method, indirection, or funcptr */ - if ((params.callType == EC_INDIR_R) || (params.callType == EC_FUNC_TOKEN && !IsAddressInRange(params.addr))) + if (params.callType == EC_INDIR_R) { /* This is an indirect call (either a virtual call or func ptr call) */ - // jalr zero/ra, ireg, offset + // jalr zero/ra, ireg, 0 id->idSetIsCallRegPtr(); regNumber reg_jalr = params.isJump ? REG_R0 : REG_RA; id->idReg4(reg_jalr); id->idReg3(params.ireg); // NOTE: for EC_INDIR_R, using idReg3. - id->idSmallCns(jalrOffset); + id->idSmallCns(0); id->idCodeSize(4); } else @@ -2029,10 +2016,12 @@ void emitter::emitIns_Call(const EmitCallParams& params) // auipc t2/ra, offset-hi20 // jalr zero/ra, t2/ra, offset-lo12 + // + // Always PC-relative, with a relocation for the VM to resolve: when the target is out of reach + // of the auipc + jalr pair the VM inserts a jump stub, as it does for arm64 and amd64 calls. assert(params.callType == EC_FUNC_TOKEN); assert(params.addr != nullptr); - assert(IsAddressInRange(params.addr)); void* addr = (void*)(((size_t)params.addr) + (params.isJump ? 0 : 1)); // NOTE: low-bit0 is used for jalr ra/r0,rd,0 diff --git a/src/coreclr/jit/lower.cpp b/src/coreclr/jit/lower.cpp index caa7e26e2246cf..5179f2dbce49cd 100644 --- a/src/coreclr/jit/lower.cpp +++ b/src/coreclr/jit/lower.cpp @@ -2948,8 +2948,8 @@ GenTree* Lowering::LowerCall(GenTree* node) call->gtControlExpr = controlExpr; #ifdef TARGET_RISCV64 - // If controlExpr is a constant, we should contain it inside the call so that we can move the lower 12-bits of - // the value to call instruction's (JALR) offset. + // If controlExpr is a constant, contain it inside the call so that codegen emits a direct call to it + // (auipc + jalr with a relocation) instead of materializing the address in a register. if (controlExpr->IsCnsIntOrI() && !controlExpr->AsIntCon()->ImmedValNeedsReloc(m_compiler) && !call->IsFastTailCall()) { diff --git a/src/coreclr/jit/lsrariscv64.cpp b/src/coreclr/jit/lsrariscv64.cpp index 2531ce69bf4605..153d098c37f82a 100644 --- a/src/coreclr/jit/lsrariscv64.cpp +++ b/src/coreclr/jit/lsrariscv64.cpp @@ -947,12 +947,6 @@ int LinearScan::BuildCall(GenTreeCall* call) } assert(ctrlExprCandidates != RBM_NONE); } - - // In case ctrlExpr is a contained constant, we need a register to store the value. - if (ctrlExpr->isContainedIntOrIImmed()) - { - buildInternalIntRegisterDefForNode(call); - } } else if (call->IsR2ROrVirtualStubRelativeIndir()) { From d1f23c5e6d00330d0bbe3f28af66c4ad8f8dc4da Mon Sep 17 00:00:00 2001 From: Peter Shrosbree Date: Mon, 12 Oct 2026 09:52:41 -0700 Subject: [PATCH 3/4] [RISC-V] Add a jump stub test that runs under DOTNET_ForceRelocs Before this PR, ForceRelocs alone could not reach the RISC-V jump stub: the first pass presets m_fJumpStubOverflow, and the retry emitted direct calls as li + jalr with no relocation. Now that the JIT records a relocation for every direct call (previous commit), the ForceRelocs address scatter in ClrVirtualAlloc, which applies on RISC-V because W^X is off there by default, can put helper and cross-heap targets out of range on the retry, so those calls go through jump stubs. Add src/tests/baseservices/jumpstubs, which runs four call shapes (allocation and write barrier, a CoreLib call, a throw caught by the caller, a plain managed call) with DOTNET_ForceRelocs=1 and tiered compilation off. --- .../jumpstubs/ForceRelocsJumpStubs.cs | 58 +++++++++++++++++++ .../jumpstubs/ForceRelocsJumpStubs.csproj | 17 ++++++ 2 files changed, 75 insertions(+) create mode 100644 src/tests/baseservices/jumpstubs/ForceRelocsJumpStubs.cs create mode 100644 src/tests/baseservices/jumpstubs/ForceRelocsJumpStubs.csproj diff --git a/src/tests/baseservices/jumpstubs/ForceRelocsJumpStubs.cs b/src/tests/baseservices/jumpstubs/ForceRelocsJumpStubs.cs new file mode 100644 index 00000000000000..74c5e1eb6df6c6 --- /dev/null +++ b/src/tests/baseservices/jumpstubs/ForceRelocsJumpStubs.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Runs four call shapes with DOTNET_ForceRelocs=1. On RISC-V, where W^X is off by default, Checked and Debug +// runtimes then reserve executable memory at scattered addresses, which can put helper and cross-heap targets +// out of auipc + jalr range, so that those calls go through jump stubs. + +using System; +using System.Runtime.CompilerServices; +using Xunit; + +public static class ForceRelocsJumpStubs +{ + private sealed class Holder + { + public object Field; + } + + [Fact] + public static void TestEntryPoint() + { + var holder = new Holder(); + Assert.Same(StoreNew(holder), holder.Field); + Assert.Equal("jumpstub", Concat("jump", "stub")); + Assert.Equal(42, CatchFromThrower()); + Assert.Equal(7, Add(3, 4)); + } + + // Allocation and write-barrier helpers. + [MethodImpl(MethodImplOptions.NoInlining)] + private static object StoreNew(Holder holder) => holder.Field = new object(); + + // A call into CoreLib. + [MethodImpl(MethodImplOptions.NoInlining)] + private static string Concat(string a, string b) => string.Concat(a, b); + + // The throw helper, and an unwind back through the caller. + [MethodImpl(MethodImplOptions.NoInlining)] + private static int CatchFromThrower() + { + try + { + Thrower(); + return -1; + } + catch (InvalidOperationException) + { + return 42; + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Thrower() => throw new InvalidOperationException(); + + // A plain managed call with a return value. + [MethodImpl(MethodImplOptions.NoInlining)] + private static int Add(int a, int b) => a + b; +} diff --git a/src/tests/baseservices/jumpstubs/ForceRelocsJumpStubs.csproj b/src/tests/baseservices/jumpstubs/ForceRelocsJumpStubs.csproj new file mode 100644 index 00000000000000..c0834c9b136df4 --- /dev/null +++ b/src/tests/baseservices/jumpstubs/ForceRelocsJumpStubs.csproj @@ -0,0 +1,17 @@ + + + + true + true + + + + + + + + + + From 80d2714e27f807136ef4069c2f0701a4e6a368a9 Mon Sep 17 00:00:00 2001 From: Peter Shrosbree Date: Mon, 12 Oct 2026 13:25:31 -0700 Subject: [PATCH 4/4] [RISC-V] Count every out-of-range call toward the jump stub reserve recordRelocation added to m_reserveForJumpStubs only when it also tried to allocate a stub, so once m_fJumpStubOverflow was set for a compilation, by a failed allocation or by the ForceRelocs preset, the remaining out-of-range calls in that pass were left out of the estimate the retry reserves with; with the preset the estimate was empty. arm64 keeps counting after the flag is set, and so does amd64 once rel32 is restricted to code targets. Move the estimate out of the !m_fJumpStubOverflow guard; the allocation stays behind it. A data relocation out of range still only forces the retry, where the JIT addresses data absolutely, so it is still not counted. --- src/coreclr/vm/jitinterface.cpp | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/coreclr/vm/jitinterface.cpp b/src/coreclr/vm/jitinterface.cpp index b79a628fa20691..7836fd7549071f 100644 --- a/src/coreclr/vm/jitinterface.cpp +++ b/src/coreclr/vm/jitinterface.cpp @@ -12546,18 +12546,23 @@ void CEEJitInfo::recordRelocation(void * location, // Target is out of 32-bit range // If we're fixing up a jump, try jumping through a stub PCODE jumpStubAddr = (PCODE)NULL; - if ((fRelocType == CorInfoReloc::RISCV64_CALL_PLT) && !m_fJumpStubOverflow) + if (fRelocType == CorInfoReloc::RISCV64_CALL_PLT) { - BYTE* loAddr = (BYTE*)location - (1ll << 31) - (1ll << 11); - BYTE* hiAddr = (BYTE*)location + (1ll << 31) - (1ll << 11) - 1; + // Once an overflow has been recorded the code is thrown away and recompiled, so do not allocate + // more jump stubs; keep counting them so that the retry reserves enough space. + if (!m_fJumpStubOverflow) + { + BYTE* loAddr = (BYTE*)location - (1ll << 31) - (1ll << 11); + BYTE* hiAddr = (BYTE*)location + (1ll << 31) - (1ll << 11) - 1; - // Check for the wrap around cases - if (loAddr > location) - loAddr = (BYTE*)UINT64_MIN; // overflow - if (hiAddr < location) - hiAddr = (BYTE*)UINT64_MAX; // overflow + // Check for the wrap around cases + if (loAddr > location) + loAddr = (BYTE*)UINT64_MIN; // overflow + if (hiAddr < location) + hiAddr = (BYTE*)UINT64_MAX; // overflow - jumpStubAddr = ExecutionManager::jumpStub(m_pMethodBeingCompiled, (PCODE)target, loAddr, hiAddr, nullptr); + jumpStubAddr = ExecutionManager::jumpStub(m_pMethodBeingCompiled, (PCODE)target, loAddr, hiAddr, nullptr); + } // Keep track of conservative estimate of how much memory may be needed by jump stubs. We will use it // to reserve extra memory on retry to increase chances that the retry succeeds.