Zepyhr: multi-board setup

This blog post shows how to setup a Zephyr project that you can use for several boards.

Changelog

  • 2024-03-03 added an ESP32-C3 board

Why multiple boards in one project?

  • You start with a development board (such as STM Nucleo or Disco) while you
    wait for the actual hardware prototype.
  • You want to run hardware-independent unit tests, either on your desktop or on
    a CI/CD server like Jenkins.
  • You have to develop for many similar devices that only have slight
    differences, and you don’t want to have many almost-identical source trees.

(Ab)use of Makefiles

The following is orchestrated mostly by a Makefile.

Even when Zephyr itself uses CMake and Ninja, Makefiles are a nicer way to
bundle lots of shell snippets into one Makefile. You can view this Makefile also
as a collection of knowledge, or as a way to have things replicable.

This blog post is based on …

This post depends and improves on Zepyhr: reproducible project setup and uses it’s Makefile.zephyr_init.

Get list of defined board

If we just enter “make” to compile our sources, we instead see a list of of boards.

If we have one set of source files but several target boards, we need a way to
configure for a specific board. So if there is no “build/” directory, we are
asked to first configure for a specific board:

:~/src/multi-board-zephyr$ make

-----------------------------------------------------------------------------

You must first select with with board you want to work:

native                configure for native (used for unit-tests)
nucleo                compile for STM32 Nucleo
local                 configure for locally defined board

-----------------------------------------------------------------------------

Configure and compile for one of the boards

We select one of the boards, e.g. the provided STM32 Nucleo one:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
~/src/multi-board-zephyr$ make nucleo
west build \
	--pristine \
	-b nucleo_f303re \
	-o "build.ninja" \
	-- \
	-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
	-DOVERLAY_CONFIG="nucleo_f303re.conf"
-- west build: generating a build system
Loading Zephyr default modules (Zephyr base).
-- Application: /home/holger/src/multi-board-zephyr
# ... many more lines ...

There are some special things here at work:

  • In line 3, we order “west” to use a pristine environment whenever
    the configuration changes. So you can do “make local” and then “make nucleo” and the “build/” directory will completely switch. There’s no need
    for you to manually remove it with “rm -rf build”.
    to.
  • In line [BROKEN LINK: nucleo/f303re], we actually select the wanted board. This one is
    provided by Zephyr itself, and you can find it in
    https://github.com/zephyrproject-rtos/zephyr/tree/main/boards/arm/nucleo/f303re.
  • Line 5 tells Zephyr’s CMake to use Ninja, which compiles as if we would
    ask CMake to generate makefiles.
  • The two dashes in line 6 tell “west” to pass over all the future
    command-line options as-is to CMake.
  • Line 7 tells CMake to generate a compilation database. Use this with an
    LSP daemon like clangd or other tools that depend on it. Many editors like
    Emacs, Visual Studio, etc., offer special services if LSP is present. See more on
    LSP in the post Zepyhr: fixing LSP issues
  • Line 8 tells the build system to configure itself according to the
    specified configuration file. They are in a Linux-style KConfig / “.config”
    syntax. Note that only board-specific configurations should be placed there.
    Anything that should be used project-wide has a better place in “prj.conf”.

If the configuration step succeed, this will also automatically compile your code.

For subsequent compilations, you just enter “make” alone. Another “make nucleo” would also re-configure the “build/” directory. That would take more
time.

How this is implemented

The differentiation between “make” doing just a re-compile or asking you to
select a board is done like this:

1
2
3
4
5
6
all::
ifeq ("$(wildcard build/build.ninja)","")           (ref:build.ninja)
	@$(call show_boards)
else
	ninja -C build
endif
  • in line [[(build.ninja))] it checks if the build environment inside the
    build/” directory has been created. If not, it calls the Make function
    “show_boards”. More on this function in a moment.
  • but if it exists, we just call in line 5ninja” with our build
    directory as working dir

The make function is simple enought: basically only some decoration around “make help_boards”:

define show_boards
	@echo ""
	@echo "-----------------------------------------------------------------------------"
	@echo ""
	@echo "You must first select with with board you want to work:"
	@$(MAKE) --no-print-directory help_boards
	@echo ""
	@echo "-----------------------------------------------------------------------------"
	@echo ""
endef

The reason I made this a function is so that it is easy to call from several
places. In this Makefile, not only “make all” calls it eventually, but also
maybe “make menuconfig” or “make xconfig”.

Finally we have a multitude of “help_boards:” targets like this:

help help_boards::
	@echo "nucleo                configure and compile for STM32 Nucleo"

Configure and compile for simulated hardware

Zephyr includes a board called native_sim. Basically when you select this
“board”, your sources are compiled for your development compiter (in my case:
Linux). So they aren’t compiled for ARM or RISV-V, but for x86. The native
simulator even allows you to similar some hardware, e.g. an AT24 EEPROM.

However, what is most useful is that you can define unit-tests and run these
unit-tests than on your develpment compiter — or on a CI/CD server, like
Jenkins.

Here is how you configure Zephyr for this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.PHONY:: native
native: .west/config
	west build \
		--pristine \
		-b native_sim \
		-o "build.ninja" \
		-- \
		-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
		-DOVERLAY_CONFIG="native_sim.conf"
	west build

As before, any native-sim-related configuration should be put into
"native_sim.conf", (line 9).

