Beginner’s Guide to Cortex-M3: Understanding Registers

Click the card below to follow Arm Technology Academy

This article is selected from the “Arm Technology Blog” in the Extreme Technology column, written by Andy Lok, originally published on Zhihu. This article will introduce the concept and operation of registers by rewriting our Blinky program in detail.

Original link:

https://zhuanlan.zhihu.com/p/52855259

In this article, we will discuss a very important concept in embedded systems—registers. Since microcontrollers interact with the external world and control their own functions primarily through registers, the use of registers will be throughout the entire learning process of microcontrollers. This article will introduce the concept and operation of registers by rewriting our Blinky program step by step.

The first half of the article will explain the basic principles of registers, while the second half will demonstrate the operation of registers through code examples.

The embedded platform used here is STM32F103, and its register manual can be downloaded here

(https://www.mcufan.com/download/stm32f10xxxcd00171190cn.pdf).

Register Operations

As we mentioned before:

Registers refer to a special memory address area, but they do not correspond to actual SRAM (Static Random-Access Memory). The operations on registers are identical to those on memory; registers can be read and written as if they were memory, and the read and write operations on the register memory segment will be converted into data exchanges with peripherals on the bus.

Thus, operations on registers are essentially read and write operations on special address memory. In the manual, we can find the starting addresses of each register (page 28):

Beginner's Guide to Cortex-M3: Understanding Registers

We will use the GPIOA peripheral register as an example. We jump to the GPIO section in the manual (page 115), where there is a table listing the structure of the GPIO_BSRR register.

What this register is used for is not important; we only need to master how to read the register table:

Beginner's Guide to Cortex-M3: Understanding Registers

The first row is the offset address. The offset address indicates the position of this register relative to the peripheral register section. From the starting address table, we can see that the starting address of the GPIOA register section is 0x4001_0800, and the offset address of GPIO_BSRR is 0x10. Therefore, the actual address of the GPIOA_BSRR register is 0x4001_0800 + 0x10 = 0x4001_0810.

The next two rows are explanations of the register bits. The numbers on the boxes are bit offset addresses, the names are in the middle of the boxes, and the read/write capabilities are below the boxes. Here, all the boxes below are ‘w’, meaning all these bits are write-only bits.

According to the explanation below, if we want to clear ODR3 (another register bit) to 0, we need to write 1 to BR3. This operation is essentially writing 0x4001_0810 memory address with 0x1<<19 (a 32-bit unsigned integer with all zeros except for the 19th bit).

Using Rust to operate would look like this:

core::ptr::write_volatile(0x4001_0810 as *mut u32, 1 << 19);

GPIO (General Purpose Input/Output)

The principle of Blinky is quite simple; it only needs to change the level of the pin connected to the LED at regular intervals to make the LED blink. By checking the circuit schematic of the core board, we find that the LED is connected to pin PC13, and from the schematic, we can see that the LED uses a common anode connection; the LED will light up only when the pin outputs a low level:

Beginner's Guide to Cortex-M3: Understanding Registers
Beginner's Guide to Cortex-M3: Understanding Registers

Pinout of STM32F103C8T6

Note: Some STM32F103 core boards may connect the LED to pin PB12; please check the schematic to confirm.

The pins in STM32 are divided into multiple groups such as GPIOA, GPIOB, GPIOC, GPIOD, etc., with each group controlling 16 pins, and each group is an independent peripheral.

Here, we need to learn two key registers of GPIO: the configuration register (PIOx_CRL, GPIOx_CRH) and the set/reset register (GPIOx_BSRR). (The ‘x’ in the register names refers to the GPIO group, A, B, C, etc.)

GPIO Configuration Register

The pins of the microcontroller often have multiple functions, such as input or output, so before using a pin, we need to configure its function through the configuration register.

We notice that there are two configuration registers: GPIOx_CRL and GPIOx_CRH. These actually represent the high and low parts of the configuration register; the low register (GPIOx_CRL) is responsible for configuring pins 0 to 7, while the high register (GPIOx_CRH) is responsible for configuring pins 8 to 15.

GPIO has the following modes:

  • Input floating

  • Input pull-up

  • Input pull-down

  • Analog input

  • Open-drain output

  • Push-pull output

  • Push-pull multiplexing function – open-drain multiplexing function

Input can be understood as reading the level on the pin, while output is controlling the pin level. Since we want to light up the LED by controlling the pin level, we choose output mode here.

There are two types of output modes: push-pull output and open-drain output. In push-pull output mode, the pin can output both high and low levels by itself, but the current driving capability is relatively weak, suitable for communication with digital components or driving LEDs; open-drain output only has low level and cutoff states, so a pull-up resistor (one end connected to power and the other end to the pin) is needed to output a high level in the cutoff state. Open-drain output has a stronger current driving capability, suitable for current-type driving.

Here we can simply choose the push-pull output mode.

By consulting the manual, we can find the structure of the configuration register (page 114):

Beginner's Guide to Cortex-M3: Understanding Registers

PC13 corresponds to bits MODE13 and CNF13 of the register. We will set MODE13 to output mode, which is 0x11 (maximum speed refers to the maximum level transition frequency, any value can be chosen here), and then set CNF13 to 0x00 to enable push-pull output.

GPIO Set/Reset Register

The set/reset register is specifically used to control the output level of the pins. Writing 1 to BR (R stands for Reset) will make the corresponding pin output a low level, while writing 1 to BS (S stands for Set) will make the corresponding pin output a high level. The operation is very simple, so I won’t elaborate.

Beginner's Guide to Cortex-M3: Understanding Registers

RCC Clock Control

The bus mentioned earlier is the APB1 and APB2 time bus. Any peripheral in the microcontroller needs to obtain time signals from the bus. However, after the microcontroller starts and resets, all peripherals are turned off by default to save energy. Therefore, the bus switch needs to be manually turned on before using the peripheral.

RCC (Reset and Clock Control) is responsible for configuring the time bus related to the microcontroller. Its APB2ENR register is used to switch on the peripherals on the APB2 bus. Since the GPIO peripheral is located on the APB2 bus, we look up the RCC_APB2ENR register (page 95):

Beginner's Guide to Cortex-M3: Understanding RegistersBeginner's Guide to Cortex-M3: Understanding Registers

From the image, we can see that writing 1 to IOPCEN of APB2ENR will enable the GPIOC peripheral.

Blinky Example

We will open the project established in the previous article and modify src/main.rs to restore it to the minimum compilable version:

#![no_std]

#![no_main]

extern crate panic_halt;

use core::ptr;

use cortex_m::asm;

use cortex_m_rt::entry;

use stm32f103xx;

#[entry]

fn main() -> ! {

asm::nop();

loop { }

}

Modify the dependencies in Cargo.toml. Here we are temporarily not using the register functions of stm32f103xx, just letting the compiler automatically link the interrupt vector table it provides, otherwise it will not compile:

[dependencies]

cortex-m = “0.5.8”

cortex-m-rt = “0.6.5”

panic-halt = “0.2.0”

stm32f103xx = “0.11”

We define the addresses of the registers based on the information from the manual:

const RCC_APB2ENR: *mut u32 = (0x4002_1000 + 0x18) as *mut u32;

const GPIOC_CRH: *mut u32 = (0x4001_1000 + 0x04) as *mut u32;

const GPIOC_BSRR: *mut u32 = (0x4001_1000 + 0x10) as *mut u32;

Next, we define the bit offsets for the registers to be used:

const APB2ENR_IOPCEN: usize = 4;

const CRH_MODE13: usize = 20;

const BSRR_BS13: usize = 13;

const BSRR_BR13: usize = 13 + 16;

Modify the main function.

#[entry]

fn main() -> ! {

unsafe {

// Enable GPIOC

ptr::write_volatile(RCC_APB2ENR, 1 << APB2ENR_IOPCEN);

// Configure GPIOC – PC13 as push-pull output

ptr::write_volatile(GPIOC_CRH, 0b0011 << CRH_MODE13);

// Reset PC13 to output low level

ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BR13);

}

loop { }

}

