Bare-Metal STM32: From Power-Up To Hello World

0

Some may ask why you’d want to program a Cortex-M microcontroller like the STM32 series using nothing but the ARM toolchain and the ST Microelectronics-provided datasheet and reference manual. If your first response to that question wasn’t a panicked dive towards the nearest emergency exit, then it might be that that question has piqued your interest. Why, indeed?

Definitely, one could use any of the existing frameworks to program an STM32 MCU, whether the ST HAL framework, plain CMSIS, or even something more Arduino-flavored. Yet where is the fun in that, when at the end of the day one is still fully dependent on that framework’s documentation and its developers? More succinctly, if the contents of the STM32 reference manuals still look like so much gibberish, does one really understand the platform?

Let’s take a look at how bare-metal STM32 programming works, and make the most basic example run, shall we?

Like a PC, only different

Fundamentally, there is little difference between a microcontroller and a full-blown Intel or AMD-based computer. You still got at least one CPU core which is initialized once external power has stabilized, at which point start-up firmware is read from a fixed location. On your desktop, this is the BIOS. In the case of an MCU this is the code stored starting at a specific offset in the (usually) integrated read-only memory (ROM). Whatever happens next is up to this code.

Generally, in this start-up code one wants to do the essentials, such as setting up the interrupt vector table and the basic contents of specific registers. Initializing the stack pointer (SP) is essential, as well as copying certain parts of the ROM into RAM and initializing a number of registers. Ultimately the main function is called, akin to when the operating system of a PC is started after the BIOS has finished setting up the environment.

The Pushy example

Probably the most basic useful example would be what I affectionately call ‘Pushy‘ in my Nodate STM32 framework. It’s more basic than the traditional ‘Blinky’ example, as it only uses the Reset & Clock Control (RCC) registers and basic GPIO peripheral. All it does is read the input register of the GPIO pin and adjust an output depending on the input value, but that still gives one the power to turn a LED on or off at will:

#include <gpio.h>
int main () {
//const uint8_t led_pin = 3; // Nucleo-f042k6: Port B, pin 3.
//const GPIO_ports led_port = GPIO_PORT_B;
//const uint8_t led_pin = 13; // STM32F4-Discovery: Port D, pin 13 (orange)
//const GPIO_ports led_port = GPIO_PORT_D;
//const uint8_t led_pin = 7; // Nucleo-F746ZG: Port B, pin 7 (blue)
//const GPIO_ports led_port = GPIO_PORT_B;
const uint8_t led_pin = 13; // Blue Pill: Port C, pin 13.
const GPIO_ports led_port = GPIO_PORT_C;
//const uint8_t button_pin = 1; // Nucleo-f042k6 (PB1)
//const GPIO_ports button_port = GPIO_PORT_B;
//const uint8_t button_pin = 0; // STM32F4-Discovery (PA0)
//const GPIO_ports button_port = GPIO_PORT_A;
//const uint8_t button_pin = 13; // Nucleo-F746ZG (PC13)
//const GPIO_ports button_port = GPIO_PORT_C;
const uint8_t button_pin = 10; // Blue Pill
const GPIO_ports button_port = GPIO_PORT_B;
// Set the pin mode on the LED pin.
GPIO::set_output(led_port, led_pin, GPIO_PULL_UP);
GPIO::write(led_port, led_pin, GPIO_LEVEL_LOW);
// Set input mode on button pin.
GPIO::set_input(button_port, button_pin, GPIO_FLOATING);
// If the button pulls down to ground (high to low), ‘button_down’ is low when pushed.
// If the button is pulled up to Vdd (low to high), ‘button_down’ is high when pushed.
uint8_t button_down;
while (1) {
button_down = GPIO::read(button_port, button_pin);
if (button_down == 1) {
GPIO::write(led_port, led_pin, GPIO_LEVEL_HIGH);
}
else {
GPIO::write(led_port, led_pin, GPIO_LEVEL_LOW);
}
}
return 0;
}

Here we can see the two most visible elements: first is the main() function that gets called, the second is the included GPIO module. This contains a static C++ class which gets called to write to the GPIO output, with connected LED, as well as read from another input that has a button connected to it. We can also see that the so-called ‘Blue Pill’ (STM32F103C8) has its pins defined, but the example has a few more presets we can change by uncommenting the appropriate lines.

STM32F0xx’s RCC_AHBENR register description in the RM.

So where do the RCC registers come into play here?  As their name suggests, they control the clock domains within the MCU, essentially acting as on/off switches for parts of the MCU. If we look at for example the RCC_AHBENR register description in the STM32F0xx Reference Manual (section 6.4), we can see a bit there that’s labelled IOPAEN (Input/Output Port A ENable), which toggles the clock for the GPIO A peripheral. The same is true for the other GPIO peripherals.

