Skip to content

ports/risc-v32/gnu: Add initial ESP32-C6 bare-metal ThreadX bring-up … - #576

Open
alieissa-commits wants to merge 4 commits into
eclipse-threadx:devfrom
alieissa-commits:esp32c6
Open

ports/risc-v32/gnu: Add initial ESP32-C6 bare-metal ThreadX bring-up …#576
alieissa-commits wants to merge 4 commits into
eclipse-threadx:devfrom
alieissa-commits:esp32c6

Conversation

@alieissa-commits

Copy link
Copy Markdown

Summary

This PR adds an initial bare-metal Eclipse ThreadX bring-up example for the Espressif ESP32-C6 microcontroller (ESP32-C6-DevKitC-1) under ports/risc-v32/gnu/example_build/esp32c6/.

It provides the basic hardware adaptation layer, memory map, build scripts, third-party licensing compliance, and a validation application demonstrating ThreadX thread creation and mutex synchronization on RISC-V32.


Key Changes & Implementations

1. Build System & Toolchain

  • CMakeLists.txt & cmake/esp32c6-toolchain.cmake: Cross-compilation support using riscv32-esp-elf-gcc (-march=rv32imac_zicsr_zifencei -mabi=ilp32). Includes post-build esptool.py elf2image generation (esp32c6_demo.bin).

2. Assembly Boot & Trap Infrastructure

  • entry.S: Initial CPU setup clearing Machine Interrupts (csrc mstatus, 8), setting up Global Pointer (gp), zeroing .bss, enforcing Direct Mode mtvec (Mode 0), and jumping to c6_startup.
  • vectors.S: Trap vector table pre-allocating 128-byte stack frames required by ThreadX RISC-V context save (_tx_thread_context_save), dispatching interrupts to C, and restoring thread context (_tx_thread_context_restore).
  • tx_initialize_low_level.S: Assembly hook allocating stack space to preserve ra across platform C driver initialization (esp32c6_platform_init).

3. Memory Map & Bootloader Validation

  • link.ld: Configured for ESP32-C6 512 KB SRAM (0x40800000). Placed KEEP(*(.rodata_desc .rodata_desc.*)) at byte offset 0x20 of segment 0 so Espressif's 2nd-stage bootloader validates esp_app_desc_t (0xABCD5432 magic word). Defines _tx_initialize_unused_memory at heap boundary.

4. Platform Hardware Drivers (platform/)

  • console.c: UART driver using Espressif ROM API (esp_rom_output_tx_one_char) with \n $\rightarrow$ \r\n line normalization.
  • interrupt.c: Configures PCR INTMTX bus clock (0x60096090), PLIC priority levels, CPU interrupt thresholds, and mie.MEIE/mie.MTIE CSR bits. Implements RISC-V PLIC Claim/Complete transaction dispatching via PLIC_MXINT_CLAIM_REG (0x20001094).
  • systimer.c: Links pinned ESP-IDF systimer_hal.c, connects Alarm 0 to 16 MHz Counter 0, takes hardware snapshots, and reloads periodic 10ms target values.
  • startup.c: Defines esp_app_desc_t, unlocks/disables hardware LP Watchdog Timer (LP_WDT), and hands off execution to tx_kernel_enter().

5. Application Demo & Compliance

  • demo_threadx.c: ThreadX kernel validation demo creating demo_mutex, thread_0, and thread_1 with 16-byte aligned static RISC-V stack buffers.
  • NOTICE.md: Formal third-party IP attribution file following Eclipse Foundation and Apache 2.0 standards for Espressif Systems, 10xEngineers, and Zephyr RTOS.
  • README.md: Build, flash, serial monitor, and OpenOCD/GDB debugging instructions.

Known Technical Issues & Status (For Future Development)

Warning

This bring-up example is an initial foundation and has active hardware tick issues documented in README.md:

  1. Standalone Reset Behavior: On physical power-on without JTAG attached, the board exhibits periodic reset behavior and shows no output.
  2. Scheduler Idle Wait: During JTAG debugging with OpenOCD/GDB, execution reaches thread_0_entry. However, after calling tx_thread_sleep(), the ThreadX scheduler remains in _tx_thread_schedule_loop because PLIC external interrupts are not advancing _tx_timer_system_clock.

