Zig comptime for C projects using CMake

Hello,

I want to share an experience I had on an embedded sound project with a friend. We are building a wavetable oscillator: it’s a Eurorack module for musicians. It was designed as a C project only.

Background

We chose a STM32. The project uses CMake as a build system and I use two different compilers:

  • GCC for debug mode: : The debugging experience is great with GCC. It is the default compiler used when generating a prject.
  • Clang for release mode: : It optimizes better than GCC in our case; my main loop runs faster.

At one point in the project, I needed to manage the volume gain with an ADC, but not on a linear scale. I needed a logarithmic scale, which is more faithful to human hearing.
To be faster at runtime, I started to generate a Look-up Table (LUT) at startup.

static uint16_t volume_factors[VOLUME_TABLE_SIZE];

static void generateVolumeFactorsLookupTable(void) {
#define VOLUME_STEP_DB ((VOLUME_MAX_DB - VOLUME_MIN_DB) / (VOLUME_TABLE_SIZE - 1))
    for (int i = 0; i < VOLUME_TABLE_SIZE; i++) {
        double db = VOLUME_MIN_DB + (i * VOLUME_STEP_DB);
        // G_db = 20 * log10 (G_linear)
        // G_linear (amplitude)= 10 ^ (G_db / 20)
        double gain = pow(10.0, db / 20.0);
        // Binary range from 0 to 2 ^ VOLUME_BIT_RESOLUTION at 0 dB
        volume_factors[i] = (uint16_t)(gain * (1 << VOLUME_BIT_RESOLUTION));
    }
}

It was not the best thing I could do, since I knew I could just put all the values in an array and compile it. One day, I had a revelation while randomly reading the official website

Add a Zig compilation unit to C/C++ projects, exposing the rich standard library to your C/C++ code.

Of course! Why not build a Zig library to generate what I need at comptime and integrate it to CMake. The comptime feature feels to open a lot of possibilities.

Integrate Zig

After digging around, I figured out how to integrate Zig, a solution was to add this build step in compile.cmake. After some trials and tests :

set(ZIG_SRC ${CMAKE_SOURCE_DIR}/user/app/comptime.zig)
set(ZIG_OBJ ${CMAKE_BINARY_DIR}/comptime.o)
set(ZIG_OBJ_H ${CMAKE_BINARY_DIR}/comptime.h)

