Preface#
The first and most important thing that I built for the project is a logger to send logs over UARTE. This is quite important, as logging gets you out of tough situations. The nRF52840 DK exposes the nRF52840’s UARTE peripheral through its onboard J-Link debugger as a virtual COM (VCOM) port, which makes it convenient for sending logs to a PC over USB. This will change on the final custom PCB, where there will be no onboard debugger. In that case, I will likely use a USB-to-serial bridge to convert the board’s UART signals into a USB serial port that can be opened from a PC.
I already explored TX-only UARTE logging in the bare-metal article, including basic transmission and viewing the output over the J-Link VCOM port. This logger builds on that work for FreeRTOS.
GitHub Repo: https://github.com/codetit4n/nrf52840-webserver
Design Goals#
Before implementing it, here are the goals I had in mind:
- The logger should be TX-only, i.e., it should only send messages over UART.
- Logging should work from multiple FreeRTOS tasks.
- Only the logger task should own the UARTE peripheral.
- The logger task should have the lowest priority so it does not interfere with higher-priority tasks such as networking.
- Log messages should be buffered using a ring buffer, allowing producer tasks to keep adding logs while the logger task processes and transmits them.
Architecture#
NOTE: There are two UARTE instances on the nRF52840. This project uses
UARTE0.
The architecture is split into two paths. On the software side, FreeRTOS tasks, such as networking and storage, enqueue log messages into a ring buffer, which are then processed by the logger task and transmitted through UARTE0. On the hardware side, the UARTE TX signal is routed through the onboard J-Link debugger, exposed as a VCOM port, and sent to the PC over USB.
UARTE Driver#
The driver code is the layer between the logger API and the actual hardware. So, we need to do the direct register tinkering in this layer.
Relevant Source Code#
- Board Config Header - include/board.h
- Driver Header - include/drivers/uarte.h
- Driver Source - src/drivers/uarte.c
Registers#
Registers for configuration, tasks, and events needed to operate the peripheral are declared in board.h:
#include "nrf52840.h"
#define UARTE NRF_UARTE0
#define UARTE_ENABLE_REG (UARTE->ENABLE)
#define UARTE_CONFIG_REG (UARTE->CONFIG)
#define UARTE_BAUDRATE_REG (UARTE->BAUDRATE)
#define UARTE_TXD_PTR_REG (UARTE->TXD.PTR)
#define UARTE_TXD_MAXCNT_REG (UARTE->TXD.MAXCNT)
#define UARTE_TASKS_STARTTX_REG (UARTE->TASKS_STARTTX)
#define UARTE_TASKS_STOPTX_REG (UARTE->TASKS_STOPTX)
#define UARTE_EVENTS_ENDTX_REG (UARTE->EVENTS_ENDTX)
#define UARTE_EVENTS_TXSTOPPED_REG (UARTE->EVENTS_TXSTOPPED)
#define UARTE_PSEL_TXD_REG (UARTE->PSEL.TXD)
#define UARTE_PSEL_RXD_REG (UARTE->PSEL.RXD)
#define TX_PIN 6 // P0.06: UARTE0 TXD -> DK J-Link VCOM RX
#define RX_PIN 8 // P0.08: UARTE0 RXD <- DK J-Link VCOM TX (unused here)
NOTE: The register definitions are included from nrf52840.h. That is the only use of the included
nrf52840.hfile in the source code.
In UARTE, the DMA moves the bytes while the CPU mostly stays out of it. So, all we need to do is control these registers to operate the UARTE peripheral. Since we are using EasyDMA here, the CPU does not have to manually move every byte.
Initialization, Configuration & Recovery#
Initialization and Configuration#
The uarte_init() function is used to initialize the UARTE0 peripheral. This function is called when the device is turned on and brings up the hardware.
GPIO Configuration for the TX Pin:
GPIO_CNF(TX_PIN) = (1 << 0) | // DIR = Output (1 << 1) | // INPUT disconnect (input buffer not needed for TX) (0 << 2) | // PULL = none (field) (0 << 8) | // DRIVE = standard (field) (0 << 16); // SENSE = disabled // PSEL format: PIN[4:0] | PORT(bit5) | CONNECT(bit31: 0=connected, 1=disconnected) UARTE_PSEL_TXD_REG = (TX_PIN << 0) | (0 << 5) | (0 << 31); UARTE_PSEL_RXD_REG = (1 << 31); // RX disconnected (TX-only)NOTE: RX_PIN configuration is not needed as this is TX-only.
Datasheet Reference

