Skip to content

fix(esp32c3): use newlib startup and merge init_array to rodata for println support#1435

Merged
xushiwei merged 7 commits intogoplus:mainfrom
luoliwoshang:fix/esp32c3println
Dec 6, 2025
Merged

fix(esp32c3): use newlib startup and merge init_array to rodata for println support#1435
xushiwei merged 7 commits intogoplus:mainfrom
luoliwoshang:fix/esp32c3println

Conversation

@luoliwoshang
Copy link
Member

@luoliwoshang luoliwoshang commented Dec 3, 2025

part of #1438
part of #1427

Issue 1: Not Using newlib's Startup File

Background

Currently, targets/riscv.json contains TinyGo's startup files start.S and handleinterrupt.S, which affect all RISC-V chips (including ESP32-C3) through the inheritance mechanism.

ESP32-C3 requires newlib's standard startup process (crt0-riscv32-unknown-none). This startup file calls __libc_init_array to execute C library constructors (such as board_init()), but it is currently overridden by TinyGo's start.S, resulting in incomplete C library initialization.

Solution: Isolate Startup Files via Inheritance Chain

Core Approach

  1. Create riscv-basic.json: Pure configuration base class without extra-files
  2. TinyGo Path: riscv.json inherits riscv-basic and adds startup files (start.S)
  3. LLGo Path: riscv-nostart.json inherits riscv-basic (no startup files)
  4. ESP32-C3: Inherits riscv32-nostart, will not include TinyGo's start.S

Inheritance Relationship Diagram

graph TD
    A[riscv-basic.json<br/>Pure config, no extra-files] --> B[riscv.json<br/>+ start.S<br/>+ handleinterrupt.S]
    A --> C[riscv-nostart.json<br/>no extra-files<br/>no startup files]

    B --> D[riscv32.json<br/>TinyGo 32-bit config]
    C --> E[riscv32-nostart.json<br/>LLGo 32-bit config<br/>no startup files]

    D --> F[Other TinyGo RISC-V chips<br/>auto get start.S]
    E --> G[esp32c3.json<br/>no start.S]
Loading

File Modification Checklist

1. Create targets/riscv-basic.json

Copy all configurations from riscv.json, remove extra-files field:

{
	"goos": "linux",
	"goarch": "arm",
	"build-tags": ["tinygo.riscv", "baremetal", "linux", "arm"],
	"gc": "conservative",
	"linker": "ld.lld",
	"rtlib": "compiler-rt",
	"libc": "picolibc",
	"cflags": [
		"-Werror",
		"-mno-relax",
		"-fno-exceptions", "-fno-unwind-tables", "-fno-asynchronous-unwind-tables",
		"-ffunction-sections", "-fdata-sections"
	],
	"ldflags": [
		"--gc-sections"
	],
	"gdb": ["riscv64-unknown-elf-gdb"]
}
2. Modify targets/riscv.json

Inherit riscv-basic, keep startup files:

{
	"inherits": ["riscv-basic"],
	"extra-files": [
		"targets/device/riscv/start.S",
		"targets/device/riscv/handleinterrupt.S"
	]
}
3. Create targets/riscv-nostart.json

Inherit riscv-basic, no startup files:

{
	"inherits": ["riscv-basic"]
}
4. Create targets/riscv32-nostart.json

Copy content from riscv32.json, change inheritance to riscv-nostart:

{
	"inherits": ["riscv-nostart"],
	"llvm-target": "riscv32-unknown-none",
	"cpu": "generic-rv32",
	"target-abi": "ilp32",
	"build-tags": ["tinygo.riscv32"],
	"scheduler": "tasks",
	"default-stack-size": 2048,
	"cflags": [
		"-march=rv32imac"
	],
	"ldflags": [
		"-melf32lriscv"
	],
	"gdb": [
		"gdb-multiarch",
		"gdb"
	]
}
5. Modify targets/esp32c3.json

Change inherits field:

{
	"inherits": [
		"riscv32-nostart"
	],
	// ... other configurations remain unchanged
}

Comparison of Effects

TinyGo Path (inherit riscv32)
TinyGo chip → riscv32 → riscv → riscv-basic
                           ↑
                       has start.S
                       has handleinterrupt.S

Build Tags: tinygo.riscv, tinygo.riscv32, ...

