Adding ucLog to a device¶
The device library is C, for bare-metal firmware and for Zephyr. This page describes the library
as released with these docs (device-v4). Its source is device/ in the
ucLog repository, mirrored on its own as
uclog-device for projects that only need it.
A project needs four things:
- The library:
src/log.cand ucComm (COBS, CBOR, a ring buffer), included. - A link: a port for your UART, USB or BLE stack (bare metal), or a devicetree node (Zephyr).
- Two linker additions: a
LOGDATAregion for the format strings, which is never loaded, and a 64-byte.apphashfield in flash. - A post-link step: it writes the app hash into the image and makes the logdata from the ELF.
The examples do all four in a few files, bare metal and Zephyr, on a NUCLEO-L452RE-P. How the logs are decoded: From device code to screen.
Bare metal¶
Start-up and the main loop¶
int main(void) {
log_pre_init(); // logs are kept from here, before the link is up
board_init(); // clocks, the 1 kHz tick
log_init("my_board", "app"); // board name and image type, sent to the host
for (;;) {
log_poll(); // frames from the host; port handlers run here
...
}
}
void SysTick_Handler(void) {
log_tick(++ms); // 1 kHz: pulses and host presence
}
Logs are kept in an 8 KiB ring (LOG_TX_BUF_SIZE) until a host connects, and sent from then on;
the link is quiet while no host is there.
The port¶
The board implements include/log_port.h, the link under the library:
| Function | |
|---|---|
log_port_init(cb_t *tx) |
start the link; TX takes its data from tx, the library's ring |
log_port_tx_schedule(prefix, n) |
start sending: prefix (if any), then what is in the ring. Usually a DMA transfer of the ring's contiguous part, restarted from its completion interrupt |
log_port_tx_pause(bool), log_port_tx_idle() |
stop starting transfers (no host); no transfer in progress |
log_port_panic() |
switch to polled TX with interrupts off (LOG_FATAL) |
log_port_rx_avail(), _peek(), _skip(n) |
the received bytes, as a ring the library reads |
log_port_rx_mark() |
a value that changes whenever a byte arrives (a DMA counter) |
log_port_rx_check() |
clear UART errors; nonzero if there were any |
log_port_lock(), _unlock(), log_port_ms() |
a critical section, and a millisecond clock |
log_port_fatal() |
stop at a breakpoint under a debugger, else reset |
The example's port.c implements it for a UART with DMA in about 250 lines of CMSIS register
code, the board's clock and tick included. At high baud rates, receive with DMA into a circular buffer: a USART without a FIFO loses
bytes at 1 Mbaud when it is read from an interrupt.
The linker script¶
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 160K
LOGDATA : ORIGIN = 0xF0000000, LENGTH = 1M /* not loaded: the format strings */
}
SECTIONS
{
...
.apphash : { . = ALIGN(4); KEEP(*(.apphash)) } > FLASH /* written after linking */
...
INCLUDE log.ld /* places .logdata in LOGDATA */
}
LOGDATA's address also names the image: its target digit is (ORIGIN >> 20) & 0xF, so a
bootloader at 0xF0000000 (target 0) and an app at 0xF0100000 (target 1) can both log, and the
host tells their logs apart.
Building¶
UCLOG_DIR := path/to/device
include $(UCLOG_DIR)/uclog.mk
SRCS += $(UCLOG_SRCS) $(UCLOG_SYSCALLS_SRCS) # the library; newlib system calls (semihost)
CFLAGS += $(UCLOG_CFLAGS) -mfp16-format=ieee # include paths; ucComm needs the fp16 format
LDFLAGS += $(UCLOG_LDFLAGS) # so the linker script finds log.ld
$(BUILD)/app.elf: $(OBJS)
$(CC) $(LDFLAGS) $(OBJS) -o $@.tmp
$(call uclog_post_link,$@.tmp,$@,$(BUILD))
add_subdirectory(path/to/device uclog)
# or:
# include(FetchContent)
# FetchContent_Declare(uclog GIT_REPOSITORY https://github.com/UnitCircleInc/uclog-device GIT_TAG v4)
# FetchContent_MakeAvailable(uclog)
target_link_libraries(app PRIVATE uclog uclog_syscalls)
uclog_add_logdata(app)
CMake compiles absolute paths, which become the file names the logs show: map them with
-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}/= (the library maps its own to uclog/…).
Compile src/log.c, src/log_plot.c, src/semihost.c, optionally src/syscalls.c, and
uccomm/{cb,cobs,cbor,crc32c}.c, with include/ and uccomm/ on the include path (Arm GCC:
-mfp16-format=ieee). After linking, run what uclog.mk's uclog_post_link runs:
tools/hash.py, objcopy --update-section .apphash=…, tools/logdata.py, and
objcopy --add-section .logdata_cbor=….
Compile with -g: the logdata takes argument types, enums and symbols from the debug
information.
uclog_syscalls (UCLOG_SYSCALLS_SRCS) implements newlib's system calls over the link, so
printf(), stdin and files reach the host (semihost). Without it, link newlib's stubs
(--specs=nosys.specs) as usual.
Zephyr¶
The library is a Zephyr module. Add it to your west.yml:
manifest:
projects:
- name: uclog-device
url: https://github.com/UnitCircleInc/uclog-device
revision: v4
path: modules/uclog
or build with -DZEPHYR_EXTRA_MODULES=path/to/device. Then choose the link in the devicetree:
/ {
chosen {
uclog,link = &uclog0;
};
};
&usart2 {
current-speed = <1000000>;
uclog0: uclog {
compatible = "staric,ucuart_z"; /* any Zephyr UART */
current-speed = <1000000>;
};
};
and add the LOGDATA region with a snippet, west build -S uclog-target0 (or uclog-target1
for target 1). The snippet also sets ucLog's Kconfig:
CONFIG_UC_LOG=y
CONFIG_LOG=y
CONFIG_LOG_MODE_MINIMAL=y
CONFIG_LOG_CUSTOM_HEADER=y # Zephyr's LOG_INF() and friends become ucLog logs
CONFIG_CONSOLE=n
CONFIG_UART_CONSOLE=n
The library starts itself (no init call). The build adds the app hash, embeds the logdata and copies it to the hosts' cache.
| Option | |
|---|---|
CONFIG_UC_LOG_BUF_SIZE |
the log ring (default 8 KiB) |
CONFIG_UC_SYSCALLS |
semihost: stdout and stdin to the host's terminal, the host's files at CONFIG_UC_SYSCALLS_MOUNT (/host) |
CONFIG_UC_SHELL |
the Zephyr shell on the command port (snippet uclog-shell) |
CONFIG_UC_LOG_SAVE |
keep the log ring across a reset (log_saved_log()) |
CONFIG_UC_SIGNED_IMAGE |
the app hash from a signed image's header |
Notes:
- The device CI builds with Zephyr 3.7 LTS and 4.4.
- Logs name files relative to the app (
src/main.c), the West workspace (zephyr/...,modules/...) or the library (uclog/...): the module sets-fmacro-prefix-mapfor these, so no absolute path ends up in the firmware. - On an STM32L4 (a USART without a FIFO) at 1 Mbaud, give
ucuart_zDMA with a circular RX channel, as the example's overlay does: interrupt-driven RX, and DMA from one buffer to the next, lose bytes. - The drivers:
ucuart_z(any Zephyr UART; tested on hardware), anducuartanducusb(nRF UARTE and USB device; build-tested).
Links¶
The library sends and receives bytes; the link is the port's business. The hosts connect over:
| Link | On the device | Reference |
|---|---|---|
| UART (an ST-LINK or J-Link virtual COM port, a USB serial adapter) | a UART, ideally with DMA | the examples; every test board |
| USB CDC-ACM | a USB device stack's CDC class | the NUCLEO-C071RB test firmware |
| USB HID | a vendor-defined interface, 63 bytes per report | the NUCLEO-C071RB test firmware |
| Bluetooth LE | a GATT service with one characteristic: notifications out, writes in | the STM32WBA55G-DK1 test firmware |
The hosts' side of each link, and the frame format, are in the protocol. A board can serve several links: the test firmware for the C071 answers on UART, CDC and HID at once, its port switching to whichever the host uses.
What the build produces¶
| File | |
|---|---|
| the ELF | with the logdata embedded (.logdata_cbor): give the ELF to a host and it decodes the logs |
<app_hash>.logdata |
the logdata alone, also copied to the hosts' cache (~/.cache/uclog/, or $UCLOG_CACHE_DIR) |
| the binary | what you flash; its app hash is what the device reports |
A host finds the logdata by the device's app hash: a file you loaded, then its cache, then a logdata service if one is set up. Rebuilding changes the hash, so each build's logs are decoded with that build's strings.
The examples¶
Both log every second and answer led on, led off and led blink on the terminal. They are
built by the device CI and were checked on the board with the hosts. Source:
device/examples/.
Bare metal¶
cd device/examples/baremetal
make STM32CUBE_L4=path/to/STM32CubeL4 # or: cmake -B build -DCMAKE_TOOLCHAIN_FILE=cortex-m4f.cmake
make flash
uclog tui # or any host
// © 2026 Unit Circle Inc.
// SPDX-License-Identifier: Apache-2.0
//
// A minimal ucLog app, bare metal: a log every second, and a terminal (port 61) with two
// commands. Connect with any host, e.g. `uclog tui`, and type `help` in the Terminal view.
#include <stdint.h>
#include <string.h>
#include "log.h"
#include "port.h"
#define TERM_PORT 61 // the terminal (spec/protocol.md §5.7)
typedef enum { LED_OFF, LED_ON, LED_BLINK } led_mode_t;
static led_mode_t led_mode = LED_BLINK;
static void reply(const char *s) {
log_tx(TERM_PORT, (const uint8_t *)s, strlen(s));
}
// A line from the terminal (the hosts send whole lines, ending in \r). Handlers run in
// log_poll(), in the main loop.
static void on_terminal(const uint8_t *rx, size_t n, void *ctx) {
static char line[32];
static size_t len;
for (size_t i = 0; i < n; i++) {
char c = (char)rx[i];
if (c != '\r' && c != '\n') {
if (len < sizeof(line) - 1) line[len++] = c;
continue;
}
line[len] = 0;
len = 0;
if (line[0] == 0) continue;
if (strcmp(line, "led on") == 0) led_mode = LED_ON;
else if (strcmp(line, "led off") == 0) led_mode = LED_OFF;
else if (strcmp(line, "led blink") == 0) led_mode = LED_BLINK;
else {
reply("commands: led on, led off, led blink\r\n");
continue;
}
LOG_INFO("LED mode {enum:led_mode_t}%d", led_mode); // shown as LED_ON, LED_OFF or LED_BLINK
reply("ok\r\n");
}
}
int main(void) {
log_pre_init(); // logs are kept from here, before the link is up
board_init();
log_init("nucleo_l452re_p", "example"); // the board and image type, sent in the device info
log_notify(TERM_PORT, on_terminal, NULL);
LOG_INFO("hello from %s", "ucLog");
uint32_t next = 0;
uint32_t count = 0;
for (;;) {
log_poll(); // frames from the host: the terminal handler above
uint32_t now = board_ms();
if ((int32_t)(now - next) >= 0) {
next = now + 1000;
count++;
LOG_INFO("tick %u, uptime %.1f s", (unsigned)count, now / 1000.0);
if (count % 10 == 0) LOG_WARN("%u ticks without a reset", (unsigned)count);
if (led_mode == LED_BLINK) board_led(count & 1);
}
if (led_mode != LED_BLINK) board_led(led_mode == LED_ON);
}
}
// © 2026 Unit Circle Inc.
// SPDX-License-Identifier: Apache-2.0
//
// The board and the ucLog port (include/log_port.h) for the NUCLEO-L452RE-P, in CMSIS registers
// only: 80 MHz, SysTick at 1 kHz, the user LED, and USART2 (the ST-LINK's virtual COM port,
// PA2/PA3) at 1 Mbaud with DMA:
//
// - RX: DMA1 channel 6 writes a ring buffer, circular, without interrupts. The library reads it
// with avail/peek/skip; the DMA's remaining count is the write position.
// - TX: DMA1 channel 7 sends the contiguous part of the library's TX ring (or the prefix it asks
// for first), and its transfer-complete interrupt starts the next part.
//
// A USART without a FIFO (as here) loses bytes at 1 Mbaud if RX is interrupt-driven: use DMA.
#include "port.h"
#include <stm32l4xx.h>
#include "log.h"
#include "log_port.h"
#define BAUD 1000000u
static volatile uint32_t ms;
// ---------------------------------------------------------------------------------------------
// Board
static void clock_init(void) {
// HSI16 → PLL (M=1, N=20, R=4) = 80 MHz; 4 flash wait states; APB1 and APB2 undivided.
RCC->CR |= RCC_CR_HSION;
while (!(RCC->CR & RCC_CR_HSIRDY)) {
}
FLASH->ACR = FLASH_ACR_LATENCY_4WS | FLASH_ACR_PRFTEN | FLASH_ACR_ICEN | FLASH_ACR_DCEN;
RCC->PLLCFGR = RCC_PLLCFGR_PLLSRC_HSI | (0u << RCC_PLLCFGR_PLLM_Pos) | (20u << RCC_PLLCFGR_PLLN_Pos) |
(1u << RCC_PLLCFGR_PLLR_Pos) | RCC_PLLCFGR_PLLREN; // PLLR field 1: divide by 4
RCC->CR |= RCC_CR_PLLON;
while (!(RCC->CR & RCC_CR_PLLRDY)) {
}
RCC->CFGR = (RCC->CFGR & ~RCC_CFGR_SW) | RCC_CFGR_SW_PLL;
while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) {
}
SystemCoreClockUpdate();
}
void board_init(void) {
clock_init();
SysTick_Config(SystemCoreClock / 1000);
NVIC_SetPriority(SysTick_IRQn, 2); // below the TX DMA interrupt (0)
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOBEN;
GPIOB->MODER = (GPIOB->MODER & ~GPIO_MODER_MODE13) | GPIO_MODER_MODE13_0; // LD2: output
}
void board_led(bool on) {
GPIOB->BSRR = on ? GPIO_BSRR_BS13 : GPIO_BSRR_BR13;
}
uint32_t board_ms(void) {
return ms;
}
void SysTick_Handler(void) {
ms = ms + 1;
log_tick(ms); // pulses and host presence
}
void HardFault_Handler(void) {
LOG_FATAL("hard fault");
}
// ---------------------------------------------------------------------------------------------
// USART2 and its DMA
static uint8_t rx_buf[2048]; // more than one frame (1452 bytes) plus what arrives meanwhile
static size_t rx_read;
// TX state, shared with the DMA interrupt.
static cb_t *tx_cb; // the library's TX ring
static const uint8_t *prefix; // sent before tx_cb (the device info)
static volatile size_t prefix_n;
static volatile size_t tx_n; // bytes of the transfer in progress
static volatile bool tx_prefix_busy; // that transfer is the prefix
static volatile bool tx_paused;
static bool panic;
static void uart_init(void) {
RCC->AHB1ENR |= RCC_AHB1ENR_DMA1EN;
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOAEN;
RCC->APB1ENR1 |= RCC_APB1ENR1_USART2EN;
// PA2 (TX), PA3 (RX): alternate function 7, very high speed, RX pulled up.
GPIOA->MODER = (GPIOA->MODER & ~(GPIO_MODER_MODE2 | GPIO_MODER_MODE3)) | GPIO_MODER_MODE2_1 | GPIO_MODER_MODE3_1;
GPIOA->AFR[0] = (GPIOA->AFR[0] & ~(GPIO_AFRL_AFSEL2 | GPIO_AFRL_AFSEL3)) | (7u << GPIO_AFRL_AFSEL2_Pos) | (7u << GPIO_AFRL_AFSEL3_Pos);
GPIOA->OSPEEDR |= GPIO_OSPEEDR_OSPEED2 | GPIO_OSPEEDR_OSPEED3;
GPIOA->PUPDR = (GPIOA->PUPDR & ~GPIO_PUPDR_PUPD3) | GPIO_PUPDR_PUPD3_0;
// DMA1 request 2 is USART2 on channels 6 (RX) and 7 (TX).
DMA1_CSELR->CSELR = (DMA1_CSELR->CSELR & ~(DMA_CSELR_C6S | DMA_CSELR_C7S)) | (2u << DMA_CSELR_C6S_Pos) | (2u << DMA_CSELR_C7S_Pos);
DMA1_Channel6->CPAR = (uint32_t)&USART2->RDR;
DMA1_Channel6->CMAR = (uint32_t)rx_buf;
DMA1_Channel6->CNDTR = sizeof(rx_buf);
DMA1_Channel6->CCR = DMA_CCR_MINC | DMA_CCR_CIRC | DMA_CCR_PL_1 | DMA_CCR_EN;
DMA1_Channel7->CPAR = (uint32_t)&USART2->TDR;
DMA1_Channel7->CCR = DMA_CCR_MINC | DMA_CCR_DIR | DMA_CCR_TCIE; // enabled per transfer
NVIC_SetPriority(DMA1_Channel7_IRQn, 0);
NVIC_EnableIRQ(DMA1_Channel7_IRQn);
// 8N1; overrun detection off (DMA keeps up; a lost byte shows as a bad frame, which the library
// drops and counts).
USART2->BRR = SystemCoreClock / BAUD; // PCLK1 = SYSCLK
USART2->CR3 = USART_CR3_DMAR | USART_CR3_DMAT | USART_CR3_OVRDIS;
USART2->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;
}
static void dma_tx(const void *b, size_t n) {
DMA1_Channel7->CMAR = (uint32_t)b;
DMA1_Channel7->CNDTR = n;
DMA1_Channel7->CCR |= DMA_CCR_EN;
}
// Start the next transfer: the prefix, then what is in tx_cb. With no transfer in progress.
static void tx_next(void) {
if (tx_paused) return;
if (prefix_n > 0) {
tx_prefix_busy = true;
tx_n = prefix_n;
dma_tx(prefix, prefix_n);
} else if ((tx_n = cb_peek_avail(tx_cb)) > 0) {
dma_tx(cb_peek(tx_cb), tx_n);
}
}
// The transfer in progress is done: drop what it sent.
static void tx_done(size_t sent) {
if (tx_prefix_busy) {
prefix_n = 0;
tx_prefix_busy = false;
} else {
cb_skip(tx_cb, sent);
}
tx_n = 0;
}
void DMA1_Channel7_IRQHandler(void) {
if (DMA1->ISR & DMA_ISR_TCIF7) {
DMA1->IFCR = DMA_IFCR_CGIF7;
DMA1_Channel7->CCR &= ~DMA_CCR_EN;
tx_done(tx_n);
tx_next();
}
}
// ---------------------------------------------------------------------------------------------
// The ucLog port
uint32_t log_port_lock(void) {
uint32_t primask = __get_PRIMASK();
__disable_irq();
return primask;
}
void log_port_unlock(uint32_t key) {
__set_PRIMASK(key);
}
uint32_t log_port_ms(void) {
return ms;
}
void log_port_init(cb_t *cb) {
tx_cb = cb;
uart_init();
}
void log_port_tx_schedule(const uint8_t *p, size_t n) {
uint32_t key = log_port_lock(); // logs and SysTick both schedule
if (p != NULL) {
prefix = p;
prefix_n = n;
}
if (panic) {
// Polled: interrupts are off for good. Send everything, then wait for the last bit.
while (cb_peek_avail(tx_cb) > 0) {
while (!(USART2->ISR & USART_ISR_TXE)) {
}
USART2->TDR = *(const uint8_t *)cb_peek(tx_cb);
cb_skip(tx_cb, 1);
}
while (!(USART2->ISR & USART_ISR_TC)) {
}
} else if (tx_n == 0) {
tx_next();
}
log_port_unlock(key);
}
void log_port_tx_pause(bool pause) {
tx_paused = pause;
}
bool log_port_tx_idle(void) {
return tx_n == 0;
}
void log_port_panic(void) {
// Stop the DMA where it is and count what it sent; the library then sends the rest, polled.
panic = true;
if (tx_n > 0) {
DMA1_Channel7->CCR &= ~DMA_CCR_EN;
tx_done(tx_n - DMA1_Channel7->CNDTR);
prefix_n = 0;
}
}
static size_t rx_write(void) {
return sizeof(rx_buf) - DMA1_Channel6->CNDTR;
}
size_t log_port_rx_avail(void) {
size_t w = rx_write();
return w >= rx_read ? w - rx_read : sizeof(rx_buf) - rx_read; // contiguous bytes only
}
const uint8_t *log_port_rx_peek(void) {
return &rx_buf[rx_read];
}
void log_port_rx_skip(size_t n) {
rx_read = (rx_read + n) % sizeof(rx_buf);
}
uint32_t log_port_rx_mark(void) {
return DMA1_Channel6->CNDTR; // changes with every byte received
}
uint32_t log_port_rx_check(void) {
// Framing and noise errors: clear and report them (the bytes still arrive, through the DMA).
uint32_t e = USART2->ISR & (USART_ISR_FE | USART_ISR_NE);
if (e) USART2->ICR = USART_ICR_FECF | USART_ICR_NECF;
return e;
}
__attribute__((noreturn)) void log_port_fatal(void) {
if (CoreDebug->DHCSR & CoreDebug_DHCSR_C_DEBUGEN_Msk) __BKPT(0);
NVIC_SystemReset();
}
# A minimal ucLog app on the NUCLEO-L452RE-P (bare metal, Makefile; CMakeLists.txt builds the same).
#
# make build/example.elf (logdata embedded), example.bin; <app_hash>.logdata goes to
# the hosts' cache, so they decode this build's logs without being told
# make flash program it over the board's ST-LINK (STM32_Programmer_CLI)
# make clean
#
# Needs arm-none-eabi-gcc (on PATH, or from STM32CubeCLT: CUBECLT) and Python 3, and ST's CMSIS
# files for the STM32L4 (STM32CUBE_L4: an STM32CubeL4 package from STM32CubeMX, or a checkout of
# github.com/STMicroelectronics/STM32CubeL4 with its submodules).
CUBECLT ?= /opt/ST/STM32CubeCLT_1.22.0
ifeq ($(shell command -v arm-none-eabi-gcc 2>/dev/null),)
CROSS ?= $(CUBECLT)/GNU-tools-for-STM32/bin/arm-none-eabi-
else
CROSS ?= arm-none-eabi-
endif
CC := $(CROSS)gcc
OBJCOPY := $(CROSS)objcopy
OBJDUMP := $(CROSS)objdump
SIZE := $(CROSS)size
PROG ?= $(CUBECLT)/STM32CubeProgrammer/bin/STM32_Programmer_CLI
STM32CUBE_L4 ?= $(HOME)/STM32Cube/Repository/STM32Cube_FW_L4_V1.18.2
CMSIS_DEV := $(STM32CUBE_L4)/Drivers/CMSIS/Device/ST/STM32L4xx
# 1. The library: its sources and flags (UCLOG_SRCS, UCLOG_CFLAGS, UCLOG_LDFLAGS, uclog_post_link).
# UCLOG_SYSCALLS_SRCS: newlib's system calls over the link (semihost), so stdout, stdin and
# files reach the host; without it, link newlib's stubs (nosys) instead.
UCLOG_DIR := ../..
include $(UCLOG_DIR)/uclog.mk
BUILD := build
SRCS := main.c port.c $(UCLOG_SRCS) $(UCLOG_SYSCALLS_SRCS) \
$(CMSIS_DEV)/Source/Templates/system_stm32l4xx.c \
$(CMSIS_DEV)/Source/Templates/gcc/startup_stm32l452xx.s
# -g: logdata.py reads the format strings' argument types (and enums) from the debug info.
# -ffile-prefix-map: the file names the logs show (uclog/src/log.c rather than ../../src/log.c).
CPU := -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard
CFLAGS := $(CPU) -std=gnu11 -Os -g -Wall -Wextra -Wno-unused-parameter -ffunction-sections \
-fdata-sections -ffile-prefix-map=$(UCLOG_DIR)/=uclog/ -DSTM32L452xx \
-I$(STM32CUBE_L4)/Drivers/CMSIS/Include -I$(CMSIS_DEV)/Include $(UCLOG_CFLAGS) -mfp16-format=ieee
LDFLAGS := $(CPU) -Tstm32l452re.ld $(UCLOG_LDFLAGS) -Wl,--gc-sections --specs=nano.specs \
--specs=nosys.specs -Wl,--no-warn-rwx-segments
OBJS := $(addprefix $(BUILD)/obj/,$(addsuffix .o,$(notdir $(basename $(SRCS)))))
vpath %.c $(sort $(dir $(filter %.c,$(SRCS))))
vpath %.s $(dir $(filter %.s,$(SRCS)))
all: $(BUILD)/example.bin
$(BUILD)/obj/%.o: %.c | $(BUILD)/obj
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
$(BUILD)/obj/%.o: %.s | $(BUILD)/obj
$(CC) $(CPU) -x assembler-with-cpp -c $< -o $@
$(BUILD)/obj:
mkdir -p $@
# 2. Link with the app hash field zeroed, then uclog_post_link: the app hash (SHA-512 of the
# image) goes into .apphash, and the logdata (format strings and types, from the ELF) is
# embedded as .logdata_cbor and copied to the hosts' cache.
$(BUILD)/example.elf: $(OBJS) stm32l452re.ld $(UCLOG_TOOLS)
$(CC) $(LDFLAGS) $(OBJS) -o $@.tmp
$(call uclog_post_link,$@.tmp,$@,$(BUILD))
@rm -f $@.tmp
$(SIZE) $@
$(BUILD)/example.bin: $(BUILD)/example.elf
$(OBJCOPY) -O binary $< $@
flash: $(BUILD)/example.bin
$(PROG) -c port=SWD mode=UR $(if $(STLINK_SN),sn=$(STLINK_SN)) -w $< 0x08000000 -v -rst
clean:
rm -rf $(BUILD)
.PHONY: all flash clean
-include $(OBJS:.o=.d)
# A minimal ucLog app on the NUCLEO-L452RE-P (bare metal, CMake; the Makefile builds the same).
#
# cmake -B build -DCMAKE_TOOLCHAIN_FILE=cortex-m4f.cmake [-DSTM32CUBE_L4=...]
# cmake --build build # build/example.elf (logdata embedded), example.bin; the logdata
# # also goes to the hosts' cache
#
# Needs CMake 3.20, Arm GCC, Python 3, and ST's CMSIS files for the STM32L4 (as in the Makefile).
cmake_minimum_required(VERSION 3.20)
project(uclog_example C ASM)
set(STM32CUBE_L4 "$ENV{HOME}/STM32Cube/Repository/STM32Cube_FW_L4_V1.18.2" CACHE PATH "STM32CubeL4")
set(CMSIS_DEV ${STM32CUBE_L4}/Drivers/CMSIS/Device/ST/STM32L4xx)
# For every target, the library's too. -g: logdata.py reads the format strings' argument types
# (and enums) from the debug info.
add_compile_options(-Os -g -ffunction-sections -fdata-sections)
# 1. The library: the uclog and uclog_syscalls targets, and uclog_add_logdata(). In your project,
# point this at the device library, or fetch it:
# FetchContent_Declare(uclog GIT_REPOSITORY https://github.com/UnitCircleInc/uclog-device GIT_TAG v4)
# FetchContent_MakeAvailable(uclog)
add_subdirectory(../.. uclog)
add_executable(example
main.c
port.c
${CMSIS_DEV}/Source/Templates/system_stm32l4xx.c
${CMSIS_DEV}/Source/Templates/gcc/startup_stm32l452xx.s
)
set_target_properties(example PROPERTIES SUFFIX .elf)
target_compile_definitions(example PRIVATE STM32L452xx)
target_include_directories(example PRIVATE ${STM32CUBE_L4}/Drivers/CMSIS/Include ${CMSIS_DEV}/Include)
# CMake compiles absolute paths: map them, so logs show main.c rather than the path on this machine
# (the library maps its own to uclog/...).
target_compile_options(example PRIVATE -Wall -Wextra -Wno-unused-parameter -ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}/=)
# uclog_syscalls: newlib's system calls over the link (semihost), so stdout, stdin and files reach
# the host. The linker script INCLUDEs the library's log.ld (uclog adds its directory to -L).
target_link_libraries(example PRIVATE uclog uclog_syscalls)
target_link_options(example PRIVATE -T${CMAKE_CURRENT_SOURCE_DIR}/stm32l452re.ld -Wl,--gc-sections -Wl,--no-warn-rwx-segments)
set_target_properties(example PROPERTIES LINK_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/stm32l452re.ld)
# 2. After the link: the app hash into .apphash, the logdata embedded (.logdata_cbor), and
# <app_hash>.logdata next to the ELF and in the hosts' cache.
uclog_add_logdata(example)
add_custom_command(TARGET example POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:example> $<TARGET_FILE_DIR:example>/example.bin
VERBATIM)
/* STM32L452RE: 512 KiB flash, 160 KiB SRAM (SRAM1 128 KiB + SRAM2 32 KiB, contiguous).
* What ucLog needs: the LOGDATA region, the .apphash section, and INCLUDE log.ld. */
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 160K
/* Log strings (not loaded). Target digit 0: 0xF0000000 + 0x100000 * target. */
LOGDATA : ORIGIN = 0xF0000000, LENGTH = 1M
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
_stack_size = 8K;
SECTIONS
{
.isr_vector :
{
KEEP(*(.isr_vector))
} > FLASH
.text :
{
*(.text .text.*)
*(.rodata .rodata.*)
KEEP(*(.init))
KEEP(*(.fini))
. = ALIGN(4);
} > FLASH
.ARM.exidx :
{
*(.ARM.exidx* .gnu.linkonce.armexidx.*)
} > FLASH
.preinit_array : { KEEP(*(.preinit_array*)) } > FLASH
.init_array :
{
KEEP(*(SORT(.init_array.*)))
KEEP(*(.init_array*))
} > FLASH
.fini_array : { KEEP(*(.fini_array*)) } > FLASH
/* SHA-512 of the image with this field zeroed (device/tools/hash.py). */
.apphash :
{
. = ALIGN(4);
KEEP(*(.apphash))
} > FLASH
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data .data.*)
. = ALIGN(4);
_edata = .;
} > RAM AT > FLASH
_sidata = LOADADDR(.data);
.bss (NOLOAD) :
{
. = ALIGN(4);
_sbss = .;
*(.bss .bss.*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
/* Heap from _end up to the stack. */
. = ALIGN(8);
_end = .;
PROVIDE(end = .);
_heap_limit = _estack - _stack_size;
ASSERT(_end <= _heap_limit, "RAM overflow")
INCLUDE log.ld
}
Zephyr¶
west build -b nucleo_l452re/stm32l452xx/p device/examples/zephyr -- -DZEPHYR_EXTRA_MODULES=$PWD/device
west flash
// © 2026 Unit Circle Inc.
// SPDX-License-Identifier: Apache-2.0
//
// A minimal ucLog app on Zephyr: a log every second, and a terminal (port 61) with two commands.
// ucLog starts itself (no init call); Zephyr's LOG_INF() and friends are ucLog logs
// (CONFIG_LOG_CUSTOM_HEADER), and log.h's LOG_INFO() works too. Connect with any host, e.g.
// `uclog tui`, and type `help` in the Terminal view.
#include <string.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include "log.h"
LOG_MODULE_REGISTER(main);
#define TERM_PORT 61 // the terminal (spec/protocol.md §5.7)
typedef enum { LED_OFF, LED_ON, LED_BLINK } led_mode_t;
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);
static led_mode_t led_mode = LED_BLINK;
static void reply(const char *s) {
log_tx(TERM_PORT, (const uint8_t *)s, strlen(s));
}
// A line from the terminal (the hosts send whole lines, ending in \r). Handlers run in ucLog's
// thread.
static void on_terminal(const uint8_t *rx, size_t n, void *ctx) {
static char line[32];
static size_t len;
for (size_t i = 0; i < n; i++) {
char c = (char)rx[i];
if (c != '\r' && c != '\n') {
if (len < sizeof(line) - 1) line[len++] = c;
continue;
}
line[len] = 0;
len = 0;
if (line[0] == 0) continue;
if (strcmp(line, "led on") == 0) led_mode = LED_ON;
else if (strcmp(line, "led off") == 0) led_mode = LED_OFF;
else if (strcmp(line, "led blink") == 0) led_mode = LED_BLINK;
else {
reply("commands: led on, led off, led blink\r\n");
continue;
}
LOG_INFO("LED mode {enum:led_mode_t}%d", led_mode); // shown as LED_ON, LED_OFF or LED_BLINK
reply("ok\r\n");
}
}
int main(void) {
gpio_pin_configure_dt(&led, GPIO_OUTPUT_INACTIVE);
log_notify(TERM_PORT, on_terminal, NULL);
LOG_INF("hello from %s on %s", "ucLog", CONFIG_BOARD);
for (uint32_t count = 1;; count++) {
k_sleep(K_SECONDS(1));
LOG_INF("tick %u, uptime %.1f s", count, k_uptime_get() / 1000.0);
if (count % 10 == 0) LOG_WRN("%u ticks without a reset", count);
gpio_pin_set_dt(&led, led_mode == LED_BLINK ? count & 1 : led_mode == LED_ON);
}
}
# ucLog on the devicetree's uclog,link (boards/*.overlay).
CONFIG_UC_LOG=y
# Zephyr's LOG_INF() and friends become ucLog logs; no Zephyr log backends or console.
CONFIG_LOG=y
CONFIG_LOG_MODE_MINIMAL=y
CONFIG_LOG_CUSTOM_HEADER=y
CONFIG_CONSOLE=n
CONFIG_UART_CONSOLE=n
CONFIG_BOOT_BANNER=n
# The example's LED.
CONFIG_GPIO=y
/* ucLog on the ST-LINK virtual COM port (USART2, PA2/PA3) at 1 Mbaud, through ucuart_z, with DMA
* (DMA1 channel 7 TX, channel 6 RX, request 2, as the bare-metal firmware) so that it uses the
* async UART API. RX DMA must be circular: the STM32L4 USART has no FIFO, so at 1 Mbaud both
* interrupt-driven RX and the default buffer-by-buffer DMA lose bytes (between buffers). */
/ {
chosen {
uclog,link = &uclog0;
};
};
&dma1 {
status = "okay";
};
&usart2 {
current-speed = <1000000>;
dmas = <&dma1 7 2 (STM32_DMA_PERIPH_TX | STM32_DMA_PRIORITY_HIGH)>,
<&dma1 6 2 (STM32_DMA_PERIPH_RX | STM32_DMA_PRIORITY_HIGH | STM32_DMA_MODE_CYCLIC)>;
dma-names = "tx", "rx";
uclog0: uclog {
compatible = "staric,ucuart_z";
current-speed = <1000000>;
};
};
# © 2026 Unit Circle Inc.
# SPDX-License-Identifier: Apache-2.0
#
# A minimal ucLog app on Zephyr. The board's overlay (boards/) picks the link; the uclog-target0
# snippet adds the LOGDATA region the format strings go in (and ucLog's Kconfig defaults):
#
# west build -b nucleo_l452re/stm32l452xx/p path/to/examples/zephyr
cmake_minimum_required(VERSION 3.20.0)
# `west build -S uclog-target0` by default, so a plain build works (-S uclog-target1 for an image
# whose logs are told apart as target 1, such as an app behind a bootloader that also logs).
if(NOT "${SNIPPET}" MATCHES "uclog-target")
set(SNIPPET uclog-target0 ${SNIPPET} CACHE STRING "" FORCE)
endif()
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(uclog_example)
target_sources(app PRIVATE src/main.c)