Post

Baremetal Arduino: Setting up a development enviroment

I decided to play around with my Arduino boards again, and this is an excellent opportunity to explain how to set up a development environment for cross-compilations. For this project, we need to set up some tools. The tools I decided to use are the avr-toolchain, including AVRDUDE, Docker to set up the toolchain, and Meson as the build system. We will mainly focus on the Arduino boards, which use Atmels ATmega328p Microcontroller. These boards are, for example, the Arduino Uno and the Arduino Nano.

Meson
Meson is a build system I quite enjoy. It keeps the build configuration simple and easy to understand and also makes it easy to set up cross-compilation.
Docker
Allows application deployment via containerization. Docker makes it easy to define the toolchain once and reuse it on different systems or for CI/CD.
AVRDUDE
A versatile tool to program Atmel AVR microcontrollers. The Arduino IDE also uses it and provides the necessary configurations for most of the Arduino boards.

Setup the Toolchain with Docker

We will set up the environment with our toolchain via Docker. Docker allows us to handle our environment with a single file and makes it easy to reproduce it on multiple machines. For our current use case, we need to create a file called Dockerfile with this content:

1
2
3
4
5
6
7
8
FROM alpine:3.18

RUN apk --no-cache add ca-certificates bash wget git make \
  meson ninja-build \
  avr-libc gcc-avr \
  avrdude

WORKDIR /workdir

The first line, FROM alpine:3.18, defines our base image. We use Alpine Linux as our base. Alpine is a small distribution that already provides everything we need in its package repository.

The RUN instruction is the heart of this file. This instruction runs the Alpine package manager apk and installs the needed software. For this project, we need the packages meson and ninja-build to provide our build system. The packages gcc-avr and avr-libc provide us with the compiler and the necessary libraries, and avrdude installs the programmer we will use. The other packages are installed for convenience. I like to work directly in the container. For this, we need bash. The packages wget, git, and make are required for Meson Subprojects, which will not used in this blog post.

The last instruction is WORKDIR. This instruction sets and creates the working directory for the container. The entry points of the container use this directory.

The next step is to create an image out of our Dockerfile. We can do this by calling:

1
docker build -t zie87/avr-toolchain .

The argument -t gives our image a name that can be chosen freely. In this case, the name for the image is zie87/avr-toolchain. With the command docker images, we can check if the image is available. After the build step, we can switch to a bash shell in our container with the following command:

1
docker run --rm --init --device "/dev/ttyACM0:/dev/ttyACM0" -v $PWD:/workdir:Z -e LANG=$LANG -it zie87/avr-toolchain /bin/bash

The command docker run runs the container and starts bash for use inside it. The meaning of the parameters are:

--rm
Removes the container if it already exists
--init
This flag tells Docker to use an init process as PID 1 inside the container. It allows to handle signal propagation and zombie processes correctly.
--device "/dev/ttyACM0:/dev/ttyACM0"
This option allows us to pass through a device from our host machine into the container. Devices are later needed to allow AVRDUDE access to the Arduino boards from our container.
-v $PWD:/workdir:Z
This option mounts our current directory as a volume to the container with \workdir as the mount point. The :Z option allows us to write directly to this mount point.
-e LANG=$LANG
This option sets the LANG environment variable to the value of the LANG variable from your host system. It can avoid some issues when we work inside the container.
-it
These flags are used to start an interactive session within the container. It opens a terminal and allows us to interact with the shell.
zie87/avr-toolchain
This is the name of the Docker image we want to run.
/bin/bash
The last argument is the command we want to run inside the container. In this case, it starts a Bash shell, and together with the -it argument, we now have an interactive shell in the container.

We can also run our container in a detached mode. To do so, we need to add the flag -d additionally and remove the call of the bash interpreter. The command would look like this:

1
docker run -d --rm -it --init --device "/dev/ttyACM0:/dev/ttyACM0" -v $PWD:/workdir:Z -e LANG=$LANG zie87/avr-toolchain

With docker ps, we can now see our container. The output can look like this:

1
2
3
$ docker ps
CONTAINER ID   IMAGE                 COMMAND                  CREATED         STATUS         PORTS     NAMES
63c5437ec73b   zie87/avr-toolchain   "/bin/sh"                2 minutes ago   Up 2 minutes             cranky_haibt

The name (cranky_haibt) is chosen by the docker engine. To define a different name, we must add the name with the --name flag to the run command. With the container detached, we can directly send commands to it with docker exec:

1
docker exec cranky_haibt ls -la

The command will list all files in the current directory of the container. With docker exec, we can run our build commands and scripts in the container without having to call docker run every time again. If we want to stop the detached container, we can do so with the following:

1
docker stop cranky_haibt

Basic build configuration

Meson already ships a tool that will provide us with a basic setup. We can use this tool by calling the meson init command:

1
meson init --name blinky --language c --build --builddir build/atmega328p

The arguments have the following meanings:

--name blinky
Defines blinky as the name of the project and the executable.
--language c
Sets C as the programming language for the project
--build
Triggers a build directly after the generation
--builddir build/atmega328p
Sets the directory build/atmega328p for the build

The command mainly creates two files: blinky.c and meson.build. The file meson.build is our main build system configuration, and blinky.c is a C example. Both could be more useful for our use case, so we must replace/adapt them. Let’s start with blinky.c. We replace the content of the file with a simple blinky implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <avr/io.h> // Contains I/O Register definitions
#include <util/delay.h>

#define MS_DELAY 1000

int main(void) {
  DDRB |= _BV(DDB5); // Configuring PB5 as Output

  while (1) {
    PORTB |= _BV(PORTB5); // Writing HIGH to PB5
    _delay_ms(MS_DELAY);
    PORTB &= ~_BV(PORTB5); // Writing LOW to PB5
    _delay_ms(MS_DELAY);
  }
}

This code will switch the internal LED of an Arduino UNO/Nano each second. The next thing we need to change is the meson.build file. Currently, the file should look like this:

1
2
3
4
5
6
7
8
project('blinky', 'c',
  version : '0.1',
  default_options : ['warning_level=3'])

exe = executable('blinky', 'blinky.c',
  install : true)

test('basic', exe)

We need to adapt this configuration for our cross-build. The project definition is fine for now. We can delete the test definition, and we need to make some changes to the definition of our executable:

1
2
3
4
5
6
exe = executable(
    'blinky',
    sources: ['blinky.c'],
    c_args: ['-DF_CPU=16000000UL'],
    name_suffix: 'elf',
)

The first change explicitly defines the sources parameter. For now, this is more of a cosmetic change. More important is the line c_args: ['-DF_CPU=16000000UL']. This argument defines the clock frequency (16 MHz) to our build, which the AVR libraries must provide to, e.g., the _delay_ms function. The last change is to define the file extension with: name_suffix: 'elf'. We need to manipulate the executable later to flash it to our device. For this, we need to be able to distinguish the different files by extension.

Enable the cross-compilation

Meson supports cross compilation through a cross-build definition file. This file allows us to set up the environment for our cross-toolchain. This file is structured in different sections. The first section, which is vital for us, is called: [binaries]. This section defines programs that will be used internally by Meson, like the compiler, or which need to be available for the find_program function. In our case, this section looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[constants]
prefix = 'avr'

[binaries]
c           = prefix + '-gcc'
cpp         = prefix + '-g++'
ld          = prefix + '-ld'
ar          = prefix + '-ar'
as          = prefix + '-as'
size        = prefix + '-size'
objdump     = prefix + '-objdump'
objcopy     = prefix + '-objcopy'
readelf     = prefix + '-readelf'
strip       = prefix + '-strip'

We use the [constants] section to define the prefix avr for all our toolchain programs and then define the name of each program. Most important are, for now, the entries c and strip, which are used internally by Meson for our current build.

The next step is configuring the compiler and the linker for our MCU via the [built-in options] section. This section overrides the built-in defaults of Meson.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[built-in options]
b_staticpic = 'false'
default_library = 'static'

c_args = [
    '-mmcu=atmega328p',
    '-ffunction-sections',
    '-fdata-sections',
    '-flto', 
    '-fno-fat-lto-objects',
    ]

c_link_args = [
    '-mmcu=atmega328p',
    '-Wl,--gc-sections',
    '-static',
    '-flto',
    '-fuse-linker-plugin',
    '-Wl,--no-warn-rwx-segment',
    '-Wl,--print-memory-usage',
    ]

The first option we need to override is b_staticpic. This variable defines if static libraries are built with position independence. The default value is true, which could mess up our object layout, so we must turn off this option. We do not use shared objects, so we also set the default library type to static.

Next are the compiler flags. We set our MCU type by adding -mmcu=atmega328p to the compiler flags. The flags -ffunction-sections and -fdata-sections tell the compiler to place each function and data item in its own section in the output file. This setting allows better link time optimization, which we also enable by the flag -flto. The last flag, “-fno-fat-lto-objects,” will ensure only slim objects are provided, which helps with the link time.

The last configurations we need to set are for the linker. We need to select the MCU type as we have done for the compiler. The flags -flto and -fuse-linker-plugin are required for the link time optimizations. We ensure static linkage with the -static flag and --gc-sections can reduce the executable size based on the compiler flags -ffunction-sections and -fdata-sections. The last arguments are for convenience: --no-warn-rwx-segment suppresses a warning about the LOAD segment permissions, which does not apply to use, and --print-memory-usage gives us an excellent output about the used memory regions after the linkage.

