Back to top

Mastering the Essentials of Rust for System Programming

Published: Feb 7, 2024


Introduction to Rust


Rust is a systems programming language that has quickly grown in popularity since its initial release in 2015. Created by Mozilla Research, Rust aims to bring greater speed, safety, and concurrency to systems-level development.


Rust provides the performance and low-level control often associated with languages like C and C++, but includes a number of higher-level features as well. The Rust compiler enforces memory safety and thread safety at compile time, eliminating many classes of bugs and vulnerabilities that plague other system languages.


The language has evolved significantly over the past decade, incorporating ideas and paradigms from numerous other languages. Rust continues to be driven by an active open source community focused on improving performance, productivity, and robustness when building system software.


Some key strengths of Rust include:


  • Memory safety - The borrow checker prevents dangling pointers, double frees, and data races at compile time. No need for garbage collection.
  • Zero-cost abstractions - Features like traits and generics enable abstraction and code reuse without runtime costs.
  • Concurrency - Lightweight tasks and message passing provide concurrency support “out of the box”.
  • Speed - Rust competes with C and C++ for performance without compromising safety.
  • Reliability - The compiler guarantees thread and memory safety, reducing crashes and security holes.

Rust has proven to be an excellent choice for developing low-level system components like operating systems, embedded devices, network services, and performance-critical applications. As the language matures, Rust continues to gain traction in fields like game development, web programming, and data science as well.


Why Rust for System Developers


The system programming landscape has traditionally been dominated by languages like C, C++, and assembly. However, Rust offers some compelling advantages that make it an excellent choice for developing fast, safe, and efficient system software.


The Challenges of System Programming


System programming involves writing software that interacts closely with computer hardware while still providing services for higher-level applications. This includes operating systems, device drivers, databases, web servers, compilers, and more.


Crafting robust system software can be challenging for several reasons:


  • Memory management - Manually allocating and freeing memory is complex and prone to errors like leaks, double frees, and invalid accesses. These can lead to crashes or vulnerabilities.
  • Concurrency - With multiple threads accessing data, race conditions and synchronization bugs can occur. These are often subtle and difficult to debug.
  • Isolation - Poor isolation between software components can cause crashes when one component fails. Lack of isolation also leads to vulnerabilities like memory safety issues.
  • Low-level control - Tight hardware integration requires operating at a low level using tricky, unsafe code in languages like C or assembly.

Languages like C and C++ provide excellent performance but place the burden of navigating all of these pitfalls solely on the developer.


Rust to the Rescue


Rust provides a unique combination of speed, safety, and control - making it a fantastic language for developing robust system components:


  • Memory safety - Rust’s borrow checker ensures memory is managed correctly without garbage collection overhead. Entire classes of memory bugs are compile-time errors in Rust.
  • Fearless concurrency - Rust’s ownership model guarantees thread safety at compile time. Lightweight tasks and message passing make concurrent code fast and easy.
  • Zero-cost abstractions - Traits, generics, and functional patterns provide polymorphism and code reuse without runtime costs.
  • Control - Rust provides fine-grained control over memory layouts, pointer aliasing, and other low-level details.

By leveraging Rust’s strengths, system developers can write blazingly fast, leak-free code without compromising system resources or safety. Rust enables writing robust system software with greater confidence.


Hello, World!


Now that we’ve covered what Rust is and why it’s useful for systems programming, let’s look at a simple “Hello, World!” program to see Rust in action. This will demonstrate how to write, compile, and run a basic Rust program.


Writing the Hello World Program


Here is the full source code for a simple Hello, World! program in Rust:


fn main() {
println!("Hello, World!");
}

Let’s break down what’s happening in this program:


  • fn main() - The main function is the entry point for a Rust program. All Rust programs must have a main function.
  • println! - This macro prints a string to the screen. The ! indicates it’s a macro rather than a normal function.
  • "Hello, World!" - This string literal is passed to println! to be printed.

And that’s all there is to it! This program prints Hello, World! to the terminal.


Compiling and Running


To compile this program, save the code in a file named main.rs and run:


rustc main.rs

This will compile the program and generate a binary executable called main.


Then to run the program, execute:


./main

You should see Hello, World! printed to the terminal. And that’s all there is to compiling and running a simple Rust program!


Variables and Mutability


Now that we can print “Hello, World”, let’s look at how we can store data in variables and work with mutability in Rust.


Declaring Variables


