Created: 2023-08-12 | Last edited: 2023-10-27
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 (please do not look at it, it is very bad). 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 probably be aimed at an audience who already knows a fair amount about programming knowledge and probably atleast a bit of an understanding about low level programming and how computers work below software.
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.P.S.S: I will not be using the HAL of CubeMX IDE for one main reason, learning purposes. Going "bare metal" (meaning writing directly on the hardware instead of having an abstraction layer between) will allow me to gain a better understanding of how the hardware, setup and peripherals function. Actually setting up the bare metal environment and understanding that will result in a much deeper knowledge.
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 and its peripherals. This makes it so that they are CMSIS compliant as well as not having to write all of them manually. This is still considered bare metal and not creating any further abstractions since this is just a header file with preprocessor directives.
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 where the file is located..clangd
CompileFlags: 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 arm-none-eabi is necessary since these microcontrollers are arm based. If I were to use normal gcc it would compile the code for a 64 bit x86 system. 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. Makefile 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.
So this rule has a prerequisite of all of the .c files existing in the src/ directory, the % operator is a wildcard for pattern matching targets. Then the target is to create the object files in the obj/ directory matching to each C source files.
@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 you may expect. After this we have a basic project setup. I did not cover the linker script in this post as it is related to the systems memory and initalization. That will be covered in the next post. Feel free to email me if you have any questions, thoughts or suggestions. I will be answering any and all emails.