Manuals, schematics, diagrams, and instructional material
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

64 KiB

Rust Embedded BluePill Workshop Notes

Resources

Part # of microcontroller: STM32F103C8T6

https://www.st.com/en/microcontrollers-microprocessors/stm32f103c8.html

https://github.com/rust-embedded/awesome-embedded-rust

Need openocd or cargo-flash~/~cargo-embed Need arm-none-eabi-gdb rustup target install thumbv7m-none-eabi

For cargo-flash, the development libraries for libusb 1.0 are required: apt install libusb-1.0-0-dev cargo install cargo-flash cargo install cargo-embed

Instead of cargo run, use cargo flash: cargo flash --release --chip STM32F103C8 or cargo embed --release

For the case of cargo embed, the chip needs to be set in the Embed.toml file at the project root.

Hardware Mod for USB

To use the Blue Pill as a USB device, a hardware mod may be required due to a design flaw that persists on many of these inexpensive boards. USB requires a pull-up resistor on the D+ line of a specific value; while some host adapters are tolerant of weaker pull-ups, others are not and will fail to enumerate the device. Fortunately the fix isn't hard.

Check the resistor labeled "R10" on the back side of the board. The correct value is 1.5kOhm, which should be marked "152" on the tiny resistor. Common incorrect values are 10kOhm (marked "01C" or "103") or 4.7kOhm (marked "472"). If you have an incorrect value, you might try connecting it via different USB ports and hubs to see if any work as-is, but if not, you'll have to modify the board.

These are 0603 size surface mount resistors, which are extremely inexpensive from standard component distributors like Mouser, Digikey, etc., but they can require some skill and patience to replace with tweezers and a soldering iron. If you are not up to that, you can hook a small through-hole resistor between PA12 and +3.3V pins, which places it in parallel with the one at R10. See https://www.allaboutcircuits.com/tools/parallel-resistance-calculator/ for a calculator to help picking resistors; depending on what values you have available and what R10 is, you may need more than one.

If R10 is 10kOhm, 1.8kOhm would be close to ideal, but you are more likely to find a 1.5kOhm and it is likely to work. If R10 is 4.7kOhm, a 2.3kOhm would be close to ideal, but you are more likely to find a 2.2kOhm or 2.7kOhm, and either would be likely to work.

If USB still does not work, it is sometimes the case that the USB connector is not soldered properly, and reflowing the USB solder joints can get it working.

Built-in Serial Bootloader

This python-based tool can be used to flash code onto the chip when an STLink is not available: https://github.com/jsnyder/stm32loader

To put the chip in serial bootloader mode, set the BOOT0 jumper to 1 and BOOT1 to 0. After completing the flash load, put both jumpers back to position 0.

Interesting Crates

Development tools

  • cargo-flash : A rust-based replacement for (parts of) openocd
  • cargo-embed : cargo-flash + multi-channel serial debug communication
  • rtt_target : target runtime support for cargo-embed communication

Base device support

  • bare-metal : Abstractions common to bare-metal systems
  • vcell : Cell with volatile read/write access
  • cortex-m : Low-level access to features common to Cortex M processors
  • cortex-m-rt : Startup code and minimal runtime for Cortex M
  • r0 : crt0 implementation in Rust
  • stm32f1 : Peripheral Access Crate for STM32F1xxx microcontrollers
  • nb : Minimal/re-usable non-blocking IO layer traits
  • embedded-hal : Hardware Abstraction Layer trait definitions
  • stm32f1xx-hal: HAL impls for stm32f1-based microcontrollers

Alternates to above:

  • stm32ral : simpler alternative to svd2rust-derived crates
  • stm32hal : Hardware Abstraction Layer on top of stm32ral

RTIC (was RTFM) task scheduling framework:

  • rtc-core
  • cortex-m-rtic

USB-related Crates

  • usb-device : generic USB device support crate
  • stm32-usbd : low-level device-specific USB support crate (for usb-device framework)
  • usbd-serial : class support for USB serial classes (for usb-device framework)
  • usbd-hid-device : class support for USB HID devices (for usb-device framework)
  • usbd-midi

OLED Display Crates

  • embedded-graphics
  • ssd1306
  • tinybmp
  • tinytga

Other Driver-related Crates

  • rotary-encoder-hal : Uses embedded-hal traits to handle a rotary encoder
  • switch-hal : Friendly abstraction over pins that sense or control switches
  • mpu6050 : A driver for the MPU6050 6-axis motion tracking sensor

No Standard Library Helper Crates

  • heapless : data structures that don't require dynamic allocation
  • m : pure rust libm-like math library
  • micromath : another pure rust arithmetic/vector library
  • byteorder : encode/decode numbers in big/little endian form
  • bitvec : bit-level access with standard Rust idioms
  • generic-array : generic fixed-size arrays
  • null_terminated : library for working with null-terminated slices and strings
  • oorandom : simple PRNG for non-crypto things
  • scroll : reading/writing values from containers (e.g. byte buffers)
  • serde : traits describing how to serialize/deserialize different formats to rust objects
  • postcard : embedded message framing using serde
  • framed : another message framing crate
  • bbqueue : spsc lock-free queue, primarily for embedded DMA usage

Topics to Cover

Basic Microcontroller Architecture

CPU

You probably know what a CPU is, but there are some details that are significant to embedded programming.

By itself, a CPU is inert; it needs some things around it to function.

  1. clock signal
  2. reset signal
  3. bus interface(s)

Reset may seem trivial, but it is actually critical; digital circuits as complex as CPUs have many stateful components, and in order to work correctly they have to start at a correct initial state. That is the job of the power-on reset signal.

The clock signal is the heartbeat of stateful digital circuits. Everything transitions at a clock edge, inputs are captured at a clock edge, outputs are asserted at a clock edge. Most CPUs are designed entirely with synchronous logic today and therefore nothing (at the level of logic, anyway) happens between clock cycles.

The ARM Cortex-M3 processor in the STM32F103 has 3 separate bus interfaces that it uses for specific purposes, but in this system they use the same address map, so we will treat them as one interface for now.

The bus interface is critical because a CPU can do very little without instructions, and when it comes out of reset a CPU has none. The first thing it does is to use its bus interface to fetch the top-of-stack address from address 0x00000000, then the location of its first instruction to execute from the next location, 0x00000004. That location is called the "Reset Vector"; it informs the CPU the address of the instructions it should execute when it comes out of reset.

The CPU itself follows instructions from the ARM Thumb-1 and Thumb-2 instruction sets; these are a more compact encoding of classic ARM instructions. It has relatively fast integer multiply and divide instructions, but no floating point unit!

The CPU has an exception-handling mechanism; when an exception condition arises, the CPU switches to "handler mode" and uses the exception vector table to determine what address it should execute from in order to handle the type of exception it received. It has a separate stack pointer for running in handler mode.

Bus

