fix(esp_common/esp_fault): make ESP_FAULT_ASSERT survive optimization

ESP_FAULT_ASSERT(C) was silently deleted by the optimizer when C is a cached
flag/status already proven by a preceding `if (!C) return/goto`: the compiler
folds C to a constant and drops all three checks, removing the fault-injection
protection with no warning.
This commit is contained in:
harshal.patil
2026-06-22 11:17:50 +05:30
parent 8110949d88
commit 4e4cb4123f
14 changed files with 197 additions and 13 deletions
@@ -5,3 +5,10 @@ components/esp_security/test_apps/crypto_drivers:
- if: ((SOC_HMAC_SUPPORTED == 1) or (SOC_DIG_SIGN_SUPPORTED == 1)) or (SOC_KEY_MANAGER_SUPPORTED == 1)
depends_components:
- esp_security
- esp_hal_security
components/esp_security/test_apps/fault_assert_opt_check:
enable:
- if: IDF_TARGET in ["esp32", "esp32c3"] # one Xtensa + one RISC-V
depends_components:
- esp_common
@@ -0,0 +1,19 @@
# The following lines of boilerplate have to be in your project's
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# "Trim" the build. Include the minimal set of components, main, and anything it depends on.
set(COMPONENTS main)
project(fault_assert_opt_check)
# Regression guard: fail the build if ESP_FAULT_ASSERT() gets optimized away.
idf_build_get_property(python PYTHON)
add_custom_command(
TARGET ${CMAKE_PROJECT_NAME}.elf POST_BUILD
COMMAND ${python} "${CMAKE_CURRENT_SOURCE_DIR}/check_fault_asserts.py"
"$<TARGET_FILE:${CMAKE_PROJECT_NAME}.elf>" "${CMAKE_OBJDUMP}"
COMMENT "Verifying ESP_FAULT_ASSERT() survived optimization"
VERBATIM)
@@ -0,0 +1,2 @@
| Supported Targets | ESP32 | ESP32-C3 |
| ----------------- | ----- | -------- |
@@ -0,0 +1,75 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""Post-build regression guard for ESP_FAULT_ASSERT().
Disassembles the test app and verifies that the ESP_FAULT_ASSERT() calls in the
known test functions still emit their reset blocks. One intact assert produces
three independent reset-on-failure paths, i.e. three references to
``esp_rom_software_reset_system`` in the function. If the macro ever regresses
and the optimizer folds the checks away, the count drops and this exits non-zero,
failing the build.
Usage: check_fault_asserts.py <app.elf> <objdump>
"""
import re
import subprocess
import sys
RESET_SYM = 'esp_rom_software_reset_system'
# function name -> number of ESP_FAULT_ASSERT calls it contains (x3 reset blocks each)
EXPECTED = {
'test_fa_guarded_flag': 1,
'test_fa_guarded_status': 1,
}
FUNC_RE = re.compile(r'^[0-9a-fA-F]+ <(.+)>:$')
def reset_counts(elf: str, objdump: str) -> dict:
dis = subprocess.run([objdump, '-d', elf], capture_output=True, text=True, check=True).stdout
counts: dict = {}
cur = None
for line in dis.splitlines():
m = FUNC_RE.match(line)
if m:
cur = m.group(1)
counts.setdefault(cur, 0)
elif cur and RESET_SYM in line:
counts[cur] += 1
return counts
def main() -> int:
if len(sys.argv) != 3:
print(__doc__)
return 2
elf, objdump = sys.argv[1], sys.argv[2]
counts = reset_counts(elf, objdump)
failed = False
for fn, n_asserts in EXPECTED.items():
expected = 3 * n_asserts
found = counts.get(fn)
if found is None:
print(f'ERROR: {fn} not found in {elf} (renamed/removed?)')
failed = True
elif found < expected:
print(
f'ERROR: ESP_FAULT_ASSERT optimized away in {fn}: '
f'{found} reset checks, expected {expected}. '
f'See components/esp_common/include/esp_fault.h'
)
failed = True
else:
print(f'OK: {fn} -> {found} reset checks ({found // 3} assert(s) x3)')
if failed:
print('FAILED: ESP_FAULT_ASSERT regression check')
return 1
print('PASSED: ESP_FAULT_ASSERT checks survived optimization')
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,2 @@
idf_component_register(SRCS "test_fault_assert.c"
INCLUDE_DIRS ".")
@@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/*
* Regression guard for ESP_FAULT_ASSERT() being silently optimised away.
*
* ESP_FAULT_ASSERT(C) must emit three independent "evaluate C -> reset if false"
* checks. When C is a value the optimiser can already prove (e.g. a flag pinned
* by a preceding "if (!C) return"), a naive implementation lets GCC constant-fold
* C and delete all three checks, removing the fault-injection protection with no
* warning.
*
* The functions below place ESP_FAULT_ASSERT in exactly that
* "proven-true cached value" shape. check_fault_asserts.py disassembles the built
* app and fails the build if any of them lost its reset blocks. Keep them
* noinline+used so each is an independent symbol the checker can find.
*/
#include <stdbool.h>
#include "esp_fault.h"
#include "esp_attr.h"
/* volatile so the initial value is opaque: only the early-return "proves" it,
* which is the precise condition that triggers the optimiser elimination. */
volatile bool fa_test_flag = true;
volatile int fa_test_status = 0;
volatile int fa_test_sink;
/* Cached bool guarded by an early return -> the original elimination case. */
bool NOINLINE_ATTR test_fa_guarded_flag(void)
{
bool valid = fa_test_flag;
if (!valid) {
return false;
}
ESP_FAULT_ASSERT(valid);
return true;
}
/* Cached status compared to a constant, guarded by an early return. */
int NOINLINE_ATTR test_fa_guarded_status(void)
{
int status = fa_test_status;
if (status != 0) {
return status;
}
ESP_FAULT_ASSERT(status == 0);
return 0;
}
void app_main(void)
{
/* Reference the test functions so they are linked (not GC'd). */
fa_test_sink = (int)test_fa_guarded_flag() + test_fa_guarded_status();
}
@@ -0,0 +1,2 @@
# -O2
CONFIG_COMPILER_OPTIMIZATION_PERF=y
@@ -0,0 +1,2 @@
# -Os
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
@@ -0,0 +1,4 @@
# The clang-built esp32 (Xtensa) bootloader is slightly larger than the GCC one and
# overflows the default 0x7000 limit; move the partition table offset to give it room.
# Harmless for GCC builds.
CONFIG_PARTITION_TABLE_OFFSET=0x9000