Skip to main content
Planet Zephyr

RISC-V Bytes: Semihosting with Zephyr on an ESP32

By May 29, 2023No Comments

Note: all analysis and code samples used in this post correspond to the
release of Zephyr.


Having a good debugging workflow is critical to developing software quickly with
high confidence. Fortunately, writing software for computers is often done,
well, on a computer, meaning that while writing programs we can run other
programs that help us understand the behavior of what we are writing. Typically
the machine we are writing software for, though it may run in a data center and
have a different form factor, doesn’t look so different from the machine we are
writing it on. My System76 laptop is not the same as
the servers racked in US East
but it is a close enough approximation.

However, when writing embedded software, the target machine looks quite
different than the machine that the software is developed on, and it is
frequently significantly more resource constrained. One option is to run an
emulator, such as QEMU, on the host machine and connect your debugger. This
is the strategy we have used in previous RISC-V Bytes
, and the built-in
gdbstub functionality makes connecting
gdb straightforward.

There is nothing like the real hardware though, and many systems allow for
debugging the software running on a device by communicating over
JTAG from the host machine. Some even
provide built-in USB-to-JTAG circuitry, absolving developers of the need to use
additional adapters. The drawback of using real hardware is that simulating
certain input and output to a program can be difficult. Furthermore, if testing against a
single component of a larger system, the components providing input and output
may not be available at the time of writing software.

To improve the development experience in these scenarios, some chips implement
semihosting, which
can greatly enhance an embedded development workflow by allowing a program
running on a device to perform operations on the host.

As we have explored in many posts in the RISC-V Bytes series, computer programs
are just a sequence of binary instructions that a CPU has been designed to
interpret in a specific manner. When writing software on a host machine, then
running it under a debugger, the debugger typically controls the child program
using functionality exposed by the operating system, such as
ptrace(2) on Linux.
This allows the debugger to selectively feed instructions from the child program
to the CPU, optionally injecting additional instructions or performing some
computation between issuing subsequent instructions.

When working with an embedded device, there are two systems at play: the host
and the device. This complicates the architecture, but similar functionality can
be accomplished, typically using interfaces such as the aforementioned JTAG or
ARM Serial Wire Debug

There is a lot of information online about JTAG, but because it can be
utilized for many different functions, it is easy to get confused about
what it offers on its own. I have found the OpenOCD JTAG
to be one of
the most succinct and helpful descriptions, especially for the context in
which JTAG will be used in this post.

These interfaces are quite different, but both enable communication with a
processor running on a device. However, the processor itself must offer
debugging functionality, much like it supports executing instructions defined by
its instruction set architecture (ISA). RISC-V has a dedicated debugging
which defines how a system can expose hardware debugging support both internally
and externally.

When using an external system (i.e. host) for debugging, communication with
RISC-V hardware debugging components happens over the Debug Transport Module
. As shown in the diagram below, there could be multiple transports used
to implement a DTM, but the specification only defines one for JTAG at this
point in time.


Diagram showing optional and required components in the RISC-V debug

When being controlled by an external debugger, a RISC-V hart is said to be in
Debug Mode (D), which can be considered an additional privilege
. When
implemented, this is the maximum privilege level, as it provides even lower
level access than Machine Mode (M). While the the debug specification
details a variety of required and optional functionality, the privileged and
unprivileged specifications
the EBREAK instruction, which is similar to the ECALL instruction we have
used in previous posts to perform system calls and trap to higher privilege
levels. The EBREAK instruction instead will transfer control to the debugging
environment, effectively implementing a software
. This type of breakpoint
is interesting because, unlike triggers and hardware breakpoints where the
debugger is forcing the program to halt, software breakpoints, though they may
be inserted by the debugger, are the program “asking to be halted”.

While the typical purpose of this behavior is to allow for examining state at
the breakpoint, it can be thought of as a general purpose communication channel.
That is, the program can form a “request” in its state, issue an EBREAK, then
read a “response” from the debugger that has been written in the program state.
This is the basic idea behind semihosting.