Most microcontrollers today don't just have a single bus, but a complex hierarchy of different types of buses. But from the CPU's perspective, it appears as a unified address space. Sometimes the details of the bus structure matter, but when starting the simplified view is fine.

The function of the bus is to translate requests from "bus masters" such as the CPU into operations on peripherals that are also connected to the bus. Peripherals are mapped into different regions of the address space, which on a 32-bit addressed bus like the one we are dealing with, is 4GB in size.

Different buses have different protocols for how requests are made and responded to, but generally a master asserts an address and some information about the request, such as whether it is a read request or write request, and whether it is for the full data width of the bus or some subset of it. If the address corresponds to a region mapped to a peripheral, that peripheral is given an enable signal and reads the details of the request from the bus, performs the operation, and signals completion.

Real buses, including the ones on our microcontroller, are far more complex in their protocols, but this description should be good enough to start with.

RAM

Memory is just a peripheral on the bus that responds to a write operation by storing the data from the bus at the address (suitably translated into its internal addressing scheme) that was on the bus. A subsequent read operation with the same address asserted on the bus will cause the memory to write the same data back to the bus.

In other words, the range of addresses mapped to RAM act just like you would expect memory to act.

Flash

Flash memory is a form of electrically-erasable read-only memory. It is typically used to store your program's instructions and data. The Flash peripheral has two regions mapped into memory; one region acts as read-only memory, and another region presents a set of read/write control registers that you can manipulate to erase, re-program, and adjust the access latency of the peripheral.

Peripherals

Peripherals are pieces of hardware, part of the microcontroller chip, that can be controlled by the CPU (or any bus master on their bus, in fact) by manipulating a set of read/write control regions that are mapped into the bus address space. These can include I/O peripherals, co-processors, or different kinds of memories.

Clock Tree

Just as the CPU needs a clock to function, all of the peripherals need clocks to function as well, and they often need to be running at different clock rates than the CPU or each other.

Clocks can either be generated by oscillating circuits internal to the microcontroller or from external oscillating circuits. There is generally a dedicated peripheral for selecting the source oscillator for core system clocks, controlling clock manipulation circuitry such as dividers, multipliers, or even fancy Phase-Locked-Loop-based clock frequency synthesizers.

Many peripheral register sets will have their own clock logic to pick among several system or external sources for their clocking as well, and may even have their own sets of clock dividers that you can choose from.

Setting up the microcontroller's clock tree properly for the peripherals you need to use and the CPU operating frequency you want can be surprisingly complex!

Power Management

Because many microcontrollers end up running from batteries, power consumption can be very important, and most have fairly fine-grained power control over parts of the chip. The STM32 we are using is fortunately configured out of reset in a useful configuration, so you should only need to investigate these controls if you want to reduce power consumption.

Pin Multiplexing

Most microcontrollers have far more I/O peripheral signals than they have pins. This means that most pins on the external package can serve more than one peripheral depending on how you configure things. Sometimes, a peripheral can choose between multiple pins for a signal, so that you can select different sets of pins depending on which other peripherals are in use.

Like clock trees, getting the right pin multiplexing configuration can be tricky!

I/O & Communication Standards

Voltage Levels

While digital logic has two states, the voltage level on microcontroller pins varies continuously. There are important things to know:

  1. What is the nominal voltage level of the "high" signal?
  2. What is the nominal voltage level of the "low" signal?
  3. What is the lowest voltage that is reliably considered "high" by an input pin?
  4. What is the highest voltage that is reliably considered "low" by an input pin?
  5. What is the lowest voltage that a will be driven "high" by an output pin?
  6. What is the highest voltage that will be driven "low" by an output pin?
  7. What is the highest voltage that can be tolerated without damage?
  8. What is the lowest voltage that can be tolerated without damage?

You can usually consider "high" values to be all around the source voltage of the controller, and "low" values to be near 0. You should never expose a pin on a microcontroller to a higher voltage than the source voltage powering the microcontroller unless you are certain that it is tolerant of higher voltages.

The STM32F103 has a 3.3V source voltage, so its nominal logic "high" level is 3.3V and the "low" level is 0V. Unusually, many (but not all!) of its I/O pins are 5V tolerant. They can act as inputs to a 5V signal without damage, but they cannot drive their outputs to 5V; they can only indicate logic "high" to a 5V device that recognizes 3.3V as a logic "high" level. This is true of 5V TTL logic, but not of 5V CMOS logic. A 5V CMOS logic input needs at least 3.7V to sense a "high" level, while a 5V TTL input only requires 2V to see a "high" level.

This is not very important if you keep all devices at the same nominal logic level, but when you need to interface devices at different levels the details will determine which strategies will be available.

Push/Pull vs. Open Collector

When the input of one device is connected to the output of another, the output usually either pushes current to drive the voltage level high, or it pulls (or sinks) current to drive the voltage low. In this configuration, only one device can drive the wire; anything else connected to the wire must passively sense the voltage level.

It is possible to allow multiple devices to drive a wire if they instead work in "open collector" mode. An open collector output will never push current to the wire, it only sinks current to drive the wire low. The high state of the wire is passively determined by "pulling up" the wire through a high-value resistor to the voltage source. Any number of devices can pull the wire low simultaneously without damaging one another.

Current Limits/Requirements

Microcontrollers are usually not designed to supply or sink a lot of current to other devices. There is usually both a per-pin limit and a total limit for all pins. Exceeding this limit can damage your microcontroller, so if you want to use a microcontroller to make something happen (drive a motor, turn on a light, etc.) be sure to use appropriate current-limiting resistors and employ some sort of external circuitry to interface with things that require a lot of power.

Plenty of cheap add-on boards are available that have this kind of circuitry in place for driving various kinds of lights, motors, relays, etc. if you don't feel comfortable with the calculations to design it yourself yet.

Single-Ended vs. Differential

A signal is called "single-ended" if the "high" and "low" voltage levels are determined with respect to a common 0V ground level between endpoints. This is how most low-speed signals on a circuit board work, and how most of the communication protocols supported by peripherals on a STM32F103 work.

Many newer and higher-speed communication protocols use a different technique called "differential" signaling; this uses a pair of driven wires rather than a single driven wire to determine a single signal, but it is much more robust against certain kinds of interference. It works by always driving one of the wires to the opposite level of the other, and measuring the output level by determining which of the wires at the receiver has a higher voltage than the other.

Communication standards that use differential signaling that you may be familiar with are USB, DVI/HDMI, Ethernet, and CAN. The STM32F103 supports both USB and CAN, although you don't need to understand all the signal-level details of those protocols to use them!

Synchronous vs. Asynchronous

A communication signal is "synchronous" if there is a shared clock signal that the endpoints use to sample the data signal. A communication signal is "asynchronous" if there is no clock, and timing has to be managed independently by sender and receiver according to a pre-arranged data rate.