To declare a variable binding in Rust, use the let keyword:


let x = 5;

This creates an immutable binding named x with the value 5.


By default, bindings in Rust are immutable. To make a binding mutable, add mut before the binding name:


let mut y = 5; // y is mutable

Constants


In addition to variables, Rust also supports constants which are immutable values bound to a name. Constants are defined using the const keyword:


const MAX_POINTS: u32 = 100_000;

Constants differ from let variables in a few ways:


  • Constants must have their type annotated
  • Constants can only be set to a constant expression, not the result of a function call or other runtime value
  • By convention, constants are named with all uppercase letters

For example:


let x = 5; // Type can be inferred

const Y: i32 = 10; // Type must be annotated

let z = get_random(); // Can call a function 

const W: f64 = get_random(); // INVALID - cannot call function

Constants are useful for defining names for fixed values that may be used throughout your program. The name documents the meaning rather than just the value itself.


Variable Scope


Variables in Rust have scope - they are only available within the block they are declared in.


let x = 5;

{
let y = 10;
} 

// x is still in scope here, but y is not

Shadowing


We can reuse a variable name using shadowing:


let x = 5;
println!("{}", x); // prints 5
{
    println!("{}", x); // prints 5
    let x = 10;        // x is shadowed here from the outer one
    println!("{}", x); // prints 10
}
println!("{}", x); // prints 5
let x = x + 1; // shadows previous binding
println!("{}", x); // prints 6

This allows reusing a variable name rather than forcing unique names everywhere.


Mutability


For a mutable binding like mut x, we can modify it:


let mut x = 5; 
x = 6; // allowed as x is mutable

This mutability allows for modifying variables when needed. However, Rust prefers immutability when possible for safety and concurrency.


Here is an expanded explanation of shadowing vs updating immutable variables in Rust:


Shadowing vs. Updating


Shadowing and updating a mutable variable are two different concepts in Rust:


  • Shadowing redefines a variable in a new scope, hiding the previous binding. This allows reusing a variable name.
  • Updating mutates a mutable variable in-place. The same binding is changed.

For example:


let x = 5; 

{
let x = x + 1; // shadows x, doesn't modify the original binding
println!("inner x is: {}", x); // prints 6
} 

println!("outer x is: {}", x); // prints 5, not modified 

let mut y = 5;

{
y = y + 1; // updates y in-place
println!("inner y is: {}", y); // prints 6
} 

println!("outer y is: {}", y); // prints 6, y was mutated

Shadowing creates a new variable, while mutating updates the original binding.


Type Annotations


Rust is statically typed, so every variable binding has a type. However, in many cases Rust can infer the type automatically:


let x = 5; // Type i32 is inferred

We can also explicitly annotate the type if desired:


let x: i32 = 5;

This states that x is an i32 integer type.


Type annotations are required when:


  • The type cannot be inferred
  • There is ambiguity
  • Added clarity is desired

For example:


let x: f64 = 5.0; // f64 annotated, i32 inferred otherwise 

let y: &str = "hello"; // Reference needs annotation

Annotating types makes code clearer and serves as in-code documentation. The compiler also enforces that the assigned value matches the annotation.


Scalar Data Types


Rust provides a variety of scalar data types for storing simple values like integers, floats, booleans, and characters. Let’s explore the built-in scalar types available in Rust.


Integer Types


Rust provides both signed and unsigned integer types in fixed bit-widths. Some common examples:


  • i8, i16, i32, i64, i128 - signed integers
  • u8, u16, u32, u64, u128 - unsigned integers

For example:


let x: i32 = -123;
let y: u8 = 255;

Floating Point Types


Rust has two primitive types for floating point numbers:


  • f32 - 32-bit float
  • f64 - 64-bit double

For example:


let x = 3.14159; // f64 by default
let y: f32 = 5.2;

Boolean Type


Rust has a built-in bool type for boolean values true and false:


let t = true;
let f: bool = false; 

Booleans are 1 byte in size.


Character Type


Rust’s char type represents a Unicode character:


let c = 'a';

char is 4 bytes in size and can represent any Unicode character.


When to Use Each Scalar Type


  • Integers - Use signed integers (i32, i64) for math or any time you need negative numbers. Use unsigned (u32, u64) for positive-only values like indices and bit patterns. Use sized integers like u8 for optimization when values are small.

  • Floats - Use f64 for most floating point math. It provides double precision. Use f32 if you need to save space or optimize for performance when less precision is acceptable.

  • Booleans - Use bool for boolean logic, flags, and anytime you need a simple true/false value.

  • Chars - Use char when you need a single Unicode character, like parsing text or tokens. Avoid using char for numeric values - use integers instead.


