Adding PicolibC for Embedded (STM32 example)

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 need lgcc 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:

  1. Renaming g_pfnVectors to __interrupt_vector. The reference can be used by picolibc.
  2. Renaming the isr_vector section to the .text.init.enter section.
  3. Renaming the old stack symbol _estack to __stack.
  4. Changing the entry point Reset_Handler to _start in the __interrupt_vector table.
  5. Removing the Reset_Handler and LoopForever functions.
  6. 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 the SystemInit(); (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 with -Wl,--defsym or -Wl,-alias linker options. What is the equivalent in Zig? (Ref: printf) Theses options can be set into the linker script.

  • It seems I can compile C code using <math.h> without specifying elf.linkSystemLibrary("m"); in my build.zig file. Why? After examining the C archives with objdump, it seems that the libm.a archive is empty. All the math library functionality is included in the main library, libc_pico.a. Picolibc design choice.

4 Likes

Edit1:
I wrote the vector table in Zig and set it as the default when compiling. I find this more interesting. (topic updated)

Edit2:
I set the printf/scanf picolibc options in the linker script. I didn’t know why I didn’t think about it in the first place.

Edit3:
I found out why linking the math library is not needed with picolibc. All symbols are included in the main library. All questions solved.

Edit4:

  • Added a note about ldr sp, =__stack, which has been removed from the original Reset_Handler function.
  • Added a Testing chapter (this was primarily to check the binary size).
  • Added a comment about the OS layer in the Testing chapter, which is required for some compilation modes.
2 Likes

Awesome post! Super cool to read your deep dive. I think this is an extremely high-value target for convincing embedded developers to give Zig a try. If some popular “embedded friendly” libCs were added to all your codebase, and integration with the Zig build system was a little easier, adding a splash of Zig here and there to your existing C codebase would be pretty attractive.

1 Like