STM32 Bare Metal Programming

After using the Raspberry Pi and the ESP32, I realized I needed to learn more about ARM in terms of programming for embedded systems. ARM is the new kid on the block, at least for me, and it’s been long overdue for me to learn it.

I needed valid reasons to choose an MCU / dev board. Apart from learning the platform, I also wanted a practical way to make use of it. I might be able to make use of something with networking for a potential extension of my senior project.

ARM Suite and ST

The options are plentiful since ARM releases multiple types of processors. It starts with the family of Cortex-M. These processors are designed for low power devices, which are perfect for embedded systems.

After browsing the various types of Cortex, I believe a middle ground member for getting started, given my experience, is the Cortex-M4. It has features I would expect to be able to use, as well as some which I could learn about, like digital signal processing features.

Cortex-M4
Cortex-M4 Family[1]

After honing in on what MCU I wanted to use, now began the task of searching for the ideal dev board. STMicroelectronics provide a bunch of different boards that you can buy for tinkering or realizing a project. They categorize their board into three different groups, discovery kits, eval boards, and nucleoboards. From previous research, mostly reddit, I was now aware that the nucleoboards were the most popular category as they are quite affordable and include nice-to-have peripherals.

Nucleoboards
ST's Nucleo Boards[2]

After glancing at the options, I noticed some of the boards have an Ethernet port. I have never gotten the chance to work with Ethernet, just WiFi with the ESP32, so I felt it was only right that I get an Ethernet dev board. The only group of the nucleoboards that have the port are the Nucleo-144. These are the largest offering, with the number after Nucleo being the amount of GPIO pins. 144 is quite a lot. Now all I needed was to choose the range of 144 options.

The Answer, Thanks to X

I had put this quest aside for a while, and then, while scrolling through X, I came across something. My algorithm on that platform is in a place where I feel like I’m not wasting my time (at least I think), reading what’s on there. The occasional meme, sports updates, and the more interesting to me is the tech part of my feed.

I’ve started liking more and more of the posts from amazing people on there, things related to Embedded, or just the tech fad of the week, AI always the forefront.

I came across a post arguing that Arduino, “does more bad than good” for people getting into Embedded. The author of the post, eul3r, recommends to buy an STM32. This could not have come towards me any sooner. Attached was a GitHub repository for a guide for bare metal programming.

GitHub Repository
GitHub Repository for Bare Metal Programming Guide [3]

Given that I have little experience with bare metal programming, FPGA class had us adapt an MCU to a dev board, I felt like this would be the best way to jump into a new platform.

Digging into the repo, there are various supported boards that are used for the guides. To my surprise, the first on the list was the STM32 Nucleo F429Zi, with projects from the basic LED interaction to a web server hosting a dashboard. Searching for this board on Google leads me to a Digikey product page. It mentions that the product is no longer manufactured. I didn’t want to buy a board that was not going to be relevant. Thankfully, after a few more searches, I found the newer model, the F439Zi. I bought it and waited for it to arrive.

GitHub Repository
The White PCB Really stands out.[4]

The Basis of Bare Metal Programming

In order to achieve a working program on any microcontroller, one must understand how the different peripherals are laid out and how they are mapped in memory.

You might have heard of MMIO, Memory Mapped I.O., or PMIO, Port Mapped I.O. In the most basic terms, everything in a microcontroller’s function, whether that be GPIO, Ethernet, SPI, is all controlled through specific parts of the memory. Modifying the values of these parts will in the end result in different behavior.

STM Documentation

The only way to know what peripheral is where is to consult the documentation (RTFM!). ST provides different types of documents, some which I find were hard to acquire, that explains the aspects of the board.

The more basic one is the user manual, which will describe the hardware layout and configuration of the different peripherals. The one that is more lengthy, the reference manual, contains everything you could possibly want to know about.

This might be too much information for now, but I’ll leave it here regardless. Below is the block diagram for my board. This lists how everything is connected, and it’s a great help when following the GitHub guide.

Block Diagram
Block Diagram for STM32 Nucleo F4#9Zi [5]

Toolchain

Since this is a bare metal environment, there is no need for fancy IDEs or libraries. Everything is done in scratch. The only tools you need are:

Compiler

  • Since we are programming for ARM, we need a different version of GCC. The guide calls for arm-none-eabi-gcc installing these are different for each Operating system. Since Im working on a NixOS machine, its the package gcc-arm-embedded-13[6]. Oh yeah, I’m using NixOS now, maybe I should write about that.

FlashTool

  • A way to load the program executable to the board. The guide calls for the “stlink” package , but I used OpenOCD[7].

Builder

  • Something that will automate the tedious commands for compiling and flashing. The popular option here is GNU Make[8].

These three things are enough to get you started creating for an ARM based controller.

How It All Comes Together, Boot Table, and Linker Script

Every MCU has an entry point. This is the code that is executed initially. Specific to ARM, there is a table of functions that are used for this process. This is the code that is executed initially.

