Run LLVM 20 Bitcode Safely with lli-20

lli-20 will run a bitcode file for you, and the exit status it hands back is the whole point of the exercise. You will assemble a small LLVM IR program, run the resulting bitcode, pass it arguments, and read that exit status correctly instead of guessing at it. The examples use the locally installed LLVM 20.1.8 runtime package, llvm-20-runtime.

Allow about fifteen minutes. You need a shell and the LLVM 20 tools lli-20 and llvm-as-20. Everything here is unprivileged.

Warning: do not reach for sudo on this workflow. It will not repair invalid bitcode, a missing entry point or an incompatible target, it just adds risk for nothing.

1. Check the installed tools

Confirm the command names resolve before creating a test module. This is a read-only check:

$ command -v lli-20
/usr/bin/lli-20
$ lli-20 --version
Ubuntu LLVM version 20.1.8
  Optimized build.
$ dpkg-query -W -f='\${Package} \${Version}\n' llvm-20-runtime
llvm-20-runtime 1:20.1.8~++20250804090239+87f0227cb601-1~exp1~20250804210352.139

The package revision can shift after an upgrade, but the installed command reports the LLVM version it actually runs. Keep that output with any bug report or reproducibility note.

2. Create a minimal bitcode program

lli-20 expects an input bitcode file, in the shape the manpage documents: lli-20 [options] <input bitcode> <program arguments>.... It will not execute an .ll source file directly, so assemble it first.

In a scratch directory, save this as exit-code.ll:

define i32 @main(i32 %argc, ptr %argv) {
entry:
  ret i32 %argc
}

This program returns the argument count and prints nothing. Assemble it with the matching LLVM 20 assembler:

$ llvm-as-20 exit-code.ll -o exit-code.bc
$ file exit-code.bc
exit-code.bc: LLVM IR bitcode

Checkpoint: if llvm-as-20 reports a syntax or verification error, stop there. lli-20 cannot make malformed input executable; fix the IR and assemble a fresh bitcode file.

3. Run the bitcode and pass arguments

Put the bitcode path first and the program arguments after it:

$ lli-20 exit-code.bc alpha beta
$ printf 'exit status: %s\n' "$?"
exit status: 3

The program receives three arguments here: its own argv[0] plus alpha and beta. The return value of main becomes the shell status, hence 3. Shell statuses stay within the usual process-status range, so do not treat a negative or oversized return value as an exact diagnostic number.

Do not confuse silence with failure. A bitcode program can complete successfully without writing anything. Capture the status immediately, before you run another command:

$ lli-20 exit-code.bc one
$ status=$?
$ printf 'program status: %s\n' "$status"
program status: 2

4. Separate loading failures from program failures

The local lli-20(1) page documents status 1 for a failure to load the program. Test that with a path that does not exist:

$ lli-20 ./does-not-exist.bc
lli-20: lli: ./does-not-exist.bc: error: Could not open input file: No such file or directory
$ printf 'loader status: %s\n' "$?"
loader status: 1

The wording can vary with the file path and build, but the useful distinction holds: status 1 here belongs to lli-20, not to a program that ran and happened to return 1. Check the path, permissions and bitcode format before you start changing options.

For a repeatable shell check, give the command status its own branch:

if lli-20 exit-code.bc alpha beta; then
    printf '%s\n' 'bitcode returned success'
else
    status=$?
    printf 'bitcode returned status %s\n' "$status" >&2
    exit "$status"
fi

5. Choose the execution mode deliberately

LLVM can use a just-in-time compiler, and the installed tool also has an interpreter fallback. The normal invocation lets lli-20 pick its own path. Use --force-interpreter when you are specifically testing interpreter behaviour or isolating a JIT-related problem:

$ lli-20 --force-interpreter exit-code.bc alpha beta
$ printf 'interpreter status: %s\n' "$?"
interpreter status: 3

This is not a general portability switch. lli-20 is not an emulator: the bitcode has to suit the host architecture, and target overrides can make execution fail or crash outright if they do not match the machine. Treat --mtriple, --march, --mcpu and --mattr as target-specific troubleshooting controls, not defaults you bolt onto every command.

6. Select a non-standard entry function

The default entry function is main. If a module has a different entry point, select it explicitly. Create alternate.ll:

define i32 @main() {
entry:
  ret i32 2
}

define i32 @alternate() {
entry:
  ret i32 13
}

Assemble and run the alternate entry:

$ llvm-as-20 alternate.ll -o alternate.bc
$ lli-20 --entry-function=alternate alternate.bc
$ printf 'alternate status: %s\n' "$?"
alternate status: 13

Keep the option before the bitcode path so the command stays easy to read. If the selected function has an unsuitable signature, or depends on setup that main would normally do, execution can fail even though the module assembled fine. Use this for a deliberately designed entry point, not as a guess when the default launch fails.

Common traps

Done means