Skip to main content

Logging

Purpose

The logging library provides a simple UART-based logging interface for embedded firmware.

Its main job is to let the application print formatted log messages such as:

[INFO] MOTOR: Initialization complete
[WARNING] SENSOR: Value out of range: 8123
[ERROR] CAN: Failed to transmit frame

The module is intentionally small:

  • it supports three log levels
  • it formats messages in a consistent style
  • it sends all output over a single UART
  • it provides convenience macros for compile-time log filtering

This is a printf-style logging system, not a structured logger, ring buffer, or asynchronous trace system.

What problem this solves

Without this module, code would need to:

  • manually format strings
  • manually select a UART
  • manually prepend log level tags
  • duplicate formatting logic across the project

This library centralizes that behavior so all logs:

  • use the same format
  • use the same transport
  • can be filtered by log level at compile time
  • remain easy to call from application code

Output format

Every log produced by LOG() is formatted as:

[LEVEL] TAG: message\r\n

Example

LOG(LOG_INFO, "IMU", "Sensor ready");

produces:

[INFO] IMU: Sensor ready\r\n

Another example:

LOG(LOG_ERROR, "ETH", "TX failed with code %d", err);

produces something like:

[ERROR] ETH: TX failed with code -3\r\n

Components

A log line contains:

  • opening bracket [
  • level string such as INFO
  • closing bracket and space ]
  • tag string
  • separator :
  • user message string
  • CRLF line ending \r\n

The line ending is Windows/terminal friendly and also common for UART console output.

Public API overview

The public interface consists of:

  • LogLevel
  • log_level_to_string()
  • LOG_init()
  • LOG()
  • convenience macros:
    • LOGE
    • LOGW
    • LOGI

Log levels

Enum definition

typedef enum {
  LOG_INFO,
  LOG_WARNING,
  LOG_ERROR,
  _LOG_LAST_LEVEL_DONT_EDIT
} LogLevel;

Meaning

The library supports three levels:

  • LOG_INFO
  • LOG_WARNING
  • LOG_ERROR

These are stored as enum values starting at 0.

The final enum value:

_LOG_LAST_LEVEL_DONT_EDIT

is not an actual log level. It is a sentinel used to:

  • count how many log levels exist
  • validate the string table size

Why that sentinel exists

The library keeps a parallel array of strings:

static const char *LOG_LEVEL_STRINGS[] = {
    "INFO",
    "WARNING",
    "ERROR",
};

The enum and string table must stay aligned.

The sentinel lets the code verify that automatically with static_assert.

log_level_to_string()

static inline const char *log_level_to_string(LogLevel logLevel);

Purpose

Converts a LogLevel enum into its corresponding string.

Valid conversions

  • LOG_INFO -> "INFO"
  • LOG_WARNING -> "WARNING"
  • LOG_ERROR -> "ERROR"

If the value is outside the valid range, it returns:

"NoLevel"

Notes

This function is declared static inline in the header, so each translation unit including the header gets its own inline copy.

It also contains a compile-time check:

static_assert((sizeof(LOG_LEVEL_STRINGS) / sizeof(LOG_LEVEL_STRINGS[0])) ==
                    _LOG_LAST_LEVEL_DONT_EDIT,
                "Mismatch in number of log level strings!");

This prevents someone from adding or removing enum levels without updating the string table.

That is one of the few parts of this module behaving like it has trust issues, which is correct.

Backend independence of the API

Although the current implementation sends logs over UART, the public API itself is not inherently UART-specific.

From the perspective of code using the logger, the interface is simply:

  • initialize the logging system with LOG_init(...)
  • emit logs with LOG(...)
  • or use the convenience macros LOGI, LOGW, and LOGE

Nothing in normal application code needs to know how the log is actually transported.

What this means in practice

The current board-specific implementation uses:

  • a UART handle passed into LOG_init()
  • HAL_UART_Transmit() for output
  • _write() retargeting for stdout

But that is only one possible backend.

A different board or firmware target could keep the same header/API and provide a different implementation, for example:

  • USB CDC logging
  • SWO / ITM logging
  • RTT logging
  • CAN or Ethernet debug output
  • buffered logging to memory
  • semihosting during development

Why this matters

This separation means the API should be understood as a logical logging interface, not as a UART contract.

In this codebase, each board can provide its own implementation behind the same header, as long as it preserves the expected external behavior of the API.

That makes the module portable across boards without forcing higher-level application code to care about the physical logging transport, which is one of the few times abstraction is actually doing something useful instead of just breeding paperwork.

Maintenance guidance

If a future board needs a different logging transport, the preferred approach is:

  • keep logging.h stable if possible
  • replace or adapt the implementation file for that board
  • preserve the meaning of:
    • LOG_init()
    • LOG()
    • LOGI/LOGW/LOGE

This allows application code to remain unchanged while the backend changes per target.

Initialization

LOG_init()

void LOG_init(void *arg);

Purpose

Initializes the logging system by providing the UART handle that will be used for all later output.

Expected argument

arg must point to a valid UART_HandleTypeDef.

In practice:

LOG_init(&huart2);

or whichever UART handle should be used for logging.

What it does internally

LOG_init() performs these steps:

  1. Casts args to UART_HandleTypeDef
  2. copies the pointed-to UART handle into a private static variable
  3. sets an internal initialized flag
  4. writes a boot banner directly using _write()
  5. emits an info log saying logging was initialized

Important requirements

