diff --git a/.ci/check-format.sh b/.ci/check-format.sh new file mode 100755 index 0000000..334ee59 --- /dev/null +++ b/.ci/check-format.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# The -e is not set because we want to get all the mismatch format at once + +set -u -o pipefail + +# In CI environment, require all formatting tools to be present +# Set CI=true to enforce tool presence (GitHub Actions sets this automatically) +REQUIRE_TOOLS="${CI:-false}" + +C_FORMAT_EXIT=0 +SH_FORMAT_EXIT=0 + +# On pull requests, check only changed files so pre-existing formatting +# debt does not block unrelated PRs. On push (to main) or local runs +# without GITHUB_BASE_REF, check the full tree. +if [ -n "${GITHUB_BASE_REF:-}" ]; then + echo "PR mode: checking only files changed vs origin/${GITHUB_BASE_REF}" + if git fetch --depth=1 origin "${GITHUB_BASE_REF}" 2> /dev/null \ + && CHANGED=$(git diff --name-only --diff-filter=d "origin/${GITHUB_BASE_REF}"...HEAD 2> /dev/null); then + # If formatting config changed, check the full tree against the new rules. + if printf '%s\n' "$CHANGED" | grep -qE '^\.(clang-format|editorconfig)$'; then + echo "Formatting config changed; checking full tree" + CHANGED=$(git ls-files) + fi + else + echo "Warning: cannot compute PR diff, falling back to full-tree check" >&2 + CHANGED=$(git ls-files) + fi +else + CHANGED=$(git ls-files) +fi + +# Filter changed files into language-specific sets +C_SOURCES=() +while IFS= read -r file; do + [ -n "$file" ] || continue + case "$file" in + include/*.h | src/*.c | src/*.h | ports/*.c | ports/*.h | \ + ports/*/*.c | ports/*/*.h | \ + ports/*/*/*.c | ports/*/*/*.h) + C_SOURCES+=("$file") + ;; + esac +done <<< "$CHANGED" + +if [ ${#C_SOURCES[@]} -gt 0 ]; then + if command -v clang-format-20 > /dev/null 2>&1; then + echo "Checking C files with clang-format-20..." + clang-format-20 -n --Werror "${C_SOURCES[@]}" + C_FORMAT_EXIT=$? + elif command -v clang-format > /dev/null 2>&1; then + echo "Checking C files with clang-format..." + clang-format -n --Werror "${C_SOURCES[@]}" + C_FORMAT_EXIT=$? + else + if [ "$REQUIRE_TOOLS" = "true" ]; then + echo "ERROR: clang-format not found (required in CI)" >&2 + C_FORMAT_EXIT=1 + else + echo "Skipping C format check: clang-format not found" >&2 + fi + fi +fi + +SH_SOURCES=() +while IFS= read -r file; do + [ -n "$file" ] || continue + case "$file" in + *.sh | .ci/*.sh | scripts/*.sh) + SH_SOURCES+=("$file") + ;; + esac +done <<< "$CHANGED" + +if [ ${#SH_SOURCES[@]} -gt 0 ]; then + if command -v shfmt > /dev/null 2>&1; then + echo "Checking shell scripts..." + MISMATCHED_SH=$(shfmt -l "${SH_SOURCES[@]}") + if [ -n "$MISMATCHED_SH" ]; then + echo "The following shell scripts are not formatted correctly:" + printf '%s\n' "$MISMATCHED_SH" + shfmt -d "${SH_SOURCES[@]}" + SH_FORMAT_EXIT=1 + fi + else + if [ "$REQUIRE_TOOLS" = "true" ]; then + echo "ERROR: shfmt not found (required in CI)" >&2 + SH_FORMAT_EXIT=1 + else + echo "Skipping shell script format check: shfmt not found" >&2 + fi + fi +fi + +# Use logical OR to avoid exit code overflow (codes are mod 256) +if [ $C_FORMAT_EXIT -ne 0 ] || [ $SH_FORMAT_EXIT -ne 0 ]; then + exit 1 +fi +exit 0 diff --git a/.ci/check-newline.sh b/.ci/check-newline.sh new file mode 100755 index 0000000..4e889a9 --- /dev/null +++ b/.ci/check-newline.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -e -u -o pipefail + +ret=0 +show=0 + +# Check all tracked text files for trailing newlines +# Excludes: externals/ and cloned RTOS trees (third-party code with own style) +# Reference: https://medium.com/@alexey.inkin/how-to-force-newline-at-end-of-files-and-why-you-should-do-it-fdf76d1d090e +while IFS= read -rd '' f; do + # Skip third-party directories + case "$f" in + externals/* | threadx/* | freertos-kernel/* | tools/kconfig/*) continue ;; + esac + + # Skip empty files + [ -s "$f" ] || continue + + if file --mime-encoding "$f" | grep -qv binary; then + tail -c1 < "$f" | read -r _ || show=1 + if [ $show -eq 1 ]; then + echo "Warning: No newline at end of file $f" + ret=1 + show=0 + fi + fi +done < <(git ls-files -z) + +exit $ret diff --git a/.ci/common.sh b/.ci/common.sh new file mode 100644 index 0000000..f96e1b1 --- /dev/null +++ b/.ci/common.sh @@ -0,0 +1,67 @@ +# Bash strict mode (enabled only when executed directly, not sourced) +if ! (return 0 2> /dev/null); then + set -euo pipefail +fi + +# Expect host is Linux/x86_64, Linux/aarch64, macOS/arm64 + +MACHINE_TYPE=$(uname -m) +OS_TYPE=$(uname -s) + +check_platform() +{ + case "${MACHINE_TYPE}/${OS_TYPE}" in + x86_64/Linux | aarch64/Linux | arm64/Darwin) ;; + + *) + echo "Unsupported platform: ${MACHINE_TYPE}/${OS_TYPE}" + exit 1 + ;; + esac +} + +if [ "${OS_TYPE}" = "Linux" ]; then + PARALLEL=-j$(nproc) +else + PARALLEL=-j$(sysctl -n hw.logicalcpu) +fi + +# Color output helpers +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +print_success() +{ + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_error() +{ + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +print_warning() +{ + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# Cleanup function registry +CLEANUP_FUNCS=() + +register_cleanup() +{ + CLEANUP_FUNCS+=("$1") +} + +cleanup() +{ + local func + for func in "${CLEANUP_FUNCS[@]-}"; do + [ -n "${func}" ] || continue + eval "${func}" || true + done +} + +trap cleanup EXIT diff --git a/.ci/install-deps.sh b/.ci/install-deps.sh new file mode 100755 index 0000000..722998f --- /dev/null +++ b/.ci/install-deps.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# Install build dependencies for CI +# Usage: .ci/install-deps.sh [posix|cortex-m|format|analysis] + +set -euo pipefail + +source "$(dirname "$0")/common.sh" + +MODE="${1:-posix}" + +# Setup LLVM 20 APT repository (shared by format and analysis modes) +setup_llvm_repo() +{ + LLVM_KEYRING=/usr/share/keyrings/llvm-archive-keyring.gpg + LLVM_KEY_FP="6084F3CF814B57C1CF12EFD515CF4D18AF4F7421" + TMPKEY=$(mktemp) + curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key -o "$TMPKEY" + ACTUAL_FP=$(gpg --with-fingerprint --with-colons "$TMPKEY" 2> /dev/null | grep fpr | head -1 | cut -d: -f10) + if [ "$ACTUAL_FP" != "$LLVM_KEY_FP" ]; then + print_error "LLVM key fingerprint mismatch!" + print_error "Expected: $LLVM_KEY_FP" + print_error "Got: $ACTUAL_FP" + rm -f "$TMPKEY" + exit 1 + fi + sudo gpg --dearmor -o "$LLVM_KEYRING" < "$TMPKEY" + rm -f "$TMPKEY" + + CODENAME=$(lsb_release -cs) + echo "deb [signed-by=${LLVM_KEYRING}] https://apt.llvm.org/${CODENAME}/ llvm-toolchain-${CODENAME}-20 main" | sudo tee /etc/apt/sources.list.d/llvm-20.list + sudo apt-get update -q=2 +} + +case "$MODE" in + posix) + # POSIX host builds only need a C compiler (pre-installed) and python3. + if [ "$OS_TYPE" = "Linux" ]; then + sudo apt-get update -q=2 + sudo apt-get install -y -q=2 --no-install-recommends python3 + else + brew install python3 + fi + ;; + cortex-m) + # Cortex-M QEMU builds: cross-compiler + emulator. + if [ "$OS_TYPE" != "Linux" ]; then + print_error "Cortex-M CI only supported on Linux" + exit 1 + fi + sudo apt-get update -q=2 + sudo apt-get install -y -q=2 --no-install-recommends \ + gcc-arm-none-eabi libnewlib-arm-none-eabi \ + qemu-system-arm python3 + ;; + format) + if [ "$OS_TYPE" != "Linux" ]; then + print_error "Formatting tools only supported on Linux" + exit 1 + fi + sudo apt-get update -q=2 + sudo apt-get install -y -q=2 --no-install-recommends shfmt gnupg ca-certificates lsb-release + setup_llvm_repo + sudo apt-get install -y -q=2 --no-install-recommends clang-format-20 + ;; + analysis) + if [ "$OS_TYPE" != "Linux" ]; then + print_error "Static analysis only supported on Linux" + exit 1 + fi + sudo apt-get update -q=2 + sudo apt-get install -y -q=2 --no-install-recommends gnupg ca-certificates lsb-release python3 + setup_llvm_repo + sudo apt-get install -y -q=2 --no-install-recommends clang-20 clang-tools-20 + ;; + *) + print_error "Unknown mode: $MODE" + echo "Usage: $0 [posix|cortex-m|format|analysis]" + exit 1 + ;; +esac + +print_success "Dependencies installed for mode: $MODE" diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..21de05a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# Top-level EditorConfig file +root = true + +# Shell script-specific settings +[*.sh] +indent_style = space +indent_size = 4 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +function_next_line = true +switch_case_indent = true +space_redirects = true +binary_next_line = true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..5ee8957 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,172 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + detect-code-related-file-changes: + runs-on: ubuntu-24.04 + outputs: + has_code_related_changes: ${{ steps.set_has_code_related_changes.outputs.has_code_related_changes }} + steps: + - name: Check out the repo + uses: actions/checkout@v6 + - name: Test changed files + id: changed-files + uses: tj-actions/changed-files@v47 + with: + files: | + .ci/** + .github/** + configs/** + include/** + mk/** + ports/** + scripts/** + src/** + .clang-format + .editorconfig + Makefile + - name: Set has_code_related_changes + id: set_has_code_related_changes + run: | + if [[ ${{ steps.changed-files.outputs.any_changed }} == true ]]; then + echo "has_code_related_changes=true" >> $GITHUB_OUTPUT + else + echo "has_code_related_changes=false" >> $GITHUB_OUTPUT + fi + + coding-style: + needs: [detect-code-related-file-changes] + if: needs.detect-code-related-file-changes.outputs.has_code_related_changes == 'true' + timeout-minutes: 10 + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Install formatting tools + run: .ci/install-deps.sh format + - name: Check newline at end of files + run: .ci/check-newline.sh + - name: Check code formatting + run: .ci/check-format.sh + + static-analysis: + needs: [detect-code-related-file-changes] + if: needs.detect-code-related-file-changes.outputs.has_code_related_changes == 'true' + timeout-minutes: 15 + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + run: .ci/install-deps.sh analysis + - name: scan-build (POSIX host) + run: | + make defconfig + make clean + scan-build-20 --status-bugs \ + -o /tmp/scan-build-report \ + make -j$(nproc) + - name: Upload scan-build report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: scan-build-report + path: /tmp/scan-build-report + retention-days: 7 + if-no-files-found: ignore + + posix-tests: + needs: [detect-code-related-file-changes] + if: needs.detect-code-related-file-changes.outputs.has_code_related_changes == 'true' + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + rtos: threadx + defconfig: defconfig + - os: ubuntu-24.04 + rtos: freertos + defconfig: freertos_posix_defconfig + - os: macos-latest + rtos: threadx + defconfig: defconfig + - os: macos-latest + rtos: freertos + defconfig: freertos_posix_defconfig + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + run: | + source .ci/common.sh + if [ "$OS_TYPE" = "Linux" ]; then + sudo apt-get update -q=2 + sudo apt-get install -y -q=2 --no-install-recommends python3 + fi + - name: Build and test (${{ matrix.rtos }} POSIX) + run: | + source .ci/common.sh + make ${{ matrix.defconfig }} + make $PARALLEL + sudo make check + + cortex-m-tests: + needs: [detect-code-related-file-changes] + if: needs.detect-code-related-file-changes.outputs.has_code_related_changes == 'true' + timeout-minutes: 30 + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - rtos: threadx + defconfig: threadx_cortex_m_defconfig + - rtos: freertos + defconfig: freertos_cortex_m_defconfig + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + run: .ci/install-deps.sh cortex-m + - name: Build and test (${{ matrix.rtos }} Cortex-M QEMU) + run: | + source .ci/common.sh + make ${{ matrix.defconfig }} + make $PARALLEL + make check + + sanitizers: + needs: [detect-code-related-file-changes, posix-tests] + if: needs.detect-code-related-file-changes.outputs.has_code_related_changes == 'true' + timeout-minutes: 30 + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - rtos: threadx + defconfig: defconfig + - rtos: freertos + defconfig: freertos_posix_defconfig + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + run: | + sudo apt-get update -q=2 + sudo apt-get install -y -q=2 --no-install-recommends python3 + - name: Build and test with ASan (${{ matrix.rtos }}) + run: | + make ${{ matrix.defconfig }} + echo "CONFIG_SANITIZERS=y" >> .config + sudo make check diff --git a/.gitignore b/.gitignore index ab62ea6..337fa9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ externals/ /threadx/ +/freertos-kernel/ /build/ .config .config.old /tools/kconfig/ !ports/threadx/ +!ports/freertos/ diff --git a/Makefile b/Makefile index 702e64c..5b686ce 100644 --- a/Makefile +++ b/Makefile @@ -59,7 +59,11 @@ TM_TEST_CYCLES ?= $(if $(CONFIG_TEST_CYCLES),$(CONFIG_TEST_CYCLES),0) # check target parameters -- short runs for smoke testing. ifeq ($(CONFIG_TARGET_CORTEX_M_QEMU),y) - CHECK_TIMEOUT := 30 + # Generous timeout: the check target builds with TM_TEST_DURATION=1, so + # tests finish in ~2 s. 120 s accommodates worst-case scenarios where + # the rebuild silently kept TM_TEST_DURATION=30 (emulated 30 s wall-clock + # is ~35 s on real hardware) or where the CI runner is very slow. + CHECK_TIMEOUT := 120 CHECK_QEMU_FLAGS := $(QEMU_FLAGS) else CHECK_DURATION := 3 @@ -195,6 +199,7 @@ endif $(CLONE_STAMP): @echo " CLONE $(CLONE_URL)" + @if [ -d $(RTOS_DIR) ] && [ ! -f $@ ]; then rm -rf $(RTOS_DIR); fi $(Q)git clone $(CLONE_URL) $(RTOS_DIR) --depth=1 @touch $@ @@ -222,16 +227,9 @@ run: QEMU=$(QEMU) scripts/qemu-run.sh $(firstword $(BINS)) $(QEMU_FLAGS) endif -check: - @$(if $(CONFIG_TARGET_CORTEX_M_QEMU),\ - if ! command -v $(QEMU) >/dev/null 2>&1; then \ - echo "Error: $(QEMU) not found (needed for Cortex-M check)"; \ - exit 1; \ - fi;) - @$(if $(CONFIG_TARGET_CORTEX_M_QEMU),\ - $(MAKE) --quiet all TM_TEST_DURATION=1 TM_TEST_CYCLES=1,\ - $(MAKE) --quiet all) - @printf " CHECK %s + %s\n" "$(RTOS_NAME)" "$(TARGET_NAME)" +# Test loop shared by both check variants. +# Each variant sets up its build and prints the banner, then calls this. +define run-check-loop @TCMD=""; \ if command -v timeout >/dev/null 2>&1; then TCMD="timeout"; \ elif command -v gtimeout >/dev/null 2>&1; then TCMD="gtimeout"; fi; \ @@ -255,6 +253,31 @@ check: done; \ printf "\n%d passed, %d failed\n" "$$passed" "$$failed"; \ [ "$$failed" -eq 0 ] +endef + +ifeq ($(CONFIG_TARGET_CORTEX_M_QEMU),y) +# Cortex-M: bake TM_TEST_DURATION=1 / TM_TEST_CYCLES=1 into binaries so +# the program self-terminates via semihosting exit. No sudo needed. +# Wipe build/ first via clean-build (handles root-owned leftovers from +# a prior sudo make) to guarantee no stale objects carry old flags. +check: + @if ! command -v $(QEMU) >/dev/null 2>&1; then \ + echo "Error: $(QEMU) not found (needed for Cortex-M check)"; \ + exit 1; \ + fi + @$(MAKE) --quiet clean-build + @$(MAKE) --quiet all TM_TEST_DURATION=1 TM_TEST_CYCLES=1 + @printf " CHECK %s + %s\n" "$(RTOS_NAME)" "$(TARGET_NAME)" + $(run-check-loop) +else +# POSIX: binaries read TM_TEST_DURATION / TM_TEST_CYCLES from env at runtime, +# so the default build is reused. ThreadX POSIX needs sudo for +# pthread_setschedparam -- run "make && sudo make check". +check: + @$(MAKE) --quiet all + @printf " CHECK %s + %s\n" "$(RTOS_NAME)" "$(TARGET_NAME)" + $(run-check-loop) +endif endif diagnose: @@ -330,14 +353,18 @@ diagnose: fi clean-bins: - rm -f $(BINS) + rm -f $(BINS) 2>/dev/null || true clean: clean-bins clean-build distclean: clean - rm -f .config - rm -rf threadx freertos-kernel rt-thread || true - rm -rf $(KCONFIG_DIR) + rm -f .config .config.old + @for d in threadx freertos-kernel rt-thread $(KCONFIG_DIR); do \ + [ -d "$$d" ] || continue; \ + rm -rf "$$d" 2>/dev/null || { \ + echo " CLEAN $$d/ (needs privilege escalation)"; \ + sudo rm -rf "$$d"; }; \ + done # Kconfig targets @@ -348,6 +375,7 @@ KCONFIGLIB_REPO := https://github.com/sysprog21/Kconfiglib $(KCONFIG_DIR)/kconfiglib.py: @echo " CLONE Kconfiglib" + @if [ -d $(KCONFIG_DIR) ] && [ ! -f $@ ]; then rm -rf $(KCONFIG_DIR); fi $(Q)git clone --depth=1 -q $(KCONFIGLIB_REPO) $(KCONFIG_DIR) $(KCONFIG_DIR)/menuconfig.py $(KCONFIG_DIR)/defconfig.py \ diff --git a/configs/Kconfig b/configs/Kconfig index 5b63a94..c910352 100644 --- a/configs/Kconfig +++ b/configs/Kconfig @@ -59,7 +59,8 @@ config TEST_CYCLES Number of reports before exit(0). 0 = infinite (original behavior: run until killed). Set to 1 for CI / QEMU semihosting so the program - terminates cleanly via _exit() -> SYS_EXIT. + terminates cleanly via the direct semihosting + SYS_EXIT helper. endmenu diff --git a/configs/freertos_cortex_m_defconfig b/configs/freertos_cortex_m_defconfig index 8593f60..78d27d0 100644 --- a/configs/freertos_cortex_m_defconfig +++ b/configs/freertos_cortex_m_defconfig @@ -1,7 +1,12 @@ # FreeRTOS on Cortex-M3 QEMU (mps2-an385) +# Default benchmark interval, but self-terminate after one report so +# QEMU semihosting runs end cleanly. CONFIG_RTOS_FREERTOS=y CONFIG_TARGET_CORTEX_M_QEMU=y +CONFIG_TEST_DURATION=30 CONFIG_TEST_CYCLES=1 +# CONFIG_OPTIMIZE_SIZE is not set +# CONFIG_DEBUG_SYMBOLS is not set CONFIG_CONFIGURED=y diff --git a/configs/freertos_posix_defconfig b/configs/freertos_posix_defconfig index 4af7b40..d9a179e 100644 --- a/configs/freertos_posix_defconfig +++ b/configs/freertos_posix_defconfig @@ -2,5 +2,10 @@ CONFIG_RTOS_FREERTOS=y CONFIG_TARGET_POSIX_HOST=y +CONFIG_TEST_DURATION=30 +CONFIG_TEST_CYCLES=0 +# CONFIG_OPTIMIZE_SIZE is not set +# CONFIG_DEBUG_SYMBOLS is not set +# CONFIG_SANITIZERS is not set CONFIG_CONFIGURED=y diff --git a/configs/threadx_cortex_m_defconfig b/configs/threadx_cortex_m_defconfig index 9cee7e2..9ace78e 100644 --- a/configs/threadx_cortex_m_defconfig +++ b/configs/threadx_cortex_m_defconfig @@ -1,7 +1,12 @@ # ThreadX on Cortex-M3 QEMU (mps2-an385) +# Default benchmark interval, but self-terminate after one report so +# QEMU semihosting runs end cleanly. CONFIG_RTOS_THREADX=y CONFIG_TARGET_CORTEX_M_QEMU=y +CONFIG_TEST_DURATION=30 CONFIG_TEST_CYCLES=1 +# CONFIG_OPTIMIZE_SIZE is not set +# CONFIG_DEBUG_SYMBOLS is not set CONFIG_CONFIGURED=y diff --git a/configs/threadx_posix_defconfig b/configs/threadx_posix_defconfig index 5e8aa42..a90050b 100644 --- a/configs/threadx_posix_defconfig +++ b/configs/threadx_posix_defconfig @@ -2,5 +2,10 @@ CONFIG_RTOS_THREADX=y CONFIG_TARGET_POSIX_HOST=y +CONFIG_TEST_DURATION=30 +CONFIG_TEST_CYCLES=0 +# CONFIG_OPTIMIZE_SIZE is not set +# CONFIG_DEBUG_SYMBOLS is not set +# CONFIG_SANITIZERS is not set CONFIG_CONFIGURED=y diff --git a/include/tm_api.h b/include/tm_api.h index 1de63b3..fe8e5c2 100644 --- a/include/tm_api.h +++ b/include/tm_api.h @@ -70,24 +70,23 @@ void tm_printf(const char *fmt, ...); * ... sleep, print, check counters ... * } TM_REPORT_FINISH */ -#define TM_REPORT_LOOP \ - { \ - int _tm_cycle; \ - for (_tm_cycle = 0; \ - !tm_test_cycles || _tm_cycle < tm_test_cycles; \ +#define TM_REPORT_LOOP \ + { \ + int _tm_cycle; \ + for (_tm_cycle = 0; !tm_test_cycles || _tm_cycle < tm_test_cycles; \ tm_test_cycles ? _tm_cycle++ : 0) -#define TM_REPORT_FINISH \ - } \ +#define TM_REPORT_FINISH \ + } \ tm_report_finish() /* Init-time check: abort on failure so mis-configured porting layers * are caught immediately instead of producing silent hangs. */ -#define TM_CHECK(call) \ - do { \ - if ((call) != TM_SUCCESS) \ - tm_check_fail("FATAL: " #call " failed\n"); \ +#define TM_CHECK(call) \ + do { \ + if ((call) != TM_SUCCESS) \ + tm_check_fail("FATAL: " #call " failed\n"); \ } while (0) diff --git a/mk/build.mk b/mk/build.mk index 8c6d0a8..f4be364 100644 --- a/mk/build.mk +++ b/mk/build.mk @@ -124,6 +124,9 @@ $(BUILD): $(Q)mkdir -p $@ clean-build: - rm -rf $(BUILD) + @if [ ! -e $(BUILD) ]; then exit 0; fi; \ + if rm -rf $(BUILD) 2>/dev/null; then exit 0; fi; \ + echo " CLEAN $(BUILD)/ (needs privilege escalation)"; \ + sudo rm -rf $(BUILD) .PHONY: clean-build diff --git a/ports/common/cortex-m/tm_putchar.c b/ports/common/cortex-m/tm_putchar.c index 5475c3c..b236550 100644 --- a/ports/common/cortex-m/tm_putchar.c +++ b/ports/common/cortex-m/tm_putchar.c @@ -1,8 +1,17 @@ /* - * ARM semihosting character output for tm_printf(). + * ARM semihosting primitives for Cortex-M QEMU builds. * * RTOS-neutral -- shared by all Cortex-M porting layers. - * Compiled only for cortex-m-qemu builds (TM_SEMIHOSTING defined). + * Compiled only when TM_SEMIHOSTING is defined. + * + * These bypass newlib entirely. In particular, tm_semihosting_exit() + * avoids the deep call chain in newlib's _exit() which probes for + * SYS_EXIT_EXTENDED support by opening ":semihosting-features", + * calling __sinit (stdio init that depends on initialise_monitor_handles, + * which -nostartfiles omits), and performing multiple file I/O + * semihosting operations. On some toolchain/QEMU combinations this + * chain faults or hangs because the newlib reent structure was never + * properly initialized. */ #include "tm_api.h" @@ -15,3 +24,16 @@ void tm_putchar(int c) register char *r1 __asm__("r1") = &ch; __asm__ volatile("bkpt #0xAB" : : "r"(r0), "r"(r1) : "memory"); } + +void tm_semihosting_exit(int code) +{ + /* ARM semihosting SYS_EXIT (0x18). + * On M-profile, r1 is the reason code directly (not a pointer). + * ADP_Stopped_ApplicationExit = 0x20026. + */ + register int r0 __asm__("r0") = 0x18; + register int r1 __asm__("r1") = code == 0 ? 0x20026 : 0x20024; + __asm__ volatile("bkpt #0xAB" : : "r"(r0), "r"(r1) : "memory"); + for (;;) + ; +} diff --git a/ports/threadx/posix-host/tx_thread_system_return.c b/ports/threadx/posix-host/tx_thread_system_return.c index 41fe428..8cfa688 100644 --- a/ports/threadx/posix-host/tx_thread_system_return.c +++ b/ports/threadx/posix-host/tx_thread_system_return.c @@ -70,6 +70,6 @@ VOID _tx_thread_system_return(VOID) pthread_exit((void *) &exit_code); } - if (!_tx_thread_current_ptr->tx_thread_posix_int_disabled_flag) + if (!temp_thread_ptr || !temp_thread_ptr->tx_thread_posix_int_disabled_flag) tx_posix_mutex_recursive_unlock(_tx_posix_mutex); } diff --git a/scripts/qemu-run.sh b/scripts/qemu-run.sh index 57a35a1..55f40f8 100755 --- a/scripts/qemu-run.sh +++ b/scripts/qemu-run.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # Run a Thread-Metric ELF under QEMU mps2-an385 (Cortex-M3). # # Usage: @@ -19,7 +19,7 @@ # QEMU -- path to qemu-system-arm (default: qemu-system-arm) # QEMU_TIMEOUT -- outer timeout in seconds (default: 120) -set -e +set -euo pipefail if [ $# -lt 1 ]; then echo "Usage: $0 [extra-qemu-flags...]" >&2 @@ -45,39 +45,85 @@ for arg in "$@"; do esac done -# Locate a timeout command. coreutils' timeout(1) is not shipped with -# macOS; Homebrew installs it as "gtimeout". -TIMEOUT_CMD="" -if command -v timeout >/dev/null 2>&1; then - TIMEOUT_CMD="timeout" -elif command -v gtimeout >/dev/null 2>&1; then - TIMEOUT_CMD="gtimeout" -else - echo "Warning: neither timeout nor gtimeout found; running without timeout" >&2 -fi +# Run QEMU under a shell-managed timeout instead of timeout(1). +# Some host timeout wrappers mis-handle QEMU semihosting exit and report a +# false timeout even though the guest reached SYS_EXIT successfully. +qemu_pid="" +watchdog_pid="" +timeout_flag="" + +cleanup() +{ + if [ -n "$watchdog_pid" ]; then + kill "$watchdog_pid" 2> /dev/null || true + wait "$watchdog_pid" 2> /dev/null || true + watchdog_pid="" + fi + if [ -n "$qemu_pid" ]; then + kill "$qemu_pid" 2> /dev/null || true + sleep 1 + kill -9 "$qemu_pid" 2> /dev/null || true + qemu_pid="" + fi + [ -z "$timeout_flag" ] || rm -f "$timeout_flag" +} + +# EXIT fires on normal exit. INT/TERM handlers clear EXIT first to +# avoid double-invocation, then clean up and exit with the right code. +trap cleanup EXIT +trap 'trap - EXIT; cleanup; exit 130' INT +trap 'trap - EXIT; cleanup; exit 143' TERM + +# The watchdog writes a sentinel file to distinguish its kill from an +# external signal. Created via mktemp to avoid predictable paths. +timeout_flag=$(mktemp "${TMPDIR:-/tmp}/tm-qemu-timeout.XXXXXX") +rm -f "$timeout_flag" -# Run QEMU with an outer timeout as a safety net. set +e -if [ -n "$TIMEOUT_CMD" ]; then - "$TIMEOUT_CMD" "$QEMU_TIMEOUT" "$QEMU" \ - -M mps2-an385 -cpu cortex-m3 -nographic \ - -kernel "$ELF" "$@" 2>&1 -else - "$QEMU" \ - -M mps2-an385 -cpu cortex-m3 -nographic \ - -kernel "$ELF" "$@" 2>&1 -fi +"$QEMU" \ + -M mps2-an385 -cpu cortex-m3 -nographic \ + -kernel "$ELF" "$@" 2>&1 & +qemu_pid=$! + +# The watchdog subshell redirects its own stdout/stderr to /dev/null +# so it does not hold the pipe open when this script is invoked inside +# $(). Without this, $() blocks until the watchdog's sleep finishes +# even after QEMU has exited. +( + sleep "$QEMU_TIMEOUT" + : > "$timeout_flag" + kill "$qemu_pid" 2> /dev/null || true + sleep 1 + kill -9 "$qemu_pid" 2> /dev/null || true +) > /dev/null 2>&1 & +watchdog_pid=$! + +wait "$qemu_pid" rc=$? +qemu_pid="" + +# Reap the watchdog immediately so it cannot linger. +kill "$watchdog_pid" 2> /dev/null || true +wait "$watchdog_pid" 2> /dev/null || true +watchdog_pid="" set -e -if [ $rc -eq 124 ]; then - # timeout(1) / gtimeout returns 124 when the child is killed. +# Timeout: the watchdog creates the sentinel before killing QEMU. +# This distinguishes a watchdog kill from an external signal. +timed_out=0 +if [ -f "$timeout_flag" ]; then + timed_out=1 +fi +rm -f "$timeout_flag" +timeout_flag="" + +if [ $timed_out -eq 1 ]; then if [ $semihosting -eq 1 ]; then echo "FAIL: QEMU timed out after ${QEMU_TIMEOUT}s (semihosting exit never reached)" >&2 echo " QEMU : $("$QEMU" --version 2>&1 | head -1)" >&2 echo " ELF : $ELF" >&2 echo " flags : $*" >&2 - echo " timeout : $TIMEOUT_CMD $QEMU_TIMEOUT" >&2 + echo " timeout : internal watchdog ${QEMU_TIMEOUT}s" >&2 echo " Hint: verify -semihosting-config is in flags above, TM_TEST_CYCLES=1 in binary," >&2 echo " and QEMU supports ARM semihosting (qemu-system-arm >= 4.0)." >&2 exit 1 diff --git a/src/tm_report.c b/src/tm_report.c index 5f2efa7..92e25bd 100644 --- a/src/tm_report.c +++ b/src/tm_report.c @@ -15,9 +15,19 @@ #include #include #include +#ifndef TM_SEMIHOSTING #include +#endif #include "tm_api.h" +#ifdef TM_SEMIHOSTING +/* Defined in ports/common/cortex-m/tm_putchar.c. Direct SYS_EXIT + * semihosting call that bypasses newlib's _exit() and its deep + * dependency chain (__sinit, _swiopen, etc.). + */ +void tm_semihosting_exit(int code); +#endif + /* Runtime test parameters -- default to compile-time values. */ int tm_test_duration = TM_TEST_DURATION; int tm_test_cycles = TM_TEST_CYCLES; @@ -179,12 +189,12 @@ void tm_printf(const char *fmt, ...) void tm_report_finish(void) { /* POSIX: exit() flushes stdio and runs atexit handlers (sanitizers - * register theirs via atexit). Semihosting: _exit() goes straight - * to SYS_EXIT -- exit() would call fflush() on uninitialized FILE - * structs and crash. + * register theirs via atexit). Semihosting: direct SYS_EXIT + * bypasses newlib's _exit() which pulls in __sinit and file I/O + * that depend on initialise_monitor_handles (-nostartfiles skips it). */ #ifdef TM_SEMIHOSTING - _exit(0); + tm_semihosting_exit(0); #else exit(0); #endif @@ -196,7 +206,7 @@ void tm_check_fail(const char *msg) tm_putchar(*msg++); /* See tm_report_finish() for the POSIX vs semihosting rationale. */ #ifdef TM_SEMIHOSTING - _exit(1); + tm_semihosting_exit(1); #else exit(1); #endif