Note that here we used ptr::write_volatile() for memory write operations because if we use ptr::write(), the compiler might optimize away the memory write operations or change the execution order, which can improve efficiency in memory operations but completely change the intent of our program when it comes to registers, leading to unpredictable consequences. The same applies to read operations on registers; we should use ptr::read_volatile() instead of ptr::read().

At this point, compiling and running will light up the LED.

Next, we create a simple delay function:

fn delay() {

for _ in 0..2_000 {

asm::nop();

}

}

Here we used an assembly function nop, which means No Operation. It will idle for one CPU clock cycle, and then we loop it to achieve a visible delay.

In fact, based on the Cortex-M3 72MHz clock speed, a delay of around 2000 cycles should be less than a millisecond. However, this delay can reach about half a second. This is because when the microcontroller starts, the chip defaults to a faster but lower frequency internal clock, approximately around 40kHz. Generally, we need to set the RCC register to switch the clock source to an external high-speed clock after reset; we will discuss this in detail later.

Modify the loop:

loop { delay();

// Reset: output low level, light up LED

unsafe { ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BR13); }

delay();

// Set: output high level, LED off

unsafe { ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BS13); }

}

Thus, our register version of Blinky is complete! Below is the complete code:

#![no_std]

#![no_main]

extern crate panic_halt;

