MicroRust
Discover the world of microcontrollers through Rust on the BBC micro:bit!
This book is an introductory course on microcontroller-based embedded systems that uses Rust as the teaching language (rather than the usual C/C++), and the micro:bit as the target system.
Approach
-
Beginner friendly. No previous experience with microcontrollers or embedded systems is required.
-
Hands on. You will be doing most of the work here. When possible, pages will end on a problem for you to solve, with the solution on the next page. There are plenty of exercises to put the theory into practice.
-
Standard. We'll make plenty use of standard tooling and processes to ease development so you can apply the skills learnt to any Rust embedded project. Fixing compiler errors, debugging with GDB, and logging will be introduced early on. Using LEDs as a debugging mechanism has no place here.
Scope
The following topics are covered in the core chapters:
- How to write, build, flash and debug an embedded program.
- Basic operation of a GPIO, ubiquitous in microcontrollers.
The rest of the chapters are independent, only requiring the core knowledge:
- Functionality ("peripherals") commonly found in microcontrollers:
- Digital input and output, including buttons and LEDs
Non-goals
What's out of scope for this book:
-
Teaching Rust. There's plenty of material on that topic already. We'll focus on microcontrollers and embedded systems.
-
Teaching electric circuit theory or electronics. We'll cover the minimum required to understand how some devices work along the way.
-
Covering Rustic, low level details. We won't be talking about linker scripts, the boot process, or how to glue those two into a minimally working Rust program.
Reporting problems
The source of this book is in this repository. If you encounter any typo or problem please report it on the issue tracker, or even submit a pull request.
Background
What is a microcontroller?
A microcontroller is a system on a chip. Whereas your laptop is made up of several discrete components: a processor, RAM sticks, a hard drive, an ethernet port, etc.; a microcontroller has all those components built into a single "chip" or package. This makes it possible to build systems with minimal part count.
What can you do with a microcontroller?
Lots of things! Microcontrollers are the central part of systems known as embedded systems. These systems are everywhere but you don't usually notice them. These systems control the brakes of your car, wash your clothes, print your documents, keep you warm, keep you cool, optimize the fuel consumption of your car, etc.
The main trait of these systems is that they operate without user intervention even if they expose a user interface like a washing machine does; most of their operation is done on their own.
The other common trait of these systems is that they control a process. And for that these systems usually have one or more sensors and one or more actuators. For example, an HVAC system has several sensors, thermometers and humidy sensors spread across some area, and several actuators as well, heating elements and fans connected to ducts.
When should I use a microcontroller?
All these application I've mentioned, you can probably implement with a Raspberry Pi, a computer that runs Linux. Why should I bother with a microcontroller that operates without an OS? Sounds like it would be harder to develop a program.
One main reason is cost. A microcontroller is much cheaper than a general purpose computer. Not only the microcontroller is cheaper; it also requires much less external electrical components to operate. This makes Printed Circuit Boards (PCB) smaller and cheaper to design and manufacture.
Another big reason is power consumption. A microcontroller consumes orders of magnitude less power than a full blown processor. If your application will run on batteries that makes a huge difference.
And last but not least: (hard) real time constraints. Some processes require their controllers to respond to some events within some time interval (e.g. a quadcopter/drone hit by a wind gust). If this deadline is not met, the process could end in catastrophic failure (e.g. the drone crashes to the ground). A general purpose computer running a general purpose OS has many services running in the background. This makes it hard to guarantee execution of a program within tight time constraints.
When should I not use a microcontroller?
Where heavy computations are involved. To keep their power consumption low, microcontrollers have very limited computational resources available to them. For example, some microcontrollers don't even have hardware support for floating point operations. On those devices, performing a simple addition of single precision numbers can take hundreds of CPU cycles.
Development on the micro:bit
The micro:bit website offers several very simple ways of programming a microbit, aimed at teaching school children how to program. This is a very good introduction to the world of microcontollers and programming, but falls short of teaching true embedded development. From there you would usually move to C to develop skills useful in industry, developing performant embedded software.
Why use Rust and not C?
Hopefully I don't need to convince you here as you are probably familiar with the language differences between Rust and C. One point I do want to bring up is package management. C lacks an official, widely accepted package management solution whereas Rust has Cargo. This makes development much easier. And, IMO, easy package management encourages code reuse because libraries can be easily integrated into an application which is also a good thing as libraries get more "battle testing".
Why should I not use Rust?
Or why should I prefer C over Rust?
The C ecosystem is way more mature. Off the shelf solution for several problems already exist. If you need to control a time sensitive process, you can grab one of the existing commercial Real Time Operating Systems (RTOS) out there and solve your problem. There are no commercial, production-grade RTOSes in Rust yet so you would have to either create one yourself or try one of the ones that are in development. If you are looking to develop your skills to find a job, it is currently unlikely that a company doing embedded software development will be using Rust, and so your time would be better spent learning normal embedded development using C.
Requirements
Knowledge
The only knowledge requirement to read this book is to know some Rust. It's hard to quantify some but a good benchmark is having read and understood the first 14 chapters of the Rust book.
Hardware
To follow this material you'll only need a micro:bit.
You can purchase the BBC micro:bit from a large list of international resellers.
FAQ: Wait, why do I need this specific device?
It makes my life and yours much easier.
The material is much, much more approachable if we don't have to worry about hardware differences. Trust me on this one.
FAQ: Can I follow this material with a different development board?
Maybe? It depends mainly on two things: your previous experience with microcontrollers and/or whether there already exists a high level crate. A list of boards with high level crates available can be found here.
With other development boards, this text would lose most if not all its beginner friendliness and "easy to follow"-ness, IMO.
There are other similar guides for different hardware. For a full list see this list.
The following are worth a special mention:
- Discovery by @japaric: The genesis guide which this is based on. Uses the STM32F3DISCOVERY.
If you have a different cortex-m development board and you don't consider yourself a total beginner, you are better off starting with the cortex-m-quickstart project template.
Meet your hardware
Let's get familiar with the hardware we'll be working with.
BBC micro:bit (the "microbit")
What does this board contain? For full details see the microbit hardware page.
-
A Nordic nRF51822 microcontroller. This microcontroller has
-
A single core ARM Cortex-M0 processor with a maximum clock frequency of 16 MHz.
-
256 KB of flash memory. (1 KB = 1024 bytes)
-
16 KB of static RAM.
-
many "peripherals": timers, GPIO, I2C, SPI, UART, etc.
-
This microcontroller operates at (around) 3.3V.
-
-
2 user buttons on the front and 1 reset button on the back.
-
A 5x5 array of user LEDs.
-
A configureable 23-pin edge connector
-
A 2.4GHz radio transciever with support for bluetooth low energy (BLE).
-
An on-core nRF51 temperature sensor.
-
An NXP/Freescale MMA8652 3-axis accelerometer.
- An NXP/Freescale MAG3110 3-axis magnetometer.
- A second microcontroller: NXP/Freescale KL26Z. This microcontroller handles the USB interface, communication between your computer and the main microcontroller, and converting the USB's input voltage from 5V to 3.3V.
Micro-USB Cable
This comes with your microbit but can be any generic cable, and is used to connect the microbit to your computer.
External battery pack
The external battery pack that comes with the microbit will not be used explicitly as part of this guide, but feel free to use it to test your software without being tethered to a computer.
Plugging it in
You can use the micro-USB cable to power the micro:bit, and to transfer data. When you power up a new micro:bit you will see the display light up as the factory-installed program is executed. Otherwise, the last program will automatically be executed. The black reset button next to the USB input will restart the program being run.
Development environment setup
Dealing with microcontrollers involves several tools, as we'll be dealing with an architecture different than your laptop's, and we'll have to run and debug programs on a "remote" device.
Documentation
Without documentation it is pretty much impossible to work with microcontrollers.
We'll be referring to the micro:bit hardware page and the links found within.
HEADS UP Some of the links point to large PDF files several MBs in size.
Tools
We'll use all the tools listed below. Where a minimum version is not specified, any recent version should work but we have listed the version we have tested.
-
Cargo &
rustc
. -
OpenOCD. version >=0.8
-
arm-none-eabi
toolchain. Tested version: gcc 8.1.0, binutils 2.30. -
arm-none-eabi-gdb
. -
minicom
on Linux and macOS. Tested version: 2.7. Readers report thatpicocom
also works but we'll useminicom
in this book. -
PuTTY
on Windows.
Next, follow OS-agnostic installation instructions for a few of the tools:
rustc
& Cargo
Install rustup by following the instructions at https://rustup.rs.
Then, install or switch to the nightly channel.
$ rustup default nightly
NOTE Make sure you have a nightly newer than nightly-2018-10-12
.
rustc -V
should return a date newer than the one shown below:
$ rustc -V
rustc 1.31.0-nightly (2c2e2c57d 2018-10-12)
OS specific instructions
Now follow the instructions specific to the OS you are using:
Linux
Here are the installation commands for a few Linux distributions.
REQUIRED packages
- Ubuntu 16.04 or newer / Debian Jessie or newer
$ sudo apt-get install \
gcc-arm-none-eabi \
gdb-arm-none-eabi \
minicom \
openocd
- Fedora 23 or newer
$ sudo dnf install \
arm-none-eabi-gcc-cs \
arm-none-eabi-gdb \
minicom \
openocd
- Arch Linux
$ sudo pacman -S \
arm-none-eabi-gcc \
arm-none-eabi-gdb \
minicom \
openocd
- Other distros
For distros that don't have packages for ARM's pre-built toolchain, download the "Linux 64-bit" file and put its bin
directory on your path. Here's one way to do it:
$ mkdir -p ~/local && cd ~/local
$ tar xjf /path/to/downloaded/file/gcc-arm-none-eabi-7-2017-q4-major-linux.tar.bz2.tbz
Then, use your editor of choice to append to your PATH
in the appropriate shell init file (e.g. ~/.zshrc
or ~/.bashrc
):
PATH=$PATH:$HOME/local/gcc-arm-none-eabi-7-2017-q4-major/bin
udev rules
These rules let you use USB devices like the F3 and the Serial module without root privilege, i.e.
sudo
.
Create this file in /etc/udev/rules.d
with the contents shown below.
$ cat /etc/udev/rules.d/99-openocd.rules
# microbit - CMSIS-DAP
ATTRS{idVendor}=="0d28", ATTRS{idProduct}=="0204", GROUP="uucp"
Then reload the udev rules with:
$ sudo udevadm control --reload-rules
If you had any board plugged to your laptop, unplug them and then plug them in again.
Finally, check if you are in the uucp
group.
$ groups $(id -nu)
(..) uucp (..)
$ # ^^^^
($(id -nu)
returns your user name.)
If uucp
appears in the output. You are all set! Go to the next section. Otherwise, keep reading:
- Add yourself to the
uucp
group.
$ sudo usermod -a -G uucp $(id -u -n)
- Check again the output of
groups
.uucp
should be there this time!
$ groups $(id -nu)
(..) uucp (..)
$ # ^^^^
You'll have to re-log for these changes to take effect. You have two options:
You can reboot or log out from your current session and then log in; this will close all the programs you have open right now.
The other option is to use the command below:
$ su - $(id -nu)
to re-log only in the current shell and get access to uucp
devices only on that shell. Other
shells won't have access to uucp
devices unless you manually re-log on them with the same su
command.
Now, go to the next section.
Windows
arm-none-eabi-*
ARM provides .exe
installers for Windows. Grab one from here, and follow the instructions.
Just before the installation process finishes tick/select the "Add path to environment variable"
option. Then verify that the tools are in your %PATH%
:
$ arm-none-eabi-gcc -v
(..)
gcc version 5.4.1 20160919 (release) (..)
OpenOCD
There's no official binary release of OpenOCD for Windows but there are unofficial releases
available here. Grab the 0.10.x zipfile and extract it somewhere in your drive (I
recommend C:\OpenOCD
but with the drive letter that makes sense to you) then update your %PATH%
environment variable to include the following path: C:\OpenOCD\bin
(or the path that you used
before).
Verify that OpenOCD is in yout %PATH%
with:
$ openocd -v
Open On-Chip Debugger 0.10.0
(..)
PuTTY
Download the latest putty.exe
from this site and place it somewhere in your %PATH%
.
That's all! Go to the next section.
macOS
UNTESTED: please submit an issue if you can confirm this works.
All the tools can be install using Homebrew:
$ brew cask install gcc-arm-embedded
$ brew install minicom openocd
If the brew cask
command doesn't work (Error: Unknown command: cask
), then run brew tap Caskroom/tap
first and try again.
That's all! Go to the next section.
Verify the installation
Let's verify that all the tools were installed correctly.
Linux only
Verify permissions
Connect the micro:bit to your laptop using an USB cable.
The micro:bit should now appear as a USB device (file) in /dev/bus/usb
.
Let's find out how it got enumerated:
$ lsusb | grep -i NXP
Bus 002 Device 033: ID 0d28:0204 NXP ARM mbed
^^^ ^^^
In my case, the micro:bit got connected to the bus #2 and got enumerated as the device #33.
This means the file /dev/bus/usb/002/033
is the Fmicro:bit3.
Let's check its permissions:
$ ls -l /dev/bus/usb/002/033
crw-rw---- 1 root uucp 189, 160 Jul 8 14:06 /dev/bus/usb/002/033
^^^^
The group should be uucp
.
If it's not ... then check your udev rules and try re-loading them with:
$ sudo udevadm control --reload-rules
All
First OpenOCD connection
First, connect the micro:bit to your computer using the micro-USB cable. The yellow LED next to the USB input should turn on right after connecting the USB cable to the board.
Next, run this command:
$ # *nix
$ openocd -f interface/cmsis-dap.cfg -f target/nrf51.cfg
$ # Windows
$ # NOTE cygwin users have reported problems with the -s flag. If you run into
$ # that you can call openocd from the `C:\OpenOCD\share\scripts` directory
$ openocd -s C:\OpenOCD\share\scripts -f interface/cmsis-dap.cfg -f target/nrf51.cfg
NOTE Windows users:
C:\OpenOCD
is the directory where you installed OpenOCD to.
You should see output like this:
Open On-Chip Debugger 0.10.0
Licensed under GNU GPL v2
For bug reports, read
http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "swd". To override use 'transport select <transport>'.
cortex_m reset_config sysresetreq
adapter speed: 1000 kHz
Info : CMSIS-DAP: SWD Supported
Info : CMSIS-DAP: Interface Initialised (SWD)
Info : CMSIS-DAP: FW Version = 1.0
Info : SWCLK/TCK = 1 SWDIO/TMS = 1 TDI = 0 TDO = 0 nTRST = 0 nRESET = 1
Info : CMSIS-DAP: Interface ready
Info : clock speed 1000 kHz
Info : SWD DPIDR 0x0bb11477
Info : nrf51.cpu: hardware has 4 breakpoints, 2 watchpoints
(If you don't ... then check the general troubleshooting instructions.)
openocd
will block the terminal. That's fine.
Also, the yellow
LED should start blinking very fast.
It may seem concerning, but it is a good sign.
That's it! It works. You can now close/kill openocd
.
Getting started
Alright, let's start as you usually would with Rust.
$ rustup update
It is always good to keep your toolchain up to date.
Now let's make a new binary project.
You might not do this often, so it is understandeable to forget.
If you run $ cargo
, you will be given a hint.
New Project
$ cargo new microrust-start
Created binary (application) `microrust-start` project
$ cd microrust-start
Cargo.toml src
This has created a binary crate.
Now we could $ cargo build
this, and even $ cargo run
it,
but everything is being compiled for, and run on, your computer.
Targets
The micro:bit has a different architecture than your computer, so the first step will be to cross compile for the micro:bit's architecture. If you were to do an internet search, you would find a platform support list for Rust. Looking into this page, you will find the micro:bit's nRF51822 Cortex-M0 microprocessor:
thumbv6m-none-eabi [*] [ ] [ ] Bare Cortex-M0, M0+, M1
"thumbv6m-none-eabi" is known a a target triple. Note what the star represents:
These are bare-metal microcontroller targets that only have access to the core library, not std.
To install this target:
$ rustup target add thumbv6m-none-eabi
Build 1
Now how should we use this? Well, if you were to take a look at $ cargo build -h
, you would try:
$ cargo build --target thumbv6m-none-eabi
error[E0463]: can't find crate for `std`
|
= note: the `thumbv6m-none-eabi` target may not be installed
error: aborting due to previous error
For more information about this error, try `rustc --explain E0463`.
error: Could not compile `microrust-start`.
To learn more, run the command again with --verbose.
The help note is rather unhelpful because we just installed that target. We also just noted that the thumbv6m-none-eabi target does not include std, only the core crate, which is has a platform independent subset of the std features. Why is it still looking for the std crate when we build?
no_std
It turns out, rust will always look for the std crate unless explicitly disabled, so we will add the no_std attribute
src/main.rs
#![no_std] fn main() { println!("Hello, world!"); }
Build 2
$ cargo build --target thumbv6m-none-eabi
error: cannot find macro `println!` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
println
is a macro found in the std crate.
We don't need it at the moment, so we can remove it and try to build again.
Build 3
$ cargo build --target thumbv6m-none-eabi
error: `#[panic_handler]` function required, but not found
This error, is because rustc required a panic handler to be implemented.
panic_impl
We could try and implement the panic macro ourselves, but it's easier and more portable to use a crate that does it for us.
If we look on crates.io for the panic-impl keyword we will find some examples. Let us pic the simplest one, and add it to our Cargo.toml. If you have forgotten how to do this, try looking at the cargo book.
Cargo.toml
[dependencies]
panic-halt = "~0.2"
src/main.rs
#![no_std] extern crate panic_halt; fn main() { }
Build 4
$ cargo build --target thumbv6m-none-eabi
error: requires `start` lang_item
no_main
In the normal command line rust binaries you would be used to making,
executing the binary usually has the operating system start by executing the C runtime library (crt0).
This in turn invokes the Rust runtime, as marked by the start
language item,
which in turn invokes the main function.
Having enabled no_std
, as we are targeting on a microcontroller,
neither the crt0 nor the rust runtime are available,
so even implementing start
would not help us.
We need to replace the operating system entry point.
You could for example name a function after the default entry point,
which for linux is _start
, and start that way.
Note, you would also need to disable name mangling:
# #![allow(unused_variables)] #![no_std] #![no_main] #fn main() { #[no_mangle] pub extern "C" fn _start() -> ! { loop {} } #}
This is the end of the road for trying to get this to work on our own. At this point we need the help of a board-specific support crate and a few cargo tweaks to get this working.
microbit crate
Let us add a dependency on the board crate for the micro:bit.
[dependencies]
panic-halt = "~0.2"
microbit="~0.7"
The microbit crate has 2 notable dependencies:
embedded-hal
This crate is a HAL implementation crate, where HAL stands for hardware abstraction layer. As rust becomes more and more popular in embedded development, it is desireable to have as little hardware specific implementation as possible.
For this reason, the embedded-hal
crate contains a range of hardware abstraction traits which can
be implemented by board specific crates.
cortex-m-rt
This crate implements the minimal startup / runtime for Cortex-M microcontrollers. Among other things this crate provides:
- the
#[entry]
attribute, to define the entry point of the program. - a definition of the hard fault handler
- a definition of the default exception handler
This crate requires:
- a definition of the specific microcontroller's memory layout as a memory.x file. fortunately this is usually provided by the board support crates
To use the #[entry]
attribute, we will need to add this as a dependency.
For more detailed information, you can use the helpful cortex-m-quickstart crate and its documentation.
cargo config
Before we go any further,
we are going to tweak the cargo's configuration by editing microrust-start/.cargo/config
.
For more information, you can read the documentation here.
.cargo/config
# Configure builds for our target, the micro:bit's architecture
[target.thumbv6m-none-eabi]
# Execute binary using gdb when calling cargo run
runner = "arm-none-eabi-gdb"
# Tweak to the linking process required by the cortex-m-rt crate
rustflags = [
"-C", "link-arg=-Tlink.x",
# The LLD linker is selected by default
#"-C", "linker=arm-none-eabi-ld",
]
# Automatically select this target when cargo building this project
[build]
target = "thumbv6m-none-eabi"
arm-none-eabi-gdb
This is a version of gdb (the GNU debugger) for the ARM EABI (embedded application binary interface). It will allow us to debug the code running on our micro:bit, from your computer.
Build target
Now, all you need to do is run $ cargo build
,
and cargo will automatically add --target thumbv6m-none-eabi
.
Build 5
Cargo.toml
[dependencies]
panic-halt = "~0.2"
microbit="~0.7"
cortex-m-rt="~0.6"
src/main.rs
#![no_std] #![no_main] extern crate panic_halt; use cortex_m_rt::entry; #[entry] fn main() { }
$ cargo build
error: custom attribute panicked
--> src/main.rs:7:1
|
7 | #[entry]
| ^^^^^^^^
|
= help: message: `#[entry]` function must have signature `[unsafe] fn() -> !`
!
return type
A little known rust feature, so I will forgive you if you do not know what this means.
A return type of !
means that the function cannot return.
An easy way to implement this is to use an infinite loop.
src/main.rs
#![no_std] #![no_main] extern crate panic_halt; use cortex_m_rt::entry; #[entry] fn main() -> ! { loop {} }
Build 6
If you try building now, you should finally be greeted with Finished
!
$ cargo build
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Build Complete
As a sanity check, let's verify that the produced executable is actually an ARM binary:
$ file target/thumbv6m-none-eabi/debug/microrust-start
target/thumbv6m-none-eabi/debug/microrust-start: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, with debug_info, not stripped
^^^ ^^^^^
Flashing
Flashing is the process of moving our program into the microcontroller's (persistent) memory. Once flashed, the microcontroller will execute the flashed program every time it is powered on.
In this case, our rustled
program will be the only program in the microcontroller memory. By this I mean that there's nothing else running on the microcontroller: no OS, no daemon, nothing. rustled
has full control over the device. This is what is meant by bare-metal programming.
- OS
- operating system
- Daemon
- program running in the background
Connect the micro:bit to your computer and run the following commands on a new terminal.
We need to give OCD the name of the interfaces we are using:
$ # All
$ # Windows: remember that you need an extra `-s %PATH_TO_OPENOCD%\<version>\scripts`
$ openocd -f interface/cmsis-dap.cfg -f target/nrf51.cfg
The program will block; leave that terminal open.
Now it's a good time to explain what this command is actually doing.
I mentioned that the micro:bit actually has two microcontrollers. One of them is used as a USB interface and programmer/debugger. This microcontroller is connected to the target microcontroller using a Serial Wire Debug (SWD) interface (this interface is an ARM standard so you'll run into it when dealing with other Cortex-M based microcontrollers). This SWD interface can be used to flash and debug a microcontroller. It uses the CMSIS-DAP protocol for host debugging of application programs. It will appear as a USB device when you connect the micro:bit to your laptop.
As for OpenOCD, it's software that provides some services like a GDB server on top of USB devices that expose a debugging protocol like SWD or JTAG.
GDB: The GNU debugger will allow us to debug our software by controlling the execution of our program. We will learn more about this a little bit later.
Onto the actual command: those .cfg
files we are using instruct OpenOCD to look for
- a CMSIS-DAP USB interface device (
interface/cmsis-dap.cfg
) - a nRF51XXX microcontroller target (
target/nrf51.cfg
) to be connected to the USB interface.
The OpenOCD output looks like this:
Open On-Chip Debugger 0.10.0
Licensed under GNU GPL v2
For bug reports, read
http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "swd". To override use 'transport select <transport>'.
cortex_m reset_config sysresetreq
adapter speed: 1000 kHz
Info : CMSIS-DAP: SWD Supported
Info : CMSIS-DAP: Interface Initialised (SWD)
Info : CMSIS-DAP: FW Version = 1.0
Info : SWCLK/TCK = 1 SWDIO/TMS = 1 TDI = 0 TDO = 0 nTRST = 0 nRESET = 1
Info : CMSIS-DAP: Interface ready
Info : clock speed 1000 kHz
Info : SWD DPIDR 0x0bb11477
Info : nrf51.cpu: hardware has 4 breakpoints, 2 watchpoints
The "4 breakpoints, 2 watchpoints" part indicates the debugging features the processor has available.
I mentioned that OpenOCD provides a GDB server so let's connect to that right now:
$ arm-none-eabi-gdb -q target/thumbv6m-none-eabi/debug/rustled
Reading symbols from target/thumbv6m-none-eabi/debug/rustled...done.
(gdb)
This only opens a GDB shell. To actually connect to the OpenOCD GDB server, use the following command within the GDB shell:
(gdb) target remote :3333
Remote debugging using :3333
0x00000000 in ?? ()
By default OpenOCD's GDB server listens on TCP port 3333 (localhost). This command is connecting to that port.
After entering this command, you'll see new output in the OpenOCD terminal:
Info : stm32f3x.cpu: hardware has 4 breakpoints, 2 watchpoints
+Info : accepting 'gdb' connection on tcp/3333
+Info : nRF51822-QFAA(build code: H0) 256kB Flash
Almost there. To flash the device, we'll use the load
command inside the GDB shell:
(gdb) load
Loading section .vector_table, size 0x188 lma 0x8000000
Loading section .text, size 0x38a lma 0x8000188
Loading section .rodata, size 0x8 lma 0x8000514
Start address 0x8000188, load size 1306
Transfer rate: 6 KB/sec, 435 bytes/write.
And that's it. You'll also see new output in the OpenOCD terminal.
Info : flash size = 256kbytes
+Info : Unable to match requested speed 1000 kHz, using 950 kHz
+Info : Unable to match requested speed 1000 kHz, using 950 kHz
+adapter speed: 950 kHz
+target state: halted
+target halted due to debug-request, current mode: Thread
+xPSR: 0x01000000 pc: 0x08000194 msp: 0x2000a000
+Info : Unable to match requested speed 8000 kHz, using 4000 kHz
+Info : Unable to match requested speed 8000 kHz, using 4000 kHz
+adapter speed: 4000 kHz
+target state: halted
+target halted due to breakpoint, current mode: Thread
+xPSR: 0x61000000 pc: 0x2000003a msp: 0x2000a000
+Info : Unable to match requested speed 1000 kHz, using 950 kHz
+Info : Unable to match requested speed 1000 kHz, using 950 kHz
+adapter speed: 950 kHz
+target state: halted
+target halted due to debug-request, current mode: Thread
+xPSR: 0x01000000 pc: 0x08000194 msp: 0x2000a000
Our program is loaded, we can now run it!
(gdb) continue
Continuing.
Continue runs the program until the next breakpoint. This time it blocks, nothing happens. This is because all we have in our code is a loop!
.gdbinit
Before we move on though, we are going to add one more file to our project. This will automate the last few steps so we don't need to repeatedly do the same actions in gdb:
.gdbinit
# Connects GDB to OpenOCD server port
target remote :3333
# (optional) Unmangle function names when debugging
set print asm-demangle on
# Load your program, breaks at entry
load
# (optional) Add breakpoint at function
break rustled::main
# Continue with execution
continue
Now we can learn how to debug code on the micro:bit.
Debugging
Setup
Before we start, let's add some code to debug:
// -- snip -- entry!(main); fn main() -> ! { let _y; let x = 42; _y = x; loop {} }
GDB session
We are already inside a debugging session so let's debug our program.
After the load
command, our program is stopped at its entry point. This is indicated by the
"Start address 0x8000XXX" part of GDB's output. The entry point is the part of a program that a
processor / CPU will execute first.
The starter project I've provided to you has some extra code that runs before the main
function.
At this time, we are not interested in that "pre-main" part so let's skip right to the beginning of
the main
function. We'll do that using a breakpoint:
(gdb) break rustled::main
Breakpoint 1 at 0x8000218: file src/main.rs, line 8.
(gdb) continue
Continuing.
Note: automatically using hardware breakpoints for read-only addresses.
Breakpoint 1, rustled::main () at src/rustled/src/main.rs:13
13 let x = 42;
Breakpoints can be used to stop the normal flow of a program.
The continue
command will let the program run freely until it reaches a breakpoint.
In this case, until it reaches the main
function because there's a breakpoint there.
Note that GDB output says "Breakpoint 1". Remember that our processor can only use four of these breakpoints so it's a good idea to pay attention to these messages.
For a nicer debugging experience, we'll be using GDB's Text User Interface (TUI). To enter into that mode, on the GDB shell enter the following command:
(gdb) layout src
NOTE Apologies Windows users. The GDB shipped with the GNU ARM Embedded Toolchain doesn't support this TUI mode
:(
.
At any point you can leave the TUI mode using the following command:
(gdb) tui disable
OK. We are now at the beginning of main
.
We can advance the program statement by statement using the step
command.
So let's use that twice to reach the y = x
statement.
Once you've typed step
once you can just hit enter to run it again.
(gdb) step
14 _y = x;
If you are not using the TUI mode,
on each step
call GDB will print back the current statement along with its line number.
We are now "on" the y = x
statement; that statement hasn't been executed yet. This means that x
is initialized but y
is not. Let's inspect those stack/local variables using the print
command:
(gdb) print x
$1 = 42
(gdb) print &x
$2 = (i32 *) 0x10001fdc
(gdb) print _y
$3 = 134219052
(gdb) print &_y
$4 = (i32 *) 0x10001fd8
As expected, x
contains the value 42
.
_y
however, contains the value 134219052
(?).
Because _y
has not been initialized yet, it contains some garbage value.
The command print &x
prints the address of the variable x
.
The interesting bit here is that GDB output shows the type of the reference:
i32*
, a pointer to an i32
value.
Another interesting thing is that the addresses of x
and _y
are very close to each other:
their addresses are just 4
bytes apart.
Instead of printing the local variables one by one, you can also use the info locals
command:
(gdb) info locals
x = 42
_y = 134219052
OK. With another step
, we'll be on top of the loop {}
statement:
(gdb) step
17 loop {}
And _y
should now be initialized.
(gdb) print _y
$5 = 42
If we use step
again on top of the loop {}
statement, we'll get stuck because the program will
never pass that statement. Instead, we'll switch to the disassemble view with the layout asm
command and advance one instruction at a time using stepi
.
NOTE If you used the
step
command by mistake and GDB got stuck, you can get unstuck by hittingCtrl+C
.
(gdb) layout asm
If you are not using the TUI mode,
you can use the disassemble /m
command to disassemble the program around the line you are currently at.
(gdb) disassemble /m
Dump of assembler code for function led_roulette::main:
11 fn main() -> ! {
0x08000188 <+0>: sub sp, #8
12 let _y;
13 let x = 42;
0x0800018a <+2>: movs r0, #42 ; 0x2a
0x0800018c <+4>: str r0, [sp, #4]
14 _y = x;
0x0800018e <+6>: ldr r0, [sp, #4]
0x08000190 <+8>: str r0, [sp, #0]
15
16 // infinite loop; just so we don't leave this stack frame
17 loop {}
=> 0x08000192 <+10>: b.n 0x8000194 <led_roulette::main+12>
0x08000194 <+12>: b.n 0x8000194 <led_roulette::main+12>
End of assembler dump.
See the fat arrow =>
on the left side? It shows the instruction the processor will execute next.
If not inside the TUI mode on each stepi
command GDB will print the statement,
the line number and the address of the instruction the processor will execute next.
(gdb) stepi
0x08000194 17 loop {}
(gdb) stepi
0x08000194 17 loop {}
One last trick before we move to something more interesting. Enter the following commands into GDB:
(gdb) monitor reset halt
Unable to match requested speed 1000 kHz, using 950 kHz
Unable to match requested speed 1000 kHz, using 950 kHz
adapter speed: 950 kHz
target halted due to debug-request, current mode: Thread
xPSR: 0x01000000 pc: 0x08000188 msp: 0x10002000
(gdb) continue
Continuing.
Breakpoint 1, led_roulette::main () at src/main.rs:8
8 let x = 42;
We are now back at the beginning of main
!
monitor reset halt
will reset the microcontroller and stop it right at the program entry point.
The following continue
command will let the program run freely until it reaches the main
function that has a breakpoint on it.
This combo is handy when you, by mistake, skipped over a part of the program that you were interested in inspecting. You can easily roll back the state of your program back to its very beginning.
The fine print: This
reset
command doesn't clear or touch RAM. That memory will retain its values from the previous run. That shouldn't be a problem though, unless your program behavior depends of the value of uninitialized variables, but that's the definition of undefined behavior (UB).
We are done with this debug session. You can end it with the quit
command.
(gdb) quit
A debugging session is active.
Inferior 1 [Remote target] will be detached.
Quit anyway? (y or n) y
Detaching from program: $PWD/target/thumbv7em-none-eabihf/debug/led-roulette, Remote target
Ending remote debugging.
NOTE If the default GDB CLI is not to your liking check out gdb-dashboard. It uses Python to turn the default GDB CLI into a dashboard that shows registers, the source view, the assembly view and other things.
Don't close OpenOCD though! We'll use it again and again later on. It's better just to leave it running.
What next?
In the next chapter we will learn how to send messages from the micro:bit to your computer, as well as howt to control the HAL GPIO.
Solution
This is a recap of what we have done so far.
Cargo.toml
[package]
name = "start"
version = "0.2.0"
[dependencies]
panic-halt = "~0.2"
microbit="~0.7"
cortex-m-rt="~0.6"
Rust
#![no_std] #![no_main] extern crate cortex_m_rt; extern crate microbit; extern crate panic_halt; use cortex_m_rt::entry; #[entry] fn main() -> ! { let _y; let x = 42; _y = x; loop {} }
.cargo/config
# Configure builds for our target, the micro:bit's architecture
[target.thumbv6m-none-eabi]
# Execute binary using gdb when calling cargo run
runner = "arm-none-eabi-gdb"
# Tweak to the linking process required by the cortex-m-rt crate
rustflags = [
"-C", "link-arg=-Tlink.x",
# The LLD linker is selected by default
#"-C", "linker=arm-none-eabi-ld",
]
# Automatically select this target when cargo building this project
[build]
target = "thumbv6m-none-eabi"
.gdbinit
# Connects GDB to OpenOCD server port
target remote :3333
# (optional) Unmangle function names when debugging
set print asm-demangle on
# Load your program, breaks at entry
load
# (optional) Add breakpoint at function
break main
# Continue with execution
continue
Hello world
In this chapter, we will discuss the basic I/O of embedded development in rust.
After this chapter,you should have all the neccesary basic knowledge to do embedded development in Rust, with anything remaining being solution specific.
Semihosting
Semihosting is a feature which allows targets without I/O support to use the I/O of the host.
When the special BKPT
instruction is reached, the host reads the characters directly from the micro:bit's memory.
Semihosting is slow
The most important thing to remember about semihosting is that it is slow. The processor halts entirely for each operation, making each operation take 107 milliseconds. This means that if you are doing any time sensitive work, you should not use it for logging. Check out this blog post for more information.
GDB
The first thing to do is to enable semihosting in GDB.
As before, we will add this to .gdbinit
to avoid typing it every time.
.gdbinit
target remote :3333
monitor arm semihosting enable
load
OpenOCD
You may have incorrectly assumed at this point that the outpust would appear in GDB. Remember that GDB simply connects to OpenOCD to interface with the micro:bit. OpenOCD is very loud currently, so it will be quite hard to see the output of our micro:bit in the noise. Fix this by stopping and restarting it with logging dumped to a file.
openocd -f interface/cmsis-dap.cfg -f target/nrf51.cfg -l /tmp/openocd.log
Panic
The easiest way to use semihosting is to use it for the panic!
macro.
Cargo.toml
panic-semihosting = ""
You can then see what happens if you add a panic!
to your code:
fn main() -> ! { panic!("test-panic"); }
Open On-Chip Debugger 0.10.0
Licensed under GNU GPL v2
For bug reports, read
http://openocd.org/doc/doxygen/bugs.html
panicked at 'test-panic', src/hello-world/src/main.rs:27:5
stdout
Finally, this is how to write to stdout.
# #![allow(unused_variables)] #fn main() { extern crate cortex_m_semihosting as sh; use core::fmt::Write; use sh::hio; // -- snip -- let mut stdout = hio::hstdout().unwrap(); stdout.write_str("semitest\n\r").unwrap(); // or writeln!(hio::hstdout().unwrap(), "Init").unwrap(); #}
Writing to stderr is just as easy.
Serial communication
The micro:bit has a perihperal called UART, a Universal Asynchronous Receiver/Transmitter. This is a form of serial communication, data is transferred serially, i.e. one bit at a a time. It is asynchronous communication, and there is no clock signal to dictate the bitrate, intead this is agreed upon beforehand. The protocol has frames consisting of a start bit, data bits, parity bits, and stop bits. We will be using 8 bits per frame: 1 start, 6 data and 1 stop. The data rate is called the baud rate, and we will use 115200bps.
USB
The micro:bit allows us to transmit and receive this serial communication over USB with no additional hardware.
Tooling
To read and write to the serial bus from your computer, you will need to configure your tooling:
Code
# #![allow(unused_variables)] #fn main() { use microbit::hal::prelude::*; use microbit::hal::serial; use microbit::hal::serial::BAUD115200; // -- snip -- if let Some(p) = microbit::Peripherals::take() { let mut gpio = p.GPIO.split(); // Configure RX and TX pins accordingly let tx = gpio.pin24.into_push_pull_output().downgrade(); let rx = gpio.pin25.into_floating_input().downgrade(); // Configure serial communication let (mut tx, _) = serial::Serial::uart0(p.UART0, tx, rx, BAUD115200).split(); // Write string with newline and carriage return // This could also be a format string let _ = write!(tx, "serial test\n\r"); } #}
In minicom/PuTTY you should see:
serial test
This is a very simple introduction to using the UART as one way serial logging. The chapter on UART serial communication goes into much more detail.
*nix tooling
Connect the serial module to your laptop and let's find out what name the OS assigned to it.
$ dmesg | grep -i tty
(..)
[ +0.000155] usb 3-2: FTDI USB Serial Device converter now attached to ttyUSB0
NOTE On macs, the USB device will named like this:
cu.usbserial-*
. Adjust the following commands accordingly!
But what's this ttyUSB0
thing? It's a file of course! Everything is a file in *nix:
$ ls -l /dev/ttyUSB0
crw-rw---- 1 root uucp 188, 0 Oct 27 00:00 /dev/ttyUSB0
You can send out data by simply writing to this file:
$ echo 'Hello, world!' > /dev/ttyUSB0
You should see the TX (red) LED on the serial module blink, just once and very fast!
minicom
Dealing with serial devices using echo
is far from ergonomic. So, we'll use the program minicom
to interact with the serial device using the keyboard.
We must configure minicom
before we use it. There are quite a few ways to do that but we'll use a
.minirc.dfl
file in the home directory. Create a file in ~/.minirc.dfl
with the following
contents:
$ cat ~/.minirc.dfl
pu baudrate 115200
pu bits 8
pu parity N
pu stopbits 1
pu rtscts No
pu xonxoff No
NOTE Make sure this file ends in a newline! Otherwise,
minicom
will fail to read it.
That file should be straightforward to read (except for the last two lines), but nonetheless let's go over it line by line:
pu baudrate 115200
. Sets baud rate to 115200 bps.pu bits 8
. 8 bits per frame.pu parity N
. No parity check.pu stopbits 1
. 1 stop bit.pu rtscts No
. No hardware control flow.pu xonxoff No
. No software control flow.
Once that's in place. We can launch minicom
$ minicom -D /dev/ttyUSB0 -b 115200
This tells minicom
to open the serial device at /dev/ttyUSB0
and set its baud rate to 115200.
A text-based user interface (TUI) will pop out.
Welcome to minicom 2.7.1
OPTIONS: I18n
Compiled on Jun 5 2018, 10:54:41.
Port /dev/ttyACM0, 19:50:57
Press CTRL-A Z for help on special keys
You can now send data using the keyboard! Go ahead and type something. Note that the TUI won't echo back what you type (nothing will happen when you type) but you'll see TX (red) LED on the serial module blink with each keystroke.
minicom
commands
minicom
exposes commands via keyboard shortcuts. On Linux, the shortcuts start with Ctrl+A
. On
mac, the shortcuts start with the Meta
key. Some useful commands below:
Ctrl+A
+Z
. Minicom Command SummaryCtrl+A
+C
. Clear the screenCtrl+A
+X
. Exit and resetCtrl+A
+Q
. Quit with no reset
NOTE mac users: In the above commands, replace
Ctrl+A
withMeta
.
Windows tooling
Before plugging the Serial module, run the following command on the terminal:
$ mode
It will print a list of devices that are connected to your laptop. The ones that start with COM
in
their names are serial devices. This is the kind of device we'll be working with. Take note of all
the COM
ports mode
outputs before plugging the serial module.
Now, plug the Serial module and run the mode
command again. You should see a new COM
port appear
on the list. That's the COM port assigned to the serial module.
Now launch putty
. A GUI will pop out.
On the starter screen, which should have the "Session" category open, pick "Serial" as the
"Connection type". On the "Serial line" field enter the COM
device you got on the previous step,
for example COM3
.
Next, pick the "Connection/Serial" category from the menu on the left. On this new view, make sure that the serial port is configured as follows:
- "Speed (baud)": 115200
- "Data bits": 8
- "Stop bits": 1
- "Parity": None
- "Flow control": None
Finally, click the Open button. A console will show up now.
If you type on this console, the TX (red) LED on the Serial module should blink. Each key stroke should make the LED blink once. Note that the console won't echo back what you type so the screen will remain blank.
GPIO and LEDs
GPIO
GPIO: General purpose input-output
The GPIO is a block of pins found on nearly all microcontrollers. As the name implies, they are general-purpose, configureable, analog or digital, input or output, electrical pins. Exactly what features each pin has on a given microcontroller will require looking at a datasheet.
Analog vs Digital: Analog signals carry data in their amplitude as they continuously vary over time, whereas digital signals have a fixed rate and fixed amplitudes. Digital signals are usually just 0 or 1, i.e. 0V or 3.3V
LED
LED: Light emitting diode
Let us now turn on an LED! But how?
Many integrated periperals like LEDs and buttons are already connected to certain GPIO pins, so lighting up an LED can be as simple as configuring a GPIO pin to be a digital output.
First we should look at the documentation of our crate, and you should be able to figure out how to get access to the gpio, and set individual pins high and low:
# #![allow(unused_variables)] #fn main() { // This takes singleton ownership of the micro:bit's peripherals if let Some(p) = microbit::Peripherals::take() { // Take the micro:bit's GPIO let mut gpio = p.GPIO.split(); // Take pin 1 of the GPIO, and configure it as a digital output let mut pin1 = gpio.pin1.into_push_pull_output(); // Set pin 1 high pin1.set_high(); } #}
Next we need to see how these pins are hooked up, for that we need the micro:bit schematics linked to at the bottom of the hardware overview. On the first sheet you should find a diagram with a grid of numbered LEDs.
If you do not know much about electronics: Each row and column (labelled ROW and COL) represent a GPIO output pin. The components labelled are LEDs. LEDs only let current flow one way, and only emit light when current is flowing. If a row is set high, high voltage, and a column is set low, low voltage, the LED at the point that they cross will have a potential difference across it; current will flow and it will light up.
As you can see, the micro:bit's display LEDs are a bit more complicated than being connected to a single pin. Each LED is connected to 2 pins, where one needs to be high, and the other low for the LED to light up.
The 5x5 array of LEDs are actually wired up as a 3x9 array (3 rows by 9 columns), with 2 missing. This is usually done to make the circuit design easier.
The fifth sheet shows how each row and column correspond to each GPIO pin.
You should now have enough information to try and turn on an LED.
Solution
This is my solution:
#![no_std] #![no_main] extern crate panic_semihosting; extern crate cortex_m_rt as rt; extern crate cortex_m_semihosting as sh; extern crate microbit; use core::fmt::Write; use rt::entry; use sh::hio; use microbit::hal::prelude::*; use microbit::hal::serial; use microbit::hal::serial::BAUD115200; #[entry] fn main() -> ! { let mut stdout = hio::hstdout().unwrap(); writeln!(stdout, "Start").unwrap(); if let Some(p) = microbit::Peripherals::take() { // Split GPIO let mut gpio = p.GPIO.split(); // Configure RX and TX pins accordingly let tx = gpio.pin24.into_push_pull_output().downgrade(); let rx = gpio.pin25.into_floating_input().downgrade(); // Configure serial communication let (mut tx, _) = serial::Serial::uart0(p.UART0, tx, rx, BAUD115200).split(); write!(tx, "serial - start\r\n"); // Get row and column for display let mut led = gpio.pin13.into_push_pull_output(); let _ = gpio.pin4.into_push_pull_output(); // Set row high (column starts low) led.set_high(); // Write string with newline and carriage return write!(tx, "serial - LED on\r\n"); } panic!("End"); }
It is worth noting that pin4 starts low, so does not need to be explicitly set low.
You now know enough to start playing around with the micro:bit's LED display and GPIO, as well as logging data back to the host.
You should know that the microbit crate already includes an abstraction for the LED display for you to use. How to implemented a simple blocking display driver is demonstrated in the LED display chapter.
Choose your own adventure
At this point of the book, you know the basics to get started with embedded development with Rust.
The following chapters are more independent of each other, and can be done in any order; the only required knowledge is found in the chapters before this. If attempting the exerises, it is best to follow the book in order to pace the difficulty.
A large portion of this book is still unfinished and I would love your support. Please submit an issue to request a new section, and a pull request to add a section.
micro:bit HAL
This chapter will demosntrate the common uses of the micro:bit crate, and its specific hardware abstraction layer (HAL) features. The content in this chapter should be deduceable from the HAL crate, but are given here as a reference.
More examples can be found in the micro:bit crate's examples.
Buttons
The micro:bit as 3 hardware buttons, 2 user buttons and the reset button.
User Buttons
The user buttons are wired up to be high when unpressed and low when pressed.
#![no_std] #![no_main] extern crate panic_abort; extern crate cortex_m_rt as rt; extern crate microbit; use core::fmt::Write; use rt::entry; use microbit::hal::prelude::*; use microbit::hal::serial; use microbit::hal::serial::BAUD115200; #[entry] fn main() -> ! { if let Some(p) = microbit::Peripherals::take() { // Split GPIO let mut gpio = p.GPIO.split(); // Configure RX and TX pins accordingly let tx = gpio.pin24.into_push_pull_output().downgrade(); let rx = gpio.pin25.into_floating_input().downgrade(); // Configure serial communication let (mut tx, _) = serial::Serial::uart0(p.UART0, tx, rx, BAUD115200).split(); // Configure button GPIOs as inputs let button_a = gpio.pin17.into_floating_input(); let button_b = gpio.pin26.into_floating_input(); // loop variables let mut state_a_low = false; let mut state_b_low = false; loop { // Get button states let button_a_low = button_a.is_low(); let button_b_low = button_b.is_low(); if button_a_low && !state_a_low { writeln!(tx, "Button A down").unwrap(); } if button_b_low && !state_b_low { writeln!(tx, "Button B down").unwrap(); } if !button_a_low && state_a_low { writeln!(tx, "Button A up").unwrap(); } if !button_b_low && state_b_low { writeln!(tx, "Button B up").unwrap(); } // Store buttons states // This should not read the GPIO pins again, as the state // may have changed and the change will not be recorded state_a_low = button_a_low; state_b_low = button_b_low; } } panic!("End"); }
Delays
The microbit has 3 timers, the micro:bit crate currently only supports using TIMER0.
# #![allow(unused_variables)] #fn main() { if let Some(p) = microbit::Peripherals::take() { let mut delay = Delay::new(p.TIMER0); delay.delay_ms(1000_u32); } #}
Display
The micro:bit display is not trivial to control, so a driver is needed; see the display chapter for more details.
Display calls for now are only blocking, and can either be for binary images (0 for off, 1 for on), or for monochrome images with differing brightness levels
Blocking binary image display
#![no_std] #![no_main] #[macro_use(entry, exception)] extern crate microbit; extern crate cortex_m_rt as rt; extern crate cortex_m_semihosting as sh; extern crate panic_abort; use core::fmt::Write; use rt::entry; use microbit::hal::delay::Delay; use microbit::hal::prelude::*; use microbit::hal::serial; use microbit::hal::serial::BAUD115200; use microbit::led; #[entry] fn main() -> ! { if let Some(p) = microbit::Peripherals::take() { let mut gpio = p.GPIO.split(); let mut delay = Delay::new(p.TIMER0); // Configure display pins let row1 = gpio.pin13.into_push_pull_output().downgrade(); let row2 = gpio.pin14.into_push_pull_output().downgrade(); let row3 = gpio.pin15.into_push_pull_output().downgrade(); let col1 = gpio.pin4.into_push_pull_output().downgrade(); let col2 = gpio.pin5.into_push_pull_output().downgrade(); let col3 = gpio.pin6.into_push_pull_output().downgrade(); let col4 = gpio.pin7.into_push_pull_output().downgrade(); let col5 = gpio.pin8.into_push_pull_output().downgrade(); let col6 = gpio.pin9.into_push_pull_output().downgrade(); let col7 = gpio.pin10.into_push_pull_output().downgrade(); let col8 = gpio.pin11.into_push_pull_output().downgrade(); let col9 = gpio.pin12.into_push_pull_output().downgrade(); // Configure RX and TX pins accordingly let tx = gpio.pin24.into_push_pull_output().downgrade(); let rx = gpio.pin25.into_floating_input().downgrade(); let mut leds = led::Display::new( row1, row2, row3, col1, col2, col3, col4, col5, col6, col7, col8, col9, ); let (mut tx, _) = serial::Serial::uart0(p.UART0, tx, rx, BAUD115200).split(); let _ = write!(tx, "\n\rStarting!\n\r"); #[allow(non_snake_case)] let letter_I = [ [0, 1, 1, 1, 0], [0, 0, 1, 0, 0], [0, 0, 1, 0, 0], [0, 0, 1, 0, 0], [0, 1, 1, 1, 0], ]; let heart = [ [0, 1, 0, 1, 0], [1, 0, 1, 0, 1], [1, 0, 0, 0, 1], [0, 1, 0, 1, 0], [0, 0, 1, 0, 0], ]; #[allow(non_snake_case)] let letter_U = [ [0, 1, 0, 1, 0], [0, 1, 0, 1, 0], [0, 1, 0, 1, 0], [0, 1, 0, 1, 0], [0, 1, 1, 1, 0], ]; loop { let _ = write!(tx, "I <3 Rust!\n\r"); leds.display(&mut delay, letter_I, 1000); leds.display(&mut delay, heart, 1000); leds.display(&mut delay, letter_U, 1000); leds.clear(); delay.delay_ms(250_u32); } } panic!("End"); }
WIP - UART serial server
In the first section of this book we saw how to do a simple debug print using serial. This is useful for logging and debugging, but does not cover the full potential of the UART peripheral.
Input and output
The simultaneous input and output capabilities of the UART allow for both your computer and the micro:bit to act as a server. They can receive a transmission, process it, perform some action, and send a response.
Echo Server
An echo server, is probably the simplest server we could make. It should receive a message, and echo it back to the sender.
We earlier said that minicom/PuTTY would transmit any keystrokes we send, and display and data received, so the end result should be the familiar experience of typing and seeing the letters typed appear as expected.
Flow
- The character
a
is typed - minicom/PuTTY encodes the
a
character's unicode code point (097
in decimal) as a word in a serial frame - The frame is transmitted to the micro:bit over USB
- The micro:bit software decodes the frame to get the word
- The microbit software re-encodes the word to get a (new but identical) frame
- The frame is transmitted to the computer over USB
- minicom/PuTTY decodes the frame's word as a unicode code point
- The letter
a
is displayed
Serial Theory
The micro:bit core crate implements the embedded_hal::serial::Write and embedded_hal::serial::Read traits for the tx and rx pins respectively.
writeln!
and Carriage Return
In the introduction page on serial communication, I brushed over this:
# #![allow(unused_variables)] #fn main() { // Write string with newline and carriage return let _ = write!(tx, "serial test\r\n"); #}
A naïve assumption would be to try the seemingly more correct writeln!
macro:
# #![allow(unused_variables)] #fn main() { // Write string with newline and carriage return let _ = writeln!(tx, "serial test"); #}
This will usually fail to do what is intended, as multiple writes will only print one line in PuTTY, and produce the following in minicom:
serial test
serial test
serial test
serial test
Your choices are to either configure minicom and PuTTY appropriately or use write!
with \r\n
.
Control Characters
The control characters operate based on a print head, as used in teleprinters.
\r
- Carriage Return - The print head is moves left to the start of the line.
\n
- Line Feed - The print head moves down once to a new line.
writeln!
macro
The writeln!
macro should append a new line,
but he documentation for core::writeln says:
On all platforms, the newline is the LINE FEED character (\n/U+000A) alone (no additional CARRIAGE RETURN (\r/U+000D).
minicom
CTRL-A + Z will tell you that CTRL-A + U will add a carriage return.
This will add a carriage return to a received \n
PuTTY
In PuTTY, you can enable enable Implicit LF in every CR
under Terminal options.
Blocking
Behind the scenes, embedded_hal::serial
uses the nb crate to allow for blocking and non-blocking operation.
This is implemented in embedded_hal crates by returning nb::Error::WouldBlock
when a read or write action cannot be performed immediately.
In this chapter, we will only be using read and write as simple blocking calls.
block!
The block!
macro provided by the crate continuously calls the expression
contained until it no longer returns Error::WouldBlock.
Tx - embedded_hal::serial::Write
or core::fmt::Write
The write!
and writeln!
macros call write_str
of the core::fmt::Write
trait which is implemented for Tx.
write_str
is implemented as a blocking call to write
of the embedded_hal::serial::Write
trait.
This means write!(tx, "a")
is equivalent to block!(tx.write(b'a'))
.
Echo Solution
#![no_std] #![no_main] extern crate panic_semihosting; extern crate cortex_m_rt as rt; extern crate cortex_m_semihosting as sh; extern crate microbit; use core::fmt::Write; use rt::entry; use sh::hio; use microbit::hal::prelude::*; use microbit::hal::serial; use microbit::hal::serial::BAUD115200; use microbit::nb::block; #[entry] fn main() -> ! { let mut stdout = hio::hstdout().unwrap(); writeln!(stdout, "Start").unwrap(); if let Some(p) = microbit::Peripherals::take() { // Split GPIO let mut gpio = p.GPIO.split(); // Configure RX and TX pins accordingly let tx = gpio.pin24.into_push_pull_output().downgrade(); let rx = gpio.pin25.into_floating_input().downgrade(); // Configure serial communication let (mut tx, mut rx) = serial::Serial::uart0(p.UART0, tx, rx, BAUD115200).split(); writeln!(tx, "Start"); loop { let val = block!(rx.read()).unwrap(); block!(tx.write(val)); } } panic!("End"); }
Exercises
- Reverse echo a line of input
- Numerical countdown
- Display echo
- Quiz game
Reverse Echo
The micro:bit should buffer characters it receives until \n
or \r
is received
(the enter key is pressed on the host computer).
The characters should then be printed back in reverse order to the host computer.
The characters may also be echoed like earlier to see what is being typed.
Flow
- Letter
a
is typed and transmitted to the micro:bit - (optional) The micro:bit retransmits the letter
a
(echo) - Letter
b
is typed and transmitted to the micro:bit - (optional) The micro:bit retransmits the letter
b
(echo) - Enter key is pressed and
\r
is transmitted to the micro:bit - Letters
ba
are transmitted from the micro:bit
Useful crates
Solution
#![no_std] #![no_main] extern crate panic_semihosting; extern crate cortex_m_rt as rt; extern crate cortex_m_semihosting as sh; extern crate heapless; extern crate microbit; use core::fmt::Write; use rt::entry; use sh::hio; use heapless::{consts, Vec}; use microbit::hal::prelude::*; use microbit::hal::delay::Delay; use microbit::hal::serial; use microbit::hal::serial::BAUD115200; #[entry] fn main() -> ! { let mut stdout = hio::hstdout().unwrap(); writeln!(stdout, "Start").unwrap(); if let Some(p) = microbit::Peripherals::take() { // Split GPIO let mut gpio = p.GPIO.split(); // Create delay provider let mut delay = Delay::new(p.TIMER0); // Configure RX and TX pins accordingly let tx = gpio.pin24.into_push_pull_output().downgrade(); let rx = gpio.pin25.into_floating_input().downgrade(); // Configure serial communication let (mut tx, mut rx) = serial::Serial::uart0(p.UART0, tx, rx, BAUD115200).split(); // A buffer with 32 bytes of capacity let mut buffer: Vec<u8, consts::U32> = Vec::new(); writeln!(tx, "Start"); loop { loop { // Read let byte = block!(rx.read()).unwrap(); // Echo block!(tx.write(byte)); // Carriage return if byte == b'\r' { break; } // Push to buffer if buffer.push(byte).is_err() { // Buffer full writeln!(tx, "\r\nWarning: buffer full, dumping buffer"); break; } } // Uncomment to not overwrite input string //writeln!(tx, ""); // Respond for b in buffer.iter().rev() { block!(tx.write(*b)); } writeln!(tx, ""); buffer.clear(); } } panic!("End"); }
I have used an implementation of a vector on the stack, provided by the heapless crate. After 32 characters (a char is a u8 byte) the heapless vector is full, and an error is shown.
Countdown
You should be able to type a number greater than 0, press enter, and the micro:bit will return a countdown.
5
4
3
2
1
Feel free to add your own surprise at the end of the countdown
Useful crates
Solution
#![no_std] #![no_main] extern crate panic_semihosting; extern crate cortex_m_rt as rt; extern crate cortex_m_semihosting as sh; extern crate heapless; extern crate microbit; use core::fmt::Write; use rt::entry; use sh::hio; use heapless::{consts, Vec, String}; use microbit::hal::prelude::*; use microbit::hal::delay::Delay; use microbit::hal::serial; use microbit::hal::serial::BAUD115200; #[entry] fn main() -> ! { let mut stdout = hio::hstdout().unwrap(); writeln!(stdout, "Start").unwrap(); if let Some(p) = microbit::Peripherals::take() { // Split GPIO let mut gpio = p.GPIO.split(); // Create delay provider let mut delay = Delay::new(p.TIMER0); // Configure RX and TX pins accordingly let tx = gpio.pin24.into_push_pull_output().downgrade(); let rx = gpio.pin25.into_floating_input().downgrade(); // Configure serial communication let (mut tx, mut rx) = serial::Serial::uart0(p.UART0, tx, rx, BAUD115200).split(); writeln!(tx, "Start"); loop { // A buffer with 32 bytes of capacity let mut buffer: Vec<u8, consts::U32> = Vec::new(); loop { // Read let byte = block!(rx.read()).unwrap(); // Echo block!(tx.write(byte)); // Carriage return if byte == b'\r' { break; } // Push to buffer if buffer.push(byte).is_err() { // Buffer full writeln!(tx, "\r\nWarning: buffer full, dumping buffer"); break; } } // Buffer to string let buf_str = String::from_utf8(buffer).unwrap(); writeln!(tx, ""); match buf_str.parse() { // Transmit countdown Ok(buf_int) => { for i in (1..buf_int).rev() { delay.delay_ms(1000_u32); writeln!(tx, "{}", i); } // Add post countdown effects here }, // Transmit parse error Err(e) => writeln!(tx, "{:?}", e).unwrap(), } } } panic!("End"); }
Quiz
Solution
WIP - LED display
This chapters follows on from the basic LED example from getting started. The LEDs are wired up in a matrix, as described in these schematics. Only a small proportion of images can be displayed at once with this layout; it is impossible for example to turn on both LED1 and LED11 without also having LED2 and LED10 turning on.
In this chapter we will be talking through how to achieve the impossible, displaying any chosen image, and even adjusting the brightness!
Theory
LED dot matrix display
A dot matrix display is a display containing a two dimensional array of points, used to represent characters, symbols, or images. All displays today are dot matrix displays, but vector displays also used to exist, examples include radar displays and 1980s arcade games.
Persistence of vision
In order to achieve full control of all LEDs we need to use an optical illusion called the persistence of vision. This effect causes light which has ceased entering the eye to still be seen for a little while after it has disappeared.
Multiplexing
Multiplexing is the combination of multiple signals over shared medium. In this case, we will be using human vision to multiplex time and space divided signals
Method
We are able to fully control one (circuit) row of LEDs at a time, so by quickly looping through the rows lof LEDs, we can let the brain blur them together into a complete image.
Problem statement
Display any given image on the micro:bit display. The image will be a 5x5 array, where 0 will represent off and 1 will represent on.
The following, for example, should display a heart:
# #![allow(unused_variables)] #fn main() { let heart = [ [0, 1, 0, 1, 0], [1, 0, 1, 0, 1], [1, 0, 0, 0, 1], [0, 1, 0, 1, 0], [0, 0, 1, 0, 0], ]; #}
At this point, you may know enough to solve the problem yourself. If you want to jump straight to the solution, you can go to the end of the next section in this chapter.
This section will continue formally breaking this problem down into smaller and more tractable pieces.
LED layout
Convert a 5x5 array into a 3x9 array to match the display's circuitry.
Schematics
The schem1 discussed earlier describe the electrical layout of the LEDs, but they do not describe how it relates to the visual layout. It would be a mistake to assume that the numbers 1 to 25 have any correlation to the visual layout of the LEDs on the micro:bit as they do NOT. It just happened to be that ROW0 and COL0 intersect at an LED in the top left corner.
Reference design
To find the relationship between the electrical array and visual array, we need to look at the reference design for the micro:bit. This can be found through a link at the bottom of the micro:bit hardware page
By navigating to the github page > PDF > Schematic Print, you can find a detailed electrical schematic for the micro:bit.
In the top right, you will see an array which can be defined in Rust as follows:
# #![allow(unused_variables)] #fn main() { const LED_LAYOUT: [[(usize, usize); 5]; 5] = [ [(0, 0), (1, 3), (0, 1), (1, 4), (0, 2)], [(2, 3), (2, 4), (2, 5), (2, 6), (2, 7)], [(1, 1), (0, 8), (1, 2), (2, 8), (1, 0)], [(0, 7), (0, 6), (0, 5), (0, 4), (0, 3)], [(2, 2), (1, 6), (2, 0), (1, 5), (2, 1)], ]; #}
Delays
Create a time delay.
Another piece of information you will need is how to create a time delay before moving to the next row. we want the time spent switching LED lines on and off to be much shorter than the time spent waiting with LEDs on.
For loop
A first attempt to implement the delay
function
without using any peripherals is to implement it as a for
loop delay:
# #![allow(unused_variables)] #fn main() { fn delay(ms: u16) { const K: u16 = 16_000; // 16MHz microprocessor, needs to be tweaked for _ in 0..(K*ms) {} } #}
When compiled in release mode however, this is optimized away. To solve this we could explicitly add an operation inside the loop. The perfect candidate is the NOP.
# #![allow(unused_variables)] #fn main() { fn delay(ms: u16) { const K: u16 = 16_000; // 16MHz microprocessor, needs to be tweaked for _ in 0..(K*ms) { cortex_m::asm::nop(); } } #}
Timers
A better way of implementing delays is by using timers. A one-shot timer (also called one pulse mode) works like an alarm clock. You set it once with the amount of time you want, and then wait until it goes off. Fortuinately for us, HAL crates usually have already solved this for us.
Microbit
The microbit has 3 timers, we will use the first: TIMER0. To use it, do the following:
# #![allow(unused_variables)] #fn main() { if let Some(p) = microbit::Peripherals::take() { let mut delay = Delay::new(p.TIMER0); delay.delay_ms(1000_u32); } #}
Multiplexing
Multiplex the rows of the matrix
The final task is to multiplex the rows of electrical matrix into a full image. We will be doing this by scanning through the rows in the display.
Pseudocode
In order to light up an LED, the row needs to be set high and the column needs to be set low. We will assume that at the start of a refresh cycle, that all the rows are set low and all the columns are set high. The order of operations during a refresh cycle is then, for each row:
- set the row high
- for each column
- set low if the LED associated with that row-column pair should be on
- sleep for a known duration, you should find 2ms is sufficient
- for each column
- set high
- set the row low
Solution
This solution describes a blocking display driver.
Layout
Convert a 5x5 array into a 3x9 array to match the display's circuitry.
# #![allow(unused_variables)] #fn main() { const LED_LAYOUT: [[(usize, usize); 5]; 5] = [ [(0, 0), (1, 3), (0, 1), (1, 4), (0, 2)], [(2, 3), (2, 4), (2, 5), (2, 6), (2, 7)], [(1, 1), (0, 8), (1, 2), (2, 8), (1, 0)], [(0, 7), (0, 6), (0, 5), (0, 4), (0, 3)], [(2, 2), (1, 6), (2, 0), (1, 5), (2, 1)], ]; /// Convert 5x5 display image to 3x9 matrix image pub fn display2matrix(led_display: [[u8; 5]; 5]) -> [[u8; 9]; 3] { // Create 3x9 array let mut led_matrix: [[u8; 9]; 3] = [[0; 9]; 3]; // Iterate through zip of input array and layout array for (led_display_row, layout_row) in led_display.iter().zip(LED_LAYOUT.iter()) { // Continue iterating through rows for (led_display_val, layout_loc) in led_display_row.iter().zip(layout_row) { // Assign dereferenced val to array led_matrix[layout_loc.0][layout_loc.1] = *led_display_val; } } return led_matrix; } #}
Multiplexing
Multiplex the rows of the matrix
# #![allow(unused_variables)] #fn main() { /// Display 3x9 matrix image for a given duration. pub fn display_pre(&mut self, delay: &mut Delay, led_matrix: [[u8; 9]; 3], duration_ms: u32) { // TODO // These need to be populated with PINs, e.g.: let rows = [PIN; 3]; let cols = [PIN; 9]; // Set refresh rate. let delay_ms = 2; // Calculate number of loops. let loops = duration_ms / (rows.len() as u32 * delay_ms); for _ in 0..loops { for (row_line, led_matrix_row) in rows.iter_mut().zip(led_matrix.iter()) { // Set the row high. row_line.set_high(); // Set the correct pins low (on) // This could lead to very small differences in execution time, // but this is not worth correcting for, as it is << 2ms. for (col_line, led_matrix_val) in cols.iter_mut().zip(led_matrix_row.iter()) { // We ignore any brightness setting, just use 0 and 1. if *led_matrix_val > 0 { col_line.set_low(); } } delay.delay_ms(delay_ms); // It is not worth the logic to check which pins need resetting, // so set all the pins back high. for col_line in &mut cols { col_line.set_high(); } // Set the row back low. row_line.set_low(); } } } #}
Full Solution
For the most modern implementations, please look at the code in the micro:bit crate.
#![no_std] #![no_main] extern crate panic_semihosting; extern crate cortex_m_rt as rt; extern crate cortex_m_semihosting as sh; extern crate microbit; use core::fmt::Write; use rt::entry; use sh::hio; use microbit::hal::delay::Delay; use microbit::hal::gpio::gpio::PIN; use microbit::hal::gpio::gpio::{PIN4, PIN5, PIN6, PIN7, PIN8, PIN9, PIN10, PIN11, PIN12, PIN13, PIN14, PIN15}; use microbit::hal::gpio::{Output, PushPull}; use microbit::hal::serial; use microbit::hal::serial::BAUD115200; use microbit::hal::prelude::*; type LED = PIN<Output<PushPull>>; const DEFAULT_DELAY_MS: u32 = 2; const LED_LAYOUT: [[(usize, usize); 5]; 5] = [ [(0, 0), (1, 3), (0, 1), (1, 4), (0, 2)], [(2, 3), (2, 4), (2, 5), (2, 6), (2, 7)], [(1, 1), (0, 8), (1, 2), (2, 8), (1, 0)], [(0, 7), (0, 6), (0, 5), (0, 4), (0, 3)], [(2, 2), (1, 6), (2, 0), (1, 5), (2, 1)], ]; /// Array of all the LEDs in the 5x5 display on the board pub struct Display { delay_ms: u32, rows: [LED; 3], cols: [LED; 9], } impl Display { /// Initializes all the user LEDs pub fn new( col1: PIN4<Output<PushPull>>, col2: PIN5<Output<PushPull>>, col3: PIN6<Output<PushPull>>, col4: PIN7<Output<PushPull>>, col5: PIN8<Output<PushPull>>, col6: PIN9<Output<PushPull>>, col7: PIN10<Output<PushPull>>, col8: PIN11<Output<PushPull>>, col9: PIN12<Output<PushPull>>, row1: PIN13<Output<PushPull>>, row2: PIN14<Output<PushPull>>, row3: PIN15<Output<PushPull>>, ) -> Self { let mut retval = Display { delay_ms: DEFAULT_DELAY_MS, rows: [row1.downgrade(), row2.downgrade(), row3.downgrade()], cols: [ col1.downgrade(), col2.downgrade(), col3.downgrade(), col4.downgrade(), col5.downgrade(), col6.downgrade(), col7.downgrade(), col8.downgrade(), col9.downgrade() ], }; // This is needed to reduce flickering on reset retval.clear(); retval } /// Clear display pub fn clear(&mut self) { for row in &mut self.rows { row.set_low(); } for col in &mut self.cols { col.set_high(); } } /// Convert 5x5 display image to 3x9 matrix image pub fn display2matrix(led_display: [[u8; 5]; 5]) -> [[u8; 9]; 3] { let mut led_matrix: [[u8; 9]; 3] = [[0; 9]; 3]; for (led_display_row, layout_row) in led_display.iter().zip(LED_LAYOUT.iter()) { for (led_display_val, layout_loc) in led_display_row.iter().zip(layout_row) { led_matrix[layout_loc.0][layout_loc.1] = *led_display_val; } } led_matrix } /// Display 5x5 display image for a given duration pub fn display(&mut self, delay: &mut Delay, led_display: [[u8; 5]; 5], duration_ms: u32) { let led_matrix = Display::display2matrix(led_display); // Calculates how long to block for // e.g. If the duration_ms is 500ms (half a second) // and self.delay_ms is 2ms (about 2ms per scan row), // each refresh takes 3rows×2ms, so we need 500ms / (3×2ms) loops. let loops = duration_ms / (self.rows.len() as u32 * self.delay_ms); for _ in 0..loops { for (row_line, led_matrix_row) in self.rows.iter_mut().zip(led_matrix.iter()) { row_line.set_high(); for (col_line, led_matrix_val) in self.cols.iter_mut().zip(led_matrix_row.iter()) { // We are keeping it simple, and not adding brightness if *led_matrix_val > 0 { col_line.set_low(); } } delay.delay_ms(self.delay_ms); for col_line in &mut self.cols { col_line.set_high(); } row_line.set_low(); } } } } #[entry] fn main() -> ! { let mut stdout = hio::hstdout().unwrap(); writeln!(stdout, "Start").unwrap(); if let Some(p) = microbit::Peripherals::take() { // Split GPIO let mut gpio = p.GPIO.split(); // Configure RX and TX pins accordingly let tx = gpio.pin24.into_push_pull_output().downgrade(); let rx = gpio.pin25.into_floating_input().downgrade(); // Configure serial communication let (mut tx, _) = serial::Serial::uart0(p.UART0, tx, rx, BAUD115200).split(); writeln!(tx, ""); writeln!(tx, "Init"); // Create delay provider let mut delay = Delay::new(p.TIMER0); // Display pins let row1 = gpio.pin13.into_push_pull_output(); let row2 = gpio.pin14.into_push_pull_output(); let row3 = gpio.pin15.into_push_pull_output(); let col1 = gpio.pin4.into_push_pull_output(); let col2 = gpio.pin5.into_push_pull_output(); let col3 = gpio.pin6.into_push_pull_output(); let col4 = gpio.pin7.into_push_pull_output(); let col5 = gpio.pin8.into_push_pull_output(); let col6 = gpio.pin9.into_push_pull_output(); let col7 = gpio.pin10.into_push_pull_output(); let col8 = gpio.pin11.into_push_pull_output(); let col9 = gpio.pin12.into_push_pull_output(); let mut leds = Display::new( col1, col2, col3, col4, col5, col6, col7, col8, col9, row1, row2, row3, ); #[allow(non_snake_case)] let letter_I = [ [0, 1, 1, 1, 0], [0, 0, 1, 0, 0], [0, 0, 1, 0, 0], [0, 0, 1, 0, 0], [0, 1, 1, 1, 0], ]; let heart = [ [0, 1, 0, 1, 0], [1, 0, 1, 0, 1], [1, 0, 0, 0, 1], [0, 1, 0, 1, 0], [0, 0, 1, 0, 0], ]; #[allow(non_snake_case)] let letter_U = [ [0, 1, 0, 1, 0], [0, 1, 0, 1, 0], [0, 1, 0, 1, 0], [0, 1, 0, 1, 0], [0, 1, 1, 1, 0], ]; writeln!(tx, "Starting!"); loop { writeln!(tx, "I <3 Rust on the micro:bit!"); leds.display(&mut delay, letter_I, 1000); leds.display(&mut delay, heart, 1000); leds.display(&mut delay, letter_U, 1000); leds.clear(); delay.delay_ms(250_u32); } } panic!("End"); }
WIP - Sensors and I²C
WIP - Interrupts
WIP - Interrupts
WIP - Real time
WIP - Creating a HAL
What's left for you to explore
We have barely scratched the surface! There's lots of stuff left for you to explore:
Multitasking
All our programs executed a single task. How could we achieve multitasking in a system with no OS, and thus no threads. There are two main approaches to multitasking: preemptive multitasking and cooperative multitasking.
In preemptive multitasking a task that's currently being executed can, at any point in time, be preempted (interrupted) by another task. On preemption, the first task will be suspended and the processor will instead execute the second task. At some point the first task will be resumed. Microcontrollers provide hardware support for preemption in the form of interrupts.
In cooperative multitasking a task that's being executed will run until it reaches a suspension point. When the processor reaches that suspension point it will stop executing the current task and instead go and execute a different task. At some point the first task will be resumed. The main difference between these two approaches to multitasking is that in cooperative multitasking yields execution control at known suspension points instead of being forcefully preempted at any point of its execution.
Direct Memory Access (DMA).
This peripheral is a kind of asynchronous memcpy
. So far our programs have
been pumping data, byte by byte, into peripherals like UART and I2C. This DMA
peripheral can be used to perform bulk transfers of data. Either from RAM to
RAM, from a peripheral, like a UART, to RAM or from RAM to a peripheral. You can
schedule a DMA transfer, like read 256 bytes from USART1 into this buffer, leave
it running in the background and then poll some register to see if it has
completed so you can do other stuff while the transfer is ongoing.
Sleeping
All our programs have been continuously polling peripherals to see if there's anything that needs to be done. However, some times there's nothing to be done! At those times, the microcontroller should "sleep".
When the processor sleeps, it stops executing instructions and this saves power.
It's almost always a good idea to save power so your microcontroller should be
sleeping as much as possible. But, how does it know when it has to wake up to
perform some action? "Interrupts" are one of the events that wake up the
microcontroller but there are others and the wfi
and wfe
are the
instructions that make the processor "sleep".
Pulse Width Modulation (PWM)
In a nutshell, PWM is turning on something and then turning it off periodically while keeping some proportion ("duty cycle") between the "on time" and the "off time". When used on a LED with a sufficiently high frequency, this can be used to dim the LED. A low duty cycle, say 10% on time and 90% off time, will make the LED very dim wheres a high duty cycle, say 90% on time and 10% off time, will make the LED much brighter (almost as if it were fully powered).
In general, PWM can be used to control how much power is given to some electric device. With proper (power) electronics between a microcontroller and an electrical motor, PWM can be used to control how much power is given to the motor thus it can be used to control its torque and speed. Then you can add an angular position sensor and you got yourself a closed loop controller that can control the position of the motor at different loads.
Digital input
We have used the microcontroller pins as digital outputs, to drive LEDs. But these pins can also be configured as digital inputs. As digital inputs, these pins can read the binary state of switches (on/off) or buttons (pressed/not pressed).
(spoilers reading the binary state of switches / buttons is not as straightforward as it sounds ;-)
Sensor fusion
The STM32F3DISCOVERY contains three motion sensors: an accelerometer, a gyroscope and a magnetometer. On their own these measure: (proper) acceleration, angular speed and (the Earth's) magnetic field. But these magnitudes can be "fused" into something more useful: a "robust" measurement of the orientation of the board. Where robust means with less measurement error than a single sensor would be capable of.
This idea of deriving more reliable data from different sources is known as sensor fusion.
Analog-to-Digital Converters (ADC)
There are a lots of digital sensors out there. You can use a protocol like I2C and SPI to read them. But analog sensors also exist! These sensors just output a voltage level that's proportional to the magnitude they are sensing.
The ADC peripheral can be use to convert that "analog" voltage level, say 1.25
Volts,into a "digital" number, say in the [0, 65535]
range, that the processor
can use in its calculations.
Digital-to-Analog Converters (DAC)
As you might expect a DAC is exactly the opposite of ADC. You can write some
digital value into a register to produce a voltage in the [0, 3.3V]
range
(assuming a 3.3V
power supply) on some "analog" pin. When this analog pin is
connected to some appropriate electronics and the register is written to at some
constant, fast rate (frequency) with the right values you can produce sounds or
even music!
Real Time Clock (RTC)
This peripheral can be used to track time in "human format". Seconds, minutes, hours, days, months and years. This peripheral handles the translation from "ticks" to these human friendly units of time. It even handles leap years and Daylight Save Time for you!
Other communication protocols
SPI, I2S, SMBUS, CAN, IrDA, Ethernet, USB, Bluetooth, etc.
Different applications use different communication protocols. User facing applications usually have an USB connector because USB is an ubiquitous protocol in PCs and smartphones. Whereas inside cars you'll find plenty of CAN "buses". Some digital sensors use SPI, others use I2C and others, SMBUS.
So where to next? There are several options:
- You could check out the examples in the
microbit
board support crate repository. All those examples work for the micro:bit board you have.
- You could try out this motion sensors demo. Details about the implementation and source code are available in this blog post.
- You could check out Real Time for The Masses. A very efficient preemptive multitasking framework that supports task prioritization and dead lock free execution.
- You could try running Rust on a different development board. The easiest way to get started is to
use the
cortex-m-quickstart
Cargo project template.
- You could check out this blog post which describes how Rust type system can prevent bugs in I/O configuration.
- You could check out my blog for miscellaneous topics about embedded development with Rust.
- You could check out the
embedded-hal
project which aims to build abstractions (traits) for all the embedded I/O functionality commonly found on microcontrollers.
- You could join the Weekly driver initiative and help us write generic drivers on top of the
embedded-hal
traits and that work for all sorts of platforms (ARM Cortex-M, AVR, MSP430, RISCV, etc.)
GDB cheatsheet
Short | Command | Action |
---|---|---|
b | break [location] | Set breakpoint at specified location. |
c | continue | Continue program being debugged. |
s | step | Step program until it reaches a different source line. |
si | stepi | Step one instruction exactly. |
p | Print value of expression EXP. | |
i lo | info locals | Local variables of current stack frame. |
la s | layout src | Displays source and command windows. |
la a | layout asm | Displays disassembly and command windows. |
tu d | tui disable | Disable TUI display mode. |
dissasmble /m | Disassemble a specified section of memory. | |
monitor reset halt | Send a command to the remote monitor. |
General troubleshooting
OpenOCD problems
can't connect to OpenOCD - "Error: open failed"
Symptoms
Upon trying to establish a new connection with the device you get an error that looks like this:
$ openocd -f (..)
(..)
Error: open failed
in procedure 'init'
in procedure 'ocd_bouncer'
Cause + Fix
- All: The device is not (properly) connected. Check the USB connection using
lsusb
or the Device Manager. - Linux: You may not have enough permission to open the device. Try again with
sudo
. If that works, you can use these instructions to make OpenOCD work without root privilege. - Windows: You are probably missing the USB drivers.
can't connect to OpenOCD - "Polling again in X00ms"
Symptoms
Upon trying to establish a new connection with the device you get an error that looks like this:
$ openocd -f (..)
(..)
Error: jtag status contains invalid mode value - communication failure
Polling target stm32f3x.cpu failed, trying to reexamine
Examination failed, GDB will be halted. Polling again in 100ms
Info : Previous state query failed, trying to reconnect
Error: jtag status contains invalid mode value - communication failure
Polling target stm32f3x.cpu failed, trying to reexamine
Examination failed, GDB will be halted. Polling again in 300ms
Info : Previous state query failed, trying to reconnect
Cause
The microcontroller may have get stuck in some tight infinite loop or it may be continuously raising an exception, e.g. the exception handler is raising an exception.
Fix
- Close OpenOCD, if running
- Press and hold the reset (black) button
- Launch the OpenOCD command
- Now, release the reset button
OpenOCD connection lost - "Polling again in X00ms"
Symptoms
A running OpenOCD session suddenly errors with:
# openocd -f (..)
Error: jtag status contains invalid mode value - communication failure
Polling target stm32f3x.cpu failed, trying to reexamine
Examination failed, GDB will be halted. Polling again in 100ms
Info : Previous state query failed, trying to reconnect
Error: jtag status contains invalid mode value - communication failure
Polling target stm32f3x.cpu failed, trying to reexamine
Examination failed, GDB will be halted. Polling again in 300ms
Info : Previous state query failed, trying to reconnect
Cause
The USB connection was lost.
Fix
- Close OpenOCD
- Disconnect and re-connect the USB cable.
- Re-launch OpenOCD
Cargo problems
"can't find crate for core
"
Symptoms
Compiling volatile-register v0.1.2
Compiling rlibc v1.0.0
Compiling r0 v0.1.0
error[E0463]: can't find crate for `core`
error: aborting due to previous error
error[E0463]: can't find crate for `core`
error: aborting due to previous error
error[E0463]: can't find crate for `core`
error: aborting due to previous error
Build failed, waiting for other jobs to finish...
Build failed, waiting for other jobs to finish...
error: Could not compile `r0`.
To learn more, run the command again with --verbose.
Cause
You are using a toolchain older than nightly-2018-04-08
and forgot to call rustup target add thumbv7m-none-eabi
.
Fix
Update your nightly and install the thumbv7em-none-eabihf
target.
$ rustup update nightly
$ rustup target add thumbv7em-none-eabihf
Build problems
error: language item required, but not found: 'eh_personality'
Cause
The eh_personality
language item is used to implement stack unwinding in case a panic occurs.
Fix
You need to use the correct target
by using --target thumbv6m-none-eabi
or modifying .cargo/config
error: ld: cannot open linker script file memory.x: No such file or directory
Cause
A memory.x file is needed, this specifies memory layout.
Fix
Either ask the board support crate maintainer to add memory.x to their crate, or add a memory.x file to your project.