image

STM32 development workflow part 2

Table of contents
  1. Introduction
  2. Create a project structure
  3. Add files
  4. CMake
  5. Building
  6. First task
  7. OpenOCD
    1. Task for flashing
    2. tasks.json
  8. Debugging
  9. Adding HAL library
    1. A simple example with HAL library
  10. Summary
  11. Extras
    1. Compiler update
    2. VScode tips
      1. red squiggles
      2. auto-save and auto-format

Introduction

So far, everything was working only if you were using the same MCU as mine - STM32F405RGT. Since, arguably, not everybody is programming drones on this exact MCU, in this post I will show how to set up a project from scratch and along this way reveal a bit of the mystery of how everything works and what was the purpose of some files from a previous post. Luckily this will be the last part of this complicated process and ideally, after that, you should be able to develop any STM32-based board.

Create a project structure

Open the folder where you want your project and create new folders: Src, Drivers, bin, build, link. I keep the same folder structure among all projects so that I can just copy an existing one and change only MCU-specific parts. Of course, you can have different folder structures, but be aware of that and make suitable adjustments throughout this post.

imageBasic project's structure

Add files

The next step is downloading and adding all essential files to the project. Let’s start with linker scripts (.ld files). These provide instructions to the linker on how to combine object files (files generated by a compiler) into an executable program or a library. It contains information about stack, heap sizes, memory layout, etc. To get linker scripts you can either write them yourself or use pre-generated from some IDE (or copy them from someone’s project). Let’s use CubeMX since you will likely be using it in some scenarios anyway. Just create a new empty project for your MCU and in the main branch, .ld files can be found. Copy them into your project’s link folder.

imageCopy linker scripts

Next, to use CMSIS-Core we need to add a bunch of header files and two source files (more about it here: link). In essence, they are necessary to start the microcontroller up, set default values, and provide constants, structures, and functions that you know from the reference manual. In my case, these files are:

  • startup_stm32f411xe.s,
  • system_stm32f4xx.c,
  • system_stm32f4xx.h,
  • stm32f4xx.h,
  • stm32f411xe.h,
  • all files from the CMSIS/Include folder (see screenshot below).

image

When you see ‘x’ in these names, files are relevant for any MCU that matches the name, disregarding parts where the ‘x’ is. It is possible because many MCUs are built similarly and only a few things need to be specified for the exact MCU and these are chosen by specifying a special constant during compilation (-DSTM32F411xE) - more about it later. I copied these files from the blank project in CubeIDE to my project:

image

The last file is a .svd file (System View Description) - it is not essential and you can compile, flash, and even debug code without it. However, if you want to have access to the registers’ values, which are often essential for debugging purposes, this file is required. In general, you can find it in many places on the Internet (an example) but a good idea is to check the ST site and download it from the manufacturer.

imageLook for your MCU

imageChoose the correct one

imageDownload .svd files package

Then choose the right file and copy it into the main branch:

image

CMake

Next what you need is CMakeList.txt. This file contains all instructions for CMake to conduct a building process for your project. You can write it from scratch or copy mine and make some adjustments.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
cmake_minimum_required(VERSION 3.16)
set(CMAKE_SYSTEM_NAME Generic)

# turn off compiler checking (assume that the compiler is working):
set(CMAKE_C_COMPILER_WORKS TRUE)
# set C standard:
set(CMAKE_C_STANDARD 17)
# set path where binary files will be saved:
set(BUILD_DIR ${CMAKE_SOURCE_DIR}/bin)
set(EXECUTABLE_OUTPUT_PATH ${BUILD_DIR})

# create targets variables:
set(TARGET "main")
set(TARGET_ELF "${TARGET}.elf")
set(TARGET_HEX "${TARGET}.hex")
set(TARGET_BIN "${TARGET}.bin")

# create project name and define required languages for building (C and assembler - ASM should be at the end):
project(MY_PROJECT C ASM)

# assign paths into variables:
set(MAIN_SRC_DIR "${CMAKE_SOURCE_DIR}/Src")

set(LINKER_DIR "${CMAKE_SOURCE_DIR}/link")
set(LD_include "-lnosys -L${LINKER_DIR}")
set(linker_script "${LINKER_DIR}/STM32F411RETX_FLASH.ld")

set(MCU_flags   "-mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard -mthumb ")