Specific to ARM, there is a table of functions that are used for this process. The vector table, or boot table, formed from the ARM developer website, quote, contains the starting addresses for each exception and interrupt.

As the guide explains, the first two entries on this table are what pertain to startup of the device. One, the stack pointer. Two, the reset handler. If we have a main function, then it must be called from within the reset handler.

Below is the code for the reset handler and the vector table. We add attributes, naked and no return, to tell the compiler that this is the code meant to be executed in the lowest of levels. No need for function overhead, and non-returning means do not expect a return to the caller.

// Startup code
__attribute__((naked, noreturn)) void _reset(void) {
  // memset .bss to zero, and copy .data section to RAM region
  extern long _sbss, _ebss, _sdata, _edata, _data_loadaddr;
  for (long *dst = &_sbss; dst < &_ebss; dst++)
    *dst = 0;
  for (long *dst = &_sdata, *src = &_data_loadaddr; dst < &_edata;)
    *dst++ = *src++;

  main(); // Call main()
  for (;;)
    (void)0; // Infinite loop in the case if main() returns
}

extern void _estack(void); // Defined in link.ld

// 16 standard and 91 STM32-specific handlers
__attribute__((section(".vectors"))) void (*const tab[16 + 91])(void) = {
    _estack, _reset, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, SysTick_Handler};

Reset Handler and Vector Table

This is all looking good, but there still is a critical part missing. Where in memory does all of this live?

We need a way to define the regions of memory that the STM32 will work with. This is where the Linker Script comes in. This is where we define all of the regions that are necessary for the MCU operation.

ENTRY(_reset);
MEMORY {
  flash(rx)  : ORIGIN = 0x08000000, LENGTH = 2048k
  sram(rwx) : ORIGIN = 0x20000000, LENGTH = 192k  /* remaining 64k in a separate address space */
}
_estack     = ORIGIN(sram) + LENGTH(sram);    /* stack points to end of SRAM */
Memory Definition in Linker Script

In the figure above, we specify the entry point, which is the reset function we wrote earlier. Next, we define both flash and SRAM memory with the starting address and length. Where do we get these values? To the refer data sheet.

It was quite the quirk getting used to having more than one document open at a time to find everything. I’ll get used to it.

Next, we have the stack pointer function. _estack. We set it to the end of the SRAM bounds to allow for the stack to grow without limitation. Without, obviously, physical limitation.

SECTIONS {
  .vectors  : { KEEP(*(.vectors)) }   > flash
  .text     : { *(.text*) }           > flash
  .rodata   : { *(.rodata*) }         > flash
...
Linker Script Sections

Here we define the sections and to which memory region they go. The vector table, the text section, and the read-only data will all go in flash, in that order. Remember, the vector table should be first since it contains the pointer to the reset function we make sure it gets passed to, the executable surrounding it with keep.

  .data : {
    _sdata = .;   /* .data section start */
    *(.first_data)
    *(.data SORT(.data.*))
    _edata = .;  /* .data section end */
  } > sram AT > flash
  _data_loadaddr = LOADADDR(.data);

  .bss : {
    _sbss = .;              /* .bss section start */
    *(.bss SORT(.bss.*) COMMON)
    _ebss = .;              /* .bss section end */
  } > sram
...
}

data and bss sections

The next two functions, .data and .bss, will be executed in flash, and are for variables. Data is pertaining to initialized and bss is for uninitialized variables. You can check the code for the _reset function, and see all we do for .bss is set everything to 0.

We add a simple main function, compile, and voila! A built executable ready for us to make some interactions with our board’s peripherals.

Blinky LED with Serial Output

Without making this any longer, I want to show what the guide allows you to make. The first of the mini projects is the Blinky LED. You then add UART support to gain printing to a serial console. This is how you would go about doing this.

RCC

As you may know or not, ARM chips are known to be more efficient, and save power. So you have to specifically allow peripherals of the microcontroller to be clocked. This is where the RCC comes in, or the Reset and Control Clock.

This is a register in memory that gets configured to allow for peripherals to be clocked, therefore, having power. You need to have RCC to enable clock throughput for specific peripherals.

If you refer to the block diagram here, you can see that the GPIO banks are connected to the AHB1 bus. Without considering for this, your different peripherals will simply not work.

So the process is as follows: get the specific peripheral you want to work with. Check in the documentation which enable register from RCC you have to interact with, then set the bits correctly.

static inline void gpio_set_mode(uint16_t pin, uint8_t mode) {
  struct gpio *gpio = GPIO(PINBANK(pin)); // GPIO bank
  int n = PINNO(pin);                     // Pin number
  RCC->AHB1ENR |= BIT(PINBANK(pin));      // Enable GPIO clock for selected pin 
  gpio->MODER &= ~(3U << (n * 2));        // Clear existing setting
  gpio->MODER |= (mode & 3U) << (n * 2);  // Set new mode
}

Enabling the appropriate bus for GPIO use.

GPIO

Next is GPIO. GPIO stands for General Purpose Input Output. And this is very simple once you read the documentation.

