Skip to main content

Building an application for the CoreMP135 using Rust and Slint

· 10 min read

This is a followup to Creating linux images for the M5Stack CoreMP135 with Buildroot. The last post talked about using buildroot to make a linux image, and this post is about building an application in Rust and running it on the image. The demo application is a CAN logger. It will use Slint to create a simple UI on the touchscreen to start and stop the log file, and it will log data to an exFAT partition on the microSD card.

Getting code onto the device

If you successfully built an image using coremp135-bsp, and you added your key to the authorized_keys file on the target, you should be able to SSH to it by connecting to ETH1 and using the static IP configured on it -- 192.168.2.254, or whatever you customized it to.

Then you can copy files to it with scp, e.g. scp -O my_file root@192.168.2.254:/root/.

I like to add the following entry to ~/.ssh/config to simplify connecting:

host coremp135
HostName 192.168.2.254
StrictHostKeyChecking no
UpdateHostkeys yes
User root

From here on out, you may see me reducing transfers to scp -O my_file coremp135:/root/; this is why.

Cross Compiling

First of all, lets just get Hello World building.

Create a new project: cargo init

Add a rust-toolchain.toml file with the following:

[toolchain]
channel = "1.90.0"
targets = [ "armv7-unknown-linux-gnueabihf" ]
profile = "default"

And, we'll go ahead and add the linuxfb dependency -- we're going to it later, and it is going to be using bindgen, and will require some header files from the buildroot system, so lets get it compiling now too.

cargo add linuxfb

Now you can build for the arm target with the following command, but it will fail!

cargo build --release --target=armv7-unknown-linux-gnueabihf

We need to use the linker from buildroot, which is setup in .cargo/config.toml:

# .cargo/config.toml
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-buildroot-linux-gnueabihf-gcc"

Great. Now building will fail differently!

We're going to be setting up some environment variables, to get that linker in our path and also to setup some options for sysroots. The extra options aren't necessary if we are building pure rust, but when you link against shared libraries, or compile C code (e.g. with bindgen/clang) during the build, you likely will need them. I put these environment variables into .envrc, so they can be used with tools like direnv, but they can also be activated in your current shell by simply sourcing the file: . ./.envrc.

# .envrc

# The directory where host binaries are stored in `/bin`, and the sysroot is stored in
# `arm-buildroot-linux-gnueabihf/sysroot`. This can be `output/host` in a buildroot working
# directory, or an SDK tarball built using `make sdk`
BUILDROOT_SDK_RELATIVE="../buildroot/output/host"
# Paths have to be absolute, because crates will build with different working directories
export BUILDROOT_SDK=$(cd "$BUILDROOT_SDK_RELATIVE"; pwd -P)
export SYSROOT=$BUILDROOT_SDK/arm-buildroot-linux-gnueabihf/sysroot
export PKG_CONFIG_SYSROOT_DIR=$SYSROOT
export PKG_CONFIG_PATH=$SYSROOT/usr/lib/pkgconfig/
# We need the linker (`arm-buildroot-linux-gnueabihf-gcc`) from buildroot in the path. But buildroot
# also builds a host rustc, and this will override the cargo installed rust toolchain unless we output
# cargo into path ahead of it
export PATH=$HOME/.cargo/bin:$BUILDROOT_SDK/bin:$PATH
# Bindgen is used by some lib crates, like linuxfb, and it needs to know where sysroot is as well
export BINDGEN_EXTRA_CLANG_ARGS_armv7_unknown_linux_gnueabihf="--sysroot $SYSROOT"

You can generate a tarball with all of the required sysroot files from buildroot using the make sdk target, and I do recommend doing this and saving the tarball once your linux system is somewhat stable.

Now hopefully, your program builds. You can transfer it to the target using scp, then login and run it:

scp -O target/armv7-unknown-linux-gnueabihf/release/demo coremp135:/root  
ssh coremp135
# ./demo
Hello, world!

Building a UI

The touchscreen can be used by drawing to the frame buffer at /dev/fb1, and reading input events from /dev/input/event0.

