Merge branch 'feat/esp-stdio-custom-driver' into 'master'

feat(esp_stdio): Add support for custom IO

Closes IDF-14901 and IDF-15541

See merge request espressif/esp-idf!45955
This commit is contained in:
Guillaume Souchere
2026-09-16 10:32:44 +02:00
22 changed files with 1135 additions and 203 deletions

View File

@@ -19,7 +19,7 @@ idf_component_register(SRCS ${srcs}
LDFRAGMENTS "linker.lf"
)
if(CONFIG_VFS_SUPPORT_IO AND CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED)
if(CONFIG_VFS_SUPPORT_IO AND CONFIG_SOC_USB_SERIAL_JTAG_SUPPORTED)
target_link_libraries(${COMPONENT_LIB} PUBLIC idf::vfs)
target_sources(${COMPONENT_LIB} PRIVATE "src/usb_serial_jtag_vfs.c")
target_link_libraries(${COMPONENT_LIB} INTERFACE "-u usb_serial_jtag_vfs_include_dev_init")

View File

@@ -716,8 +716,7 @@ void usb_serial_jtag_vfs_dev_port_deinit(const esp_console_dev_usb_serial_jtag_c
#if CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG
ESP_SYSTEM_INIT_FN(init_vfs_usj_sec, CORE, BIT(0), 112)
{
// "/dev/seccondary_usb_serial_jtag" unfortunately is too long for vfs
esp_vfs_register_fs("/dev/secondary", &s_vfs_jtag, ESP_VFS_FLAG_STATIC | ESP_VFS_FLAG_CONTEXT_PTR, NULL);
esp_vfs_register_fs("/dev/usj", &s_vfs_jtag, ESP_VFS_FLAG_STATIC | ESP_VFS_FLAG_CONTEXT_PTR, NULL);
return ESP_OK;
}
#endif

View File

@@ -9,12 +9,18 @@ if(non_os_build)
endif()
set(srcs)
set(includes)
set(priv_includes)
if(${target} STREQUAL "linux")
list(APPEND srcs "stdio_port.c"
"linux/esp_stdio_linux.c")
list(APPEND includes "include"
"linux/include")
if(CONFIG_VFS_SUPPORT_IO)
list(APPEND srcs "stdio_vfs.c" "stdio_mux_api.c")
list(APPEND priv_includes "private_include")
endif()
else()
list(APPEND srcs "stdio_port.c"
"stdio_simple.c"
@@ -22,17 +28,23 @@ else()
list(APPEND includes "include")
if(CONFIG_VFS_SUPPORT_IO)
list(APPEND srcs "stdio_vfs.c")
list(APPEND srcs "stdio_vfs.c" "stdio_mux_api.c")
list(APPEND priv_includes "private_include")
endif()
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS ${includes})
INCLUDE_DIRS ${includes}
PRIV_INCLUDE_DIRS ${priv_includes})
if(CONFIG_VFS_SUPPORT_IO)
# The public header esp_stdio.h includes VFS types when VFS is enabled.
# Make sure vfs include dirs are available on all targets.
idf_component_optional_requires(PUBLIC vfs)
endif()
if(CONFIG_VFS_SUPPORT_IO AND NOT ${target} STREQUAL "linux")
if(IDF_BUILD_V2)
idf_component_include(vfs)
if(CONFIG_ESP_CONSOLE_UART)
idf_component_include(esp_driver_uart)
target_link_libraries(${COMPONENT_TARGET} PRIVATE
@@ -65,9 +77,8 @@ if(CONFIG_VFS_SUPPORT_IO AND NOT ${target} STREQUAL "linux")
endif()
target_link_libraries(${COMPONENT_LIB} PRIVATE idf::vfs)
# Make sure esp_stdio_register gets called at startup stage.
# The referenced symbol is defined in stdio_vfs.c, which is only added to
# the source list above on non-Linux targets; the force-undef is gated on
# the same condition for the link line to remain resolvable.
target_link_libraries(${COMPONENT_LIB} INTERFACE "-u esp_vfs_include_console_register")
# Make sure esp_stdio_register gets called at startup stage
if(NOT ${target} STREQUAL "linux")
target_link_libraries(${COMPONENT_LIB} INTERFACE "-u esp_vfs_include_console_register")
endif()
endif()

View File

@@ -15,14 +15,110 @@ extern "C" {
#define ESP_VFS_DEV_CONSOLE "/dev/console"
/**
* @brief add uart/usb_serial_jtag/usb_otg_acmcdc virtual filesystem driver
* @brief Register the default console VFS backend(s) selected in Kconfig.
*
* This function is called from startup code to enable serial output
* Sets up the primary (and any Kconfig-selected secondary) console sink and
* mounts them under /dev/console. This function is automatically called from
* startup code to enable serial output; applications normally do not need to
* call it themselves.
*
* @return ESP_OK on success, or an error code from the underlying VFS registration.
*/
esp_err_t esp_stdio_register(void);
#if CONFIG_VFS_SUPPORT_IO
#include "esp_vfs_common.h"
#include "esp_vfs_ops.h"
/**
* @brief Install and enable the default stdio driver.
* @brief Configuration for registering a console I/O backend in the mux.
*
* The caller must keep the ops table, context, and path string valid until
* esp_stdio_unregister_io() is called.
*/
typedef struct {
const esp_vfs_fs_ops_t *vfs_ops; /*!< VFS operations table (must not be NULL) */
void *vfs_ctx; /*!< Context pointer forwarded to every VFS callback */
const char *path; /*!< Path used for open() inside the driver (e.g. "/0", "/") */
} esp_stdio_io_config_t;
/** @brief Opaque handle representing a registered console I/O backend. */
typedef struct esp_stdio_entry *esp_stdio_handle_t;
/**
* @brief Register a VFS backend as a write-only auxiliary sink in the console mux.
*
* The backend starts receiving fan-out writes immediately (once /dev/console
* is open). To make it the active read+write primary, pass the returned
* handle to esp_stdio_push_primary().
*
* The caller is responsible for initialising the driver before calling this
* function. The ops table, context, and path string must remain valid until
* esp_stdio_unregister_io() is called.
*
* @param config Pointer to the I/O configuration (must not be NULL; vfs_ops
* and path must not be NULL).
* @param out_handle Receives the opaque handle on success (must not be NULL).
* @return ESP_OK on success
* ESP_ERR_INVALID_ARG if any required pointer is NULL
* ESP_ERR_NO_MEM if the entry pool is exhausted
*/
esp_err_t esp_stdio_register_io(const esp_stdio_io_config_t *config,
esp_stdio_handle_t *out_handle);
/**
* @brief Push an existing handle onto the primary stack.
*
* Removes the entry from the auxiliary list and makes it the active
* read+write backend. The previous primary is suspended on a stack and
* resumes when esp_stdio_pop_primary() is called.
*
* @param handle A valid handle returned by esp_stdio_register_io().
* @return ESP_OK on success
* ESP_ERR_INVALID_ARG if handle is NULL or not currently registered as auxiliary
* ESP_ERR_NO_MEM if the primary stack is full
*/
esp_err_t esp_stdio_push_primary(esp_stdio_handle_t handle);
/**
* @brief Remove a primary from the stack.
*
* The removed entry is returned to the auxiliary list. When it was the active
* (top) primary, the next-most-recent primary becomes active; the
* system-registered primary at the base of the stack always remains as the
* ultimate fallback and can never be removed.
*
* @param handle The primary to remove. Pass NULL to remove the current active
* (top) primary. Pass a specific handle to remove it from
* wherever it sits in the stack (owner-keyed removal, which lets
* independent owners release their own primary without disturbing
* a more-recently-pushed one).
* @return ESP_OK on success
* ESP_ERR_INVALID_STATE if there is no user-pushed primary to remove
* (NULL), or if @p handle is not currently on the
* primary stack
*/
esp_err_t esp_stdio_pop_primary(esp_stdio_handle_t handle);
/**
* @brief Unregister a backend from the console mux entirely.
*
* The handle's backend fd is closed if it was open. If the handle is
* currently the active primary it is implicitly popped first (the previous
* primary is restored). If it is buried in the primary stack it is removed
* from wherever it sits. After this call the handle is invalid.
*
* @param handle A valid handle returned by esp_stdio_register_io().
* @return ESP_OK on success
* ESP_ERR_INVALID_ARG if handle is NULL
*/
esp_err_t esp_stdio_unregister_io(esp_stdio_handle_t handle);
#endif // CONFIG_VFS_SUPPORT_IO
/**
* @brief Install and enable the stdio driver.
*
* Initializes the selected console backend and registers it as the active
* input and output stream source. After calling this function, standard I/O
@@ -32,6 +128,10 @@ esp_err_t esp_stdio_register(void);
* - RX: CR (terminals send CR when Enter is pressed)
* - TX: CRLF (move cursor to beginning of next line on newline)
*
* If a user has taken over the primary console with esp_stdio_push_primary(),
* this function is a no-op (the user backend is assumed to be already
* initialised by the caller).
*
* @return ESP_OK if the driver is successfully installed, or an appropriate error code otherwise.
*/
esp_err_t esp_stdio_install_io_driver(void);
@@ -42,6 +142,9 @@ esp_err_t esp_stdio_install_io_driver(void);
* Restores driver state and detaches the console backend from the standard I/O
* streams. Call this when shutting down or when replacing the current console
* driver with a different one.
*
* If a user has taken over the primary console with esp_stdio_push_primary(),
* this function is a no-op.
*/
void esp_stdio_uninstall_io_driver(void);

View File

@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stdbool.h>
#include <fcntl.h>
#include <sys/lock.h>
#include "sdkconfig.h"
#include "esp_vfs.h"
#ifdef __cplusplus
extern "C" {
#endif
#if CONFIG_VFS_SUPPORT_IO
#define STDIO_MAX_ENTRIES CONFIG_ESP_STDIO_MAX_VFS_ENTRIES
typedef struct esp_stdio_entry {
const esp_vfs_fs_ops_t *ops;
void *vfs_ctx;
const char *path;
int fd; /* backend fd; -1 when closed */
bool in_use; /* false = slot available */
} esp_stdio_entry_t;
/*
* Shared mux state — defined in stdio_vfs.c, used by stdio_mux_api.c.
*
* s_primary_stack[0] is the immovable system-registered sentinel.
* s_primary_stack[s_primary_index] is the active primary.
* Auxiliaries are any in-use pool entries not on the stack.
*/
extern esp_stdio_entry_t s_entry_pool[STDIO_MAX_ENTRIES];
extern esp_stdio_entry_t *s_primary_stack[STDIO_MAX_ENTRIES];
extern int s_primary_index;
extern int s_open_count;
extern _lock_t s_lock;
/* Low-level helpers — defined in stdio_vfs.c, called from stdio_mux_api.c. */
void entry_open(esp_stdio_entry_t *e, int flags);
void entry_close(esp_stdio_entry_t *e);
/**
* @brief Return true if the active primary is NOT the system-registered backend.
*
* Used by stdio_port.c to decide whether to skip hardware init/deinit
* (the caller of esp_stdio_push_primary() is responsible for their own driver
* lifecycle).
*/
bool esp_stdio_has_user_primary(void);
#endif // CONFIG_VFS_SUPPORT_IO
#ifdef __cplusplus
}
#endif

View File

@@ -0,0 +1,198 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/*
* Public mux API: esp_stdio_register_io / unregister_io / push_primary /
* pop_primary / has_user_primary.
*
* This file is intentionally separate from stdio_vfs.c so that it is only
* linked into a binary when the application actually calls one of these
* functions. stdio_vfs.c is always pulled in (via the
* -u esp_vfs_include_console_register linker hook) but this file is not, so
* apps that never use the custom-IO API do not pay for the code here.
*/
#include "sdkconfig.h"
#if CONFIG_VFS_SUPPORT_IO
#include <string.h>
#include <fcntl.h>
#include "esp_err.h"
#include "esp_log.h"
#include "esp_stdio.h"
#include "esp_stdio_private.h"
static const char *TAG = "esp_stdio";
/* True if the entry is currently on the primary stack (at any depth). */
static bool on_stack(const esp_stdio_entry_t *e)
{
for (int i = 0; i <= s_primary_index; i++) {
if (s_primary_stack[i] == e) {
return true;
}
}
return false;
}
/* Remove an entry from the primary stack at any depth (does not close fd).
* Returns true if found. */
static bool stack_remove(esp_stdio_entry_t *e)
{
for (int i = 0; i <= s_primary_index; i++) {
if (s_primary_stack[i] == e) {
/* Shift everything above it down so the entry below becomes the
* new active primary. */
memmove(&s_primary_stack[i], &s_primary_stack[i + 1],
(s_primary_index - i) * sizeof(s_primary_stack[0]));
s_primary_stack[s_primary_index--] = NULL;
return true;
}
}
return false;
}
bool esp_stdio_has_user_primary(void)
{
/* The system-registered primary is always at the base of the stack, so a
* user primary is active exactly when something has been pushed on top. */
return s_primary_index > 0;
}
esp_err_t esp_stdio_register_io(const esp_stdio_io_config_t *config,
esp_stdio_handle_t *out_handle)
{
if (!config || !config->vfs_ops || !config->path || !out_handle) {
return ESP_ERR_INVALID_ARG;
}
_lock_acquire(&s_lock);
esp_stdio_entry_t *e = NULL;
for (int i = 0; i < STDIO_MAX_ENTRIES; i++) {
if (!s_entry_pool[i].in_use) {
e = &s_entry_pool[i];
break;
}
}
if (!e) {
_lock_release(&s_lock);
ESP_EARLY_LOGE(TAG, "No space left for stdio registration");
return ESP_ERR_NO_MEM;
}
*e = (esp_stdio_entry_t) {
.ops = config->vfs_ops,
.vfs_ctx = config->vfs_ctx,
.path = config->path,
.fd = -1,
.in_use = true,
};
/* A freshly registered entry is an auxiliary. If /dev/console is already
* open, open it now so it starts receiving the write/fsync fan-out. */
if (s_open_count > 0) {
entry_open(e, O_WRONLY);
}
_lock_release(&s_lock);
*out_handle = e;
return ESP_OK;
}
esp_err_t esp_stdio_unregister_io(esp_stdio_handle_t handle)
{
if (!handle) {
return ESP_ERR_INVALID_ARG;
}
_lock_acquire(&s_lock);
if (!handle->in_use) {
_lock_release(&s_lock);
return ESP_ERR_INVALID_ARG;
}
/* If it is on the primary stack at any depth, remove it so the previous
* primary takes over. Auxiliaries are not on the stack: no-op for them. */
stack_remove(handle);
entry_close(handle);
handle->in_use = false;
handle->ops = NULL;
_lock_release(&s_lock);
return ESP_OK;
}
esp_err_t esp_stdio_push_primary(esp_stdio_handle_t handle)
{
if (!handle) {
return ESP_ERR_INVALID_ARG;
}
_lock_acquire(&s_lock);
if (!handle->in_use || on_stack(handle)) {
_lock_release(&s_lock);
return ESP_ERR_INVALID_ARG;
}
if (s_primary_index + 1 >= STDIO_MAX_ENTRIES) {
_lock_release(&s_lock);
return ESP_ERR_NO_MEM;
}
/* The previously active primary stays suspended on the stack (still open,
* so it receives the write/fsync fan-out). Open the new primary if the
* console is already open.
*
* If the entry was previously used as an auxiliary it will already have
* an fd opened O_WRONLY. entry_open() would return early in that case
* without upgrading to O_RDWR, causing reads from the new primary to
* fail. Close first so the re-open below picks up the correct flags. */
if (s_open_count > 0) {
if (handle->fd >= 0) {
entry_close(handle);
}
entry_open(handle, O_RDWR);
}
s_primary_stack[++s_primary_index] = handle;
_lock_release(&s_lock);
return ESP_OK;
}
esp_err_t esp_stdio_pop_primary(esp_stdio_handle_t handle)
{
_lock_acquire(&s_lock);
/* s_primary_stack[0] is the system-registered sentinel: it can never be
* removed. Its handle is never exposed to callers, but guard against it
* explicitly. */
if (handle == NULL) {
/* Remove the active (top) primary. */
if (s_primary_index <= 0) {
_lock_release(&s_lock);
return ESP_ERR_INVALID_STATE;
}
handle = s_primary_stack[s_primary_index];
} else if (handle == s_primary_stack[0] || !on_stack(handle)) {
_lock_release(&s_lock);
return ESP_ERR_INVALID_STATE;
}
/* Remove from the stack. The entry stays in_use and open, reverting to
* auxiliary status. No fd juggling required. */
stack_remove(handle);
_lock_release(&s_lock);
return ESP_OK;
}
#endif // CONFIG_VFS_SUPPORT_IO

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
#include <string.h>
#include <stdbool.h>
#include "sdkconfig.h"
#include "esp_stdio.h"
@@ -30,8 +32,20 @@
#endif // CONFIG_VFS_SUPPORT_IO
#if CONFIG_VFS_SUPPORT_IO
#include "esp_stdio_private.h"
#endif
esp_err_t esp_stdio_install_io_driver(void)
{
#if CONFIG_VFS_SUPPORT_IO
/* If a user primary is active, it was initialised by the caller. Skip HW init. */
if (esp_stdio_has_user_primary()) {
return ESP_OK;
}
#endif // CONFIG_VFS_SUPPORT_IO
/* No user primary — proceed with Kconfig default HW init */
esp_err_t ret = ESP_FAIL;
#if CONFIG_IDF_TARGET_LINUX
@@ -39,7 +53,7 @@ esp_err_t esp_stdio_install_io_driver(void)
ret = linux_vfs_dev_port_init(&config);
#elif CONFIG_VFS_SUPPORT_IO
/* - set rx_mode to ESP_LINE_ENDINGS_CRLF as minicom, screen, idf_monitor
/* - set rx_mode to ESP_LINE_ENDINGS_CR as minicom, screen, idf_monitor
* send CR when ENTER key is pressed.
* - set tx_mode to move the caret to the beginning of the next line on '\n' */
@@ -61,6 +75,13 @@ esp_err_t esp_stdio_install_io_driver(void)
void esp_stdio_uninstall_io_driver(void)
{
#if CONFIG_VFS_SUPPORT_IO
/* If a user primary is registered, deinit is the caller's responsibility. */
if (esp_stdio_has_user_primary()) {
return;
}
#endif // CONFIG_VFS_SUPPORT_IO
#if CONFIG_IDF_TARGET_LINUX
linux_port_config_t config = ESP_CONSOLE_DEV_LINUX_CONFIG_DEFAULT();
linux_vfs_dev_port_deinit(&config);

View File

@@ -16,6 +16,14 @@
#include "esp_vfs.h"
#include <sys/errno.h>
/**
* This file is to concentrate all the vfs(UART, USB_SERIAL_JTAG, CDCACM) console into one single file.
* Get the vfs information from their component (i.e. uart_vfs.c),
* which can help us to output some string to two different ports(i.e both through uart and usb_serial_jtag).
* Usually, we set a port as primary and another as secondary. For primary, it is used for all the features
* supported by each vfs implementation, while the secondary is only used for output.
*/
#if CONFIG_VFS_SUPPORT_IO
#if CONFIG_ESP_CONSOLE_USB_CDC
@@ -28,111 +36,96 @@
#include "driver/usb_serial_jtag_vfs.h"
#endif // CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED
#if CONFIG_ESP_CONSOLE_UART
#if CONFIG_ESP_CONSOLE_UART && !CONFIG_IDF_TARGET_LINUX
#include "driver/esp_private/uart_vfs.h"
#include "driver/uart_vfs.h"
#endif // CONFIG_ESP_CONSOLE_UART
#endif // CONFIG_ESP_CONSOLE_UART && !CONFIG_IDF_TARGET_LINUX
#include "esp_private/startup_internal.h"
#include "esp_private/nullfs.h"
#include "esp_stdio_private.h"
#include <sys/lock.h>
#endif
#endif // CONFIG_VFS_SUPPORT_IO
#define STRINGIFY(s) STRINGIFY2(s)
#define STRINGIFY2(s) #s
/**
* This file is to concentrate all the vfs(UART, USB_SERIAL_JTAG, CDCACM) console into one single file.
* Get the vfs information from their component (i.e. uart_vfs.c),
* which can help us to output some string to two different ports(i.e both through uart and usb_serial_jtag).
* Usually, we set a port as primary and another as secondary. For primary, it is used for all the features supported by each vfs implementation,
* while the secondary is only used for output.
*/
/* Pool, stack and lock — declared extern in esp_stdio_private.h so that
* stdio_mux_api.c can access them without pulling in this object file. */
esp_stdio_entry_t s_entry_pool[STDIO_MAX_ENTRIES];
esp_stdio_entry_t *s_primary_stack[STDIO_MAX_ENTRIES];
int s_primary_index = -1;
int s_open_count;
_lock_t s_lock;
#if CONFIG_VFS_SUPPORT_IO
/* The active primary is always the top of the stack. */
static inline esp_stdio_entry_t *active_primary(void)
{
assert(s_primary_index >= 0);
return s_primary_stack[s_primary_index];
}
typedef struct {
const esp_vfs_fs_ops_t *ops; /* NULL = slot is free */
void *vfs_ctx;
const char *path;
int fd; /* -1 if not opened (used by auxiliaries) */
} mux_entry_t;
/* Open the backend for an entry and store the fd.
* Non-static: also called from stdio_mux_api.c. */
void entry_open(esp_stdio_entry_t *e, int flags)
{
if (!e->ops || !e->ops->open_p || e->fd >= 0) {
return;
}
e->fd = e->ops->open_p(e->vfs_ctx, e->path, flags, 0);
}
/* Primary backend (all ops forwarded except open + write + fsync) */
static mux_entry_t s_primary = { .fd = -1 };
/* Auxiliary sinks (write-only fan-out) */
#define STDIO_MAX_AUXILIARY (CONFIG_ESP_STDIO_MAX_VFS_ENTRIES - 1)
static mux_entry_t s_auxiliary[STDIO_MAX_AUXILIARY] = { [0 ... STDIO_MAX_AUXILIARY - 1] = { .fd = -1 } };
static int s_open_count = 0;
static _lock_t s_lock;
static const char *TAG = "esp_stdio";
/* Close the backend fd for an entry.
* Non-static: also called from stdio_mux_api.c. */
void entry_close(esp_stdio_entry_t *e)
{
if (e->fd < 0) {
return;
}
if (e->ops && e->ops->close_p) {
e->ops->close_p(e->vfs_ctx, e->fd);
}
e->fd = -1;
}
#ifdef CONFIG_VFS_SUPPORT_TERMIOS
static const mux_entry_t *get_primary_termios_entry(int fd, const esp_vfs_termios_ops_t **out_ops)
static esp_stdio_entry_t *get_primary_termios_entry(int fd, const esp_vfs_termios_ops_t **out_ops)
{
const mux_entry_t *entry = &s_primary;
assert(entry);
const esp_vfs_termios_ops_t *termios = entry->ops->termios;
if (!termios) {
esp_stdio_entry_t *entry = active_primary();
if (!entry || !entry->ops || !entry->ops->termios) {
errno = ENOSYS;
return NULL;
}
if (out_ops) {
*out_ops = termios;
*out_ops = entry->ops->termios;
}
return entry;
}
#endif // CONFIG_VFS_SUPPORT_TERMIOS
static esp_err_t __attribute__((unused)) register_auxiliary(const esp_vfs_fs_ops_t *ops, void *ctx, const char *path)
{
if (!ops || !path || !ops->write_p) {
return ESP_ERR_INVALID_ARG;
}
_lock_acquire(&s_lock);
for (size_t i = 0; i < STDIO_MAX_AUXILIARY; i++) {
if (s_auxiliary[i].ops == NULL) {
s_auxiliary[i] = (mux_entry_t) {
.ops = ops,
.vfs_ctx = ctx,
.path = path,
.fd = -1,
};
_lock_release(&s_lock);
return ESP_OK;
}
}
_lock_release(&s_lock);
ESP_EARLY_LOGE(TAG, "Too many auxiliary sinks");
return ESP_ERR_NO_MEM;
}
int console_open(__attribute__((unused)) void *ctx, const char * path, int flags, int mode)
int console_open(__attribute__((unused)) void *ctx, const char *path, int flags, int mode)
{
(void)path;
const mux_entry_t *entry = &s_primary;
if (!entry->ops || !entry->ops->open_p) {
esp_stdio_entry_t *primary = active_primary();
if (!primary->ops || !primary->ops->open_p) {
errno = ENOSYS;
return -1;
}
int local_fd = entry->ops->open_p(entry->vfs_ctx, entry->path, flags, mode);
int local_fd = primary->ops->open_p(primary->vfs_ctx, primary->path, flags, mode);
if (local_fd < 0) {
return -1;
}
primary->fd = local_fd;
/* Lazily open auxiliaries on first console open */
/* Lazily open every other in-use entry (the auxiliaries) on first console
* open so they receive the write/fsync fan-out. */
_lock_acquire(&s_lock);
if (s_open_count == 0) {
for (size_t i = 0; i < STDIO_MAX_AUXILIARY; i++) {
mux_entry_t *aux = &s_auxiliary[i];
if (aux->ops && aux->ops->open_p && aux->fd < 0) {
aux->fd = aux->ops->open_p(aux->vfs_ctx, aux->path, O_WRONLY, 0);
for (int i = 0; i < STDIO_MAX_ENTRIES; i++) {
esp_stdio_entry_t *e = &s_entry_pool[i];
if (e->in_use && e != primary) {
entry_open(e, O_WRONLY);
}
}
}
@@ -144,28 +137,30 @@ int console_open(__attribute__((unused)) void *ctx, const char * path, int flags
int console_close(__attribute__((unused)) void *ctx, int fd)
{
const mux_entry_t *entry = &s_primary;
if (!entry->ops || !entry->ops->close_p) {
esp_stdio_entry_t *primary = active_primary();
if (!primary->ops || !primary->ops->close_p) {
errno = ENOSYS;
return -1;
}
int ret = entry->ops->close_p(entry->vfs_ctx, fd);
/* Route to the active backend's own fd: after a push/pop/unregister the
* active primary may differ from the backend that originally produced the
* caller-visible fd, and each backend validates fds in its own namespace. */
int ret = primary->ops->close_p(primary->vfs_ctx, primary->fd >= 0 ? primary->fd : fd);
if (ret != 0) {
return ret;
}
primary->fd = -1;
/* Close auxiliaries when last console fd is closed */
_lock_acquire(&s_lock);
if (s_open_count > 0) {
s_open_count--;
}
if (s_open_count == 0) {
for (size_t i = 0; i < STDIO_MAX_AUXILIARY; i++) {
mux_entry_t *aux = &s_auxiliary[i];
if (aux->ops && aux->ops->close_p && aux->fd >= 0) {
aux->ops->close_p(aux->vfs_ctx, aux->fd);
aux->fd = -1;
for (int i = 0; i < STDIO_MAX_ENTRIES; i++) {
esp_stdio_entry_t *e = &s_entry_pool[i];
if (e->in_use && e != primary) {
entry_close(e);
}
}
}
@@ -176,120 +171,112 @@ int console_close(__attribute__((unused)) void *ctx, int fd)
ssize_t console_write(__attribute__((unused)) void *ctx, int fd, const void *data, size_t size)
{
const mux_entry_t *primary = &s_primary;
ssize_t ret_val = primary->ops->write_p(primary->vfs_ctx, fd, data, size);
esp_stdio_entry_t *primary = active_primary();
ssize_t ret = primary->ops->write_p(primary->vfs_ctx, primary->fd >= 0 ? primary->fd : fd, data, size);
_lock_acquire(&s_lock);
for (size_t i = 0; i < STDIO_MAX_AUXILIARY; i++) {
const mux_entry_t *entry = &s_auxiliary[i];
if (entry->ops && entry->ops->write_p && entry->fd >= 0) {
(void) entry->ops->write_p(entry->vfs_ctx, entry->fd, data, size);
for (int i = 0; i < STDIO_MAX_ENTRIES; i++) {
esp_stdio_entry_t *e = &s_entry_pool[i];
if (e->in_use && e != primary && e->ops && e->ops->write_p && e->fd >= 0) {
(void)e->ops->write_p(e->vfs_ctx, e->fd, data, size);
}
}
_lock_release(&s_lock);
return ret_val;
return ret;
}
int console_fstat(__attribute__((unused)) void *ctx, int fd, struct stat * st)
int console_fstat(__attribute__((unused)) void *ctx, int fd, struct stat *st)
{
const mux_entry_t *entry = &s_primary;
if (!entry->ops->fstat_p) {
esp_stdio_entry_t *primary = active_primary();
if (!primary->ops->fstat_p) {
errno = ENOSYS;
return -1;
}
return entry->ops->fstat_p(entry->vfs_ctx, fd, st);
return primary->ops->fstat_p(primary->vfs_ctx, primary->fd >= 0 ? primary->fd : fd, st);
}
ssize_t console_read(__attribute__((unused)) void *ctx, int fd, void * dst, size_t size)
ssize_t console_read(__attribute__((unused)) void *ctx, int fd, void *dst, size_t size)
{
const mux_entry_t *entry = &s_primary;
if (!entry->ops->read_p) {
esp_stdio_entry_t *primary = active_primary();
if (!primary->ops->read_p) {
errno = ENOSYS;
return -1;
}
return entry->ops->read_p(entry->vfs_ctx, fd, dst, size);
return primary->ops->read_p(primary->vfs_ctx, primary->fd >= 0 ? primary->fd : fd, dst, size);
}
int console_fcntl(__attribute__((unused)) void *ctx, int fd, int cmd, int arg)
{
const mux_entry_t *entry = &s_primary;
if (!entry->ops->fcntl_p) {
esp_stdio_entry_t *primary = active_primary();
if (!primary->ops->fcntl_p) {
errno = ENOSYS;
return -1;
}
return entry->ops->fcntl_p(entry->vfs_ctx, fd, cmd, arg);
return primary->ops->fcntl_p(primary->vfs_ctx, primary->fd >= 0 ? primary->fd : fd, cmd, arg);
}
int console_fsync(__attribute__((unused)) void *ctx, int fd)
{
const mux_entry_t *primary = &s_primary;
esp_stdio_entry_t *primary = active_primary();
if (!primary->ops->fsync_p) {
errno = ENOSYS;
return -1;
}
int ret_val = primary->ops->fsync_p(primary->vfs_ctx, fd);
int ret = primary->ops->fsync_p(primary->vfs_ctx, primary->fd >= 0 ? primary->fd : fd);
_lock_acquire(&s_lock);
for (size_t i = 0; i < STDIO_MAX_AUXILIARY; i++) {
const mux_entry_t *entry = &s_auxiliary[i];
if (entry->ops && entry->ops->fsync_p && entry->fd >= 0) {
(void) entry->ops->fsync_p(entry->vfs_ctx, entry->fd);
for (int i = 0; i < STDIO_MAX_ENTRIES; i++) {
esp_stdio_entry_t *e = &s_entry_pool[i];
if (e->in_use && e != primary && e->ops && e->ops->fsync_p && e->fd >= 0) {
(void)e->ops->fsync_p(e->vfs_ctx, e->fd);
}
}
_lock_release(&s_lock);
return ret_val;
return ret;
}
#ifdef CONFIG_VFS_SUPPORT_DIR
int console_access(__attribute__((unused)) void *ctx, const char *path, int amode)
{
const mux_entry_t *entry = &s_primary;
assert(entry);
if (!entry->ops->dir || !entry->ops->dir->access_p) {
esp_stdio_entry_t *primary = active_primary();
if (!primary->ops->dir || !primary->ops->dir->access_p) {
errno = ENOSYS;
return -1;
}
(void) path;
return entry->ops->dir->access_p(entry->vfs_ctx, entry->path, amode);
(void)path;
return primary->ops->dir->access_p(primary->vfs_ctx, primary->path, amode);
}
#endif // CONFIG_VFS_SUPPORT_DIR
#ifdef CONFIG_VFS_SUPPORT_SELECT
static esp_err_t console_start_select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
esp_vfs_select_sem_t select_sem, void **end_select_args)
{
const mux_entry_t *entry = &s_primary;
if (!entry->ops || !entry->ops->select || !entry->ops->select->start_select) {
const esp_vfs_fs_ops_t *ops = active_primary()->ops;
if (!ops || !ops->select || !ops->select->start_select) {
return ESP_ERR_NOT_SUPPORTED;
}
return entry->ops->select->start_select(nfds, readfds, writefds, exceptfds, select_sem, end_select_args);
return ops->select->start_select(nfds, readfds, writefds, exceptfds, select_sem, end_select_args);
}
static esp_err_t console_end_select(void *end_select_args)
{
const mux_entry_t *entry = &s_primary;
if (!entry->ops || !entry->ops->select || !entry->ops->select->end_select) {
const esp_vfs_fs_ops_t *ops = active_primary()->ops;
if (!ops || !ops->select || !ops->select->end_select) {
return ESP_ERR_NOT_SUPPORTED;
}
return entry->ops->select->end_select(end_select_args);
return ops->select->end_select(end_select_args);
}
#endif // CONFIG_VFS_SUPPORT_SELECT
#ifdef CONFIG_VFS_SUPPORT_TERMIOS
int console_tcsetattr(__attribute__((unused)) void *ctx, int fd, int optional_actions, const struct termios *p)
{
const esp_vfs_termios_ops_t *termios = NULL;
const mux_entry_t *entry = get_primary_termios_entry(fd, &termios);
if (!entry) {
return -1;
}
if (!termios->tcsetattr_p) {
esp_stdio_entry_t *entry = get_primary_termios_entry(fd, &termios);
if (!entry || !termios->tcsetattr_p) {
errno = ENOSYS;
return -1;
}
@@ -299,11 +286,8 @@ int console_tcsetattr(__attribute__((unused)) void *ctx, int fd, int optional_ac
int console_tcgetattr(__attribute__((unused)) void *ctx, int fd, struct termios *p)
{
const esp_vfs_termios_ops_t *termios = NULL;
const mux_entry_t *entry = get_primary_termios_entry(fd, &termios);
if (!entry) {
return -1;
}
if (!termios->tcgetattr_p) {
esp_stdio_entry_t *entry = get_primary_termios_entry(fd, &termios);
if (!entry || !termios->tcgetattr_p) {
errno = ENOSYS;
return -1;
}
@@ -313,11 +297,8 @@ int console_tcgetattr(__attribute__((unused)) void *ctx, int fd, struct termios
int console_tcdrain(__attribute__((unused)) void *ctx, int fd)
{
const esp_vfs_termios_ops_t *termios = NULL;
const mux_entry_t *entry = get_primary_termios_entry(fd, &termios);
if (!entry) {
return -1;
}
if (!termios->tcdrain_p) {
esp_stdio_entry_t *entry = get_primary_termios_entry(fd, &termios);
if (!entry || !termios->tcdrain_p) {
errno = ENOSYS;
return -1;
}
@@ -327,11 +308,8 @@ int console_tcdrain(__attribute__((unused)) void *ctx, int fd)
int console_tcflush(__attribute__((unused)) void *ctx, int fd, int select)
{
const esp_vfs_termios_ops_t *termios = NULL;
const mux_entry_t *entry = get_primary_termios_entry(fd, &termios);
if (!entry) {
return -1;
}
if (!termios->tcflush_p) {
esp_stdio_entry_t *entry = get_primary_termios_entry(fd, &termios);
if (!entry || !termios->tcflush_p) {
errno = ENOSYS;
return -1;
}
@@ -369,69 +347,61 @@ static const esp_vfs_fs_ops_t s_vfs_console = {
.read_p = &console_read,
.fcntl_p = &console_fcntl,
.fsync_p = &console_fsync,
#ifdef CONFIG_VFS_SUPPORT_DIR
.dir = &s_vfs_console_dir,
#endif // CONFIG_VFS_SUPPORT_DIR
#endif
#ifdef CONFIG_VFS_SUPPORT_SELECT
.select = &s_vfs_console_select,
#endif // CONFIG_VFS_SUPPORT_SELECT
#endif
#ifdef CONFIG_VFS_SUPPORT_TERMIOS
.termios = &s_vfs_console_termios,
#endif // CONFIG_VFS_SUPPORT_TERMIOS
#endif
};
esp_err_t esp_stdio_register(void)
{
_lock_init(&s_lock);
// Primary vfs part.
#if CONFIG_ESP_CONSOLE_UART
s_primary = (mux_entry_t) {
.ops = esp_vfs_uart_get_vfs(),
/* Directly initialise the system primary at pool slot 0 and push it as the
* immovable sentinel at stack index 0. We bypass the public API here to
* avoid a link-time dependency on stdio_mux_api.c so that binaries which
* never call the mux API don't pay for it. */
esp_stdio_entry_t *system_primary = &s_entry_pool[0];
*system_primary = (esp_stdio_entry_t) {
.vfs_ctx = NULL,
.path = "/" STRINGIFY(CONFIG_ESP_CONSOLE_UART_NUM),
.path = "/",
.fd = -1,
.in_use = true,
};
#if CONFIG_ESP_CONSOLE_UART && !CONFIG_IDF_TARGET_LINUX
/* no need to check vfs_ops for NULL: there is no config in which it can be NULL */
system_primary->ops = esp_vfs_uart_get_vfs();
system_primary->path = "/" STRINGIFY(CONFIG_ESP_CONSOLE_UART_NUM);
#elif CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG
s_primary = (mux_entry_t) {
system_primary->ops = esp_vfs_usb_serial_jtag_get_vfs();
#elif CONFIG_ESP_CONSOLE_USB_CDC
system_primary->ops = esp_vfs_cdcacm_get_vfs();
#else
system_primary->ops = esp_vfs_null_get_vfs();
#endif
s_primary_stack[0] = system_primary;
s_primary_index = 0;
#if CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG
esp_stdio_entry_t *secondary = &s_entry_pool[1];
*secondary = (esp_stdio_entry_t) {
.ops = esp_vfs_usb_serial_jtag_get_vfs(),
.vfs_ctx = NULL,
.path = "/",
.fd = -1,
};
#elif CONFIG_ESP_CONSOLE_USB_CDC
s_primary = (mux_entry_t) {
.ops = esp_vfs_cdcacm_get_vfs(),
.vfs_ctx = NULL,
.path = "/",
.fd = -1,
};
#else
s_primary = (mux_entry_t) {
.ops = esp_vfs_null_get_vfs(),
.vfs_ctx = NULL,
.path = "/",
.fd = -1,
.in_use = true,
};
#endif
if (!s_primary.ops) {
ESP_EARLY_LOGE(TAG, "No primary console backend available");
return ESP_FAIL;
}
// Auxiliary sinks (write-only fan-out).
#if CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG
esp_err_t err = register_auxiliary(esp_vfs_usb_serial_jtag_get_vfs(), NULL, "/");
if (err != ESP_OK) {
return err;
}
#endif
return esp_vfs_register_fs(ESP_VFS_DEV_CONSOLE, &s_vfs_console, ESP_VFS_FLAG_STATIC | ESP_VFS_FLAG_CONTEXT_PTR, NULL);
return esp_vfs_register_fs(ESP_VFS_DEV_CONSOLE, &s_vfs_console,
ESP_VFS_FLAG_STATIC | ESP_VFS_FLAG_CONTEXT_PTR, NULL);
}
ESP_SYSTEM_INIT_FN(init_vfs_console, CORE, BIT(0), 119)
@@ -439,8 +409,6 @@ ESP_SYSTEM_INIT_FN(init_vfs_console, CORE, BIT(0), 119)
return esp_stdio_register();
}
#endif // CONFIG_VFS_SUPPORT_IO
void esp_vfs_include_console_register(void)
{
// Linker hook function, exists to make the linker examine this file

View File

@@ -1,4 +1,11 @@
# Documentation: .gitlab/ci/README.md#manifest-file-to-control-the-buildtest-apps
components/esp_stdio/test_apps/custom_io:
enable:
- if: IDF_TARGET == "linux"
depends_components:
- esp_stdio
- vfs
components/esp_stdio/test_apps/stdio:
disable:
- if: CONFIG_NAME == "serial_jtag_only" and SOC_USB_SERIAL_JTAG_SUPPORTED != 1

View File

@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
set(COMPONENTS main)
project(test_esp_stdio_custom_io)

View File

@@ -0,0 +1,24 @@
| Supported Targets | Linux |
| ----------------- | ----- |
# esp_stdio custom I/O (Linux host test)
Linux-only host test for the runtime console-mux registration API in `esp_stdio`:
`esp_stdio_register_custom_io()` / `esp_stdio_unregister_custom_io()` for both
`ESP_STDIO_PRIMARY` and `ESP_STDIO_AUXILIARY` roles. The mux logic is pure C
(dispatch through `esp_vfs_fs_ops_t` function pointers), so a small in-memory
mock VFS backend is enough — no MCU peripheral required.
## Run
```
idf.py --preview set-target linux
idf.py build
build/test_esp_stdio_custom_io.elf
```
Or via pytest:
```
pytest pytest_esp_stdio_custom_io.py --target linux
```

View File

@@ -0,0 +1,6 @@
idf_component_register(SRCS "test_app_main.c"
"test_custom_io.c"
"mock_vfs.c"
INCLUDE_DIRS "."
PRIV_REQUIRES unity esp_stdio vfs
WHOLE_ARCHIVE)

View File

@@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <string.h>
#include "mock_vfs.h"
mock_vfs_state_t mock_a;
mock_vfs_state_t mock_b;
static int mock_open(void *ctx, const char *path, int flags, int mode)
{
(void)path;
(void)flags;
(void)mode;
mock_vfs_state_t *s = (mock_vfs_state_t *)ctx;
s->open_count++;
return s->next_fd;
}
static int mock_close(void *ctx, int fd)
{
mock_vfs_state_t *s = (mock_vfs_state_t *)ctx;
s->close_count++;
s->last_closed_fd = fd;
return 0;
}
static ssize_t mock_write(void *ctx, int fd, const void *data, size_t size)
{
(void)fd;
mock_vfs_state_t *s = (mock_vfs_state_t *)ctx;
s->write_count++;
size_t room = MOCK_VFS_BUF_SIZE - s->write_len;
size_t n = size < room ? size : room;
memcpy(s->write_buf + s->write_len, data, n);
s->write_len += n;
return (ssize_t)size;
}
static ssize_t mock_read(void *ctx, int fd, void *dst, size_t size)
{
(void)fd;
mock_vfs_state_t *s = (mock_vfs_state_t *)ctx;
s->read_count++;
size_t avail = s->read_len - s->read_pos;
size_t n = size < avail ? size : avail;
memcpy(dst, s->read_buf + s->read_pos, n);
s->read_pos += n;
return (ssize_t)n;
}
static int mock_fstat(void *ctx, int fd, struct stat *st)
{
(void)ctx;
(void)fd;
if (st) {
memset(st, 0, sizeof(*st));
}
return 0;
}
static int mock_fsync(void *ctx, int fd)
{
(void)fd;
mock_vfs_state_t *s = (mock_vfs_state_t *)ctx;
s->fsync_count++;
return 0;
}
const esp_vfs_fs_ops_t mock_vfs_ops_a = {
.write_p = &mock_write,
.open_p = &mock_open,
.close_p = &mock_close,
.read_p = &mock_read,
.fstat_p = &mock_fstat,
.fsync_p = &mock_fsync,
};
const esp_vfs_fs_ops_t mock_vfs_ops_b = {
.write_p = &mock_write,
.open_p = &mock_open,
.close_p = &mock_close,
.read_p = &mock_read,
.fstat_p = &mock_fstat,
.fsync_p = &mock_fsync,
};
void mock_vfs_reset(void)
{
memset(&mock_a, 0, sizeof(mock_a));
memset(&mock_b, 0, sizeof(mock_b));
mock_a.next_fd = 10;
mock_b.next_fd = 20;
mock_a.last_closed_fd = -1;
mock_b.last_closed_fd = -1;
}

View File

@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stddef.h>
#include <sys/types.h>
#include <sys/stat.h>
#include "esp_vfs_ops.h"
#ifdef __cplusplus
extern "C" {
#endif
#define MOCK_VFS_BUF_SIZE 256
/**
* @brief In-memory fake VFS backend used to exercise the esp_stdio console mux.
*
* Every callback records that it was invoked so tests can assert exactly how
* the mux dispatched the operation. Writes are appended to @c write_buf; reads
* are served from @c read_buf.
*/
typedef struct {
int open_count; /*!< number of open_p() calls */
int close_count; /*!< number of close_p() calls */
int write_count; /*!< number of write_p() calls */
int read_count; /*!< number of read_p() calls */
int fsync_count; /*!< number of fsync_p() calls */
int next_fd; /*!< fd returned by next open_p() */
int last_closed_fd; /*!< fd passed to the last close_p() */
char write_buf[MOCK_VFS_BUF_SIZE]; /*!< accumulated written bytes */
size_t write_len; /*!< bytes stored in write_buf */
char read_buf[MOCK_VFS_BUF_SIZE]; /*!< bytes served by read_p() */
size_t read_len; /*!< bytes available in read_buf */
size_t read_pos; /*!< read cursor into read_buf */
} mock_vfs_state_t;
/* Two independent backend instances + ops tables, enough for primary + aux tests. */
extern mock_vfs_state_t mock_a;
extern mock_vfs_state_t mock_b;
extern const esp_vfs_fs_ops_t mock_vfs_ops_a;
extern const esp_vfs_fs_ops_t mock_vfs_ops_b;
/** @brief Reset both mock backends to a clean state. */
void mock_vfs_reset(void);
#ifdef __cplusplus
}
#endif

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "unity.h"
#include "unity_test_runner.h"
#include "mock_vfs.h"
void setUp(void)
{
mock_vfs_reset();
}
void app_main(void)
{
unity_run_menu();
}

View File

@@ -0,0 +1,268 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include "unity.h"
#include "unity_test_runner.h"
#include "esp_stdio.h"
#include "mock_vfs.h"
static const esp_stdio_io_config_t cfg_a = {
.vfs_ops = &mock_vfs_ops_a, .vfs_ctx = &mock_a, .path = "/",
};
static const esp_stdio_io_config_t cfg_b = {
.vfs_ops = &mock_vfs_ops_b, .vfs_ctx = &mock_b, .path = "/",
};
TEST_CASE("register_io/push+pop primary: write and read routed correctly", "[esp_stdio]")
{
esp_stdio_handle_t h;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_a, &h));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_push_primary(h));
int fd = open("/dev/console", O_RDWR);
TEST_ASSERT_GREATER_OR_EQUAL(0, fd);
TEST_ASSERT_EQUAL(1, mock_a.open_count);
const char *msg = "hello";
TEST_ASSERT_EQUAL((ssize_t)strlen(msg), write(fd, msg, strlen(msg)));
TEST_ASSERT_EQUAL(1, mock_a.write_count);
TEST_ASSERT_EQUAL_STRING_LEN(msg, mock_a.write_buf, strlen(msg));
TEST_ASSERT_EQUAL(0, close(fd));
TEST_ASSERT_EQUAL(1, mock_a.close_count);
/* pop_primary: system primary (null VFS) takes over; mock_a receives no more traffic */
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_pop_primary(NULL));
fd = open("/dev/console", O_RDWR);
TEST_ASSERT_GREATER_OR_EQUAL(0, fd);
TEST_ASSERT_EQUAL(1, write(fd, "x", 1)); /* goes to null VFS + mock_a as aux */
TEST_ASSERT_EQUAL(2, mock_a.write_count); /* mock_a is now aux again */
close(fd);
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(h));
}
TEST_CASE("primary read path", "[esp_stdio]")
{
strcpy(mock_a.read_buf, "abc");
mock_a.read_len = 3;
esp_stdio_handle_t h;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_a, &h));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_push_primary(h));
int fd = open("/dev/console", O_RDWR);
TEST_ASSERT_GREATER_OR_EQUAL(0, fd);
char buf[8] = {0};
TEST_ASSERT_EQUAL(3, read(fd, buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("abc", buf);
TEST_ASSERT_EQUAL(1, mock_a.read_count);
close(fd);
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_pop_primary(NULL));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(h));
}
TEST_CASE("auxiliary fan-out", "[esp_stdio]")
{
esp_stdio_handle_t h;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_b, &h));
/* stays as auxiliary */
int fd = open("/dev/console", O_RDWR);
TEST_ASSERT_GREATER_OR_EQUAL(0, fd);
TEST_ASSERT_EQUAL(1, mock_b.open_count);
const char *msg = "fan";
TEST_ASSERT_EQUAL((ssize_t)strlen(msg), write(fd, msg, strlen(msg)));
TEST_ASSERT_EQUAL(1, mock_b.write_count);
TEST_ASSERT_EQUAL_STRING_LEN(msg, mock_b.write_buf, strlen(msg));
TEST_ASSERT_EQUAL(0, fsync(fd));
TEST_ASSERT_EQUAL(1, mock_b.fsync_count);
close(fd);
TEST_ASSERT_EQUAL(1, mock_b.close_count);
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(h));
}
TEST_CASE("entry pool exhaustion", "[esp_stdio]")
{
/* Pool size = CONFIG_ESP_STDIO_MAX_VFS_ENTRIES=3; system uses 1 -> 2 free */
esp_stdio_handle_t h1, h2, h3;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_a, &h1));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_b, &h2));
TEST_ASSERT_EQUAL(ESP_ERR_NO_MEM, esp_stdio_register_io(&cfg_a, &h3));
int fd = open("/dev/console", O_RDWR);
TEST_ASSERT_EQUAL(1, write(fd, "x", 1));
TEST_ASSERT_EQUAL(1, mock_a.write_count);
TEST_ASSERT_EQUAL(1, mock_b.write_count);
close(fd);
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(h1));
/* freed a slot */
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_a, &h1));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(h1));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(h2));
}
TEST_CASE("invalid arguments", "[esp_stdio]")
{
esp_stdio_handle_t h;
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_stdio_register_io(NULL, &h));
esp_stdio_io_config_t bad = { .vfs_ops = NULL, .path = "/" };
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_stdio_register_io(&bad, &h));
bad.vfs_ops = &mock_vfs_ops_a;
bad.path = NULL;
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_stdio_register_io(&bad, &h));
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_stdio_register_io(&cfg_a, NULL));
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_stdio_push_primary(NULL));
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_stdio_unregister_io(NULL));
}
TEST_CASE("pop_primary with no user primary returns INVALID_STATE", "[esp_stdio]")
{
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, esp_stdio_pop_primary(NULL));
}
TEST_CASE("push_primary reroutes writes, pop restores previous", "[esp_stdio]")
{
esp_stdio_handle_t ha, hb;
strcpy(mock_a.read_buf, "AAA");
mock_a.read_len = 3;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_a, &ha));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_push_primary(ha));
int fd = open("/dev/console", O_RDWR);
char buf[8] = {0};
TEST_ASSERT_EQUAL(3, read(fd, buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("AAA", buf);
TEST_ASSERT_EQUAL(2, write(fd, "a1", 2));
TEST_ASSERT_EQUAL(1, mock_a.write_count);
close(fd);
/* Push B on top — A drops back to aux */
strcpy(mock_b.read_buf, "BBB");
mock_b.read_len = 3;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_b, &hb));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_push_primary(hb));
fd = open("/dev/console", O_RDWR);
memset(buf, 0, sizeof(buf));
TEST_ASSERT_EQUAL(3, read(fd, buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("BBB", buf);
TEST_ASSERT_EQUAL(2, write(fd, "b1", 2));
TEST_ASSERT_EQUAL(1, mock_b.write_count);
TEST_ASSERT_EQUAL(2, mock_a.write_count); /* A is aux, gets fan-out */
close(fd);
/* Pop B — A is primary again */
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_pop_primary(NULL));
fd = open("/dev/console", O_RDWR);
TEST_ASSERT_EQUAL(1, write(fd, "y", 1));
TEST_ASSERT_EQUAL(3, mock_a.write_count); /* primary write */
TEST_ASSERT_EQUAL(2, mock_b.write_count); /* B now aux, gets fan-out */
close(fd);
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_pop_primary(NULL));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(ha));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(hb));
}
TEST_CASE("unregister active primary succeeds (implicit pop)", "[esp_stdio]")
{
esp_stdio_handle_t h;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_a, &h));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_push_primary(h));
int fd = open("/dev/console", O_RDWR);
TEST_ASSERT_EQUAL(1, write(fd, "z", 1));
TEST_ASSERT_EQUAL(1, mock_a.write_count);
/* Unregister while it is the active primary — must succeed */
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(h));
/* Console still works via the restored system primary */
TEST_ASSERT_EQUAL(1, write(fd, "q", 1));
close(fd);
}
TEST_CASE("install/uninstall are no-ops with user primary", "[esp_stdio]")
{
esp_stdio_handle_t h;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_a, &h));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_push_primary(h));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_install_io_driver());
esp_stdio_uninstall_io_driver();
TEST_ASSERT_EQUAL(0, mock_a.open_count); /* driver not initialised by install */
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_pop_primary(NULL));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(h));
}
TEST_CASE("unregister one auxiliary keeps others working", "[esp_stdio]")
{
esp_stdio_handle_t ha, hb;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_a, &ha));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_b, &hb));
int fd = open("/dev/console", O_RDWR);
TEST_ASSERT_EQUAL(2, write(fd, "hi", 2));
TEST_ASSERT_EQUAL(1, mock_a.write_count);
TEST_ASSERT_EQUAL(1, mock_b.write_count);
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(ha));
TEST_ASSERT_EQUAL(1, mock_a.close_count);
TEST_ASSERT_EQUAL(2, write(fd, "yo", 2));
TEST_ASSERT_EQUAL(1, mock_a.write_count); /* A gone */
TEST_ASSERT_EQUAL(2, mock_b.write_count); /* B still there */
close(fd);
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(hb));
}
TEST_CASE("pop_primary(handle) removes a buried primary without disturbing the active one", "[esp_stdio]")
{
/* Models two independent owners: A pushes its primary, then B pushes on
* top. A releases its own (now buried) primary by handle — B must stay
* active and A must drop back to being an auxiliary sink. */
esp_stdio_handle_t ha, hb;
strcpy(mock_b.read_buf, "BBB");
mock_b.read_len = 3;
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_a, &ha));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_push_primary(ha));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_register_io(&cfg_b, &hb));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_push_primary(hb));
/* Remove the buried primary A (not the active top B) */
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_pop_primary(ha));
int fd = open("/dev/console", O_RDWR);
char buf[8] = {0};
TEST_ASSERT_EQUAL(3, read(fd, buf, sizeof(buf)));
TEST_ASSERT_EQUAL_STRING("BBB", buf); /* B is still the active primary */
TEST_ASSERT_EQUAL(1, write(fd, "x", 1));
TEST_ASSERT_EQUAL(1, mock_b.write_count); /* B primary write */
TEST_ASSERT_EQUAL(1, mock_a.write_count); /* A demoted to aux, gets fan-out */
close(fd);
/* A is no longer on the stack — popping it again is INVALID_STATE */
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, esp_stdio_pop_primary(ha));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_pop_primary(NULL));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(ha));
TEST_ASSERT_EQUAL(ESP_OK, esp_stdio_unregister_io(hb));
}