Now, when we configure and compile, we now get a binary that we can run under
Linux (or WSL, if you’re on Windows):

~/src/multi-board-zephyr$ make native
west build \
	--pristine \
	-b native_sim \
	-o "build.ninja" \
	-- \
	-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
	-DOVERLAY_CONFIG="native_sim.conf"
-- west build: making build dir /home/holger/src/multi-board-zephyr/build pristine
-- west build: generating a build system
Loading Zephyr default modules (Zephyr base).
-- Application: /home/holger/src/multi-board-zephyr

# ... many lines omitted ...

[93/93] cd /home/holger/src/multi-board-zephyr/bui...ger/src/multi-board-zephyr/build/zephyr/zephyr.ex

It’s even named “*.exe” :-)

$ file build/zephyr/zephyr.exe
build/zephyr/zephyr.exe: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=d4b863c9b8d6e9e2265fdef874ec0b9df70efdc9, for GNU/Linux 3.2.0, with debug_info, not stripped

And you can call it normally:

~/src/multi-board-zephyr$ build/zephyr/zephyr.exe
Running TESTSUITE tests
===================================================================
START - demo_test
 PASS - demo_test in 0.000 seconds
===================================================================
TESTSUITE tests succeeded

------ TESTSUITE SUMMARY START ------

SUITE PASS - 100.00% [tests]: pass = 1, fail = 0, skip = 0, total = 1 duration = 0.000 seconds
 - PASS - [tests.demo_test] duration = 0.000 seconds

------ TESTSUITE SUMMARY END ------

===================================================================
PROJECT EXECUTION SUCCESSFUL

I will create another blog soon on how to integrate this into Jenkings: by
converting the output into the TAP format.

Define a local board

So far, we used boards already defined by the Zephyr source code. But perhaps
you want to use Zephyr on one of your own boards, where you don’t plan to
publish it upstream? That’s entirely possible, and the board called “local” in
this project is exactly that: a board defined for Zephyr but out-of-tree. The
Makefile snippet for it sounds familiar …

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.PHONY:: local
local: .west/config
	west build \
		--pristine \
		-b local \
		-o "build.ninja" \
		-- \
		-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
		-DOVERLAY_CONFIG="boards/arm/local/local_defconfig" \
		-DBOARD_ROOT=.
	west build

… but there are some differences:

  • Line 9 gives the full path to the default configuration of the
    board.
  • Line 10 specifies our project (not Zephyr) as the board root, so
    Zephyr won’t look into “=zephyr/boards/” but instead into “=boards/” when
    looking for boards.

Now we need to have such a “boards/arm/local/” directory and populate it with some files:

FilePurpose
Kconfig.boardthis is where you introduce board-specific Kconfig options
Kconfig.defconfigwithout setting CONFIG_BOARD to the name of your board, Zephyr wouldn’t find the following files
board.cmakecan contain CMake definitions, usually used for OpenOCD or JLink settings
local.dtsthe Device Tree for your board
local_defconfigthe default configuaration for your board, only put things there that isn’t in “prj.conf
support/openocd.cfgif you use OpenOCD, this contains configuration for it

Compiling some sources only for some boards

This can easily be done via “CMakeLists.txt”:

1
2
3
4
5
6
7
8
target_sources(app PRIVATE
  main.c)

target_sources_ifdef(CONFIG_BOARD_LOCAL app PRIVATE
  board_local.c)

target_sources_ifdef(CONFIG_BOARD_NATIVE_SIM app PRIVATE
  board_native.c)
  • Any sources that must compile for every board are specified like in line
    [BROKEN LINK: src/main]. Note that the hanging indent is there as a hint that you can
    specify multiple source files in one “target\/source” declaration.
  • According to line [BROKEN LINK: src\/local], the file “board\/local.c” will only be
    compiled if your current board is the board named “local”.
  • And you guessed it; line [BROKEN LINK: src\/native] ensures that this source file is only
    considered when compiling for the “native\/sim” board. Here, I’d put the
    device-independent unit tests, for example.

You can use the CONFIG_ … variables also direcly in your C sources:

#ifdef CONFIG_BOARD_LOCAL
   LOG_INF("Running on local")
endif

Configuration

You also learned about the various “*.conf” files like

But how to find out which “CONFIG_*” settings you can use?

Use either

  • make menuconfig” or
  • make xconfig

When you make changes and save, you can then just run “make” to compile your
board with these settings. However, to make these changes permanent (and
reproducible), you need to update one of the configuration files listed above.

Get help from make

I already showed “make help\/boards”. The same method (multiple pseudo
makefile targets emitting helpful text) is available to get an idea of what the
Makefile can do for you:

~/src/multi-board-zephyr$ make help
init                  do all of these steps:
   debs               only install debian packages
   venv               create and check Python3 virtual environment
   west               install and configure the 'west' tool
   zephyr             clone Zephyr
   modules            install Zeyphr modules (e.g. ST and STM32 HAL, CMSIS ...)
     module_cmsis     update only CMSIS
     module_espressif update only ESPRESSIF (ESP-32)
     module_st        update only ST HAL
     module_stm32     update only STM32 HAL

all                   compile for current board
menuconfig            run menuconfig for current board
xconfig               run xconfig for current board

esp32c3               configure and compile for ESP32-C3 DevKit M
local                 configure and compile for locally defined board
native                configure and compile for native (used for unit-tests)
nucleo                configure and compile for STM32 Nucleo