Recommended Next Steps

As outlined in [threadx_ESP32C6_native_WIFI_plan.md]:

  • Replace the custom platform/systimer.c implementation with a pinned ESP-IDF esp_timer framework component import (esp_timer_systimer.c / systimer_hal.c) or use the native CPU RISC-V Machine Timer (mtimecmp / MTIE).

Verification Performed

  • Clean compilation with riscv32-esp-elf-gcc 15.2.0.
  • Post-build binary generation with esptool.py elf2image.
  • Verified SP, GP, vector alignment, and memory sections in esp32c6_demo.elf.
  • Tested OpenOCD JTAG connection and GDB symbols loading.

@fdesbiens

Copy link
Copy Markdown
Contributor

Thank you for this — the hard parts are in good shape, and the two problems you documented in the README both have identifiable causes. Notes below so you can pick this up when you are back.

First, what I checked and found correct, because these are the easy things to get wrong in a RISC-V port:

  • The context save frame is exactly right. ports/risc-v32/gnu/src/tx_thread_context_save.S documents its contract as addi sp, sp, -32*4 with sw ra, 28*4(sp), and your -128 / 112(sp) match it. Good.
  • The systimer ISR has the right shape: clear the status bit, re-arm, then call _tx_timer_interrupt().
  • The esp_app_desc_t placement at offset 0x20 of segment 0 is right, which is why the image passes second-stage bootloader validation at all.

Issue 1, the standalone reset: fixed on the branch

I have pushed one commit for this, because it was small and self-contained.

The watchdog disable was never taking effect. With DR_REG_LP_WDT_BASE at 0x600B1C00, LP_WDT_WPROTECT_REG sits at offset 0x18, so 0x600B1C18. The unlock key was going to 0x600B1C1C, which is LP_WDT_SWD_CONFIG_REG. Write protection was therefore never lifted, the hardware silently dropped the CONFIG0 = 0 write meant to stop the timer, and the key itself landed in the super watchdog's configuration register as an arbitrary value.

The super watchdog was also not being touched at all. It is a separate timer in the same block, enabled out of reset, and it resets the chip on its own period whatever LP_WDT is doing — so stopping only LP_WDT could not have fixed the loop. One ESP32-C6 specific trap worth remembering: this chip uses the same key for both write-protect registers, while the C3 and S3 use 0x8F1D312A for the super watchdog, so a snippet copied from those targets fails silently here.

Worth noting why JTAG hid this: OpenOCD's C6 target feeds or disables the RTC watchdog when it attaches, and the core is halted for much of a debug session. "Works under GDB, resets standalone" is a useful signature for a watchdog problem rather than a coincidence.

The commit is verified against a stubbed register block, not on hardware, so please confirm it on the DevKitC when you are back.

Issue 2, the tick never advancing

I have left this one for you, because it touches three files and needs the board to confirm. There are three independent causes, and the first is on its own enough to explain the symptom.

The CPU has no direct mode

entry.S does andi t0, t0, -4 to force mtvec MODE 0. On this core that is not a selectable mode. ESP-IDF's own components/riscv/vectors_intc.S states the hardware contract:

the CPU jumps to MTVEC (i.e. the first entry) in case of an exception, and (MTVEC & 0xfffffffc) + (mcause & 0x7fffffff) * 4, in case of an interrupt

Note: for our CPU, we need to place this on a 256-byte boundary, as CPU only uses the 24 MSBs of the MTVEC

and rv_utils_set_mtvec() unconditionally ORs in MTVEC_MODE_CSR, which is 1 for this interrupt controller. The CPU always vectors. Your single handler at the table base is therefore only ever reached for mcause == 0; a systimer interrupt on CPU line 1 jumps to base + 4, landing on sw ra, 112(sp) with no stack allocated.

