Skip to content

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

Open
alieissa-commits wants to merge 3 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 3 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.

alieissa-commits and others added 3 commits July 31, 2026 19:37
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>
@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.

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