# C definitions (additional arguments parse with cmd line):
set(C_DEFS " -DSTM32F411xE ")
# C-specific flags:
set(C_flags     "${MCU_flags} ${C_DEFS} -Wall -fdata-sections -ffunction-sections -fanalyzer ")
# Assembler-specific flags:
set(AS_flags    "${MCU_flags} -Wall -fdata-sections -ffunction-sections ")
# Linker's flags:
set(LD_flags    "${MCU_flags} -specs=nano.specs -specs=nosys.specs -T${linker_script} ${LD_include} -Wl,--print-memory-usage -u _printf_float ")

# CMake variables setup:
set(CMAKE_C_FLAGS "${C_flags}")
set(CMAKE_ASM_FLAGS "${AS_flags}")
set(CMAKE_EXE_LINKER_FLAGS "${LD_flags}")

# add all your executable files:
add_executable(${TARGET_ELF}
    Src/startup/startup_stm32f411retx.s
    Src/startup/system_stm32f4xx.c
    Src/main.c
)

# include all directories where header files occur:
target_include_directories(${TARGET_ELF} PUBLIC
    Src
    Src/startup
    Drivers
    Drivers/Include
)

# link GNU c and m ("math") libraries (more here: https://www.gnu.org/software/libc/manual/pdf/libc.pdf):
target_link_libraries(${TARGET_ELF} PUBLIC c m)

# set shortcut for command:
set(OBJCOPY arm-none-eabi-objcopy)
# make new targets .hex and .bin from .elf file:
add_custom_target(${TARGET_BIN} ALL COMMAND ${OBJCOPY} -O binary -S ${BUILD_DIR}/${TARGET_ELF} ${BUILD_DIR}/${TARGET_BIN})
add_custom_target(${TARGET_HEX} ALL COMMAND ${OBJCOPY} -O ihex -S ${BUILD_DIR}/${TARGET_ELF} ${BUILD_DIR}/${TARGET_HEX})

# define dependencies so that .hex file is created after .elf and .bin as the last one:
add_dependencies( ${TARGET_HEX} ${TARGET_ELF})
add_dependencies(${TARGET_BIN} ${TARGET_ELF} ${TARGET_HEX})

For each project, you only need to adjust MCU_flags, C_DEFS, linker_script name, and executables paths.

MCU_flags are used to tell the compiler about the used family of MCU, FPU type, and some other things. How do you know which flags are required for your MCU? You can either check it on the Internet or use CubeMX and copy flags from the generated project.

For the second option, generate an empty project for your MCU again but remember to choose Project Manager->Project->Toolchain/IDE = Makefile.

image

Then search in the generated Makefile for CFLAGS and copy them into your CMakeLists.txt.

image

In the same file, a few lines below you can find C_DEFS from which only the first one is important (for more advanced projects you may want some of the other definitions). This defines some configurations that are specific to your MCU in stm32f4xx.h file (or similar for your MCU family).

image

Next update the linker_script variable accordingly to your .ld file (STM32F411RETX_FLASH.ld in my case) and add all of your executables - remember about the assembler startup file (.s-ended file).

image

Building