A second, quieter problem sits alongside it: .align 4 in RISC-V GNU as means 2⁴ = 16 bytes, not 256. Since only the top 24 bits of mtvec are used, the effective base rounds down to the previous 256-byte boundary, into whatever code precedes the table in .iram0.text.

So vectors.S wants to become a real table:

    .section .iram0.vectors, "ax"
    .balign 0x100                  /* 256 bytes, not .align 4 */
    .option push
    .option norvc                  /* each entry must be exactly 4 bytes */
    .global _esp32c6_vector_table
_esp32c6_vector_table:
    j _esp32c6_panic_handler       /* 0: exceptions */
    j _esp32c6_trap_entry          /* 1 */
    j _esp32c6_trap_entry          /* 2 */
    /* ... 3 through 31, all the same shim ... */
    .option pop

with entry.S setting the mode rather than clearing it:

    la      t0, _esp32c6_vector_table
    ori     t0, t0, 1              /* vectored mode */
    csrw    mtvec, t0

Please also add . = ALIGN(256); before the section in link.ld and place .iram0.vectors ahead of .iram0.text, so the alignment cannot be lost again as the example grows.

The interrupt number is in mcause, not in a claim register

esp32c6_interrupt_dispatch() reads PLIC_MXINT_CLAIM_REG to learn which line fired. On this chip that number is already in mcause, and the claim register is not part of the dispatch path — searching ESP-IDF, it is referenced only by sleep_cpu_dynamic.c and sleep_cpu_static.c for sleep save and restore. If it reads back 0, your if (cpu_intr_num > 0 …) guard fails and the handler is never called, which is exactly what you are seeing.

The in-tree qemu_virt example shows the pattern — pass mcause in:

    call    _tx_thread_context_save
    csrr    a0, mcause
    call    esp32c6_interrupt_dispatch
void esp32c6_interrupt_dispatch(uint32_t mcause_val)
{
    if (!(mcause_val & RISCV_MCAUSE_INTERRUPT_FLAG)) return;
    uint32_t line = mcause_val & 0x1F;             /* the CPU interrupt line */
    if (line && s_isr_table[line]) s_isr_table[line](s_isr_arg[line]);
    REG_WRITE(PLIC_MXINT_CLEAR_REG, 1UL << line);  /* edge acknowledge */
}

The systimer bus clock is never enabled

systimer_hal_init() calls only systimer_ll_enable_clock(). systimer_ll_enable_bus_clock() and systimer_ll_reset_register() are separate helpers, called by ESP-IDF's RCC layer before the HAL. Bare metal has no such layer, so esp32c6_systimer_init() needs to call them itself — otherwise the register writes that follow may go nowhere.

Two smaller things in the same file. PLIC_MXINT_TYPE_REG is never configured, even though the source you chose is ..._EDGE_INTR_SOURCE. And the alarm mixes modes: systimer_ll_enable_alarm_period() pairs with systimer_ll_set_alarm_period(), not with set_alarm_target(). Since the ISR already re-arms by hand, target mode is the one to keep — drop the period call.

Suggested order

Each step is independently observable, which should make picking this up cold easier:

  1. Confirm the watchdog commit on hardware. Standalone boot should stop resetting and UART output should appear, which gives you observability before anything else.
  2. Vector table: 256-byte aligned, 32 entries, MODE 1. Check with riscv32-esp-elf-objdump -d that the symbol address ends in 00, and that mtvec reads back as base|1 in GDB.
  3. Dispatch from mcause. A breakpoint on esp32c6_systimer_isr should now be hit.
  4. Systimer bus clock and reset, then the type and alarm-mode tidy-up. Confirm the tick with tx_time_get().

One suggestion on scope

I would not follow the README's recommendation to replace the systimer with esp_timer yet. The systimer approach here is sound, and the reason it does not tick is the three causes above rather than the choice of timer — swapping frameworks now would paper over a vector table problem that would resurface with any interrupt source.

Separately, threadx_ESP32C6_native_WIFI_plan.md reads as planning material rather than port documentation. Please drop it from this PR; the parts of it a future reader needs belong in README.md.

alieissa-commits and others added 4 commits September 2, 2026 09:06
The watchdog disable never took effect, which is what resets the board on a
standalone power-on.

