diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a20de7..88e9bb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,3 +139,36 @@ jobs: name: nucleo-f401re-demo-elf path: targets/STMicroelectronics/NUCLEO_F401RE/build/app/nucleo_f401re.elf retention-days: 1 + + test-nucleo-renode: + name: Headless Renode Emulation & Assertion Test (NUCLEO-F401RE) + needs: build-arm-nucleo + runs-on: ubuntu-24.04 + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set Up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Download Built NUCLEO-F401RE ELF + uses: actions/download-artifact@v4 + with: + name: nucleo-f401re-demo-elf + path: targets/STMicroelectronics/NUCLEO_F401RE/build/app + + - name: Install Portable Renode Emulation Environment + run: | + wget -q https://builds.renode.io/renode-latest.linux-portable.tar.gz + mkdir -p $HOME/renode + tar -xzf renode-latest.linux-portable.tar.gz -C $HOME/renode --strip-components=1 + rm renode-latest.linux-portable.tar.gz + echo "$HOME/renode" >> $GITHUB_PATH + + - name: Run Deterministic Headless Renode Test + run: | + python3 targets/STMicroelectronics/NUCLEO_F401RE/scripts/test_renode.py diff --git a/targets/STMicroelectronics/NUCLEO_F401RE/README.md b/targets/STMicroelectronics/NUCLEO_F401RE/README.md index 828a66b..0c932dc 100644 --- a/targets/STMicroelectronics/NUCLEO_F401RE/README.md +++ b/targets/STMicroelectronics/NUCLEO_F401RE/README.md @@ -50,6 +50,59 @@ The package includes build scripts under the `scripts/` directory for convenienc These scripts clean the build directory, run CMake configuration, and compile the target executable. +## Emulation & Regression Testing + +Renode ships no NUCLEO-F401RE board description, so `renode/nucleo_f401re.repl` +derives one from the generic STM32F4 CPU platform, correcting Flash to 512 KB +and SRAM to 96 KB. Those limits matter: the linker script's heap reservation and +the `_sbrk()` bound both depend on them. + +### Interactive Simulation + +```bash +renode targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_demo.resc +``` + +USART2 is the demo console, shown in a terminal analyzer. + +### Automated Headless Test + +```bash +python3 targets/STMicroelectronics/NUCLEO_F401RE/scripts/test_renode.py +``` + +Runs `nucleo_f401re_ci.resc`, which advances a fixed span of virtual time and +quits on its own, so the result does not depend on host speed. Exits non-zero +if any assertion is unmet. This gates CI as the `test-nucleo-renode` job. + +The Robot Framework suite in `renode/nucleo_f401re_demo.robot` covers the same +ground for use with `renode-test`. + +### What is asserted + +The demo runs seven startup self-tests before `tx_kernel_enter()` and prints a +`[SELF-TEST]` summary the harness asserts on: + +| # | Self-test | Guards against | +|---|-----------|----------------| +| 1 | `_sbrk()` allocation lands inside the heap reservation | heap escaping its linker reservation | +| 2 | `_sbrk()` releases back to the heap base | broken negative-increment path | +| 3 | `_sbrk()` underflow rejected with `EINVAL` | shrinking below the heap base | +| 4 | `_sbrk()` rejects a request that fits SRAM but not the heap | bounding the heap at the end of SRAM | +| 5 | Heap reservation ends at or below the ThreadX byte pool | linker script layout regression | +| 6 | `SystemCoreClock` is 84 MHz | a silently wrong PLL configuration | +| 7 | HAL timebase (TIM2) tick advancing | `HAL_InitTick()` re-entry leaving TIM2 stopped | + +Test 4 is the regression guard for the heap bound. Requesting 32 KB fits inside +the 96 KB SRAM but far exceeds the heap reservation, so bounding `_sbrk()` +against the end of SRAM rather than `_heap_limit` let it succeed and handed +`malloc()` memory owned by the ThreadX byte pool and the main stack. + +Beyond the self-tests, the harness asserts the boot banner reaches the console, +the blink thread and the 1 Hz application timer have both run (covering the LED +path and the timer service), and that the mutex, queue, event-flag and semaphore +counters are all non-zero. + ## Flashing the Application Connect the NUCLEO-F401RE board via the ST-LINK USB connector. @@ -136,14 +189,15 @@ The BSP overrides `HAL_InitTick()` to configure TIM2 as the HAL timebase and pro - **`lib/stm32cubef4/`**: HAL Driver wrapper and platform drivers - **`lib/threadx/`**: ThreadX user configuration file (`tx_user.h`) - **`cmake/`**: Cross-compilation module definitions -- **`scripts/`**: Utility build automation scripts +- **`scripts/`**: Utility build automation scripts and the headless Renode test runner +- **`renode/`**: Renode platform description, run scripts, and Robot Framework suite ## Validation Record ### Verification Environment - **Toolchain**: Arm GNU Toolchain 14.2.Rel1 (GCC 14.2.1), the version pinned by CI -- **Static ROM usage**: 20120 Bytes (3.84% of 512 KB Flash) +- **Static ROM usage**: 22068 Bytes (4.21% of 512 KB Flash), including the startup self-tests - **Static RAM usage**: 6000 Bytes (6.10% of 96 KB RAM) - **Dynamic Stack & Buffer allocation**: Stacks (8 x 1024 bytes) and Queue buffer (40 bytes) are dynamically allocated from the `TX_BYTE_POOL` (consuming 8312 bytes total, including pool headers). - **Board Hardware**: NUCLEO-F401RE diff --git a/targets/STMicroelectronics/NUCLEO_F401RE/app/starter/main.c b/targets/STMicroelectronics/NUCLEO_F401RE/app/starter/main.c index 53b79b8..5493933 100644 --- a/targets/STMicroelectronics/NUCLEO_F401RE/app/starter/main.c +++ b/targets/STMicroelectronics/NUCLEO_F401RE/app/starter/main.c @@ -8,16 +8,25 @@ * SPDX-License-Identifier: MIT */ +#include +#include +#include #include #include "tx_api.h" #include "bsp/board.h" #include "bsp/led.h" #include "bsp/console.h" +#include "board_config.h" #include "cloud_config.h" +#include "stm32f4xx_hal.h" #define THREAD_STACK_SIZE 1024 +/* Bytes of SRAM held back above the ThreadX byte pool for the main stack, + * which serves every interrupt handler once the scheduler is running. */ +#define MAIN_STACK_MARGIN 4096 + typedef struct { CHAR *name; UINT state; @@ -348,9 +357,9 @@ void tx_application_define(void *first_unused_memory) { CHAR *stack_ptr; ULONG pool_size; - /* Calculate available RAM for the byte pool, leaving 4KB margin for system - * stack at the top (0x20018000) */ - pool_size = (0x20018000 - 4096) - (ULONG)first_unused_memory; + /* Calculate available RAM for the byte pool, leaving a 4 KB margin for the + * main stack at the top of SRAM. */ + pool_size = (BSP_RAM_END - MAIN_STACK_MARGIN) - (ULONG)first_unused_memory; /* Initialize the byte pool */ status = tx_byte_pool_create(&byte_pool, "system byte pool", @@ -540,9 +549,102 @@ void tx_application_define(void *first_unused_memory) { thread_registry[8] = &semaphore_thread; } +/* ------------------------------------------------------------------------- * + * Startup self-tests + * + * These run before tx_kernel_enter() so a failure is reported even when the + * scheduler never starts. scripts/test_renode.py asserts on the summary line. + * ------------------------------------------------------------------------- */ + +/* Defined by NUCLEO_F401RE.ld rather than by any translation unit. */ +extern char _end; +extern char _heap_limit; +extern char __RAM_segment_used_end__; + +extern void *_sbrk(ptrdiff_t incr); + +static unsigned selftest_failures = 0; + +static void selftest_report(int passed, const char *message) { + if (passed) { + printf("[+] PASS: %s\r\n", message); + } else { + selftest_failures++; + printf("[-] FAIL: %s\r\n", message); + } +} + +static void run_startup_self_tests(void) { + const uintptr_t heap_base = (uintptr_t)&_end; + const uintptr_t heap_limit = (uintptr_t)&_heap_limit; + const uintptr_t pool_base = (uintptr_t)&__RAM_segment_used_end__; + char msg[128]; + + printf("[SELF-TEST] Starting BSP & Runtime Verification...\r\n"); + + /* 1. A modest request lands inside the heap reservation. */ + void *p_ok = _sbrk(64); + selftest_report((p_ok != (void *)-1) && ((uintptr_t)p_ok >= heap_base) && + ((uintptr_t)p_ok < heap_limit), + "_sbrk() valid allocation inside the heap reservation"); + + /* 2. Releasing it returns the break to the heap base, leaving the heap + * exactly as the remaining tests found it. */ + selftest_report(_sbrk(-64) != (void *)-1, + "_sbrk() released 64 bytes back to the heap base"); + + /* 3. Shrinking below the heap base is rejected. */ + errno = 0; + void *p_under = _sbrk(-128); + selftest_report((p_under == (void *)-1) && (errno == EINVAL), + "_sbrk() underflow guard rejected with EINVAL"); + + /* 4. Regression guard for the heap bound. 32 KB fits inside the 96 KB SRAM + * but far exceeds the heap reservation, so bounding _sbrk() against the end + * of SRAM rather than _heap_limit let this succeed and handed malloc() + * memory owned by the ThreadX byte pool and the main stack. */ + errno = 0; + void *p_over = _sbrk((ptrdiff_t)0x8000); + selftest_report((p_over == (void *)-1) && (errno == ENOMEM), + "_sbrk() rejects a request that fits SRAM but not the heap"); + + /* 5. The heap reservation must end at or below the first byte ThreadX owns. */ + snprintf(msg, sizeof(msg), + "heap [0x%08lX,0x%08lX) ends at or below the ThreadX pool at 0x%08lX", + (unsigned long)heap_base, (unsigned long)heap_limit, + (unsigned long)pool_base); + selftest_report(heap_limit <= pool_base, msg); + + /* 6. SystemClock_Config() reached the documented 84 MHz. */ + snprintf(msg, sizeof(msg), "SystemCoreClock is %lu Hz (expected %lu Hz)", + (unsigned long)SystemCoreClock, (unsigned long)BSP_CPU_CLOCK_HZ); + selftest_report(SystemCoreClock == (uint32_t)BSP_CPU_CLOCK_HZ, msg); + + /* 7. The HAL timebase runs on TIM2 so ThreadX keeps SysTick. HAL_InitTick() + * is re-entered by HAL_RCC_ClockConfig() once the PLL is live, so confirm the + * timer is still ticking afterwards. The spin cap is a liveness bound, not a + * timing expectation: one TIM2 tick is ~84000 core cycles. */ + uint32_t tick_start = HAL_GetTick(); + uint32_t spins = 0; + while ((HAL_GetTick() == tick_start) && (spins < 5000000UL)) { + spins++; + } + selftest_report(HAL_GetTick() != tick_start, + "HAL timebase (TIM2) tick advancing"); + + if (selftest_failures == 0) { + printf("[SELF-TEST] All startup verification tests PASSED!\r\n\r\n"); + } else { + printf("[SELF-TEST] %u startup verification test(s) FAILED!\r\n\r\n", + selftest_failures); + } +} + int main(void) { bsp_board_init(); + run_startup_self_tests(); + /* Start the ThreadX kernel */ tx_kernel_enter(); diff --git a/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re.repl b/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re.repl new file mode 100644 index 0000000..b6c15ae --- /dev/null +++ b/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re.repl @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Eclipse ThreadX contributors +// +// This program and the accompanying materials are made available +// under the terms of the MIT license which is available at +// https://opensource.org/license/mit. +// +// SPDX-License-Identifier: MIT +// +// ST NUCLEO-F401RE (STM32F401RET6, Cortex-M4F). +// +// Renode ships no NUCLEO-F401RE board description, so this derives one from the +// generic STM32F4 CPU platform. That platform is sized for the larger F407/F429 +// parts; the F401RE has 512 KB Flash and 96 KB SRAM, and the demo's linker +// script and _sbrk() bound both depend on those limits being accurate. + +using "platforms/cpus/stm32f4.repl" + +sram: + size: 0x18000 + +flash: + size: 0x80000 diff --git a/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_ci.resc b/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_ci.resc new file mode 100644 index 0000000..4294976 --- /dev/null +++ b/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_ci.resc @@ -0,0 +1,35 @@ +:name: NUCLEO-F401RE - ThreadX Demo (headless CI run) +:description: Deterministic virtual-time run driven by scripts/test_renode.py. + +# Standalone counterpart to nucleo_f401re_demo.resc rather than an include of +# it. Renode resolves $ORIGIN only in variable assignment, so an included script +# cannot be located relative to the file including it, and the two run +# differently anyway: the demo script free-runs for interactive use, while this +# one advances a fixed span of virtual time so the run is reproducible and +# terminates on its own instead of depending on wall clock. + +Clear + +using sysbus +mach create "NUCLEO_F401RE" + +$platform?=$ORIGIN/nucleo_f401re.repl +machine LoadPlatformDescription $platform + +# Under --plain --disable-gui this logs USART2 traffic to stdout, which is what +# the assertions in test_renode.py read. +showAnalyzer usart2 + +$bin?=$ORIGIN/../build/app/nucleo_f401re.elf + +macro reset +""" + sysbus LoadELF $bin +""" +runMacro $reset + +# Long enough for the startup self-tests, the 1 Hz application timer, and at +# least two reporter-thread status blocks with non-zero RTOS counters. +emulation RunFor "4" + +quit diff --git a/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_demo.resc b/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_demo.resc new file mode 100644 index 0000000..53497f0 --- /dev/null +++ b/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_demo.resc @@ -0,0 +1,25 @@ +:name: NUCLEO-F401RE - ThreadX Device Monitor Demo +:description: Eclipse ThreadX RTOS on ST NUCLEO-F401RE (Renode, ARM Cortex-M4) + +# Clear previous emulation state +Clear + +using sysbus +mach create "NUCLEO_F401RE" + +$platform?=$ORIGIN/nucleo_f401re.repl +machine LoadPlatformDescription $platform + +# USART2 is the ST-LINK virtual COM port on this board and the demo console. +showAnalyzer usart2 + +# Portable relative path using Renode's built-in $ORIGIN variable +$bin?=$ORIGIN/../build/app/nucleo_f401re.elf + +macro reset +""" + sysbus LoadELF $bin +""" +runMacro $reset + +start diff --git a/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_demo.robot b/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_demo.robot new file mode 100644 index 0000000..20f94ee --- /dev/null +++ b/targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_demo.robot @@ -0,0 +1,26 @@ +*** Settings *** +Suite Setup Setup +Suite Teardown Teardown +Test Setup Reset Emulation +Test Teardown Test Teardown +Resource ${RENODEKEYWORDS} + +*** Test Cases *** +Should Pass Startup Self-Tests + Execute Command include @${CURDIR}/nucleo_f401re_demo.resc + Create Terminal Tester sysbus.usart2 + + Wait For Line On Uart NUCLEO-F401RE Device Monitor Demo timeout=15 + Wait For Line On Uart [SELF-TEST] Starting BSP & Runtime Verification... timeout=15 + Wait For Line On Uart [SELF-TEST] All startup verification tests PASSED! timeout=15 + +Should Run ThreadX Scheduler And Exercise RTOS Primitives + Execute Command include @${CURDIR}/nucleo_f401re_demo.resc + Create Terminal Tester sysbus.usart2 + + Wait For Line On Uart [SELF-TEST] All startup verification tests PASSED! timeout=15 + Wait For Line On Uart System Status: timeout=15 + # The blink thread drives bsp_led_toggle() on PA5 and the application timer + # drives the wake counter, so a non-zero pair covers both BSP paths. + Wait For Line On Uart Runs: Monitor: (\\d+) .* Blink: [1-9]\\d* timeout=20 treatAsRegex=true + Wait For Line On Uart Mutex Locks: [1-9]\\d*.*Queue Msgs: [1-9]\\d* timeout=20 treatAsRegex=true diff --git a/targets/STMicroelectronics/NUCLEO_F401RE/scripts/test_renode.py b/targets/STMicroelectronics/NUCLEO_F401RE/scripts/test_renode.py new file mode 100755 index 0000000..e37a8a7 --- /dev/null +++ b/targets/STMicroelectronics/NUCLEO_F401RE/scripts/test_renode.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available +# under the terms of the MIT license which is available at +# https://opensource.org/licenses/MIT. +# +# SPDX-License-Identifier: MIT +# + +""" +Headless Renode Verification Test for the NUCLEO-F401RE ThreadX Demo. + +Runs targets/STMicroelectronics/NUCLEO_F401RE/renode/nucleo_f401re_ci.resc and +asserts on the USART2 console output. The run advances a fixed span of virtual +time and quits on its own, so the result does not depend on host speed. +""" + +import os +import re +import shutil +import subprocess +import sys +import threading +import queue +import time + +RESC_NAME = "nucleo_f401re_ci.resc" + +# The reporter thread prints these once the RTOS primitives have been exercised. +RUNS_RE = re.compile(r"Runs: Monitor: (\d+) \| Reporter: (\d+) \| Blink: (\d+) \| Timer Wakes: (\d+)") +RTOS_RE = re.compile( + r"Mutex Locks: (\d+)/(\d+) \| Queue Msgs: (\d+) \| Event Wakes: (\d+) \| Sema Wakes: (\d+)" +) + + +def find_renode(): + renode_bin = shutil.which("renode") + if renode_bin: + return renode_bin + + win_paths = [ + r"C:\Program Files\Renode\renode.exe", + os.path.expanduser(r"~\AppData\Local\Programs\Renode\renode.exe"), + ] + for path in win_paths: + if os.path.isfile(path): + return path + + return "renode" + + +def reader_thread_fn(pipe, q): + try: + for line in iter(pipe.readline, ""): + q.put(line) + except Exception: + pass + finally: + pipe.close() + + +def run_test(test_timeout_mode=False): + renode = find_renode() + script_dir = os.path.dirname(os.path.abspath(__file__)) + target_dir = os.path.dirname(script_dir) + resc_path = os.path.join(target_dir, "renode", RESC_NAME).replace("\\", "/") + + if test_timeout_mode: + print("[*] Running intentional timeout test mode (2.0s deadline)...") + timeout_seconds = 2.0 + else: + print(f"[*] Starting headless Renode test using: {renode}") + print(f"[*] Loading script: {resc_path}") + timeout_seconds = 600.0 + + cmd = [renode, "--plain", "--disable-gui", "--port", "-1", + "-e", f"include @{resc_path}"] + + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + output_q = queue.Queue() + reader_t = threading.Thread(target=reader_thread_fn, args=(proc.stdout, output_q), daemon=True) + reader_t.start() + + found_banner = False + found_selftests = False + found_selftest_failure = False + found_led_and_timer = False + found_rtos_primitives = False + start_time = time.time() + + try: + while time.time() - start_time < timeout_seconds: + try: + line = output_q.get(timeout=0.1) + print(line, end="") + if test_timeout_mode: + continue + + if "NUCLEO-F401RE Device Monitor Demo" in line: + found_banner = True + if "[-] FAIL:" in line or "startup verification test(s) FAILED" in line: + found_selftest_failure = True + if "[SELF-TEST] All startup verification tests PASSED!" in line: + found_selftests = True + + # The blink thread is the only caller of bsp_led_toggle(), and + # timer wakes come from the 1 Hz ThreadX application timer, so a + # non-zero pair covers the LED path and the timer service. + m = RUNS_RE.search(line) + if m and int(m.group(3)) > 0 and int(m.group(4)) > 0: + found_led_and_timer = True + + m = RTOS_RE.search(line) + if m and all(int(g) > 0 for g in m.groups()): + found_rtos_primitives = True + + # A failed self-test is recorded but does not stop the run, so + # the remaining assertions are still reported rather than hidden. + if (found_banner and found_selftests and not found_selftest_failure + and found_led_and_timer and found_rtos_primitives): + print("\n[+] SUCCESS: boot banner, startup self-tests, LED and " + "timer activity, and all RTOS primitives verified!") + break + except queue.Empty: + if proc.poll() is not None: + break + finally: + try: + proc.terminate() + proc.wait(timeout=5) + except Exception: + try: + proc.kill() + except Exception: + pass + + if test_timeout_mode: + elapsed = time.time() - start_time + print(f"\n[+] SUCCESS: Intentional timeout triggered after {elapsed:.2f}s " + f"and terminated child process cleanly.") + sys.exit(0) + + checks = { + "boot banner reached the console": found_banner, + "startup self-tests passed": found_selftests and not found_selftest_failure, + "LED blink thread and application timer ran": found_led_and_timer, + "mutex, queue, event flag and semaphore all exercised": found_rtos_primitives, + } + failed = [name for name, ok in checks.items() if not ok] + + if not failed: + print("[+] Renode headless test PASSED.") + sys.exit(0) + + print("\n[-] FAILED. Unmet assertions:") + for name in failed: + print(" - %s" % name) + sys.exit(1) + + +if __name__ == "__main__": + run_test(test_timeout_mode="--test-timeout" in sys.argv)