[esp32] Trim mbedTLS to client-only defaults and stub vasprintf on the C6

Linking an HTTPS client (http_request, mqtt) pulls in TLS features no
ESPHome client ever negotiates: the server-side handshake, static RSA and
static ECDH key exchange, renegotiation, session tickets, AES-CCM suites,
the EC private-key parsing extras and deterministic ECDSA. Two new esp32
advanced options, on by default, turn them off and save around 14 KB per
build; OpenThread opts back in through require hooks because its DTLS
commissioner is a server and it uses CCM and deterministic ECDSA directly.

On the ESP32-C6 the ROM exports a full-format vsnprintf but no vasprintf,
so esp_http_client's vasprintf call was the only thing dragging newlib's
printf engine (~20 KB) into the image. A linker-wrapped vasprintf built on
the ROM vsnprintf keeps it out.
This commit is contained in:
Jesse Hills
2026-09-11 16:56:08 +12:00
parent 3e3822e554
commit bfb8569636
11 changed files with 288 additions and 0 deletions
+92
View File
@@ -193,6 +193,13 @@ PSRAM_XIP_VARIANTS = {
# 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
# should be added here.
# Variants whose ROM exports a full-format vsnprintf but no vasprintf
# (esp32c6.rom.newlib-normal.ld). There, the newlib printf engine is only
# linked because esp_http_client calls vasprintf; see vasprintf_stubs.cpp.
# The other variants either export both (classic ESP32, nano-format only) or
# neither, so the engine is already in the image and the wrap saves nothing.
ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6}
NVS_ENCRYPTION_HMAC_VARIANTS = {
VARIANT_ESP32S2,
VARIANT_ESP32S3,
@@ -1732,6 +1739,8 @@ CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary"
CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs"
CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert"
CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7"
CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server"
CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras"
CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram"
CONF_DISABLE_FATFS = "disable_fatfs"
CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram"
@@ -1746,6 +1755,8 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required"
KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required"
KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required"
KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required"
KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required"
KEY_FATFS_REQUIRED = "fatfs_required"
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
@@ -1830,6 +1841,26 @@ def require_mbedtls_pkcs7() -> None:
CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True
def require_mbedtls_tls_server() -> None:
"""Mark that the mbedTLS server-side TLS/DTLS handshake is required.
Call this from components that accept TLS connections (OpenThread's DTLS
commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from
being selected.
"""
CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True
def require_mbedtls_tls_extras() -> None:
"""Mark that the TLS features disabled by ``disable_mbedtls_tls_extras`` are required.
Call this from components that need AES-CCM, deterministic ECDSA signing,
static RSA/ECDH key exchange, TLS renegotiation or session tickets.
OpenThread uses CCM and deterministic ECDSA directly.
"""
CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] = True
def require_mbedtls_sha512() -> None:
"""Mark that mbedTLS SHA-384/SHA-512 support is required by a component.
@@ -1987,6 +2018,8 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean,
cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean,
cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean,
@@ -2302,6 +2335,47 @@ async def _reconcile_certificate_bundle_sdkconfig() -> None:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True)
# TLS features an HTTPS/MQTT client talking to a modern server never
# negotiates. Static RSA and static ECDH key exchange have no forward secrecy
# and are gone in TLS 1.3, renegotiation is deprecated, esp-tls never enables
# session tickets, AES-CCM ciphersuites are not offered by web servers, and
# the EC key parsing extras and deterministic ECDSA only matter when signing
# with a private key. Together they cost ~11 KB of flash whenever TLS is
# linked (http_request, mqtt).
MBEDTLS_TLS_EXTRA_OPTIONS = (
"CONFIG_MBEDTLS_KEY_EXCHANGE_RSA",
"CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA",
"CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_RSA",
"CONFIG_MBEDTLS_SSL_RENEGOTIATION",
"CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS",
"CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS",
"CONFIG_MBEDTLS_CCM_C",
"CONFIG_MBEDTLS_PK_PARSE_EC_EXTENDED",
"CONFIG_MBEDTLS_PK_PARSE_EC_COMPRESSED",
"CONFIG_MBEDTLS_ECDSA_DETERMINISTIC",
)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_mbedtls_tls_sdkconfig(
disable_tls_server: bool, disable_tls_extras: bool
) -> None:
"""Trim mbedTLS to what a TLS client needs unless a component asked otherwise.
Runs at FINAL priority so every require_mbedtls_tls_server() and
require_mbedtls_tls_extras() call has happened. Only the server-side
handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS
connections, but OpenThread's DTLS commissioner does.
"""
data = CORE.data[KEY_ESP32]
if disable_tls_server and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False):
add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False)
if disable_tls_extras and not data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, False):
for option in MBEDTLS_TLS_EXTRA_OPTIONS:
add_idf_sdkconfig_option(option, False)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_network_sdkconfig() -> None:
"""Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags.
@@ -2566,6 +2640,17 @@ async def to_code(config):
else:
for symbol in ("vprintf", "printf", "fprintf", "vfprintf"):
cg.add_build_flag(f"-Wl,--wrap={symbol}")
# esp_http_client calls vasprintf, which on the ESP32-C6 is the only
# reference to newlib's full printf engine (~20 KB: _svfprintf_r,
# _dtoa_r and their helpers); every other caller resolves to the
# ROM. See vasprintf_stubs.cpp. The --undefined flag is needed
# because libsrc.a is scanned before the IDF libraries that
# reference the symbol, so the stub would otherwise never be pulled
# from the archive.
if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS:
cg.add_define("USE_ESP32_VASPRINTF_STUB")
cg.add_build_flag("-Wl,--wrap=vasprintf")
cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf")
else:
cg.add_build_flag("-DUSE_ARDUINO")
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO")
@@ -2991,6 +3076,13 @@ async def to_code(config):
# FINAL priority: runs after every require_certificate_bundle() call
CORE.add_job(_reconcile_certificate_bundle_sdkconfig)
# FINAL priority: runs after every require_mbedtls_tls_*() call
CORE.add_job(
_reconcile_mbedtls_tls_sdkconfig,
advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER],
advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS],
)
# FINAL: require_*() calls can come from to_code at or below this priority, so an
# inline read would be iteration-order-dependent; reconcile once after every job ran.
CORE.add_job(
@@ -0,0 +1,52 @@
/*
* Linker wrap stub for vasprintf() on variants whose ROM exports a
* full-format vsnprintf() but no vasprintf() (ESP32-C6, newlib only).
*
* On those chips every snprintf/vsnprintf call in the image resolves to
* the ROM, so the newlib printf engine (_svfprintf_r, _dtoa_r and their
* helpers, ~20 KB) is not linked at all until something references a
* printf-family function the ROM lacks. esp_http_client does exactly that
* through vasprintf() in its header and auth helpers, so adding
* http_request to a build costs the whole engine on top of the HTTP and
* TLS code itself.
*
* This stub reimplements vasprintf() on top of the ROM vsnprintf(), which
* keeps the engine out of the image. It is only compiled in when codegen
* defines USE_ESP32_VASPRINTF_STUB, which is gated on the variant's ROM
* linker script and on the same newlib condition as printf_stubs.cpp.
*/
#include "esphome/core/defines.h"
#if defined(USE_ESP_IDF) && defined(USE_ESP32_VASPRINTF_STUB)
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
namespace esphome::esp32 {}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
int __wrap_vasprintf(char **strp, const char *fmt, va_list ap) {
va_list ap_copy;
va_copy(ap_copy, ap);
int len = vsnprintf(nullptr, 0, fmt, ap_copy);
va_end(ap_copy);
if (len < 0) {
return len;
}
char *buf = static_cast<char *>(malloc(static_cast<size_t>(len) + 1));
if (buf == nullptr) {
return -1;
}
vsnprintf(buf, static_cast<size_t>(len) + 1, fmt, ap);
*strp = buf;
return len;
}
} // extern "C"
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_ESP_IDF && USE_ESP32_VASPRINTF_STUB
@@ -13,6 +13,8 @@ from esphome.components.esp32 import (
get_esp32_variant,
include_builtin_idf_component,
only_on_variant,
require_mbedtls_tls_extras,
require_mbedtls_tls_server,
require_vfs_select,
)
from esphome.components.mdns import MDNSComponent, enable_mdns_storage
@@ -109,6 +111,12 @@ def set_sdkconfig_options(config: ConfigType) -> None:
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True)
# OpenThread's DTLS commissioner is a TLS server, and its crypto platform
# uses AES-CCM and deterministic ECDSA directly. Keep the esp32 component
# from trimming them out of mbedTLS.
require_mbedtls_tls_server()
require_mbedtls_tls_extras()
if not config.get(CONF_TLV):
if pan_id := config.get(CONF_PAN_ID):
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id)
+1
View File
@@ -298,6 +298,7 @@
// ESP32-specific feature flags
#ifdef USE_ESP32
#define USE_ESP32_CRASH_HANDLER
#define USE_ESP32_VASPRINTF_STUB
#define USE_ESP32_INTERNAL_GPIO
#define USE_MQTT_IDF_ENQUEUE
#define USE_ESPHOME_TASK_LOG_BUFFER
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
http_request:
verify_ssl: true
@@ -0,0 +1,19 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
network:
enable_ipv6: true
openthread:
channel: 13
network_name: OpenThread-8f28
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
pan_id: 0x8f28
ext_pan_id: 0xd63e8e3e495ebbc3
pskc: 0xc23a76e98f1a6483639b1ac1271e2e27
mesh_local_prefix: fd53:145f:ed22:ad81::/64
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
disable_mbedtls_tls_server: false
disable_mbedtls_tls_extras: false
wifi:
ssid: "test_ssid"
password: "test_password"
http_request:
verify_ssl: true
@@ -0,0 +1,7 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
advanced:
enable_full_printf: true
+67
View File
@@ -11,9 +11,12 @@ import pytest
from esphome.components.esp32 import (
KEY_FATFS_REQUIRED,
KEY_MBEDTLS_TLS_EXTRAS_REQUIRED,
KEY_MBEDTLS_TLS_SERVER_REQUIRED,
KEY_VFS_DIR_REQUIRED,
KEY_VFS_SELECT_REQUIRED,
KEY_VFS_TERMIOS_REQUIRED,
MBEDTLS_TLS_EXTRA_OPTIONS,
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
@@ -1339,3 +1342,67 @@ def test_esp32_s31_gpio_validation(
with caplog.at_level("WARNING"):
validate_supports(pin)
assert "GPIO36 is a strapping PIN" in caplog.text
_TLS_SERVER_OPTIONS = (
"CONFIG_MBEDTLS_TLS_CLIENT_ONLY",
"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT",
)
@pytest.mark.parametrize(
("config_file", "server", "extras"),
[
pytest.param("mbedtls_tls_default.yaml", (True, False), False, id="default"),
pytest.param("mbedtls_tls_opt_out.yaml", (None, None), None, id="opt_out"),
pytest.param(
"mbedtls_tls_openthread.yaml", (None, None), None, id="openthread"
),
],
)
def test_mbedtls_tls_trim_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
server: tuple[bool | None, bool | None],
extras: bool | None,
) -> None:
"""Client-only TLS and the unused-feature trims apply unless opted out or required."""
generate_main(component_config_path(config_file))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == server
assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras}
def test_mbedtls_tls_openthread_requires_server_and_extras(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required."""
generate_main(component_config_path("mbedtls_tls_openthread.yaml"))
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] is True
_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}
@pytest.mark.parametrize(
("config_file", "expected"),
[
pytest.param("vasprintf_stub_c6.yaml", True, id="c6"),
pytest.param("vasprintf_stub_c6_full_printf.yaml", False, id="c6_full_printf"),
pytest.param("exclusion_reincludes.yaml", False, id="esp32"),
],
)
def test_vasprintf_stub_only_on_rom_vsnprintf_variants(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
expected: bool,
) -> None:
"""The vasprintf wrap is emitted only where the ROM lacks vasprintf but has vsnprintf."""
generate_main(component_config_path(config_file))
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
@@ -17,6 +17,8 @@ esp32:
disable_dev_null_vfs: true
disable_mbedtls_peer_cert: true
disable_mbedtls_pkcs7: true
disable_mbedtls_tls_server: true
disable_mbedtls_tls_extras: true
disable_regi2c_in_iram: true
disable_fatfs: true
sram1_as_iram: true