Develop, Compile, and Upload a RISC-V Intent
This guide explains how to build a minimal RISC-V program, package it with OpenSBI, and upload it as an intent into Core Lane.
Prerequisites
Install Rust, the RISC-V Rust toolchain, and QEMU:
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Add RISC-V target
rustup target add riscv64gc-unknown-none-elf
Create the project
Initialize a new Rust binary project named kernel:
cargo init --bin --name kernel
Add a minimal RISC-V runtime
Add riscv-rt to reduce boilerplate. It supplies a small runtime, linker script, and a default trap handler. We enable supervisor mode (s-mode) to use the appropriate runtime profile.
cargo add riscv-rt --features s-mode
Configure toolchain, target, and runner
Create toolchain and cargo config files to lock the Rust version, select the RISC-V target, and set a runner and linker flags.
Create rust-toolchain.toml:
[toolchain]
channel = "stable"
targets = ["riscv64gc-unknown-none-elf"]
Create .cargo/config.toml:
[build]
target = "riscv64gc-unknown-none-elf"
# Linker flags
rustflags = [
"-Clink-arg=-Tmemory.x",
"-Clink-arg=-Tlink.x",
]
Define memory layout (linker scripts)
The -Tmemory.x and -Tlink.x flags point the linker to scripts describing how the program is placed in memory. For bare-metal builds this must be specified explicitly. The riscv-rt crate provides a default link.x; we supply memory.x with the RAM map.
For QEMU’s virt machine, place the whole kernel in RAM starting at 0x80200000 with 16 MiB available. Create memory.x at the project root:
MEMORY
{
RAM : ORIGIN = 0x80200000, LENGTH = 16M
}
REGION_ALIAS("REGION_TEXT", RAM);
REGION_ALIAS("REGION_RODATA", RAM);
REGION_ALIAS("REGION_DATA", RAM);
REGION_ALIAS("REGION_BSS", RAM);
REGION_ALIAS("REGION_HEAP", RAM);
REGION_ALIAS("REGION_STACK", RAM);
Make the linker see memory.x
Tell Cargo to copy memory.x to the build output and add that directory to the link search path by adding a build.rs file:
Create build.rs at the project root:
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
fs::write(out_dir.join("memory.x"), include_bytes!("memory.x")).unwrap();
println!("cargo:rustc-link-search={}", out_dir.display());
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rerun-if-changed=build.rs");
}
Writing RISC-V intent program
This section walks through building a minimal bare-metal RISC-V program that can communicate with a host system. We’ll create each file and explain what it does and why it’s needed.
Step 1: Set up dependencies
First, add the required dependencies to Cargo.toml:
[dependencies]
riscv-rt = { version = "0.12", features = ["s-mode"] }
sbi = "0.3"
linked-list-allocator = "0.10"
riscv-rt: Provides the runtime, linker script, and entry point for bare-metal RISC-Vsbi: Supervisor Binary Interface for system calls (console, shutdown, etc.)linked-list-allocator: Simple heap allocator for dynamic memory (Vec,String, etc.)
Step 2: Create the panic handler (src/panic_handler.rs)
In bare-metal programming, we need to handle panics ourselves since there’s no operating system:
use crate::println;
use core::panic::PanicInfo;
use sbi::system_reset::{ResetReason, ResetType};
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("A panic occurred: {info}");
let _ = sbi::system_reset::system_reset(ResetType::Shutdown, ResetReason::SystemFailure);
println!("System reset failed");
// We need to loop forever to satisfy the `!` return type,
// since `!` effectively means "this function never returns".
loop {}
}
What this does:
#[panic_handler]: Tells Rust this function handles all panicsPanicInfo: Contains panic location and messagesbi::system_reset::system_reset(ResetType::Shutdown, ResetReason::SystemFailure): Shuts down the virtual machine cleanly!return type: Means “never returns” (infinite loop or termination)
Step 3: Create utility functions (src/utils.rs)
Since we can’t use std::println!, we need to implement our own printing:
pub fn print(t: &str) {
for c in t.chars() {
sbi::legacy::console_putchar(c.try_into().unwrap_or(b'?'))
}
}
struct Writer {}
pub fn print_args(t: core::fmt::Arguments) {
use core::fmt::Write;
let mut writer = Writer {};
writer.write_fmt(t).unwrap();
}
impl core::fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> core::fmt::Result {
print(s);
Ok(())
}
}
#[macro_export]
macro_rules! print {
($fmt:literal$(, $($arg: tt)+)?) => {
$crate::utils::print_args(format_args!($fmt $(,$($arg)+)?))
}
}
#[macro_export]
macro_rules! println {
($fmt:literal$(, $($arg: tt)+)?) => ;
() => {
$crate::utils::print("\n");
}
}
pub fn shutdown() -> ! {
let _ = sbi::system_reset::system_reset(
sbi::system_reset::ResetType::Shutdown,
sbi::system_reset::ResetReason::NoReason,
);
unreachable!("System reset failed");
}
What this does:
print(): Sends each character to the console viasbi::legacy::console_putcharWriterstruct: Implementsfmt::Writetrait for formatted outputprint!/println!macros: Provide familiar printing interface usingformat_args!shutdown(): Usessbi::system_reset::system_resetto terminate the virtual machine
Step 4: Create the CMIO driver (src/cmio.rs)
CMIO (Cartesi Machine I/O) allows communication between the guest kernel and host system:
#![allow(dead_code)]
extern crate alloc;
use alloc::vec;
use alloc::vec::Vec;
// SBI extension and function IDs
const SBI_YIELD: usize = 9;
// CMIO buffer addresses
pub const PMA_CMIO_RX_BUFFER_START: usize = 0x60000000;
pub const PMA_CMIO_TX_BUFFER_START: usize = 0x60800000;
pub const HTIF_DEVICE_YIELD: u8 = 2;
pub const HTIF_YIELD_CMD_MANUAL: u8 = 1;
pub fn sbi_yield(req: u64) -> usize {
unsafe { sbi::ecall1(req as usize, SBI_YIELD, 0) }.unwrap_or(0)
}
pub fn pack_yield(dev: u8, cmd: u8, reason: u16, data: u32) -> u64 {
((dev as u64) << 56) | ((cmd as u64) << 48) | ((reason as u64) << 32) | (data as u64)
}
pub struct CMIODriver {
rx_base: usize,
tx_base: usize,
}
impl CMIODriver {
pub const fn new() -> Self {
Self {
rx_base: PMA_CMIO_RX_BUFFER_START,
tx_base: PMA_CMIO_TX_BUFFER_START,
}
}
pub fn tx_write(&self, data: &[u8]) -> usize {
unsafe {
let dst_payload = core::slice::from_raw_parts_mut(self.tx_base as *mut u8, data.len());
dst_payload.copy_from_slice(data);
}
data.len()
}
pub fn rx_read(&self, rx_len: usize) -> Vec<u8> {
let mut out = vec![0u8; rx_len];
unsafe {
let src = core::slice::from_raw_parts(self.rx_base as *const u8, rx_len);
out.copy_from_slice(src);
}
out
}
}
What this does:
- Buffer addresses:
PMA_CMIO_RX_BUFFER_START(0x60000000) andPMA_CMIO_TX_BUFFER_START(0x60800000) are memory-mapped regions shared with the host sbi_yield(): Makes a supervisor call usingsbi::ecall1withSBI_YIELDextension to notify the hostpack_yield(): PacksHTIF_DEVICE_YIELD,HTIF_YIELD_CMD_MANUAL, reason, and data into a 64-bit valueCMIODriver: Provides safe access to the shared memory buffers vianew(),tx_write(), andrx_read()tx_write(): Usescore::slice::from_raw_parts_mutto copy data to the TX buffer (guest → host)rx_read(): Usescore::slice::from_raw_partsto copy data from the RX buffer (host → guest)
Step 5: Create the main program (src/main.rs)
Now we put it all together in the main entry point:
#![no_std]
#![no_main]
extern crate alloc;
use alloc::vec::Vec;
use core::str::from_utf8;
use linked_list_allocator::LockedHeap;
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::empty();
use riscv_rt::entry;
mod cmio;
mod panic_handler;
mod utils;
const HEAP_START: usize = 0x8100_0000;
const HEAP_SIZE: usize = 512 * 1024;
use crate::cmio::HTIF_DEVICE_YIELD;
use crate::cmio::HTIF_YIELD_CMD_MANUAL;
use crate::cmio::pack_yield;
use crate::cmio::sbi_yield;
#[entry]
fn main(a0: usize) -> ! {
unsafe {
ALLOCATOR.lock().init(HEAP_START as *mut u8, HEAP_SIZE);
}
println!("Hello world {}!", a0);
let dev = cmio::CMIODriver::new();
let msg = b"GUEST_MARK\n";
let written: usize = dev.tx_write(msg);
println!("GUEST: wrote {} bytes to HOST(TX)", written);
let req = pack_yield(HTIF_DEVICE_YIELD, HTIF_YIELD_CMD_MANUAL, 0, written as u32);
let rx_len = sbi_yield(req);
let read_back: Vec<u8> = dev.rx_read(rx_len);
let received_request = from_utf8(&read_back).unwrap();
println!(
"GUEST: received {} bytes from HOST",
&received_request.len()
);
println!("GUEST: read: {}", &received_request);
utils::shutdown();
}
What this does step by step:
#![no_std]: Disables standard library (we’re bare-metal)#![no_main]: We provide our own entry point#[global_allocator]: Sets upLockedHeapallocator forVecand other dynamic types#[entry]: Marks this as the program entry point (replacesmain)- Heap initialization: Calls
ALLOCATOR.lock().init()to set up 512KB of heap memory atHEAP_START(0x8100_0000) - Print greeting: Uses
println!macro to show the kernel is running (includes boot argumenta0) - Create CMIO driver: Calls
cmio::CMIODriver::new()to set up communication with host - Write message: Calls
dev.tx_write(b"GUEST_MARK\n")to send data to the host via TX buffer - Yield to host: Uses
pack_yield()andsbi_yield()to notify host to process our message - Read response: Calls
dev.rx_read(rx_len)to get the host’s reply from RX buffer - Print results: Uses
from_utf8()to decode andprintln!to show what we received - Shutdown: Calls
utils::shutdown()to cleanly terminate the virtual machine
Building Your RISC-V Program
Step 1: Build Your Kernel
Build your kernel for production:
cargo build --release
What this does: Compiles your kernel with optimizations enabled, creating a smaller and faster executable.
Step 2: Create Binary Image
Convert the ELF executable into a raw binary that can be loaded by a bootloader:
rust-objcopy -O binary target/riscv64gc-unknown-none-elf/release/kernel $(pwd)/Image
What this does:
rust-objcopy: Tool to manipulate object files-O binary: Output format is raw binary (no headers)- Creates
Imagefile containing just the executable code
Uploading Your RISC-V Intent to Core Lane
Now that your future intent is built, let’s upload it as an intent to Core Lane.
Step 1: Store Your Kernel as a Blob
First, upload your compiled kernel binary to Core Lane’s blob storage:
target/debug/core-lane store-blob \
--rpc-url <rpc-url> \
--contract <contract> \
--private-key <private-key> \
--file <path-to-Image-file> \
--max-fee-per-gas <max-fee-per-gas> \
--max-priority-fee-per-gas <max-priority-fee-per-gas>
What this does:
- Uploads your
Imagefile to Core Lane’s blob storage - Returns a blob hash that references your kernel
- Uses gas parameters for transaction fees
Step 2: Submit Intent (CBOR intentData)
Now submit an intent where intentData is CBOR-encoded and includes the RISC-V blob hash from Step 1:
# Example: intent(intentData, nonce)
cast send --rpc-url <rpc-url> --private-key <private-key> \
<contract> \
"intent(bytes,uint256)" <cbor-intent-data-hex> <nonce> \
-- --max-fee-per-gas <max-fee-per-gas> --max-priority-fee-per-gas <max-priority-fee-per-gas>
Where <cbor-intent-data-hex> is a hex-encoded CBOR blob that embeds <blob-hash> and any other parameters your RISC-V program needs.
What this does:
- Submits the intent directly using CBOR
intentData - Returns an intent ID for the next steps
Step 3: Lock Intent for Solving
Lock the intent so it can be solved by providing a Bitcoin transaction:
INTENT_ID=<intent-id>
cast send -r <rpc-url> \
--legacy --async \
--private-key <private-key> \
<contract> \
"function lockIntentForSolving(bytes32 intentId, bytes data) payable" "$INTENT_ID" <data>
What this does:
- Locks the intent for solving (prevents other changes)
- The intent is now waiting for a Bitcoin transaction to fulfill it
Step 4: Create Bitcoin Fill Transaction
Create a Bitcoin transaction that will fulfill the intent. This demonstrates the Bitcoin anchoring:
# Set up Bitcoin transaction parameters
export DEST=<destination-address>
export AMOUNT_SATS=<amount>
# Connect to Bitcoin regtest node
export BCLI='docker exec <bitcoin-container-name> bitcoin-cli -regtest -rpcuser=<rpc-user> -rpcpassword=<rpc-password>'
# Ensure wallet exists and has funds
if ! $BCLI -rpcwallet=mine getwalletinfo >/dev/null 2>&1; then
$BCLI -named loadwallet filename=mine >/dev/null 2>&1 || \
$BCLI -named createwallet wallet_name=mine descriptors=true >/dev/null 2>&1
fi
export BCLI_WALLET="$BCLI -rpcwallet=mine"
# Mine blocks to get spendable coins
ADDR=$($BCLI_WALLET getnewaddress mine bech32)
$BCLI generatetoaddress 101 "$ADDR" >/dev/null 2>&1 || true
# Create transaction with intent ID in OP_RETURN
PAY_BTC=$(python3 -c 'import os;print("{:.8f}".format(int(os.environ["AMOUNT_SATS"])/1e8))')
INTENT_ID_NO0X=${INTENT_ID#0x}
# Build, fund, sign, and send transaction
RAW=$($BCLI_WALLET createrawtransaction "[]" "{\"$DEST\":$PAY_BTC,\"data\":\"$INTENT_ID_NO0X\"}")
FUNDED_HEX=$($BCLI_WALLET fundrawtransaction "$RAW" | jq -r .hex)
SIGNED_HEX=$($BCLI_WALLET signrawtransactionwithwallet "$FUNDED_HEX" | jq -r .hex)
TXID=$($BCLI sendrawtransaction "$SIGNED_HEX"); echo "fill tx: $TXID"
# Verify transaction structure
$BCLI getrawtransaction "$TXID" 1 | jq '.vout | length'
$BCLI getrawtransaction "$TXID" 1 | jq -r '.vout[] | {n:.n, sats:(.value*100000000|floor), type:.scriptPubKey.type, asm:.scriptPubKey.asm}'
What this does:
- Creates a Bitcoin transaction that pays to
DESTaddress - Embeds the intent ID in an OP_RETURN output
- This transaction “fills” the intent by providing the required Bitcoin proof
Step 5: Get Bitcoin Block Height
Extract the block height where the Bitcoin transaction was confirmed:
# Get the block hash for our transaction
BLOCKHASH=$($BCLI getrawtransaction "$TXID" 1 | jq -r .blockhash)
if [ -z "$BLOCKHASH" ] || [ "$BLOCKHASH" = "null" ]; then
# If not confirmed, mine a block
MINE_TO=$($BCLI getnewaddress mine bech32)
$BCLI generatetoaddress 1 "$MINE_TO" >/dev/null
BLOCKHASH=$($BCLI getrawtransaction "$TXID" 1 | jq -r .blockhash)
fi
# Get block height and convert to little-endian format
export HEIGHT=$($BCLI getblockheader "$BLOCKHASH" | jq -r .height)
export LE=$(python3 -c 'import struct,os;print("0x"+struct.pack("<Q", int(os.environ["HEIGHT"])) .hex())')
echo "little endian: $LE"
What this does:
- Finds the block height where the Bitcoin transaction was confirmed
- Converts the height to little-endian format (required by the intent)
- This height proves the Bitcoin transaction was included in a block
Step 6: Solve the Intent
Finally, solve the intent by providing the Bitcoin block height proof:
cast send -r <rpc-url> --legacy --async --private-key <private-key> \
<contract> \
"function solveIntent(bytes32 intentId, bytes data) payable" \
"$INTENT_ID" "$LE"
What this does:
- Submits the Bitcoin block height as proof
- Core Lane verifies the Bitcoin transaction and block height
- If valid, the intent is solved and your RISC-V kernel can be executed