Skip to content

emb:esp32c3:implement _write by jtag output#1440

Merged
xushiwei merged 6 commits intogoplus:mainfrom
luoliwoshang:fix/esp32c3jtagout
Dec 17, 2025
Merged

emb:esp32c3:implement _write by jtag output#1440
xushiwei merged 6 commits intogoplus:mainfrom
luoliwoshang:fix/esp32c3jtagout

Conversation

@luoliwoshang
Copy link
Member

@luoliwoshang luoliwoshang commented Dec 5, 2025

part of #1427

ESP32-C3 USB Serial JTAG Output Support

Overview

Implements USB Serial JTAG output support for ESP32-C3, enabling printf() and other standard output functions to work in bare-metal applications without external UART connections.

Why USB Serial/JTAG Instead of UART?

Core reason: All ESP32-C3 chips have built-in USB Serial/JTAG, but not all development boards have USB-UART converter chips!

According to ESP-IDF USB Serial/JTAG Controller Documentation:

"Generally, ESP chips implement a serial port using UART and can be connected to a serial console emulator on a host/PC via an external USB-UART bridge chip. However, on ESP chips that contain a USB Serial/JTAG Controller, the CDC-ACM portion of the controller implements a serial port that is connected directly to a host/PC, thus does not require an external USB-UART bridge chip."

Many low-cost development boards (e.g., XIAO ESP32C3, SuperMini) do not include external USB-UART chips (CH340/CP2102) to save costs. Their USB ports connect directly to the chip's built-in USB Serial/JTAG (GPIO18/19). If UART output were used, these boards would have no serial output at all.

TinyGo encountered the same issue in Issue #3631 and resolved it via PR #4011 by changing the default serial from UART to USB Serial/JTAG:

// targets/esp32c3.json
- "serial": "uart",
+ "serial": "usb",

Source: TinyGo commit d7c77b67

Key Features

  • USB Serial JTAG Output - Implemented via _write() system call
  • Bare-Metal Implementation - No FreeRTOS or driver layer required
  • Non-Blocking - Discards characters when FIFO is full, never hangs
  • Automatic CRLF Conversion - \n automatically converted to \r\n
  • Direct Register Access - Based on ESP-IDF HAL layer, but without full framework

Implementation Sources

This implementation is based on official code from ESP-IDF v6.0, streamlined and adapted:

1. USB Serial JTAG Register Structure

Source: components/soc/esp32c3/register/soc/usb_serial_jtag_struct.h

Defines the register layout for USB Serial JTAG peripheral, including:

  • ep1 - TX/RX FIFO data register
  • ep1_conf - FIFO status and control register
  • Other control and status registers

Mapped to hardware address 0x60043000.

2. HAL Layer Low-Level Functions

Source: hal/esp32c3/include/hal/usb_serial_jtag_ll.h

Implements three key inline functions:

  • usb_serial_jtag_ll_txfifo_flush() - Flush TX FIFO
  • usb_serial_jtag_ll_txfifo_writable() - Check if TX FIFO has space
  • usb_serial_jtag_ll_write_txfifo() - Write data to TX FIFO

3. System Call Implementation

Source: Referenced from esp_driver_usb_serial_jtag/src/usb_serial_jtag_vfs.c

Implements _write() system call to redirect standard output to USB Serial JTAG. Main features:

  • Supports stdout and stderr
  • Non-blocking write (discards on FIFO full)
  • Automatic LF → CRLF conversion

Code Changes

1. Memory Layout (targets/esp32c3.memory.ld)

Added USB Serial JTAG peripheral base address:

PROVIDE ( USB_SERIAL_JTAG = 0x60043000 );

2. Device Implementation (targets/device/esp/esp32c3.c)

Complete USB Serial JTAG driver implementation (~400 lines), including:

  • Structure definitions (from ESP-IDF SOC layer)
  • HAL layer functions (from ESP-IDF HAL layer)
  • _write() system call (referenced from ESP-IDF VFS layer)

Dependencies

Required: goplus/newlib#9

Newlib PR #9 declares _write() as a weak symbol, allowing us to provide a custom implementation:

// libgloss/riscv/esp/syscalls.c
__attribute__((weak))
ssize_t _write(int file, const char *ptr, size_t len) {
    // Default UART implementation (can be overridden)
}

