Bootstrapping My Embedded Rust Development Environment

After watching James Munns' Something for Nothing talk at RustConf about all of the cool things in the embedded Rust world that have been going on, I decided to take a crack at some embedded work. I built an ErgoDox a while back and already had some basic understanding of how its keyboard controller operates, so I thought "why not design my own keyboard?"

Disclaimer: I'm mostly a newbie at embedded development short of a few classes I took back in college that mostly involved developing for the 68HC11 and 6800, both of which were released in the 80's. Yikes. Anyway, take all of this with a grain of salt! 🙂

Selecting A Controller

Having worked with the Teensy 2.0 for my ErgoDox, I originally planned to use the ARM-based Teensy LC for my controller. Since there wasn't much in the way of device or board support for it in the embedded rust world, I went down the rabbit hole of building it mostly from the ground up.

I got as far as generating the device crate via svd2rust and started on the board support crate before hitting a snag. I disregarded this bit in the example memory.x from the Rust Embedded Book:

/* You can use this symbol to customize the location of the .text section */
/* If omitted the .text section will be placed right after the .vector_table
   section */
/* This is required only on microcontrollers that store some configuration right
   after the vector table */
/* _stext = ORIGIN(FLASH) + 0x400; */

Everything looked to be going alright - I had a basic "blink" program that I could build and load that seemed to work for the most part, but then I decided to add an interrupt handler. This was apparently enough to brick my Teensy. The chip on the Teensy-LC, the MKL26Z64VFT4, has a small flash-config section inside the main flash region of memory, which is what the comment in the memory.x example alluded to.

As far as I could tell, I managed to either load some bad data into that region when flashing my board (which the teensy bootloader is supposed to prevent), my running code somehow managed to touch it, or something else entirely went wrong. At any rate, my first Teensy-LC stopped showing up as a USB device even when the reset button was pressed. As a part of my debugging process, I flashed the same program to my second Teensy-LC to make sure that it was in fact my program that had killed it and not some other electrical issue I may have induced. Turns out I was right, and both boards were now inoperable. Whoops.

Flash Config Section

I created a thread on the PJRC forum in hopes that someone would have some ideas for me, or perhaps some replacement boards since, from what I gathered, it wasn't supposed to be possible to brick them in this manner. Alas, no replies.

Designing for Debuggability

One of the major failings of the Teensy family is their lack of an accessible hardware debugging interface. While the core microcontroller technically has a debugging interface, it's "hijacked" by the bootloader/flasher coprocessor, making it unusable without some hacking that I didn't feel up for, especially since I had already killed two boards. I suspect that if I had a real debugging interface to connect to, I might have been able to recover them, but since the proprietary incommunicado bootloader was the only way to program them, I was out of luck.

I'd always heard great things about the STM32 family and their debuggability, in addition to pre-existing Rust support, so I went looking for a suitable board in that vein. I pretty quickly found the stm32duino wiki and their list of STM32F103 boards. The RobotDyn "Black Pill" seemed like it would do the trick, so I grabbed 5 of them from their site.

Running Some Code

A simple "blink" program is usually a good first pass at any embedded development target. I've been using the one from the stm32f103xx-hal crate. The setup of the Rust project is covered pretty thoroughly in the Embedded Book, so I won't go into much detail on it here.

The STM32 family comes with their own built-in bootloader that supports flashing over pretty much any serial peripheral, such as I2C or UART. I happened to have one of these USB to TTL cables laying around, so that made flashing the boards pretty straightforward.

In the above video, RX and TX are connected to PA9 and PA10 respectively and 3v3/GND to their respective pins. With that set up, the blink program can be flashed with stm32flash:

$ stm32flash -w blink.bin -v -g 0x0 /dev/ttyUSB0
stm32flash 0.5

http://stm32flash.sourceforge.net/