LLGo Path (inherit riscv32-nostart)
esp32c3 → riscv32-nostart → riscv-nostart → riscv-basic
                               ↑
                           no start.S

Build Tags: tinygo.riscv, tinygo.riscv32, esp32c3, esp

Issue 2: .init_array Section Lost During Firmware Conversion

Background

After using newlib's crt0 startup file, although __libc_init_array is called, the constructor array .init_array section is lost during the ELF → BIN conversion process when flashing ESP32-C3 firmware, causing the program to crash on startup.

Problem Analysis

Boot Log

Segments loaded during ESP32-C3 startup:

load:0x40380000,len:0xe000    ← .text section
load:0x4038e000,len:0xd50      ← .rodata section
entry 0x40380000

Last IRAM segment end address:

0x4038e000 + 0xd50 = 0x4038ed50

ELF File Analysis

Check ELF section table:

$ llvm-readelf -S test.elf
[Nr] Name              Type        Address    Off    Size   ES Flg Lk Inf Al
[ 4] .init_array       INIT_ARRAY  4038ed50   00fd50 000004 00 WA  0   0  4

Key Finding:

  • .init_array section address: 0x4038ed50 (exactly at the end of the last LOAD segment!)
  • Section size: 4 bytes (one function pointer)
  • Section type: INIT_ARRAY (14)

Check .init_array section content:

$ llvm-objdump -s -j .init_array test.elf
Contents of section .init_array:
 4038ed50 c0003840                             ..8@

Little-endian decoding: c0 00 38 400x403800c0 (board_init function address)

BIN File Comparison

$ esptool.py --chip esp32c3 image_info test.bin
Segment 5: len 0x00d50 load 0x4038e000 [IRAM]

Segment 5 end address: 0x4038e000 + 0xd50 = 0x4038ed50

Conclusion: The .init_array section (4 bytes starting at 0x4038ed50) is NOT included in the BIN file!

Crash Reason

Register Value Meaning
PC 0xa7997b50 Address to execute (garbage data)
S0 0x4038ed50 __init_array_start address
A0 0xa7997b50 Value read from 0x4038ed50 (garbage)

Crash Flow:

  1. __libc_init_array reads value at __init_array_start (0x4038ed50)
  2. Expects to read board_init address (0x403800c0)
  3. Actually reads garbage data (0xa7997b50) - memory not initialized
  4. Tries to jump to 0xa7997b50 → triggers illegal instruction exception

Root Cause

ESP32-C3's firmware conversion tool (internal/build/esp.go:makeESPFirmareImage) only extracts sections of type SHT_PROGBITS:

// Only process PROGBITS type sections
if sec.Type != elf.SHT_PROGBITS {
    continue
}

The .init_array section type is SHT_INIT_ARRAY (14), not SHT_PROGBITS (1), so it's skipped.

Since the .init_array section is only 4 bytes and located exactly at the end of the last LOAD segment, it was omitted during conversion.

Solution: Merge .init_array into .rodata Section

Following ESP-IDF's approach, modify linker script targets/esp32-riscv.app.elf.ld:

Before (separate sections):

.rodata : {
    *(.rodata .rodata.*)
} > iram_seg

.init_array : {
    PROVIDE_HIDDEN (__init_array_start = .);
    KEEP (*(.init_array))
    PROVIDE_HIDDEN (__init_array_end = .);
} > iram_seg

After (merged into .rodata):

.rodata : {
    *(.rodata .rodata.*)

    /* Merge constructor arrays into rodata */
    . = ALIGN(4);
    PROVIDE_HIDDEN (__preinit_array_start = .);
    KEEP (*(.preinit_array))
    PROVIDE_HIDDEN (__preinit_array_end = .);

    . = ALIGN(4);
    PROVIDE_HIDDEN (__init_array_start = .);
    KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*)))
    KEEP (*(.init_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .ctors))
    PROVIDE_HIDDEN (__init_array_end = .);

    . = ALIGN(4);
    PROVIDE_HIDDEN (__fini_array_start = .);
    KEEP (*(SORT_BY_INIT_PRIORITY(.fini_array.*) SORT_BY_INIT_PRIORITY(.dtors.*)))
    KEEP (*(.fini_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .dtors))
    PROVIDE_HIDDEN (__fini_array_end = .);
} > iram_seg