During linking:

esp32c3.o: _write (strong symbol)  ← Our JTAG implementation (wins)
syscalls.o: _write (weak symbol)   ← Newlib's UART implementation (ignored)

Usage Example

package main

import "github.com/goplus/lib/c"

func main() {
    c.Printf(c.Str("Hello from ESP32-C3 via USB Serial JTAG!\n"))
}

Build and run (automatic flash + monitor):

llgo run -a -target esp32c3 .

Expected output (displayed directly via USB cable):

ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
rst:0x7 (TG0WDT_SYS_RST),boot:0xd (SPI_FAST_FLASH_BOOT)
Saved PC:0x40052b02
SPIWP:0xee
mode:DIO, clock div:1
load:0x3fc855e0,len:0xfc
load:0x3fc856dc,len:0x4
load:0x3fc856e0,len:0x44
load:0x40380000,len:0x1578
load:0x40381578,len:0x68
entry 0x40380000
Hello from ESP32-C3 via USB Serial JTAG!

Implementation Notes

Why Not Use ESP-IDF Directly?

  1. Code Size: ESP-IDF framework is large, containing many unnecessary features
  2. Complex Dependencies: Requires FreeRTOS, driver layer, VFS layer, etc.
  3. Startup Overhead: Long initialization time, unsuitable for simple applications
  4. Bare-Metal Target: llgo's embedded targets prioritize lightweight and direct control

Our Approach

Extract Core: Only extract register definitions and key functions
Simplified Implementation: Remove VFS layer, interrupts, DMA, and other complex features
Maintain Compatibility: Register operations fully compatible with ESP-IDF
Document Sources: Code clearly annotated with sources for easy maintenance and updates

Related Links

ESP-IDF Official Documentation

ESP-IDF Source Code References

Dependency PR


Commit History

This PR includes the following commits:

  1. emb:esp32c3:USB_SERIAL_JTAG load - Add peripheral base address
  2. emb:esp32c3:implement _write by jtag output - Implement complete driver
  3. build: upgrade newlib-esp32 from patch3 to patch4 - Upgrade Newlib with weak symbol support
  4. emb:esp32c3:add source reference comments - Add source annotations

Fix Type: Feature
Priority: Medium (improves ESP32-C3 development experience)
Backward Compatible: Yes (does not affect existing code)


🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com

@gemini-code-assist
Copy link

Summary of Changes

Hello @luoliwoshang, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly improves the debugging and output capabilities for bare-metal ESP32-C3 applications by integrating _write system call support through the USB Serial JTAG. It also refines the underlying RISC-V target configurations, adopting a more structured inheritance model and ensuring critical initialization sections are correctly handled during the build process. These changes are validated by a new regression test, providing a more robust and functional environment for ESP32-C3 development.

Highlights

  • USB Serial JTAG Output for ESP32-C3: Implemented the _write system call for ESP32-C3, enabling stdout and stderr output via the USB Serial JTAG interface for bare-metal applications.
  • Linker Script Enhancements: Modified the ESP32-C3 linker script to correctly merge .preinit_array, .init_array, and .fini_array sections into .rodata, ensuring proper initialization array handling during ELF to BIN conversion, aligning with ESP-IDF's approach.
  • RISC-V Target Refactoring: Introduced new modular RISC-V target configurations (riscv-basic.json, riscv-nostart.json, riscv32-nostart.json) to streamline inheritance and improve maintainability across RISC-V targets.
  • Startup Regression Test: Added a comprehensive shell script (_demo/embed/test_esp32c3_startup.sh) to verify the ESP32-C3's newlib startup, __libc_init_array calls, .init_array merging, and .rodata inclusion in the final binary.