# Theses application headers can be used by Zig. Rebuild on change.
file(GLOB ZIG_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/user/app/*.h)

add_custom_command(
    OUTPUT ${ZIG_OBJ}
    COMMAND zig build-obj ${ZIG_SRC} -static -target thumb-freestanding-eabihf -mcpu=cortex_m4+vfp4d16sp+dsp -fsingle-threaded
            -femit-bin=${ZIG_OBJ} -femit-h={ZIG_OBJ_H} -I${CMAKE_CURRENT_SOURCE_DIR}/user/app
            -fno-unwind-tables -fno-ubsan-rt -OReleaseFast
    DEPENDS ${ZIG_SRC} ${ZIG_HEADERS}
    COMMENT "Compiling Zig code"
)

add_library(comptime STATIC ${ZIG_OBJ})
set_target_properties(comptime PROPERTIES LINKER_LANGUAGE C)

Note about argument:

  • -target thumb-freestanding-eabihf -mcpu=cortex_m4+vfp4d16sp+dsp -fsingle-threaded : The target description.
  • -fno-unwind-tables -fno-ubsan-rt : Removes unwanted symbols.
  • -I${CMAKE_CURRENT_SOURCE_DIR}/user/app The C includes path for my Zig library.

Then, update your main CMakeLists.txt with the following:

include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/comptime.cmake)
target_link_libraries(${EXECUTABLE} comptime)
target_link_options(-Wl,-z noexecstack) # Add -Wl,-z noexecstack

If you use a Zig library with GCC, you need to add -Wl,-z noexecstack as a linker argument, or ld will complain about a missing .note.GNU-stack section.
Here is the Zig code to generate the volume gain factors, which replaces the C implementation:

const std = @import("std");
const cfg = @cImport({
    @cInclude("system_configs.h");
});

export const volume_factors: [cfg.VOLUME_TABLE_SIZE]u16 = generateVolumeFactorsLookupTable();

fn generateVolumeFactorsLookupTable() [cfg.VOLUME_TABLE_SIZE]u16 {
    var table: [cfg.VOLUME_TABLE_SIZE]u16 = undefined;

    const step_db: f64 = (cfg.VOLUME_MAX_DB - cfg.VOLUME_MIN_DB) / (@as(f64, cfg.VOLUME_TABLE_SIZE) - 1.0);
    @setEvalBranchQuota(100000);
    for (&table, 0..cfg.VOLUME_TABLE_SIZE) |*elem, i| {
        // G_db = 20 * log10 (G_linear)
        // G_linear (amplitude)= 10 ^ (G_db / 20)
        const db = cfg.VOLUME_MIN_DB + (i * step_db);
        const gain = std.math.pow(f64, 10.0, db / 20.0);
        // Binary range from 0 to 2 ^ VOLUME_BIT_RESOLUTION at 0 dB
        elem.* = @intFromFloat(gain * @as(f64, (1 << cfg.VOLUME_BIT_RESOLUTION)));
    }
    return table;
}

The project uses a header configuration file (system_configs.h) for many options, and the cool thing is that I can easily import it into my Zig code with cImport

Header comptime.h to include by the C application:

#include <stdint.h>
#include "system_configs.h"

extern const uint16_t volume_factors[VOLUME_TABLE_SIZE];

Problem encountered and notes

  1. libc: Linking libc with the freestanding target is not implemented. In my initial trials, I got errors when the command tried to link it. In my case, I don’t need it, and I couldn’t find the CLI equivalent of .link_libc = false in mu build script. If I need libc, I would want to use my own implementation with picolibc. I don’t know why it not require it anymore too.

  2. Header Generation: I had to manually update my header file. The -femit-h= option seems to not generate the file. Is this disabled a at this time?

  3. @cImport Future: @cImport will be removed in the future. I expect I will need a step with zig translate-c to handle this.

Opening

Since embedded components or projects can use custom build systems based on CMake (pico-sdk, Zephyr RTOS, ESP-IDF, etc.), I was wondering how to add Zig in combination with these tools without rewriting the entire build process.
Some people have tried this, but it seems like a lot of effort to maintain. Here are some examples I found:

I will probably use the method described in this article when working with a CMake-based SDK.
If you have any suggestions or improvements for my implementation or ways to integrate Zig, I’d love to hear them :slight_smile: I’m not yet aware of all the limitations I might encounter with this method.
For now, I have my compile-time content generator, and it works well. I will add more content over time.

I had been using the preprocessor and X Macros to generate code at compile-time in C, but with Zig, the code feels much more readable and has opened my mind to more complex possibilities. It is pretty cool. I also get access to the Zig standard library! :smiley:

5 Likes

@setEvalBranchQuota should be set to the actual upper bound of backwards branches (loop/recursion iterations) or a good estimate, which you have right there as the table size.

Header generation is broken, has been for some time, no idea when it will be fixed, it has been a few times but broke again, just that it will be before 1.0.

translate-c is in fact the intended replacement of @cImport

  • GCC for debug mode: : The debugging experience is great with GCC. It is the default compiler used when generating a prject.

This is interesting… what are those differences to Clang? For me the choice between GCC and Clang is usually the platform I work on (e.g. on macOS I build my C code with Apple’s Clang, while on Linux I use GCC).

The only difference I’m aware of are slightly different warnings, but what are some of the downsides of using Clang for debug-mode vs GCC?

To clarify my point: I’m debugging with the -Og level to get a reasonable code size for my target.
I think both compilers are mostly fine for debugging without optimization (-O0). However:

The Clang -Og option behaves differently from GCC . According to the Clang documentation:
-Og is like -O1. In future versions, this option might disable different optimizations in order to improve debuggability.”

When I experimented with debugging under Clang, I found that the code was still too optimized in some scenarios with this option. For example, I couldn’t always set breakpoints where I wanted.

2 Likes

@setEvalBranchQuota should be set to the actual upper bound of backwards branches (loop/recursion iterations) or a good estimate, which you have right there as the table size.

I use it mostly for the std.math.pow function. Some Compilation error with different value:"

ldexp.zig:22:19: error: evaluation exceeded 1000 backwards branches
    if (math.isNan(x) or !math.isFinite(x))

float.zig:35:20: error: evaluation exceeded 10000 backwards branches
   comptime assert(@typeInfo(T) == .float);

frexp.zig:27:58: error: evaluation exceeded 17000 backwards branches
    const exp_bits: comptime_int = math.floatExponentBits(T);

It seems I need to set the value between 19000 and 20000.
Maybe the following is a more accurate way to use @setEvalBranchQuota?

    for (&table, 0..cfg.VOLUME_TABLE_SIZE) |*elem, i| {
        // G_db = 20 * log10 (G_linear)
        // G_linear (amplitude)= 10 ^ (G_db / 20)
        const db = cfg.VOLUME_MIN_DB + (i * step_db);
        @setEvalBranchQuota(20000);
        const gain = std.math.pow(f64, 10.0, db / 20.0);
        // Binary range from 0 to 2 ^ VOLUME_BIT_RESOLUTION at 0 dB
        elem.* = @intFromFloat(gain * @as(f64, (1 << cfg.VOLUME_BIT_RESOLUTION)));
    }