Skip to main content

Intro to Rust

We will use the Rust programming language for this course.

Resources

  1. The Rust Programming Language, Chapters 1, 2, 3 and 5
  2. Tour of Rust step by step tutorial
  3. Let's Get Rusty - The Rust Lang Book
tip

This lab is rather long, but it tries to be a really quick intro to Rust. We suggest going directly to the Exercises, solve one by one, and read the required documentation as you go.

Standard library

The standard library is divided into three levels:

LevelDescriptionNeeds
coreProvides the required language elements that Rust needs for compiling, like the Display and Debug traits. Data can only be global items (stored in .data) or on the stack.Hardware
allocProvides everything from the core level plus heap allocated data structures like, Box and Vec. The developer has to provide a memory allocator, like embedded_alloc.Memory Allocator
stdProvides everything from the alloc level plus a lot of features that depend on the platform, including threads and I/O. This is the default level for Windows, Linux, macOS and similar OSes applications.Operating System
note

This course will mostly use the core level of the standard library, as the software has to run on a STM32 Nucleo-U545RE-Q.

By default, Rust has a set of elements defined in the standard library that are imported into the program of each application. This set is called the prelude, and you can look it up in the standard library documentation.

If a type you want to use is not in the prelude, you must bring that type into scope explicitly with a use statement. Using the std::io module gives you a number of useful features, including the ability to accept user input.

use std::io; 

The main function

The main function is the entry point of our program.

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

We use println! macro to print messages on the screen.

info

To print more complex variables, you can use {:?}, which ensures that any type that implements the Debug trait can be printed.

To insert a placeholder in the println! macro, use a pair of braces {} . We provide the variable name or expression to replace the provided placeholder outside the string.

fn main() {

let name = "Mary";
let age = 26;

println!("Hello, {}. You are {} years old", name, age);
// if the replacements are only variables, one can use the inline version
println!("Hello, {name}. You are {age} years old");
}

Variables and mutability

We use the let keyword to create a variable.

let a = 5;

By default, in Rust, variables are immutable , meaning once a value is tied to a name, you cannot change that value.

Example:

fn main() {
let x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}

In this case, we will get a compilation error because we are trying to modify the value of x from 5 to 6, but x is immutable, so we cannot make this modification.

Although variables are immutable by default, you can make them mutable by adding mut in front of the variable name. Adding mut also conveys intent to future readers of the code by indicating that other parts of the code will modify the value of this variable.

fn main() {
let mut x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}

Now the value of x can become 6.

Constants

Like immutable variables, constants are values that are tied to a name and have a value known at compile time.

You are not allowed to use mut with constants. Constants are not only immutable by default, they are always immutable. You declare constants using the const keyword instead of the let keyword. Constants's data type has to be specified at declaration.

const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
info

For a better understanding, please read chapter 3 of the documentation.

Data Types

Scalar types

A scalar type represents a single value. Rust has four main scalar types: integers, floating point numbers, booleans, and characters.

Integer → Each variant can be signed or unsigned and has an explicit size.

let x: i8 = -2;
let y: u16 = 25;
LengthSignedUnsignedJava EquivalentC Equivalent1
8-biti8u8byte/ Byte2char / unsigned char
16-biti16u16short / Short2short / unsigned short
32-biti32u32int / Integer2int / unsigned int
64-biti64u64long / Long2long long / unsigned long long
128-biti128u128N/AN/A
archisizeusizeN/Aintptr_t / uintptr_t

Floating Point → Rust's floating point types are f32 and f64, which are 32-bit and 64-bit in size, respectively. The default type is f64 because on modern CPUs it is about the same speed as f32 but is capable of more precision. All floating point types are signed.

LengthFloating pointJava EquivalentC Equivalent
32-bitf32floatfloat
64-bitf64doubledouble
128-bitf128N/AN/A
fn main() {
let x = 2.0; // f64
let y1: f32 = 3.0; // f32
let y2 = 3.0f32; // f32
}

Boolean → Booleans are one byte in size. Boolean type in Rust is specified using bool.

let t = true;
let f: bool = false; // with explicit type annotation

Character → The Rust char type is the most primitive alphabetic type in the language.

let c = 'z';
let z: char = 'ℤ'; // with explicit type annotation
let heart_eyed_cat = '😻';

Structures

Structs are a data type construct that contain other data types in the form of fields. Rust structures are similar to C structs and Java classes.