The most common asynchronous signal you are likely to use is the UART-based serial signal. UART stands for "Universal Asynchronous Receiver/Transmitter". Both ends have to be configured for the same transmission rate, which is measured in "baud" for historical reasons, but this corresponds to bits per second most of the time. Successful communication over a UART link requires both ends to have their sampling clocks running at close enough to the same rate that a full byte can be received before enough drift occurs that data is sampled at the wrong point by the receiver.

Both common protocols for linking a microcontroller to external peripherals, I2C and SPI, are synchronous. One device provides the clock, and both ensure that the data signal is driven or is sampled correctly relative to the clock signal edges.

Peripheral Types

GPIO

General Purpose Input/Output

Most of the pins on your microcontroller can be used to do input or output under direct software control. The GPIO peripheral registers allow you to configure whether each pin is to be an input or ouput and whether it is to be under software control or the control of a peripheral.

When under software control, your code can directly read or change the state of a single pin or set of pins in a bank. While this is very flexible, it is often faster and more reliable to take advantage of the I/O peripherals to do complex protocol exchanges. Software-controlled GPIO is often used for scanning a key matrix, lighting a few individual LEDs, or other simple tasks that depend on direct I/O connections rather than a serial communication line to an external peripheral chip.

When you use software-controlled GPIO signals to implement a communication protocol instead of using a peripheral dedicated to that protocol, it is often called "bit-banging" that protocol.

Timer

Uses a clock to increment or decrement a register on every clock edge. Beyond that common principle, operation and uses can vary a lot!

A basic use for a timer is to create a periodic interrupt for the CPU. All Cortex M3 CPUs have a SysTick timer that can be used as a one-shot or periodic exception that occurs when the timer reaches 0.

Some timers can be used in capture mode, where the register counts continuously and the current value is copied to a capture register when an external (or sometimes internal from another peripheral) signal change occurs. This can be used to measure the rate that some external event occurs, for example.

Some timers can be used in compare mode, where the register counts continuously and causes some action to happen when the timer reaches the value stored on the compare register. There can be multiple compare registers, and different kinds of actions. A common case is for one compare register to toggle the state of a pin, and the other to switch the count direction from up to down. This has the effect of creating a pulse-width modulated signal on the pin.

A special kind of timer called a "watchdog" can be used for production systems to trigger a system reset if your code goes into a fault condition or otherwise becomes unresponsive. To keep this reset from happening, you must "feed" or "pet" the watchdog timer every cycle before it expires.

By arranging for a timer to send a signal to another peripheral instead of an external pin, you can schedule periodic sampling of an analog-to-digital converter, or periodic transfer of a buffer to a communication peripheral by a DMA peripheral.

Clever use of timers with peripherals can take a great deal of load and complexity out of your software and ensure that timing-critical tasks are carried out more precisely than software is easily capable of.

I2C

Inter-Integrated Circuit

Protocol overview: https://i2c.info/

This is a 2-wire synchronous serial bus protocol. The two signals are named:

  • SCL - Clock (Up to 100kHz in slow mode, up to 400kHz in fast mode)
  • SDA - Data (address and data multiplexed)

It is a multi-master bus protocol; the STM32F103 can function as master or slave. Devices on the bus use open-collector I/O, pulled-up externally to 3.3V. Each device has a unique address, either 7 or 10 bits depending on the mode, but we'll only worry about 7-bit mode for now. Data is sampled on the rising edge of the clock.

A master-mode device initiates a transaction by pulling the data line low for one bit period while leaving the clock line high; this is called the "start" signal.

It then clocks out 7 bits of address, followed by a 1 to indicate a write transaction or a 0 to indicate a read transaction.

After clocking out the 8th bit, it releases the bus and cycles the clock one more time. This is the "ack" cycle, and the receiver pulls the data line low during this clock cycle as an acknowledgment. This "ack" cycle is repeated after every byte of data is transmitted by either party, and the other is responsible for doing the acknowledgment.

If a write transaction was indicated, the master device can continue clocking out bytes, waiting for an "ack" after each one, until it is finished with the transaction. If a read transaction was indicated, the slave device takes over driving the data line while the master cycles the clock and acknowledges the slave bytes. The master can end the transaction by leaving the data line high during the "ack" cycle, which signals "nack".

At the end of a transaction, the master signals that the bus is free by letting the data line transition from low to high while the clock line is high. This makes the "stop" symbol.

Sometimes a devices will require the master to write a value to the slave, then immediately perform a read again. Rather than sending a "stop" followed by a "start", the master can just send "start"; this is known as "repeated start". It still has to follow with the slave address, this time indicating a write transaction. The write-followed-by-read pair is often used when a slave devices has multiple internal registers to read from. The write selects the register, and the read reads its value.

The I2C peripheral also supports the closely related SMBus protocol, which is used on PC motherboards for talking to memory sticks, batteries, temperature sensors, etc.

SPI

Serial Peripheral Interface

This is another synchronous serial bus protocol with the capability to be full-duplex. It is generally a single-master bus, and each slave requires a dedicated chip select signal. There are too many variations to go into; this is just the basic operation.

Common signals:

  • SCLK - Clock (as fast as both devices and the board layout can support)
  • MISO - Master In, Slave Out Data
  • MOSI - Master Out, Slave In Data
  • CS - Chip Select (one per slave, usually active-low)

The master is in charge of driving the clock. It may clock out one bit of data on MOSI every clock cycle and clock in one bit of data every clock cycle from MISO.

If a slave device's chip select is inactive, it will ignore all other signals. When active, it may clock out one bit of data for every clock cycle on MOSI and clock in one bit of data from MISO.

Depending on the master and slave, it is possible for both to stream bits out continuously on every clock cycle indefinitely without any start/stop bits, parity bits, etc. and without any particular byte or word divisions. In practice, however, both tend to work in chunks of at least 8 bits and have some sort of device-specific interaction protocol that you will have to read the slave device data sheet to determine.

Because of its ability to continuously shift out bits at a precise rate, the SPI peripheral is often put to off-brand use as an arbitrary digital signal generator on the MOSI line.

DMA

Direct Memory Access

A DMA peripheral is one of the few devices on a bus that can act as a bus master and initiate bus transactions. It is typically used to arrange for a transfer to occur from one bus address to another without the CPU having to be involved.

DMA operations are programmed separately into one of the available DMA channels. The channel gets a source address, destination address, and a transfer size. You can usually arrange for either or both of the addresses to increment by some value after a transfer completes, and sometimes you can arrange for addresses to wrap back to the starting value in order to implement circular buffer operations.

DMA transfers are initiated by a signal called a DMA request, which usually comes from a peripheral, although a control bit can be set by software to manually initiate one. The signal may come from a timer, a communication port, or an analog-to-digital converter; it is up to you to enable the correct signal linkage for the peripheral you want to use DMA with.

The DMA peripheral can signal to software via interrupt at different stages of its transfer operation, in case you need to take some action at those times to ensure that whatever process DMA is enabling continues to run smoothly.