Some rules of thumb:


  • Use unsigned integers for positive-only values
  • Use i64/u64 as general purpose integer unless you have a specific size need
  • Use f64 for most floating point math
  • Use bool for true/false flags and conditions
  • Use char for single Unicode characters

Choosing the right scalar type in Rust ensures efficient code that matches the intended use case. Now let’s look at…


Tuples


Tuples are a compound data type in Rust that allow grouping multiple values of different types together in a fixed order. Tuples are useful for returning multiple values from functions or temporarily grouping related values.


Creating Tuples


Tuples are defined by listing comma-separated values inside parentheses:


let tup = (500, 6.4, 1); // A 3-element tuple

Each position in the tuple has a type, inferred from the corresponding value:


let tup: (i32, f64, u8) = (500, 6.4, 1);

Tuples can contain mixed types and can also be nested:


let nested = ((4, 'a'), 2.3, true); // Nested tuple

Accessing Tuple Elements


To access elements of a tuple, we can use dot notation with the index:


let tup = (500, 6.4, 1);

let (u, v, w) = tup;
println!(“{}”, u); // 500 – first element

let x = tup.0; // 500 - first element
let y = tup.1; // 6.4 - second element

This allows retrieving tuple elements conveniently. Tuples are useful for returning multiple values from functions or temporarily grouping related data.


Arrays


Arrays allow storing multiple values of the same type in a contiguous block of memory. Unlike tuples, arrays have a fixed length set at compile time.


Creating Arrays


Array types are written as [T; N] where T is the element type and N is the fixed length.


Arrays can be initialized using a literal with the same syntax:


let a: [i32; 5] = [1, 2, 3, 4, 5]; // i32 array of length 5

Alternatively, you can use the vec! macro to initialize an array:


let a = vec![1, 2, 3, 4, 5]; // a is inferred as [i32; 5]

Accessing Array Elements


Array elements are accessed using indexing syntax:


let a = [1, 2, 3];

let first = a[0]; 
let second = a[1];

Attempting to access an invalid index will result in a panic.


Array Operations


Common array operations include:


  • Iterating through elements with a for loop
  • Checking length with array.len()
  • Slicing sections like &array[..2]
  • Comparing arrays with ==

Arrays allow fixed-size contiguous storage when needed.


Modifying Array Elements


Unlike vectors, arrays have a fixed size set at compile time. However, if an array is mutable, we can modify elements by assigning to indexed positions:


let mut arr = [1, 2, 3];
arr[1] = 5; // Set second element to 5

The entire array can also be reassigned:


let mut arr = [1, 2, 3];
arr = [5, 6, 7]; // Reassign entire array

Note that arrays are stack allocated, so modifying large arrays can be expensive. Vectors are generally preferred for growable data.


Rules for modifying arrays:


  • An array must be declared mut to be modified
  • Elements can be modified by index assignment
  • Entire array contents can be replaced
  • Array length cannot be changed

Vectors


Vectors provide a resizable array type in Rust. Unlike arrays, vectors can grow or shrink in size dynamically.


Creating Vectors


Vectors are created using the vec! macro:


let v = vec![1, 2, 3]; // v: Vec<i32>

They can also be created empty:


let v = Vec::new();

Vector elements can be any copyable type.


Modifying Vectors


Elements can be pushed to the end of a vector with push():


let mut v = vec![1, 2];
v.push(3);

Elements can be popped from the end with pop():


let mut v = vec![1, 2, 3];
v.pop(); // Pops 3

Other modifying methods include insert(), remove(), clear(), etc.


Reading Elements


Elements can be read using indexing syntax:


let v = vec![1, 2, 3];
let x = v[1]; // x is 2

Vectors implement many helpful methods like iter() for iteration. Vectors provide a flexible way to store a resizable list of elements.


Comparing Tuples, Arrays, and Vectors


Rust provides several compound data types for storing multiple values. Here we will compare tuples, arrays, and vectors - three options for grouping data.


Key Differences


FeatureTupleArrayVector
Size
Fixed
Fixed
Resizeable
Types
Mixed
Homogeneous
Homogeneous
Heap/Stack
Stack
Stack
Heap
Declaration
Parentheses
Brackets
`vec!` macro

