How to Fix: Cranelift: call_indirect sends random data

7 min read

Cranelift call_indirect sending random data is almost never random: it is usually a signature mismatch, an incorrectly declared callee type, or a bad function pointer value being passed into the indirect call site. When Cranelift IR believes the callee expects one ABI shape but the actual target function uses another, argument registers and stack slots are interpreted incorrectly, which looks exactly like corrupted or random input.

Understanding the Root Cause

With call_indirect, Cranelift does not infer the callee ABI from the raw pointer. It uses the signature you attach to the indirect call instruction. That signature controls:

  • which arguments go in registers,
  • which arguments spill to the stack,
  • how return values are read back, and
  • which calling convention is used.

If that signature differs from the real function being called, the generated machine code is still valid from Cranelift’s perspective, but the target function receives the wrong bits in the wrong places. That is why the issue appears as “random data.”

The most common causes are:

  • Wrong indirect-call signature: the imported Signature used in call_indirect does not exactly match the actual callee.
  • Wrong calling convention: for example, the caller uses Cranelift’s default calling convention while the host function pointer expects the system ABI.
  • Bad pointer type or value: the function address is loaded incorrectly, truncated, sign-extended improperly, or passed as a non-native-sized integer.
  • Mismatched parameter list: even one missing or reordered parameter can shift all later arguments.
  • Lifetime/import confusion: a signature or function reference is created for one function shape and then reused for another.

In practice, call and call_indirect differ in one important way: call references a known function declaration in the IR, while call_indirect relies on a runtime pointer plus an explicit signature. If that explicit signature is wrong, the generated call sequence is wrong.

Step-by-Step Solution

The fix is to make the Cranelift signature, the host function type, and the runtime function pointer match exactly.

1. Define the exact function signature

Start by declaring the real target function type in Rust terms. Example:

extern "C" fn target(a: i64, b: i64) -> i64 {
    a + b
}

If the host function uses the system ABI, use the matching calling convention in Cranelift.

use cranelift::codegen::ir::{AbiParam, Signature, types::I64};
use cranelift::prelude::CallConv;

let mut sig = Signature::new(CallConv::SystemV);
sig.params.push(AbiParam::new(I64));
sig.params.push(AbiParam::new(I64));
sig.returns.push(AbiParam::new(I64));

On non-System V platforms, use the correct platform ABI instead of hardcoding one blindly. The key point is that the Cranelift signature must match the actual callee ABI.

2. Import that signature for call_indirect

let sig_ref = func.import_signature(sig);

Do not reuse a signature ref from some unrelated function shape.

3. Pass the function pointer as a native-sized address

The callee pointer must be represented correctly for the target architecture. On a 64-bit target, a raw function pointer should not be truncated or packed incorrectly.

let fn_addr: *const u8 = target as *const u8;
let fn_bits = fn_addr as i64;

Then materialize it into IR:

let callee_ptr = builder.ins().iconst(I64, fn_bits);

If you are compiling for multiple architectures, use the target’s pointer type instead of assuming I64.

let ptr_ty = isa.pointer_type();
let callee_ptr = builder.ins().iconst(ptr_ty, fn_addr as i64);

4. Emit call_indirect with arguments in the exact declared order

let arg0 = builder.ins().iconst(I64, 10);
let arg1 = builder.ins().iconst(I64, 32);

let call = builder.ins().call_indirect(sig_ref, callee_ptr, &[arg0, arg1]);
let results = builder.inst_results(call);
let sum = results[0];

If the actual function is (a, b), passing [b, a] will not crash, but it will produce confusing behavior that can look like corruption.

5. Ensure the host function ABI matches the Cranelift ABI

This is the step that fixes many “random data” reports. If your Rust function is declared with extern "C", your Cranelift signature should use the corresponding system calling convention. If your function is plain Rust ABI, indirect calls across JIT boundaries are unsafe because the Rust ABI is not stable for this purpose.

extern "C" fn target(a: i64, b: i64) -> i64 {
    a + b
}

Avoid:

fn target(a: i64, b: i64) -> i64 {
    a + b
}

That second form uses the Rust ABI, which is not what you want for stable JIT-to-host interop.