ADC

Analog to Digital Conversion

This translates a voltage level between 0V and a reference voltage into a digital value, i.e. a number. It has 12 bits of precision, meaning that 4096 discrete values can be reported between 0V and the reference voltage. With a 3.3V reference, that means a measurement precision of under 1 millivolt. A conversion takes a minimum of 1 microsecond to complete, so there is a theoretical maximum sample rate of one million per second.

Conversion channels can be set to sample continuously (re-triggering upon completion) or they can be triggered periodically by a timer signal. Completion of conversion (on some channels, at least) can trigger a DMA transfer. These can be used to collect a circular buffer of captured samples in memory at a precise sample rate.

One of the ADC channels (16) is connected to an internal temperature sensor, and another (17) is connected to an internal voltage reference.

There are a wide variety of analog sensors that can be read with an ADC, and they can also be used for user interface devices. A common use is to adjust some parameter by reading the voltage from the wiper contact of a potentiometer, which can form the basis of a game paddle or joystick axis. You can also read a multi-button control strip by clever use of resistor voltage dividers that get switched in and out of circuit by pressing buttons.

USART

Universal Synchronous/Asynchronous Receiver/Transmitter

In asynchronous mode, this can be used as a familiar serial port that you can connect to your computer via a serial-to-USB adapter. You can't connect directly to an old RS232 serial port, however–although the signals follow the same pattern, RS232 uses different voltage levels and could damage your microcontroller!

The baud rate generator can run at up to 4.5Mbit/s, and you can select 8 or 9 bit word length (i.e. 0 or 1 parity bits) and 1 or 2 stop bits.

Additional serial protocols supported by this peripheral include LIN (an automotive bus), IrDA SIR (infrared remote control), and Smartcard emulation.

USB

Universal Serial Bus

The USB peripheral supports USB 2.0 Full Speed, which can operate at up to 12Mbit/s. It functions only as a device (rather than host or on-the-go modes) and supports up to 8 endpoints.

The protocol is too complex to go into much detail about, but Rust libraries exist to help you create HID (keyboards, mice, game controllers), serial, or MIDI endpoints.

CAN

Controller-Area Network

CAN is a serial bus networking protocol that supports a potentially large number of endpoints on a single twisted-pair differential bus. It has a priority-based bus arbitration scheme and networks can be engineered to meet communication latency and reliability goals.

Use of this automotive/industrial control protocol would require an external transceiver device, and it cannot be used concurrently with USB due to sharing a SRAM buffer, so we will not describe it further. But you may want to explore it if you are interested in how devices within your car communicate, or if you want to create a wired home automation network with numerous communicating sensors and control surfaces.

Peripheral Access Crate API Basics

SVD-covered registers and fields: https://stm32.agg.io/rs/STM32F103.html

Crate top-level items

The whole set of peripherals is managed by a singleton object of type Peripherals in the base namespace of the crate. You take ownership of this object via Peripherals::take(); this returns Option<Peripherals> because it can only be performed once and will subsequently return None.

The Peripherals struct will have a single field for each peripheral that was described in the SVD file from which the crate was built, with the same name. Each of these peripheral fields has a type by the same name (e.g. the GPIOA field is of type GPIOA) defined at the top level of the crate. This is a proxy object; essentially it guards ownership of a method that returns the pointer to the base address of the memory-mapped registers for the peripheral. It looks like:

pub const fn ptr() -> *const RegisterBlock

Each proxy object also has a Deref impl for that same RegisterBlock, which is defined inside a sub-module of the appropriately-cased name of the field/type. So if you have an object p: Peripherals the field p.GPIOA will be a proxy object of type GPIOA that will dereference to gpioa::RegisterBlock, which is where the individual registers are described. If you want to access the field crl of gpioa::RegisterBlock, you can do it via p.GPIOA.crl because of the Deref<gpioa::RegisterBlock> impl on the GPIOA proxy.

Because Peripherals is a zero-sized type that exists largely to control access to constant functions, it ends up pretty much evaporating once compiler optimizations are turned on. Although it can be a little awkward to trace through the auto-generated documentation due to the indirection, it's not really necessary to use the crate-level documentation since it all follows an extremely regular pattern based on the SVD file, which follows the programmer's reference manuals from the manufacturer.

For convenience, the peripherals from the CPU itself are re-exported under the structure CorePeripherals; this has the same style API as Peripherals (and in fact is named Peripherals in the cortex_m crate).

Peripheral sub-modules

Each peripheral has its own sub-module in the crate, named as a lowercase version of the field name and proxy object type that it has in the top-level Peripherals object.

The most important object in this sub-module is the RegisterBlock object, which plays the same role that Peripherals did at the top level. It is a struct with one field per register; these fields are named as an all-lowercase version of the register name from the SVD file and they have a type that is an all-uppercase version of the name. For example, on the STM32F103, the rcc::RegisterBlock struct has a field named cfgr of type CFGR. So if you have a value p: Peripherals and use the expression p.RCC.cfgr you will get the object of type CFGR that can be used to access the CFGR register of the RCC peripheral.

A major difference is that rather than being zero-sized, it is actually allocated at the region of the address space where the peripheral registers are memory-mapped, so its fields are at the locations in address space corresponding to the peripheral control registers they are named after. It does not take away from usable RAM or Flash space.

Each field is defined as a single-member structure holding a vcell::VolatileCell<T> where T is the register width, which is u32 on the STM32 family. The vcell crate's VolatileCell structure is a transparent wrapper around the standard Rust UnsafeCell that provides get and set access methods that do ptr::read_volatile() and ptr::write_volatile() operations to ensure that the compiler doesn't optimize them away or re-order them.

Each register also has a sub-module (with the same name as the register's field name) under the peripheral sub-module, where proxy objects for individual field access are defined, often along with enum definitions for the values that are valid for each field. Whether they exist or not depends on whether they were present in the SVD file. For example, the CFGR register of the RCC peripheral mentioned earlier has the sub-module rcc::cfgr where those items are found.

Register sub-modules

Each register sub-module is organized around two structs, R and W. A register with any readable fields will have R, and with any writable fields it will have W. Both have a single field called bits that holds an integer of appropriate width, such as u32, to match the type T used for the vcell::VolatileCell<T> mapped to the register itself in the parent module. You can think of an R as a copy of the register's contents from which you can extract individual fields, and a W as a staging value that you can modify individual fields of before copying them all-at-once into the register.

The first-level methods defined in the register sub-module are actually defined on the field access struct from the peripheral sub-module; e.g. for the STM32F103's rcc::cfgr sub-module, methods are defined on the rcc::CFGR struct that holds the vcell::VolatileCell<u32> for the register. The basic methods defined are read, write, modify, reset, and reset_value. These methods do their work by giving controlled access to R and W values and using those to directly interact with the register cell. The availability of all the methods depends on the presence of readable and writable fields in the register.

The methods look like this:

  • fn read(&self) -> R
  • fn reset_value(&self) -> T where T is the integer type of the underlying vcell::VolatileCell<T>
  • fn reset(&self)
  • fn write<F>(&self, f: F) where F: FnOnce(&mut W) -> &mut W
  • fn modify<F>(&self, f: F) where for<'w> F: FnOnce(&R, &'w mut W) -> &'w mut W

