From af1a00ccf9b8f055a9a51fd9fe019b9c8d9eb638 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 12:35:41 -1000 Subject: [PATCH 1/8] [esp32] Wrap printf/vprintf/fprintf to eliminate _vfprintf_r (~11 KB flash) ESP-IDF SDK components reference fprintf(), printf(), and vprintf() which pull in newlib's _vfprintf_r (~11 KB). This is a separate implementation from _svfprintf_r (used by snprintf/vsnprintf) that handles FILE* stream I/O with buffering and locking. ESPHome replaces the ESP-IDF log handler via esp_log_set_vprintf_(), so the SDK's vprintf() path is dead code at runtime. The fprintf() and printf() calls in SDK components are only in debug/assert paths that are either GC'd or never called. These linker --wrap stubs redirect through vsnprintf() + fwrite(), allowing the linker to dead-code eliminate _vfprintf_r. An escape hatch is provided via enable_full_printf: true in the esp32 advanced config section for external components that need full FILE*-based fprintf. --- esphome/components/esp32/__init__.py | 10 +++ esphome/components/esp32/printf_stubs.cpp | 79 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 esphome/components/esp32/printf_stubs.cpp diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 998913ecec..e3a58e904e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -952,6 +952,7 @@ CONF_HEAP_IN_IRAM = "heap_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" CONF_USE_FULL_CERTIFICATE_BUNDLE = "use_full_certificate_bundle" CONF_DISABLE_DEBUG_STUBS = "disable_debug_stubs" +CONF_ENABLE_FULL_PRINTF = "enable_full_printf" CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" @@ -1126,6 +1127,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional( CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, default=[] ): cv.ensure_list(cv.string_strict), + cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, cv.Optional(CONF_DISABLE_DEBUG_STUBS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_OCD_AWARE, default=True): cv.boolean, cv.Optional( @@ -1469,6 +1471,14 @@ async def to_code(config): "_ZSt25__throw_bad_function_callv", ]: cg.add_build_flag(f"-Wl,--wrap={mangled}") + + # Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r + # (~11 KB). See printf_stubs.cpp for implementation. + if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF]: + cg.add_define("USE_FULL_PRINTF") + else: + for symbol in ["vprintf", "printf", "fprintf"]: + cg.add_build_flag(f"-Wl,--wrap={symbol}") else: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp new file mode 100644 index 0000000000..5ee4650d50 --- /dev/null +++ b/esphome/components/esp32/printf_stubs.cpp @@ -0,0 +1,79 @@ +/* + * Linker wrap stubs for FILE*-based printf functions. + * + * ESP-IDF SDK components (gpio driver, ringbuf, log_write) reference + * fprintf(), printf(), and vprintf() which pull in newlib's _vfprintf_r + * (~11 KB). This is a separate implementation from _svfprintf_r (used by + * snprintf/vsnprintf) that handles FILE* stream I/O with buffering and + * locking. + * + * ESPHome replaces the ESP-IDF log handler via esp_log_set_vprintf_(), + * so the SDK's vprintf() path is dead code at runtime. The fprintf() + * and printf() calls in SDK components are only in debug/assert paths + * (gpio_dump_io_configuration, ringbuf diagnostics) that are either + * GC'd or never called. + * + * These stubs redirect through vsnprintf() (which uses _svfprintf_r + * already in the binary) and fwrite(), allowing the linker to + * dead-code eliminate _vfprintf_r. + * + * Saves ~11 KB of flash. + * + * To disable these wraps, set enable_full_printf: true in the esp32 + * advanced config section. + */ + +#if defined(USE_ESP_IDF) && !defined(USE_FULL_PRINTF) +#include +#include + +#include "esp_system.h" + +static constexpr size_t PRINTF_BUFFER_SIZE = 512; + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vprintf(const char *fmt, va_list ap) { + char buf[PRINTF_BUFFER_SIZE]; + int len = vsnprintf(buf, sizeof(buf), fmt, ap); + if (len < 0) { + return len; + } + if (static_cast(len) >= sizeof(buf)) { + // Output was truncated — this should not happen in normal operation. + // Abort to make the issue visible rather than silently losing output. + esp_system_abort("printf buffer overflow; set enable_full_printf: true in esp32 advanced config"); + } + fwrite(buf, 1, len, stdout); + return len; +} + +int __wrap_printf(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + int len = __wrap_vprintf(fmt, ap); + va_end(ap); + return len; +} + +int __wrap_fprintf(FILE *stream, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char buf[PRINTF_BUFFER_SIZE]; + int len = vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + if (len < 0) { + return len; + } + if (static_cast(len) >= sizeof(buf)) { + esp_system_abort("fprintf buffer overflow; set enable_full_printf: true in esp32 advanced config"); + } + fwrite(buf, 1, len, stream); + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP_IDF && !USE_FULL_PRINTF From 9f11dc736fc989cad67aca417327e770f0c31e84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 12:39:30 -1000 Subject: [PATCH 2/8] Add namespace esphome::esp32 to satisfy CI lint check --- esphome/components/esp32/printf_stubs.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 5ee4650d50..d85e92aacd 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -29,6 +29,8 @@ #include "esp_system.h" +namespace esphome::esp32 {} + static constexpr size_t PRINTF_BUFFER_SIZE = 512; // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) From a3a6ed358288f6be39afcc34b3013b6dfa3413a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 12:40:58 -1000 Subject: [PATCH 3/8] Extract common buffer write + overflow check into helper --- esphome/components/esp32/printf_stubs.cpp | 35 +++++++++++------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index d85e92aacd..8fc303cfe9 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -33,22 +33,26 @@ namespace esphome::esp32 {} static constexpr size_t PRINTF_BUFFER_SIZE = 512; +// Write formatted buffer to stream, aborting on overflow. +static int write_printf_buffer_(FILE *stream, char *buf, int len) { + if (len < 0) { + return len; + } + if (static_cast(len) >= PRINTF_BUFFER_SIZE) { + // Output was truncated — this should not happen in normal operation. + // Abort to make the issue visible rather than silently losing output. + esp_system_abort("printf buffer overflow; set enable_full_printf: true in esp32 advanced config"); + } + fwrite(buf, 1, len, stream); + return len; +} + // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) extern "C" { int __wrap_vprintf(const char *fmt, va_list ap) { char buf[PRINTF_BUFFER_SIZE]; - int len = vsnprintf(buf, sizeof(buf), fmt, ap); - if (len < 0) { - return len; - } - if (static_cast(len) >= sizeof(buf)) { - // Output was truncated — this should not happen in normal operation. - // Abort to make the issue visible rather than silently losing output. - esp_system_abort("printf buffer overflow; set enable_full_printf: true in esp32 advanced config"); - } - fwrite(buf, 1, len, stdout); - return len; + return write_printf_buffer_(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); } int __wrap_printf(const char *fmt, ...) { @@ -63,15 +67,8 @@ int __wrap_fprintf(FILE *stream, const char *fmt, ...) { va_list ap; va_start(ap, fmt); char buf[PRINTF_BUFFER_SIZE]; - int len = vsnprintf(buf, sizeof(buf), fmt, ap); + int len = write_printf_buffer_(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); va_end(ap); - if (len < 0) { - return len; - } - if (static_cast(len) >= sizeof(buf)) { - esp_system_abort("fprintf buffer overflow; set enable_full_printf: true in esp32 advanced config"); - } - fwrite(buf, 1, len, stream); return len; } From b2728cbd4461f71b5737833cde90c7f68b7880ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 12:43:22 -1000 Subject: [PATCH 4/8] Address review: flush before abort, check fwrite errors, add test --- esphome/components/esp32/printf_stubs.cpp | 12 ++++++++---- tests/components/esp32/test.esp32-idf.yaml | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 8fc303cfe9..33098ea516 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -38,12 +38,16 @@ static int write_printf_buffer_(FILE *stream, char *buf, int len) { if (len < 0) { return len; } - if (static_cast(len) >= PRINTF_BUFFER_SIZE) { - // Output was truncated — this should not happen in normal operation. - // Abort to make the issue visible rather than silently losing output. + size_t write_len = len; + if (write_len >= PRINTF_BUFFER_SIZE) { + // Output was truncated — flush what we have before aborting + // so the user sees context leading up to the overflow. + fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); esp_system_abort("printf buffer overflow; set enable_full_printf: true in esp32 advanced config"); } - fwrite(buf, 1, len, stream); + if (fwrite(buf, 1, write_len, stream) < write_len || ferror(stream)) { + return -1; + } return len; } diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index f80c854de5..da85aa3b0f 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -10,6 +10,7 @@ esp32: use_full_certificate_bundle: false # Test CMN bundle (default) include_builtin_idf_components: - freertos # Test escape hatch (freertos is always included anyway) + enable_full_printf: false disable_debug_stubs: true disable_ocd_aware: true disable_usb_serial_jtag_secondary: true From 2f903c91ff946ba232b40370da6ad63ffa78f92e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 12:45:00 -1000 Subject: [PATCH 5/8] Clarify that stubs are dead code at runtime and overflow is defensive --- esphome/components/esp32/printf_stubs.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 33098ea516..0b94b66967 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -33,15 +33,16 @@ namespace esphome::esp32 {} static constexpr size_t PRINTF_BUFFER_SIZE = 512; -// Write formatted buffer to stream, aborting on overflow. +// These stubs are essentially dead code at runtime — ESPHome replaces the +// ESP-IDF log handler, and the SDK's printf/fprintf calls only exist in +// debug/assert paths that are never reached in normal operation. +// The buffer overflow check is purely defensive and should never trigger. static int write_printf_buffer_(FILE *stream, char *buf, int len) { if (len < 0) { return len; } size_t write_len = len; if (write_len >= PRINTF_BUFFER_SIZE) { - // Output was truncated — flush what we have before aborting - // so the user sees context leading up to the overflow. fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); esp_system_abort("printf buffer overflow; set enable_full_printf: true in esp32 advanced config"); } From 8dd24bfe782c882a8a3c746a73d7228c1bd3928f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 12:46:04 -1000 Subject: [PATCH 6/8] Note that crash backtraces use esp_rom_printf, not libc --- esphome/components/esp32/printf_stubs.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 0b94b66967..5c24174034 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -11,7 +11,9 @@ * so the SDK's vprintf() path is dead code at runtime. The fprintf() * and printf() calls in SDK components are only in debug/assert paths * (gpio_dump_io_configuration, ringbuf diagnostics) that are either - * GC'd or never called. + * GC'd or never called. Crash backtraces and panic output are + * unaffected — they use esp_rom_printf() which is a ROM function + * and does not go through libc. * * These stubs redirect through vsnprintf() (which uses _svfprintf_r * already in the binary) and fwrite(), allowing the linker to From 35ac7a16f924961f32e3efff82c5aeb59ffa425a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 12:46:51 -1000 Subject: [PATCH 7/8] Use tuple instead of list for static iteration --- esphome/components/esp32/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e3a58e904e..dd9e394fd2 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1477,7 +1477,7 @@ async def to_code(config): if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF]: cg.add_define("USE_FULL_PRINTF") else: - for symbol in ["vprintf", "printf", "fprintf"]: + for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") else: cg.add_build_flag("-DUSE_ARDUINO") From 0e14d0b0a0db9af899acb6085edc3b35bdac6c00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 12:47:08 -1000 Subject: [PATCH 8/8] Fix config path in comments: esp32 framework advanced --- esphome/components/esp32/printf_stubs.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 5c24174034..5b93919f4e 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -46,7 +46,7 @@ static int write_printf_buffer_(FILE *stream, char *buf, int len) { size_t write_len = len; if (write_len >= PRINTF_BUFFER_SIZE) { fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); - esp_system_abort("printf buffer overflow; set enable_full_printf: true in esp32 advanced config"); + esp_system_abort("printf buffer overflow; set enable_full_printf: true in esp32 framework advanced config"); } if (fwrite(buf, 1, write_len, stream) < write_len || ferror(stream)) { return -1;