struct gpio {
  volatile uint32_t MODER, OTYPER, OSPEEDR, PUPDR, IDR, ODR, BSRR, LCKR, AFR[2];
};
#define GPIO(bank) ((struct gpio *)(0x40020000 + 0x400 * (bank)))
// we define the memory address of a GPIO bank, base being the 0x40020000 with offsets

enum { GPIO_MODE_INPUT, GPIO_MODE_OUTPUT, GPIO_MODE_AF, GPIO_MODE_ANALOG };

GPIO Struct and Address

All we have to do is once again clock through it from RCC. Specify which pins you want to interact with. Don’t forget to set the mode as shown previously. And interact with registers that allow for input and output set.

static inline void gpio_write(uint16_t pin, bool val) {
  struct gpio *gpio = GPIO(PINBANK(pin));
  gpio->BSRR = (1U << PINNO(pin)) << (val ? 0 : 16);
}

GPIO Write function, to toggle bits high or low

SysTick

SysTick is a way for us to keep time or to have constant time intervals in our program. The SysTick is just a 24 bit counter, that is clocked directly from the clock source of the processor. Once that counter is done, it wraps around and starts counting down again. We can use this as a way to keep constant time in our program. Essentially what we can do is specify a SysTick handler to be a part of the vector table.

Adding a function like this to the vector table means that this SysTick handler, essentially a timer, can run with the clock frequency of the processor to maintain constant time.

struct systick {
  volatile uint32_t CTRL, LOAD, VAL, CALIB;
};

#define SYSTICK ((struct systick *)0xe000e010)

static volatile uint32_t s_ticks; // volatile is important!!

void SysTick_Handler(void) { s_ticks++; } // global *volatile* variable will increase by one with each expiration of SysTick counter

static inline void systick_init(uint32_t ticks) { // we can set an arbitrary value relative to the default frequency of the processor: 16Mhz
  if ((ticks - 1) > 0xffffff)
    return; // Systick timer is 24 bit
  SYSTICK->LOAD = ticks - 1;
  SYSTICK->VAL = 0;
  SYSTICK->CTRL = BIT(0) | BIT(1) | BIT(2); // Enable systick
  RCC->APB2ENR |= BIT(14);                  // Always RCC!
}

SysTick Definition and Initialization Function

USART

USART or UART most commonly known is another peripheral that we can use. This is mainly for printing messages to the serial output on a computer. This allows for things like printf debugging which is more common since embedded systems can be hard to debug live.

All you have to do is find the specific peripheral, in this case we see that USART3 peripheral is wired directly to the USB port that is connected to our ST-Link debugger. So we can conveniently use USART3. Set the corresponding variables to enable transfer and receive and create methods to interact with it how we would expect.

USART
USART and ST-Link Communication [9]

Main Function

int main(void) {
  systick_init(16000000 / 1000);
  uart_init(UART3, 115200);             // Initialise UART
  uint16_t led = PIN('B', 7);           // Blue LED
  gpio_set_mode(led, GPIO_MODE_OUTPUT); // Set blue LED to output mode
  uint32_t timer, period = 500;         // Declare timer and 500ms period
  for (;;) {
    if (timer_expired(&timer, period, s_ticks)) {
      static bool on;      // static variables maintain their state 
                           // in between calls, in this case a for loop 
      gpio_write(led, on);
      on = !on;            // Toggle LED state
      char *message = "HELLO FROM USART3!\r\n";
      uart_write_buf(UART3, message, strlen(message));
    }
  }
  return 0;
}

Main Function

This is the main function once everything has been built.

We see that we create our systick_init function for our specific clock frequency divided by 1000, so that the timer will trigger an interrupt every millisecond. We create a period of 500 * 1ms and a timer to keep track of things.

Our GPIO ports are defined and modes are also set and once we enter our while loop we can expect a Blinky LED.

Video Demo

Flashing and Running Code on board, with Minicom to Display Serial Output [10]

Takeaways

This experience has been incredibly enlightening for me. I have never fully engaged in a project as in-depth as this before, where I was involved in every aspect of the process. In the past, many elements, such as linkers and compilers, felt somewhat obscure to me. However, this journey has sparked a genuine curiosity about how they function.

I believe that embarking on a bare metal programming project like this should be a fundamental experience for anyone entering the embedded systems field. It also highlights the convenience that abstraction layers, such as STM’s HAL or the ESP-IDF, provide. I am excited to explore how I can push the boundaries of this knowledge further and perhaps venture into new territories.


References

1 - Cortex-M4 Family ↩

2 - STM32 Nucleo Boards ↩

3 - GitHub Repository for Bare Metal Programming Guide ↩

4 - STM32 Nucleo-F439ZI ↩

5 - Page 20 - Block Diagram for STM32 Nucleo-F439Zi ↩

6 - Arm GNU Toolchain ↩

7 - OpenOCD ↩

8 - GNU Make ↩

9 - Page 26 - USART Connection to ST-Link ↩

10 - Video Demo ↩