Ignored Files
  • Ignored by pattern: .github/workflows/** (1)
    • .github/workflows/llgo.yml
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the _write syscall for the ESP32-C3 target to allow output via JTAG, which is a great addition for debugging. The changes include a new C file with the _write implementation, updates to linker scripts to ensure correct section mapping, and a new test script to validate these changes. Additionally, there's a nice refactoring of the RISC-V target configuration files, improving maintainability.

My review focuses on the new C implementation. I've suggested a couple of improvements:

  • A type-safety improvement in a low-level write function.
  • A correctness fix in _write to handle file descriptors as described in its documentation.

Overall, this is a solid contribution. The changes are well-structured and the inclusion of a test script is excellent.

* @return Number of bytes written, or -1 on error
*/
ssize_t _write(int fd, const void *buf, size_t count) {
const char *data = (const char *)buf;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The function's documentation comment states that it only supports stdout (1) and stderr (2) and should return an error for other file descriptors. However, the current implementation doesn't check the fd value, causing it to write to the JTAG serial for any file descriptor. This contradicts the documentation and could lead to unexpected behavior. Please add a check to handle unsupported file descriptors.

    if (fd != 1 && fd != 2) {
        return -1;
    }
    const char *data = (const char *)buf;

Comment on lines +326 to +334
static inline int usb_serial_jtag_ll_write_txfifo(const uint8_t *buf, uint32_t wr_len) {
int i;
for (i = 0; i < (int)wr_len; i++) {
if (!USB_SERIAL_JTAG.ep1_conf.serial_in_ep_data_free)
break;
USB_SERIAL_JTAG.ep1.rdwr_byte = buf[i];
}
return i;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The loop counter i is an int while the length wr_len is a uint32_t. Casting wr_len to int could lead to issues if wr_len exceeds INT_MAX. It's safer to use uint32_t for the loop counter and the return type to maintain type consistency and avoid potential overflow problems, even if large writes are unlikely with the current FIFO size.

Suggested change
static inline int usb_serial_jtag_ll_write_txfifo(const uint8_t *buf, uint32_t wr_len) {
int i;
for (i = 0; i < (int)wr_len; i++) {
if (!USB_SERIAL_JTAG.ep1_conf.serial_in_ep_data_free)
break;
USB_SERIAL_JTAG.ep1.rdwr_byte = buf[i];
}
return i;
}
static inline uint32_t usb_serial_jtag_ll_write_txfifo(const uint8_t *buf, uint32_t wr_len) {
uint32_t i;
for (i = 0; i < wr_len; i++) {
if (!USB_SERIAL_JTAG.ep1_conf.serial_in_ep_data_free) {
break;
}
USB_SERIAL_JTAG.ep1.rdwr_byte = buf[i];
}
return i;
}

* @param count Number of bytes to write
* @return Number of bytes written, or -1 on error
*/
ssize_t _write(int fd, const void *buf, size_t count) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security/Correctness] Missing file descriptor validation

The documentation states "only stdout (1) and stderr (2) are supported" but the code doesn't validate the fd parameter. Any file descriptor (including 0, -1, or invalid values) will write to JTAG output.

Impact:

  • Violates documented API contract
  • Could cause information disclosure if code attempts to write to other file descriptors expecting failure
  • Breaks POSIX compliance

Recommendation:

ssize_t _write(int fd, const void *buf, size_t count) {
    // Only stdout (1) and stderr (2) are supported
    if (fd != 1 && fd != 2) {
        return -1;  // EBADF: Bad file descriptor
    }
    
    const char *data = (const char *)buf;
    // ... rest of implementation

* @return Number of bytes written, or -1 on error
*/
ssize_t _write(int fd, const void *buf, size_t count) {
const char *data = (const char *)buf;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] Missing NULL pointer check

No validation of buf before dereferencing. In bare-metal systems without MMU, _write(1, NULL, 10) could cause undefined behavior or crash.

Recommendation:

if (buf == NULL && count > 0) {
    return -1;  // EFAULT: Bad address
}

* @param fd File descriptor (only 1 and 2 supported)
* @param buf Data buffer to write
* @param count Number of bytes to write
* @return Number of bytes written, or -1 on error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Documentation] Inaccurate return value documentation

The documentation says "or -1 on error" but the implementation always returns count and never returns -1. This misleads developers about error handling.

Also, the function always reports success even when characters are dropped due to FIFO full (non-blocking behavior).

Recommendation: Update documentation to:

 * @return Always returns count (number of bytes requested).
 *         NOTE: Due to non-blocking FIFO behavior, some characters may be dropped
 *         if the FIFO is full. The return value does NOT guarantee transmission.

*/
static void usb_serial_jtag_write_char(char c) {
// Check if TX FIFO has space
if (usb_serial_jtag_ll_txfifo_writable()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Performance] Redundant FIFO check

The outer if check at line 350 is redundant since usb_serial_jtag_ll_write_txfifo() already checks FIFO availability internally (line 329). This causes 2 volatile register reads per character.

Impact: ~5-10% performance overhead on write operations.

Recommendation: Remove the outer check or inline the register write:

static void usb_serial_jtag_write_char(char c) {
    if (usb_serial_jtag_ll_txfifo_writable()) {
        USB_SERIAL_JTAG.ep1.rdwr_byte = (uint8_t)c;  // Direct write
        
        if (c == '\n') {
            usb_serial_jtag_ll_txfifo_flush();
        }
    }
}

*
* SPDX-License-Identifier: Apache-2.0
*/
#ifndef _SOC_USB_SERIAL_JTAG_STRUCT_H_
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code Quality] Unnecessary header guard in .c file

This entire C file is wrapped in #ifndef _SOC_USB_SERIAL_JTAG_STRUCT_H_ header guards (lines 6-394). Header guards are only needed for .h files that might be included multiple times, not for .c files.

Recommendation: Remove the header guards or extract the struct definitions to a proper .h file if they need to be shared.

@xgopilot
Copy link
Contributor

xgopilot bot commented Dec 5, 2025

Code Review Summary

This PR implements USB Serial JTAG output for ESP32-C3 with comprehensive testing. The implementation is functional and demonstrates solid embedded systems knowledge. However, several issues need addressing:

Critical Issues (Must Fix):

  • Missing file descriptor validation in _write() - violates documented API contract
  • Missing NULL pointer check - could cause crashes in bare-metal environment
  • Documentation claims function returns -1 on error, but it never does

Performance & Quality:

  • Redundant FIFO checks waste ~5-10% on write operations
  • Unnecessary header guards in .c file

Strengths:

  • Excellent test coverage with comprehensive validation script
  • Well-documented hardware register definitions
  • Proper linker script changes for .init_array handling

Please address the critical issues before merging.

@codecov
Copy link

codecov bot commented Dec 5, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.58%. Comparing base (5627fb3) to head (fb79304).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1440   +/-   ##
=======================================
  Coverage   90.58%   90.58%           
=======================================
  Files          43       43           
  Lines       11429    11429           
=======================================
  Hits        10353    10353           
  Misses        914      914           
  Partials      162      162           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

luoliwoshang added a commit to luoliwoshang/llgo that referenced this pull request Dec 9, 2025
This commit fixes the illegal instruction crash issue for ESP32-C3 and
improves the architecture to support all ESP RISC-V chips through a
proper llvm-target based approach.

## Problem

1. ESP32-C3 only supports RV32IMC (no A/D/F extensions)
2. Previous fix used hardcoded `targetName == "esp32c3"` check, which
   doesn't work for inherited targets (esp32c3-supermini, m5stamp-c3, etc.)
3. compiler-rt's atomic.c was included for all riscv32 targets, causing
   compilation failure on ESP chips (requires A extension)

## Solution

### 1. Create ESP RISC-V base configuration

- Rename: `riscv32-nostart.json` → `riscv32-esp.json`
- Change `llvm-target` from `riscv32-unknown-none` to `riscv32-esp-elf`
- Change `cflags` from `-march=rv32imac` to `-march=rv32imc`

### 2. Update ESP32-C3 inheritance

- Update `targets/esp32c3.json` to inherit from `riscv32-esp`
- Automatically applies to all boards inheriting esp32c3:
  - esp32c3-supermini
  - m5stamp-c3
  - esp32-c3-devkit-rust-1
  - esp-c3-32s-kit
  - esp32c3-12f

### 3. Fix compiler-rt atomic.c inclusion

- Exclude atomic.c for ESP targets (riscv32-esp-elf)
- Keep atomic.c for other riscv32 targets that support A extension

### 4. Use llvm-target instead of targetName

- Check `config.LLVMTarget == "riscv32-esp-elf"` instead of hardcoded name
- Properly supports inheritance relationships
- ESP series: `-march=rv32imc`
- Other riscv32: `-march=rv32imac`

## Benefits

✅ Fixes illegal instruction crash (C.FLD) for all ESP32-C3 boards
✅ Fixes compiler-rt atomic.c compilation error
✅ Supports inheritance - all ESP32-C3 variants automatically fixed
✅ Follows ESP-IDF conventions (riscv32-esp-elf is official target triple)
✅ Scalable - future ESP RISC-V chips just inherit riscv32-esp.json
✅ Backward compatible - other riscv32 targets unchanged

## Files Changed

- targets/riscv32-nostart.json → targets/riscv32-esp.json (rename + modify)
- targets/esp32c3.json (update inherits)
- internal/crosscompile/compile/rtlib/compiler_rt.go (exclude atomic.c for ESP)
- internal/crosscompile/crosscompile.go (use LLVMTarget instead of targetName)

Fixes: Issue goplus#1427
Related: PR goplus#1440

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@luoliwoshang luoliwoshang force-pushed the fix/esp32c3jtagout branch 2 times, most recently from 76201f4 to 7865382 Compare December 9, 2025 11:09
@luoliwoshang luoliwoshang mentioned this pull request Dec 9, 2025
4 tasks
@cpunion
Copy link
Collaborator

cpunion commented Dec 10, 2025

  • _write is non-blocking and drops on full. The USB Serial JTAG TX FIFO is only 64B and the UART TX FIFO is ~128B on ESP32-C3, so long/bursty prints will be lost when those buffers fill.
  • Make the console sink pluggable (JTAG / UART / noop / custom). Allow overriding from Go so users can route output elsewhere (e.g., LCD) instead of being hardwired to JTAG.

@luoliwoshang
Copy link
Member Author

Make the console sink pluggable (JTAG / UART / noop / custom). Allow overriding from Go so users can route output elsewhere (e.g., LCD) instead of being hardwired to JTAG.

If the _write implementation in the current targets/device/esp/esp32c3.c is made a weak symbol, under the current linking rules, it will actually use the _write symbol from newlib. By "overriding," do you mean implementing it as a weak symbol?

*/
static void usb_serial_jtag_write_char(char c) {
// Check if TX FIFO has space
if (usb_serial_jtag_ll_txfifo_writable()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems it's not a good implement, when the JTAG buffer is full, we should wait until available, that's we called blocked write

Copy link
Contributor

@MeteorsLiu MeteorsLiu Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, in the future, we may support non-blocking way to implement it. See #1445

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_write is non-blocking and drops on full. The USB Serial JTAG TX FIFO is only 64B and the UART TX FIFO is ~128B on ESP32-C3, so long/bursty prints will be lost when those buffers fill.

@cpunion

In the ESP-IDF implementation, it will wait 50 milliseconds before choosing to discard the data. Perhaps here, to ensure data is not lost, we could keep blocking and waiting for usb_serial_jtag_ll_txfifo_writable()?

static void usb_serial_jtag_tx_char_no_driver(int fd, int c)
{
    uint8_t cc = (uint8_t)c;
    // Try to write to the buffer as long as we still expect the buffer to have
    // a chance of being emptied by an active host. Just drop the data if there's
    // no chance anymore.
    // When we first try to send a character and the buffer is not accessible yet,
    // we wait until the time has been more than TX_FLUSH_TIMEOUT_US since we successfully
    // sent the last byte. If it takes longer than TX_FLUSH_TIMEOUT_US, we drop every
    // byte until the buffer can be accessible again.
    do {
        if (usb_serial_jtag_ll_txfifo_writable()) {
            usb_serial_jtag_ll_write_txfifo(&cc, 1);
            if (c == '\n') {
                //Make sure line doesn't linger in fifo
                usb_serial_jtag_ll_txfifo_flush();
            }
            //update time of last successful tx to now.
            s_ctx.last_tx_ts = esp_timer_get_time();
            break;
        }
    } while ((esp_timer_get_time() - s_ctx.last_tx_ts) < TX_FLUSH_TIMEOUT_US);

}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current implement as blocking b45b56c

Change _write() implementation from non-blocking to blocking mode.
Now waits until TX FIFO has space before writing instead of dropping
characters when FIFO is full.
@luoliwoshang
Copy link
Member Author

@xushiwei

@xushiwei xushiwei merged commit c7c6e9c into goplus:main Dec 17, 2025
41 checks passed
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.

4 participants