AUSTIN MORLAN

CODE CONTACT LINKEDIN RSS
May 09, 2020

Game Programming on an ESP32: Build System (Part 1)


Before we can write any code for the Odroid Go, we first need to get the ESP32’s SDK set up. It contains the code that actually gets the ESP32 to boot and call into our main function, as well as code for peripherals like SPI which we’ll need to use when we write our LCD driver.

Espressif calls the SDK the ESP-IDF and we’ll be using the latest stable version which is v4.0.

We can either clone the repository per their instructions (with the recursive flag) or just download the zip from the releases page.

Our first objective is to get a minimal Hello World sort of application deployed to our Odroid Go that proves our build environment is properly configured.

C vs C++


The ESP-IDF uses C99 so that’s what we’ll use as well. We could use C++ if we wanted to (there is a C++ compiler in the ESP32 toolchain) but we’re going to stick with C for now.

I actually like C quite a bit and find its simplicity refreshing. No matter how much time I spend writing C++ code, I can never quite get to the point where I enjoy it. This person sums up my thoughts pretty well.

Besides, we can always switch to C++ in the future if needed.

Minimal Project


The IDF uses CMake to manage the build system. It also supports Makefiles but that is now deprecated as of v4.0, so we’ll just use CMake.

At the very minimum we need a CMakeLists.txt file describing our project, a main directory that contains a source file with the entry point into our game, and another CMakeLists.txt file inside of main that lists our source files.

CMake needs to reference some environment variables that tell it where to find the IDF and where to find the toolchain. I found it annoying to have to set them all again every time I started a new terminal session, so I wrote a script called export.sh to do the work for me. It sets IDF_PATH and IDF_TOOLS_PATH and sources the IDF export which sets up other environment variables.

All that’s needed of someone using the script is to set the IDF_PATH and IDF_TOOLS_PATH variables.

IDF_PATH=
IDF_TOOLS_PATH=


if [ -z "$IDF_PATH" ]
then
	echo "IDF_PATH not set"
	return
fi

if [ -z "$IDF_TOOLS_PATH" ]
then
	echo "IDF_TOOLS_PATH not set"
	return
fi


export IDF_PATH
export IDF_TOOLS_PATH

source $IDF_PATH/export.sh

The root CMakeLists.txt:

cmake_minimum_required(VERSION 3.5)

set(EXTRA_COMPONENT_DIRS "src")
set(COMPONENTS "esptool_py src")

include($ENV{IDF_PATH}/tools/cmake/project.cmake)

project(game)

By default the build system will build every possible component inside of $ESP_IDF/components which causes our compilation time to be longer than necessary. Instead we want the bare minimum set of components required to get our main function called, and then we can add additional components later as we require them. That’s what the COMPONENTS variable does.

We tell the IDF to look in the src directory for the code for our game with EXTRA_COMPONENT_DIRS and specifying src in COMPONENTS. The esptool_py component is required for flashing the binary to the ESP32.

The CMakeLists.txt inside of src:

idf_component_register(
	SRCS "main.c"
    INCLUDE_DIRS ""
    PRIV_REQUIRES "")

The main.c is as simple as it gets:

#include <stdio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>


void app_main(void)
{
	for (;;)
	{
		printf("Hello World!\n");
		vTaskDelay(1000 / portTICK_PERIOD_MS);
	}

	// Should never get here
	esp_restart();
}

All this does is print “Hello World” to the serial monitor every second forever. The vTaskDelay is using FreeRTOS to delay.

Notice that our function is called app_main and not main. The actual main function is used by the IDF to do necessary set up and then it creates a task with our function app_main as its entry point.

A task is just a unit of execution that FreeRTOS can schedule. We don’t really have to worry about it right now (or maybe ever) but the important thing to note is that our game is running on a single core (the ESP32 actually has two cores), and the task is delayed execution for one second with every iteration of the for loop. During that delay the FreeRTOS scheduler is free to go and execute other code that is waiting for its turn to run, if there is any.

We may opt to use both cores but for now we’ll just stick with a single core.

Components


Even if we reduce the list of components to the bare minimum required for a Hello World application (which is esptool_py), it still builds some other components that we don’t need due to the way the dependency chain is set up. It will build all of the following:

app_trace app_update bootloader bootloader_support cxx driver efuse esp32 esp_common esp_eth esp_event esp_ringbuf
esp_rom esp_wifi espcoredump esptool_py freertos heap log lwip mbedtls newlib nvs_flash partition_table pthread
soc spi_flash tcpip_adapter vfs wpa_supplicant xtensa

A lot of those make sense (bootloader, esp32, freertos), but then there are those that we don’t need because we aren’t using any networking features: esp_eth, esp_wifi, lwip, mbedtls, tcpip_adapter, wpa_supplicant. Unfortunately we still have to build those components.

Thankfully the linker is smart enough to not link unused components into our final game binary. We can verify that with make size-components.

Total sizes:
 DRAM .data size:    8476 bytes
 DRAM .bss  size:    4144 bytes