The key differences include:


  • Size - Tuples have a fixed length while vectors can grow or shrink. Arrays are fixed size.
  • Types - Tuples can contain values of different types, while array and vector elements must have the same type.
  • Allocation - Tuples are allocated on the stack. Arrays can be stack or heap allocated. Vectors are always heap allocated.
  • Creation - Tuples use parentheses, arrays use brackets, and vectors use the vec! macro.

Methods


The available methods also differ:


  • Tuples - Tuples have only a few methods like tup.0 to access elements. No modification is allowed.
  • Arrays - Arrays support methods like arr.len() for length and arr[idx] for access. No built-in modification methods.
  • Vectors - Vectors provide many methods like push, pop, insert, and remove for modifying data. They also have iter(), len(), and indexing support.

Use Cases


  • Use tuples for temporary groups of values or returning multiple values from a function.
  • Use arrays when you need a fixed-size collection stored on the stack.
  • Use vectors when you need a resizable heap-allocated collection.

So in summary, tuples, arrays, and vectors each serve different use cases in Rust. Pick the type that makes sense for a particular task.


Slices


Slices provide a view into a contiguous sequence of elements in a collection. Slices are a powerful feature in Rust.


Slice Syntax


Slices are created using brackets with a start and end index:


let arr = [1, 2, 3, 4, 5];
let slice = &arr[1..3]; 

This creates a slice referencing elements 1 to 3. Slices do not copy data - they are just a view.


Slices can be mutable too:


let mut arr = [1, 2, 3];
let slice = &mut arr[0..2]; // Mutable reference
println!("{:?}", slice); // [1, 2]
slice[1] = 5;
println!("{:?}", slice); // [1, 5]
println!("{:?}", arr); // [1, 5, 3]

Borrowing


Slices borrow a reference to the underlying data. The borrow checker ensures the original data is not moved while the slice is active.


Slices implement Deref to provide transparent access:


let arr = vec![1, 2, 3];
let slice = &arr[..];

println!("{}", slice[1]); // prints 2

Use Cases


Slices are used when:


  • Borrowing a section of an array or vector
  • Passing a view into a large buffer
  • Allowing functions to access a section without taking ownership

Overall, slices provide an important view abstraction in Rust. They are used extensively when borrowing sections of collections.


Strings and &str


Rust has two main string types - String and &str. Both support string slices, but they have some key differences.


String


String is an owned, growable UTF-8 string type. Strings can be created from string literals:


let s = "Hello".to_string(); // s: String

Strings can grow and their contents can be modified:


let mut s = String::from("Hello");
s.push_str(" World!"); // Growable

&str


&str is a string slice - it borrows a view of a UTF-8 string. String literals have type &str:


let s = "Hello"; // s: &str

&str cannot be directly mutated as it is borrowed.


Ownership


The key difference is String owns its data while &str borrows. This impacts their creation, usage, and performance:


  • String requires allocation and copying
  • &str is just a view, efficient for small strings
  • Functions taking &str avoid costs of String creation

For owned, growable strings, use String. For string slices, use &str.


String Literals


Let’s now look at string literals in Rust and the differences between string literals and String/&str types.


String Literal Syntax


String literals use double quotes:


let s = "hello"; // string literal

Escape characters like \\n can be used within string literals:


let s = "hello\nworld"; // literal with escape

Differences from String/&str


String literals have some differences from String and &str types:


  • String literals are fixed at compile time, String/&str are runtime types.
  • Literals don’t support method calls, while String/&str have methods.
  • Literal syntax is more convenient, but provides less flexibility.

For example:


let s = "hello"; // immutable literal

let mut s2 = String::from("hello"); // mutable String
s2.push_str(" world!"); // can modify at runtime

So while literals provide a nice syntax, String/&str have more functionality.


Type of String Literals


The type of a string literal is &static str - an immutable reference with a static lifetime. This lifetime means the string data is valid for the entire duration of the program.


In summary, string literals provide a convenient syntax for string data at compile time. For mutable runtime strings, convert literals to a String or &str.


Operators


Rust provides a variety of operators that can be used to perform operations on primitives and other types. Let’s take a look at some common operators in Rust.


Arithmetic Operators


Arithmetic operators are used to perform basic math operations:


let x = 5 + 2; // Addition
let y = 10 - 3; // Subtraction 

let z = 5 * 2; // Multiplication
let w = 10 / 2; // Division

