Asynchronous, Thread-safe and Non-blocking Serial Library (with HWCDC and USBCDC support) for ESP32 Arduino Core.
SafeSerial masks hardware and timing limitations through an asynchronous queuing mechanism. It ensures that critical code paths (e.g., network callbacks) never stall due to relatively slow serial port operations. Supported port types: UART, HWCDC and USBCDC. SafeSerial (with the exception of a few hardware-specific settings) standardizes and abstracts the serial interfaces used, allowing them to be used in a uniform manner.
The library leverages FreeRTOS capabilities to decouple the application logic from the serial peripheral:
- Queue Management: Every message to be sent is placed into a fixed-size FIFO (First-In-First-Out) queue. This is thread-safe and executes nearly instantaneously. The bytes to be read also arrive via a FIFO queue.
- Background Task: A dedicated task (using
xTaskCreateUniversal) monitors the queues and transmits data when resources are available.
Add to your platformio.ini:
lib_deps =
soosp/SafeSerialSearch for SafeSerial in the Library Manager (Sketch → Include Library → Manage Libraries).
#include <SafeSerial.h>
void setup() {
// Silence internal ESP-IDF logs that bypass our queue logic (optional)
esp_log_level_set("*", ESP_LOG_NONE);
// Start SafeSerial (automatically detects port type)
SafeSerial.begin(115200);
}
void loop() {
SafeSerial.println("This won't block your code!");
vTaskDelay(pdMS_TO_TICKS(1000));
}SafeSerial inherits from the Arduino Stream class, making it a drop-in
replacement for standard Serial calls.
SafeSerial.println("System initialized.");
SafeSerial.printf("IP Address: %s", WiFi.localIP().toString().c_str());
SafeSerial.printf("Sensor value: %d", analogRead(34));if (SafeSerial.available()) {
uint8_t c = SafeSerial.read();
}char buf[SAFESERIAL_LINE_BUFFER_SIZE];
// Fill buffer with zeros
memset(buf, 0, sizeof(buf));
if (SafeSerial.available()) {
// readBytesUntil reads until '\n' or buffer is full
// This cuts the message to the appropriate line length so that the zero
// at the end remains
SafeSerial.readBytesUntil('\n', buf, sizeof(buf) - 1);
}Serial.* directly to prevent blocking and resource conflicts.
By default SafeSerial use Serial. If you need to use an another UART or need
to remap UART pins:
#include <SafeSerial.h>
// Create a new instance using SafeSerialClass
// This is needed if you want a custom name or multiple concurrent instances
// for multiple physical ports
SafeSerialClass MyLogger;
void setup() {
// Turn off ESP-IDF logs
esp_log_level_set("*", ESP_LOG_NONE);
// Set the parameters BEFORE initialitation
MyLogger.setTxBufferSize(1024); // Transmit buffer size for UART
MyLogger.setTxQueueSize(50); // SafeSerial queue depth
// Using Serial1 with custom pins (RX: 12, TX: 15)
// This remains non-blocking even if the UART buffer gets full.
MyLogger.begin(Serial1, 115200, SERIAL_8N1, 12, 15);
MyLogger.println("MyLogger is now active on Serial1 (Pins 12/15)");
}Instead of hardcoded macros, SafeSerial uses setter functions to allow dynamic configuration before initialization:
| Method | Default | Description |
|---|---|---|
setTxQueueSize(uint16_t) |
30 | Number of messages buffered before dropping new ones. |
setRxQueueSize(uint16_t) |
1024 | Size of the RX queue in bytes |
setTaskPriority(uint8_t) |
5 | FreeRTOS task priority. |
setStackSize(size_t) |
4096 | RAM allocated for the background task. |
setCore(BaseType_t) |
1 | CPU core assigned to the logger. Has no effect on single core chips (e.g., on ESP32-C3). |
setRxBufferSize(size_t) |
0 | Sets size of receive buffer for hardware based (UART, HWCDC) Serial ports. The default value of 0 means, that the hardware's default value is used. Has no effect in case of USBCDC. |
setTxBufferSize(size_t) |
0 | Sets size of transmit buffer for hardware based (UART, HWCDC) Serial ports. The default value of 0 means, that the hardware's default value is used. Has no effect in case of USBCDC. |
setTxTimeoutMs(uint32_t) |
100 | Sets the timeout in ms on USB (HWCDC, USBCDC) Serial ports. Has no effect in the case of UART. |
setMaxRxBurst(uint8_t) |
64 | Sets the maximum amount of bytes to read at once to avoid blocking at receiving. |
If you use the default Serial and want to autodetect the port type, simply
use the single-parameter begin:
SafeSerial.begin(115200);SafeSerial.begin(port, baud, config, rxPin, txPin);| Parameter | Type | Description |
|---|---|---|
port |
HardwareSerial& |
The UART instance (Serial0, Serial1, Serial2). |
baud |
unsigned long |
Communication speed. |
config |
uint32_t |
Data bits, parity, and stop bits (default: SERIAL_8N1). |
rxPin |
int8_t |
Custom GPIO for Receive (default: -1). |
txPin |
int8_t |
Custom GPIO for Transmit (default: -1). |
For compatibility reasons, on original ESP32 chips there is available a begin
function uses UART0 (Serial0) as Serial:
SafeSerial.begin(baud, config, rxPin, txPin);SafeSerial.begin(port, baud);| Parameter | Type | Description |
|---|---|---|
port |
HWCDC& |
ESP32-S3 HWCDC (default: Serial). |
baud |
unsigned long |
Communication speed. |
SafeSerial.begin(port, baud);| Parameter | Type | Description |
|---|---|---|
port |
USBCDC& |
ESP32-S3 USBCDC (default: Serial). |
baud |
unsigned long |
Communication speed. |
If your application generates a high volume of logs:
- Match Queue to Bursts: Set setTxQueueSize() to the maximum number of messages expected in a single "burst" (e.g., if 10 tasks log simultaneously, use a queue of at least 20).
- Task Delays: Ensure your logging tasks have at least a vTaskDelay(1) to allow the FreeRTOS IDLE task and the Serial stack to breathe.
- Keep it Short: Use shorter log lines (see
SAFESERIAL_LINE_BUFFER_SIZE) to reduce the amount of data the serial port must process per transaction.
The maximum size of a single log line (including the trailing zero) is defined
as a compile-time constant (#define). While dynamic sizing (runtime) might
seem more flexible, SafeSerial prioritizes system stability.
This constant determines the size of the static buffers used during string formatting (printf) and message processing in the background task.
- Deterministic RAM Usage: By using a fixed-size buffer, the memory is allocated at the start. You will never face a "surprise" out-of-memory crash after 48 hours of operation because of a long log line.
- Heap Fragmentation Prevention: In embedded systems, frequently using new, malloc, or String operations can "punch holes" in your RAM (fragmentation). Over time, this makes it impossible to allocate even small blocks of memory, leading to crashes. SafeSerial stays purely on the Stack, which is faster and safer.
- Performance: Allocating a buffer on the stack is nearly instantaneous (just moving a pointer), whereas heap allocation requires searching for a free block of memory.
If you need longer log lines (e.g., for JSON exports), do not look for a setter function. Instead, define it in your build environment.
build_flags =
-D SAFESERIAL_LINE_BUFFER_SIZE=512 ; Default is 256A sketch-level #define does not work: every library .cpp is compiled as
a separate translation unit and never sees a macro defined in your .ino.
Instead, add a file named build_opt.h in the sketch folder, containing the
raw compiler flag:
-DSAFESERIAL_LINE_BUFFER_SIZE=512The ESP32 core passes build_opt.h to every translation unit — including the
library — via gcc's @file mechanism, so the size stays consistent everywhere.
It holds compiler flags, not #defines (despite the .h name), and has no
comment support, so keep it to bare -D flags. This works identically under
arduino-cli (the file just lives in the sketch folder).
Note:
build_opt.his the ESP32 mechanism. It is unrelated to the ESP8266<Sketch>.ino.globals.h//*@create-file:build.opt@ ... */approach, which the ESP32 does not read. Do not use a*.ino.globals.hfile on ESP32: it has no effect, and the.inoin its name also hides the folder from the Arduino IDE Examples menu.
setStackSize().
The line is formatted (via vsnprintf) on the stack of the calling task,
and a copy is processed on the SafeSerial task's own stack. Both must stay
larger than the maximum line length plus some overhead (use setStackSize()
for the SafeSerial task; size your own tasks accordingly).
How a serial port behaves when data is written while no terminal is listening depends on the port type:
- HW-CDC (native USB Serial/JTAG): with no terminal open, the hardware TX
FIFO fills and each write blocks (up to the TX timeout, ~100 ms by
default). On a plain
Serialthis stalls the whole program. - USB-CDC (TinyUSB stack): non-blocking. With no DTR connection the driver discards the bytes and execution continues.
- HW-UART (real UART / USB-to-UART bridge): non-blocking. The UART pushes bytes out of the TX pin regardless of whether anyone listens; there is no feedback loop.
Because SafeSerial performs the actual print()/flush() inside its
background task (never on the caller), your application never blocks in any of
these cases — println()/printf() only enqueue. However, on a headless
HW-CDC port the background task itself would still block on flush(),
congesting the TX queue and stalling RX servicing on that instance.
To prevent this, the background task checks the port before writing: if no
terminal/host is connected it discards the message instead of blocking. UART
ports have no host-presence signal and are always treated as connected. You
can query the state yourself with isConnected() (see Diagnostics), e.g. to
skip verbose logging while headless.
Note: HW-CDC/USB-CDC connection detection relies on the port's boolean operator (DTR / CDC line state). Its reliability is core-version dependent (some early ESP32 Arduino cores always reported "connected"), and some terminals do not assert DTR.
To test the stack usage use the UBaseType_t getStackHighWatermark(void)
function. It returns the lowest value of free stack size of SafeSerial task
since it started:
SafeSerial.printf(F("SafeSerial Stack HighWatermark: %u bytes\n"), SafeSerial.getStackHighWatermark());Due to static memory usage, the value remains constant after the task fully starts.
There is a getter function uint32_t getDroppedMessages(void) to check the
number of dropped messages during the task's lifetime:
SafeSerial.printf(F("Dropped messages: %u\n"), SafeSerial.getDroppedMessages());The result is 32-bit to preserve the atomic nature of the internal counter on the ESP32. Its maximum value is UINT32_MAX (4,294,967,295). That’s more than enough for most purposes. When it reaches this value, it stops incrementing; it does not reset to 0.
bool isConnected(void) reports whether a terminal/host is currently present
(always true on UART ports). Messages dropped specifically because no
terminal was connected are counted both in getDroppedMessages() (the total)
and, separately, in getSkippedWhileDisconnected():
SafeSerial.printf(F("Skipped while disconnected: %u\n"),
SafeSerial.getSkippedWhileDisconnected());Subtracting this from getDroppedMessages() isolates the drops caused by
genuine queue overflow (high load) — the value to watch when tuning
setTxQueueSize().
- Memory Footprint: By default, it uses ~8.5 kB for the queues (~7.5 kB
for Tx, ~1 kB for Rx) plus 4 kB for the task stack per instance
(conservative default; typically ~1-2 kB is enough, verifiable with
getStackHighWatermark()). - Dropping Logs: If the queue is full (e.g., during a massive flood of logs), new messages will be dropped to prevent the main application from hanging.
- Headless HW-CDC: When no terminal is connected, messages are dropped
rather than blocking the background task, and counted via
getSkippedWhileDisconnected(). - Throughput Limit: Dropped messages depend not only on queue depth and message frequency, but also on line length and baud rate — the serial interface transmits one character at a time, so longer lines at lower baud rates increase the risk of queue overflow.
MIT - Free for use in both personal and commercial projects.
Notice: Portions of this software were generated using AI language models. This code is provided "as is".
Created in 2026 by Péter Soós.