Language Characteristics
Rust introduces some new concepts and programming paradigms that we will take at look now.
Ownership
Programming languages take vastly different approaches when it comes to dynamic memory management. In Java and C# the developer does not bother with memory management, because a garbage collector frees unused objects at runtime. In C, however, it is up to the developer to allocate and free memory in the code. It is similar C++ except we now have smart pointers to help. unique_ptr<T>
and shared_ptr<T>
track access and delete the referenced object as soon as it is not used anymore.
Rust takes an approach that combines the advantages of the two methods described above: the developer does not have to take care of memory management, but freeing memory is resolved at compile time with no overhead at runtime. The solution is ownership. For every variable there is only ever one owner. As soon the owner goes out of scope the memory for the variable can be freed. This allows for the compiler to decide when to free a variable with no cost at runtime. [1, pp. 71] [7, pp. 59]
If you want to share a variable without changing its owner, you can use a reference. A reference borrows a variable from its owner. To ensure that a reference is valid, Rust enforces some rules: [7, pp. 70]
- At any time you can have only one mutable reference, that can change the value of the referenced variable or multiple immutable references.
- A reference cannot outlive the owner of the variable.
let a: &u32;
{
let b = 42;
a = &b; // 'a' borrows 'b'
println!("{}", a); // would print 42
}
println!("{}", a);
// compiler error because 'b' does not live long enough
Listing: Reference mutable borrow
The example in the listing shows an example where the reference a
outlives the owner of b
. In C this situation could occur if a function returned a pointer to a local variable from its stack. This typically shows an undefined behavior at runtime when the same spot on the stack is reused. The Rust compiler, in contrast, checks for the lifetime of the variable and throws an error.
The implications of Rusts rules on ownership and references go far beyond memory allocation and freeing. They assure save access to variables by checking lifetimes and single mutable access at compile time. This also solves many issues in concurrent programming, because data races are eliminated.
let sda = gpiob.pb9.into_alternate_af4_open_drain();
let scl = gpiob.pb8.into_alternate_af4_open_drain();
let i2c1 = hal::i2c::I2c::i2c1(dp.I2C1, (scl, sda), KiloHertz(100), clocks);1
let mut sensor_imu = Lsm6dso::new(i2c2 , SlaveAddr::Alternate).unwrap();
let mut sensor_temp = Stts751::new(i2c3 ).unwrap();
// compiler error because 'sensor_imu' already took ownership of 'i2c'
Listing: Ownership move
In the example code in the listing the ownership the i2c
interface is moved from the local scope Lsm6dso::new()
Stts751::new()
i2c
interface anymore. As a result the compiler throws an error. The ownership of primitive types (e.g. u8
) is not moved, it will be simply copied.
Zero Cost Abstraction
We use abstraction to make software platform independent, reusable, easier understandable and testable. There is typically a trade off between abstraction and costs at runtime or code implementation, e.g. by using an architecture with many layers, we can easily reuse higher level drivers on another hardware platform, but increase runtime for simple tasks that have to go through all the layers to get to the hardware.
A common abstraction is the use of interfaces. They separate software modules neatly, so software can be reused and is testable. In C++ you can use abstract classes to create an interface. There is however a slight overhead at runtime, because the specific implementation of a method must be selected from the vtable
. Rust offers interface in the form of trait
's. In contrast to C++ the correct method from the implementations of a trait can be selected at compile time. This means that we have no overhead at runtime: a zero cost abstraction. In embedded system development this is especially helpful when we use interfaces to make a module testable. There will only be two implementations: one for the hardware and a mock/stub for testing purposes.
pub trait Eucledean3D {1
fn get_coordinates(&self) -> (i32, i32, i32);
fn set_position(&mut self, pos: (i32, i32, i32)) -> ();
}
struct Point{x: i32, y: i32, z: i32}
impl Eucledean3D for Point {2
fn get_coordinates(&self) -> (i32, i32, i32) { /*...*/}
fn set_position(&mut self, pos: (i32, i32, i32)) -> () { /*...*/ }
}
fn move_object<T>(eucledean: &mut T)
where T: Eucledean3D3
{
eucledean.set_position((1,0,0,));
}
/*...*/
fn main() {
let mut p = Point { x: 0, y: 0, z: 0 };
move_object(&mut p);4
}
Listing: Implementing an interface using Rust traits and generics
The first few lines Point
. In the function move_object()
the parameter has the generic type T
. The where
directive Eucledean3D
trait. Even though the move_object()
function uses a generic parameter type, it can access the set_position()
function from Point
Error Handling
When writing a function in C we normally use the return value for error codes and the actual return value is passed via a pointer in a function parameter. Rust offers a clean solution to handle errors: Result<ReturnValueType, SomeErrorType>
. The Result
trait combines errors and return value. [1, pp. 145]
impl Lsm6dso {
pub fn new(i2c: I2C, address: SlaveAddr) -> Result<Self, Error<E>>1 {
let mut lsm6dso = Lsm6dso {
i2c,
address: address.addr(),
};
// Abort and return error code.
if lsm6dso.get_device_id()?2 != DEVICE_ID {
return Err(Error::WrongAddress);3
}
// This method could return an error: pass it the caller with '?'.
lsm6dso.set_gyro_datarate(GyroDataRate::Hz_416)?;
// Init finished with success, pack the return value into Result.
Ok(lsm6dso)4
}
/*...*/
}
Listing: Throwing errors
From new()
returns a result containing a trait object (similar to new in an object-oriented language) or an error of type E
. To pack the return value or error code we can use library function Err()
Ok()
?
fn main() -> ! {
// 1. Abort program (panic) and print stack trace.
let mut sensor_imu = Lsm6dso::new(i2c, SlaveAddr::Alternate).unwind();
// 2. Print message if error was returned.
let mut sensor_imu = Lsm6dso::new(i2c, SlaveAddr::Alternate)
.expect("Message");
// 3. More envolved error handling.
let mut sensor_imu: Lsm6dso;
match Lsm6dso::new(i2c, SlaveAddr::Alternate) {
Ok(sensor) => {
sensor_imu = sensor;
}
Err(err) => {
// Use fallback sensor, etc.
}
}
/*...*/
}
Listing: Handling errors
Three more possibilities to handle errors are shown in the listing. The first one, unwind()
, should only be used during debugging as it panics and thus aborts the program.
Testing
Many mistakes a programmer can make are already caught by the Rust compiler, but it is limited to static analysis. Support to test the behavior of a module is built right into the language. [7, pp. 207]
[project]/src/lib.rs
pub fn a_public_function(a: i32, b: i32) -> i32 {
a - b
}
fn a_private_function(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_test() {
assert_eq!(8, a_public_function(25, 17));
assert_eq!(42, a_private_function(25, 17));
}
}
Listing: Unit Test and implementation for a_module
A unit test is commonly put in the source file of the module under test. the listing shows the simple set-up for a unit test. With #[cfg(test)]
the Rust compiler only includes the test modules if the option test is enabled during the build. #[test]
adds a function to the test runner, which is generated automatically.
[project]/tests/integration_test.rs
use a_module;
#[test]
fn test_public_interface() {
assert_eq!(7, a_module::a_public_function(42, 35));
}
Listing: Integration Test for a_module
There is also support for integration tests. They are put in a separate directory in the project root called tests/
. Comparing the integration test in the listing to unit test above, shows that #[cfg(test)]
is not necessary anymore as the test is separate module. Further, can only the public functions be accessed.
Cargo
Cargo is the build system and package manager for Rust. In contrast to C/C++ projects using CMake or Makefiles in Rust we do not need to list the sources. The mandatory project structure (i.e. sources in the src/
directory) allow Cargo and the compiler to resolve file dependencies automatically.
Managing dependencies in C/C++ projects for embedded systems has not been great. With git submodules or CMakes FetchContent()
command it is possible to avoid manually copying files between projects. Yet managing dependencies with Cargo is much easier. Mainly because the whole community uses the same system. All crates (packages) are hosted on crates.io, code documentation is done in the source file with comments similar to doxygen and are automatically build and hosted. [1, pp. 161]
[package]
name = "project_name"
version = "0.1.0"
authors = ["Stefan Lüthi <stefan.luethi@bfh.ch>"]
edition = "2018"
[dependencies]
embedded-hal = "0.2"
panic-itm = "0.4.1"
Listing: Cargo project file for an executable
A simple Cargo project file for an executable is shown in the listing. The first section contains some information about the project, after that more options such as dependencies with specific versions are listed.