View File

@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Unlicense OR CC0-1.0
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.host_test
@idf_parametrize('target', ['linux'], indirect=['target'])
def test_esp_stdio_custom_io_linux(dut: Dut) -> None:
dut.run_all_single_board_cases(timeout=60)

View File

@@ -0,0 +1,3 @@
CONFIG_IDF_TARGET="linux"
CONFIG_VFS_SUPPORT_IO=y
CONFIG_ESP_STDIO_MAX_VFS_ENTRIES=3

View File

@@ -6,11 +6,13 @@ if(${target} STREQUAL "linux")
set(inc)
set(priv_inc)
list(APPEND inc include)
# linux_include provides stubs (sys/reent.h, dirent.h, errno.h) required by
# esp_vfs.h unconditionally, so expose it for all Linux builds regardless of
# VFS_SUPPORT_IO, which now defaults to n on Linux.
list(APPEND inc include linux_include)
if(CONFIG_VFS_SUPPORT_IO)
list(APPEND inc linux_include)
list(APPEND priv_inc private_include)
list(APPEND srcs "vfs_linux.c" "vfs_linux_default_coop.c" "vfs.c" "vfs_calls.c")
list(APPEND srcs "vfs_linux.c" "vfs_linux_default_coop.c" "vfs.c" "vfs_calls.c" "nullfs.c")
endif()
if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux")