6. Use the same pointer width as the target ISA

If the JIT target is configurable, derive the pointer type from the ISA rather than hardcoding I64 everywhere.

let ptr_ty = isa.pointer_type();
let callee = builder.ins().iconst(ptr_ty, fn_addr as i64);

This prevents pointer corruption on 32-bit targets and makes the IR portable across target configurations.

7. Full minimal pattern

use cranelift::codegen::ir::{AbiParam, Function, InstBuilder, Signature, UserFuncName, types::I64};
use cranelift::frontend::{FunctionBuilder, FunctionBuilderContext};
use cranelift::prelude::CallConv;

extern "C" fn target(a: i64, b: i64) -> i64 {
    a + b
}

fn build_function(func: &mut Function) {
    func.signature.params.clear();
    func.signature.returns.clear();
    func.signature.returns.push(AbiParam::new(I64));

    let mut ctx = FunctionBuilderContext::new();
    let mut builder = FunctionBuilder::new(func, &mut ctx);

    let block = builder.create_block();
    builder.switch_to_block(block);
    builder.seal_block(block);

    let mut callee_sig = Signature::new(CallConv::SystemV);
    callee_sig.params.push(AbiParam::new(I64));
    callee_sig.params.push(AbiParam::new(I64));
    callee_sig.returns.push(AbiParam::new(I64));

    let sig_ref = builder.func.import_signature(callee_sig);

    let fn_addr = target as *const u8 as i64;
    let callee_ptr = builder.ins().iconst(I64, fn_addr);

    let a = builder.ins().iconst(I64, 10);
    let b = builder.ins().iconst(I64, 32);

    let call = builder.ins().call_indirect(sig_ref, callee_ptr, &[a, b]);
    let result = builder.inst_results(call)[0];

    builder.ins().return_(&[result]);
    builder.finalize();
}

If this exact pattern works but your original code does not, compare these four things first: signature, calling convention, pointer type, and argument order.

Common Edge Cases

  • Using FuncRef where an indirect raw pointer is intended: FuncRef is for direct function references in Cranelift IR, not arbitrary machine addresses. For call_indirect, pass a value containing the actual function pointer.
  • Importing the wrong signature once and reusing it everywhere: this often happens when helper code caches a SigRef globally without accounting for different callee shapes.
  • Pointer-sized integer mismatch: storing a 64-bit address into a 32-bit target type truncates the high bits and causes jumps to garbage.
  • Host/JIT ABI mismatch on Windows: Windows and System V register rules differ. A signature that works on Linux may pass nonsense on Windows if the calling convention is left wrong.
  • Struct or float parameters: non-integer arguments are even more sensitive to ABI rules. Passing f64, vectors, or aggregates with an integer-only signature will produce broken values.
  • Variadic functions: avoid indirect-calling C variadic functions unless you model the ABI requirements very carefully.
  • Dangling function pointer: if the pointer originates from unloaded code, a moved trampoline, or freed executable memory, symptoms may resemble random argument corruption before a crash appears.
  • Wrong target ISA configuration: if your JIT is configured for a different architecture or calling convention assumptions than the current machine, indirect calls become unreliable.

FAQ

Why does call work but call_indirect sends garbage?

call uses a declared function reference already known to Cranelift, while call_indirect depends on the signature you explicitly provide plus a runtime pointer. If either is wrong, the call frame is built incorrectly.

Do I need extern "C" for host functions called from Cranelift?

Yes, in almost all JIT interop cases you should use a stable external ABI such as extern "C". Calling plain Rust ABI functions indirectly is not a safe assumption.

How can I verify that the signature is the real problem?

Reduce the callee to a tiny extern "C" fn(i64, i64) -> i64, import the exact same Cranelift signature, and call it with constant arguments. If that works, your original issue is almost certainly an ABI, parameter, or pointer-shape mismatch.

The practical fix for this GitHub issue is simple: treat call_indirect as an ABI contract. Once the signature, calling convention, pointer width, and callee declaration all match exactly, the so-called random data disappears.

Leave a Reply

Your email address will not be published. Required fields are marked *