use core::ptr;

use stm32f103xx;

use cortex_m::asm;

use cortex_m_rt::entry;

const RCC_APB2ENR: *mut u32 = (0x4002_1000 + 0x18) as *mut u32;

const GPIOC_CRH: *mut u32 = (0x4001_1000 + 0x04) as *mut u32;

const GPIOC_BSRR: *mut u32 = (0x4001_1000 + 0x10) as *mut u32;

const APB2ENR_IOPCEN: usize = 4;

const CRH_MODE13: usize = 20;

const BSRR_BS13: usize = 13;

const BSRR_BR13: usize = 13 + 16;

#[entry]

fn main() -> ! {

unsafe {

// Enable GPIOC

ptr::write_volatile(RCC_APB2ENR, 1 << APB2ENR_IOPCEN);

// Configure GPIOC – PC13 as push-pull output

ptr::write_volatile(GPIOC_CRH, 0b0011 << CRH_MODE13);

// Reset PC13 to output low level

ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BR13);

}

loop {

delay();

// Reset: output low level, light up LED

unsafe { ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BR13); }

delay();

// Set: output high level, LED off

unsafe { ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BS13); }

}

}

fn delay() {

for _ in 0..2_000 {

asm::nop();

}

}

Blinky: Abstraction

The method used in the above code to operate registers is simple and direct, similar to C language. While it works, it can be seen that the semantics of such operations are very vague, often requiring repeated reference to the manual, and this will heavily use unsafe memory operations, easily leading to human errors. Fortunately, Rust provides safer abstractions that can greatly improve these two issues.

The stm32f103xx library safely encapsulates the register operation interface, and it is automatically generated by svd2rust, so it eliminates human errors. Its documentation can be found here.

Let’s see how to use this library:

// Get Peripherals

let dp = stm32f103xx::Peripherals::take().unwrap();

// Enable GPIOC

dp.RCC.apb2enr.write(|w| w.iopben().enabled());

The first line, stm32f103xx::Peripherals::take(), will only return Some(dp) on the first call, thus avoiding data races caused by multiple register instances.

Peripherals is a struct that has all the peripheral interface definitions, such as RCC here. We can write to the RCC’s apb2enr register. The library encapsulates the read and write operations of the registers within a closure, so it can perform some safety operations (reset register values or disable interrupts) before and after reading/writing. ‘w’ is the writer for apb2enr; calling w.iopben().enabled() is equivalent to the previous unsafe memory write, and it is zero-cost; the compiled instructions generally will not differ.

Similarly, we can rewrite the operations on GPIOC as follows:

// Configure PC13

dp.GPIOC.crh.write(|w| w.mode13().output().cnf13().push());

// Set

dp.GPIOC.bsrr.write(|w| w.bs13().set());