let r = 15 % 4; // Remainder

Comparison Operators


Comparison operators compare two values and return a boolean:


let a = 5;
let b = 2;

let c = a == b; // Equality
let d = a != b; // Inequality 

let e = a > b; // Greater than
let f = a < b; // Less than

let g = a >= b; // Greater than or equal to 
let h = a <= b; // Less than or equal to

Logical Operators


Logical operators like !, && and || can combine boolean values:


let x = 5;

let y = !true; // Logical NOT 

let z = x > 0 && x < 10; // Logical AND

let a = x == 5 || x == 6; // Logical OR

Rust provides many more operators - bitwise, compound assignment, etc. Operators provide convenient syntactic sugar for operations on values.


Functions


Functions are core building blocks in Rust. Let’s look at how to define and call functions in Rust.


Function Syntax


Functions are defined using the fn keyword:


fn print_hello() {
    println!("hello");
}

The function name follows fn. Parameters go inside the parentheses and the function body goes inside curly braces.


Calling Functions


To call a function, specify its name followed by parentheses:


print_hello(); // Call the function

Parameters can be passed during the function call:


fn print_sum(x: i32, y: i32) {
    println!("{}", x + y);
}

print_sum(5, 10); // Passes 5 and 10 to the function

Return Values


In Rust, functions and all code blocks return the value of the last expression implicitly. For example:


fn sum(x: i32, y: i32) -> i32 {
    x + y // No semicolon - returns the sum 
}

let result = sum(5, 10); // Assigns the returned sum

If a semicolon is added after the last expression, the function would return () (unit type) instead.


The return keyword can be used to return early before the end of the function body:


fn check_limit(x: i32) -> bool {
  if x > 100 {
      return true; // Returns early here 
  }
false // This line does not execute if return above is reached
}

return is only needed when you want to return before the end of the function body.


The ? operator is also commonly used to propagate errors:


fn read_file(path: &str) -> Result<String> {
let contents = fs::read_to_string(path)?; // Return early if error
Ok(contents)
}

Control Flow


Control flow allows a Rust program to conditionally execute code based on different paths. Let’s look at if..else conditionals and loops in Rust.


if..else Conditionals


The if expression allows conditional branching:


let x = 5;

if x > 0 {
    println!("x is positive"); 
} else {
    println!("x is not positive");
}

The curly braces denote a conditional block. else if can also chain multiple conditions:


if x > 0 {
    // x is positive
} else if x < 0 {
    // x is negative
} else {
    // x must be 0
}

Loops


Rust provides loop, while, and for loops.


A loop loops unconditionally:


loop {
    // repeats forever 
}

A while loops conditionally based on a boolean expression:


while x != 0 {
    // keep looping while x != 0
} 

A for loop iterates over a collection:


for i in 0..10 {
    // loops 10 times  
}

Controlling loops


To end a loop early in Rust, the break keyword is used:


let mut x = 0;

loop {
x += 1; 

if x == 10 {
    break; // Ends the loop if x is 10
}
}

The continue keyword skips the current iteration, but keeps looping:


for i in 0..100 {
if i % 2 == 0 { 
    continue; // Skip even numbers
}

println!("{}", i); // Prints odd numbers
}

Loop Labels


To disambiguate between nested loops, Rust allows naming outer loops with labels:


outer: for x in 0..10 {
  'inner: for y in 0..10 {

      // Breaking the outer loop specifically
      if x * y > 20 {
          break outer; 
      } 
  }
}

Looping constructs allow repeating code execution conditionally or iteratively in Rust.


Recap and Next Steps


In this post we covered the basics of Rust programming including:


  • Introduction to Rust - a systems programming language focused on safety, speed and concurrency
  • Writing a simple Hello World program
  • Using variables, mutability, and data types like integers, floats, bools, and chars
  • Compound data types like tuples, arrays, vectors, and string
  • Control flow with conditionals and loops
  • Functions for reusing code and abstracting logic

These core concepts will be built upon in future posts.


Some suggestions for next steps:


  • Read The Rust Programming Language Book for deeper examples
  • Work through the rustlings course for hands-on practice
  • Experiment by writing small programs and reading docs
  • Join the Rust community forums for discussion
  • Check the appendix for additional learning resources

Rust has a lot more to offer - ownership, traits, error handling, and much more. But this post provides the basic foundation needed to start exploring the language. Happy Rusting!