The read method is available whenever there are readable fields. It takes a copy of the current register contents via the underlying vcell's volatile_read operation and gives it to you as an R value.

The reset_value method is not often used directly by the programmer, but it is important to understand what it is to understand how write works. Hardware typically has a network of circuitry throughout it so that when it is first powered on, or perhaps when it has gone into an undesired state, it can be brought back to a well-defined "reset" state. Although RAM usually has at least partially random values when a system is powered on or reset, memory-mapped configuration registers are usually given well-defined values by the reset circuitry in a peripheral so they don't start out in a random configuration. The reset value is usually given by the programming manual for the part and the SVD file, but if you need to use it in your program (and it was set correctly in the SVD file) you can access it via this method.

The reset method just writes the register's reset value back into all the writable fields, which puts that register back into the state which it had when first starting up. The write is performed via the vcell's volatile_write operation.

The write method allows you to set one or more fields of a register by passing in a FnOnce closure. That closure will be given a mutable reference to a W value that is initialized with the reset value of the register, not the current value actually contained in the register. This ensures that as long as any field you change is set to a valid value, the whole W should contain only valid field values. At the end of your closure's execution, the W is written to the register via the vcell's volatile_write operation.

The modify method allows you to change one or more fields of a register by passing in a FnOnce closure. That closure will be given a R reference and a mutable W reference that is initialized to hold the same bit-pattern as the R. The R is set to the value of the underlying register via the underlying vcell's volatile_read operation just before your closure is invoked. Your closure can read any fields via the R and overwrite any fields via the W. At the end of your closure's execution, the W is written to the register via the vcell's volatile_write operation.

Field-level read access

Every R definition on a u32 register will include the method fn bits(&self) -> u32, which reads and returns the entire contents of the register. If you need the whole register value for some reason, you can just call reg.read().bits(), although it is generally better to access specific fields, even if they are the width of the entire register.

For each field defined for this register in the source SVD file, a method named after the field will exist, which takes &self and returns an enum type with the same name as the method, but all-caps and with a R appended. For example, for the STM32F103, the rcc::cfgr::R struct has a method pub fn sw(&self) -> SWR which reads the SW field of the CFGR register of the RCC peripheral. The enumerated possible values are described by the type rcc::cfgr::SWR.

The enumerated "possible values" read type is also defined in the current module; it will have one variant for each enumerated value in the SVD file for the register field, with the same name as in the SVD file but in all-caps. If the enumerated values don't cover all possible bit patterns that a field of its bit-width could hold, it will also have a variant named _Reserved(T) where T is an integer type large enough to hold all possible field values; this is used for when the value doesn't match any defined enumerated values in the SVD, or when no enumerated values were defined.

Each field value enumerated type is also equipped with a set of methods of the following form:

  • fn bits(&self) -> T
  • fn _from(value: T) -> Self
  • is_X(&self) -> bool for each X where X is the lowercased name of one of the enum variants

For single-bit fields, the bits method is replaced with the following three methods:

  • fn bit(&self) -> bool
  • fn bit_is_set(&self) -> bool
  • fn bit_is_clear(&self) -> bool

The bits method is usually only needed if no variants were defined in the SVD file for the field; it can also be helpful if the field value has an interesting numeric value instead of just being a selector. The bit, bit_is_set, and bit_is_clear methods are similar; they may be the only ones available for a single-bit field if the SVD doesn't give any information about what being set or clear means.

The _from method is a helper to construct the enumerated type inside the field access methods on R.

The rest of the methods are boolean predicates, for when checking for a specific value is more useful than a pattern match. Not strictly necessary, but sometimes helpful.

Field-level write access

Every W definition will include the method unsafe fn bits(&mut self, bits: u32) -> &mut Self which will replace the entire contents of the register with the supplied bits parameter. This is unsafe because you must manually verify that all sub-fields of the register are being given valid bit-values. These two operations form the basis for implementing all the field-specific access methods that may also exist.

For each field in this register defined in the source SVD file, a method named after the field will exist on the W struct that takes &mut self and returns a proxy object. The proxy's type is the all-caps name of the field with an underscore in front and a W at the end. Each one is defined as a struct with a single field that holds the same &mut W passed to the field-writing method to begin with. The function of this proxy is to hold methods that will update the named field within the W value. Those methods allow specifying the field value either by a raw numeric value or by an enumerated type, which has the same name as the proxy type but without the underscore.

The proxy methods generally follow this form:

  • fn bits(self, value: T) -> &'a mut W (which is sometimes marked unsafe as well)
  • fn variant(self, value: ENUMW) -> &'a mut W where ENUMW is the enumerated type for this field's values

Also, for each variant in the enumerated type for this field's values, there is a setter named as a lowercase version of the variant which takes self and returns &'a mut W after setting the proxy's field in the W to the value named by the setter method.

For the example of the STM32F103's RCC CFGR register, rcc:cfgr::W has a method pub fn sw(&mut self) -> _SWW, which returns a _SWW proxy that holds a &mut W reference. The enumerated values for writing to that field are described by rcc::cfgr::SWW. Writing a value to this field would be accomplished with chained methods such as this:

rcc.CFGR.write(|w| w.sw().pll());
// or
rcc.CFGR.write(|w| w.sw().variant(SWW::PLL));
// or
rcc.CFGR.write(|w| unsafe { w.sw().bits(2) });

The bits method is unsafe in this case because the SVD did not define variants for all possible bitfield values, meaning that some numbers don't represent valid field values and it is up to the programmer to verify that they are passing a valid number.

Since the value-setting methods on the proxy object all return &'a mut W, they can be easily chained to set multiple fields in the same write or update call.

Device Hardware Abstraction Layer Crate API Basics

For many devices, including the STM32F103 that we are using, there is a device-specific HAL crate that provides higher-level interfaces to some device functionality. HAL, in this case, stands for "Hardware Abstraction Layer".

PAC Extension Traits

The first API generally used from a HAL crate is one that is used to take ownership of the lower-level peripheral objects and constrain them to the higher-level API provided by the HAL crate.

When the peripheral is an "atomic" one, i.e. there's only one thing to manage rather than a series of them, the extension trait defines a constrain(self) -> HALTYPE method, where HALTYPE is the type of the HAL struct that manages the higher-level API.

For example, you will almost always start your setup code with lines like these:

let dp = pac::Peripherals.take().unwrap();
let mut rcc = dp.RCC.constrain();
let mut flash = dp.FLASH.constrain();