Now it is time to build the project. We can use the terminal Ctrl+Shift+` in the build directory (cd ./build) and write: cmake --build ..

imageIf everything is ok you should have similar notifications

First task

You may presume that it is not particularly convenient to write everything in the cmd line each time you want to build your program (you need to be in the right directory and remember the commands). Therefore VScode allows you to set some presets (called tasks) and use them with shortcuts. Let’s create the first task for building our program:

image

image

One file is added (.vscode/tasks.json) containing all your tasks. You can add more or modify the existing ones but for now, just press Ctr+Shift+B and build your program. There should be the same notifications as building from the terminal (completed builds and statistics of used memory). If you want to use my task “Make Firmware” for building, delete the existing one (from .vscode/tasks.json) and paste the code below (it gives no problems with other tasks that we will soon add).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "Make Firmware",
      "type": "shell",
      "detail": "build program",
      "command": "cmake --build .",
      "options": {
        "cwd": "${workspaceRoot}/build"
      },
      "group": {
        "kind": "build",
        "isDefault": true
      },
      "problemMatcher": {
        "base": "$gcc",
        "fileLocation": ["absolute"]
      }
    }
  ]
}

OpenOCD

Having built our program it is time to load it into a microcontroller - so far everything was made on your host computer (you don’t need to have your MCU connected).

The bin folder contains binary files (zeros and ones) that could be transferred to any MCU (however it would transferred successfully only for your type of MCU). Moreover, different programming devices use various interfaces (st-link-v1, st-link-v2, jtag …) and openOCD supports many of these options. Consequently, you have to tell openocd what kind of target device and interface you want to use. You can write it in the terminal (in the project’s main directory): openocd.exe -f interface/stlink-v2.cfg -f target/stm32f4x.cfg -c 'program ./bin/main.bin verify reset exit 0x08000000'.

Remember to choose the target (stm32f4x.cfg) and interface (stlink-v2.cfg) respectively to the used MCU (you can find it here) and programmer (for Nucleo’s programmer leave stlink-v2.cfg).

image

Task for flashing

For me, rather long command to write each time you want to flash your program, so let’s make a task for that (remember to choose the correct interface and target):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
		{
            "label": "Load Firmware",
            "type": "shell",
            "detail": "flash into MCU",
            "command": "openocd.exe",
            "args": [
                "-f",
                "interface/stlink-v2.cfg",
                "-f",
                "target/stm32f4x.cfg",
                "-c",
                {
                    "value": "program ./bin/main.bin verify reset exit 0x08000000",
                    "quoting": "strong"
                }
            ],
            "options": {
                "cwd": "${workspaceRoot}"
            },
            "group": {
                "kind": "build",
                "isDefault": true
        	}
    	}
	]
}

tasks.json

If you want, you can add 2 more tasks - one for rebuilding a whole project and the second one to combine building and flashing into one task:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
    "label": "Clean & Build",
    "type": "shell",
    "detail": "clean first and build program",
    "command": "cmake --build . --clean-first ",
    "options": {
      "cwd": "${workspaceRoot}/build"
    },
    "group":{
      "kind": "build",
        "isDefault": true
    },
    "problemMatcher": {
      "base": "$gcc",
      "fileLocation": ["absolute"]
  }
},
{
    "type": "shell",
    "label": "Build & Load",
    "dependsOrder": "sequence",
    "dependsOn": ["Make Firmware", "Load Firmware"],
    "group": {
      "kind": "build",
      "isDefault": true
    },
    "detail": "build and load program into MCU"
}

Now when you press Ctrl+Shift+B and choose between the tasks:

image

Debugging

For debugging, we need to provide some information: where cortex-debug extension can find the .svd file, what kind of interface we are using, etc. For this purpose, VScode has a launch.json file. Press Ctrl+Shift+P and write Debug: Add Configuration....

image

A new file is created (.vscode/launch.json). Delete the default config code and copy the below one:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
"version": "0.2.0",
    "configurations": [
     	{
          // name for debug settings:
          "name": "Debug (OpenOCD)",
          // use cortex-debug:
          "type": "cortex-debug",
          "request": "launch",
          // use openocd for connection:
          "servertype": "openocd",
          "cwd": "${workspaceFolder}",
          // where program will stop (at entrance to main function):
          "runToEntryPoint":"main",
          "showDevDebugOutput": "both",
          // before debugging build program (see task.json):
          "preLaunchTask": "Make Firmware",
          // path for .elf binary:
          "executable": "${workspaceFolder}/bin/main.elf",
          // turn on live variables:
          "liveWatch": {
          	"enabled": true,
          	"samplesPerSecond": 4
          },
          "configFiles": [
            "${workspaceFolder}/openocd.cfg"
          ],
          // .svd file name that you want to use:
          "svdFile":"STM32F411.svd"
    	}
	]
}

Remember to change the name of the .svd file to match with your. After that, we need to add the last file to the main branch - openocd.cfg file, with 4 lines of code. Cortex-debug needs it to have info about the interface etc.. Anyway, create a file and copy my code into it (adjust for your interface and target):

image

Now, let’s debug - press F5 and enjoy. On the left, you can find all the interesting stuff (live watch, registers’ values, variables, etc.) but for now, you can set breakpoints and iterate through your code:

image

This is the end of the configuration. You can program, build, flash, and debug your code 😊

Adding HAL library

It is worth remembering that using HAL for programming your MCU is not essential. I have personally created a whole software for a flying drone only on registers. However, at some point (for me, it was adding USB handling), it became too impractical not to use it. Therefore, as much as I think writing your libraries is enlightening and leads to a better understanding of algorithms and programming itself, it is not the way for casual projects. Having said that, let’s add the HAL library to the project.

Once more, let’s use an empty project from CubeMX. Things worth changing in CubeMX: clock configuration (to make your MCU run with desired frequency) and Project Manager->Code Generator->STM32Cube MCU…packs = Copy all used libraries into the project folder.

Doing so will generate a folder with a whole HAL library not only with basic functions (since the project is empty and no peripheries are used).

image

Next, we can copy all the necessary files: stm32f4xx_hal_conf.h, stm32f4xx_it.h, stm32f4xx_hal_msp.c, stm32f4xx_it.c and a whole folder STM32F4xx_HAL_Driver:

image

You can put these files wherever you want (remember to change paths respectively) in my case, the structure looks like this:

image

Now you have all source code and header files to use HAL library but there are a few additional steps to make it work. Firstly, add all new paths into CMakeLists.txt - you can write it by hand but the easiest way is to copy it from the CubeMX project. Go to Makefile and find “C_SOURCES”. Now you have paths to all required HAL files:

image

Copy them with essential changes to your paths (in my case stm32f4xx_hal_msp.c and stm32f4xx_it.c have different paths). Also, add new headers directories (below executables):

imageAdd paths to executables and headers directories

Secondly, we need to add 2 functions: SystemClock_Config(void) and Error_Handler(void). Just copy them from the main.c generated by CubeMX to your main.c:

image

Next, add definitions of these functions at the top of the main.c. The last step is to replace all #include "main.h" with #include "stm32f4xx_hal.h" (in files: stm32f4xx_it.h, stm32f4xx_hal_msp.c and main.c).

imageAdd functions and declarations, change #include "main.h" to #include "stm32f4xx_hal.h"

Now, everything should be working (building with no problem) and you can use the HAL library. However, if you want to use any other peripheral you will need to add adequate paths in CMakeLists.txt (I recommend configuring these peripherals in CubeMX and next copy-paste all paths from the generated Makefile - check the example below).

A simple example with HAL library

A simple example of toggling LED on my Nucleo (F411RE) board:

  1. Configure PA5 (LED2 is connected to it) as output,
  2. Configure the clocks,
  3. In Project Manager->Advanced Settings check if you use only the HAL library, not the LL (let’s keep it simple for now),
  4. Choose Project Manager->Project->Toolchain/IDE = Makefile,
  5. Generate the code,
  6. Copy all additional paths from the Makefile (HAL handling for GPIO was added before so only the path to gpio.c needs to be added),
  7. Copy new files (gpio.c and gpio.h) and change #include "main.h" to #include "stm32f4xx_hal.h",
  8. Include gpio.h into your main.c,
  9. Add MX_GPIO_Init() and write a toggle routine in the main loop,
  10. Build and flash your program.

image1.

image2.

image3.

image4.

image6. 7. 8.

image9.

image10.

This is the end.

Summary

All in all, after these 2 posts, you can create the project from scratch for any MCU you want.

The project described in this post can be found on my GitHub: link - take anything you like.

There was a lot of information and many complicated tools, each of which could have a separate post. Therefore most of the descriptions and options are kept to a minimum. If you want to learn more, presumably you now understand enough to ask good questions and find the answer on your own. For a good start here are some links for materials that I found really interesting:

Extras

Compiler update

So far we have been using a compiler from 07.2021 but since then GCC has been upgraded a few times so why not take advantage of a new compiler and use it for our projects?

Everything is pretty simple - we need to download a new toolchain: link.

image

Next, install it wherever you want. I decided to keep everything in one place C:\tools\Toolchain GNU Arm:

image

Then remember to check if the path to the bin folder was added into environmental variables and we are ready to use our new compiler.

Finally, it is necessary to let to know CMake about a new compiler:

imageChoose a compiler with the status bar

imageOr choose it using the CMake extension

If you don’t have a status bar visible you can change it in the settings:

image

VScode tips

red squiggles

Errors highlighting and autocompletion are handled in VScode by Intellisense. This software is downloaded with a C/C++ extension for VScode (we did it in part 1). Unfortunately, it often doesn’t work properly right after installation. If it doesn’t see your header files despite that everything is working and the project can be built that means the Intellisence isn’t properly configured. Fortunately, there is a quick solution for that. We need to inform Intellisense to use CMake’s files to get paths for all headers that we include. Open C/C++ Configurations (Ctrl+Shift+P) either JSON or UI and add "configurationProvider": "ms-vscode.cmake-tools" to your configuration.

imageAdd this line of code

Now Intellisense should use information about included headers from CMkaeLists.txt and red squiggles should be gone.

auto-save and auto-format

VScode allows to set auto-save on each change of focus and simultaneously formatting code to maintain readability. To set it, go to settings and change the auto-save option as well as formatting on after saving option:

imageChoose your favourite option

imageEnable formatting after saving