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:
LogLevellog_level_to_string()LOG_init()LOG()- convenience macros:
LOGELOGWLOGI
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_INFOLOG_WARNINGLOG_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, andLOGE
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.hstable 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:
- Casts
argstoUART_HandleTypeDef - copies the pointed-to UART handle into a private static variable
- sets an internal
initializedflag - writes a boot banner directly using
_write() - 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_INFOLOG_WARNINGLOG_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:
- once to calculate the final required length
- 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:
LOGELOGWLOGI
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 = 0LOG_WARNING = 1LOG_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"
Since this logger is plain text over UART, bloated tags just make the output harder to scan.