Rendering

For this, we're going to use Slint to build the UI. You can use tools like the Slint vscode plugin or SlintPad to design the slint file, and it has good support for embedded devices (although note the licensing for commercial products). Of course, any UI library capable of drawing pixels to a frame buffer will work here too!

We'll use the Slint software renderer and the linuxfb crate crate to draw to the screen in our own event loop.

The minimum test application to render a slint UI looks like this:

First, add the following dependencies to Cargo.toml:

[dependencies]
linuxfb = "0.3.1"
slint = { version = "1.13.1", default-features = false, features = ["renderer-software", "std", "compat-1-2"] }


[build-dependencies]
slint-build = "1.13.1"

Then, add a simple .slint file to define the UI in ui/touch.slint:

import { AboutSlint, Button, VerticalBox } from "std-widgets.slint";

export component Demo inherits Window {
VerticalBox {
alignment: start;
Text {
text: "Hello World!";
font-size: 24px;
horizontal-alignment: center;
}
AboutSlint {
preferred-height: 150px;
}
HorizontalLayout { alignment: center; Button { text: "OK!"; } }
}
}

Then add a build.rs to compile your UI at build-time:

// build.rs
fn main() {
slint_build::compile_with_config(
"ui/touch.slint",
slint_build::CompilerConfiguration::new()
.embed_resources(slint_build::EmbedResourcesKind::EmbedForSoftwareRenderer),
).unwrap();
}

Then, in the application:

// main.rs
use std::{rc::Rc, time::Instant};
use slint::platform::{software_renderer::{MinimalSoftwareWindow, Rgb565Pixel}, Platform};

struct MyPlatform {
window: Rc<MinimalSoftwareWindow>,
start_instant: Instant,
}
impl MyPlatform {
pub fn new(window: Rc<MinimalSoftwareWindow>) -> Self {
let start_instant = Instant::now();
Self {
window,
start_instant,
}
}
}
impl Platform for MyPlatform {
fn create_window_adapter(
&self,
) -> Result<Rc<dyn slint::platform::WindowAdapter>, slint::PlatformError> {
Ok(self.window.clone())
}

fn duration_since_start(&self) -> core::time::Duration {
self.start_instant.elapsed()
}
}

// Include the types created by slint in build.rs
slint::include_modules!();

/// Draw the UI to the framebuffer and exit
fn main() {

const DISPLAY_WIDTH: u32 = 320;
const DISPLAY_HEIGHT: u32 = 240;
let fb = linuxfb::Framebuffer::new("/dev/fb1").unwrap();

let window = MinimalSoftwareWindow::new(Default::default());
slint::platform::set_platform(Box::new(MyPlatform::new(window.clone()))).unwrap();
window.set_size(slint::PhysicalSize::new(320, 240));

// 'Demo' is the type exported in touch.slint
let _ui = Demo::new().unwrap();
let mut buffer = [Rgb565Pixel(0); (DISPLAY_WIDTH * DISPLAY_HEIGHT) as usize];
slint::platform::update_timers_and_animations();

window.draw_if_needed(|renderer| {
let mut data = fb.map().unwrap();

renderer.render(&mut buffer, DISPLAY_WIDTH as usize);
unsafe {
core::ptr::copy_nonoverlapping(
buffer.as_ptr() as *mut u8,
data.as_mut_ptr(),
data.len(),
)
}
});
}

Run that, and if all goes well, it will draw the UI onto the screen, and quit.

Input Events

Now we need to be able to push buttons!