To define a structure, we enter the struct keyword and name the entire structure. Then, within curly brackets, we define the names and types of the data, which we call fields.

struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}

To use a structure after having defined it, we create an instance of this structure by specifying concrete values ​​for each of the fields. We create a stack allocated instance by specifying the structure name , then add curly braces containing key: value pairs , where the keys are the field names and the values ​​are the data we want to store in those fields.

fn main() {
let user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("[email protected]"),
sign_in_count: 1,
};
}

To access a certain member of the structure we use this syntax:

fn main() {
let mut user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("[email protected]"),
sign_in_count: 1,
};

user1.email = String::from("[email protected]")
}
warning

Note that the entire instance must be mutable ; Rust doesn't allow us to mark only certain fields as mutable!

We can construct a new instance of the structure as the last expression in the function body to implicitly return this new instance.

fn build_user(email: String, username: String) -> User {
User {
active: true,
username: username,
email: email,
sign_in_count: 1,
}
}

Structure Implementation

Structures in Rust are similar to classes in OOP. Besides the operations mentioned above, structures can also be implemented and have methods specific to a structure. The methods are defined in the structure's implementation.

struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}

impl User {
// static method (no self parameter)
// called with User::new()
fn new() -> User {
// ...
}
// method
// called user.is_active()
fn is_active (&self) -> bool {
return self.active;
}
}

Printing Structures

If we try to print an instance of User using the println! macro as we have seen early, it will not work.

fn main() {
let user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("[email protected]"),
sign_in_count: 1,
};

println!("User is: {}", user1);
}

We will get the following error message:

error[E0277]: `User` doesn't implement `std::fmt::Display`

In order to print a structure, we need to use {:?} instead of {}, and implement Debug trait for the structure with #[derive(Debug)].

note

We use Debug trait to print structures, arrays, enums or any other type that doesn't implement Display.

#[derive(Debug)]
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}

fn main() {
let user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("[email protected]"),
sign_in_count: 1,
};

println!("User is: {:?}", user1);
}

Output:

User is: User { active: true, username: "someusername123", email: "[email protected]", sign_in_count: 1 }
tip

To format the Debug nicely use {:#?}.

Tuple structures

Tuples are the same as structures, just that instead of using names for their fields, they use numbers (indexes).

struct Color(i32, i32, i32);
struct Device(String, u8);

fn main() {
let black = Color(0, 0, 0);
let device = Device(String::from("STM32 Nucleo-U545RE-Q"), 2);

println!("The device type is {} and the version is {}", device.0, device.1);
}

Tuples can be named (as the one shown above) or can be anonymous. The following example shows how functions use anonymous tuples to return multiple values.

fn get_item_and_index(value: &str) -> (String, usize) {
// usually search the value here
(String::from("the name"), 0)
}

let value = get_item_and_index("...");
// use value.0 and value.1
info

For a better understanding, please read chapter 5 of the documentation.

Enums

Enumerations, also referred as enums, allow you to define a type by enumerating its possible variants.
How to define an enum:

enum IpAddrKind {
V4,
V6,
}

Option enum

Option is another enum defined by the standard library. The Option type encodes the very common scenario in which a value can be something or nothing.

Rust doesn't have the null functionality that many other languages ​​have. Null is a value that means there is no value here. In languages ​​with null, variables can always be in one of two states: null or non-null.

As such, Rust does not have null values, but it does have an enumeration that can encode the concept of a value being present or absent. This enumeration is Option<T>, and it is defined by the standard library as follows:

enum Option<T> {
None,
Some(T),
}

For now, all you need to know is that <T> means that the Some variant of the Option enumeration can contain data of any type.

fn integer_division (a:isize, b: isize) -> Option<isize> {
if b == 0 {
None
} else {
Some(a / b)
}
}

When we have a Some value, we know that a value is present and that the value is contained in Some. When we have a None value, it kind of means the same thing as null: we don't have a valid value.

note

You must convert an Option<T> to a T before you can perform T operations with it.

Match

Rust has an extremely powerful control flow construct called match that allows you to compare a value against a series of patterns and then run code based on which pattern matches. Patterns can consist of literal values, variable names, wildcards, and many other things.

enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}

When the match expression runs, it compares the resulting value to the model for each arm, in order. If a pattern matches the value, the code associated with that pattern is executed. If this pattern does not match the value, execution continues to the next arm.

