🦀 Rust Under the Hood Companion
📖 Review the Book
If you enjoyed the book, please take a moment to leave a review on Amazon. Your feedback is greatly appreciated!
📢 Discord Server
Join the Rust Under the Hood Discord Server to discuss the book and ask questions.
🐞 Report and Examine Errors in the Book
Report and examine errors in the book in the Rust Under the Hood Errata GitHub repository.
🔬 Compiler Explorer and Rust Playground Links
Links to Compiler Explorer and Rust Playground examples for each book chapter.
Chapter 1 Assembly Basics: Integers, Floats, and Tuples
Chapter 3 Deconstructing Match and If-Else Expressions
Chapter 4 Deep Dive into Enums: Layout and Patterns
Chapter 5 Arguments: Value, Reference, Box, Rc, and Arc
Chapter 6 Structs: Alignment and Patterns
Chapter 7 Arrays, Tuples, Option, and Box Internals
Chapter 8 Traditional vs. Functional Array Iteration
Chapter 9 Optimizing Array Iteration with SIMD
Chapter 10 In-Depth: Array Slices and Pattern Matching
Chapter 11 Vector Iteration in Assembly
Chapter 12 Advanced Iterations: Mapping and Folding
Chapter 13 Strings and Slices: Under the Surface
Chapter 14 Static vs. Dynamic Dispatch
Chapter 15 Traits, Vtables, and Tail Calls
Chapter 16 Optimizing Recursive Functions
Chapter 17 Closures: From Environment to Execution
Chapter 18 Async/Await: Internal State Machine Dynamics
Chapter 19 Nested Async/Await: Desugaring to Loops
Chapter 20 Async Executors: Task Scheduling Insights
Chapter 21 Transforming into String Slice Vector
Chapter 22 Transforming into Owned String Vector
🧭 More Compiler Explorer Examples
Explore Compiler Explorer examples that didn't make it into the book. Share your interesting Compiler Explorer examples via the Rust Under the Hood Discord Server, and we'll share them with other readers by adding them here.
Array bound checking in Rust
The get_element_guarded
function in Rust safely retrieves an element from a slice of integers based
on a given index. It first checks if the index is within the slice's bounds. If the index is valid, it
returns Some(arr[index])
, wrapping the element in an Option
. If the index is out of
bounds, it returns None
, indicating the absence of a valid element. This approach prevents
potential runtime errors caused by out-of-bounds access.
The generated assembly for get_element_guarded
first compares the index with the length of the
slice. If the index is within bounds, it retrieves the element and sets the return value to indicate a
successful retrieval. If the index is out of bounds, it sets the return value to indicate None
. The
assembly ensures that the function performs a conditional check and efficiently handles both in-bounds and
out-of-bounds cases.
On the other hand, the get_element_unguarded
function retrieves an element from a slice without
performing any bounds checking. It directly accesses the slice using the provided index, which can lead to
undefined behavior if the index is out of bounds. This approach can be more efficient but sacrifices safety, as
it relies on the caller to ensure the valid index.
The generated assembly for get_element_unguarded
includes a bounds check that triggers a panic by
calling the Rust runtime's panic function if the index is out of bounds. If the index is valid, it directly
retrieves and returns the element. This assembly shows that while the function does not explicitly check bounds
in Rust code, the compiler includes necessary safety checks to prevent undefined behavior, leading to a panic if
an out-of-bounds access occurs.
Rust code:
pub fn get_element_guarded(arr: &[i32], index: usize) -> Option<i32> { if index < arr.len() { Some(arr[index]) } else { None } }
pub fn get_element_unguarded(arr: &[i32], index: usize) -> i32 { arr[index] }
Assembly code:
example::get_element_guarded::hc553e4e808b090d7: cmp rdx, rsi ; Compare index (rdx) with the length of the array (rsi) jae .LBB0_1 ; Jump to .LBB0_1 if index >= length (i.e., out of bounds) mov edx, dword ptr [rdi + 4*rdx] ; Move the element at the specified index into edx (element size is 4 bytes) mov eax, 1 ; Set eax to 1 (indicating Some variant of Option) ret ; Return, with eax = 1 and edx containing the element value .LBB0_1: xor eax, eax ; Set eax to 0 (indicating None variant of Option) ret ; Return, with eax = 0
example::get_element_unguarded::he039bda96e0fe6b1: cmp rdx, rsi ; Compare index (rdx) with the length of the array (rsi) jae .LBB1_2 ; Jump to .LBB1_2 if index >= length (i.e., out of bounds) mov eax, dword ptr [rdi + 4*rdx] ; Move the element at the specified index into eax (element size is 4 bytes) ret ; Return, with eax containing the element value .LBB1_2: push rax ; Push rax onto the stack to preserve its value lea rax, [rip + .L__unnamed_1] ; Load the address of the out-of-bounds panic message into rax mov rdi, rdx ; Move the index (rdx) into rdi (first argument for panic function) mov rdx, rax ; Move the address of the panic message into rdx (second argument for panic function) call qword ptr [rip + core::panicking::panic_bounds_check::hd7e618b1b39cc1c3@GOTPCREL] ; Call the panic function .L__unnamed_2: .ascii "/app/example.rs" ; File path where the panic occurred .L__unnamed_1: .quad .L__unnamed_2 ; Address of the file path string .asciz "\017\000\000\000\000\000\000\000\n\000\000\000\005\000\000" ; Additional metadata for the panic
Compliler Explorer Link:
https://godbolt.org/z/jK4Y5WE43
Number of bits needed to encode
The how_many_bits_needed_to_encode
function in Rust calculates the minimum number of bits required
to encode a given non-negative integer in binary. It checks if the input number n
is non-zero. If
it is, the function computes the base-2 logarithm of n
(which gives the position of the highest set
bit) and adds 1 to determine the number of bits needed. If n
is zero, the function returns 1, since
a single bit is sufficient to represent zero.
The generated assembly for this function performs a bit scan reverse (bsr
) to find the index of the
most significant set bit in the input. It then adjusts this index with an XOR operation and an addition to
compute the bit count. A conditional move ensures that if the input is zero, the function returns 1. Otherwise,
it returns the computed bit count. The assembly code efficiently handles the core logic of determining the bit
position and includes a safeguard for zero input, ensuring correctness and efficiency.
Rust code:
pub fn how_many_bits_needed_to_encode(n: u64) -> u32 {
if n != 0 {
n.ilog2() + 1
} else {
1
}
}
Assembly code:
example::how_many_bits_needed_to_encode::he2fbb901a8ddbd35:
bsr rcx, rdi ; Bit Scan Reverse: rcx = index of the most significant set bit in rdi
xor ecx, -64 ; Exclusive OR: ecx = ecx ^ -64 (this effectively flips all the bits of ecx and subtracts 1)
add ecx, 65 ; Add 65: ecx = ecx + 65 (this converts the flipped index back to the original index + 1)
test rdi, rdi ; Test: sets the zero flag if rdi is 0
mov eax, 1 ; Move 1 to eax: eax = 1 (default return value if rdi is 0)
cmovne eax, ecx ; Conditional move: if zero flag is not set (rdi != 0), move ecx to eax
ret ; Return: return eax
Compiler Explorer Link:
💡 Learn More: Compiler Explorer and LLVM
Compiler Explorer
Throughout the book, we have extensively used Compiler Explorer to understand the mapping from Rust to x86-64 assembly. This video demonstrates the advanced features of Compiler Explorer using a C++ example, many of which also apply to Rust.
Conversion of a Rust File to x86-64 Assembly Using LLVM
The conversion of a Rust file to x86-64 assembly using the LLVM (Low-Level Virtual Machine) toolchain involves several stages. Here's a high-level overview of the process:
- Parsing: The Rust compiler (rustc) parses the source code into an Abstract Syntax Tree (AST). This step checks for syntactical correctness and produces a tree representation of the code.
- Analysis and Intermediate Representation (HIR and MIR):
- HIR (High-Level Intermediate Representation): The AST is converted into HIR, a simplified version of the AST. This step involves type-checking and macro expansion.
- MIR (Mid-Level Intermediate Representation): The HIR is converted into MIR, a more abstract and simplified form closer to machine code. MIR is used for optimizations and borrow checking.
- Translation to LLVM IR: The MIR is translated into LLVM IR (Intermediate Representation). LLVM IR is a low-level programming language similar to assembly but designed to be portable across different architectures. This step involves transforming Rust-specific constructs into LLVM IR.
- Optimization: LLVM performs a series of optimizations on the LLVM IR. These optimizations include inlining functions, removing dead code, and optimizing loops. This step helps to produce more efficient machine code.
- Code Generation: The optimized LLVM IR is converted into machine-specific assembly code. In this case, the target is x86-64 assembly. This step involves selecting the appropriate instructions for the target architecture and generating the corresponding assembly code.
- Assembly and Linking: The final assembly code is assembled into object files and linked to create the final executable. This step can involve linking with libraries and resolving external symbols.
The following video shows some of the steps we have outlined above.