🦀 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.

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 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 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:

https://godbolt.org/z/jrG91EMbb

💡 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.