View File

@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
* SPDX-FileCopyrightText: 2025-2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
@@ -32,10 +32,16 @@ typedef struct {
/**
* @brief Directory entry structure
*
* Fields match the glibc layout so that this header is binary-compatible with
* glibc's readdir() when CONFIG_VFS_SUPPORT_IO is disabled on Linux (in which
* case glibc's readdir() fills the struct, not the VFS wrapper).
*/
struct dirent {
ino_t d_ino; /*!< file number */
uint8_t d_type; /*!< not defined in POSIX, but present in BSD and Linux */
ino_t d_ino; /*!< file number */
off_t d_off; /*!< implementation-defined offset (glibc extension) */
unsigned short d_reclen; /*!< length of this record (glibc extension) */
uint8_t d_type; /*!< not defined in POSIX, but present in BSD and Linux */
#define DT_UNKNOWN 0
#define DT_REG 1
#define DT_DIR 2

View File

@@ -148,7 +148,7 @@ static ssize_t vfs_null_read(__attribute__((unused)) void *ctx, int fd, void *da
return -1;
}
static int vfs_null_pread(__attribute__((unused)) void *ctx, int fd, void *data, size_t size, off_t offset)
static ssize_t vfs_null_pread(__attribute__((unused)) void *ctx, int fd, void *data, size_t size, off_t offset)
{
UNUSED(data);
UNUSED(size);
@@ -165,7 +165,7 @@ static int vfs_null_pread(__attribute__((unused)) void *ctx, int fd, void *data,
}
static int vfs_null_pwrite(__attribute__((unused)) void *ctx, int fd, const void *data, size_t size, off_t offset)
static ssize_t vfs_null_pwrite(__attribute__((unused)) void *ctx, int fd, const void *data, size_t size, off_t offset)
{
UNUSED(data);
UNUSED(offset);

View File

@@ -174,3 +174,72 @@ Once you have created a custom VFS driver, use :cpp:func:`esp_vfs_register_fs()`
stderr = f;
Note that logging functions (``ESP_LOGE()``, etc.) write their output to ``stdout``. Keep this in mind when using logging within the implementation of your custom VFS (or any components which it calls). For example, if the custom VFS driver's ``write()`` operation fails and uses ``ESP_LOGE()`` to log the error, this will cause the output to be sent to ``stdout``, which would again call the custom VFS driver's ``write()`` operation. This would result in an infinite loop. It is recommended to keep track of this re-entry condition in the VFS driver's ``write()`` implementation, and return immediately if the write operation is still in progress.
Console I/O multiplexer
-----------------------
In addition to redirecting ``stdout`` and ``stderr`` manually (as shown above), ESP-IDF provides a console I/O multiplexer that lets an application register one or more custom VFS backends and switch between them at runtime, without reassigning the ``stdin``/``stdout``/``stderr`` streams itself.
The multiplexer is exposed on ``/dev/console`` and manages backends in two roles:
- **Primary** — the active read and write backend. Both application input (``stdin``) and output (``stdout``/``stderr``) go through it. Primaries are kept on a stack: pushing a new primary suspends the previous one, and popping restores it. The default console selected in Kconfig sits at the base of the stack and is always kept as the ultimate fallback.
- **Auxiliary** — a write-only sink. Every byte written to ``stdout`` and ``stderr`` is fanned out to all registered auxiliaries in addition to the primary. This is useful, for example, to mirror console output to a log file or a network connection while keeping the interactive console on the physical interface.
The maximum number of backends that can be registered at once (primaries plus auxiliaries, including the Kconfig default) is set by :ref:`CONFIG_ESP_STDIO_MAX_VFS_ENTRIES`.
API overview
^^^^^^^^^^^^
The multiplexer API is declared in ``esp_stdio.h`` and is available when ``CONFIG_VFS_SUPPORT_IO`` is enabled:
- ``esp_stdio_register_io()`` — register a VFS backend as a write-only auxiliary and return an opaque handle. The backend starts receiving the write fan-out immediately.
- ``esp_stdio_push_primary()`` — promote a previously registered handle to the active read and write primary. The current primary is suspended on the stack.
- ``esp_stdio_pop_primary()`` — remove a primary from the stack, returning it to auxiliary status. Passing ``NULL`` removes the current (top) primary; passing a specific handle removes it from wherever it sits in the stack.
- ``esp_stdio_unregister_io()`` — remove a backend from the multiplexer entirely. If it is currently the active primary, it is popped first.
The backend is described by an ``esp_stdio_io_config_t`` structure:
.. list::
- ``vfs_ops`` — pointer to the VFS operations table (must not be ``NULL``).
- ``vfs_ctx`` — context pointer forwarded to every VFS callback.
- ``path`` — path passed to ``open()`` inside the driver, for example ``"/"``.
.. note::
The ops table, context, and path string passed in the configuration must remain valid until the backend is removed with ``esp_stdio_unregister_io()``.
Example
^^^^^^^
The following example registers a custom backend, makes it the active console, and later restores the previous one:
.. code-block:: c
#include "esp_stdio.h"
// A VFS operations table implemented by the application.
extern const esp_vfs_fs_ops_t my_console_vfs;
esp_stdio_handle_t handle;
esp_stdio_io_config_t config = {
.vfs_ops = &my_console_vfs,
.vfs_ctx = NULL,
.path = "/",
};
// Register as a write-only auxiliary: output is now mirrored to it.
ESP_ERROR_CHECK(esp_stdio_register_io(&config, &handle));
// Promote it to the active read + write primary.
ESP_ERROR_CHECK(esp_stdio_push_primary(handle));
// ... application uses the custom console for stdin/stdout/stderr ...
// Restore the previous primary (the custom backend reverts to auxiliary).
ESP_ERROR_CHECK(esp_stdio_pop_primary(handle));
// Remove the backend from the multiplexer entirely.
ESP_ERROR_CHECK(esp_stdio_unregister_io(handle));
The same re-entrancy caveat described above for logging applies here: the backend's ``write()`` implementation must not trigger logging that would recursively write to the console.