The evdev crate allows reading touch events from the touchscreen. The MinimalSoftwareWindow struct provides the dispatch_event() method for receiving input events. So to build an interactive UI, we just need to create an event loop: a thread which reads events, passes them to the Window, and then redraws as necessary. The only thing is, the dispatch_event() method wants to receive a `WindowEvent', so clearly we need some code to convert the evdev events into these.

If you are trying to do this...you are in luck! I did it already and published it in the slint-evdev-input crate.

The code for the entire event loop is getting a little long, so I'm not going to include it here. Instead, check it out in the repo.

Putting it all together

Now, you can build your linux filesystem with buildroot, and you can compile a rust application, linking against the SDK/filesystem created by buildroot which will run on your system. But ultimately, you probably want to combine those steps.

There's no clear one-right-way here, that I see. One way would be to take the coremp135-bsp project, clone it, and start modifying it. But I wanted to set it up so that I could re-use a single BSP with different applications. The method I've arrived at is to create an application repository and have it pull in the buildroot tree and the coremp135-bsp tree as submodules, then define a second external tree which ammends the BSP tree to customize the system, and add its own applications.

IMHO, Buildroot does not exactly shine at this, and this process feels a little brittle. But, it is the best I've come up with so far.

Compiling outside of buildroot vs a package

You can create your application as a buildroot package, letting the buildroot make system compile and install it. This is a very typical thing to do. The issues that I ran into were:

  1. I want to also be able to cargo build + scp during development, so I need to set that up to link against the buildroot SDK anyway
  2. The version of the rust toolchain included with buildroot was too old for me, so I preferred to compile with my own rust toolchain, rather than the one buildroot installs for itself.

Build and Install

In the end, I have a simple Makefile at the top-level of the project which builds everything.

all: buildroot

.PHONY: buildroot
buildroot:
cd buildroot; \
make BR2_EXTERNAL=../coremp135-bsp:../canlogger-external coremp135_defconfig; \
make COREMP135_EXFAT_DATA=1 -j4

The rust application is built and installed via a post-build script

set -e 

# Build rust application
(cd ${BR2_EXTERNAL_CANLOGGER_PATH}/canlogger; source ./.envrc; cargo build --release --target armv7-unknown-linux-gnueabihf)
# Copy application to target output directory
cp ${BR2_EXTERNAL_CANLOGGER_PATH}/canlogger/target/armv7-unknown-linux-gnueabihf/release/canlogger $TARGET_DIR/usr/bin/canlogger

# Copy overlay files to target output directory
cp -r ${BR2_EXTERNAL_CANLOGGER_PATH}/board/canlogger/overlay/* $TARGET_DIR

# Copy public key for login
if [ -e ~/.ssh/id_rsa.pub ]; then
mkdir -p $TARGET_DIR/root/.ssh
cp ~/.ssh/id_rsa.pub $TARGET_DIR/root/.ssh/authorized_keys
fi

And this seems to work OK.

Expandable Data partition

A nuisance of programming devices with SD cards is that you don't generally know the size of the flash when you create an image -- more over, you ideally don't want create a 32GB image file just so you can program a mostly empty partition on a 32GB card.

My setup has three mountable partitions:

- rootfs A
- rootfs B
- data

The two rootfs partitions contain the filesystem, and are used by RAUC for doing updates -- if you are currently booting from A, then an update will be installed on B and the next reboot will boot from B.

The data partition is meant to store data which should not be erased on update. I also like to create separate filesystems for writing data, because setting things up so that your application doesn't write to your root filesystem is good for stability.

During image creation, the data partition is created as the last partition on the card, with a fixed, relatively small size. But really, I'd like it to be expanded to fill whatever space is available on the card. This is done in canlogger via an init script. After flashing the card, the first bootup will see that the partition table is small, and it will expand the partition to the end of the drive. Since the drive is already mounted as the / drive, a reboot is required to get linux to reload the partition table, so the script reboots after modifying partitions. Then on the next boot, it sees that the partition is large, but that the filesystem is still at its original size, so it resizes the filesystem. This is all handled in the S15resize_data init script.

Repos

All the relevant code is at https://github.com/mcbridejc/coremp135-canlogger. Oh, and it's also capable of logging two CAN busses to a CSV file on the exFAT partition. Maybe that's useful to somebody too.

If you want to build a project based on the CoreMP135 and want help, you can hire me! I'd love a good reason to design a custom board around the STM32MP1 or STM32MP2 family, so especially if you are thinking about doing that let's chat.