That is because those peripherals are required to adjust the clock tree and get back a "frozen" set of configured clock rates that are needed for further peripheral setup.

On the other hand, peripherals such as GPIO banks are multi-resources; you may need to pass pins individually to peripherals, or just keep them in software-controlled mode to read button state or light LEDs. To bring this kind of peripheral into the higher-level API, the method on the extension trait is typically split(self) -> HALTYPE, where HALTYPE is the type of the HAL struct that manages those resources.

An example would be:

let mut gpioa = dp.GPIOA.split(&mut rcc.apb2);

Unlike the earlier constrain methods, this split takes an extra parameter. This will vary by the peripheral and what other peripherals are required to get it into a usable state. In this case, access to the apb2 configuration (this stands for "Advanced Peripheral Bus 2") is required to enable most peripherals, so you will need to pass it (or apb1) a lot.

The constrain method for RCC is defined on the RccExt trait, and the constrain method on FLASH is defined on the FlashExt trait, and the split method for GPIOA is on the GpioExt trait. If you are unsure of where to find the appropriate constraining method for a peripheral, check the prelude module in the HAL crate documentation and look at the list of traits exported; the ones ending in Ext provide helper extension methods for existing types.

Embedded HAL Traits

In addition to giving you a higher-level API for basic microcontroller setup and peripheral management, the job of the device HAL crate is to implement the standard traits from the embedded-hal crate, which provide a common interface for "driver" crates and other higher-level portable libraries.

The following is an outline of most of the traits defined in embedded-hal. The ones marked Unproven need to have the unproven build option enabled for them to be available; those APIs have not seen much use yet and are more likely to change or be removed.

The device HAL crate will usually provide these traits on a peripheral that has been constrained, or on its parts if it has a split method instead, although sometimes there will be some other constructor instead. If a driver you want to use requires certain traits, you can search the device HAL crate docs for the traits you are interested in to find how to construct objects that implement them.

adc::OneShot,
    /// Request that the ADC begin a conversion on the specified pin
    fn read(&mut self, pin: &mut Pin) -> Result<Word, Self::Error>
    
blocking::{
    delay::{
      DelayMs, 
          /// Pause execution for the specified number of milliseconds
          fn delay_ms(&mut self, ms: UXX)
      DelayUs
          /// Pause execution for the specified number of microseconds
          fn delay_us(&mut self, us: UXX)
    },
    i2c::{
        Read, 
            /// Read enough bytes from slave with specified address to fill the buffer
            fn read(&mut self, address: u8, buffer: &mut [u8]) -> Result<(), Self::Error>
        Write,
            /// Send bytes to slave with specified address
            fn write(&mut self, addr: u8, bytes: &[u8]) -> Result<(), Self::Error>
        WriteIter,
            /// Send iterator's bytes to slave with specified address
            fn write<B>(&mut self, addr: u8, bytes: B) -> Result<(), Self::Error>
            where B: IntoIterator<Item = u8>
        WriteIter,
            /// Send iterator's bytes to slave with specified address, then read to fill buffer
            fn write_iter_read<B>(
                &mut self,
                addr: u8,
                bytes: B,
                buffer: &mut [u8],
            ) -> Result<(), Self::Error>
            where B: IntoIterator<Item = u8>
        WriteRead,
            /// Send bytes to slave with specified address and then read to fill buffer
            fn write_read(
                &mut self, 
                address: u8, 
                bytes: &[u8], 
                buffer: &mut [u8],
            ) -> Result<(), Self::Error>
    },
    rng::Read, // *Unproven*
        /// Read enough bytes from hardware RNG to fill buffer
        fn read(&mut self, buffer: &mut [u8]) -> Result<(), Self::Error>
    serial::Write,
        /// Write a slice, blocking until everything has been written
        fn bwrite_all(&mut self, buffer: &[Word]) -> Result<(), Self::Error>
        /// Block until the serial interface has sent all buffered words
        fn bflush(&mut self) -> Result<(), Self::Error>
    spi::{
        Transfer,
            /// Send specified words to the slave, return the words received from the slave
            fn transfer<'w>(&mut self, words: &'w mut [W]) -> Result<&'w [W], Self::Error>
        Write,
            /// Send specified words to the slave, ignoring all the incoming words
            fn write(&mut self, words: &[W]) -> Result<(), Self::Error>
    },
},
digital::v1::{
    InputPin, // *Unproven* *Deprecated*
        /// Is the input pin high?
        fn is_high(&self) -> bool
        /// Is the input pin low?
        fn is_low(&self) -> bool
    OutputPin, // *Deprecated*
        /// Drive the pin low
        fn set_low(&mut self)
        /// Drive the pin high
        fn set_high(&mut self)
    ToggleableOutputPin, // *Unproven* *Deprecated*
        /// Toggle pin output
        fn toggle(&mut self)
},
digital::v2::{
    InputPin, // *Unproven*
        /// Is the input pin high?
        fn is_high(&self) -> Result<bool, Self::Error>
        /// Is the input pin low?
        fn is_low(&self) -> Result<bool, Self::Error>
    OutputPin,
        /// Drive the pin low
        fn set_low(&mut self) -> Result<(), Self::Error>
        /// Drive the pin high
        fn set_high(&mut self) -> Result<(), Self::Error>
    StatefulOutputPin, // *Unproven*
        /// Is the pin in drive low mode?
        fn is_set_low(&self) -> Result<bool, Self::Error>
        /// Is the pin in drive high mode?
        fn is_set_high(&self) -> Result<bool, Self::Error>
    ToggleableOutputPin, // *Unproven*
        /// Toggle pin output
        fn toggle(&mut self) -> Result<(), Self::Error>
},
serial::{
    Read,
        /// Read a single word from the serial interface
        fn read(&mut self) -> Result<Word, Self::Error>
    Write,
        /// Write a single word to the serial interface
        fn write(&mut self, word: Word) -> Result<(), Self::Error>
        /// Ensure none of the previously written words are still buffered
        fn flush(&mut self) -> Result<(), Self::Error>
},
spi::FullDuplex,
    /// Send a word to the slave
    fn send(&mut self, word: Word) -> Result<(), Self::Error>
    /// Read the word stored in the shift register (a word must be sent first)
    fn read(&mut self) -> Result<Word, Self::Error>
timer::CountDown,
    /// Start a new count down
    fn start<T>(&mut self, count: T) where t: Into<Self::Time>
    /// Non-blockingly "wait" for the count down to finish
    fn wait(&mut self) -> Result<(), Void>
