From d1943fe835bad96a35fd700dc3f9231b255b2f06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 07:28:40 -0500 Subject: [PATCH] [esp32] Wrap sscanf to drop newlib's scanf engine from Bluetooth builds --- esphome/components/esp32/__init__.py | 36 ++ esphome/components/esp32/sscanf_stubs.cpp | 342 ++++++++++++++++++ esphome/core/defines.h | 1 + script/ci-custom.py | 2 + .../esp32/config/sscanf_stub_ble.yaml | 9 + .../esp32/config/sscanf_stub_ble_c6.yaml | 9 + .../config/sscanf_stub_ble_full_scanf.yaml | 11 + tests/component_tests/esp32/test_esp32.py | 25 ++ 8 files changed, 435 insertions(+) create mode 100644 esphome/components/esp32/sscanf_stubs.cpp create mode 100644 tests/component_tests/esp32/config/sscanf_stub_ble.yaml create mode 100644 tests/component_tests/esp32/config/sscanf_stub_ble_c6.yaml create mode 100644 tests/component_tests/esp32/config/sscanf_stub_ble_full_scanf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d027c9a1c61..7f345cfcaa3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -196,6 +196,13 @@ PSRAM_XIP_VARIANTS = { # neither, so the engine is already in the image and the wrap saves nothing. ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6} +# Variants whose ROM exports sscanf under the newlib format ESPHome links +# against: the ESP32-C6 normal-format ROM (esp32c6.rom.newlib-normal.ld) and +# the ESP32-C2 nano-format ROM, nano being the IDF default on the C2. There +# the sscanf wrap saves nothing; see sscanf_stubs.cpp. The classic ESP32 ROM +# only exports it under nano format, which ESPHome does not use. +ROM_SSCANF_VARIANTS = {VARIANT_ESP32C2, VARIANT_ESP32C6} + # NVS encryption (HMAC peripheral scheme) is only available on variants that # expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original # ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral @@ -1733,6 +1740,7 @@ CONF_RINGBUF_IN_IRAM = "ringbuf_in_iram" 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_ENABLE_FULL_SCANF = "enable_full_scanf" CONF_DISABLE_DEBUG_STUBS = "disable_debug_stubs" CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" @@ -2014,6 +2022,7 @@ FRAMEWORK_SCHEMA = cv.Schema( 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_ENABLE_FULL_SCANF, 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( @@ -2314,6 +2323,30 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_sscanf_stub(enable_full_scanf: bool) -> None: + """Wrap sscanf when Bluetooth is the only reason newlib's scanf engine links. + + Runs at FINAL priority so every request_bluetooth() call has happened. + bluedroid is the only sscanf caller in an ESPHome image (~13 KB of engine + for two "%02x" parses); see sscanf_stubs.cpp. Newlib only, like the printf + wrap: IDF 6.0+ switches to picolibc, where the saving is unmeasured. The + --undefined flag is needed because libsrc.a is scanned before libbt.a, so + the stub would otherwise never be pulled from the archive. + """ + if ( + enable_full_scanf + or CORE.using_arduino + or idf_version() >= cv.Version(6, 0, 0) + or not _network_sdkconfig().bluetooth + or get_esp32_variant() in ROM_SSCANF_VARIANTS + ): + return + cg.add_define("USE_ESP32_SSCANF_STUB") + cg.add_build_flag("-Wl,--wrap=sscanf") + cg.add_build_flag("-Wl,--undefined=__wrap_sscanf") + + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_certificate_bundle_sdkconfig() -> None: """Enable the mbedTLS certificate bundle only when something asked for it. @@ -3099,6 +3132,9 @@ async def to_code(config): # FINAL priority: runs after every network/coexistence request_*() call CORE.add_job(_reconcile_network_sdkconfig) + # FINAL priority: runs after every request_bluetooth() call + CORE.add_job(_add_sscanf_stub, advanced[CONF_ENABLE_FULL_SCANF]) + # FINAL priority: runs after every require_certificate_bundle() call CORE.add_job(_reconcile_certificate_bundle_sdkconfig) diff --git a/esphome/components/esp32/sscanf_stubs.cpp b/esphome/components/esp32/sscanf_stubs.cpp new file mode 100644 index 00000000000..1cd5c611841 --- /dev/null +++ b/esphome/components/esp32/sscanf_stubs.cpp @@ -0,0 +1,342 @@ +/* + * Linker wrap stub for sscanf() (ESP-IDF, newlib only). + * + * Nothing in ESPHome calls sscanf(), but bluedroid does in two places: + * btc_config_get_bin() decodes stored bonding keys with "%02x" and + * string_to_bdaddr() parses "%02x:%02x:%02x:%02x:%02x:%02x". Those two + * calls are the only reference to newlib's scanf engine (__ssvfscanf_r, + * _strtod_l and their helpers, ~13 KB) in a Bluetooth build on variants + * whose ROM does not export sscanf. + * + * This stub replaces sscanf() with a small scanner covering the integer, + * character, string and scanset conversions, so any other sscanf() caller + * in the image (user lambdas included) keeps working. Floating-point + * conversions are the one gap: supporting them would pull _strtod_l + * straight back, so a format using %f and friends aborts with a message + * pointing at enable_full_scanf: true. + * + * Only compiled in when codegen defines USE_ESP32_SSCANF_STUB, which is + * gated on a Bluetooth component being present, on the variant's ROM and + * on the same newlib condition as printf_stubs.cpp. + */ + +#include "esphome/core/defines.h" + +#if defined(USE_ESP_IDF) && defined(USE_ESP32_SSCANF_STUB) + +#include +#include +#include +#include + +#include "esp_system.h" + +namespace esphome::esp32 { + +namespace { + +enum class SscanfLength : uint8_t { + SSCANF_LENGTH_NONE, + SSCANF_LENGTH_HH, + SSCANF_LENGTH_H, + SSCANF_LENGTH_L, + SSCANF_LENGTH_LL, + SSCANF_LENGTH_Z, + SSCANF_LENGTH_J, + SSCANF_LENGTH_T, +}; + +bool is_space(char c) { return c == ' ' || (c >= '\t' && c <= '\r'); } + +int digit_value(char c, unsigned base) { + unsigned value; + if (c >= '0' && c <= '9') { + value = c - '0'; + } else if (c >= 'a' && c <= 'z') { + value = c - 'a' + 10; + } else if (c >= 'A' && c <= 'Z') { + value = c - 'A' + 10; + } else { + return -1; + } + return value < base ? static_cast(value) : -1; +} + +// Stores through the unsigned counterpart of each length; the bit pattern is +// what the signed conversions want as well. +void store_int(va_list &ap, SscanfLength length, unsigned long long value) { + switch (length) { + case SscanfLength::SSCANF_LENGTH_HH: + *va_arg(ap, unsigned char *) = static_cast(value); + break; + case SscanfLength::SSCANF_LENGTH_H: + *va_arg(ap, unsigned short *) = static_cast(value); + break; + case SscanfLength::SSCANF_LENGTH_L: + *va_arg(ap, unsigned long *) = static_cast(value); + break; + case SscanfLength::SSCANF_LENGTH_LL: + case SscanfLength::SSCANF_LENGTH_J: + *va_arg(ap, unsigned long long *) = value; + break; + case SscanfLength::SSCANF_LENGTH_Z: + *va_arg(ap, size_t *) = static_cast(value); + break; + case SscanfLength::SSCANF_LENGTH_T: + *va_arg(ap, ptrdiff_t *) = static_cast(value); + break; + default: + *va_arg(ap, unsigned int *) = static_cast(value); + break; + } +} + +// Parses a "[set]" directive starting just after the '[' into a 256-bit +// membership bitmap and returns the position after the closing ']'. +const char *parse_scanset(const char *f, uint8_t *bitmap, bool &negate) { + for (size_t i = 0; i < 32; i++) + bitmap[i] = 0; + negate = false; + if (*f == '^') { + negate = true; + f++; + } + // A ']' right after '[' or '[^' is a member, not the terminator. + bool first = true; + for (; *f != '\0' && (first || *f != ']'); f++) { + first = false; + auto lo = static_cast(*f); + auto hi = lo; + if (f[1] == '-' && f[2] != ']' && f[2] != '\0') { + hi = static_cast(f[2]); + f += 2; + } + for (unsigned c = lo; c <= hi; c++) + bitmap[c >> 3] |= static_cast(1u << (c & 7)); + } + return *f == ']' ? f + 1 : f; +} + +bool in_scanset(const uint8_t *bitmap, bool negate, char c) { + auto u = static_cast(c); + return ((bitmap[u >> 3] >> (u & 7)) & 1u) != negate; +} + +[[noreturn]] void unsupported_conversion() { + esp_system_abort("sscanf: unsupported conversion; set enable_full_scanf: true in esp32 framework advanced config"); +} + +int vsscanf_stub(const char *str, const char *fmt, va_list ap) { + const char *in = str; + int assigned = 0; + bool input_failure = false; + for (const char *f = fmt; *f != '\0'; f++) { + if (is_space(*f)) { + while (is_space(*in)) + in++; + continue; + } + if (*f != '%') { + if (*in != *f) { + input_failure = *in == '\0'; + break; + } + in++; + continue; + } + f++; + if (*f == '%') { + while (is_space(*in)) + in++; + if (*in != '%') { + input_failure = *in == '\0'; + break; + } + in++; + continue; + } + bool suppress = false; + if (*f == '*') { + suppress = true; + f++; + } + size_t width = 0; + while (*f >= '0' && *f <= '9') { + width = width * 10 + static_cast(*f - '0'); + f++; + } + if (width == 0) + width = SIZE_MAX; + auto length = SscanfLength::SSCANF_LENGTH_NONE; + switch (*f) { + case 'h': + f++; + if (*f == 'h') { + length = SscanfLength::SSCANF_LENGTH_HH; + f++; + } else { + length = SscanfLength::SSCANF_LENGTH_H; + } + break; + case 'l': + f++; + if (*f == 'l') { + length = SscanfLength::SSCANF_LENGTH_LL; + f++; + } else { + length = SscanfLength::SSCANF_LENGTH_L; + } + break; + case 'j': + length = SscanfLength::SSCANF_LENGTH_J; + f++; + break; + case 'z': + length = SscanfLength::SSCANF_LENGTH_Z; + f++; + break; + case 't': + length = SscanfLength::SSCANF_LENGTH_T; + f++; + break; + case 'L': + f++; + break; + default: + break; + } + const char conv = *f; + if (conv == '\0') + break; + if (conv == 'n') { + if (!suppress) + store_int(ap, length, static_cast(in - str)); + continue; + } + const bool wide = length == SscanfLength::SSCANF_LENGTH_L || length == SscanfLength::SSCANF_LENGTH_LL; + if (conv != 'c' && conv != '[') { + while (is_space(*in)) + in++; + } + if (*in == '\0') { + input_failure = true; + break; + } + unsigned base; + switch (conv) { + case 'd': + case 'u': + base = 10; + break; + case 'i': + base = 0; + break; + case 'o': + base = 8; + break; + case 'x': + case 'X': + base = 16; + break; + case 'c': + case 's': + case '[': + if (wide) + unsupported_conversion(); + base = 1; + break; + default: + unsupported_conversion(); + } + if (base != 1) { + const char *p = in; + size_t n = 0; + bool negative = false; + if ((*p == '+' || *p == '-') && n < width) { + negative = *p == '-'; + p++; + n++; + } + if ((base == 0 || base == 16) && p[0] == '0' && (p[1] == 'x' || p[1] == 'X') && n + 2 < width && + digit_value(p[2], 16) >= 0) { + p += 2; + n += 2; + base = 16; + } + if (base == 0) + base = *p == '0' ? 8 : 10; + unsigned long long value = 0; + bool any_digit = false; + while (n < width) { + int digit = digit_value(*p, base); + if (digit < 0) + break; + value = value * base + static_cast(digit); + p++; + n++; + any_digit = true; + } + if (!any_digit) + break; + in = p; + if (!suppress) { + store_int(ap, length, negative ? 0 - value : value); + assigned++; + } + continue; + } + char *dest = suppress ? nullptr : va_arg(ap, char *); + if (conv == 'c') { + // Like newlib, a short read still assigns whatever was available. + const size_t count = width == SIZE_MAX ? 1 : width; + size_t n = 0; + while (n < count && in[n] != '\0') { + if (dest != nullptr) + dest[n] = in[n]; + n++; + } + in += n; + } else { + uint8_t bitmap[32]; + bool negate = false; + if (conv == '[') + f = parse_scanset(f + 1, bitmap, negate) - 1; + size_t n = 0; + while (n < width && in[n] != '\0' && (conv == '[' ? in_scanset(bitmap, negate, in[n]) : !is_space(in[n]))) { + if (dest != nullptr) + dest[n] = in[n]; + n++; + } + if (n == 0) + break; + if (dest != nullptr) + dest[n] = '\0'; + in += n; + } + if (!suppress) + assigned++; + } + if (input_failure && assigned == 0) + return EOF; + return assigned; +} + +} // namespace + +} // namespace esphome::esp32 + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_sscanf(const char *str, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + int result = esphome::esp32::vsscanf_stub(str, fmt, ap); + va_end(ap); + return result; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP_IDF && USE_ESP32_SSCANF_STUB diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9144e65576f..68861109008 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -299,6 +299,7 @@ #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER #define USE_ESP32_VASPRINTF_STUB +#define USE_ESP32_SSCANF_STUB #define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER diff --git a/script/ci-custom.py b/script/ci-custom.py index e2b7cd8d376..0d8f6662ff4 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1055,6 +1055,8 @@ def lint_no_std_to_string(fname, match): # [^\w] ensures we match function calls, not substrings r"[^\w]((?:std::)?v?[fs]?scanf)\s*\(" + CPP_RE_EOL, include=cpp_include, + # The linker wrap that keeps the scanf engine out of Bluetooth builds + exclude=["esphome/components/esp32/sscanf_stubs.cpp"], ) def lint_no_scanf(fname, match): func = match.group(1) diff --git a/tests/component_tests/esp32/config/sscanf_stub_ble.yaml b/tests/component_tests/esp32/config/sscanf_stub_ble.yaml new file mode 100644 index 00000000000..036a6b72f19 --- /dev/null +++ b/tests/component_tests/esp32/config/sscanf_stub_ble.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +esp32_ble_tracker: diff --git a/tests/component_tests/esp32/config/sscanf_stub_ble_c6.yaml b/tests/component_tests/esp32/config/sscanf_stub_ble_c6.yaml new file mode 100644 index 00000000000..c186c342273 --- /dev/null +++ b/tests/component_tests/esp32/config/sscanf_stub_ble_c6.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +esp32_ble_tracker: diff --git a/tests/component_tests/esp32/config/sscanf_stub_ble_full_scanf.yaml b/tests/component_tests/esp32/config/sscanf_stub_ble_full_scanf.yaml new file mode 100644 index 00000000000..e4e53d0a4dc --- /dev/null +++ b/tests/component_tests/esp32/config/sscanf_stub_ble_full_scanf.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + enable_full_scanf: true + +esp32_ble_tracker: diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 2dd2a50c83c..fa40ca5d2ef 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1438,3 +1438,28 @@ def test_vasprintf_stub_only_on_rom_vsnprintf_variants( assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected defines = {define.name for define in CORE.defines} assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected + + +_SSCANF_STUB_FLAGS = {"-Wl,--wrap=sscanf", "-Wl,--undefined=__wrap_sscanf"} + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + pytest.param("sscanf_stub_ble.yaml", True, id="esp32_ble"), + pytest.param("sscanf_stub_ble_c6.yaml", False, id="c6_ble_rom_sscanf"), + pytest.param("sscanf_stub_ble_full_scanf.yaml", False, id="full_scanf"), + pytest.param("exclusion_reincludes.yaml", False, id="esp32_no_ble"), + ], +) +def test_sscanf_stub_only_for_bluetooth_without_rom_sscanf( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """The sscanf wrap is emitted only for Bluetooth builds on variants whose ROM lacks sscanf.""" + generate_main(component_config_path(config_file)) + assert (CORE.build_flags >= _SSCANF_STUB_FLAGS) is expected + defines = {define.name for define in CORE.defines} + assert ("USE_ESP32_SSCANF_STUB" in defines) is expected