My motivation for this project was very simple: I wanted to blink some LEDs at "some rate(tm)" using entirely rust. I also did not want to use any of the embedded-std libs that were available, because I wanted to really get a feel for bit-banging the registers (including documenting where I grabbed the info from).
From this original goal, I also decided to try to note all of the items that are usually glossed over- including where to find the memory map of your chip, etc. This should make the general procedure as chip-agnostic as possible because the following isn't a tutorial for a given chip, just an account of what I did to get it working.
Defining literally everything myself is a bit unreasonable, especially when awesome projects like stm32-rs exist. This project use a community-built collection of patches to the basic SVD files, to build a peripheral access crate. This gives us a foundation of the registers and fields available on our given chip. The PAC leverages svd2rust which provides a great API for safely using the PAC.
Before we can get started we need to make sure we have a target-toolchain for our chip as part of our buildstystem
(cargo
) to support cross-compiling. To identify the toolchain I first checked which ARM family
the STM32F107
is part of, which is Cortex-M3
. I then deferred to the Cortex M Quickstart
documentation which had a lookup table which showed that I wanted the thumbv7m-none-eabi
target. There
are certainly more ways to establish this, but it was already written down there so I stopped looking.
$ rustup target add thumbv7m-none-eabi
At this point, you can verify that your target was installed with rustup by using rustup show
:
installed targets for active toolchain
--------------------------------------
aarch64-unknown-linux-gnu
thumbv7m-none-eabi
x86_64-unknown-linux-gnu
You may not have all of the toolchains shown above, but make sure the one you wished to install is now available.
This gives you a starting point to be able to compile an ELF
for your target architecture, however
a microcontroller will need a standard binary. This is easily accomplished with objcopy
which we can
install cross versions of simply using some great utilities:
$ cargo install cargo-binutils
$ rustup component add llvm-tools-preview
This will give us the environment we need to get started. I'm not going to explain these utilities in depth here, but I would spend some time looking into them if you're curious.
For this code, we won't have any standard-library which means we're going to have to roll our own
versions of the utility functions we're used to having (and in this example, specifically delay_us
).
In our main file, we'll have to define multiple items to make it possibile for us to run on our embedded target:
#![no_std]
- std libs are heavy, and our toolchain doesn't provide them
#![panic_handler]
- This is a relic of not having a standard lib, and we must define how we handle panics
- In this example, I do nothing. Hopefully we don't panic!
#![no_main]
- Due to this, we also need to mark our
main()
as#[no_mangle]
so the entrypoint can still be found
- Due to this, we also need to mark our
As you can tell, it very quickly goes into the weeds that embedded usually does (not that we're particularily surprised).
The target application, as said before is just to blink an LED at some rate on this development kit I have access to. Easy enough.
On the Cortex-M3
, all of the peripheral clocks are disabled by default and they must be enabled. This means for our application
we must identify which timer and which GPIO we want to use so we can adequately turn them on.
From the devkit's user manual1 one of the LEDs is on PD13
which means we must turn on the clock for bank D. While we're here,
it makes sense to pick a timer to use. I arbitrarily picked TIM2
which could have been anything.
From the processor-specific datasheet2, I checked which Advanced Peripheral Bus (APB) the items I want are on (Block diagram in
section 2.3). Bank D for the GPIO is on APB2
and TIM2
is on APB1
. At this point, it's time to move into the reference manual3
for register definition.
This is an extremely tedious part if you don't know what you're looking for, so I'm going to cut to the chase on most of it. A lot of learning how to do this is going to come from reading circles in datasheets and reference manuals, but this should give you a reasonable idea of what you're looking for. All of this is done in the reference manual3 for the chip family.
Since we know we need to enable the peripheral clocks, we'll jump straight to the Reset and Clock Control (RCC), section 7.3. From there,
we will continue onto the registers which actually enable the clocks for our APB's: RCC_APB1ENR
and RCC_APB2ENR
. Looking at the registers
we see that TIM2
is bit 0 and IOPDEN
is bit 5 of their respective registers. Since we are using the PAC crate the specifics of "which bit
do we need to set" matters a lot less, but while we're here it makes sense to make sure there are no side-effects or anything else we need
to do.
Because there isn't, we can very easily (using the PAC) enable this:
// identify TIM2 is on the APB1 from the 107 RM S2.3 (p13)
peripherals.RCC.apb1enr.write(|w| w.tim2en().bit(true));
// enable the clock for IO port D (as our LED is on GPIO D-13)
peripherals.RCC.apb2enr.write(|w| w.iopden().bit(true));
Note: I didn't describe setting up the stm32-rs
library etc, that is better explained in their documentation.
Jumping ahead a bit, we'll also need to setup our GPIO to be an output, and finally drive it so we can turn our LED on. This can be found
in section 9, specifically 9.2 for the register map. We first register we see is GPIOx_CRL
- if you haven't seen this syntax before it
can be a bit confusing, but the point is "this register map repeats itself for each bank (in this case A-E)". In the chip datasheet2
you can check the memory-map to see exactly where the "base address" of each bank is- but we won't need that here due to the PAC.
The first register gives us the mode and configuration bits for GPIO's 0-7, and since we need 13 we'll move onto the next register, GPIOx_CRH
.
Here we see the fields we need, MODE13[1:0]
and CNF13[1:0]
. Using the information given below the table, knowing we want to drive an LED, we
will use output mode and general-purpose output push-pull. If we were doing this without the PAC, we would need to know exactly which bits to set
and where to set them but instead we can textually describe it:
// LED is on GPIO D13, set it to a push-pull output
peripherals.GPIOD.crh.write(|w| {
w.mode13().output();
w.cnf13().push_pull()
});
At this point, hopefully it's more obvious what you're looking for, but at the end of the day there is no way around it: it's a lot of reading.
On an embedded system, we need to describe a bit of extra information for the linker to be able to actually put together our binary. We will
define a memory.x
file which will define the locations of our flash (memory) and our ram on the chip. Going back into our trusty datasheet2
we instead seek to the memory map this time (which can be found in section 4) and look for the two fields we need which will give us the start
and end addresses. For my chip, I find the following information:
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 256K
RAM : ORIGIN = 0x20000000, LENGTH = 64K
}
At this point, for convenience we'll create a file .cargo/config
which will contain the following:
[target.thumbv7m-none-eabi]
runner = 'gdb-multiarch'
rustflags = [
"-C", "link-arg=-Tlink.x",
]
[build]
target = "thumbv7m-none-eabi"
As you can easily recognize from earlier, this gives cargo extra information on our toolchain, including our new files. The [build]
section's
target
allows us to avoid specifying --target thumbv7m-none-eabi
every time we invoke cargo
, but is technically optional although a nice to have.
At this point, after doing a build (if everything went well), we can look in the target/thumbv7m-none-eabi/release/
directory and find our binary! Unfortunately,
as I alluded to during the toolchain setup, this gave us an ELF
file and we need a binary. Lucky for us, we're ready for that!
We can use the combination of our llvm-tools-preview
and cargo-binutils
to get a cross-ready objcopy
, neatly aliased through cargo
.
cargo objcopy --release -- -O binary target/thumbv7m-none-eabi/release/stm32f107.bin
This will do a build, and then do an objcopy
to the specified location. The name is arbitrary, and I just didn't name it very creatively.
At this point, it becomes extremely setup-dependent, so I won't go in too far, however a few tips if you have a Segger J-LINK:
- You'll want to install the
JLinkExe
application (yes, it's named that on linux too) - When you run the application, make sure your debugger is already plugged in, it'll autodetect it over usb
- Figure out how it's attached to your processor. On my devkit it was via SWD and not JTAG.
- Connect to your CPU with the
connect
command (it'll give you an interactive prompt looking for more information to do this) - Use the
loadbin
command to flash your binary, ther
command to reset the chip, and thego
command to start your processor again!
At this point you may want other generally useful debugging commands like mem32
to peek at registers, w4
to write to them, and so on. This
can be a very powerful debugging tool to make sure that you're actually doing what you think you are. For example, if you forget to setup the
peripheral clocks, a write to the GPIO bank will just "not work". A hard but fair reminder to turn them on.
Good luck!