Why It Works

  1. Correct Type: .rodata section type is SHT_PROGBITS, which will be extracted by esp.go
  2. Content Embedded: .init_array content is embedded into .rodata section
  3. Symbols Preserved: Linker symbols __init_array_start/end still point to correct locations
  4. Complete BIN: BIN file contains complete constructor pointer data

Implementation Commit

Commit: 765ab4b9

Modified file: targets/esp32-riscv.app.elf.ld

After recompilation, segmentation fault disappeared and board_init() executes successfully!

@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 focuses on integrating newlibc's startup procedures for ESP32 RISC-V targets. It streamlines the build process by removing redundant custom startup assembly files and adjusts the linker script to correctly manage memory sections like initialization arrays and the stack, aligning with the newlibc's requirements. Additionally, it ensures immediate I/O response on baremetal systems by disabling standard stream buffering and refines the RISC-V 32-bit architecture flags for optimal compilation.

Highlights

  • Newlibc Startup Integration: Removed custom start.S and handleinterrupt.S files for RISC-V targets, indicating a transition to using the newlibc's (picolibc) provided startup routines for ESP32 RISC-V.
  • Linker Script Refinements: Modified the esp32-riscv.app.elf.ld linker script to reorder initialization arrays (.preinit_array, .init_array, .fini_array) and relocate the .stack section, ensuring proper memory layout and alignment for the newlibc's startup process.
  • Baremetal I/O Buffering Control: Implemented a mechanism to disable buffering for Stdin and Stdout on baremetal targets, ensuring immediate input/output operations which is crucial for embedded system debugging and real-time interaction.
  • RISC-V Architecture Flag Adjustment: Updated the riscv32 architecture flag from rv32imac to rv32imc in the cross-compilation settings, potentially optimizing or correcting the instruction set extensions used for RISC-V 32-bit compilation.
  • Build Process Enhancement: Added a linker flag to generate a map file (-Map=111.map) during the build process, which can be used for detailed analysis of the final executable's memory layout and symbol addresses.
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.

@luoliwoshang luoliwoshang changed the title fix:esp32 use the newlibc's start fix:esp32 use the newlibc's start & init_array to rodata avoid init fail Dec 3, 2025
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 updates the build process for ESP32 targets to use newlibc's _start function. The changes include removing the custom start.S, adjusting the riscv32 architecture flags, modifying the ESP32-RISCV linker script, and disabling stdio buffering for baremetal targets.

Overall, the changes seem to align with the goal of the pull request. However, I've identified a couple of issues that should be addressed:

  • A hardcoded map file name has been added in the build process, which appears to be a leftover from debugging.
  • A change to the generic riscv32 architecture flags might be too broad and could affect other targets. It would be better to make this change more specific to the intended ESP32 target.

Details are in the specific comments.

}

buildArgs := []string{"-o", app}
buildArgs = append(buildArgs, "-Map=111.map")

Choose a reason for hiding this comment

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

high

The map file name is hardcoded to 111.map. This looks like a debugging artifact that should not be in production code. A hardcoded name can cause race conditions if builds are run in parallel, and it is not descriptive. It would be better to derive the map file name from the application name, for example app + ".map".

Suggested change
buildArgs = append(buildArgs, "-Map=111.map")
buildArgs = append(buildArgs, "-Map="+app+".map")

ccflags = append(ccflags, "-mdouble=64")
case "riscv32":
ccflags = append(ccflags, "-march=rv32imac", "-fforce-enable-int128")
ccflags = append(ccflags, "-march=rv32imc", "-fforce-enable-int128")

Choose a reason for hiding this comment

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

high

Changing -march=rv32imac to rv32imc for the generic riscv32 architecture case removes support for atomic instructions ('A' extension). While this is correct for some ESP32 chips like the ESP32-C3 (which is RV32IMC), other RISC-V chips, including some ESP32 variants like the ESP32-C6 (RV32IMAC), do support atomics. This general change could break builds for other riscv32 targets that rely on atomic instructions.

It would be safer to make this change specific to the target that requires it (e.g., in a target JSON file) rather than changing the generic riscv32 case.

}

buildArgs := []string{"-o", app}
buildArgs = append(buildArgs, "-Map=111.map")
Copy link
Contributor

Choose a reason for hiding this comment

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

Critical Issue: Hardcoded debug artifact