Using Parser : Raw BINARY
Interface serial_posix: 57600 8E1
Version      : 0x22
Option 1     : 0x00
Option 2     : 0x00
Device ID    : 0x0410 (STM32F10xxx Medium-density)
- RAM        : 20KiB  (512b reserved by bootloader)
- Flash      : 128KiB (size first sector: 4x1024)
- Option RAM : 16b
- System RAM : 2KiB
Write to memory
Erasing memory
Wrote and verified address 0x0abcdef0 (100.00%) Done.

For this, the BOOT0 pin (closest to the USB port) needs to be jumpered high when the board is powered up. This puts the device into bootloader mode. In order to actually run the code, the board needs to be started with BOOT0 jumpered low. This is obviously a bit of a pain when trying to iterate quickly. Luckily, it's not (usually) necessary when you have a real debugger.

Building The Debugger

Running code is fun and all, but what I really wanted was a way to debug it. Enter Black Magic Probe.

Black Magic Probe is an open-source on-chip-debugger that runs its own GDB server, so it doesn't require any additional tooling on the host side. It runs on quite a few platforms and can target many common Cortex-M and Cortex-A controllers.

Since I got several of the RobotDyn boards, I figured I could spare one for a debugging platform. Building BMP for it was fairly straightforward:

$ git clone https://github.com/blacksphere/blackmagic && cd blackmagic
$ make ENABLE_DEBUG=1 PROBE_HOST=swlink
...
  OBJCOPY blackmagic.bin
...
  OBJCOPY blackmagic_dfu.bin
...

This gave me both src/blackmagic_dfu.bin and src/blackmagic.bin. The DFU binary is a bootloader that allows upgrading the probe over USB rather than having to break out the USB-TTL cable every time. As with the blink program, flashing it was as easy as

$ stm32flash -w src/blackmagic_dfu.bin -v /dev/ttyUSB0
...
Wrote and verified address 0x08001c4c (100.00%) Done.

Again, BOOT0 must be jumpered high for this.

From there, everything else could be done over USB with BOOT0 held low!

An interesting quirk of the STM32F103C8 chips is that, while they're only declared to have 64KB of flash, nearly all of them in reality have 128KB. This isn't guaranteed, YMMV, etc., but for my purposes, this is great news, considering that BMP takes more than 64KB. dfu-util, unfortunately, will respect the announced 64KB limit and will refuse to flash the blackmagic.bin.

$ dfu-util -d 1d50:6018,:6017 -s 0x08002000:leave -D src/blackmagic.bin
...
dfu-util: Last page at 0x08015f67 is not writeable

Fortunately, there's a script in the BMP project (scripts/stm32_mem.py) that disregards the announced memory.

$ ./scripts/stm32_mem.py src/blackmagic.bin

USB Device Firmware Upgrade - Host Utility -- version 1.2
Copyright (C) 2011  Black Sphere Technologies
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

Device ID:       1d50:6017
Manufacturer:    Black Sphere Technologies
Product:         Black Magic Probe (Upgrade)
Serial:          81C37E91
Programming memory at 0x08017C00
Verifying memory at   0x08017C00
Verified!
All operations complete!

Now we have a fully functional BMP debugger!

Debugging Some Code

Now the real fun begins. The BMP reuses the SWD pins at the opposite side of the board form the USB port for its own connection to targets that are begin debugged. This makes connecting everything fairly straightforward. SWDIO to SWDIO, SWCLK to SWCLK, etc. Since the BMP provides power to the target, no additional connections are strictly necessary aside from Host USB->BMP and BMP->Target SWD.

Debugger Connection

In this picture, my debugger is the one without the full headers and with the boot pins pulled permanently low.

When the BMP is plugged into the host computer, it shows up as two CDC ACM devices - effectively two serial devices. The first is the debugging interface and the second is its USB->Serial adapter. We're just going to worry about the debugger for now.

The debugger runs its own GDB server, so attaching to it only requires the appropriate GDB.