The code associated with each arm is an expression , and the resulting value of the expression in the corresponding arm is the returned value for the entire matching expression.

In the previous section, we wanted to extract the internal T value of the Some case when using Option<T>; we can also handle Option<T> using match , like we did with the Coin enumeration! Instead of comparing parts, we will compare variants of Option<T>, but the way the match expression works remains the same.

fn main () {
let x = 120;
let y = 7;
match integer_division (x, y) {
Some(d) => println! ("{}:{} = {}", x, y, d),
None => println! ("division by 0")
};
}

Result enum

Result is an enum whose purpose is to encode error management information:

  • The variant Ok indicates that the operation was successful and within the Ok the intended result is wrapped
  • The variant Err indicates that the operation had an error somewhere and within the Err a struct/enum with more information about the error that happened

The definition given by the standard library is:

enum Result<T,E> {
Ok(T),
Err(E),
}

where T and E are generic types and mean you may have anything within an Ok or an Err

Example:

use std::fs::File;

fn main() {
let greeting_file_result = File::open("hello.txt");

let greeting_file = match greeting_file_result {
Ok(file) => {
// use the file variable here
}
Err(error) => panic!("Problem opening the file: {:?}", error),
};
}
The ? operator

You may place a ? after a call that returns a result value, and it will immediatelly propagate the error to the caller, otherwise continue as if the expression is the value inside the Ok variant

Example:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();

File::open("hello.txt")?.read_to_string(&mut username)?;

Ok(username)
}
info

For a better understanding, please read chapter 6 of the documentation.

Tuple → A tuple is a structure used for grouping a number of values ​​with a variety of types into a single compound type. Tuples have a fixed length : once declared, their size cannot increase or decrease.

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

Array → Unlike a tuple, each element in an array must have the same type. Unlike arrays in some other languages, Rust arrays have a fixed length.

let a = [1, 2, 3, 4, 5];
info

For a better understanding, please read chapter 3 of the documentation.

Functions

We define a function in Rust by entering fn keyword followed by a function name and a set of parentheses. Curly braces tell the compiler where the function body begins and ends.

fn main() {
println!("Hello, world!");

another_function();
}

fn another_function() {
println!("Another function.");
}

Parameters

We can define functions with parameters, which are special variables that are part of a function's signature. When a function has parameters, you can provide it with concrete values ​​for those parameters, also called arguments.

fn main() {
// the `another_function` function call has one single argument, the value 5.
another_function(5);
}

// the `another_function`function has one single parameter `x` of type `i32`
fn another_function(x: i32) {
println!("The value of x is: {x}");
}
note

In function signatures you must declare the type of each parameter!

Functions with return values

Functions can return values ​​to the code that calls them. We don't name the return values, but we must declare their type after an arrow (->). In Rust, the function's return value is synonymous with the value of the final expression in a function's body block. You can return earlier from a function by using the return keyword and specifying a value, but most functions implicitly return the last expression.

fn five() -> i32 {
5
}

fn main() {
let x = five();
println!("The value of x is: {x}");// "The value of x is: 5"
}
info

For a better understanding, please read chapter 3 of the documentation.

Control flow

if-else

All if expressions start with the if keyword , followed by a condition. Optionally, we can also include an else expression.

fn main() {
let number = 3;

if number < 5 {
println!("condition was true");
} else {
println!("condition was false");
}
}

You can use multiple conditions by combining if and else in an else if expression:

fn main() {
let number = 6;

if number % 4 == 0 {
println!("number is divisible by 4");
} else if number % 3 == 0 {
println!("number is divisible by 3");
} else if number % 2 == 0 {
println!("number is divisible by 2");
} else {
println!("number is not divisible by 4, 3, or 2");
}
}

Because if is an expression, we can use it on the right side of a let statement to assign the result to a variable.

fn main() {
let condition = true;
let number = if condition { 5 } else { 6 };

println!("The value of number is: {number}");//"The value of the number is 5"
}

loop

The loop keyword tells Rust to run a block of code over and over forever or until you explicitly tell it to stop.

fn main() {
loop {
println!("again!");
}
}

One use of a loop is to retry an operation that you know might fail, such as checking if a thread has finished its work. You may also need to pass the result of this operation out of the loop to the rest of your code. To do this, you can add the value you want to return after the break expression you use to stop the loop; this value will be returned out of the loop so you can use it:

fn main() {
let mut counter = 0;

let result = loop {
counter += 1;

if counter == 10 {
break counter * 2;
}
};

println!("The result is {result}");
}