The hardcoded filename "111.map" appears to be a temporary debugging artifact that should not be in production code. This causes several problems:

  1. Concurrency issues: Parallel builds will overwrite the same file
  2. Security concern: Linker map files contain sensitive memory layout and symbol information
  3. Disk waste: Map files accumulate without cleanup
  4. No configurability: Users cannot disable or customize this

Recommendation: Either remove this line entirely, or make it configurable:

// Generate map file only if explicitly requested
if ctx.buildConf.GenerateMapFile {
    mapFile := strings.TrimSuffix(app, filepath.Ext(app)) + ".map"
    buildArgs = append(buildArgs, fmt.Sprintf("-Map=%s", mapFile))
}

ccflags = append(ccflags, "-mdouble=64")
case "riscv32":
ccflags = append(ccflags, "-march=rv32imac", "-fforce-enable-int128")
ccflags = append(ccflags, "-march=rv32imc", "-fforce-enable-int128")
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing documentation for architectural flag

The -fforce-enable-int128 flag is added without explanation. Please add a comment explaining:

  • Why int128 support needs to be forced on riscv32
  • What Go runtime requirements this addresses
  • Performance implications (software emulation on 32-bit arch)

Example:

case "riscv32":
    // Force enable int128 support on 32-bit RISC-V to match Go's type system requirements
    // even though native support is limited. This requires software emulation and may
    // have performance overhead. Required for Go runtime compatibility.
    ccflags = append(ccflags, "-march=rv32imc", "-fforce-enable-int128")