Configure the UARTE peripheral: set baud rate, clear events, etc.
UARTE_CONFIG_REG = (0 << 0) | // HWFC disabled (0x0 << 1) | // PARITY excluded (0 << 4); // 1 stop bit UARTE_BAUDRATE_REG = 0x10000000; // 1 Mbaud UARTE_ENABLE_REG = 8; // Enable UARTEDatasheet Reference

Create a mutex to guard exclusive access to the UARTE0 peripheral:
static SemaphoreHandle_t uarte_mutex = NULL; // ... uarte_mutex = xSemaphoreCreateMutex();This prevents concurrent access from multiple tasks, which could corrupt the peripheral state or produce invalid UARTE transfers.
Recovery#
The uarte_recover() function handles recovery if the peripheral runs into trouble during operation. Nothing special here: it stops any ongoing transmission, disables UARTE, clears the relevant events and state, and then re-enables the peripheral.
// signal the peripheral to stop transmission and wait
// until tx is fully stopped ...
UARTE_ENABLE_REG = 0; // Disable UARTE
// clear events, reset state, pin high, etc. ...
UARTE_ENABLE_REG = 8; // Enable UARTE
NOTE: The TX pin is also driven high during recovery to keep the UART line in its idle state while UARTE is disabled — similar to the
uarte_init()function.
EasyDMA transmission#
The data that needs to be sent over UARTE needs to be in Data RAM because EasyDMA can only access Data RAM. So,
I have a static buffer for
that (because static variables live in RAM):
#define UART_TX_BUF_SIZE 256
static uint8_t tx_buf[UART_TX_BUF_SIZE];NOTE: This is a writable buffer and writable global/static variables are placed in the .bss section (if uninitialized). So, it lives in Data RAM at runtime.
The transmission process is simple:
Prepare the transmit buffer in Data RAM and configure UARTE.
Write the initial address pointer to the
TXD.PTRregister, and the number of bytes in the RAM buffer to theTXD.MAXCNTregister:UARTE_TXD_PTR_REG = (uint32_t)(uintptr_t)tx; UARTE_TXD_MAXCNT_REG = (uint32_t)len;Start transmission by triggering the
TASKS_STARTTXtask register:UARTE_TASKS_STARTTX_REG = 1;Once the configured number of bytes has been transmitted, the
EVENTS_ENDTXevent is generated. The driver waits for this event and handles a timeout if it takes too long:while (UARTE_EVENTS_ENDTX_REG == 0) { if ((xTaskGetTickCount() - start) > timeout) { uarte_recover(); return 0; } taskYIELD(); }
In short: It is basically just taking a mutex, pointing the registers to the buffer, and releasing the mutex upon completion or timeout.
Timeout logic#
First, you store the tick count at the start of the transmission.
Then, calculate the timeout in ticks based on the data size, baud rate, etc. Because it is not fixed how much data you will send with each transmission, the algorithm for the calculation is roughly:
Step 1 - Each UART byte takes 10 bits on the wire: 1 start bit, 8 data bits, and 1 stop bit.
Step 2 - Calculate the expected transmission time for
bytesat 1,000,000 baud and round it up to the next whole millisecond:const uint32_t baud = 1000000; const uint32_t bits_per_byte = 10; // ceiling division uint32_t wire_ms = (bytes * bits_per_byte * 1000u + baud - 1u) / baud;Step 3 - Add a small 15 ms margin for RTOS scheduling and timing jitter, then enforce a minimum timeout of 20 ms:
// Fixed RTOS / jitter margin const uint32_t margin_ms = 15; uint32_t timeout_ms = wire_ms + margin_ms; // Minimum timeout floor if (timeout_ms < 20) timeout_ms = 20;Step 4 - Convert the final timeout from milliseconds to FreeRTOS ticks using
pdMS_TO_TICKS().Wait for completion, and if it does not complete in time (i.e., we do not get the
EVENTS_ENDTXevent) within the calculated timeout range, recover the UARTE peripheral.
Logger Implementation#
The logger implementation sits between the application tasks and the UARTE driver. It handles buffering, synchronization, and passing log messages down to the driver for transmission.
Relevant Source Code#
- Logger Header - include/modules/logger.h
- Logger Source - src/modules/logger.c
Ring Buffer#
The logger uses a ring buffer architecture for logging messages. This is because the logger is not a critical component of the project - so it is fine if it drops a few messages here and there. What I do here is I keep adding messages to the rear of the queue and the logger task keeps draining it from the front and the queue wraps around once full.
Queue Structure#
I have kept every message in the queue and the size of the queue itself small so it can be transmitted over UARTE quickly and does not keep the logger task busy for too long.
The queue can hold a maximum of 64 messages, and the payload of each log message is limited to 64 bytes. The label for each log message is limited to 20 bytes:
#define LOGGER_MAX_LOG_PAYLOAD 64
#define LOGGER_QUEUE_CAP 64
#define LOGGER_MAX_LOG_LABEL 20NOTE: Each message in the queue will have a label and a payload. This gives it a nice structure and makes it easier to identify the source of the log message.
Here is the complete structure of a log message:
typedef enum {
LOG_HEX,
LOG_UINT,
LOG_INT,
LOG_STRING,
} payload_t;
typedef struct {
payload_t type;
uint8_t label[LOGGER_MAX_LOG_LABEL];
uint8_t payload[LOGGER_MAX_LOG_PAYLOAD];
uint8_t len;
} log_t;Four types of log messages are supported:
- Raw hexadecimal values. E.g.,
0xAB 0xCD 0xEF- very useful for dumping raw data buffers without any formatting. - Unsigned integers. E.g.,
12345- useful for logging counters, sizes, or any non-negative numeric values. - Signed integers. E.g.,
-42- useful for logging values that can be negative, such as error codes. - Strings. E.g.,
"Hello, world!"- useful for logging human-readable messages.
Apart from the type, each log message has a label and a payload. The label is a short string that acts like a label
or tag for the log message, indicating its source or context. Here is an example of what a log message looks like when printed:

Queue Handling#
Log messages are stored in an array of log_t structures, which acts as the ring buffer. The logger maintains two indices: front and
rear, which point to the next log message to be processed and the next available slot for a new log message, respectively, just like
a classic queue. The maximum capacity of the queue is fixed at 64 messages to keep the memory footprint small and ensure that the logger
task can process messages quickly without blocking other tasks for too long.
static log_t log_q[LOGGER_QUEUE_CAP];
static volatile uint8_t front;
static volatile uint8_t rear;Operations
Enqueue: When a task wants to log a message, it calls the
logger_log()function (or one of the helpers likelogger_log_literal_len(),logger_log_uint_len(), etc.), which puts the message into the queue at the rear index.void logger_log(log_t log) { // ... basic checks and validations ... taskENTER_CRITICAL(); // enter critical section was_empty = (ctr == 0); if (ctr == LOGGER_QUEUE_CAP) { // full queue front = idx_next(front); // drop oldest, ctr stays at CAP ++dropped; } else { ++ctr; } log_q[rear] = log; rear = idx_next(rear); taskEXIT_CRITICAL(); // exit critical section if (was_empty && logger_task_handle != NULL) { // wake logger task if it was waiting for logs xTaskNotifyGive(logger_task_handle); } }The queue state (
front,rear, andctr) is shared between multiple producer tasks and the logger task, so updates are done inside a critical section to prevent race conditions.If the queue is already full, the oldest entry is dropped by advancing
front. The new log is then written atrear, so the logger keeps the most recent messages instead of rejecting new ones.The
was_emptyflag is checked before inserting the new entry. The logger task is notified only when the queue changes from empty to non-empty, because if it is already processing logs there is no need to wake it again.NOTE: The
idx_next()function wraps the index around when it reaches the end of the queue, implementing the circular nature of the ring buffer.Dequeue: This basically removes one item from the
frontof the queue and returns it to the logger task for processing. Thefrontindex is advanced, and the count of items in the queue is updated.uint8_t logger_try_pop(log_t* out) { // ... basic checks and validations ... uint8_t ok = 0; taskENTER_CRITICAL(); // enter critical section if (ctr > 0) { *out = log_q[front]; front = idx_next(front); --ctr; ok = 1; } taskEXIT_CRITICAL(); // exit critical section return ok; }NOTE: The critical section is used here as well to ensure that the queue state is consistent when accessed by multiple tasks.
Flush: This function is used when we want to make sure all pending log messages have been processed before continuing.
void logger_flush(void) { // ... basic checks and validations ... taskENTER_CRITICAL(); // enter critical section logger_flush_requested = 1; taskEXIT_CRITICAL(); // exit critical section // Ensure the logger task wakes even if it was waiting. xTaskNotifyGive(logger_task_handle); // Block until logger_task confirms the queue was drained. xSemaphoreTake(logger_flush_done, portMAX_DELAY); }The function first sets
logger_flush_requested, then wakes the logger task usingxTaskNotifyGive()in case it is currently blocked waiting for new logs.The calling task then blocks on the
logger_flush_donesemaphore:xSemaphoreTake(logger_flush_done, portMAX_DELAY);Inside
logger_task(), once the queue has been completely drained (ctr == 0), the flush request is cleared and the semaphore is given back:uint8_t signal_flush = 0; taskENTER_CRITICAL(); if (logger_flush_requested && ctr == 0) { logger_flush_requested = 0; signal_flush = 1; } taskEXIT_CRITICAL(); if (signal_flush) { xSemaphoreGive(logger_flush_done); }This allows
logger_flush()to return only after all queued log messages have been processed.
Logger Task#
The logger_task()
function is the consumer side of the logging system. Producer tasks only place messages into the ring buffer; the logger
task is responsible for taking those messages out, formatting them, and passing them to the UARTE driver.
The task first drains everything currently waiting in the queue:
while (logger_try_pop(&log)) {
// format and transmit log
}Each log entry contains a type, label, and payload. Based on the type, the task converts the payload into the final text representation:
switch (log.type) {
case LOG_UINT:
// format unsigned integer
break;
case LOG_INT:
// format signed integer
break;
case LOG_HEX:
// format bytes as hexadecimal
break;
case LOG_STRING:
default:
// copy string payload
break;
}The label and formatted payload are combined into one line, followed by \r\n (i.e., Carriage Return + Line Feed (CRLF)
- a common line ending for serial terminals), and the complete message is then sent
through the UARTE driver in a single transfer:
line[line_len++] = '\r';
line[line_len++] = '\n';
uarte_write(line, line_len);NOTE: The logger task is the only task that transmits through the UARTE peripheral, so it can safely call
uarte_write()without worrying about concurrent access from other tasks.
Once the queue has been drained, there is no reason for the logger task to keep running. It blocks indefinitely waiting for a task notification:
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);When a producer adds a message to an empty queue, it wakes the logger task using:
xTaskNotifyGive(logger_task_handle);The task then starts another drain cycle.
NOTE: The ring buffer stores the actual log messages. The FreeRTOS task notification only acts as a signal to tell the logger task that there is work available.
Using the Logger#
Most of the firmware does not call logger_log() directly. Instead, small helper functions are used for the common log
types, such as strings, integers, and hexadecimal data. These helpers build a log_t entry and then pass it to
logger_log().
For example, a string log eventually follows this path:
logger_log_literal_len(...);which creates the log entry and calls:
logger_log(l);Similarly, there are helpers for different data types:
logger_log_uint_len(...); // logs an unsigned integer with a label
logger_log_int_len(...); // logs a signed integer with a label
logger_log_hex_len(...); // logs a hexadecimal buffer with a label
logger_log_nl(); // logs a newline
This keeps the logging calls in the rest of the firmware small while keeping the queueing and UARTE handling inside the logger module.
For example, modules such as networking or storage can submit a log message when needed without directly interacting with the UARTE peripheral or the ring buffer. Here is an example:
logger_log_uint_len("SPI BEGIN:",
(uint8_t)(sizeof("SPI BEGIN:") - 1),
&dev->cs_pin,
(uint8_t)sizeof(dev->cs_pin));This logs the label SPI BEGIN: followed by the value of dev->cs_pin as an unsigned integer. The logger module
handles the formatting, queuing, and transmission over UARTE, while the SPI driver only needs to call the appropriate logging helper.