watchdog::{
    Watchdog,
        /// Feed the watchdog, restarting its counter
        fn feed(&mut self)
    WatchdogDisable,
        /// Disable the watchdog
        fn disable(&mut self)
    WatchdogEnable,
        /// Enable the watchdog with the given period
        fn start<T>(&mut self, period: T) where T: Into<Self::Time>
},
Capture, // *Unproven*
    /// Wait for a transition in the capture channel and return the value of the counter
    fn capture(
        &mut self,
        channel: Self::Channel
    ) -> Result<Self::Capture, Self::Error>
    /// Disable a capture channel
    fn disable(&mut self, channel: Self::Channel)
    /// Enable a capture channel
    fn enable(&mut self, channel: Self::Channel)
    /// Return the current timer resolution
    fn get_resolution(&self) -> Self::Time
    /// Set the resolution of the capture timer
    fn set_resolution<R>(&mut self, resolution: R) where R: Into<Self::Time>
Pwm, // *Unproven*
    /// Disable a PWM channel
    fn disable(&mut self, channel: Self::Channel)
    /// Enable a PWM channel
    fn enable(&mut self, channel: Self::Channel)
    /// Return the current PWM period
    fn get_period(&self) -> Self::Time
    /// Return the current duty cycle
    fn get_duty(&self, channel: Self::Channel) -> Self::Duty
    /// Return the maximum duty cycle value
    fn get_max_duty(&self) -> Self::Duty
    /// Set a new duty cycle
    fn set_duty(&mut self, channel: Self::Channel, duty: Self::Duty)
    /// Set a new PWM period
    fn set_period<P>(&mut self, period: P) where P: Into<Self::Time>
PwmPin, // *Unproven*
    /// Disable a PWM channel
    fn disable(&mut self)
    /// Enable a PWM channel
    fn enable(&mut self)
    /// Return the current duty cycle
    fn get_duty(&self) -> Self::Duty
    /// Return the maximum duty cycle
    fn get_max_duty(&self) -> Self::Duty
    /// Set a new duty cycle
    fn set_duty(&mut self, duty: Self::Duty)
Qei, // *Unproven*
    /// Return the current pulse count of the encoder
    fn count(&self) -> Self::Count
    /// Return the count direction
    fn direction(&self) -> Direction

The stm32f1xx_hal Crate API

Many of a device HAL crate's features will be related to the specific features of the device, so they will vary quite a bit from one HAL crate to another in order to provide higher-level access to device-specific peripherals and special peripheral features or setup concerns.

This is an overview of the stm32f1xx_hal crate, especially as it pertains to the STM32F103 and our Blue Pill boards.

Prelude

You will often want to start your module code with something like:

use stm32f1xx_hal::prelude::*;

The prelude module re-exports all the embedded_hal traits as well as the PAC extension traits provided by the crate and some other convenient extension traits such as time::U32Ext. They are prefixed with their sources as they are re-exported, so they won't conflict with names that you choose, but you'll still get access to the trait methods.

rcc - Reset and Clock Control

This provides structs and traits for managing one of the most important parts of device setup, which is to correctly configure the clock tree and turn on clocking to the peripherals you want to make use of.

At startup, a STM32F103 is configured to take its main clock (SYSCLK) from the oscillator called HSI, which stands for "High Speed Internal". This runs at 8MHz, but it is not terribly accurate and can vary based on voltage or temperature variations.

Running at 8MHz is usable, but since the CPU and some peripherals are capable of running at up to 72MHz, you may want to use the RCC to turn up the speed!

As mentioned in the section on PAC Extension traits, the Rcc structure with the HAL methods is available by calling the constrain method on the PAC peripheral:

let dp = stm32f1xx_hal::pac::Peripherals.take().unwrap();
let mut rcc = dp.RCC.constrain();
let mut flash = dp.FLASH.constrain();

The constrain on FLASH returns a stm32f1xx_hal::flash::Parts struct with a single field, which holds an opaque proxy for the ACR register. There's unfortunately not any high-level API for manipulating the flash peripheral yet; this is just to pass to the RCC API so that it can set the flash wait states appropriately when the clock is set high enough for the change to be required for proper code fetches.

The constrain of RCC returns a stm32f1xx_hal::rcc::Rcc struct; this holds proxies for the AHB, APB1, APB2, CFGR, and BKP registers. Each field is named (in lowercase) for its register, e.g. cfgr for the CFGR proxy.

The BKP proxy has a further constrain method; calling this provides the proxy structure of type BackupDomain and enables the backup domain registers for writing. This is a bank of 10 16-bit registers (numbered 0-9) that will maintain their state through system resets.

The CFGR proxy holds the main clock tree configuration methods, which are arranged in a sort of builder pattern; you set each value that you want to change from its default state, then call the freeze(self, acr: &mut ACR) -> Clocks method to perform the configuration and get back a record of what frequency each of the main clocks is running at.

The AHB, APB1, and APB2 registers are named after the buses that the peripherals are attached to; AHB is the "Advanced High-performance Bus" while the other two follow the "Advanced Peripheral Bus" protocol. They all implement the GetBusFreq trait; APB1 additionally has the method fn set_pwren(&mut self) to enable the power interface clock.

The GetBusFreq trait has the following methods:

  • fn get_frequency(clocks: &Clocks) -> Hertz
  • fn get_timer_frequency(clocks: &Clocks) -> Hertz

These make it convenient to find out what frequency each bus and its associated timers are clocked at based on how you configured RCC.

Continuing the example, we increase the clocks (mostly) to their highest frequencies:

let clocks = rcc
    .cfgr
    .use_hse(8.mhz()) // Use High Speed External 8Mhz crystal oscillator
    .sysclk(72.mhz()) // Use the PLL to multiply SYSCLK to 72MHz
    .hclk(72.mhz())   // Leave AHB prescaler at /1
    .pclk1(36.mhz())  // Use the APB1 prescaler to divide the clock to 36MHz (max supported)
    .pclk2(72.mhz())  // Leave the APB2 prescaler at /1 
    .adcclk(12.mhz()) // ADC prescaler of /6 (max speed of 14MHz, but /4 gives 18MHz)
    .freeze(&mut flash.acr);

Because the clocks are arranged in a hierarchy, many of the choices affect the possibilities beneath them. The first choice we make is to switch from the internal oscillator to the external crystal oscillator; they are the same frequency, but the external oscillator will be more stable and accurate.

After selecting the external clock, we set the SYSCLK frequency to the maximum. Any value other than the natural frequency of the selected clock source means that we switch SYSCLK to be sourced from the PLL, which is a block that can pre-divide and then multiply the clock, which gives a wide range of possible frequencies from a single source frequency.

The SYSCLK passes through the AHB prescaler to provide HCLK, which is the clock for the CPU, memory, DMA controller, and the rest of the AHB bus.

The HCLK sources the APB1 prescaler to provide PCLK1 for peripherals on the APB1 bus. Since 72 MHz is above the max clock speed of 36MHz, we have to divide the clock in the prescaler; fortunately setting it to /2 gives us the maximum clock.

The HCLK also sources the APB2 prescaler to provide PCLK2 for peripherals on the APB2 bus. It supports 72MHz operation, so we can leave the prescaler at /1.

