Tinkering
Come for the Foo, stay for the Bar


Interacting with Assembly in Rust

Posted on

Table of Contents:

Note: I originally wrote this article for LogRocket. You can find the original here.

For many Rust developers the process of producing a binary from their Rust code is a straightforward process which doesn’t require much thought. However, modern compilers are complicated programs in and of themselves, and may yield binaries that perform very differently in response to a minor change in the source code. In diagnosing performance issues like this, inspecting the output of the compiler can be helpful. The Rust compiler is able to emit various types of output, one of which is assembly. Rust also has facilities for embedding assembly. In this article you’ll learn about tools provided by the language and the community for extracting and embedding assembly.

Viewing assembly

To view the assembly output of various tools we’ll use the following example program:

const NAMES: [&'static str; 10] = [
    "Kaladin", "Teft", "Drehy", "Skar", "Rock", "Sigzil", "Moash", "Leyten", "Lopen", "Hobber",
];

fn main() {
    roll_call();
}

pub fn roll_call() {
    println!("SOUND OFF");
    for name in NAMES.iter() {
        println!("{}: HERE!", name);
    }
    let num_present = NAMES.len();
    println!("All {} accounted for!", num_present);
}

rustc

The quickest and easiest way to generate assembly is with the compiler itself. This method doesn’t require installing any additional tools, but the output can be difficult to navigate. rustc can emit assembly with the --emit asm option (documentation). To format the output with Intel syntax (instead of the default AT&T syntax) you can also pass the -C llvm-args=-x86-asm-syntax=intel option to rustc. However, it’s more common to interact with cargo than with rustc directly. You can pass this option to rustc in one of two ways:

$ cargo rustc -- --emit asm -C llvm-args=-x86-asm-syntax=intel
$ RUSTFLAGS="--emit asm -C llvm-args=-x86-asm-syntax=intel" cargo build

The assembly will be placed in target/debug/deps/<crate name>-<hash>.s (if compiled in release mode it will be under target/release). The assembly file contains all the assembly for the crate and can be hard to navigate.

Godbolt Compiler Explorer

A simple way to examine short snippets of code is to run it through the Godbolt Compiler Explorer. This tool is a web application, and as such doesn’t require installation of any additional tools. Code entered in the left pane is compiled to assembly and displayed in the right pane. The code entered in the left pane acts like it’s inside of the main function, so you don’t need to enter your own main function.

Sections of the code in the left pane are color coded so that the assembly in the right pane can be easily identified. For example, entering the roll_call function and NAMES array into the left pane displays the following view of the roll_call function.

We can identify the assembly corresponding to the println!("SOUND OFF") macro by right-clicking that line and selecting “Reveal linked code” or by searching for the assembly that’s highlighted in the same color.

cargo-asm

cargo-asm is a Cargo subcommand (found here) that displays the assembly for a single function at a time. The beauty of this tool is its ability to resolve symbol names and display the source code interleaved with the corresponding assembly. Note, however, that cargo-asm appears to only work with library crates. Put the NAMES array and roll_call function into a library crate called asm_rust_lib then call cargo-asm as follows (note: the --rust option interleaves the source code as this is not the default).

$ cargo asm --rust asm_rust_lib::roll_call

The first few lines of the output should look appear as follows.

Rust developers learning assembly may find the ability to compare unfamiliar assembly to the corresponding (familiar) Rust code particularly useful.

Including assembly

We could always compile assembly into an object file and link that into our binary, but that adds more complexity than we’d like, especially if we only need to include a few lines of assembly. Luckily Rust provides some facilities to make this process easy, especially in simple cases.

llvm_asm!

Until recently the official method for including inline assembly into Rust code was the asm! macro, and required Rust nightly. This macro was essentially a wrapper around LLVM’s inline assembler directives. This macro has been renamed to llvm_asm! while a new asm! macro is worked on in Rust nightly, but a nightly compiler is still required to use llvm_asm!.

The syntax for the macro is as follows:

llvm_asm!(assembly template
   : output operands
   : input operands
   : clobbers
   : options
   );

The “assembly template” section is a template string that contains the assembly. The input and output operands handle how values should cross the Rust/assembly boundary. The “clobbers” section lists which registers the assembly may modify to indicate that the compiler shouldn’t rely on values in those registers remaining constant. The “options” section, as you can imagine, contains options, notably the option to use Intel syntax. Each section of the macro requires a specific syntax, so it’s highly recommended to read the documentation for more information.

Note that using the llvm_asm! macro requires an unsafe block since assembly bypasses all of the safety checks normally provided by the compiler.

asm!

The new asm! macro provides a nicer syntax for using inline assembly than the llvm_asm! macro. An understanding of LLVM inline assembler directives is no longer necessary, and the documentation is extensive compared to that of llvm_asm!. The new syntax is closer to the normal format string syntax used with the println! and format! macros while still allowing the Rust/assembly boundary to be crossed with precision. Consider the small program shown below.

let mut x: u64 = 3;
unsafe {
    asm!("add {0}, {number}", inout(reg) x, number = const 5);
}

The inout(reg) x statement indicates that the compiler should find a suitable general purpose register, prepare that register with the current value of x, store the output of the add instruction in the same general purpose register, then store the value of that general purpose register in x. The syntax is nice and compact given the complexity of crossing the Rust/assembly boundary.

Conclusion

Assembly is a language that many developers don’t use on a daily basis, but it can still be fun and educational to see how code manipulates the CPU directly. A debugger wasn’t mentioned above, but modern debuggers (GDB, LLDB) also allow you to disassemble code and step through it instruction by instruction. Armed with the tools above and a debugger, you should be able to explore the assembly that your code is translated into in a multitude of ways.


P.S. - You can follow me on Mastodon at @zmitchell for Rust, Nix, and lukewarm takes.

P.P.S. - If you notice that something could be more accessible, please reach out and I'll do my best to fix it!