Assemble LLVM IR Safely with llvm-as-18

Feed llvm-as-18 a human-readable .ll file and it hands back the bitcode your toolchain actually wants. You will turn LLVM IR into bitcode, confirm the output is valid, and keep a failed run from wrecking a result you already trusted. Allow about ten minutes for a small module. The examples use the installed Ubuntu LLVM 18.1.3 command, llvm-as-18, and ordinary user permissions.

1. Check the installed assembler

Start by checking which executable will run and which release it reports. This matters because the installed manpage is generated from an LLVM 15 manual, while the package on this machine provides LLVM 18.1.3. Rely on the local command's actual behaviour for the examples below, and do not assume an option from a different LLVM release is available:

$ command -v llvm-as-18
/usr/bin/llvm-as-18
$ llvm-as-18 --version
Ubuntu LLVM version 18.1.3
  Optimized build.

The documented core interface is small: an optional input filename, -o for the destination, -f to permit raw bitcode on a terminal, and -help for a summary. The installed help also exposes LLVM 18-specific options, but this guide sticks to the documented input and output workflow so scripts stay easy to review.

2. Create a minimal LLVM IR file

LLVM IR is text, so inspect it before assembling. This module defines a function that returns zero, which is enough to test the whole path:

$ mkdir -p "$HOME/llvm-work"
$ cd "$HOME/llvm-work"
$ cat > hello.ll <<'EOF'
; ModuleID = 'hello'
source_filename = "hello.ll"

define i32 @main() {
  ret i32 0
}
EOF
$ sed -n '1,12p' hello.ll
; ModuleID = 'hello'
source_filename = "hello.ll"

define i32 @main() {
  ret i32 0
}

The shell redirection creates or replaces hello.ll. Use a path in a scratch directory when the source matters, or edit a copy. No elevated privilege is needed to create or read a file in your own working directory.

3. Assemble to a deliberate output path

Use -o when the destination should be explicit. The output is binary LLVM bitcode, not readable text:

$ llvm-as-18 hello.ll -o hello.bc
$ printf 'assembler status: %s\n' "$?"
assembler status: 0
$ file hello.bc
hello.bc: LLVM IR bitcode

A zero exit status means the assembler completed, and the file result confirms the destination is recognised as bitcode. If llvm-dis-18 is installed, use it to make a readable verification copy:

$ llvm-dis-18 hello.bc -o hello-roundtrip.ll
$ sed -n '1,12p' hello-roundtrip.ll
; ModuleID = 'hello.bc'
source_filename = "hello.ll"

define i32 @main() {
  ret i32 0
}

LLVM may add or reorder presentation details during a round trip, so check the function or declarations you actually care about rather than comparing every line byte for byte.

Checkpoint: llvm-as-18 --version reports the expected installed release, the source stays a readable .ll file, the assembler exits with status 0, and file identifies the result as LLVM IR bitcode.

4. Understand automatic output names

When -o is omitted, the local manpage describes three rules. Input from standard input goes to standard output. A filename ending in .ll becomes the same basename with .bc. Any other filename simply gets .bc appended:

$ llvm-as-18 hello.ll
$ file hello.bc
hello.bc: LLVM IR bitcode
$ cp hello.ll module.source
$ llvm-as-18 module.source
$ file module.source.bc
module.source.bc: LLVM IR bitcode

These defaults are convenient for a one-off conversion but can surprise a batch script. Prefer -o whenever the destination gets consumed by another command, checked into a build directory, or chosen from a variable.

5. Assemble from standard input

Omit the filename, or pass -, to read LLVM IR from standard input. With no -o, bitcode goes to standard output, so redirect it to a file:

$ printf '%s\n' \
  '; ModuleID = '\''pipe-test'\''' \
  'define i32 @answer() {' \
  '  ret i32 42' \
  '}' | llvm-as-18 -o pipe-test.bc
$ file pipe-test.bc
pipe-test.bc: LLVM IR bitcode
$ printf '%s\n' \
  '; ModuleID = '\''pipe-test'\''' \
  'define i32 @answer() {' \
  '  ret i32 42' \
  '}' | llvm-as-18 - > pipe-test-stdout.bc
$ file pipe-test-stdout.bc
pipe-test-stdout.bc: LLVM IR bitcode

Do not print raw bitcode straight to an interactive terminal. The assembler normally refuses that unsafe output. The documented -f option overrides the terminal check, but redirecting to a named file is clearer and safer for routine work.

6. Protect an existing bitcode file

Before using -o, check whether the destination already exists. A successful assembly can replace it, and a shell redirection such as > output.bc truncates a file before the command even starts:

$ test -e release.bc && printf 'already exists: release.bc\n'
already exists: release.bc
$ llvm-as-18 hello.ll -o release.bc.new
$ file release.bc.new
release.bc.new: LLVM IR bitcode
$ mv release.bc.new release.bc

That final mv is the deliberate replacement point. If assembly fails, the old release.bc stays untouched, and you can remove the incomplete temporary file once you have looked at the error.

Warning: that removal is destructive, so identify the exact temporary path before using rm. If you replace a file accidentally with no backup, stop writing to the directory and recover it through your normal filesystem or backup process.

7. Diagnose a failed assembly

Syntax and semantic errors produce a non-zero exit status and diagnostic text on standard error. Capture that text while preserving the shell status:

$ llvm-as-18 broken.ll -o broken.bc 2> assembler-error.txt
$ status=$?
$ printf 'assembler status: %s\n' "$status"
assembler status: 1
$ sed -n '1,8p' assembler-error.txt
llvm-as-18: broken.ll: error: ...

The exact diagnostic depends on the invalid IR. Fix the source and rerun the command. Do not treat the presence of a destination file as proof of success on its own: check the exit status, then inspect the bitcode with file or disassemble it. A missing input file is also a failure, not a reason to reach for sudo, check the path and read permission first.

Done means