Fortunately for existing debugging software, RISC-V opted to use a very similar
implementation for semihosting as the one initially defined for ARM processors.
The unofficial semihosting

defines the following sequence of instructions for a program to indicate to a
debugger that the encountered breakpoint is a semihosting request.

slli x0, x0, 0x1f       # 0x01f01013    Entry NOP
ebreak                  # 0x00100073    Break to debugger
srai x0, x0, 7          # 0x40705013    NOP encoding the semihosting call number 7

On both 32-bit and 64-bit harts, the a0 register is used to specify the type
being requested, and a1 is used to supply parameters. a0 is used to supply
responses. Depending on the type of operation, the request parameter in a1 or
the response in a0 may be either a value or a memory address of the data
block. In this manner, arbitrary data may be transferred between host and

In the last few posts, we’ve explored the Zephyr
. Zephyr conveniently offers support for
semihosting on ARM and RISC-V targets using a generic interface, which we’ll use
in this post to communicate with an Espressif
. The ESP32-C3
implements the RISC-V debug specification, and the DevKitC-02 has built-in
USB-to-JTAG circuitry, meaning that we can connect gdb on our host machine to
the hart on the device using OpenOCD (more on this in a bit).

The Zephyr semihosting interface is defined in semihost.h, where operation
enums are defined, as well as signatures for a few common operations. A codensed
representation is provided below.

include/zephyr/arch/common/semihost.h (ref)

long semihost_exec(enum semihost_instr instr, void *args);
char semihost_poll_in(void);
void semihost_poll_out(char c);
long semihost_open(const char *path, long mode);
long semihost_close(long fd);
long semihost_flen(long fd);
long semihost_seek(long fd, long offset);
long semihost_read(long fd, void *buf, long len);
long semihost_write(long fd, const void *buf, long len);

In reality, all functions listed are just helpful wrappers around
semihost_exec, meaning that they can all be implemented generically, while
semihost_exec must be defined for each architecture. For example, the
implementation of semihost_close looks as follows.

arch/common/semihost.c (ref)

