What you'll learn: How Rust organizes code into modules and crates — privacy-by-default visibility,
pubmodifiers, workspaces, and thecrates.ioecosystem. Replaces C/C++ header files,#include, and CMake dependency management.
mod keyword.pub (public). The scope of pub can be further restricted to pub(crate), etcuse keyword. Child submodules can reference types in the parent scope using the use super::main.rs (executable) or lib.rsfn keyword. The -> keyword declares that the function returns a value (the default is void) with the type u32 (unsigned 32-bit integer)struct foo in mod a { struct foo; } is a distinct type (a::foo) from mod b { struct foo; } (b::foo))Starter code — complete the functions:
mod math {
// TODO: implement pub fn add(a: u32, b: u32) -> u32
}
fn greet(name: &str) -> String {
// TODO: return "Hello, <name>! The secret number is <math::add(21,21)>"
todo!()
}
fn main() {
println!("{}", greet("Rustacean"));
}
mod math {
pub fn add(a: u32, b: u32) -> u32 {
a + b
}
}
fn greet(name: &str) -> String {
format!("Hello, {}! The secret number is {}", name, math::add(21, 21))
}
fn main() {
println!("{}", greet("Rustacean"));
}
// Output: Hello, Rustacean! The secret number is 42
Cargo.toml at the workspace root should have a pointer to the constituent packages (crates)[workspace]
resolver = "2"
members = ["package1", "package2"]
workspace_root/
|-- Cargo.toml # Workspace configuration
|-- package1/
| |-- Cargo.toml # Package 1 configuration
| `-- src/
| `-- lib.rs # Package 1 source code
|-- package2/
| |-- Cargo.toml # Package 2 configuration
| `-- src/
| `-- main.rs # Package 2 source code
hello world program`mkdir workspace
cd workspace
[workspace]
resolver = "2"
members = []
cargo new --lib specifies a library instead of an executable`)cargo new hello
cargo new --lib hellolib
hello and hellolib. Notice that both of them have been added to the upper level Cargo.tomllib.rs in hellolib implies a library package (see https://doc.rust-lang.org/cargo/reference/cargo-targets.html for customization options)hellolib in Cargo.toml for hello[dependencies]
hellolib = {path = "../hellolib"}
add() from hellolibfn main() {
println!("Hello, world! {}", hellolib::add(21, 21));
}
The complete workspace setup:
# Terminal commands
mkdir workspace && cd workspace
# Create workspace Cargo.toml
cat > Cargo.toml << 'EOF'
[workspace]
resolver = "2"
members = ["hello", "hellolib"]
EOF
cargo new hello
cargo new --lib hellolib
# hello/Cargo.toml — add dependency
[dependencies]
hellolib = {path = "../hellolib"}
// hellolib/src/lib.rs — already has add() from cargo new --lib
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
// hello/src/main.rs
fn main() {
println!("Hello, world! {}", hellolib::add(21, 21));
}
// Output: Hello, world! 42
crates.io has a major and minor version
SemVer guidelines defined here: https://doc.rust-lang.org/cargo/reference/semver.htmlCargo.toml entries for declaring a dependency on the rand crate0.10.0, but anything < 0.11.0 is fine[dependencies]
rand = { version = "0.10.0"}
0.10.0, and nothing else[dependencies]
rand = { version = "=0.10.0"}
cargo will select the latest version[dependencies]
rand = { version = "*"}
helloworld example to print a random numbercargo add rand to add a dependencyhttps://docs.rs/rand/latest/rand/ as a reference for the APIStarter code — add this to main.rs after running cargo add rand:
use rand::RngExt;
fn main() {
let mut rng = rand::rng();
// TODO: Generate and print a random u32 in 1..=100
// TODO: Generate and print a random bool
// TODO: Generate and print a random f64
}
use rand::RngExt;
fn main() {
let mut rng = rand::rng();
let n: u32 = rng.random_range(1..=100);
println!("Random number (1-100): {n}");
// Generate a random boolean
let b: bool = rng.random();
println!("Random bool: {b}");
// Generate a random float between 0.0 and 1.0
let f: f64 = rng.random();
println!("Random float: {f:.4}");
}
Cargo.toml had specified a version of 0.10.0, cargo is free to choose any version that is < 0.11.0Cargo.lock in the git repo to ensure reproducible buildscfg (configuration) feature. Configurations are useful for creating platform specific code (Linux vs. Windows) for examplecargo test. Reference: https://doc.rust-lang.org/reference/conditional-compilation.htmlpub fn add(left: u64, right: u64) -> u64 {
left + right
}
// Will be included only during testing
#[cfg(test)]
mod tests {
use super::*; // This makes all types in the parent scope visible
#[test]
fn it_works() {
let result = add(2, 2); // Alternatively, super::add(2, 2);
assert_eq!(result, 4);
}
}
cargo has several other useful features including:
cargo clippy is a great way of linting Rust code. In general, warnings should be fixed (or rarely suppressed if really warranted)cargo format executes the rustfmt tool to format source code. Using the tool ensures standard formatting of checked-in code and puts an end to debates about stylecargo doc can be used to generate documentation from the /// style comments. The documentation for all crates on crates.io was generated using this methodIn C, you pass -O0, -O2, -Os, -flto to gcc/clang. In Rust, you configure
build profiles in Cargo.toml:
# Cargo.toml — build profile configuration
[profile.dev]
opt-level = 0 # No optimization (fast compile, like -O0)
debug = true # Full debug symbols (like -g)
[profile.release]
opt-level = 3 # Maximum optimization (like -O3)
lto = "fat" # Link-Time Optimization (like -flto)
strip = true # Strip symbols (like the strip command)
codegen-units = 1 # Single codegen unit — slower compile, better optimization
panic = "abort" # No unwind tables (smaller binary)
| C/GCC Flag | Cargo.toml Key | Values |
|---|---|---|
-O0 / -O2 / -O3 | opt-level | 0, 1, 2, 3, "s", "z" |
-flto | lto | false, "thin", "fat" |
-g / no -g | debug | true, false, "line-tables-only" |
strip command | strip | "none", "debuginfo", "symbols", true/false |
| — | codegen-units | 1 = best opt, slowest compile |
cargo build # Uses [profile.dev]
cargo build --release # Uses [profile.release]
build.rs): Linking C LibrariesIn C, you use Makefiles or CMake to link libraries and run code generation.
Rust uses a build.rs file at the crate root:
// build.rs — runs before compiling the crate
fn main() {
// Link a system C library (like -lbmc_ipmi in gcc)
println!("cargo::rustc-link-lib=bmc_ipmi");
// Where to find the library (like -L/usr/lib/bmc)
println!("cargo::rustc-link-search=/usr/lib/bmc");
// Re-run if the C header changes
println!("cargo::rerun-if-changed=wrapper.h");
}
You can even compile C source files directly from a Rust crate:
# Cargo.toml
[build-dependencies]
cc = "1" # C compiler integration
// build.rs
fn main() {
cc::Build::new()
.file("src/c_helpers/ipmi_raw.c")
.include("/usr/include/bmc")
.compile("ipmi_raw"); // Produces libipmi_raw.a, linked automatically
println!("cargo::rerun-if-changed=src/c_helpers/ipmi_raw.c");
}
| C / Make / CMake | Rust build.rs |
|---|---|
-lfoo | println!("cargo::rustc-link-lib=foo") |
-L/path | println!("cargo::rustc-link-search=/path") |
| Compile C source | cc::Build::new().file("foo.c").compile("foo") |
| Generate code | Write files to $OUT_DIR, then include!() |
In C, cross-compilation requires installing a separate toolchain (arm-linux-gnueabihf-gcc)
and configuring Make/CMake. In Rust:
# Install a cross-compilation target
rustup target add aarch64-unknown-linux-gnu
# Cross-compile
cargo build --target aarch64-unknown-linux-gnu --release
Specify the linker in .cargo/config.toml:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
| C Cross-Compile | Rust Equivalent |
|---|---|
apt install gcc-aarch64-linux-gnu | rustup target add aarch64-unknown-linux-gnu + install linker |
CC=aarch64-linux-gnu-gcc make | .cargo/config.toml [target.X] linker = "..." |
#ifdef __aarch64__ | #[cfg(target_arch = "aarch64")] |
| Separate Makefile targets | cargo build --target ... |
C uses #ifdef and -DFOO for conditional compilation. Rust uses feature flags
defined in Cargo.toml:
# Cargo.toml
[features]
default = ["json"] # Enabled by default
json = ["dep:serde_json"] # Optional dependency
verbose = [] # Flag with no dependency
gpu = ["dep:cuda-sys"] # Optional GPU support
// Code gated on features:
#[cfg(feature = "json")]
pub fn parse_config(data: &str) -> Result<Config, Error> {
serde_json::from_str(data).map_err(Error::from)
}
#[cfg(feature = "verbose")]
macro_rules! verbose {
($($arg:tt)*) => { eprintln!("[VERBOSE] {}", format!($($arg)*)); }
}
#[cfg(not(feature = "verbose"))]
macro_rules! verbose {
($($arg:tt)*) => {}; // Compiles to nothing
}
| C Preprocessor | Rust Feature Flags |
|---|---|
gcc -DDEBUG | cargo build --features verbose |
#ifdef DEBUG | #[cfg(feature = "verbose")] |
#define MAX 100 | const MAX: u32 = 100; |
#ifdef __linux__ | #[cfg(target_os = "linux")] |
Unit tests live next to the code with #[cfg(test)]. Integration tests live in
tests/ and test your crate's public API only:
// tests/smoke_test.rs — no #[cfg(test)] needed
use my_crate::parse_config;
#[test]
fn parse_valid_config() {
let config = parse_config("test_data/valid.json").unwrap();
assert_eq!(config.max_retries, 5);
}
| Aspect | Unit Tests (#[cfg(test)]) | Integration Tests (tests/) |
|---|---|---|
| Location | Same file as code | Separate tests/ directory |
| Access | Private + public items | Public API only |
| Run command | cargo test | cargo test --test smoke_test |
C firmware teams typically write tests in CUnit, CMocka, or custom frameworks with a lot of boilerplate. Rust's built-in test harness is far more capable. This section covers patterns you'll need for production code.
#[should_panic] — Testing Expected Failures// Test that certain conditions cause panics (like C's assert failures)
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_bounds_check() {
let v = vec![1, 2, 3];
let _ = v[10]; // Should panic
}
#[test]
#[should_panic(expected = "temperature exceeds safe limit")]
fn test_thermal_shutdown() {
fn check_temperature(celsius: f64) {
if celsius > 105.0 {
panic!("temperature exceeds safe limit: {celsius}°C");
}
}
check_temperature(110.0);
}
#[ignore] — Slow or Hardware-Dependent Tests// Mark tests that require special conditions (like C's #ifdef HARDWARE_TEST)
#[test]
#[ignore = "requires GPU hardware"]
fn test_gpu_ecc_scrub() {
// This test only runs on machines with GPUs
// Run with: cargo test -- --ignored
// Run with: cargo test -- --include-ignored (runs ALL tests)
}
unwrap chains)// Instead of many unwrap() calls that hide the actual failure:
#[test]
fn test_config_parsing() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"hostname": "node-01", "port": 8080}"#;
let config: ServerConfig = serde_json::from_str(json)?; // ? instead of unwrap()
assert_eq!(config.hostname, "node-01");
assert_eq!(config.port, 8080);
Ok(()) // Test passes if we reach here without error
}
C uses setUp()/tearDown() functions. Rust uses helper functions and Drop:
struct TestFixture {
temp_dir: std::path::PathBuf,
config: Config,
}
impl TestFixture {
fn new() -> Self {
let temp_dir = std::env::temp_dir().join(format!("test_{}", std::process::id()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = Config {
log_dir: temp_dir.clone(),
max_retries: 3,
..Default::default()
};
Self { temp_dir, config }
}
}
impl Drop for TestFixture {
fn drop(&mut self) {
// Automatic cleanup — like C's tearDown() but can't be forgotten
let _ = std::fs::remove_dir_all(&self.temp_dir);
}
}
#[test]
fn test_with_fixture() {
let fixture = TestFixture::new();
// Use fixture.config, fixture.temp_dir...
assert!(fixture.temp_dir.exists());
// fixture is automatically dropped here → cleanup runs
}
In C, mocking hardware requires preprocessor tricks or function pointer swapping. In Rust, traits make this natural:
// Production trait for IPMI communication
trait IpmiTransport {
fn send_command(&self, cmd: u8, data: &[u8]) -> Result<Vec<u8>, String>;
}
// Real implementation (used in production)
struct RealIpmi { /* BMC connection details */ }
impl IpmiTransport for RealIpmi {
fn send_command(&self, cmd: u8, data: &[u8]) -> Result<Vec<u8>, String> {
// Actually talks to BMC hardware
todo!("Real IPMI call")
}
}
// Mock implementation (used in tests)
struct MockIpmi {
responses: std::collections::HashMap<u8, Vec<u8>>,
}
impl IpmiTransport for MockIpmi {
fn send_command(&self, cmd: u8, _data: &[u8]) -> Result<Vec<u8>, String> {
self.responses.get(&cmd)
.cloned()
.ok_or_else(|| format!("No mock response for cmd 0x{cmd:02x}"))
}
}
// Generic function that works with both real and mock
fn read_sensor_temperature(transport: &dyn IpmiTransport) -> Result<f64, String> {
let response = transport.send_command(0x2D, &[])?;
if response.len() < 2 {
return Err("Response too short".into());
}
Ok(response[0] as f64 + (response[1] as f64 / 256.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_temperature_reading() {
let mut mock = MockIpmi { responses: std::collections::HashMap::new() };
mock.responses.insert(0x2D, vec![72, 128]); // 72.5°C
let temp = read_sensor_temperature(&mock).unwrap();
assert!((temp - 72.5).abs() < 0.01);
}
#[test]
fn test_short_response() {
let mock = MockIpmi { responses: std::collections::HashMap::new() };
// No response configured → error
assert!(read_sensor_temperature(&mock).is_err());
}
}
proptestInstead of testing specific values, test properties that must always hold:
// Cargo.toml: [dev-dependencies] proptest = "1"
use proptest::prelude::*;
fn parse_sensor_id(s: &str) -> Option<u32> {
s.strip_prefix("sensor_")?.parse().ok()
}
fn format_sensor_id(id: u32) -> String {
format!("sensor_{id}")
}
proptest! {
#[test]
fn roundtrip_sensor_id(id in 0u32..10000) {
// Property: format then parse should give back the original
let formatted = format_sensor_id(id);
let parsed = parse_sensor_id(&formatted);
prop_assert_eq!(parsed, Some(id));
}
#[test]
fn parse_rejects_garbage(s in "[^s].*") {
// Property: strings not starting with 's' should never parse
let result = parse_sensor_id(&s);
prop_assert!(result.is_none());
}
}
| C Testing | Rust Equivalent |
|---|---|
CUnit, CMocka, custom framework | Built-in #[test] + cargo test |
setUp() / tearDown() | Builder function + Drop trait |
#ifdef TEST mock functions | Trait-based dependency injection |
assert(x == y) | assert_eq!(x, y) with auto diff output |
| Separate test executable | Same binary, conditional compilation with #[cfg(test)] |
valgrind --leak-check=full ./test | cargo test (memory safe by default) + cargo miri test |
Code coverage: gcov / lcov | cargo tarpaulin or cargo llvm-cov |
| Test discovery: manual registration | Automatic — any #[test] fn is discovered |