LOG_init() must be called before any normal logging is expected to work.

If LOG() is called before initialization, it silently returns without output.

Main logging function

LOG()

void LOG(LogLevel level, const char *TAG, const char *log_message, ...);

Purpose

Formats and transmits a log line over UART.

Parameters

level

The severity level of the message.

Expected values:

  • LOG_INFO
  • LOG_WARNING
  • LOG_ERROR

If the value is invalid, the implementation falls back to:

"UNKNOWN"

for formatting.

TAG

A short text label identifying the source of the log.

Typical examples:

  • "IMU"
  • "CAN"
  • "DISPATCHER"
  • "ETH"

This appears in the formatted output after the log level.

log_message

A printf-style format string.

Examples:

  • "Init done"
  • "Received packet %u"
  • "Voltage too high: %d mV"

Optional variadic arguments used by the format string.

Example usage

LOG(LOG_INFO, "MOTOR", "Started with speed %u", speed);
LOG(LOG_WARNING, "TEMP", "High temperature: %d", temp);
LOG(LOG_ERROR, "FLASH", "Write failed");

Behavior when not initialized

If logging has not been initialized yet, the function returns immediately:

if (initialized == 0) {
    return;
}

No output is produced.

This is deliberate.

Internal formatting process

The implementation builds the final message in two phases.

Phase 1: Build a full format string

It first constructs a format string like:

[INFO] MOTOR: Started with speed %u\r\n

This is stored in dynamically allocated memory called format_message.

Phase 2: Format variadic arguments into final output

It then uses vsnprintf() twice:

  1. once to calculate the final required length
  2. once to write the fully formatted message into another dynamically allocated buffer

That final message is transmitted using:

HAL_UART_Transmit(&huart_handler, (uint8_t *)total_message, total_len, HAL_MAX_DELAY);

After transmission, both heap allocations are freed.

Retargeted _write()

int _write(int file, char *ptr, int len);

Purpose

This function retargets standard output to the configured UART.

Behavior

If the file descriptor is 1:

if (file == 1)

the function transmits the provided buffer over UART using HAL_UART_Transmit().

It then returns len.

Why this exists

On many embedded toolchains, overriding _write() allows C library output functions such as printf() to write to UART.

That means this module is not only a custom logging module. It also partially redirects stdout.

Important note

This implementation only handles file descriptor 1, which is typically stdout.

It does not distinguish stderr or other descriptors.

Interaction with LOG()

LOG() does not actually use printf() or _write() for its main output path. It calls HAL_UART_Transmit() directly after formatting its message.

LOG_init() does use _write() once to print the boot banner.

So _write() exists mainly for stdout retargeting and the boot line, not as the core mechanism used by LOG() itself.

Convenience macros

The header defines these macros:

  • LOGE
  • LOGW
  • LOGI

These call LOG() with a fixed level, but only if that level is enabled by CONFIG_LOG_LEVEL.

Default log level configuration

If CONFIG_LOG_LEVEL is not defined by the build system, the header sets:

#define CONFIG_LOG_LEVEL LOG_INFO

This means all log levels are enabled by default.

Macro behavior

LOGE

#define LOGE(TAG, format, ...) LOG(LOG_ERROR, TAG, format, ##__VA_ARGS__)

Enabled when:

(CONFIG_LOG_LEVEL <= LOG_ERROR)

Because LOG_ERROR is the highest enum value in this setup, this macro is enabled for all current supported configurations.

LOGW

#define LOGW(TAG, format, ...) LOG(LOG_WARNING, TAG, format, ##__VA_ARGS__)

Enabled when:

(CONFIG_LOG_LEVEL <= LOG_WARNING)

LOGI

#define LOGI(TAG, format, ...) LOG(LOG_INFO, TAG, format, ##__VA_ARGS__)

Enabled when:

(CONFIG_LOG_LEVEL <= LOG_INFO)

Filtering semantics

Since the enum values are ordered:

  • LOG_INFO = 0
  • LOG_WARNING = 1
  • LOG_ERROR = 2

a lower configured value means more logs enabled.

Examples

CONFIG_LOG_LEVEL = LOG_INFO

Enabled:

  • info
  • warning
  • error

CONFIG_LOG_LEVEL = LOG_WARNING

Enabled:

  • warning
  • error

Disabled:

  • info

CONFIG_LOG_LEVEL = LOG_ERROR

Enabled:

  • error only

Disabled:

  • warning
  • info

Disabled macro behavior

When disabled, the macro expands to:

(void)0

So the call is compiled out.

This is compile-time filtering, not runtime filtering.

That matters because disabled log calls impose essentially no runtime cost.

Typical usage pattern

Initialization

At system startup, once the UART peripheral is ready:

LOG_init(&huart2);

This should happen before any code that expects logging output.


Logging from application code

Use one of the convenience macros in normal code:

LOGI("NET", "Ethernet initialized");
LOGW("ADC", "Reading outside expected range: %u", sample);
LOGE("FLASH", "Erase failed at sector %u", sector);

This is the intended public usage style.

Using LOG() directly is also valid when needed.

Tag conventions

The module does not enforce tag format, so the team should adopt a convention.

A good pattern is to use short subsystem names, such as:

  • "ETH"
  • "CAN"
  • "SCHED"
  • "MOTOR"
  • "UI"
  • "SENSOR"

Keep tags short enough for readable UART logs.

Since this logger is plain text over UART, bloated tags just make the output harder to scan.