With DR_REG_LP_WDT_BASE at 0x600B1C00, LP_WDT_WPROTECT_REG is at offset 0x18,
so 0x600B1C18. The unlock key was going to 0x600B1C1C, which is
LP_WDT_SWD_CONFIG_REG. Write protection was therefore never lifted and the
hardware dropped the CONFIG0 write that was meant to stop the timer, while the
key itself landed in the super watchdog's configuration register as an arbitrary
value.

Send the key to WPROTECT, clear CONFIG0, then re-lock. Name the registers rather
than open coding the addresses, since that is how the mistake hid.

Also disable the super watchdog, which was not being touched at all. It is a
separate timer in the same block, enabled out of reset, and it resets the chip on
its own period whatever LP_WDT is doing, so stopping only LP_WDT cannot fix the
reset loop. Note that the ESP32-C6 uses the same key for both write-protect
registers; the C3 and S3 use 0x8F1D312A for the super watchdog, so a snippet
copied from those targets silently fails to unlock it here.

Register offsets and the key value are from ESP-IDF's soc/lp_wdt_reg.h and
esp_hal_wdt/esp32c6 lpwdt_ll.h. Verified by running the sequence against a
stubbed register block: CONFIG0 ends at 0, both write-protect registers are
re-locked, SWD_CONFIG has the disable and auto-feed bits set, and the key is no
longer written to SWD_CONFIG. Not yet verified on hardware.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The example assembled its own ThreadX library from two globs, and one of them
was a directory level short:

    ${CMAKE_CURRENT_SOURCE_DIR}/../../../../common/src/*.c

Four levels up from example_build/esp32c6 is ports/, so that pattern named
ports/common/src, which does not exist. file(GLOB) on a missing directory is
not an error -- it returns nothing -- so the library was built from the eight
port .S files and no kernel C source at all. Every ThreadX entry point the
example calls was therefore undefined at link time:
_tx_initialize_kernel_enter from startup.c, and tx_mutex_create, tx_mutex_get,
tx_mutex_put, tx_thread_create and tx_thread_sleep from demo_threadx.c. The
port root is five levels up, which is what core_v_mcu/CMakeLists.txt already
says.

The same hand-rolled library also left include/tx_user.h parsed by nothing.
TX_INCLUDE_USER_DEFINE_FILE is the #ifdef guarding the #include "tx_user.h" in
ports/risc-v32/common/tx_port_riscv32_common.h, and it appeared nowhere in the
example, so all four settings in that file silently fell back to the port
default -- TX_ENABLE_STACK_CHECKING was off and TX_MINIMUM_STACK was 1024
rather than the 512 the file asks for. The demo's 2048-byte stacks clear both
figures, so nothing failed loudly; the configuration was just not applied.

Both defects have one cause, so take the fix the other RISC-V examples already
use: set THREADX_ARCH, THREADX_TOOLCHAIN and TX_USER_FILE, then
add_subdirectory() the top-level CMakeLists and link the threadx target. The
top level copies TX_USER_FILE to custom_inc/tx_user.h and defines
TX_INCLUDE_USER_DEFINE_FILE as a PUBLIC property of that target, so linking it
is what makes the kernel and the example agree on one configuration. The BSP
object library links it too, for the headers and the macro rather than for any
archive.

Also drop ESP32C6_LINK_FLAGS, which was computed over two blocks and then
never referenced -- target_link_options() below it repeats the same three flags
directly.

Verified with cmake and riscv32 GCC: the kernel builds 194/194 and the six
symbols above are defined; TX_INCLUDE_USER_DEFINE_FILE and custom_inc reach the
kernel, the BSP, the demo and the assembly sources; and a _Static_assert on
TX_MINIMUM_STACK == 512 and TX_TIMER_TICKS_PER_SECOND == 100 plus an #ifdef on
TX_ENABLE_STACK_CHECKING all hold now and all fail without the change. The BSP
and the final image still need a real ESP-IDF and riscv32-esp-elf-gcc, neither
of which was available, so they are unbuilt here.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
@fdesbiens

Copy link
Copy Markdown
Contributor

Thanks for this, Ali. I've rebased the branch onto current dev and force-pushed, then added one commit. Here's the context.

Rebase

The branch was cut from master (44d7c95c) rather than dev, so it was 13 commits behind the base it targets. The rebase replayed all three commits with no conflicts and the diff is byte-identical to what you had — nothing in those 13 commits touches ports/risc-v32.

Worth knowing about two of them, since they now gate this PR for the first time:

One commit added: the example couldn't link

While checking the rebase I found the CMakeLists was building the kernel from a glob that was one directory level short:

file(GLOB THREADX_COMMON_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../../../../common/src/*.c")

Four levels up from example_build/esp32c6 is ports/, so that names ports/common/src, which doesn't exist. file(GLOB) on a missing directory isn't an error — it just returns nothing — so the threadx library was built from the eight port .S files and no kernel C source at all. I built the target as specified and checked it with nm: _tx_initialize_kernel_enter (from startup.c) plus tx_mutex_create, tx_mutex_get, tx_mutex_put, tx_thread_create and tx_thread_sleep (from demo_threadx.c) were all undefined. The port root is five levels up, which core_v_mcu/CMakeLists.txt already gets right.

The same hand-rolled library also left include/tx_user.h read by nothing. TX_INCLUDE_USER_DEFINE_FILE is the #ifdef guarding the #include "tx_user.h" in ports/risc-v32/common/tx_port_riscv32_common.h, and it appeared nowhere in the example — so all four settings in your file silently fell back to the port default. TX_ENABLE_STACK_CHECKING was off, and TX_MINIMUM_STACK was 1024 rather than the 512 you ask for. The demo's 2048-byte stacks clear both figures, so nothing failed loudly; the configuration just wasn't applied.

Both have one cause, so I took the fix the other RISC-V examples already use: set THREADX_ARCH / THREADX_TOOLCHAIN / TX_USER_FILE, then add_subdirectory() the top-level CMakeLists.txt and link the threadx target. The top level copies TX_USER_FILE into custom_inc/tx_user.h and defines TX_INCLUDE_USER_DEFINE_FILE as a PUBLIC property of that target, so linking it is what makes the kernel and the example agree on one configuration. I also dropped ESP32C6_LINK_FLAGS, which was computed over two blocks and then never referenced.

What I verified, and what I couldn't

With cmake and a RISC-V GCC, using your own toolchain file's flags:

  • Kernel builds 194/194 clean; all six symbols above are now defined.
  • TX_INCLUDE_USER_DEFINE_FILE and custom_inc reach the kernel, the BSP, the demo and the assembly sources.
  • A _Static_assert on TX_MINIMUM_STACK == 512 and TX_TIMER_TICKS_PER_SECOND == 100, plus an #ifdef on TX_ENABLE_STACK_CHECKING, all hold now and all fail without the change.
  • scripts/check_ports.sh passes.

I could not build the BSP or the image — no ESP-IDF and no riscv32-esp-elf-gcc on my machine, so esp32c6_bsp stops at esp32c6_example.h:22: fatal error: soc/soc.h. The build wiring is verified; the ESP-IDF-dependent compile and the elf2image step are not. Could you re-run the full build on your setup and confirm? That's the one thing I can't check from here.

Two things for you

  1. The tick issue. Since the example couldn't link as committed, the _tx_thread_schedule_loop symptom in the description can't have come from this tree — presumably you were building through another route earlier. Worth re-confirming it against a build that actually links before spending more time on systimer.c; it's possible the picture changes. (Note stack checking is genuinely on now, which it wasn't before.)
  2. threadx_ESP32C6_native_WIFI_plan.md is 1243 lines — over half the diff — and reads as a planning document rather than part of the example. I'd rather not carry it in the port tree; could you drop it from the branch? Happy for it to live in the PR description or an issue instead.

No objection to the overall shape of the contribution — a bare-metal C6 bring-up is a welcome addition, and the NOTICE.md attribution work is appreciated. Let's get the build confirmed on hardware-capable tooling and then look at the tick.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants