Skip to content

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:

  1. The library: src/log.c and ucComm (COBS, CBOR, a ring buffer), included.
  2. A link: a port for your UART, USB or BLE stack (bare metal), or a devicetree node (Zephyr).
  3. Two linker additions: a LOGDATA region for the format strings, which is never loaded, and a 64-byte .apphash field in flash.
  4. 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-map for these, so no absolute path ends up in the firmware.
  • On an STM32L4 (a USART without a FIFO) at 1 Mbaud, give ucuart_z DMA 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), and ucuart and ucusb (nRF UARTE and USB device; build-tested).

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)