Adding Picolibc to an Embedded C/Zig Project (STM32 Example)
Hello everyone,
I’d like to share my experience integrating a custom libc build into my embedded C/Zig project for STM32. I’m using Zig 0.14.0 (to be released soon).
The libc I chose is Picolibc because I’ve heard a lot about it, and it is well-documented.
The result (branch 0.14):
Context: Why Do I Need libc and Use C?
-
I use a manufacturer software generator (STM32CubeMX in this case) to simplify device initialization and peripheral configuration. The generated code is in C.
-
These projects rely heavily on C libraries such as FreeRTOS, filesystems, and GUI libraries, which depend on libc.
-
I also found it interesting to integrate Zig into an existing embedded project by replacing the build system (which is very cool, BTW) and gradually adding Zig code to the project.
You can use the alternative newlib
built-in version of libc provided by GCC, as demonstrated in this STM32 Zig Porting Guide and even in my Blinky example code.
You can also explore the libc topic further here: Adding Picolibc (or an Alternative) for Embedded?
1. Build PicolibC
First, I checked how to build Picolibc. The library uses the Meson build system, so after digging into the basics, I saw that the library can be built with GCC or Clang. I decided to compile the library with Clang. One of the reasons for this:
zig cc --version
clang version 19.1.7 (https://github.com/ziglang/zig-bootstrap 50d8e88ba329b5d58a212f9fd2e9b1ad59a88e7d)
If Clang can compile it successfully, then Zig can probably do it as well.
A lot of Meson scripts are available in the project. I used this file as a base for my work: cross-clang-thumbv7e+fp-none-eabi.txt, as it corresponds most closely to my device. After making some changes to adapt the build to my CPU parameters, here is the result:
# Meson settings to compile picolibc with clang.
[binaries]
c = ['clang-19', '-m32', '-target', 'thumb-freestanding-eabihf', '-mcpu=cortex-m4', '-mfloat-abi=hard', '-mfpu=fpv5-sp-d16', '-nostdlib']
cpp = ['clang-19', '-m32', '-target', 'thumb-freestanding-eabihf', '-mcpu=cortex-m4', '-mfloat-abi=hard', '-mfpu=fpv5-sp-d16', '-nostdlib']
as = ['clang-19', '-m32', '-target', 'thumb-freestanding-eabihf', '-mcpu=cortex-m4', '-mfloat-abi=hard', '-mfpu=fpv5-sp-d16', '-nostdlib']
ar = 'llvm-ar-19'
nm = 'llvm-nm-19'
strip = 'llvm-strip-19'
[host_machine]
system = 'none'
cpu_family = 'arm'
cpu = 'arm'
endian = 'little'
[built-in options]
c_args = [ '-Werror=double-promotion', '-Wno-unsupported-floating-point-opt', '-fshort-enums']
c_link_args = [ '-Wl,-z,noexecstack', '-Wno-unused-command-line-argument']
cpp_link_args = [ '-Wl,-z,noexecstack', '-Wno-unused-command-line-argument']
# Default flash and ram information for stm32l476rgt
[properties]
skip_sanity_check = true
default_flash_addr = '0x08000000'
default_flash_size = '0x00100000'
default_ram_addr = '0x20000000'
default_ram_size = '0x00018000'
I have removed the GCC dependency as well. (It is still needed for testing, see the note below.)
Now I will compile it into a container with the following options.
meson setup --cross-file /workspace/libc/cross-clang-thumbv7e+fp-custom.txt \
--prefix=/workspace/libc \
-Dtests=false \
-Dpicocrt=true \
-Dnewlib-global-atexit=true \
-Ddebug=false \
-Doptimization=s \
/picolibc/
Done, I got my libc. A lot of options are available; don’t hesitate to modify what you need.
Note
-
I want to use
zig cc
directly, but the Meson build system does not support Zig. Maybe it will in the future? GitHub issue -
Tests will not compile (option:
-Dtests=true
) because you needlgcc
to compile them. You need to add-L/usr/lib/gcc/arm-none-eabi/<version>/thumb/v7e-m+fp/hard/'
to the C/C++ link arguments
c_link_args = ['-L/usr/lib/gcc/arm-none-eabi/14.2.1/thumb/v7e-m+fp/hard/', '-Wl,-z,noexecstack', '-Wno-unused-command-line-argument']
cpp_link_args = ['-L/usr/lib/gcc/arm-none-eabi/14.2.1/thumb/v7e-m+fp/hard/', '-Wl,-z,noexecstack', '-Wno-unused-command-line-argument']
And ensure there is enough space in the RAM and flash sections. (e.g., as in the original file)
default_flash_size = '0x00400000'
default_ram_size = '0x00200000'
2. Update the Linker Script
Picolibc provides linker scripts picolibc.ld
and picolibcpp.ld
, and this is my second reason for choosing Clang to compile picolibc. It makes these scripts compatible with lld
and usable directly by Zig without modification. A minimal custom linker script for my target is simpler:
/* This will override default value provided by picolibc */
__flash = 0x08000000;
__flash_size = 1024K;
__ram = 0x20000000;
__ram_size = 96K;
__stack_size = 512;
INCLUDE libc/lib/picolibc.ld
I can set printf/scanf picolibc options too
vfprintf = __m_vfprintf;
vfscanf = __m_vfscanf;
You can add more settings about the target as a second ram section .ram2
(for my case).
Next step, update the startup code and the vector table.
3. Update the startup code and the vector table
I created my own startup file vector_table.zig
with modifications for picolibc integration. You can see the assembler and C implementations under the folder vector_table_examples
. Here is what needs to be changed from the original file startup_stm32l476xx.s
generated by STM32CubeMX
:
- Renaming
g_pfnVectors
to__interrupt_vector
. The reference can be used by picolibc. - Renaming the
isr_vector
section to the.text.init.enter
section. - Renaming the old stack symbol
_estack
to__stack
. - Changing the entry point
Reset_Handler
to_start
in the__interrupt_vector
table. - Removing the
Reset_Handler
andLoopForever
functions. - Removing the symbols
_sidata
,_sdata
,_edata
,_sbss
, and_ebss
.
Add your project-specific target startup. The _init
function is a good place for that. It will be called before entering your main function. Do not use the crt0-minimal
option, or it will never be called. Example:
void _init(void)
{
// CMSIS System Initialization
SystemInit();
}
Note about ldr sp, =__stack
that is removed
The instruction ldr sp, =__stack
is not used anymore . This instruction is used by ARM architecture to initialize the stack pointer. For STM32 microcontrollers (and many other ARM Cortex-M-based microcontrollers), the stack pointer is automatically initialized by the hardware using a specific memory address defined in the vector table (here 0x08000000
)
I don’t know exactly why this instruction is in the template (it might be for historical or compatibility reasons), but you can remove it without any issues. One example where it might be needed is when you have a bootloader and your starting address changes (e.g., 0x08010000
). However, in such cases, you would typically set the new vector table before jumping to the application in the bootloader.
4. Add Picolibc to Your Project with Zig
To add your libc to your build, add the following into your build.zig
file:
elf.addLibraryPath(.{ .cwd_relative = "libc/lib/" });
elf.addSystemIncludePath(.{ .cwd_relative = "libc/include" });
elf.linkSystemLibrary("c");
elf.linkSystemLibrary("crt0"); // Set of execution startup routines (ex: _start)
If you compile your Zig program using elf.linkSystemLibrary("c");
or if your module has the option .link_libc = true
, you may encounter the following error:
error: libc not available
note: run 'zig libc -h' to learn about libc installations
note: run 'zig targets' to see the targets for which zig can always provide libc
Zig does not currently allow you to customize your own libc implementation (am I wrong?). Maybe custom implementations will be easier in the future. See this issue that talk about it.
However, there is a hacky way to add it anyway, you can rename the libc library and link it manually.
mv libc/lib/libc.a libc/lib/libc_pico.a
Then, change elf.linkSystemLibrary("c");
to elf.linkSystemLibrary("c_pico");
.
Now it compiles, however, Zig code will not benefit from the libc implementation; only the C
components will.
Testing
- Zig version 0.14.0-dev.3089,
- Newlib (arm-none-eabi-gcc (xPack GNU Arm Embedded GCC x86_64) 14.2.1 )
- No printf float support.
- HAL drivers, sin,sinf,snprintf,malloc,printf
I tested both blinky programs with both libc implementations to verify the consistency of the binaries.
Mode | newlib (bytes) | picolibc (bytes) |
---|---|---|
Debug | 29056 | 25140 |
ReleaseSafe | 29480 | 25436 |
ReleaseSmall | 26504 | 23012 |
Debug+lto | 24200 | 20736 |
ReleaseSafe+lto | 24688 | 21108 |
ReleaseSmall+lto | 22560 | 19368 |
My Picolibc build seems to take up less space compared to the integrated newlib one.
I had to add a custom OS layer in syscalls.c
. It is currently blank but can be mapped to a serial port in the future.
Conclusion
And voilà, my experimentation. I enjoyed making picolibc work on my target from scratch. Don’t hesitate to share your ideas, suggestions, or corrections about it.
You can find a functional example in my repository blinky_picolibc with a container image and instructions to build picolibc.
Notes
-
I need to control the binary size output; it seems a little larger than I expected.Tested above -
One feature I miss for my developpement is the ability to generate a JSON Compilation Database. However, you can use this Zig package or maybe get lucky with Zig itself soon.
-
Maybe the next step could be to create a
build.zig
module for picolibc. It seems like a lot of work. -
Efforts are being made to create a libc in pure Zig! Foundation libc
Questions
-
For STM32 users, what special features does theSystemInit();
(CMSIS System Initialization) call provide that the libc startup code does not? Clock configuration and vector table registers, what else?SystemInit
is specific to hardware and configures it (clock, flash, MPU, interrupts, etc.). -
Some picolibc features can be enabled/disabled withTheses options can be set into the linker script.-Wl,--defsym
or-Wl,-alias
linker options. What is the equivalent in Zig? (Ref: printf) -
It seems I can compile C code usingAfter examining the C archives with<math.h>
without specifyingelf.linkSystemLibrary("m");
in mybuild.zig
file. Why?objdump
, it seems that thelibm.a
archive is empty. All the math library functionality is included in the main library,libc_pico.a
. Picolibc design choice.