Created: 2023-08-12 | Last edited: 2023-08-14
I have been interested in low level programming ever since I started learning to code. I started my programming Journey with C++ writing simple CLI applications. My first project, a CLI personal finance tool is still up on my GitHub. I started with C++ thinking I was doing "low level programming"; I soon realized I wasn't. Eventually I found myself into osdev where I started working on ParOS. But once I learned that Embedded programming existed I realized that this is where I want to be. Osdev is still very interesting to me, I will just be taking the concepts over to embedded instead of desktop x86.
This series of posts will go over what I am currently learning when it comes to embedded programming. I have purchased a STM32L4 development board (NUCLEO-STM32L432KC). This series will NOT be using CubeMX, a HAL or any external tools along those lines. Everything will be done with bare metal programming. I will be learning everything I can about this board and embedded in general and will be using this series to write about what I am learning and explain it so that I am able to understand the topics better and so that hopefully someone else will learn something. So without further without further ado; lets get started.
P.S: I do not apologize for the lack of syntax highlighting in the code blocks. For now the site is staying very minimal. You can always look at my GitHub if you would like to see a pretty version. I may add fancy code blocks later.
I usually follow a fairly simple project structure. For now we will have our main directory and then a directory with all of our source files. Below is what my project setup looks like to get started with.
Project Structure|-- Makefile |-- linker_script.ld |-- .clangd |-- CMSIS/ | |-- ... | |-- Device/ | |-- ST/ | |-- STM32L4xx/ | |-- Include/ | |-- stm32l432xx.h |-- src/ |-- init.c |-- main.c
This basic file directory setup is perfectly fine for what I will be doing in the beginning of this series and it is what I recommend to start with since no projects are currently happening. I am just tinkering around with the board.
The CMSIS directory is provided by ST for all of their boards. It normally comes with CubeMX, their IDE, but as I stated earlier my goal is complete bare metal programming. If you are following along you can get this directory from here. We want the stm32l432xx.h file that I have listed in the project directory. This file has a bunch of predefined macros to access all of the memory mapped I/O of the microcontroller. This makes it so that they are CMSIS compliant as well as not having to write all of them manually.
The last thing I have setup is a file that tells clang (the LSP I use for C in Neovim) where to find that file as well as any other includes. This is done so that the linter does not show that the header file cannot be found. Below I list the contents of the file, which is to be named .clangd and is listed in the project structure above. The ../ is needed before the include paths because Neovim will be open in the src/ folder, so it needs to look a level back to the root.
.clangdCompileFlags: Add: [-I../CMSIS/Include, -I../CMSIS/Device/ST/STM32L4xx/Include]
The toolset that I use for flashing my microcontroller is called stlink. This gives access to st-flash along with a whole suite of excellent tools to get started.
I used to dislike Makefile due to it seeming complex. After sitting down and reading this guide I realized that Makefiles aren't so bad and are very useful. This will allow easy building and flashing of any code that I write. Below is the whole Makefile. For a pretty version please view it on my GitHub. I will go over a few small things that I learned along the way.
Makefile# Compiler and flags CC = arm-none-eabi-gcc OBJCOPY = arm-none-eabi-objcopy CFLAGS = -mcpu=cortex-m4 -mthumb -g -Wall -Werror -lgcc LDFLAGS = -T linker_script.ld -Xlinker --cref -Xlinker -Map -Xlinker main.map -nostartfiles INCLUDES = -ICMSIS/Include -ICMSIS/Device/ST/STM32L4xx/Include # Source and object directories SRC_DIR = src OBJ_DIR = obj # Find all source files in the source directory SRCS = $(wildcard $(SRC_DIR)/*.c) OBJS = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRCS)) # Target binary TARGET = main.elf # Build rules .PHONY: all all: $(TARGET) main.bin # Link all object files into an executable $(TARGET): $(OBJS) $(CC) $(CFLAGS) $(INCLUDES) $(LDFLAGS) $^ -o $@ # Convert the .elf to a .bin for flashing main.bin: $(TARGET) $(OBJCOPY) -O binary $< $@ # Rule to compile .c files into .o files $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c @mkdir -p $(@D) $(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@ # Clean up the project after building and flashing .PHONY: clean clean: rm -rf $(OBJ_DIR) $(TARGET) main.hex main.bin main.map # Ability to flash with Make .PHONY: flash flash: all @st-flash reset @st-flash erase @st-flash write main.bin 0x08000000
For those that do not know, Make is a build tool that will make building the project easier.
So with this Makefile we have the ability to build and flash the project to the microcontroller as well as clean up the project directory. Starting at the top I am just declaring variables for the the compiler and linker. The first two CFLAGS will set the correct cpu and the correct mode for the CPU to run in (Cortex M4 always runs with THUMB instructions). The extra LDFLAGS will give a memory map which I will dive into in a later post in this series. The SRCS and OBJS variables will use some magic Makefile functions to find all C source files and store them for later use.Now onto building the project. Makefiles run starting at the first target unless a different one is specified. In our case the first target is "all". The .PHONY keyword states that this target is not a file that needs to be created or accessed. Rules are setup as shown below.
targets: prerequisites command
For the rule to run, all of the prerequisite targets have to be built first. So in this case the "all" target will first go to the $(TARGET) target. And since that rule needs the object files to be built it will go to the that rule first. The rule to compile the .c files to .o files is fairly simple. Lets break it down to get a better understanding of how these rules work.
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
So this rule have a prerequisite of all of the .c files existing in the src/ directory, the % operator is a wildcard, similar to * in bash. Then the target is to create the objet files in the obj/ directory.
@mkdir -p $(@D)
This command creates a new directory for the object files. The $(@D) is a Makefile automatic variable. It grabs the directory part of the target, which in this case is $(OBJ_DIR) aka obj/. The @ infront of the command surpresses the output. Here is a reference to all the automatic variables
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
And lastly this is where the building happens. The first three variables are just being concatenated, it is setting up a normal gcc command with the correct flags and include directory. Then we get onto some more automatic variables. The $< is the name of the prerequisite, so in this case the .c file. Then the $@ is the name of of the target, so in this case the .o file. All of this comes together to make commands that look like what is shown below. You are able to see the commands being built for both of the source files we currently have.
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -g -Wall -Werror -lgcc -ICMSIS/Include -ICMSIS/Device/ST/STM32L4xx/Include -c src/init.c -o obj/init.o arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -g -Wall -Werror -lgcc -ICMSIS/Include -ICMSIS/Device/ST/STM32L4xx/Include -c src/main.c -o obj/main.o
Now moving onto the final rule to produce the executable, $(TARGET). Since I have already gone over how Make works and a specific example, I will breeze over the last parts. This rule simply invokes the linker with the correct flags and links all the object files together. The $^ automatic variables is a list of ALL of the prerequisites. After this stage we have our .elf file.
The last rule to run is converting the .elf to a .bin. This allows the st-tools flashing utility to read and flash the binary to the microcontroller.
That is all for the Makefile. The "flash" rule and "clean" rule do exactly what is expected. Now moving on to the linker script.
The job of the Linker is to take all of the object files and link them together into a single executable. This was shown when I went over the Makefile; main.o and init.o were built and these were linked together into main.elf. The job of the linker script is to tell the linker where to put different sections of the object files into memory. I will be going over the linker script that is used in this project, it is shown below.
MEMORY { flash : origin = 0x08000000, len = 256k sram1 : origin = 0x20000000, len = 48k sram2 : origin = 0x10000000, len = 16k } SECTIONS { .text : { *(.vector_table); *(.text); } >flash .data : { _data_values = LOADADDR(.data); _data_start = .; *(.data); _data_end = .; } >sram1 AT>flash .bss : { _bss_start = .; *(.bss); _bss_end = .; } >sram1 }
So first off with the MEMORY part of the file. This section allows us to specify different parts of the memory in our microcontroller. In the case of the STM32l432KC there is 256k of flash and 64k of SRAM split into two sections. This information can be found in the datasheet. Specifying the different parts of memory, their locations and lengths allows us to put specific parts of the program into different parts of the memory.
The next component of a linker script is SECTIONS. First off lets go over all of the different sections in a normal C program.
As you can see in the SECTIONS part of the linker script I am using there is no .rodata section. The read only data will be stored in the .text section for now. The *(.text) syntax tells the linker to take the .text sections from all the object files and place them where the *(.text) is located, in this case in the .text section. The *(.vector_table) has to do with the microcontroller startup code, that will be covered in the next post in this series. The ">flash" at the end of the .text section tells the linker to put the .text section into the flash memory which is specified in the MEMORY section.
The .data section gets a bit more interesting. Here I have defined some extra symbols. The "." is a variable that stores the current memory address, this is automatically updated by the linker. The _data_start and _data_end variables store the location of the .data section in memory. There are two different types of memory addresses that are important here. The first is LMA (load memory address), this specifies where the section will be loaded into memory. The second is VMA (virtual memory address), this specifies the virtual address; it is where the data will be placed into RAM so that it can be accesses during the programs runtime. In the case of the previous section, .text, the ">flash" specifies the LMA, which means that it will load the .text section into the flash at address 0x08000000 (since .text is the first section). The .data section is unique because it has initialized data that needs to be saved in the flash so that it can later be loaded into the RAM. Therefore we use the ">sram1 AT>flash" syntax. This means that the LMA is the flash, and the VMA is the sram1. This means that the .data section is loaded into the flash but copied into sram1 during runtime. The "LOADADDR()" function gives us the LMA. So we are able to initalize the global and static variables during runtime, since the LMA will be where the actual data is stored in flash. I will be going over initalization in a later post.
That is all in terms of getting setup for bare metal STM32 programming. I hope that the small Makefile and Linker script tutorials made everything going on in this project very clear. I know that when I first got started with them the syntax seemed very obscure. My next post will be going over initalization.