while

fn main() {
let mut number = 3;

while number != 0 {
println!("{number}!");

number -= 1;
}

println!("LIFTOFF!!!");
}

for

In Rust, the for structure is used to iterate over a list of elements (vec). With each iteration, a reference to an element in the list is returned.

fn main() {
let a = [10, 20, 30, 40, 50];

for element in a {

println!("the value is: {element}");
}
}

We can also write for loop in a more traditional way:

fn main() {
for i in 0..10 {
println!("the value is: {i}");
}
}
info

For a better understanding, please read chapter 3 of the documentation.

Complex Data Types

Vec

The data type that Rust standard library provides for storing a list of data is Vec. This is similar ot the C++ vector and Java ArrayList.

The data type of a vector is Vec<T>, where T can be any data type.

To create a new vector, Rust provides the vec! macro. A longer vay of writing is Vec::new().

let v = vec![];
// or
let v = Vec::new();

The actual type of T should be inferred by the compiler.

warning

Sometimes the compiler is not able to infer the data type and we must provide it.

let v: Vec<String> = vec![];
// or
let v = Vec::<String>::new();

The Vec type provides several functions to insert, access and delete elements.

MethodDescriptionReturned Data Type
len()Number of elements in the vectorusize
push(t: T)Add an element of type T to the end of the vector()
get(index: usize)Get a reference to one of the elements in the vectorOption<&T>
get_mut(index:usize)Get a mutable reference to one of the elements in the vectorOption<&mut T>
remove(index:usize)Remove the element at an index.T
warning

The remove function will panic if the index is out of bounds.

The best way to iterate of all the elements in a Vec is using a for.

for element in v {
// use element of type &T
}

String

Rust has only one type of string in the core language, which is the string slice str which is usually seen in its borrowed form &str.

The String type , which is provided by the Rust standard library rather than encoded in the main language, is a scalable, mutable, and owned UTF-8 encoded string type .

Creating a new String

let mut s = String::new();

This line creates a new empty string called s, which we can then load data into.

We can use the String::from function or the to_string function to create a string from a string literal:

let s = String::from("initial contents");
let data = "initial contents";

let s = data.to_string();

// the method also works on a literal directly:
let s = "initial contents".to_string();

Adding to a string

We can expand a string using the push_str method to add a string slice.

let mut s = String::from("foo");
s.push_str("bar");

The push method takes a single character as a parameter and adds it to the string.

let mut s = String::from("lo");
s.push('l');

Iteration Methods on Strings

The best way to operate on pieces of strings is to be explicit about whether you want characters or bytes. For individual Unicode scalar values, use the chars method .

for c in "Зд".chars() {
println!("{}", c);
}

Run the program

In order to run the program we may be anywhere in the crate's folder and execute the command:

cargo run

Bitwise Operations in Rust

When developing embedded software for the STM32 Nucleo-U545RE-Q, you are frequently required to configure specific hardware peripherals. Whether you are turning on one of the 5 LEDs or reading the state of the 4 buttons on your lab board, you communicate with the microcontroller by modifying specific bits within its hardware registers.

Because hardware registers group multiple independent configuration settings into a single 32-bit (or 8-bit) word, you cannot simply overwrite the entire register without risking the disruption of other settings. This is where bitwise operations come in.

The Anatomy of a Hardware Register

Registers in microcontrollers generally consist of bits grouped by their specific roles. Here is what those bits actually do under the hood:

  • Control bits: These are used to control different operating modes of the microcontroller or to activate specific hardware components. For example, setting a specific control bit might turn on the UART communication peripheral.
  • Status bits: These are generally read-only bits used to check the state of an action or hardware peripheral. For instance, a status bit might turn to 1 automatically when new data has arrived in a buffer.
  • Data bits: These bits are used to provide the microcontroller with data to be processed, or to retrieve data from it.
  • Reserved bits: These bits are not currently used by the microcontroller. As a general rule in embedded programming, you should never modify reserved bits.

Bit Shifting

Before modifying registers, you need to know how to target a specific bit. We do this by taking the number 1 and "shifting" it to the correct position.

  • Right Shift (>>): This moves each bit of the operand to the right by a specified number of positions. Zeros are added to the left to maintain the dimension of the number. Mathematically, shifting right is a highly efficient way to divide a binary number by 2n2^n in a single operation.
info