long semihost_close(long fd)
	struct semihost_close_args args = {
		.fd = fd

	return semihost_exec(SEMIHOST_CLOSE, &args);

The corresponding semihost_exec implementation for RISC-V mirrors what we
previously saw in the unofficial specification, written in C with inline

arch/riscv/core/semihost.c (ref)

 * QEMU requires that the semihosting trap instruction sequence, consisting of
 * three uncompressed instructions, lie in the same page, and refuses to
 * interpret the trap sequence if these instructions are placed across two
 * different pages.
 * The `semihost_exec` function, which occupies 12 bytes, is aligned at a
 * 16-byte boundary to ensure that the three trap sequence instructions are
 * never placed across two different pages.
long __aligned(16) semihost_exec(enum semihost_instr instr, void *args)
	register unsigned long a0 __asm__ ("a0") = instr;
	register void *a1 __asm__ ("a1") = args;
	register long ret __asm__ ("a0");

	__asm__ volatile (
		".option pushnt"
		".option norvcnt"
		"slli zero, zero, 0x1fnt"
		"srai zero, zero, 0x7nt"
		".option pop"
		: "=r" (ret) : "r" (a0), "r" (a1) : "memory");

	return ret;

While the ESP32-C3-DevKitC-02 does have built-in USB-to-JTAG support, it cannot
be accessed over the built-in Micro-USB port. Instead, a dual-channel USB
interface is available on the GPIO18 (D-) and GPIO19 (D+) pins, meaning that
a single connection can be used to simultaneously provide USB terminal and JTAG
access. I unfortunately did not have a USB breakout cable on hand, so I had to
pull out the wire cutters / strippers.


Picture of my heinous wire-stripping job.

After exposing the wires, I used female-to-male jumpers to connect D-, D+,
5V, and GND to a breadboard where I had placed the development kit. I
plugged it into my host and saw signs of life!


Picture of LED lit up on the ESP32-C3-DevKitC-02 with USB wires attached to
appropriate pins via breadboard.

There is an excellent JTAG debugging

hidden in the the ESP32-C3 documentation that explains setup, but if you
followed along with the previous Exploring a Custom ESP32

and Zephyr on the
posts, you
should already have all of your Espressif and Zephyr tools installed.

$ ls ~/.espressif/
dist  espidf.constraints.v5.1.txt  idf-env.json  python_env  tools
$ ls ~/.local/zephyr-sdk-0.16.0/
arm-zephyr-eabi  cmake  environment-setup-x86_64-pokysdk-linux  riscv64-zephyr-elf  sdk_toolchains  sdk_version  sysroots  version-x86_64-pokysdk-linux

With the USB cable connected to the host machine, we should be able to find the device.

$ lsusb | grep Espressif
Bus 002 Device 008: ID 303a:1001 Espressif USB JTAG/serial debug unit

One tool we haven’t used in previous posts is Open On-Chip Debugger
OpenOCD is both magical and maddening. It is magical in that it enables
communicating with a wide variety of hardware, and maddening in that vendors
will frequently fork the project and deliver custom distributions, meaning the
you’ll quickly find yourself with many builds of the tool on your local machine.
Espressif follows this pattern by providing their own OpenOCD
, which is included in the
previously acquired tooling.

OpenOCD is intended to be configuration driven, and there are three types of

that inform how the software will talk with a device: board, interface,
and target. The relevant configuration for the ESP32-C3 can be found in the
OpenOCD scripts directory. The one we will actually reference is the board


# SPDX-License-Identifier: GPL-2.0-or-later
# Example OpenOCD configuration file for ESP32-C3 connected via builtin USB-JTAG adapter.
# For example, OpenOCD can be started for ESP32-C3 debugging on
#   openocd -f board/esp32c3-builtin.cfg

# Source the JTAG interface configuration file
source [find interface/esp_usb_jtag.cfg]
# Source the ESP32-C3 configuration file
source [find target/esp32c3.cfg]

For the ESP32-C3 “built-in”, the board file simply references the interface, which is USB /
JTAG, and the chip, which is the ESP32-C3. The interface file is relatively
uninteresting, though you will notice it matches our lsusb output above.


# SPDX-License-Identifier: GPL-2.0-or-later
# ESP32-C3 builtin USB-JTAG adapter

adapter driver esp_usb_jtag

espusbjtag vid_pid 0x303a 0x1001
espusbjtag caps_descriptor 0x2000

#max speed
adapter speed 40000

The target file, on the other hand, provides extensive information about how the
debug functionality works, and provides configuration to make gdb work properly.
We don’t break down all parts in-depth in this post, but we’ll explore more
about how OpenOCD works in the future.


# SPDX-License-Identifier: GPL-2.0-or-later
# The ESP32-C3 only supports JTAG.
transport select jtag

source [find bitsbytes.tcl]
source [find memory.tcl]
source [find mmr_helpers.tcl]
# Source the ESP common configuration file
source [find target/esp_common.cfg]

# Target specific registers
set EFUSE_MAC_ADDR_REG  0x60008844

if { [info exists CHIPNAME] } {
} else {
	set _CHIPNAME esp32c3

if { [info exists CPUTAPID] } {
} else {
	set _CPUTAPID 0x00005c25

set _CPUNAME cpu

jtag newtap $_CHIPNAME $_CPUNAME -irlen 5 -expected-id $_CPUTAPID

proc esp32c3_wdt_disable { } {
    # Halt event can occur during config phase (before "init" is done).
    # Ignore it since mww commands don't work at that time.
    if { [string compare [command mode] config] == 0 } {

    # Timer Group 0 & 1 WDTs
    mww 0x6001f064 0x50D83AA1
    mww 0x6001F048 0
    mww 0x60020064 0x50D83AA1
    mww 0x60020048 0
    # RTC WDT
    mww 0x600080a8 0x50D83AA1
    mww 0x60008090 0
    # SWD
    mww 0x600080b0 0x8F1D312A
    mww 0x600080ac 0x84B00000

proc esp32c3_soc_reset { } {
    # This procedure does "digital system reset", i.e. resets
    # all the peripherals except for the RTC block.
    # It is called from reset-assert-post target event callback,
    # after assert_reset procedure was called.
    # Since we need the hart to to execute a write to RTC_CNTL_SW_SYS_RST,
    # temporarily take it out of reset. Save the dmcontrol state before
    # doing so.
    riscv dmi_write 0x10 0x80000001
    # Trigger the reset
    mww 0x60008000 0x9c00a000
    # Workaround for stuck in cpu start during calibration.
    # By writing zero to TIMG_RTCCALICFG_REG, we are disabling calibration
    mww 0x6001F068 0
    # Wait for the reset to happen
    sleep 10
    # Disable the watchdogs again

    # Here debugger reads allresumeack and allhalted bits as set (0x330a2)
    # We will clean allhalted state by resuming the core.
    riscv dmi_write 0x10 0x40000001

    # Put the hart back into reset state. Note that we need to keep haltreq set.
    riscv dmi_write 0x10 0x80000003

proc esp32c3_memprot_is_enabled { } {
    if { [get_mmr_bit 0x600C10A8 0] != 0 } {
        return 1
    if { [get_mmr_bit 0x600C10C0 0] != 0 } {
        return 1
    return 0

if { $_RTOS == "none" } {
    target create $_TARGETNAME esp32c3 -chain-position $_TAPNAME
} else {
    target create $_TARGETNAME esp32c3 -chain-position $_TAPNAME -rtos $_RTOS

$_TARGETNAME configure -event reset-assert-post { esp32c3_soc_reset }
$_TARGETNAME configure -event halted {
    esp halted_event_handler
$_TARGETNAME configure -event examine-end {
    # Need this to handle 'apptrace init' syscall correctly because semihosting is not enabled by default
    arm semihosting enable
    arm semihosting_resexit enable
    if { [info exists _SEMIHOST_BASEDIR] } {
        if { $_SEMIHOST_BASEDIR != "" } {
            arm semihosting_basedir $_SEMIHOST_BASEDIR

$_TARGETNAME configure -event gdb-detach {
	$_TARGETNAME esp gdb_detach_handler

$_TARGETNAME configure -event gdb-attach {
    # 'halt' is necessary to auto-probe flash bank when GDB is connected and generate proper memory map
    halt 1000
    if { [esp32c3_memprot_is_enabled] } {
        # 'reset halt' to disable memory protection and allow flasher to work correctly
        echo "Memory protection is enabled. Reset target to disable it..."
        reset halt
    # by default mask interrupts while stepping
    riscv set_maskisr steponly

# stub flasher may need a lot of memory in case of compressed writes to flash (~107KB):
# - for apptrace: 2x16KB up buffers + 32KB down buffer
# - for uncompression: 32KB for unzip buffer size + 11KB for inflator data structs
# TODO: In general when up buffers are swapped apptrace copies `host->target` data from new up buffer to down buffer to free space for `target->host` data.
# In case of flash writes we use apptrace transfers in one direction only. So we can avoid copying and re-use up buffer instead of down one.
configure_esp_workarea $_TARGETNAME 0x40380000 0x4000 0x3FC84000 0x20000
configure_esp_flash_bank $_TARGETNAME $_TARGETNAME $_FLASH_SIZE

if { $_FLASH_SIZE == 0 } {
	gdb_breakpoint_override hard

riscv set_reset_timeout_sec 2
riscv set_command_timeout_sec 5
riscv set_mem_access sysbus progbuf abstract
riscv set_ebreakm on
riscv set_ebreaks on
riscv set_ebreaku on

We can reference the board file to ensure OpenOCD has all the necessary
information to connect to the device successfully.

$ ~/.espressif/tools/openocd-esp32/v0.12.0-esp32-20230313/openocd-esp32/bin/openocd -f ~/.espressif/tools/openocd-esp32/v0.12.0-esp32-20230313/openocd-esp32/share/openocd/scripts/board/esp32c3-builtin.cfg
Open On-Chip Debugger v0.12.0-esp32-20230313 (2023-03-13-09:07)
Licensed under GNU GPL v2
For bug reports, read
Info : only one transport option; autoselecting 'jtag'
Info : esp_usb_jtag: VID set to 0x303a and PID to 0x1001
Info : esp_usb_jtag: capabilities descriptor set to 0x2000
Warn : Transport "jtag" was already selected
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : esp_usb_jtag: serial (58:CF:79:16:7D:A0)
Info : esp_usb_jtag: Device found. Base speed 40000KHz, div range 1 to 255
Info : clock speed 40000 kHz
Info : JTAG tap: esp32c3.cpu tap/device found: 0x00005c25 (mfg: 0x612 (Espressif Systems), part: 0x0005, ver: 0x0)
Info : datacount=2 progbufsize=16
Info : Examined RISC-V core; found 1 harts
Info :  hart 0: XLEN=32, misa=0x40101104
Info : starting gdb server for esp32c3 on 3333
Info : Listening on port 3333 for gdb connections

We can now connect gdb to port 3333, just as we did when working with
. However, before
doing so, we need to write some software to debug!

Writing to the Host Filesystem

Link to heading

Our Zephyr semihosting project will look similar to the “Hello, RISC-V Bytes!”
but this time instead of printing to the UART console, we’ll write to a file on
the host system.


├── CMakeLists.txt
├── gdbinit
├── prj.conf
├── src
│   └── main.c

The new files are prj.conf and gdbinit. The former is used to enable
which is disabled by default for the EPS32-C3 in Zephyr.



gdbinit is a set of startup commands provided by Espressif that makes getting
started debugging easier. It first connects to OpenOCD’s gdbserver, then
performs some configuration before instructing OpenOCD to reset the hart and
immediately halt startup (mon reset halt). After flushing registers, the thb
command is used to set a temporary hardware breakpoint at main, meaning that
the first time we encounter main an exception should be triggered, then that
hardware breakpoint should be discarded. Lastly, the c command is used to
continue util we hit that hardware breakpoint.


target remote :3333
set remote hardware-watchpoint-limit 2
mon reset halt
thb main

The only change in CMakeLists is the name of the project.

cmake_minimum_required(VERSION 3.20.0)

find_package(Zephyr 3.3.0 REQUIRED)

target_sources(app PRIVATE src/main.c)

The program itself is a fairly minimal adaptation of the example from the
Zephyr semihosting
and likely looks familiar to basic file input / ouput code you may have written
before. We start by opening a file for writing text (SEMIHOST_OPEN_WT), write
the contents of our buffer, then close the file.



int main(void) {
  const char *path = "./semihost.txt";
  long fd, write;
  uint8_t buffer[] = {'H', 'e', 'l', 'l', 'o', ',', ' ', 'R', 'I', 'S', 'C',
                      '-', 'V', ' ', 'B', 'y', 't', 'e', 's', '!', 'n'};

  fd = semihost_open(path, SEMIHOST_OPEN_WB);
  if (fd < 0) {
    return -ENOENT;
  write = semihost_write(fd, buffer, 21);
  if (write < 0) {
    return -ENOENT;
  return 0;

Note: we use “write binary” (WB) instead of “write text” (WT) because t
is not supported as a

in the ARM semihosting specification.

Now we can build the project using west as we have done in the past.

$ export ZEPHYR_BASE=~/code/
$ west build -b esp32c3_devkitm
west build -b esp32c3_devkitm
-- west build: generating a build system
Loading Zephyr default modules (Zephyr base).
-- Application: /home/hasheddan/code/
-- CMake version: 3.22.1
-- Found Python3: /usr/bin/python3.10 (found suitable exact version "3.10.6") found components: Interpreter 
-- Cache files will be written to: /home/hasheddan/.cache/zephyr
-- Zephyr version: 3.3.0 (/home/hasheddan/code/
-- Found west (found suitable version "1.0.0", minimum required is "0.7.1")
-- Board: esp32c3_devkitm
-- ZEPHYR_TOOLCHAIN_VARIANT not set, trying to locate Zephyr SDK
-- Found host-tools: zephyr 0.16.0 (/home/hasheddan/.local/zephyr-sdk-0.16.0)
-- Found toolchain: zephyr 0.16.0 (/home/hasheddan/.local/zephyr-sdk-0.16.0)
-- Found Dtc: /home/hasheddan/.local/zephyr-sdk-0.16.0/sysroots/x86_64-pokysdk-linux/usr/bin/dtc (found suitable version "1.6.0", minimum required is "1.4.6") 
-- Found BOARD.dts: /home/hasheddan/code/
-- Generated zephyr.dts: /home/hasheddan/code/
-- Generated devicetree_generated.h: /home/hasheddan/code/
-- Including generated dts.cmake file: /home/hasheddan/code/
Parsing /home/hasheddan/code/
Loaded configuration '/home/hasheddan/code/'
Merged configuration '/home/hasheddan/code/'
Configuration saved to '/home/hasheddan/code/'
Kconfig header saved to '/home/hasheddan/code/'
-- The C compiler identification is GNU 12.2.0
-- The CXX compiler identification is GNU 12.2.0
-- The ASM compiler identification is GNU
-- Found assembler: /home/hasheddan/.local/zephyr-sdk-0.16.0/riscv64-zephyr-elf/bin/riscv64-zephyr-elf-gcc
-- Configuring done
-- Generating done
-- Build files have been written to: /home/hasheddan/code/
-- west build: building application
[1/181] Preparing syscall dependency handling

[2/181] Generating include/generated/version.h
-- Zephyr version: 3.3.0 (/home/hasheddan/code/, build: 07c6af3b8c35
[164/181] Performing configure step for 'EspIdfBootloader'
-- Build files have been written to: /home/hasheddan/code/
[165/181] Performing build step for 'EspIdfBootloader'
[1/88] Generating project_elf_src_esp32c3.c
[87/88] Generating binary image from built executable v3.3
Creating esp32c3 image...
Merged 1 ELF section
Successfully created esp32c3 image.
Generated /home/hasheddan/code/
[88/88] cd /home/hasheddan/code/ && /usr/bin/python3.10 /home/hasheddan/code/ --offset 0x8000 bootloader 0x0 /home/hasheddan/code/
Bootloader binary size 0x4ba0 bytes. 0x3460 bytes (41%) free.
[171/181] Linking C executable zephyr/zephyr_pre0.elf

[175/181] Linking C executable zephyr/zephyr_pre1.elf

[181/181] Linking C executable zephyr/zephyr.elf
Memory region         Used Size  Region Size  %age Used
     mcuboot_hdr:          32 B         32 B    100.00%
        metadata:          28 B         32 B     87.50%
             ROM:       32560 B    4194240 B      0.78%
     iram0_0_seg:       11536 B       320 KB      3.52%
     irom0_0_seg:       83792 B    4194272 B      2.00%
     drom0_0_seg:        2024 B    4194240 B      0.05%
     dram0_0_seg:       21428 B       320 KB      6.54%
    rtc_iram_seg:          0 GB         8 KB      0.00%
        IDT_LIST:          0 GB         8 KB      0.00% v3.3
Creating esp32c3 image...
Merged 6 ELF sections
Successfully created esp32c3 image.

Then flash the program with west flash.

Note: I had to disconnect OpenOCD and reset the board via the RST button to
get west flash to work properly.

$ west flash
-- west flash: rebuilding
ninja: no work to do.
-- west flash: using runner esp32
-- runners.esp32: Flashing esp32 chip on None (921600bps) v3.3
Found 1 serial ports
Serial port /dev/ttyACM0
Detecting chip type... ESP32-C3
Chip is ESP32-C3 (revision 3)
Features: Wi-Fi
Crystal is 40MHz
MAC: 58:cf:79:16:7d:a0
Uploading stub...
Running stub...
Stub running...
Changing baud rate to 921600
Configuring flash size...
Auto-detected Flash size: 4MB
Flash will be erased from 0x00000000 to 0x00004fff...
Flash will be erased from 0x00008000 to 0x00008fff...
Flash will be erased from 0x00010000 to 0x00034fff...
Flash params set to 0x0220
Wrote 32768 bytes at 0x00000000 in 0.4 seconds (734.3 kbit/s)...
Hash of data verified.
Wrote 16384 bytes at 0x00008000 in 0.2 seconds (763.2 kbit/s)...
Hash of data verified.
Wrote 163840 bytes at 0x00010000 in 1.6 seconds (808.7 kbit/s)...
Hash of data verified.

Hard resetting via RTS pin...

With the software running on the board, we can connect gdb to OpenOCD by passing
the gdbinit script and specifying the ELF file to get debugging symbols.

$ ~/.espressif/tools/riscv32-esp-elf-gdb/12.1_20221002/riscv32-esp-elf-gdb/bin/riscv32-esp-elf-gdb -x gdbinit ../zephyr-riscv-semihosting/build/zephyr/zephyr.elf 
GNU gdb (esp-gdb) 12.1_20221002
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later 
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "--host=x86_64-linux-gnu --target=riscv32-esp-elf".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
Find the GDB manual and other documentation resources online at:

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ../zephyr-riscv-semihosting/build/zephyr/zephyr.elf...
arch_system_halt (reason=reason@entry=0) at /home/hasheddan/code/
32		for (;;) {
JTAG tap: esp32c3.cpu tap/device found: 0x00005c25 (mfg: 0x612 (Espressif Systems), part: 0x0005, ver: 0x0)
Reset cause (3) - (Software core reset)
Warning: 'flushregs', an alias for the command 'maintenance flush register-cache', is deprecated.
Use 'maintenance flush register-cache'.

Hardware assisted breakpoint 1 at 0x42010038: file /home/hasheddan/code/, line 7.

Temporary breakpoint 1, main () at /home/hasheddan/code/
7	  uint8_t buffer[] = {'H', 'e', 'l', 'l', 'o', ',', ' ', 'R', 'I', 'S',

The establishing of the connection can be observed in the OpenOCD logs.

Info : accepting 'gdb' connection on tcp/3333
Info : [esp32c3] Halt cause (3) - (Hardware Breakpoint/Watchpoint or EBREAK)
Warn : No symbols for FreeRTOS!
Info : [esp32c3] Halt cause (3) - (Hardware Breakpoint/Watchpoint or EBREAK)
Info : [esp32c3] Found 8 triggers
Info : Flash mapping 0: 0x20040 -> 0x3c000040, 1 KB
Info : Flash mapping 1: 0x30020 -> 0x42010020, 17 KB
Info : [esp32c3] Halt cause (3) - (Hardware Breakpoint/Watchpoint or EBREAK)
Info : Auto-detected flash bank 'esp32c3.flash' size 4096 KB
Info : Using flash bank 'esp32c3.flash' size 4096 KB
Info : [esp32c3] Halt cause (3) - (Hardware Breakpoint/Watchpoint or EBREAK)
Info : Flash mapping 0: 0x20040 -> 0x3c000040, 1 KB
Info : Flash mapping 1: 0x30020 -> 0x42010020, 17 KB
Info : Using flash bank 'esp32c3.irom' size 20 KB
Info : [esp32c3] Halt cause (3) - (Hardware Breakpoint/Watchpoint or EBREAK)
Info : Flash mapping 0: 0x20040 -> 0x3c000040, 1 KB
Info : Flash mapping 1: 0x30020 -> 0x42010020, 17 KB
Info : Using flash bank 'esp32c3.drom' size 4 KB
Warn : negative reply, retrying
Warn : Prefer GDB command "target extended-remote :3333" instead of "target remote :3333"
Warn : RTOS FreeRTOS not detected. (GDB could not find symbol 'pxReadyTasksLists')
Info : JTAG tap: esp32c3.cpu tap/device found: 0x00005c25 (mfg: 0x612 (Espressif Systems), part: 0x0005, ver: 0x0)
Info : Reset cause (3) - (Software core reset)
Error: Don't have the number of threads in FreeRTOS!

Just like when we are debugging locally, we are able to step through the
program. Stepping twice will move us past the initial file open operation.

Note: you may see a Don't have the number of threads in FreeRTOS! warning
emitted from gdb. These can safely be ignored for our scenario.

(gdb) n
Note: automatically using hardware breakpoints for read-only addresses.
10	  fd = semihost_open(path, SEMIHOST_OPEN_WB);
(gdb) n
11	  if (fd < 0) {

At this point, we have already exercised semihosting once, and we should be able
to see the semihost.txt file on the host in the same directory where gdb is
running, though the contents should be empty.

$ ls -la semihost.txt 
-rw-r--r-- 1 hasheddan hasheddan 0 May 29 17:55 semihost.txt

To see the instructions that were executed and resulted in the creation of
semihost.txt, we can instruct gdb to disassemble the function.

(gdb) disass semihost_open
Dump of assembler code for function semihost_open:
   0x420100a8 :	    addi	sp,sp,-32
   0x420100ac :	    sw	ra,28(sp)
   0x420100b0 :	    sw	a1,8(sp)
   0x420100b4 :	sw	a0,4(sp)
   0x420100b8 :	auipc	ra,0xfe373
   0x420100bc :	jalr	-1284(ra) # 0x40382bb4 
   0x420100c0 :	sw	a0,12(sp)
   0x420100c4 :	addi	a1,sp,4
   0x420100c8 :	li	a0,1
   0x420100cc :	jal	ra,0x42010020 
   0x420100d0 :	lw	ra,28(sp)
   0x420100d4 :	addi	sp,sp,32
   0x420100d8 :	ret
End of assembler dump.

After the prologue, the address of the data block for the path and mode
parameters is stored in a1, while the li a0,1 sets the operation to be
performed to
Execution then jumps to to semihost_exec, where the actual semihosting
instruction sequence is performed.

(gdb) disass semihost_exec
Dump of assembler code for function semihost_exec:
   0x42010020 :	    slli	zero,zero,0x1f
   0x42010024 :	    ebreak
   0x42010028 :	    srai	zero,zero,0x7
   0x4201002c :	ret
End of assembler dump.

The following semihosting operations follow the same pattern. If we continue,
the write and file close will be executed and main will return, causing the
program to sit in the idle thread.

(gdb) c
Program received signal SIGINT, Interrupt.
0x420104fc in arch_cpu_idle () at /home/hasheddan/code/
27	}

Now if we examine the contents of semihost.txt, we should see our message!

$ cat semihost.txt 
Hello, RISC-V Bytes!

We have only scratched the surface of cases where semihosting can be useful when
working with embedded systems. For example, Zephyr supports using semihosting as
a console
However, with knowledge of its general purpose input / output capabilities, we
can start to utilize it as a tool across projects.

As always, these posts are meant to serve as a useful resource for folks who are
interested in learning more about RISC-V and low-level software in general. If I
can do a better job of reaching that goal, or you have any questions or
comments, please feel free to send me a message
@hasheddan Twitter, on Mastodon, or on

Benjamin Cabé