Arduino helps circuit developers to build electronic projects and is, perhaps, the most used open-source hardware and software platform. It is popular across millions of hobbyists across the world. Historically, Arduino boards are programmed with C++ programming language using the Arduino IDE. The availability of powerful ARM-based Arduino-compatible boards made it possible to use python, JavaScript, or even a browser to program your circuit. While they are easier to study for a new joiner without an existing programming background, C++ stays a default language choice, especially when dealing with cheap and low-memory AVR-controller boards and having a need to run more or less complex projects.
However, the compact binary size and efficiency of compiled code is probably not the main advantage of the traditional Arduino ecosystem. If the project is more complex than blinking the led, it would likely require integration with the range of sensors, servo motors, and other third-party peripheries. Most manufacturers develop precisely C++ Arduino-compatible driver libraries to be used "out of the box".
Rust language shares all advantages of efficient C++ code. With the rust community growing year after year, more and more people try using rust to program their Arduino boards. Consequently, the Arduino Rust ecosystem have significantly developed in the last couple of years. The Hardware Abstraction Layer for AVR microcontrollers avr-hal, Rudino library and ravedude CLI utility to make Rust development for AVR microcontrollers easier are just a few examples of the solid foundation developed so far.
Third-party library availability is, however, still lagging behind. Luckily enough, Rust supports nearly seamless interaction with the C code! While it is very well possible to link almost any Arduino library to the rust project, I was not able to find any meaningful description of the steps required to do so! My best hit was the rust-arduino-helpers project. Despite that the code quality is, unfortunately, not great (it contains large fragments of commented code and was not updated for a while), it was a great help for me to find the right direction.
The absence of the well documented steps was my motivation to write this tutorial.
Project and Goals
Here is the set of steps we will cover today:
- Prepare environment to program Arduino board with Rust.
- Create the avr-hal based rust project and blink the led.
- Compile Arduino SDK and the third-party library and link it to the rust project.
- Generate rust bindings for the Arduino library.
- Write the code and run it on your board!
The list of the hardware I've had in my possession for this project is the following:
- Arduino Uno board
- 1602 LCD Display Module with I2C Interface
- LED
- Linux or Windows PC
What we are going to do is simply try writing a text message to the LCD Display using Rust. In order to do so, we will have to figure out how to link and use the LiquidCrystal-I2C Arduino library in our rust crate. In other words, we would like to do something like this, but in Rust.
Here is the little demonstration of the final project:
As a bonus, we will write a nice configuration file to configure Arduino dependencies to make the setup extensible and reusable.
The source code is available on GitHub. I was able to run it on both Linux and Windows PC. I would be focusing on the Linux (Fedora 35) installation steps in this tutorial, windows users could refer to the corresponding section in the readme.md file.
This article assumes the reader has the basic knowledge about Rust project setup. I also assume the reader has a rust standard development environment (rustup
and cargo
) configured.
Step 1: Environment setup
Compiler and Arduino IDE
Arduino IDE and Arduino libraries are intended to be used by non-professional programmers and so they are designed to just work out of the box. We will have to gain some understanding of what happens under the hood of them to be able to compile the Arduino code outside of the Arduino IDE.
First all, we would need to download and install the Arduino IDE. I am using version 2.0.1.
Linux users could install it running the following command:
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh
At this point, you can run some Arduino C++ Hello World project to ensure that your board is recognized by your PC and working fine. It is also important, because it would force Arduino IDE to download require tools and libraries.
While you are there, install LiquidCrystal_I2C library to Arduino Libraries folder using Arduino IDE like how you would normally do.
Next, we need to familiarize ourselves with a set of tools used for AVR development:
-
avrdude
is a command-line program for programming AVR chips. It allows uploading compiled sketch to your board. -
avr-gcc
is a compiler that takes C language high level code and creates a binary source which can be uploaded into an AVR micro controller. -
avr-libc
is C standard library for use withavr-gcc
on Atmel AVR microcontrollers.
These tools come bundled with the Arduino IDE. On my laptop, they are installed in the ~/.arduino15/packages/arduino/tools/
folder. Despite this, I prefer to also install the system-wide copy of them from Fedora repositories to avoid adding the above folder to the %PATH manually:
sudo dnf install avrdude avr-gcc avr-libc
The above step is technically optional, I skipped it on Windows as there is no easy way to the same there.
Ravedude
We could have been just using avrdude
to program our board. However since we are planning to develop in rust, ravedude would be a better alternative as we could just configure it as a runner for cargo
build tool.
I had to install some dependencies to compile ravedude
first:
sudo dnf install systemd-devel pkgconf-pkg-config
Installing ravedude
is then as easy as running
cargo install ravedude
I also assume the reader has a rust standard development environment (rustup
and cargo
) configured.
Bindgen
We are planning to use rust-bindgen project to automatically generate rust bindings based on the C++ library header. We will use bindgen as rust library during the build time, however it require libclang
to operate, so we have to install it:
sudo dnf install clang-devel
Cargo-generate
We would like to simply the next step and use cargo-generate tool to create our Arduino project from a template. Somehow (please, do not ask me why) it requires Perl to compile, so we have to do:
sudo dnf install perl
cargo install cargo-generate
Step 2: Project setup
As I've just mentioned, we would create out project using cargo generate
command:
cargo generate --git https://github.com/Rahix/avr-hal-template.git
You will have to specify the project name and select the board type. There is an excellent creativcoder's article outlining the process step-by-step in case you would like to do project the setup manually.
Have a look to .cargo/config.toml
to understand why we've installed ravedude
earlier. The following section:
[target.'cfg(target_arch = "avr")']
runner = "ravedude uno -cb 57600"
passing compiled file to ravedude
when you execute cargo run
command, so it would be programmed to your board. ravedude
would also listen the serial port to print the debug output of your sketch.
Let's update main.rs
with a simple program to use the serial port and blink the LED on a pin #13:
use arduino_hal::prelude::*;
use panic_halt as _;
#[arduino_hal::entry]
unsafe fn main() -> ! {
let dp = arduino_hal::Peripherals::take().unwrap();
let pins = arduino_hal::pins!(dp);
let mut serial = arduino_hal::default_serial!(dp, pins, 57600);
let mut led = pins.d13.into_output();
ufmt::uwriteln!(&mut serial, "Hello world\r").void_unwrap();
loop {
led.toggle();
arduino_hal::delay_ms(1000);
}
}
Simply execute
cargo run
and the above code should be running on your board.
If you are having trouble understanding the above code, consider referring to avr-hal documentation and creativcoder's article for more information.
Step 3: Compile and Link Arduino SDK and library
Arduino SDK dependency
Ok, we have a working rust project. Let's connect our I2C display to the Arduino board as explained in the article and try figure out how we can port the C++ example outlined there to rust:
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x3F,16,2); // set the LCD address to 0x3F for a 16 chars and 2 line display
void setup() {
lcd.init();
lcd.clear();
lcd.backlight(); // Make sure backlight is on
// Print a message on both lines of the LCD.
lcd.setCursor(2,0); //Set cursor to character 2 on line 0
lcd.print("Hello world!");
lcd.setCursor(2,1); //Move cursor to character 2 on line 1
lcd.print("LCD Tutorial");
}
void loop() {
}
Clearly, we would like to compile LiquidCrystal_I2C
, link it to our crate as a dependency and call the same functions from rust.
Ok, should be easy, right? Well, if you just have a standalone C library, compiling and calling it from rust is straightforward. Let's have a closer look at the source code of LiquidCrystal_I2C.cpp:
#include <inttypes.h>
#include <Arduino.h>
#include <Wire.h>
What are they?
- Arduino.h is the main include file for the Arduino SDK
- Wire.h is TWI/I2C library for Arduino & Wiring provided as part of Arduino SDK
-
inttypes.h
is standard library header file providing the support for width-based integral types.
What does it mean? Well, it means we can't just use LiquidCrystal_I2C
alone without compiling Arduino SDK.
Compiling Arduino SDK and the library
Configuring Arduino SDK location
Let's create an empty file named build.rs
in the root of our project. This is where we would write steps to compile a link to Arduino dependencies.
First, we should find the location of Arduino SDK and 3rd party LiquidCrystal_I2C
library installed earlier.
In my case, Arduino is installed at ~/.arduino15/
, and 3rd party library folder is ~/Arduino/libraries/
. We could have just hardcoded the above as a constant in our build.rs
, but let's try to apply some better engineering principles and make our configuration more reusable! I propose to create a file called arduino.yaml
next to the build.rs
. We could then use serde_yaml to read it in the build.rs
file to avoid making code changes if the location changes in the future:
arduino_home: $HOME/.arduino15
external_libraries_home: $HOME/Arduino/libraries
Now, let's locate the Arduino SDK inside the home folder of Arduino. In my case, it is ~/.arduino15/packages/arduino/hardware/avr/1.8.6/
. The path is pretty standard, but the version might change in future, so we can move the version to our yaml file as well.
The Arduino SDK folder contains a set of the subfolders, but we are interested in two of them libraries
and variants
:
- The
libraries
folder contains the code which can run on any Arduino board. This is where we can findWire
library we need. Nice! - The
variants
folder definition of the pins specific to the given board. The folder for Arduino UNO is calledeightanaloginputs
(again, do not ask my why please).
Finally, we would also need C standard library headers (e.g. inttypes.h
) provided by the compiler. It is also installed with Arduino. In my case the path is ~/.arduino15/packages/arduino/tools/avr-gcc/7.3.0-atmel3.6.1-arduino7/avr/include/
We can now add more information to our arduino.yaml
, specifying the core version, the compiler version, list of Arduino and external libraries to build:
arduino_home: $HOME/.arduino15
external_libraries_home: $HOME/Arduino/libraries
core_version: 1.8.6
variant: eightanaloginputs
avr_gcc_version: 7.3.0-atmel3.6.1-arduino7
arduino_libraries:
- Wire
external_libraries:
- LiquidCrystal_I2C
Reading the configuration
We can now read the above file in build.rs
. In order to do some add serde_yaml
compile time dependency. We would also use envmnt crate to resolve the environment variables mentioned in the file:
[build-dependencies]
envmnt = "0.10.4"
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
Then, we can define the Config
struct and helper methods to construct the above path in the code
const CONFIG_FILE: &str = "arduino.yaml";
#[derive(Debug, Deserialize)]
struct Config {
pub arduino_home: String,
pub external_libraries_home: String,
pub core_version: String,
pub variant: String,
pub avr_gcc_version: String,
pub arduino_libraries: Vec<String>,
}
impl Config {
fn arduino_package_path(&self) -> PathBuf {
let expanded = envmnt::expand(&self.arduino_home, None);
let arduino_home_path = PathBuf::from(expanded);
arduino_home_path.join("packages").join("arduino")
}
fn core_path(&self) -> PathBuf {
self.arduino_package_path()
.join("hardware")
.join("avr")
.join(&self.core_version)
}
fn avr_gcc_homeavr_gcc_home(&self) -> PathBuf {
self.arduino_package_path()
.join("tools")
.join("avr-gcc")
.join(&self.avr_gcc_version)
}
fn avg_gcc(&self) -> PathBuf {
self.avr_gcc_home().join("bin").join("avr-gcc")
}
fn arduino_core_path(&self) -> PathBuf {
self.core_path().join("cores").join("arduino")
}
}
Since we were dealing with path, we also defined the helper avg_gcc
method to provide the path to the compiler binary
We can now read this file and print it to console
fn main() {
println!("cargo:rerun-if-changed={}", CONFIG_FILE);
let config_string = std::fs::read_to_string(CONFIG_FILE)
.unwrap_or_else(|e| panic!("Unable to read {} file: {}", CONFIG_FILE, e));
let config: Config = serde_yaml::from_str(&config_string)
.unwrap_or_else(|e| panic!("Unable to parse {} file: {}", CONFIG_FILE, e));
println!("Arduino configuration: {:#?}", config);
}
The following line ensure the project is rebuild each time the yaml file is updated.
println!("cargo:rerun-if-changed={}", CONFIG_FILE);
At this point, we can run cargo build -vv
(very verbose) and be able to see the parse config printed.
Headers and sources
Ok, we know where our dependencies are. We will need to pass them to avr-gcc
compiler soon. There are 3 types of files we need to take care of:
- Header files
.h
- C source code
.c
- C++ source code
.cpp
Headers are the easiest to deal with. We would just pass it to the compiler using -I
flag. We need 4 header folders to be included:
- Arduino variant-specific header(s)
- C standard library
- Arduino libraries
- External libraries
Let's implement the helper methods to get the paths of them:
impl Config {
fn arduino_include_dirs(&self) -> Vec<PathBuf> {
let variant_path = self.core_path().join("variants").join(&self.variant);
let avr_gcc_include_path = self.avr_gcc_home().join("avr").join("include");
vec![self.arduino_core_path(), variant_path, avr_gcc_include_path]
}
fn arduino_libraries_path(&self) -> Vec<PathBuf> {
let library_root = self.core_path().join("libraries");
let mut result = vec![];
for library in &self.arduino_libraries {
result.push(library_root.join(library).join("src"))
}
result
}
fn external_libraries_path(&self) -> Vec<PathBuf> {
let expanded = envmnt::expand(&self.external_libraries_home, None);
let external_library_root = PathBuf::from(expanded);
let mut result = vec![];
for library in &self.external_libraries {
result.push(external_library_root.join(library))
}
result
}
fn include_dirs(&self) -> Vec<PathBuf> {
let mut result = self.arduino_include_dirs();
result.extend(self.arduino_libraries_path());
result.extend(self.external_libraries_path());
result
}
}
C and C++ files would be passed to the compiler as an input one by one. The will need to be compiled with a slightly different set of the compiler flags, so let's define separate helpers to get the list of them:
impl Config {
fn project_files(&self, patten: &str) -> Vec<PathBuf> {
let mut result =
files_in_folder(self.arduino_core_path().to_string_lossy().as_ref(), patten);
let mut libraries = self.arduino_libraries_path();
libraries.extend(self.external_libraries_path());
let pattern = format!("**/{}", patten);
for library in libraries {
let lib_sources = files_in_folder(library.to_string_lossy().as_ref(), &pattern);
result.extend(lib_sources);
}
result
}
fn cpp_files(&self) -> Vec<PathBuf> {
self.project_files("*.cpp")
}
fn c_files(&self) -> Vec<PathBuf> {
self.project_files("*.c")
}
}
fn files_in_folder(folder: &str, pattern: &str) -> Vec<PathBuf> {
let cpp_pattern = format!("{}/{}", folder, pattern);
let mut results = vec![];
for cpp_file in glob(&cpp_pattern).unwrap() {
let file = cpp_file.unwrap();
if !file.ends_with("main.cpp") {
results.push(file);
}
}
results
}
Here we use the glob
create to look for the files recursively, so we need to also add it to Cargo.toml
as a build dependency:
glob = "0.3"
Compiler flags and definitions
In order to compile for Arduino UNO, we would need to pass some board specific flags to the compiler, in paricular:
-DARDUINO=10807 -DF_CPU: 16000000L -DARDUINO_AVR_UNO=1 -DARDUINO_ARCH_AVR -mmcu=atmega328p
As they are board specific, let's move the definition of them to arduino.yaml
as well:
definitions:
ARDUINO: '10807'
F_CPU: 16000000L
ARDUINO_AVR_UNO: '1'
ARDUINO_ARCH_AVR: '1'
flags:
- '-mmcu=atmega328p'
We will also have to add corresponding fields to a Config
struct
#[derive(Debug, Deserialize)]
struct Config {
// Existing fields here
pub definitions: HashMap<String, String>,
pub flags: Vec<String>,
}
Compilation and linking
While it is technically possible to construct the command line to invoke compiler manually using the CC crate is a much better option. Let's add the CC dependency to Cargo.toml
:
[build-dependencies]
cc = "1.0.74"
As mentioned above, C and C++ code needs be compiled with a slightly different set of flags. Let's define a helper function to define the common part of the configuration
fn configure_arduino(config: &Config) -> Build {
let mut builder = Build::new();
for (k, v) in &config.definitions {
builder.define(k, v.as_str());
}
for flag in &config.flags {
builder.flag(flag);
}
builder
.compiler(config.avg_gcc())
.flag("-Os")
.cpp_set_stdlib(None)
.flag("-fno-exceptions")
.flag("-ffunction-sections")
.flag("-fdata-sections");
for include_dir in config.include_dirs() {
builder.include(include_dir);
}
builder
}
Here we create CC provided builder and pass compile flags, definition and include folders.
We can then use configured build to compile C/C++ parts of Arduino standard library:
pub fn add_source_file(builder: &mut Build, files: Vec<PathBuf>) {
for file in files {
println!("cargo:rerun-if-changed={}", file.to_string_lossy());
builder.file(file);
}
}
fn compile_arduino(config: &Config) {
let mut builder = configure_arduino(&config);
builder
.cpp(true)
.flag("-std=gnu++11")
.flag("-fpermissive")
.flag("-fno-threadsafe-statics");
add_source_file(&mut builder, config.cpp_files());
builder.compile("libarduino_c++.a");
let mut builder = configure_arduino(&config);
builder.flag("-std=gnu11");
add_source_file(&mut builder, config.c_files());
builder.compile("libarduino_c.a");
println!("cargo:rustc-link-lib=static=arduino_c++");
println!("cargo:rustc-link-lib=static=arduino_c");
}
We simply compile each C and CPP file we can find together with the only exception of the main.cpp
as the main method is defined in rust. CC crate will call avr-gcc
and produce to object files: libarduino_c++.a
and libarduino_c.a
.
We then instruct cargo to statically link rust code with them by printing
cargo:rustc-link-lib=static=arduino_c++
cargo:rustc-link-lib=static=arduino_c
lines to the standard output.
Generate rust bindings
So far we've compiled the required dependencies with our rust project. But running cargo run
still does nothing, but blinks the LED. In order to interact with the display, we need to write code to do so. But how could we call a C++ function defined in liquidcrystal_i2c.h from rust?
This is where bingen is coming to help. Bingen takes C//C++ header files as an input and generates rust definitions of the functions and types provided by them. Let's add it to our Cargo.toml
as well:
[build-dependencies]
bindgen = "0.61"
First step is to define the headers files to pass to bindgen. We can just implement a small helper method to find them in the external libraries folder:
impl Config {
fn bindgen_headers(&self) -> Vec<PathBuf> {
let mut result = vec![];
for library in self.external_libraries_path() {
let lib_headers = files_in_folder(library.to_string_lossy().as_ref(), "*.h");
result.extend(lib_headers);
}
result
}
}
Next, we need to define the bingen configuration:
fn configure_bindgen_for_arduino(config: &Config) -> Builder {
let mut builder = Builder::default();
for (k, v) in &config.definitions {
builder = builder.clang_arg(&format!("-D{}={}", k, v));
}
for flag in &config.flags {
builder = builder.clang_arg(flag);
}
builder = builder
.clang_args(&["-x", "c++", "-std=gnu++11"])
.use_core()
.layout_tests(false)
.parse_callbacks(Box::new(bindgen::CargoCallbacks));
for include_dir in config.include_dirs() {
builder = builder.clang_arg(&format!("-I{}", include_dir.to_string_lossy()));
}
for header in config.bindgen_headers() {
builder = builder.header(header.to_string_lossy());
}
builder
}
Note that we need to pass the same set of compiler flags and definitions to bingen as we passed to avr-gcc
earlier.
Then, we can call generate
method to generate the bindings and write them to src/arduino.rs
file:
fn generate_bindings(config: &Config) {
let bindings: Bindings = configure_bindgen_for_arduino(&config)
.generate()
.expect("Unable to generate bindings");
let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("arduino.rs");
bindings
.write_to_file(project_root)
.expect("Couldn't write bindings!");
}
We can now open src/arduino.rs
and see generated code. A lot of generated code! This is because by default bingen goes over each header file recursively and generates bindings for every type and method. As we do not need most of them, we can configure allow and block list to exclude useless parts.
To do so, we can add a new section to arduino.yaml
:
bindgen_lists:
allowlist_function:
- LiquidCrystal_I2C.*
allowlist_type:
- LiquidCrystal_I2C.*
blocklist_function:
- Print.*
- String.*
blocklist_type:
- Print.*
- String.*
We can then modify the config object:
#[derive(Debug, Deserialize)]
struct BindgenLists {
pub allowlist_function: Vec<String>,
pub allowlist_type: Vec<String>,
pub blocklist_function: Vec<String>,
pub blocklist_type: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct Config {
// Existing fields
pub bindgen_lists: BindgenLists,
}
and configure_bindgen_for_arduino
function to make use of them
fn configure_bindgen_for_arduino(config: &Config) -> Builder {
// Existing code
for item in &config.bindgen_lists.allowlist_function {
builder = builder.allowlist_function(item);
}
for item in &config.bindgen_lists.allowlist_type {
builder = builder.allowlist_type(item);
}
for item in &config.bindgen_lists.blocklist_function {
builder = builder.blocklist_function(item);
}
for item in &config.bindgen_lists.blocklist_type {
builder = builder.blocklist_type(item);
}
builder
}
arduino.rs looks so much nicer now!
Step 5: Writing and running the code
Have you ever wondered why there is no main method in the Arduino sketches? Indeed, the standard template of the Arduino file is the following:
void setup() {
// put your setup code here, to run once:
}
void loop() {
// put your main code here, to run repeatedly:
}
Remember the main.cpp
file we've excluded in the step 3? Let's look inside (simplified code):
#include <Arduino.h>
int main(void)
{
init();
setup();
for (;;) {
loop();
if (serialEventRun) serialEventRun();
}
return 0;
}
Looks like no magic at all! It just calls setup()
and then loop
method in the loop. We do not care about setup
and loop
in rust, but what is the init call?! It turns out that it is Arduino SDK provided function to initialize the board. We would need to call it in rust as well before we start using any Arduino library. We could have just generated it using bindgen, but since its signature is so simple (no arguments) we can just define it in place:
extern "C" {
fn init();
}
Now we are ready to modify our main.rs
with some code to initialize our display and print some text:
#[arduino_hal::entry]
unsafe fn main() -> ! {
init();
let dp = arduino_hal::Peripherals::take().unwrap();
let pins = arduino_hal::pins!(dp);
let mut serial = arduino_hal::default_serial!(dp, pins, 57600);
let mut led = pins.d13.into_output();
ufmt::uwriteln!(&mut serial, "starting on {}\r", 0x27).void_unwrap();
let mut lcd = LiquidCrystal_I2C::new(0x27, 16, 2);
let ferris = &[
0b01010u8, 0b01010u8, 0b00000u8, 0b00100u8, 0b10101u8, 0b10101u8, 0b11111u8, 0b10101u8,
];
lcd.begin(16, 2, 0);
lcd.init();
lcd.backlight();
lcd.clear();
lcd.printstr("Good morning\0".as_ptr().cast());
lcd.setCursor(0, 1);
lcd.printstr("from Rust!!\0".as_ptr().cast());
lcd.createChar(0, ferris.as_ptr() as *mut _);
lcd.setCursor(12, 1);
LiquidCrystal_I2C_write((&mut lcd as *mut LiquidCrystal_I2C).cast(), 0);
loop {
led.toggle();
arduino_hal::delay_ms(1000);
}
}
Here we combine the arduino_hal
method and the methods provided by LiquidCrystal_I2C library.
It is now time to run cargo run
and see the message on the display! Well done!
Summary
I really hope you've learned something by reading this article! We covered the following topics today:
- Rust Arduino Environment setup
- Structure of Arduino SDK
- Compilation Arduino library, linking it to rust crate and generation of the rust definitions for C++ methods.
If the article was useful for you, please put a reaction and start the GitHub repository.
I am also happy to hear a feedback about the arduino.yaml
DSL developed as part of this article. Would it be useful to move it into separate library to be used in the build time? Is there a better way to do the same? Please share your opinion in the comments!
Top comments (3)
Was it worth it? Looks like same time spent setting this up would take to build third of an average Adruino firmware, if not half.
It seems like a lot of configuration will also be required to add dependencies further on.
I need to do the same, but for SMT32. For example for BluePill (
STM32F103RE
).Could you please help with that?
How would I go about finding the different compiler flags for other arduino boards like the arduino mega?