[esp32] Wrap sscanf to drop newlib's scanf engine from Bluetooth builds

This commit is contained in:
J. Nick Koston
2026-09-11 07:28:40 -05:00
parent 6a21ab4ea7
commit d1943fe835
8 changed files with 435 additions and 0 deletions
+36
View File
@@ -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)
+342
View File
@@ -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 <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#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<int>(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<unsigned char>(value);
break;
case SscanfLength::SSCANF_LENGTH_H:
*va_arg(ap, unsigned short *) = static_cast<unsigned short>(value);
break;
case SscanfLength::SSCANF_LENGTH_L:
*va_arg(ap, unsigned long *) = static_cast<unsigned long>(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<size_t>(value);
break;
case SscanfLength::SSCANF_LENGTH_T:
*va_arg(ap, ptrdiff_t *) = static_cast<ptrdiff_t>(value);
break;
default:
*va_arg(ap, unsigned int *) = static_cast<unsigned int>(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<uint8_t>(*f);
auto hi = lo;
if (f[1] == '-' && f[2] != ']' && f[2] != '\0') {
hi = static_cast<uint8_t>(f[2]);
f += 2;
}
for (unsigned c = lo; c <= hi; c++)
bitmap[c >> 3] |= static_cast<uint8_t>(1u << (c & 7));
}
return *f == ']' ? f + 1 : f;
}
bool in_scanset(const uint8_t *bitmap, bool negate, char c) {
auto u = static_cast<uint8_t>(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<size_t>(*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<unsigned long long>(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<unsigned>(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
+1
View File
@@ -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
+2
View File
@@ -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)
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
esp32_ble_tracker:
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
esp32_ble_tracker:
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
enable_full_scanf: true
esp32_ble_tracker:
+25
View File
@@ -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