1010 >> 1 = 0101 (In decimal: 10 divided by 2 becomes 5).

  • Left Shift (<<): Moves bits to the left, filling the empty spaces on the right with zeros. Shifting left by n is mathematically equivalent to multiplying by 2n2^n.
info

0011 << 1 = 0110 (In decimal: 3 multiplied by 2 becomes 6).

Modifying Registers

Here are the primary operations you will use daily to manipulate bits without corrupting the rest of the register.

Setting Bits (Making them 1)

To set a specific bit to 1 while leaving the rest of the register untouched, we use the bitwise OR (|) operator.

  • Set a single bit: register | 1 << bit
  • Set multiple bits: register | bits

Clearing Bits (Making them 0)

To clear a specific bit to 0, we use a combination of the bitwise AND (&) and bitwise NOT (!) operators.

  • Clear a single bit: register & !(1 << bit)
  • Clear multiple bits: register & !bits

Toggling / Flipping Bits

If you need to invert the current state of a bit (change 1 to 0, or 0 to 1), use the bitwise XOR (^) operator.

  • Flip a single bit: register ^ (1 << bit)
  • Flip multiple bits: register ^ bits

Value Extraction(Masking)

Often, a hardware register contains a specific multi-bit "field" (like a 4-bit configuration value) packed into a larger 32-bit word. To read just that field, you need to shift the bits down to the zero position and apply a Mask to zero-out everything else.

Here is a practical example of extracting a specific portion of a 32-bit ID:

// We define a mask that isolates the bottom 12 bits
const MASK: u32 = 0b0000_0000_0000_0000_0000_1111_1111_1111;

fn main() {
// A 32-bit register value
let large_id: u32 = 0b1100_1010_1111_1100_0000_1111_0110_1101;

// 1. Shift right by 20 to bring the target bits to the bottom
// 2. Apply the AND mask to clear all upper bits
let extracted_bits = (large_id >> 20) & MASK;

// Result: 00000000_0000_0000_0000_1100_1010_1111
}

Common Pitfalls

Bitwise operations are prone to logical typos. Watch out for these common errors:

warning

Using the assignment operator (=) instead of the compound bitwise operator (|= or &=).

  • Wrong: register = 1 << 4 (This wipes out the entire register and only sets bit 4)
  • Correct: register |= 1 << 4 (This preserves the rest of the register while setting bit 4).
warning

Do not confuse logical operators with bitwise operators!

  • && and || evaluate truthiness (e.g., true && false)
  • & and | perform mathematically accurate bit-by-bit operations (e.g., 0b1100 & 0b0101 = 0b0100).

Exercises

tip

If you don't have Rust installed, you can use Rust Playground to solve the topics.

info

Before tackling the exercises, take a look and cover chapters 1, 2 and 3 of Tour of Rust tutorials.

  1. Write a function that takes your name as a parameter and greets you to stdout (prints on the screen). What type should the parameter have and why?.
  2. Write a function that takes an unsigned integer N as a parameter and prints out the first N odd numbers.
  3. Write a function that returns the first even number from an array slice. Make sure to handle the case when there is no even number. Hint: a slice is a part of an array, &a[first..end]. Take a look at for and Option. Keep in mind that for gives a reference to each element in the list.
  4. Write a function that looks through a vector of strings and returns the first element that has more than 4 characters Hint: Take a look at for, Option, and the from(), len() and to_string() functions.
  5. Define a vector of transactions that can either be in Ron, Dollars, Euros, Pounds and Bitcoin. Create a function that calculates the total value in Ron that the vector has (assume Ron is 1, Dollar is 4.5, Euro is 5, Pound is 6 and Bitcoin is 100000) Hint: take a look at enum and structures.
  6. Write a function that transforms a string slice &str into an unsigned integer, returning either the value, or an error code. Create an error type that handles the cases where string is empty, string has invalid character (and at which position) and when the number given is negative. Hint: Take a look at Option and Result
  7. Define a structure Complex with floats. a. implement an associated static function new for this structure. b. Implement 2 possible operations for it (which includes the absolute value and multiplication). c. Implement a display method that prints the number.

Footnotes

  1. The data types used here are considered for a 32 bit system, for other architectures the equivalent data types might differ (short is at least 2 bytes long).

  2. Starting with Java 8, the Number classes have some helper methods, like compareUnsigned and toUnsigned... that allow the usage and manipulation of unsigned numbers. 2 3 4