The PCLK2 sources the ADC prescaler, which has factors of /2, /4, /6, and /8 and must drop the clock to no more than 14MHz. If we wanted to optimize our ADC sample rate, we would want to set SYSCLK to 56MHz instead of 72MHz, since the /4 prescaler would then give us exactly the maximum clock rate to the ADC. As it is, we settle for 12MHz and a faster CPU.

gpio - General Purpose Input Output

After configuring the clocks, the next most important task of setting up the device for your intended purpose is configuring the pins.

The API for GPIO makes heavy use of an implementation technique called "type state" which uses generic type parameters to constrain the way a value of that type can be used. State-changing API methods consume the value at one type parameter, matching the input state, and produce the value again with a different type parameter, representing the output state.

The pins on the microcontroller are divided into banks of up to 16, each of which has its own peripheral register block to manage it. The proxy objects for these are in the fields named GPIOA through GPIOC of the Peripherals struct. To access them, you invoke the split method via the GpioExt trait implementation on that bank's proxy object, which will give you a stm32f1xx_hal::gpio::gpioa::Parts, for example, if used on the GPIOA field:

let dp = stm32f1xx_hal::pac::Peripherals.take().unwrap();
let gpioa = dp.GPIOA.split();

The Parts structure fields hold proxy objects for each pin, as well as two configuration registers CRL and CRH. The crl field holds a proxy of type CRL which needs to be passed to certain pin mode change methods for pins 0-7, while crh holds a proxy of type CRH which needs to be passed to the methods for pins 8-15.

The pin fields are named p<letter><number>, where the letter is the same as the one on the GPIO bank and the silkscreen by the Blue Pill board connectors. For example, field pa5 refers to pin 5 of bank GPIOA and corresponds to the board silkscreen indicator "A5". The type of each pin is a bit more involved due to the "type state" pattern described earlier.

Each pin has a unique base struct type named after itself, and it is parameterized by a phantom type indicating the state it is currently in. For most pins, this state is Input<Floating> while stored in the Parts struct. So field pa5 holds a value of type PA5<Input<Floating>>.

The states that each pin can be in are represented by the following structure types:

// Base state "Debugger"; reserved for debug use
pub struct Debugger;

// Base state "Analog"; in use by the ADC peripheral
pub struct Analog;

// Base state "Input"
pub struct Input<MODE> { _mode: PhantomData<MODE> }

// Input mode parameter indicating the pin is not pulling in either direction.
pub struct Floating;
// Input mode parameter indicating the pin will pull the wire low if nothing drives it.
pub struct PullDown;
// Input mode parameter indicating the pin will pull the wire high if nothing drives it.
pub struct PullUp;

// Base state "Output"
pub struct Output<MODE> { _mode: PhantomData<MODE> }

// Base state "Alternate" output; in use by a peripheral other than the ADC
pub struct Alternate<MODE> { _mode: PhantomData<MODE> }

// Output mode parameter indicating the pin will drive both high and low directions.
pub struct PushPull;
// Output mode parameter indicating the pin will only drive the line low.
pub struct OpenDrain;

There are some marker traits that apply to these type state types:

// This marks each mode type with a trait that can be used as a trait bound in a where clause
pub trait Mode<MODE> {}
// This marks all the active states for use in trait bounds
pub trait Active {}

// All input states are marked active
impl<MODE> Active for Input<MODE> {}

// All output states are marked active
impl<MODE> Active for Output<MODE> {}

// All alternate states are marked active
impl<MODE> Active for Alternate<MODE> {}

Two more interesting pin types are available for special circumstances, Generic<MODE> and Pxx<MODE>. These "erase" information from the type and store it as a runtime value instead. The first erases the pin number; the second wraps a Generic<MODE> in a bank-specific variant in the Pxx<MODE> enumeration type. This allows bundling of pin references in an array, which has to have members of the same type.

With those types in place, we can list the methods and traits available at different type states. The $CR and $PXi tokens can be replaced with the appropriate one of CRL/CRH and the pin's base type, respectively.

  • Configuration methods, impl for $PXi<MODE> where MODE: Active

    • fn into_alternate_push_pull(self, cr: &mut $CR) -> $PXi<Alternate<PushPull>>
    • fn into_alternate_open_drain(self, cr: &mut $CR) -> $PXi<Alternate<OpenDrain>>
    • fn into_floating_input(self, cr: &mut $CR) -> $PXi<Input<Floating>>
    • fn into_pull_down_input(self, cr: &mut $CR) -> $PXi<Input<PullDown>>
    • fn into_pull_up_input(self, cr: &mut $CR) -> $PXi<Input<PullUp>>
    • fn into_open_drain_output(self, cr: &mut $CR) -> $PXi<Output<OpenDrain>
    • fn into_open_drain_output_with_state(self, cr: &mut $CR, initial_state: State) -> $PXi<Output<OpenDrain>>
    • fn into_push_pull_output(self, cr: &mut $CR) -> $PXi<Output<PushPull>>
    • fn into_push_pull_output_with_state(self, cr: &mut $CR, initial_state: State) -> $PXi<Output<PushPull>>
    • fn into_analog(self, cr: &mut $CR) -> $PXi<Analog>
    • fn downgrade(self) -> Pxx<MODE>
  • Trait ExtiPin methods, impl for $PXi<Input<MODE>>

    • fn make_interrupt_source(&mut self, afio: &mut afio::Parts)
    • fn trigger_on_edge(&mut self, exti: &EXTI, edge: Edge)
    • fn enable_interrupt(&mut self, exti: &EXTI)
    • fn disable_interupt(&mut self, exti: &EXTI)
    • fn clear_interrupt_pending_bit(&mut self)
    • fn check_interrupt(&mut self) -> bool
  • Traits Channel<ADC1>, Channel<ADC2> impl for $PXi<Analog>
  • Traits from embedded-hal

    • impl<MODE> OutputPin for $PXi<Output<MODE>> { ... }
    • impl<MODE> StatefulOutputPin for $PXi<Output<MODE>> { ... }
    • impl<MODE> toggleable::Default for $PXi<Output<MODE>> {}
    • impl<MODE> InputPin for $PXi<Input<MODE>> { ... }
    • impl InputPin for $PXi<Output<OpenDrain>> { ... }

Any methods on the generic types also exist on the erased Pxx<MODE> version as well.

The following example shows the use of some pin configuration methods:

let dp = stm32f1xx_hal::pac::Peripherals.take().unwrap();
let mut gpiob = dp.GPIOB.split(&mut rcc.apb2);
let mut gpioc = dp.GPIOC.split(&mut rcc.apb2);

// Configure the I2C2 pins from GPIO bank B
let scl = gpiob.pb10.into_alternate_open_drain(&mut gpiob.crh);
let sda = gpiob.pb11.into_alternate_open_drain(&mut gpiob.crh);

// Configure pin C13 to drive the "PC13" LED 
let led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);

// Turn the LED on via the OutputPin trait
led.set_().unwrap();