func init() {
// Disable buffering for baremetal targets to ensure immediate output
setvbuf(Stdout, nil, _IONBF, 0)
Copy link
Contributor

Choose a reason for hiding this comment

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

Performance Impact: Unbuffered I/O

Disabling buffering with _IONBF will cause every write to trigger an immediate I/O operation, which can severely degrade performance (10-100x slower for frequent small writes). While this ensures immediate output on baremetal targets, consider:

  1. Adding nil checks in case Fopen fails:
if Stdout != nil {
    setvbuf(Stdout, nil, _IONBF, 0)
}
if Stdin != nil {
    setvbuf(Stdin, nil, _IONBF, 0)
}
  1. Alternative: Use line-buffering (_IOLBF = 1) as a middle ground - flushes on newlines while still buffering within lines

  2. Make it configurable via environment variable if the performance trade-off is significant for some use cases

@xgopilot
Copy link
Contributor

xgopilot bot commented Dec 3, 2025

Code Review Summary

Reviewed changes for baremetal/newlibc improvements. Key findings:

Critical: Remove or fix hardcoded "111.map" in internal/build/build.go:818 - appears to be debug code.

Important: Add documentation for -fforce-enable-int128 flag explaining why it's needed for riscv32.

Performance Note: Disabling I/O buffering in baremetal stdio will impact I/O-heavy applications. Consider making configurable or using line-buffering.

Otherwise, the linker script updates and target configuration changes look appropriate for ESP32 RISC-V support.

@luoliwoshang luoliwoshang marked this pull request as draft December 3, 2025 08:23
@codecov
Copy link

codecov bot commented Dec 4, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.63%. Comparing base (b62b732) to head (0f559dc).
⚠️ Report is 53 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1435      +/-   ##
==========================================
+ Coverage   90.59%   90.63%   +0.03%     
==========================================
  Files          43       43              
  Lines       11400    11400              
==========================================
+ Hits        10328    10332       +4     
+ Misses        911      907       -4     
  Partials      161      161              

☔ 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 and others added 2 commits December 4, 2025 16:06
Separate TinyGo and LLGo RISC-V targets to avoid startup file conflicts:

- Create riscv-basic.json as pure config base (no extra-files)
- Refactor riscv.json to inherit riscv-basic with target_tinygo tag
- Create riscv-llgo.json inheriting riscv-basic with target_llgo tag
- Create riscv32-llgo.json inheriting riscv-llgo
- Update esp32c3.json to inherit riscv32-llgo

This allows ESP32-C3 to use newlib's standard startup flow (crt0)
instead of TinyGo's start.S, enabling proper C library initialization
including __libc_init_array and constructor functions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
Add CI test to verify ESP32-C3 uses newlib's standard startup flow
instead of TinyGo's start.S:

- Create test_esp32c3_startup.sh that builds a minimal program
  and checks for __libc_init_array call in _start function
- Add test step to GitHub Actions workflow

This test ensures the target inheritance chain works correctly:
esp32c3 → riscv32-llgo → riscv-llgo → riscv-basic (no start.S)

The test verifies _start calls __libc_init_array, which is only
present in newlib's crt0-riscv32-unknown-none startup file.

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

Co-Authored-By: Claude <noreply@anthropic.com>
@luoliwoshang luoliwoshang marked this pull request as ready for review December 4, 2025 08:35
@xgopilot
Copy link
Contributor

xgopilot bot commented Dec 4, 2025

Code Review Summary

This PR demonstrates excellent architectural refactoring. The separation of TinyGo and LLGo target configurations through a clean inheritance hierarchy is well-designed and maintainable.

Key Strengths

  • ✅ Clean separation of concerns with riscv-basicriscv-llgoriscv32-llgoesp32c3
  • ✅ Comprehensive regression test with proper error handling
  • ✅ No performance concerns (init_array remains in fast IRAM)
  • ✅ Secure implementation with proper input handling
  • ✅ Accurate documentation matching implementation

Minor Enhancement Opportunities

While the PR is solid, consider these optional improvements for maintainability:

  1. Add a targets/README.md explaining the inheritance architecture and target_tinygo vs target_llgo distinction
  2. Consider inline comments in key config files explaining design decisions (e.g., why extra-files: [] in esp32c3.json)

Recommendation: Approve

echo "==> Checking for __libc_init_array call in _start..."

# Disassemble _start and check for __libc_init_array call
if llvm-objdump -d "$TEST_ELF" | grep -A30 "<_start>:" | grep "__libc_init_array" > /dev/null; then
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider increasing -A30 to -A50 for additional safety margin. While 30 lines is reasonable for most _start implementations, using a larger value provides extra headroom if the function grows in the future.


.preinit_array :
{
. = ALIGN(4);
Copy link
Contributor

Choose a reason for hiding this comment

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

Documentation note: These sections now reside within .rodata (which maps to iram_seg), ensuring they remain in fast instruction RAM. This placement is both secure (read-only) and performant (single-cycle access). Consider adding a brief comment explaining this design decision for future maintainers.

@luoliwoshang luoliwoshang changed the title fix:esp32 use the newlibc's start & init_array to rodata avoid init fail fix(esp32c3): use newlib startup and merge init_array to rodata for println support (#1427) Dec 4, 2025
@luoliwoshang luoliwoshang changed the title fix(esp32c3): use newlib startup and merge init_array to rodata for println support (#1427) fix(esp32c3): use newlib startup and merge init_array to rodata for println support Dec 4, 2025
- Add detailed comment in esp32-riscv.app.elf.ld explaining why
  .preinit_array, .init_array and .fini_array are merged into .rodata
  (they get lost during ELF→BIN conversion, following ESP-IDF approach)

- Increase grep context from -A30 to -A50 in startup regression test
  for additional safety margin if _start function grows

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

Co-Authored-By: Claude <noreply@anthropic.com>
@luoliwoshang luoliwoshang mentioned this pull request Dec 4, 2025
7 tasks
@luoliwoshang
Copy link
Member Author

luoliwoshang commented Dec 4, 2025

riscv-llgo -> riscv-nostart
riscv32-llgo -> riscv32-nostart


verify the bin file have the init_array

- Rename riscv-llgo.json to riscv-nostart.json (more semantic)
- Rename riscv32-llgo.json to riscv32-nostart.json (more semantic)
- Update inheritance references in esp32c3.json and riscv32-nostart.json
- Remove duplicate gdb config from riscv.json (already defined in riscv-basic.json)

The '-nostart' suffix clearly indicates these targets don't include startup files,
making the inheritance chain more self-documenting.
Remove target_llgo and target_tinygo build-tags as they are not needed
for the current implementation.
Add comprehensive regression test to verify:
1. .init_array section is merged into .rodata section
2. __init_array_start symbol points within .rodata
3. .rodata (including .init_array) is included in BIN file

The test uses:
- llvm-readelf to verify ELF section layout
- llvm-nm to check symbol addresses
- esptool.py to validate BIN file segments

This ensures constructor function pointers are correctly flashed to ESP32-C3.
@luoliwoshang
Copy link
Member Author

@xushiwei

@xushiwei xushiwei merged commit aad42ea into goplus:main Dec 6, 2025
42 checks passed
@luoliwoshang luoliwoshang mentioned this pull request Dec 9, 2025
4 tasks
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.

3 participants