// Reset

dp.GPIOC.bsrr.write(|w| w.br13().reset());

Complete code:

#![no_std]

#![no_main]

extern crate panic_halt;

use core::ptr;

use stm32f103xx;

use cortex_m::asm;

use cortex_m_rt::entry;

#[entry]

fn main() -> ! {

// Get Peripherals

let dp = stm32f103xx::Peripherals::take().unwrap();

// Convert RCC register struct to further abstract hal struct

let mut rcc = dp.RCC.constrain();

// Get GPIOC instance, which will automatically enable the bus switch

let mut gpioc = dp.GPIOC.split(&mut rcc.apb2);

// Get PC13 instance and configure the pin

let mut led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);

// Output high level

led.set_high();

// Output low level

led.set_low();

}

fn delay() {

for _ in 0..2_000 {

asm::nop();

}

}

Blinky: Further Abstraction

The performance of stm32f103xx is impressive, but it has not yet fully tapped into Rust’s potential. The embedded working group provides us with the embedded-hal abstraction library, and stm32f103xx-hal is the specific implementation of embedded-hal on stm32f103. The stm32f103xx-hal library further abstracts the logical details of register operations based on stm32f103xx. For instance, stm32f103xx-hal can automatically enable the apb2enr bus switch before we use GPIOC. Likewise, this library is also zero-cost.

Modify Cargo.toml to add dependencies:

[dependencies.stm32f103xx-hal]

features = [“rt”]

git= “https://github.com/japaric/stm32f103xx-hal”

In src/main.rs, import hal:

extern crate stm32f103xx_hal as hal;

use hal::prelude::*;

hal::prelude defines many traits that are implemented by default on peripheral structs (such as RCC) to provide the constrain() conversion function. constrain() will convert the stm32f103xx peripheral instance into the peripheral type in stm32f103xx-hal.

let dp = stm32f103xx::Peripherals::take().unwrap();

// Convert RCC register struct to further abstract hal struct

let mut rcc = dp.RCC.constrain();

// Get GPIOC instance, which will automatically enable the bus switch

let mut gpioc = dp.GPIOC.split(&mut rcc.apb2);

// Get PC13 instance and configure the pin

let mut led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);

// Output high level

led.set_high();

// Output low level

led.set_low();

}

Complete code:

#![no_std]

#![no_main]

extern crate panic_halt;

extern crate stm32f103xx_hal as hal;

use core::ptr;

use stm32f103xx;

use cortex_m::asm;

use cortex_m_rt::entry;

use hal::prelude::*;

#[entry]

fn main() -> ! {

// Get Peripherals

let dp = stm32f103xx::Peripherals::take().unwrap();

// Convert RCC register struct to further abstract hal struct

let mut rcc = dp.RCC.constrain();

// Get GPIOC instance, which will automatically enable the bus switch

let mut gpioc = dp.GPIOC.split(&mut rcc.apb2);

// Get PC13 instance and configure the pin

let mut led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);

loop {

delay();

// Output low level

led.set_low();

delay();

// Output high level

led.set_high();

}

}

fn delay() {

for _ in 0..2_000 {

asm::nop();

}

}

Conclusion

This article is lengthy, covering everything from the principles of registers to memory operation methods, and demonstrating how Rust’s powerful abstraction capabilities can hide scattered memory operations behind safe operation interfaces. Additionally, based on embedded-hal, we further abstracted the logical details of register operations, achieving a safe and easy-to-use API while allowing flexible selection of abstraction levels as needed. I believe readers can already feel the significant advantages of Rust in the embedded field compared to C.

Recommended Reading

  • Arm Series – AArch64 Registers

  • Simple and Direct Interpretation of Cortex-M23/33 (Part 2)

  • Beginner’s Guide to Cortex-M3 (Part 1): Overview of Architecture

Follow Arm Technology Academy

Beginner's Guide to Cortex-M3: Understanding Registers Click below on “Read the Original“, to read more articles from theArm Technology Blog‘scolumn.

Leave a Comment

Your email address will not be published. Required fields are marked *