DEV Community

Konstantin Grechishchev
Konstantin Grechishchev

Posted on

Five simple steps to use any Arduino C++ library in a Rust project πŸ¦€

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
Enter fullscreen mode Exit fullscreen mode

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 with avr-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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Installing ravedude is then as easy as running

cargo install ravedude
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Simply execute

cargo run
Enter fullscreen mode Exit fullscreen mode

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() {
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 find Wire library we need. Nice!
  • The variants folder definition of the pins specific to the given board. The folder for Arduino UNO is called eightanaloginputs (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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

The following line ensure the project is rebuild each time the yaml file is updated.

println!("cargo:rerun-if-changed={}", CONFIG_FILE);
Enter fullscreen mode Exit fullscreen mode

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
    }

}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!");
}
Enter fullscreen mode Exit fullscreen mode

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.*
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
alkurop profile image
Alexey Kuropiantyk • Edited

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.

Collapse
 
pronvis profile image
pronvis

I need to do the same, but for SMT32. For example for BluePill (STM32F103RE).
Could you please help with that?

Collapse
 
nictrib profile image
NicTrib

How would I go about finding the different compiler flags for other arduino boards like the arduino mega?