$  arm-none-eabi-gdb target/thumbv7m-none-eabi/release/blink
(gdb) target extended-remote /dev/ttyACM0
Remote debugging using /dev/ttyACM0

And now we're connected to the BMP! Attaching to the target is equally simple:

(gdb) monitor swdp_scan # First scan for connected targets.
Target voltage: ABSENT!
Available Targets:
No. Att Driver
 1      STM32F1 medium density
(gdb) attach 1 # attach to the one we found
Attaching to program: /data/home/jchase/src/gitlab.com/jrobsonchase/blink/target/thumbv7m-none-eabi/release/blink, Remote target
0x08000298 in main () at src/main.rs:46
46              block!(timer.wait()).unwrap();

Note that it pauses whatever is currently running on the target. Since it's the program that I'd loaded previously, I get source/line information, but you may not if you haven't loaded anything yet, or if the code on the controller differs from the binary you're using.

Loading new code onto it is also easy:

(gdb) load
Loading section .vector_table, size 0xc0 lma 0x8000000
Loading section .text, size 0x259c lma 0x80000c0
Loading section .rodata, size 0x1d40 lma 0x8002660
Start address 0x800084c, load size 17308
Transfer rate: 17 KB/sec, 910 bytes/write.

And then, of course, running!

(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /data/home/jchase/src/gitlab.com/jrobsonchase/blink/target/thumbv7m-none-eabi/release/blink 

Interrupting the program and adding breakpoints all work pretty much as expected, which blew my mind!

(gdb) break main.rs:42
Breakpoint 1 at 0x800028a: file src/main.rs, line 42.
(gdb) cont
Continuing.
Note: automatically using hardware breakpoints for read-only addresses.

Breakpoint 1, main () at src/main.rs:42
42              led.set_high();
(gdb) list
37          let mut led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);
38          // Try a different timer (even SYST)
39          let mut timer = Timer::syst(cp.SYST, 1.hz(), clocks);
40          loop {
41              block!(timer.wait()).unwrap();
42              led.set_high();
43              block!(timer.wait()).unwrap();
44              led.set_low();
45          }
46      }
(gdb) info locals
timer = <a very long type> # edited for brevity
led = <a very long type> # edited for brevity
gpioc = <optimized out>
clocks = <a very long type> # edited for brevity
rcc = <a very long type> # edited for brevity
flash = <optimized out>
dp = <optimized out>
cp = <optimized out>

This can be integrated into the Cargo workflow via the .cargo/config:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "arm-none-eabi-gdb -q -x bmp-connect.gdb -ex run"

bmp-connect.gdb:

target extended-remote /dev/ttyACM0
monitor swdp_scan
attach 1

set print asm-demangle on

break DefaultHandler
break UserHardFault
break rust_begin_unwind

load

and then the program can be flashed and executed with just cargo run!

IDE Integration

Debugging via the GDB cli is cool, but I wanted to take it a step further and get it all working through my IDE, VSCode. Luckily, there's an extension that makes it pretty trivial - Cortex Debug. After installing it, an entry in the launch.json can be created:

{
    "name": "Cortex Debug",
    "cwd": "${workspaceRoot}",
    "preLaunchTask": "build debug",
    "executable": "${workspaceRoot}/target/thumbv7m-none-eabi/debug/blink",
    "request": "launch",
    "type": "cortex-debug",
    "BMPGDBSerialPort": "/dev/ttyACM0",
    "device": "stm32f103xx",
    "servertype": "bmp"
}

Some things to note here are my "preLaunchTask" which runs a debug build task defined as:

{
    "label": "build debug",
    "command": "cargo",
    "args": [
        "build"
    ],
    "problemMatcher": [
        "$rustc"
    ],
    "group": {
        "kind": "build",
        "isDefault": true
    }
}

and also the "device" entry, which allows the debugger to display the device peripherals.

With this, everything "Just Works!"