As listed in the above graphic, AHBENR means the enable register for the AHB, which is one of the buses inside the MCU to which the processor core, SRAM, ROM and peripherals are connected:

STM32F0xx system architecture (RM section 2.1).

The AHB (Advanced High-performance Bus) along with the APB (Advanced Peripheral Bus) are covered by the Arm AMBA specification. Generally, the AHB is the fastest bus, connecting the processor core with SRAM, ROM and high-speed peripherals. Slower peripherals are placed on the slower APB, with an AHB-to-APB bridge allowing for communication.

Time to assemble

As mentioned earlier, the first code to run is the start-up code. For the STM32F042x6 MCU, a generic start-up program in Thumb assembler can be seen here. This is the generic ASM as provided by ST (e.g. for STM32F0xx) along with the CMSIS device package. It initializes the MCU and calls the SystemInit() function in the low-level CMSIS C code, e.g. for STM32F0xx.

This SystemInit() function resets the system clock registers to the desired reset state: using the internal HSI oscillator, at default speed. After libc setup routines (here Newlib, a C/C++ support library), it finally starts the main() function with:

bl main

This instruction means ‘Branch with Link‘, causing the execution to jump to the specified label, essentially. At this point we’re firmly in our ‘Pushy’ example’s main(). It’s now all down to the GPIO class to pull things together.

GPIO

The first class method we call is GPIO::set_output() to set a certain pin as an output with enabled pull-up resistor. This is also where we encounter our first differences between the STM32 families, as the older Cortex-M3-based F1 family has very different GPIO peripherals from its newer F0, F4 and F7 siblings. This means that for the STM32F1xx we have to wrangle multiple options per pin into a single register:

// Input/output registers are spread over two combined registers (CRL, CRH).
if (pin < 8) {
// Set CRL register (CNF & MODE).
uint8_t pinmode = pin * 4;
uint8_t pincnf = pinmode + 2;
if (speed == GPIO_LOW) { instance.regs->CRL |= (0x2 << pinmode); }
else if (speed == GPIO_MID) { instance.regs->CRL |= (0x1 << pinmode); }
else if (speed == GPIO_HIGH) { instance.regs->CRL |= (0x3 << pinmode); }
if (type == GPIO_PUSH_PULL) { instance.regs->CRL &= ~(0x1 << pincnf); }
else if (type == GPIO_OPEN_DRAIN) { instance.regs->CRL |= (0x1 << pincnf); }
}
else {
// Set CRH register.
uint8_t pinmode = (pin – 8) * 4;
uint8_t pincnf = pinmode + 2;
if (speed == GPIO_LOW) { instance.regs->CRH |= (0x2 << pinmode); }
else if (speed == GPIO_MID) { instance.regs->CRH |= (0x1 << pinmode); }
else if (speed == GPIO_HIGH) { instance.regs->CRH |= (0x3 << pinmode); }
if (type == GPIO_PUSH_PULL) { instance.regs->CRH &= ~(0x1 << pincnf); }
else if (type == GPIO_OPEN_DRAIN) { instance.regs->CRH |= (0x1 << pincnf); }
}

But for the other mentioned families we have a different register for each option (mode, speed, pull-up/down, type):

uint8_t pin2 = pin * 2;
instance.regs->MODER &= ~(0x3 << pin2);
instance.regs->MODER |= (0x1 << pin2);
instance.regs->PUPDR &= ~(0x3 << pin2);
if (pupd == GPIO_PULL_UP) {
instance.regs->PUPDR |= (0x1 << pin2);
}
else if (pupd == GPIO_PULL_DOWN) {
instance.regs->PUPDR |= (0x2 << pin2);
}
if (type == GPIO_PUSH_PULL) {
instance.regs->OTYPER &= ~(0x1 << pin);
}
else if (type == GPIO_OPEN_DRAIN) {
instance.regs->OTYPER |= (0x1 << pin);
}
if (speed == GPIO_LOW) {
instance.regs->OSPEEDR &= ~(0x3 << pin2);
}
else if (speed == GPIO_MID) {
instance.regs->OSPEEDR &= ~(0x3 << pin2);
instance.regs->OSPEEDR |= (0x1 << pin2);
}
else if (speed == GPIO_HIGH) {
instance.regs->OSPEEDR &= ~(0x3 << pin2);
instance.regs->OSPEEDR |= (0x3 << pin2);
}

Setting an option in a register is done using bitwise operations to set the target bits through bitmask manipulation. The register name is usually fairly descriptive, with for example PUPDR meaning Pull-Up Pull-Down Register.

Which style one prefers is mostly in the eye of the beholder. In the case of setting a pin as input, however, I much prefer the newer GPIO peripheral style, with the following nice, compact code instead of the STM32F1xx convoluted horror show:

uint8_t pin2 = pin * 2;
instance.regs->MODER &= ~(0x3 << pin2);
instance.regs->PUPDR &= ~(0x3 << pin2);
if (pupd == GPIO_PULL_UP) {
instance.regs->PUPDR |= (0x1 << pin2);
}
else {
instance.regs->PUPDR |= (0x2 << pin2);
}

To read from an input pin, we reference the Input Data Register (GPIO_IDR) for that GPIO bank:

uint32_t idr = instance.regs->IDR;
out = (idr >> pin) & 1U; // Read out desired bit.

Similarly, we use the Output Data Register (ODR) when we write to a pin:

if (level == GPIO_LEVEL_LOW) {
instance.regs->ODR &= ~(0x1 << pin);
}
else if (level == GPIO_LEVEL_HIGH) {
instance.regs->ODR |= (0x1 << pin);
}

Finally, the instance in the above code snippets is a reference to an entry in an std::vector which was created statically upon start-up. It registers properties for each peripheral:

std::vector<GPIO_instance>* GPIO_instances() {
GPIO_instance instance;
static std::vector<GPIO_instance>* instancesStatic = new std::vector<GPIO_instance>(12, instance);
#if defined RCC_AHBENR_GPIOAEN || defined RCC_AHB1ENR_GPIOAEN || defined RCC_APB2ENR_IOPAEN
((*instancesStatic))[GPIO_PORT_A].regs = GPIOA;
#endif
#if defined RCC_AHBENR_GPIOBEN || defined RCC_AHB1ENR_GPIOBEN || defined RCC_APB2ENR_IOPBEN
((*instancesStatic))[GPIO_PORT_B].regs = GPIOB;
#endif
[..]
return instancesStatic;
}
static std::vector<GPIO_instance>* instancesStatic = GPIO_instances();

If a peripheral exists (i.e. listed in the CMSIS header for that MCU, e.g. STM32F042), an entry is created in a GPIO_instance struct pointing to its memory-mapped registers (‘regs‘). These instances can then be referenced along with any meta information in them, such as whether they have been activated yet:

GPIO_instance &instance = (*instancesStatic)[port];
// Check if port is active, if not, try to activate it.
if (!instance.active) {
if (Rcc::enablePort((RccPort) port)) {
instance.active = true;
}
else {
return false;
}
}

The advantage of this is – as we saw earlier – that the same code can then be used, no matter which peripheral we’re addressing, as they are all identical in terms of register layout.

RCC

The RCC class also tracks whether a peripheral exists using the same CMSIS preprocessor defines to prevent any surprises. After this, enabling a peripheral’s clock is quite easy:

bool Rcc::enable(RccPeripheral peripheral) {
uint8_t perNum = (uint8_t) peripheral;
RccPeripheralHandle &ph = (*perHandlesStatic)[perNum];
if (ph.exists == false) {
return false;
}
// Check the current peripheral status.
if (ph.count > 0) {
if (ph.count >= handle_max) {
return false;
}
// Increase handler count by one.
ph.count++;
}
else {
// Activate the peripheral.
ph.count = 1;
*(ph.enr) |= (1 << ph.enable);
}
return true;
}

In addition to toggling the relevant bit position (ph.enable), we also perform reference counting, just so that we don’t accidentally disable a peripheral when another part of the code is still using it.

Running the example

After working through the above material, we should have some idea of how the ‘Pushy’ example works on a fundamental level. We can now build and run it. For this we need, as mentioned, the ARM toolchain and Nodate framework installed. The former can be obtained via one’s favorite package manager (package: arm-none-eabi-gcc) or Arm website. The Nodate framework is obtained via Github, after which the location of Nodate’s root folder has to be specified in a global NODATE_HOME system variable.

After this has been taken care of, navigate to the Nodate folder, and into the examples/stm32/pushy folder. Here, open the Makefile and pick any of the board presets (currently Blue Pill, Nucleo-F042K6, STM32F4-Discovery or Nucleo-746ZG). Next, open src/pushy.cpp and ensure the appropriate lines for the target board are uncommented.

Next, in the folder with the Makefile, build with make. With the target board connected via ST-Link, ensure OpenOCD is installed and flash with make flash. This should write the firmware image to the board.

With a button connected to the specified pin and Vdd, pushing this button will make a LED light up on the board. This demonstrates the basic use of an STM32 GPIO peripheral, and you’re already one step beyond “blinky”.

Hopefully this showed how bare-metal STM32 development is rather straightforward. Please stay tuned for more advanced topics as we push further into this topic.

FOLLOW us ON GOOGLE NEWS

 

Source

Get real time updates directly on you device, subscribe now.

This website uses cookies to improve your experience. We'll assume you're ok with this, but you can opt-out if you wish. AcceptRead More