Used static DRAM:   12620 bytes ( 168116 available, 7.0% used)
Used static IRAM:   56345 bytes (  74727 available, 43.0% used)
      Flash code:   95710 bytes
    Flash rodata:   40732 bytes
Total image size:~ 201263 bytes (.bin may be padded larger)
Per-archive contributions to ELF file:
            Archive File DRAM .data & .bss   IRAM Flash code & rodata   Total
                  libc.a        364      8   5975      63037     3833   73217
              libesp32.a       2110    151  15236      15415    21485   54397
           libfreertos.a       4148    776  14269          0     1972   21165
                libsoc.a        184      4   7909        875     4144   13116
          libspi_flash.a        714    294   5069       1320     1386    8783
                libvfs.a        308     48      0       5860      973    7189
         libesp_common.a         16   2240    521       1199     3060    7036
             libdriver.a         87     32      0       4335     2200    6654
               libheap.a        317      8   3150       1218      748    5441
             libnewlib.a        152    272    869        908       99    2300
        libesp_ringbuf.a          0      0    906          0      163    1069
                liblog.a          8    268    488         98        0     862
         libapp_update.a          0      4    127        159      486     776
 libbootloader_support.a          0      0      0        634        0     634
                libhal.a          0      0    519          0       32     551
            libpthread.a          8     12      0        288        0     308
             libxtensa.a          0      0    220          0        0     220
                libgcc.a          0      0      0          0      160     160
                libsrc.a          0      0      0         22       13      35
                libcxx.a          0      0      0         11        0      11
                   (exe)          0      0      0          0        0       0
              libefuse.a          0      0      0          0        0       0
         libmbedcrypto.a          0      0      0          0        0       0
     libwpa_supplicant.a          0      0      0          0        0       0

The absolute biggest contributor to our binary’s size is libc and that’s okay.

Project Configuration


The IDF allows us to set compile-time configuration parameters that it uses during build to enable and disable different features. There are a few parameters that we should set that let us take advantage of some of the extras provided by the Odroid Go.

First we need to source the export.sh script so that CMake has access to the necessary environment variables. Then, like all CMake projects, we need to create a build directory and call CMake from within it.

source export.sh
mkdir build
cd build
cmake ..

If we run make menuconfig, we’ll get a window to configure project-specific settings.

Enabling Optimizations

We’ll enable optimizations to increase performance. Normally optimizations makes debugging code more difficult because they change the code flow, but we aren’t able to debug anyway because we don’t have access to JTAG.

We can enable them by going to Compiler options -> Optimization Level -> Release (-Os).

Expanding flash to 16MB

The Odroid Go expands the default amount of flash storage to 16MB which we can enable by going to Serial flasher config -> Flash size -> 16MB.

Enabling external SPI RAM

We also have access to an additional 4MB of external RAM connected via SPI. We can enable it by going to Component config -> ESP32-specific -> Support for external, SPI-connected RAM and pressing Space to enable it. We would like to be able to explicitly allocate memory from the SPI RAM which we can enable by going to SPI RAM config -> SPI RAM access method -> Make RAM allocatable using heap_caps_malloc.

Lowering the clock speed

Finally, the ESP32 defaults to 160MHz but let’s set it to 80MHz for now and see how far we can get on the slowest clock speed. We’d like to be able to run on battery power and lowering the frequency will help to save battery life. We can change it by going to Component config -> ESP32-specific -> CPU frequency -> 80MHz.

If we select Save a file called sdkconfig will be saved to the root of the project directory. We could check that file into git but it has a lot of parameters in it that we really don’t care about. Right now we’re okay with the defaults for most everything except for the above parameters that we just changed.

We can instead create a file called sdkconfig.defaults which will contain only the values we changed above. Everything else will be the defaults. At build time the IDF will read sdkconfig.defaults, override the values that we set, and use defaults for all other parameters.

sdkconfig.defaults looks like this for now:

# Set flash size to 16MB
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y

# Set CPU frequency to 80MHz
CONFIG_ESP32_DEFAULT_CPU_FREQ_80=y

# Enable SPI RAM and allocate with heap_caps_malloc()
CONFIG_ESP32_SPIRAM_SUPPORT=y
CONFIG_SPIRAM_USE_CAPS_ALLOC=y

# Enable optimizations
CONFIG_COMPILER_OPTIMIZATION_LEVEL_RELEASE=y

In total, the initial structure of our game will look like this:

embedded_game_programming
├── CMakeLists.txt
├── export.sh
├── src
│   ├── CMakeLists.txt
│   └── main.c
└── sdkconfig.defaults

Building and Flashing


The actual process of building and flashing the image is simple enough.

We run make to actually compile (add -j4 or -j8 for a parallel build), make flash to flash the image to the Odroid Go, and make monitor to view the output of the printf statements.

make
make flash
make monitor

We could also do them all in one go:

make flash monitor

Demo


The result of all of this is not very exciting but it sets us up for the rest of the project.

Monitor

Source Code


You can find all of the source code here.

References


Discussion