If you need more clarification about the proper compiler configurations, you can use the Arduino IDE builds as a template. If you run the build, the output shows which flags are used by the compiler and the linker.

The last section we need to take care of is [host_machine]:

1
2
3
4
5
[host_machine]
system     = 'atmega328p'
cpu_family = 'avr'
cpu        = 'atmega328p'
endian     = 'little'

In this section, we define some information about the machine that will run our code later. Here, we give our system and CPU a name (atmega328p). We also define the cpu_family based on the reference table, and we provide the information about the system’s endianness.

With this in place, we can build our application for the Arduino board. To build it, we need to first configure our project with the cross-definition file we have created. We do this with meson setup:

1
meson setup --cross-file ./avr-atmega328p.txt --reconfigure --buildtype minsize build/

Then, we can compile the project:

1
meson compile -C build

After this, we will find a blinky.elf file in the build directory. We can verify if the build is proper for our Atmel MCU with the command file ./build/blinky.elf. The command should show us an output like this:

1
2
$ file build/blinky.elf
build/blinky.elf: ELF 32-bit LSB executable, Atmel AVR 8-bit, version 1 (SYSV), statically linked, with debug_info, not stripped

Programm the Arduino

We have created an ELF-file, which contains all the necessary information about how the data and code must be organized. But to program it, we need to align this with the memory regions of the Arduino. For this, we need to convert the ELF-file into an HEX-file, which would reflect the memory regions of the Arduino. The tool avr-objcopy creates this HEX-file for us. The call of avr-objcopy will look like this:

1
avr-objcopy -O ihex -R .eeprom blinky.elf blinky.hex

We can integrate this step directly into our Meson build. To do so, we need to define the binary for objcopy in the [binaries] section of our cross-build definition file (avr-atmega328p.txt). Doing so allows us to find the program with the find_program function of Meson. Next, we must add the program call to our meson.build file. We can do this by adding the following lines to the file:

1
2
3
4
5
6
7
8
9
10
objcopy = find_program('objcopy')

custom_target(
    'blinky_hex',
    input: exe,
    output: ['blinky.hex'],
    build_by_default: true,
    command: [objcopy, '-O', 'ihex', '-R', '.eeprom', '@INPUT@', '@OUTPUT@'],
    depends: [exe]
)

The call of the find_program function gives us the avr-objcopy executable. We must then define a custom build target via the custom_target function. The first parameter (blink_hex) is the target’s name. Next, we must define our input and output. The input is simply the executable target (exe), and the output is defined as blinky.hex. The command argument specifies our call to avr-objcopy. Here, we only provide the parameters for the call separated by ‘,’ to the objcopy-program and use the defined input and output as parameters by the corresponding @INPUT@ and @OUTPUT@. The last steps are to ensure it is called by the default build with: build_by_default: true and define that this should only run after the executable was compiled by defining the dependency: depends: [exe].

Now, if we recompile the project with meson compile -C build, we will also find the file blinky.hex in the build directory. This hex file can be used by AVRDUDE to program our Arduino boards. The command to start the programming looks like this:

1
avrdude -p atmega328p -c arduino -P /dev/ttyACM0 -b 115200 -U flash:w:./build/blinky.hex:i

Let us unpack the provided arguments:

-p atmega328p
This defines the MCU, which is connected to our programmer.
-c arduino
Defines the used pin configuration. This configuration is read from a configuration file, and the id arduino provides a predefinition for Arduino UNO/Nano boards.
-P /dev/ttyUSBO
Port where the programmer is connected to. Usually, this would be a COM port under Windows, and under Linux, it is /dev/ttyUSBx or /dev/ttyACMx.
-b 115200
Defines the baud rate for the programmer. Mainly, 115200 baud should work, but for some cheap clones, you need to reduce the baud rate to 96000 or 57600.
-U flash:w:./build/blinky.hex:i
Spezialize the memory operation in the format: memtype:op:filepath[:format]. The field memtype defines the memory type to operate on, in our case, flash. The operation is ‘w’ in our case, which will read the date from the provided file and write it to the device memory. The filepath is the path to our HEX-file (/build/blinky.hex), and the format value ‘i’ indicates that we use the Intel Hex format1.

Summary

In this blog post, we created a development environment for Arduino with the use of Meson, Docker and ADRDUDE. The setup of such an environment is a small amount of work, which pays off quickly. The environment we have created allows us to reproduce our builds on each system that supports docker. The build system makes it easy to compile and ensures the compilation configurations for the complete project.

References

Footnotes

  1. It would also be possible to provide the ELF-file by defining the format with e. The call would look like this: avrdude -p atmega328p -c arduino -P /dev/ttyACM0 -b 115200 -U flash:w:./build/blinky.elf:e 

This post is licensed under CC BY 4.0 by the author.