Repository: probe-rs/probe-rs
Pull request #2292 (2024-03-14 23:25:00)

`embedded-test` integration
I've rebased/rewritten `feature/testing`.  `probe-rs run` will now autodetect whether it is a test binary or a normal binary. You no longer need `probe-rs test` (adapt your cargo runner!)


<details>
<summary>New `probe-rs run --help` output</summary>

```
Flash and run an ELF program

Usage: probe-rs run [OPTIONS] <PATH> [TEST_FILTER]...

Arguments:
  <PATH>
          The path to the ELF file to flash and run.
          If the binary uses `embedded-test` each test will be executed in turn. See `TEST OPTIONS` for more configuration options exclusive to this mode.
          If the binary does not use `embedded-test` the binary will be flashed and run normally. See `RUN OPTIONS` for more configuration options exclusive to this mode.

Options:
      --always-print-stacktrace
          Always print the stacktrace on ctrl + c

      --no-location
          Suppress filename and line number information from the rtt log

      --log-format <LOG_FORMAT>
          The default format string to use for decoding defmt logs

      --rtt-scan-memory
          Scan the memory to find the RTT control block

  -h, --help
          Print help (see a summary with '-h')

RUN OPTIONS:
      --catch-reset
          Enable reset vector catch if its supported on the target

      --catch-hardfault
          Enable hardfault vector catch if its supported on the target

TEST OPTIONS:
      --list
          List all tests instead of executing them

      --format <pretty|terse|json>
          Configure formatting of the test report output

          Possible values:
          - pretty: One line per test. Output for humans. (default)
          - terse:  One character per test. Usefull for test suites with many tests
          - json:   Json output

      --exact
          If set, filters are matched exactly rather than by substring

  [TEST_FILTER]...
          The TEST_FILTER string is tested against the name of all tests, and only those tests whose names contain the filter are run. Multiple filter strings may be passed, which will run all tests matching any of the filters.

DOWNLOAD CONFIGURATION:
      --disable-progressbars
          

      --disable-double-buffering
          Use this flag to disable double-buffering when downloading flash data. If download fails during programming with timeout errors, try this option

      --restore-unwritten
          Enable this flag to restore all bytes erased in the sector erase but not overwritten by any page

      --flash-layout <filename>
          Requests the flash builder to output the layout into the given file in SVG format

      --verify
          After flashing, read back all the flashed data to verify it has been written correctly

      --binary-format <BINARY_FORMAT>
          If a format is provided, use it. If a target has a preferred format, we use that. Finally, if neither of the above cases are true, we default to ELF

      --base-address <BASE_ADDRESS>
          The address in memory where the binary will be put at. This is only considered when `bin` is selected as the format

      --skip <SKIP>
          The number of bytes to skip at the start of the binary file. This is only considered when `bin` is selected as the format
          
          [default: 0]

      --idf-bootloader <IDF_BOOTLOADER>
          The idf bootloader path

      --idf-partition-table <IDF_PARTITION_TABLE>
          The idf partition table path

      --chip-erase
          Whether to erase the entire chip before downloading

PROBE CONFIGURATION:
      --chip <CHIP>
          [env: PROBE_RS_CHIP=]

      --chip-description-path <chip description file path>
          [env: PROBE_RS_CHIP_DESCRIPTION_PATH=]

      --protocol <PROTOCOL>
          Protocol used to connect to chip. Possible options: [swd, jtag]
          
          [env: PROBE_RS_PROTOCOL=]

      --probe <PROBE>
          Use this flag to select a specific probe in the list.
          
          Use '--probe VID:PID' or '--probe VID:PID:Serial' if you have more than one probe with the same VID:PID.",
          
          [env: PROBE_RS_PROBE=]

      --speed <SPEED>
          The protocol speed in kHz
          
          [env: PROBE_RS_SPEED=]

      --connect-under-reset
          Use this flag to assert the nreset & ntrst pins during attaching the probe to the chip
          
          [env: PROBE_RS_CONNECT_UNDER_RESET=]

      --dry-run
          [env: PROBE_RS_DRY_RUN=]

      --allow-erase-all
          Use this flag to allow all memory, including security keys and 3rd party firmware, to be erased even when it has read-only protection
          
          [env: PROBE_RS_ALLOW_ERASE_ALL=]

LOG CONFIGURATION:
      --log-file <LOG_FILE>
          Location for log file
          
          If no location is specified, the behaviour depends on `--log-to-folder`.

      --log-to-folder
          Enable logging to the default folder. This option is ignored if `--log-file` is specified

Process finished with exit code 0

```

</details>

## Remaining TODOs:
- [x] Improve Semihosting (maybe in a separate PR)
     - [x] The architectures currently invoke `decode_semihosting_syscall` in every call to `CoreInterface::status()`, even if the target has not been continued between the calls. This is inefficient and leads to strange error messages (e.g. semihosting operation=0x00, because the register in question was overriden by the semihosting return value)
     - [x] All semihosting operations which allow continuation of the core (e.g. `SYS_GET_CMDLINE`), should write a default return value back to the CPU register, so that even if the semihosting operation is not answered by the application, the core reads something valid back.
- [x] In Test RunMode: Print RTT + Stacktraces to stderr instead of stdout, so that test report can be collected by tools 
       RustRover can handle the current output just fine though
- [x] Cleanup log messages ( + change log levels)

## Test it

In order to test this branch, you need to use a diferent branch of embedded-test: https://github.com/probe-rs/embedded-test/compare/next?expand=1

I've also updated the example project: https://github.com/probe-rs/embedded-test-example/tree/next

You can test the whole thing for the esp32c6 with the following commands:

```
cargo install probe-rs --git https://github.com/probe-rs/probe-rs --branch feature/testing-rebased --features cli --bin probe-rs
git clone  git@github.com:probe-rs/embedded-test-example.git --branch next
cd embedded-test-example
cargo test --test async_test --features embassy
```

t-moe: When a test fails on riscv, I get the following output, if I print a stacktrace:

```
 INFO status: probe_rs::architecture::riscv: Semihosting found pc=0x4201e7a4 a0=0x20 a1=0x4086ed4c
 WARN probe_rs::debug::debug_info: UNWIND: Error while checking for exception context. The stack trace will not include the calling frames. : The unwinding of exception frames capability has not yet been implemented for this architecture.

Because of the large varieties of supported architectures, it is not always possible for
a contributor to implement functionality for all of them. This allows us to
implement new functionality on selected architectures first, and then add support for
the other architectures later.
 WARN probe_rs::debug::debug_info: UNWIND: Error while checking for exception context. The stack trace will not include the calling frames. : The unwinding of exception frames capability has not yet been implemented for this architecture.

Because of the large varieties of supported architectures, it is not always possible for
a contributor to implement functionality for all of them. This allows us to
implement new functionality on selected architectures first, and then add support for
the other architectures later.
 WARN probe_rs::debug::debug_info: UNWIND: Error while checking for exception context. The stack trace will not include the calling frames. : The unwinding of exception frames capability has not yet been implemented for this architecture.

Because of the large varieties of supported architectures, it is not always possible for
a contributor to implement functionality for all of them. This allows us to
implement new functionality on selected architectures first, and then add support for
the other architectures later.
 WARN probe_rs::debug::debug_info: UNWIND: Error while checking for exception context. The stack trace will not include the calling frames. : The unwinding of exception frames capability has not yet been implemented for this architecture.

Because of the large varieties of supported architectures, it is not always possible for
a contributor to implement functionality for all of them. This allows us to
implement new functionality on selected architectures first, and then add support for
the other architectures later.
 WARN probe_rs::debug::debug_info: UNWIND: Error while checking for exception context. The stack trace will not include the calling frames. : The unwinding of exception frames capability has not yet been implemented for this architecture.

Because of the large varieties of supported architectures, it is not always possible for
a contributor to implement functionality for all of them. This allows us to
implement new functionality on selected architectures first, and then add support for
the other architectures later.
Frame 0: syscall_readonly @ 0x4201e7a4
       /home/timo/.cargo/registry/src/index.crates.io-6f17d22bba15001f/semihosting-0.1.6/src/sys/arm_compat/syscall/riscv.rs:35:9
Frame 1: sys_exit_extended @ 0x4201e912
       /home/timo/.cargo/registry/src/index.crates.io-6f17d22bba15001f/semihosting-0.1.6/src/sys/arm_compat/mod.rs:198:2
Frame 2: exit @ 0x4201e844
       /home/timo/.cargo/registry/src/index.crates.io-6f17d22bba15001f/semihosting-0.1.6/src/sys/arm_compat/mod.rs:165:5
Frame 3: exit @ 0x4201e736
       /home/timo/.cargo/registry/src/index.crates.io-6f17d22bba15001f/semihosting-0.1.6/src/process.rs:14:5
Frame 4: <unknown function @ 0x4201e74e> @ 0x4201e74e
Error: "Test should have passed but it panicked"

```

We should de-noise this output :)
t-moe: Ok. RTT works. I only created the channel, but forgot to register a log (sink) :man_facepalming: 

One strange thing is, that the autodetection of the test does only work if we use async testcases.

Assuming you've executed the steps listed in the toplevel comment

  `cargo test --test async_test --features embassy`  works  

```
objdump -t target/riscv32imac-unknown-none-elf/debug/deps/async_test-cfd14d1241ac06a8 | grep EMBEDDED
4080107c g     O .bss	00000004 EMBEDDED_TEST_VERSION
```


 but  `cargo test --test example_test` does not.   
```
objdump -t target/riscv32imac-unknown-none-elf/debug/deps/example_test-241856ddd2d32572 | grep EMBEDDED
<no output>
```

Instead probe-rs prints `No embedded-test in ELF file. Running as normal`.

t-moe: > One strange thing is, that the autodetection of the test does only work if we use async testcases.

Thanks to @MabezDev this issue is now resolved, with a custom linker script that keeps the symbol all the time :partying_face: 

SergioGasquez: Thanks for all your work here @t-moe! I've updated [my fork of esp-hal](https://github.com/SergioGasquez/esp-hal/tree/feature/hil-test) and did some local test of our HIL tests, here is a summary of the current status:
- C6: All tests are working fine (as before)
- H2: All tests are working fine (as before)
- C3: Is now supported! All test are passing.
- C2: Only some test can be flashed and pass (although the print an error reading from rtt), others just fail mid-flashing. Here is a log with both scenarios:
```
esp-hal/hil-test on  feature/hil-test [$] is 📦 v0.0.0 via 🦀 v1.76.0 took 2s 
❯ CARGO_BUILD_TARGET=riscv32imc-unknown-none-elf \
                  PROBE_RS_CHIP=esp32c2 \
                  cargo +nightly test --features=esp32c2 --test spi_full_duplex 
...
warning: `esp-hal` (lib) generated 4 warnings
    Finished `test` profile [optimized + debuginfo] target(s) in 0.03s
     Running tests/spi_full_duplex.rs (target/riscv32imc-unknown-none-elf/debug/deps/spi_full_duplex-c4f369c0047c7e05)
 WARN probe_rs::probe::ftdi: Read 0 bytes, expected 6
 WARN probe_rs::session: Failed to deconfigure device during shutdown: A RISC-V specific error occurred.

Caused by:
    Error occurred during execution of an abstract command: HaltResume                                                                                                                                                                                           Error: The flashing procedure failed for '/home/sergio/Documents/Espressif/forks/esp-hal/hil-test/target/riscv32imc-unknown-none-elf/debug/deps/spi_full_duplex-c4f369c0047c7e05'.

Caused by:
    0: Something during the interaction with the core went wrong
    1: An error with the usage of the probe occurred
    2: An error which is specific to the debug probe in use occurred.
    3: A timeout occurred during probe operation.
error: test failed, to rerun pass `--test spi_full_duplex`

Caused by:
  process didn't exit successfully: `probe-rs run /home/sergio/Documents/Espressif/forks/esp-hal/hil-test/target/riscv32imc-unknown-none-elf/debug/deps/spi_full_duplex-c4f369c0047c7e05` (exit status: 1)

esp-hal/hil-test on  feature/hil-test [$] is 📦 v0.0.0 via 🦀 v1.76.0 took 2s 
❯ CARGO_BUILD_TARGET=riscv32imc-unknown-none-elf \
                  PROBE_RS_CHIP=esp32c2 \
                  cargo +nightly test --features=esp32c2 --test uart 
....
warning: `esp-hal` (lib) generated 4 warnings
    Finished `test` profile [optimized + debuginfo] target(s) in 0.03s
     Running tests/uart.rs (target/riscv32imc-unknown-none-elf/debug/deps/uart-5ff41442273db4c9)
      Erasing ✔ [00:00:00] [#####################################################################################################################################################################################] 192.00 KiB/192.00 KiB @ 246.31 KiB/s (eta 0s )
  Programming ✔ [00:00:01] [########################################################################################################################################################################################] 35.88 KiB/35.88 KiB @ 24.46 KiB/s (eta 0s )    Finished in 2.318s

running 1 test
test tests::test_send_receive ... ERROR probe_rs::util::rtt: 
Error reading from RTT: The control block has been corrupted. write pointer is 578562214 while buffer size is 1024 for "up" channel 0 (defmt)
ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.87s
```
t-moe: I would like to get a first round of reviews for this PR.
There are only a few TODOs left, which are described in the top-level comment.
SergioGasquez: Actually, the architecture matching (https://github.com/probe-rs/probe-rs/pull/2292#discussion_r1533727462) was dumb, after reverting the changes (https://github.com/probe-rs/probe-rs/pull/2308):
- On Xtensa devices, `reg` is: 
```
id: RegisterId(2), roles: [Core("a2"), Argument("a2"), Return("a2")], data_type: UnsignedInteger(32), unwind_rule: Clear
```
- On RISC-V devies, `reg` is:
```
id: RegisterId(4106), roles: [Core("x10"), Argument("a0"), Return("r0")], data_type: UnsignedInteger(32), unwind_rule: Clear
```

So both archs work fine with how it was.
SergioGasquez: Since we now support Xtensa devices, tried to run tests with this branch for Xtensa targets:

<details>
<summary>ESP32 Output Error</summary>

```
  Erasing ✔ [00:00:00] [########################################] 192.00 KiB/192.00 KiB @ 483.95 KiB/s (eta 0s )
  Programming ✔ [00:00:01] [###########################################] 46.58 KiB/46.58 KiB @ 37.84 KiB/s (eta 0s )    Finished in 1.693s
 INFO probe_rs::cmd::run::test_run_mode: libtest args Arguments { include_ignored: false, ignored: false, test: false, bench: false, list: false, nocapture: false, show_output: false, unstable_flags: None, exact: false, quiet: false, test_threads: Some(1), logfile: None, skip: [], color: None, format: None, filter: None }
 INFO probe_rs::util::rtt: Initializing RTT
 INFO probe_rs::util::rtt: Initializing RTT
 INFO probe_rs::util::rtt: Initializing RTT
 INFO probe_rs::util::rtt: Initializing RTT
 INFO probe_rs::util::rtt: Initializing RTT
 INFO probe_rs::util::rtt: Initializing RTT
 INFO probe_rs::util::rtt: Initializing RTT
 INFO probe_rs::util::rtt: Initializing RTT
 INFO probe_rs::util::rtt: Initializing RTT
 INFO probe_rs::util::rtt: Initializing RTT
ERROR probe_rs::cmd::run: Failed to attach to RTT, continuing...
Frame 0: <unknown function @ 0x400073d9> @ 0x400073d9
Frame 1: <unknown function @ 0x8000fc16> @ 0x8000fc16
 INFO session_drop: probe_rs::session: Core status: Halted(Request)
 INFO session_drop: probe_rs::architecture::xtensa::communication_interface: Left OCD mode
Error: The target did not respond with test list until timeout.
error: test failed, to rerun pass `--test gpio`

Caused by:
  process didn't exit successfully: `probe-rs run /home/sergio/Documents/Espressif/esp-rs/esp-hal/hil-test/target/xtensa-esp32-none-elf/debug/deps/gpio-5fbfd0d1ee03a753` (exit status: 1)
```
</details>

Will add ESP32-S2 later on

t-moe: I tried to improve the semihosting stuff today. Recall the two problems (top comment)
>  * The architectures currently invoke decode_semihosting_syscall in every call to CoreInterface::status(), even if the target has not been continued between the calls. This is inefficient and leads to strange error messages (e.g. semihosting operation=0x00, because the register in question was overriden by the semihosting return value)
>  * All semihosting operations which allow continuation of the core (e.g. SYS_GET_CMDLINE), should write a default return value back to the CPU register, so that even if the semihosting operation is not answered by the application, the core reads something valid back.

Especially the first point needs careful review. Please look at a4789d542805330f55a20a08451c8b6af94fc34e . I have only a risc-v here, and of course I validated the change there, but for the other platforms I need your eyes. Thank you!
t-moe: I've also fixed the remaining ToDo and you can now obtain a test report on stdout, so that it can be processed by the CI pipeline.

e.g.
`probe-rs run --chip esp32c6 --format=json <ELF> | cargo2junit > testreport.xml`
SergioGasquez: > Please look at https://github.com/probe-rs/probe-rs/commit/a4789d542805330f55a20a08451c8b6af94fc34e

Just had a look and it looks good to me. I've also retested Xtensa and RISC-V targets:
 - C2, C3, C6, H2 and S3 are working fine. But some targets (C2 and C3) shows some rtt errors:
```
28.97 KiB/28.97 KiB @ 22.69 KiB/s (eta 0s )    Finished in 2.209s
ERROR probe_rs::util::rtt: 
Error reading from RTT: The control block has been corrupted. write pointer is 59496787 while buffer size is 1024 for "up" channel 0 (defmt)

running 1 test
test tests::test_send_receive ... ERROR probe_rs::util::rtt: 
Error reading from RTT: The control block has been corrupted. write pointer is 327110218 while buffer size is 1024 for "up" channel 0 (defmt)
ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.77s
```
 - ESP32-S2
 ```
     Finished in 2.207s

running 1 test
test tests::test_send_receive ... Frame 0: syscall_readonly @ 0x40082831 inline
       /home/sergio/.cargo/registry/src/index.crates.io-6f17d22bba15001f/semihosting-0.1.7/src/sys/arm_compat/syscall/xtensa.rs:29:9
Frame 1: sys_exit_extended @ 0x0000000040082831 inline
       /home/sergio/.cargo/registry/src/index.crates.io-6f17d22bba15001f/semihosting-0.1.7/src/sys/arm_compat/mod.rs:197:9
Frame 2: exit @ 0x0000000040082825 inline
       /home/sergio/.cargo/registry/src/index.crates.io-6f17d22bba15001f/semihosting-0.1.7/src/sys/arm_compat/mod.rs:166:5
Frame 3: exit @ 0x0000000040082825
       /home/sergio/.cargo/registry/src/index.crates.io-6f17d22bba15001f/semihosting-0.1.7/src/process.rs:14:5
Frame 4: <unknown function @ 0x80082854> @ 0x80082854
FAILED

failures:

---- tests::test_send_receive ----
Test should Pass but it did Panic


failures:
    tests::test_send_receive

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.66s
 ```
 - ESP32: 
 ```
     Finished in 1.71s
Frame 0: <unknown function @ 0x400073d9> @ 0x400073d9
Frame 1: <unknown function @ 0x8000fc16> @ 0x8000fc16
Error: The target did not respond with test list until timeout.
```
MabezDev: C2/C3 issue could be a known limitation of defmt where the debugger reads the control block before its fully initialized.

S2 looks fine, but the panic message should be printed somewhere (look at the test outcome).

ESP32, unknown what this issue is.

Patch:
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
deleted file mode 100644
index 2e048d80..00000000
--- a/.github/actions/setup/action.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-name: Install toolchain
-description: Install toolchain
-inputs:
-  targets:
-    description: "The targets to install"
-    required: false
-  components:
-    description: "The components to install"
-    required: false
-runs:
-  using: "composite"
-  steps:
-    - name: Install toolchain
-      uses: dtolnay/rust-toolchain@1.76.0
-      with:
-        targets: ${{ inputs.targets }}
-        components: ${{ inputs.components }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 909f3782..b0e40ab2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,8 +38,6 @@ jobs:
         # Only install on Ubuntu
         if: (matrix.os == 'ubuntu-latest') || (matrix.os == 'ubuntu-20.04')
 
-      - uses: ./.github/actions/setup
-
       - name: Cache Dependencies
         uses: Swatinem/rust-cache@v2.7.3
 
@@ -76,9 +74,8 @@ jobs:
         # Only install on Ubuntu
         if: matrix.os == 'ubuntu-latest'
 
-      - uses: ./.github/actions/setup
-        with:
-          targets: thumbv7m-none-eabi
+      - name: "Install thumbv7m-none-eabi target for tests"
+        run: rustup target add thumbv7m-none-eabi
       
       - name: Cache Dependencies
         uses: Swatinem/rust-cache@v2.7.3
@@ -93,10 +90,6 @@ jobs:
       - name: Checkout sources
         uses: actions/checkout@v4
 
-      - uses: ./.github/actions/setup
-        with:
-          components: rustfmt
-
       - name: Run cargo fmt
         run: cargo fmt --all -- --check
 
@@ -112,10 +105,6 @@ jobs:
           sudo apt update
           sudo apt install -y libudev-dev
 
-      - uses: ./.github/actions/setup
-        with:
-          components: clippy
-
       - name: Cache Dependencies
         uses: Swatinem/rust-cache@v2.7.3
 
@@ -140,8 +129,6 @@ jobs:
           sudo apt update
           sudo apt install -y libudev-dev
 
-      - uses: ./.github/actions/setup
-
       - name: Cache Dependencies
         uses: Swatinem/rust-cache@v2.7.3
 
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 74d1d55d..d02d1b2c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,8 +58,6 @@ jobs:
       - uses: actions/checkout@v4
         with:
           submodules: recursive
-      - name: Install Rust
-        run: rustup update "1.74.0" --no-self-update && rustup default "1.74.0"
       - name: Install cargo-dist
         # we specify bash to get pipefail; it guards against the `curl` command
         # failing. otherwise `sh` won't catch that `curl` returned non-0
@@ -110,8 +108,6 @@ jobs:
       - uses: actions/checkout@v4
         with:
           submodules: recursive
-      - name: Install Rust
-        run: rustup update "1.74.0" --no-self-update && rustup default "1.74.0"
       - uses: swatinem/rust-cache@v2
       - name: Install cargo-dist
         run: ${{ matrix.install_dist }}
@@ -164,8 +160,6 @@ jobs:
       - uses: actions/checkout@v4
         with:
           submodules: recursive
-      - name: Install Rust
-        run: rustup update "1.74.0" --no-self-update && rustup default "1.74.0"
       - name: Install cargo-dist
         shell: bash
         run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.11.1/cargo-dist-installer.sh | sh"
@@ -212,8 +206,6 @@ jobs:
       - uses: actions/checkout@v4
         with:
           submodules: recursive
-      - name: Install Rust
-        run: rustup update "1.74.0" --no-self-update && rustup default "1.74.0"
       - name: Install cargo-dist
         run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.11.1/cargo-dist-installer.sh | sh"
       # Fetch artifacts from scratch-storage
diff --git a/Cargo.lock b/Cargo.lock
index aa7d2cca..da35b7ce 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1132,6 +1132,15 @@ version = "3.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b"
 
+[[package]]
+name = "escape8259"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba4f4911e3666fcd7826997b4745c8224295a6f3072f1418c3067b97a67557ee"
+dependencies = [
+ "rustversion",
+]
+
 [[package]]
 name = "esp-idf-part"
 version = "0.5.0"
@@ -2079,6 +2088,18 @@ dependencies = [
  "redox_syscall",
 ]
 
+[[package]]
+name = "libtest-mimic"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f0f4c6f44ecfd52e8b443f2ad18f2b996540135771561283c2352ce56a1c70b"
+dependencies = [
+ "clap",
+ "escape8259",
+ "termcolor",
+ "threadpool",
+]
+
 [[package]]
 name = "libudev-sys"
 version = "0.1.4"
@@ -2706,6 +2727,7 @@ dependencies = [
  "itm",
  "jep106",
  "kmp",
+ "libtest-mimic",
  "miniz_oxide",
  "num-traits",
  "nusb",
@@ -3969,6 +3991,15 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "threadpool"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
+dependencies = [
+ "num_cpus",
+]
+
 [[package]]
 name = "time"
 version = "0.3.34"
diff --git a/Cargo.toml b/Cargo.toml
index 35a18412..6ca86b14 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -37,8 +37,6 @@ tag-name = "v{{version}}"
 [workspace.metadata.dist]
 # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax)
 cargo-dist-version = "0.11.1"
-# The preferred Rust toolchain to use in CI (rustup toolchain syntax)
-rust-toolchain-version = "1.74.0"
 # CI backends to support
 ci = ["github"]
 # Target platforms to build apps for (Rust target-triple syntax)
diff --git a/changelog/added-xtensa-semihosting.md b/changelog/added-xtensa-semihosting.md
new file mode 100644
index 00000000..9eccd81d
--- /dev/null
+++ b/changelog/added-xtensa-semihosting.md
@@ -0,0 +1,1 @@
+Added semihosting support for Xtensa Architecture
diff --git a/probe-rs/Cargo.toml b/probe-rs/Cargo.toml
index eb93e11e..e9cc1faa 100644
--- a/probe-rs/Cargo.toml
+++ b/probe-rs/Cargo.toml
@@ -69,6 +69,7 @@ cli = [
     "dep:textwrap",
     "dep:addr2line",
     "dep:signal-hook",
+    "dep:libtest-mimic"
 ]
 
 # Enable all built in targets.
@@ -157,6 +158,7 @@ goblin = { version = "0.8", optional = true }
 indicatif = { version = "0.17", optional = true }
 insta = { version = "1", features = ["yaml", "filters"], optional = true }
 itm = { version = "0.9.0-rc.1", default-features = false, optional = true }
+libtest-mimic = { version = "0.7.0" , optional = true }
 rand = { version = "0.8", optional = true }
 rustyline = { version = "14", optional = true }
 sanitize-filename = { version = "0.5", optional = true }
diff --git a/probe-rs/src/architecture/arm/core/armv6m.rs b/probe-rs/src/architecture/arm/core/armv6m.rs
index bab5db86..32a4e59b 100644
--- a/probe-rs/src/architecture/arm/core/armv6m.rs
+++ b/probe-rs/src/architecture/arm/core/armv6m.rs
@@ -9,8 +9,8 @@ use crate::{
     error::Error,
     memory::valid_32bit_address,
     probe::DebugProbeError,
-    Architecture, CoreInformation, CoreInterface, CoreRegister, CoreStatus, CoreType, HaltReason,
-    InstructionSet, MemoryInterface, MemoryMappedRegister,
+    Architecture, BreakpointCause, CoreInformation, CoreInterface, CoreRegister, CoreStatus,
+    CoreType, HaltReason, InstructionSet, MemoryInterface, MemoryMappedRegister,
 };
 use anyhow::Result;
 use bitfield::bitfield;
@@ -540,7 +540,14 @@ impl<'probe> CoreInterface for Armv6m<'probe> {
             self.set_core_status(CoreStatus::Halted(reason));
 
             if let HaltReason::Breakpoint(_) = reason {
-                reason = super::cortex_m::check_for_semihosting(reason, self)?;
+                self.state.semihosting_command = super::cortex_m::check_for_semihosting(
+                    self.state.semihosting_command.take(),
+                    self,
+                )?;
+                if let Some(command) = self.state.semihosting_command {
+                    reason = HaltReason::Breakpoint(BreakpointCause::Semihosting(command));
+                }
+
                 // Set it again if it's changed
                 self.set_core_status(CoreStatus::Halted(reason));
             }
@@ -599,6 +606,8 @@ impl<'probe> CoreInterface for Armv6m<'probe> {
     }
 
     fn reset(&mut self) -> Result<(), Error> {
+        self.state.semihosting_command = None;
+
         self.sequence
             .reset_system(&mut *self.memory, crate::CoreType::Armv6m, None)?;
         Ok(())
@@ -632,6 +641,8 @@ impl<'probe> CoreInterface for Armv6m<'probe> {
     }
 
     fn step(&mut self) -> Result<CoreInformation, Error> {
+        self.state.semihosting_command = None;
+
         // First check if we stopped on a breakpoint, because this requires special handling before we can continue.
         let breakpoint_at_pc = if matches!(
             self.state.current_state,
diff --git a/probe-rs/src/architecture/arm/core/armv7m.rs b/probe-rs/src/architecture/arm/core/armv7m.rs
index 4cfbdcd4..e6a1a7c4 100644
--- a/probe-rs/src/architecture/arm/core/armv7m.rs
+++ b/probe-rs/src/architecture/arm/core/armv7m.rs
@@ -19,7 +19,7 @@ use crate::{
     error::Error,
     memory::valid_32bit_address,
     probe::DebugProbeError,
-    CoreRegister, CoreType, InstructionSet, MemoryInterface,
+    BreakpointCause, CoreRegister, CoreType, InstructionSet, MemoryInterface,
 };
 use anyhow::{anyhow, Result};
 use bitfield::bitfield;
@@ -719,7 +719,14 @@ impl<'probe> CoreInterface for Armv7m<'probe> {
             self.set_core_status(CoreStatus::Halted(reason));
 
             if let HaltReason::Breakpoint(_) = reason {
-                reason = super::cortex_m::check_for_semihosting(reason, self)?;
+                self.state.semihosting_command = super::cortex_m::check_for_semihosting(
+                    self.state.semihosting_command.take(),
+                    self,
+                )?;
+                if let Some(command) = self.state.semihosting_command {
+                    reason = HaltReason::Breakpoint(BreakpointCause::Semihosting(command));
+                }
+
                 // Set it again if it's changed
                 self.set_core_status(CoreStatus::Halted(reason));
             }
@@ -789,6 +796,8 @@ impl<'probe> CoreInterface for Armv7m<'probe> {
     }
 
     fn reset(&mut self) -> Result<(), Error> {
+        self.state.semihosting_command = None;
+
         self.sequence
             .reset_system(&mut *self.memory, crate::CoreType::Armv7m, None)?;
         Ok(())
@@ -824,6 +833,8 @@ impl<'probe> CoreInterface for Armv7m<'probe> {
     }
 
     fn step(&mut self) -> Result<CoreInformation, Error> {
+        self.state.semihosting_command = None;
+
         // First check if we stopped on a breakpoint, because this requires special handling before we can continue.
         let breakpoint_at_pc = if matches!(
             self.state.current_state,
diff --git a/probe-rs/src/architecture/arm/core/armv8m.rs b/probe-rs/src/architecture/arm/core/armv8m.rs
index 14096d5d..cd0e4bfd 100644
--- a/probe-rs/src/architecture/arm/core/armv8m.rs
+++ b/probe-rs/src/architecture/arm/core/armv8m.rs
@@ -15,8 +15,8 @@ use crate::{
     core::{CoreRegisters, RegisterId, RegisterValue, VectorCatchCondition},
     error::Error,
     memory::valid_32bit_address,
-    Architecture, CoreInformation, CoreInterface, CoreRegister, CoreStatus, CoreType, HaltReason,
-    InstructionSet, MemoryInterface, MemoryMappedRegister,
+    Architecture, BreakpointCause, CoreInformation, CoreInterface, CoreRegister, CoreStatus,
+    CoreType, HaltReason, InstructionSet, MemoryInterface, MemoryMappedRegister,
 };
 use anyhow::Result;
 use bitfield::bitfield;
@@ -162,7 +162,14 @@ impl<'probe> CoreInterface for Armv8m<'probe> {
             self.set_core_status(CoreStatus::Halted(reason));
 
             if let HaltReason::Breakpoint(_) = reason {
-                reason = super::cortex_m::check_for_semihosting(reason, self)?;
+                self.state.semihosting_command = super::cortex_m::check_for_semihosting(
+                    self.state.semihosting_command.take(),
+                    self,
+                )?;
+                if let Some(command) = self.state.semihosting_command {
+                    reason = HaltReason::Breakpoint(BreakpointCause::Semihosting(command));
+                }
+
                 // Set it again if it's changed
                 self.set_core_status(CoreStatus::Halted(reason));
             }
@@ -222,6 +229,8 @@ impl<'probe> CoreInterface for Armv8m<'probe> {
     }
 
     fn reset(&mut self) -> Result<(), Error> {
+        self.state.semihosting_command = None;
+
         self.sequence
             .reset_system(&mut *self.memory, crate::CoreType::Armv8m, None)?;
         Ok(())
@@ -257,6 +266,8 @@ impl<'probe> CoreInterface for Armv8m<'probe> {
     }
 
     fn step(&mut self) -> Result<CoreInformation, Error> {
+        self.state.semihosting_command = None;
+
         // First check if we stopped on a breakpoint, because this requires special handling before we can continue.
         let breakpoint_at_pc = if matches!(
             self.state.current_state,
diff --git a/probe-rs/src/architecture/arm/core/cortex_m.rs b/probe-rs/src/architecture/arm/core/cortex_m.rs
index bb5a0693..4617e529 100644
--- a/probe-rs/src/architecture/arm/core/cortex_m.rs
+++ b/probe-rs/src/architecture/arm/core/cortex_m.rs
@@ -5,7 +5,7 @@ use crate::{
     core::RegisterId,
     memory_mapped_bitfield_register,
     semihosting::decode_semihosting_syscall,
-    BreakpointCause, CoreInterface, Error, HaltReason, MemoryMappedRegister,
+    CoreInterface, Error, MemoryMappedRegister, SemihostingCommand,
 };
 use std::time::{Duration, Instant};
 
@@ -181,10 +181,9 @@ pub(crate) fn write_core_reg(
 ///
 /// Call this if you get some kind of breakpoint. Works on ARMv6-M, ARMv7-M and ARMv8-M.
 pub(crate) fn check_for_semihosting(
-    old_reason: HaltReason,
+    cached_command: Option<SemihostingCommand>,
     core: &mut dyn CoreInterface,
-) -> Result<HaltReason, Error> {
-    let mut reason = old_reason;
+) -> Result<Option<SemihostingCommand>, Error> {
     let pc: u32 = core.read_core_reg(core.program_counter().id)?.try_into()?;
 
     // The Arm Semihosting Specification, specificies that the instruction
@@ -207,15 +206,20 @@ pub(crate) fn check_for_semihosting(
 
     if TRAP_INSTRUCTION == actual_instruction {
         // BKPT 0xAB -> we are semihosting
-        let r0: u32 = core.read_core_reg(RegisterId(0))?.try_into()?;
-        let r1: u32 = core.read_core_reg(RegisterId(1))?.try_into()?;
-        tracing::info!("Semihosting found pc={pc:#x} r0={r0:#x} r1={r1:#x}");
 
-        reason = HaltReason::Breakpoint(BreakpointCause::Semihosting(decode_semihosting_syscall(
-            core, r0, r1,
-        )?));
+        Ok(Some(match cached_command {
+            None => {
+                // We only want to decode the semihosting command once, since answering it might change some of the registers
+                let r0: u32 = core.read_core_reg(RegisterId(0))?.try_into()?;
+                let r1: u32 = core.read_core_reg(RegisterId(1))?.try_into()?;
+                tracing::info!("Semihosting found pc={pc:#x} r0={r0:#x} r1={r1:#x}");
+                decode_semihosting_syscall(core, r0, r1)?
+            }
+            Some(cached_command) => cached_command,
+        }))
+    } else {
+        Ok(None)
     }
-    Ok(reason)
 }
 
 fn wait_for_core_register_transfer(
diff --git a/probe-rs/src/architecture/arm/core/mod.rs b/probe-rs/src/architecture/arm/core/mod.rs
index ebdd603f..81138630 100644
--- a/probe-rs/src/architecture/arm/core/mod.rs
+++ b/probe-rs/src/architecture/arm/core/mod.rs
@@ -2,7 +2,7 @@
 
 use crate::{
     core::{BreakpointCause, RegisterValue},
-    memory_mapped_bitfield_register, CoreStatus, HaltReason,
+    memory_mapped_bitfield_register, CoreStatus, HaltReason, SemihostingCommand,
 };
 
 pub mod armv6m;
@@ -135,6 +135,9 @@ pub struct CortexMState {
     current_state: CoreStatus,
 
     fp_present: bool,
+
+    /// The semihosting command that was decoded at the current program counter
+    semihosting_command: Option<SemihostingCommand>,
 }
 
 impl CortexMState {
@@ -144,6 +147,7 @@ impl CortexMState {
             hw_breakpoints_enabled: false,
             current_state: CoreStatus::Unknown,
             fp_present: false,
+            semihosting_command: None,
         }
     }
 
diff --git a/probe-rs/src/architecture/riscv/mod.rs b/probe-rs/src/architecture/riscv/mod.rs
index a8432f20..aa8fccbe 100644
--- a/probe-rs/src/architecture/riscv/mod.rs
+++ b/probe-rs/src/architecture/riscv/mod.rs
@@ -11,7 +11,7 @@ use crate::{
     probe::DebugProbeError,
     semihosting::decode_semihosting_syscall,
     CoreInterface, CoreRegister, CoreStatus, CoreType, Error, HaltReason, InstructionSet,
-    MemoryInterface, MemoryMappedRegister,
+    MemoryInterface, MemoryMappedRegister, SemihostingCommand,
 };
 use anyhow::{anyhow, Result};
 use bitfield::bitfield;
@@ -74,6 +74,7 @@ impl<'probe> Riscv32<'probe> {
     // Resume the core.
     fn resume_core(&mut self) -> Result<(), crate::Error> {
         self.interface.select_hart(self.hart)?;
+        self.state.semihosting_command = None;
 
         // set resume request.
         let mut dmcontrol: Dmcontrol = self.interface.read_dm_register()?;
@@ -113,12 +114,8 @@ impl<'probe> Riscv32<'probe> {
     }
 
     /// Check if the current breakpoint is a semihosting call
-    fn check_for_semihosting(
-        old_reason: HaltReason,
-        core: &mut dyn CoreInterface,
-    ) -> Result<HaltReason, Error> {
-        let mut reason = old_reason;
-        let pc: u32 = core.read_core_reg(core.program_counter().id)?.try_into()?;
+    fn check_for_semihosting(&mut self) -> Result<Option<SemihostingCommand>, Error> {
+        let pc: u32 = self.read_core_reg(self.program_counter().id)?.try_into()?;
 
         // The Riscv Semihosting Specification, specificies the following sequence of instructions,
         // to trigger a semihosting call:
@@ -132,7 +129,7 @@ impl<'probe> Riscv32<'probe> {
 
         // Read the actual instructions, starting at the instruction before the ebreak (PC-4)
         let mut actual_instructions = [0u32; 3];
-        core.read_32((pc - 4) as u64, &mut actual_instructions)?;
+        self.read_32((pc - 4) as u64, &mut actual_instructions)?;
         let actual_instructions = actual_instructions.as_slice();
 
         tracing::debug!(
@@ -144,20 +141,26 @@ impl<'probe> Riscv32<'probe> {
 
         if TRAP_INSTRUCTIONS == actual_instructions {
             // Trap sequence found -> we're semihosting
-            let a0: u32 = core
-                .read_core_reg(core.registers().get_argument_register(0).unwrap().id())?
-                .try_into()?;
-            let a1: u32 = core
-                .read_core_reg(core.registers().get_argument_register(1).unwrap().id())?
-                .try_into()?;
-
-            tracing::info!("Semihosting found pc={pc:#x} a0={a0:#x} a1={a1:#x}");
-
-            reason = HaltReason::Breakpoint(BreakpointCause::Semihosting(
-                decode_semihosting_syscall(core, a0, a1)?,
-            ));
+            match self.state.semihosting_command {
+                None => {
+                    // We only want to decode the semihosting command once, since answering it might change some of the registers
+                    let a0: u32 = self
+                        .read_core_reg(self.registers().get_argument_register(0).unwrap().id())?
+                        .try_into()?;
+                    let a1: u32 = self
+                        .read_core_reg(self.registers().get_argument_register(1).unwrap().id())?
+                        .try_into()?;
+
+                    tracing::info!("Semihosting found pc={pc:#x} a0={a0:#x} a1={a1:#x}");
+                    let cmd = decode_semihosting_syscall(self, a0, a1)?;
+                    self.state.semihosting_command = Some(cmd);
+                    Ok(Some(cmd))
+                }
+                Some(command) => Ok(Some(command)),
+            }
+        } else {
+            Ok(None)
         }
-        Ok(reason)
     }
 }
 
@@ -190,8 +193,11 @@ impl<'probe> CoreInterface for Riscv32<'probe> {
             let reason = match dcsr.cause() {
                 // An ebreak instruction was hit
                 1 => {
-                    let reason = HaltReason::Breakpoint(BreakpointCause::Software);
-                    Riscv32::check_for_semihosting(reason, self)?
+                    if let Some(cmd) = self.check_for_semihosting()? {
+                        HaltReason::Breakpoint(BreakpointCause::Semihosting(cmd))
+                    } else {
+                        HaltReason::Breakpoint(BreakpointCause::Software)
+                    }
                     // TODO: Add testcase to probe-rs-debugger-test to validate semihosting exit/abort work and unknown semihosting operations are skipped
                 }
                 // Trigger module caused halt
@@ -877,6 +883,9 @@ pub struct RiscVState {
 
     /// Store the value of the `hasresethaltreq` bit of the `dmcstatus` register.
     hasresethaltreq: Option<bool>,
+
+    /// The semihosting command that was decoded at the current program counter
+    semihosting_command: Option<SemihostingCommand>,
 }
 
 impl RiscVState {
@@ -884,6 +893,7 @@ impl RiscVState {
         Self {
             hw_breakpoints_enabled: false,
             hasresethaltreq: None,
+            semihosting_command: None,
         }
     }
 }
diff --git a/probe-rs/src/architecture/xtensa/mod.rs b/probe-rs/src/architecture/xtensa/mod.rs
index 7a508fff..9b73e42c 100644
--- a/probe-rs/src/architecture/xtensa/mod.rs
+++ b/probe-rs/src/architecture/xtensa/mod.rs
@@ -6,12 +6,17 @@ use probe_rs_target::{Architecture, CoreType, InstructionSet};
 
 use crate::{
     architecture::xtensa::{
-        arch::{Register, SpecialRegister},
+        arch::{instruction::Instruction, Register, SpecialRegister},
         communication_interface::{DebugCause, IBreakEn},
         registers::{FP, PC, RA, SP, XTENSA_CORE_REGSISTERS},
     },
-    core::registers::{CoreRegisters, RegisterId, RegisterValue},
-    CoreInformation, CoreInterface, CoreRegister, CoreStatus, Error, MemoryInterface,
+    core::{
+        registers::{CoreRegisters, RegisterId, RegisterValue},
+        BreakpointCause,
+    },
+    semihosting::decode_semihosting_syscall,
+    CoreInformation, CoreInterface, CoreRegister, CoreStatus, Error, HaltReason, MemoryInterface,
+    SemihostingCommand,
 };
 
 use self::communication_interface::XtensaCommunicationInterface;
@@ -33,6 +38,9 @@ pub struct XtensaState {
     /// Whether the PC was written since we last halted. Used to avoid incrementing the PC on
     /// resume.
     pc_written: bool,
+
+    /// The semihosting command that was decoded at the current program counter
+    semihosting_command: Option<SemihostingCommand>,
 }
 
 impl XtensaState {
@@ -42,6 +50,7 @@ impl XtensaState {
             breakpoints_enabled: false,
             breakpoint_set: [false; 2],
             pc_written: false,
+            semihosting_command: None,
         }
     }
 
@@ -78,6 +87,7 @@ impl<'probe> Xtensa<'probe> {
     }
 
     fn skip_breakpoint_instruction(&mut self) -> Result<(), Error> {
+        self.state.semihosting_command = None;
         if !self.state.pc_written {
             let debug_cause = self.interface.read_register::<DebugCause>()?;
 
@@ -101,6 +111,50 @@ impl<'probe> Xtensa<'probe> {
 
         Ok(())
     }
+
+    /// Check if the current breakpoint is a semihosting call
+    // OpenOCD implementation: https://github.com/espressif/openocd-esp32/blob/93dd01511fd13d4a9fb322cd9b600c337becef9e/src/target/espressif/esp_xtensa_semihosting.c#L42-L103
+    fn check_for_semihosting(&mut self) -> Result<Option<SemihostingCommand>, Error> {
+        let pc: u32 = self.read_core_reg(self.program_counter().id)?.try_into()?;
+
+        let mut actual_instructions = [0u8; 3];
+        self.read_8((pc) as u64, &mut actual_instructions)?;
+
+        tracing::debug!(
+            "Semihosting check pc={pc:#x} instructions={0:#08x} {1:#08x} {2:#08x}",
+            actual_instructions[0],
+            actual_instructions[1],
+            actual_instructions[2],
+        );
+
+        let mut expected_instruction = vec![];
+        Instruction::Break(1, 14).encode_into_vec(&mut expected_instruction);
+
+        tracing::debug!(
+            "Expected instructions={0:#08x} {1:#08x} {2:#08x}",
+            expected_instruction[0],
+            expected_instruction[1],
+            expected_instruction[2]
+        );
+
+        if actual_instructions[..3] == expected_instruction.as_slice()[..3] {
+            match self.state.semihosting_command {
+                None => {
+                    // We only want to decode the semihosting command once, since answering it might change some of the registers
+                    let a2: u32 = self.read_core_reg(RegisterId::from(2))?.try_into()?;
+                    let a3: u32 = self.read_core_reg(RegisterId::from(3))?.try_into()?;
+
+                    tracing::info!("Semihosting found pc={pc:#x} a2={a2:#x} a3={a3:#x}");
+                    let cmd = decode_semihosting_syscall(self, a2, a3)?;
+                    self.state.semihosting_command = Some(cmd);
+                    Ok(Some(cmd))
+                }
+                Some(command) => Ok(Some(command)),
+            }
+        } else {
+            Ok(None)
+        }
+    }
 }
 
 impl<'probe> MemoryInterface for Xtensa<'probe> {
@@ -204,7 +258,20 @@ impl<'probe> CoreInterface for Xtensa<'probe> {
     fn status(&mut self) -> Result<CoreStatus, Error> {
         let status = if self.core_halted()? {
             let debug_cause = self.interface.read_register::<DebugCause>()?;
-            CoreStatus::Halted(debug_cause.halt_reason())
+            let reason =
+                if debug_cause.halt_reason() == HaltReason::Breakpoint(BreakpointCause::Software) {
+                    // The chip initiated this halt, therefore we need to update pc_written state
+                    self.state.pc_written = false;
+                    // Check if the breakpoint is a semihosting call
+                    if let Some(cmd) = self.check_for_semihosting()? {
+                        HaltReason::Breakpoint(BreakpointCause::Semihosting(cmd))
+                    } else {
+                        debug_cause.halt_reason()
+                    }
+                } else {
+                    debug_cause.halt_reason()
+                };
+            CoreStatus::Halted(reason)
         } else {
             CoreStatus::Running
         };
@@ -225,10 +292,12 @@ impl<'probe> CoreInterface for Xtensa<'probe> {
     }
 
     fn reset(&mut self) -> Result<(), Error> {
+        self.state.semihosting_command = None;
         Ok(self.interface.reset()?)
     }
 
     fn reset_and_halt(&mut self, timeout: Duration) -> Result<CoreInformation, Error> {
+        self.state.semihosting_command = None;
         self.interface.reset_and_halt(timeout)?;
 
         self.core_info()
diff --git a/probe-rs/src/bin/probe-rs/cmd/run/mod.rs b/probe-rs/src/bin/probe-rs/cmd/run/mod.rs
index 41f2f2e8..24a3ca72 100644
--- a/probe-rs/src/bin/probe-rs/cmd/run/mod.rs
+++ b/probe-rs/src/bin/probe-rs/cmd/run/mod.rs
@@ -1,16 +1,19 @@
 mod normal_run_mode;
 use normal_run_mode::*;
+mod test_run_mode;
+use test_run_mode::*;
 
 use std::fs::File;
-use std::io::Write;
+use std::io::{Read, Write};
 use std::ops::Range;
 use std::path::Path;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
 use std::time::{Duration, Instant};
 
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, Context, Result};
 use probe_rs::debug::{DebugInfo, DebugRegisters};
+use probe_rs::flashing::FileDownloadError;
 use probe_rs::rtt::ScanRegion;
 use probe_rs::{
     exception_handler_for_core, probe::list::Lister, Core, CoreInterface, Error, HaltReason,
@@ -33,6 +36,10 @@ pub struct Cmd {
     #[clap(flatten)]
     pub(crate) run_options: NormalRunOptions,
 
+    /// Options only used when in test mode
+    #[clap(flatten)]
+    pub(crate) test_options: TestOptions,
+
     /// Options shared by all run modes
     #[clap(flatten)]
     pub(crate) shared_options: SharedOptions,
@@ -46,7 +53,13 @@ pub struct SharedOptions {
     #[clap(flatten)]
     pub(crate) download_options: BinaryDownloadOptions,
 
-    /// The path to the ELF file to flash and run
+    /// The path to the ELF file to flash and run.
+    #[clap(
+        index = 1,
+        help = "The path to the ELF file to flash and run.\n\
+    If the binary uses `embedded-test` each test will be executed in turn. See `TEST OPTIONS` for more configuration options exclusive to this mode.\n\
+    If the binary does not use `embedded-test` the binary will be flashed and run normally. See `RUN OPTIONS` for more configuration options exclusive to this mode."
+    )]
     pub(crate) path: String,
 
     /// Always print the stacktrace on ctrl + c.
@@ -164,12 +177,44 @@ trait RunMode {
 }
 
 fn detect_run_mode(cmd: &Cmd) -> Result<Box<dyn RunMode>, anyhow::Error> {
-    // We'll add more run modes here as we add support for them.
-    // Possible run modes:
-    // - TestRunMode (runs embedded-test)
-    // - SemihostingArgsRunMode (passes arguments to the target via semihosting)
+    let elf_contains_test = {
+        let mut file = match File::open(cmd.shared_options.path.as_str()) {
+            Ok(file) => file,
+            Err(e) => return Err(FileDownloadError::IO(e)).context("Failed to open binary file."),
+        };
+        let mut buffer = Vec::new();
+        file.read_to_end(&mut buffer)?;
+        match goblin::elf::Elf::parse(buffer.as_slice()) {
+            Ok(elf) if elf.syms.is_empty() => {
+                tracing::debug!("No Symbols in ELF");
+                false
+            }
+            Ok(elf) => elf
+                .syms
+                .iter()
+                .any(|sym| elf.strtab.get_at(sym.st_name) == Some("EMBEDDED_TEST_VERSION")),
+            Err(_) => {
+                tracing::debug!("Failed to parse ELF file");
+                false
+            }
+        }
+    };
 
-    Ok(NormalRunMode::new(cmd.run_options.clone()))
+    if elf_contains_test {
+        // We tolerate the run options, even in test mode so that you can set `probe-rs run --catch-hardfault` as cargo runner (used for both unit tests and normal binaries)
+        tracing::info!("Detected embedded-test in ELF file. Running as test");
+        Ok(TestRunMode::new(&cmd.test_options))
+    } else {
+        let test_args_specified = cmd.test_options.list
+            || cmd.test_options.exact
+            || cmd.test_options.format.is_some()
+            || !cmd.test_options.filter.is_empty();
+        if test_args_specified {
+            return Err(anyhow!("probe-rs was invoked with arguments exclusive to test mode, but the binary does not contain embedded-test"));
+        }
+        tracing::debug!("No embedded-test in ELF file. Running as normal");
+        Ok(NormalRunMode::new(cmd.run_options.clone()))
+    }
 }
 
 struct RunLoop {
@@ -193,6 +238,12 @@ enum ReturnReason<R> {
     Timeout,
 }
 
+/// The output stream to print RTT and Stack Traces to
+enum OutputStream {
+    Stdout,
+    Stderr,
+}
+
 impl RunLoop {
     /// Attaches to RTT and runs the core until it halts.
     ///
@@ -207,6 +258,7 @@ impl RunLoop {
         core: &mut Core,
         catch_hardfault: bool,
         catch_reset: bool,
+        output_stream: OutputStream,
         timeout: Option<Duration>,
         mut predicate: F,
     ) -> Result<ReturnReason<R>>
@@ -257,7 +309,19 @@ impl RunLoop {
         let exit = Arc::new(AtomicBool::new(false));
         let sig_id = signal_hook::flag::register(signal::SIGINT, exit.clone())?;
 
-        let mut stdout = std::io::stdout();
+        let mut stdout;
+        let mut stderr;
+        let output_stream: &mut dyn Write = match output_stream {
+            OutputStream::Stdout => {
+                stdout = std::io::stdout();
+                &mut stdout
+            }
+            OutputStream::Stderr => {
+                stderr = std::io::stderr();
+                &mut stderr
+            }
+        };
+
         let mut return_reason = None;
         while !exit.load(Ordering::Relaxed) && return_reason.is_none() {
             // check for halt first, poll rtt after.
@@ -280,7 +344,7 @@ impl RunLoop {
                 }
             }
 
-            let had_rtt_data = poll_rtt(&mut rtta, core, &mut stdout)?;
+            let had_rtt_data = poll_rtt(&mut rtta, core, output_stream)?;
 
             match timeout {
                 Some(timeout) if start.elapsed() >= timeout => {
@@ -316,7 +380,7 @@ impl RunLoop {
             || return_reason.is_err()
             || matches!(return_reason, Ok(ReturnReason::Timeout))
         {
-            print_stacktrace(core, Path::new(&self.path))?;
+            print_stacktrace(core, Path::new(&self.path), output_stream)?;
         }
 
         signal_hook::low_level::unregister(sig_id);
@@ -327,7 +391,11 @@ impl RunLoop {
 }
 
 /// Prints the stacktrace of the current execution state.
-fn print_stacktrace(core: &mut impl CoreInterface, path: &Path) -> Result<(), anyhow::Error> {
+fn print_stacktrace<S: Write + ?Sized>(
+    core: &mut impl CoreInterface,
+    path: &Path,
+    output_stream: &mut S,
+) -> Result<(), anyhow::Error> {
     let Some(debug_info) = DebugInfo::from_file(path).ok() else {
         tracing::error!("No debug info found.");
         return Ok(());
@@ -344,41 +412,45 @@ fn print_stacktrace(core: &mut impl CoreInterface, path: &Path) -> Result<(), an
         )
         .unwrap();
     for (i, frame) in stack_frames.iter().enumerate() {
-        print!("Frame {}: {} @ {}", i, frame.function_name, frame.pc);
+        write!(
+            output_stream,
+            "Frame {}: {} @ {}",
+            i, frame.function_name, frame.pc
+        )?;
 
         if frame.is_inlined {
-            print!(" inline");
+            write!(output_stream, " inline")?;
         }
-        println!();
+        writeln!(output_stream)?;
 
         if let Some(location) = &frame.source_location {
             if location.directory.is_some() || location.file.is_some() {
-                print!("       ");
+                write!(output_stream, "       ")?;
 
                 if let Some(dir) = &location.directory {
-                    print!("{}", dir.to_path().display());
+                    write!(output_stream, "{}", dir.to_path().display())?;
                 }
 
                 if let Some(file) = &location.file {
-                    print!("/{file}");
+                    write!(output_stream, "/{file}")?;
 
                     if let Some(line) = location.line {
-                        print!(":{line}");
+                        write!(output_stream, ":{line}")?;
 
                         if let Some(col) = location.column {
                             match col {
                                 probe_rs::debug::ColumnType::LeftEdge => {
-                                    print!(":1")
+                                    write!(output_stream, ":1")?
                                 }
                                 probe_rs::debug::ColumnType::Column(c) => {
-                                    print!(":{c}")
+                                    write!(output_stream, ":{c}")?
                                 }
                             }
                         }
                     }
                 }
 
-                println!();
+                writeln!(output_stream)?;
             }
         }
     }
@@ -386,19 +458,19 @@ fn print_stacktrace(core: &mut impl CoreInterface, path: &Path) -> Result<(), an
 }
 
 /// Poll RTT and print the received buffer.
-fn poll_rtt(
+fn poll_rtt<S: Write + ?Sized>(
     rtta: &mut Option<rtt::RttActiveTarget>,
     core: &mut Core<'_>,
-    stdout: &mut std::io::Stdout,
+    out_stream: &mut S,
 ) -> Result<bool, anyhow::Error> {
     let mut had_data = false;
     if let Some(rtta) = rtta {
-        struct StdOutCollector<'a> {
-            stdout: &'a mut std::io::Stdout,
+        struct OutCollector<'a, O: Write + ?Sized> {
+            out_stream: &'a mut O,
             had_data: bool,
         }
 
-        impl ChannelDataCallbacks for StdOutCollector<'_> {
+        impl<O: Write + ?Sized> ChannelDataCallbacks for OutCollector<'_, O> {
             fn on_string_data(
                 &mut self,
                 _channel: usize,
@@ -408,13 +480,13 @@ fn poll_rtt(
                     return Ok(());
                 }
                 self.had_data = true;
-                self.stdout.write_all(data.as_bytes())?;
+                self.out_stream.write_all(data.as_bytes())?;
                 Ok(())
             }
         }
 
-        let mut out = StdOutCollector {
-            stdout,
+        let mut out = OutCollector {
+            out_stream,
             had_data: false,
         };
 
diff --git a/probe-rs/src/bin/probe-rs/cmd/run/normal_run_mode.rs b/probe-rs/src/bin/probe-rs/cmd/run/normal_run_mode.rs
index 8a64b105..c7902273 100644
--- a/probe-rs/src/bin/probe-rs/cmd/run/normal_run_mode.rs
+++ b/probe-rs/src/bin/probe-rs/cmd/run/normal_run_mode.rs
@@ -1,4 +1,4 @@
-use crate::cmd::run::{RunLoop, RunMode};
+use crate::cmd::run::{OutputStream, RunLoop, RunMode};
 use anyhow::anyhow;
 use probe_rs::{BreakpointCause, Core, HaltReason, SemihostingCommand, Session};
 
@@ -53,6 +53,7 @@ impl RunMode for NormalRunMode {
             &mut core,
             self.run_options.catch_hardfault,
             self.run_options.catch_reset,
+            OutputStream::Stdout,
             None,
             halt_handler,
         )?;
diff --git a/probe-rs/src/bin/probe-rs/util/rtt.rs b/probe-rs/src/bin/probe-rs/util/rtt.rs
index 00c526ac..dc115020 100644
--- a/probe-rs/src/bin/probe-rs/util/rtt.rs
+++ b/probe-rs/src/bin/probe-rs/util/rtt.rs
@@ -39,7 +39,7 @@ pub fn attach_to_rtt(
         rtt_region = &exact_rtt_region;
     }
 
-    tracing::info!("Initializing RTT");
+    tracing::debug!("Initializing RTT");
 
     if let ScanRegion::Ranges(rngs) = &rtt_region {
         if rngs.is_empty() {
diff --git a/probe-rs/src/debug/debug_info.rs b/probe-rs/src/debug/debug_info.rs
index 2ac2de9a..c734f242 100644
--- a/probe-rs/src/debug/debug_info.rs
+++ b/probe-rs/src/debug/debug_info.rs
@@ -563,7 +563,7 @@ impl DebugInfo {
                     None
                 }
                 Err(e) => {
-                    tracing::warn!("UNWIND: Error while checking for exception context. The stack trace will not include the calling frames. : {}", e);
+                    tracing::debug!("UNWIND: Error while checking for exception context. The stack trace will not include the calling frames. : {}", e);
                     None
                 }
             };
diff --git a/probe-rs/src/rtt.rs b/probe-rs/src/rtt.rs
index ec67ce93..35749fd0 100644
--- a/probe-rs/src/rtt.rs
+++ b/probe-rs/src/rtt.rs
@@ -283,9 +283,7 @@ impl Rtt {
                     core.read(range.start, mem.as_mut()).ok()?;
                 }
 
-                let Some(offset) = kmp::kmp_find(&Self::RTT_ID, mem.as_slice()) else {
-                    return None;
-                };
+                let offset = kmp::kmp_find(&Self::RTT_ID, mem.as_slice())?;
 
                 let target_ptr = range.start + (offset as u64);
                 let Ok(target_ptr) = target_ptr.try_into() else {
diff --git a/probe-rs/src/semihosting.rs b/probe-rs/src/semihosting.rs
index 851fa760..0351b454 100644
--- a/probe-rs/src/semihosting.rs
+++ b/probe-rs/src/semihosting.rs
@@ -1,4 +1,4 @@
-use crate::{CoreInterface, Error};
+use crate::{CoreInterface, Error, RegisterValue};
 use anyhow::{bail, Result};
 
 /// Indicates the operation the target would like the debugger to perform.
@@ -59,10 +59,14 @@ pub struct UnknownCommandDetails {
 
 impl UnknownCommandDetails {
     /// Returns the buffer pointed-to by the parameter of the semihosting operation
-    #[allow(dead_code)] // TODO: remove once embedded-test uses this
-    pub(crate) fn get_buffer(&self, core: &mut dyn CoreInterface) -> Result<Buffer> {
+    pub fn get_buffer(&self, core: &mut dyn CoreInterface) -> Result<Buffer> {
         Buffer::from_block_at(core, self.parameter)
     }
+
+    /// Writes the status of the semihosting operation to the return register of the target
+    pub fn write_status(&self, core: &mut dyn CoreInterface, status: u32) -> Result<()> {
+        write_status(core, status)
+    }
 }
 
 /// A request to read the command line arguments from the target
@@ -72,21 +76,33 @@ pub struct GetCommandLineRequest(Buffer);
 impl GetCommandLineRequest {
     /// Writes the command line to the target. You have to continue the core manually afterwards.
     pub fn write_command_line_to_target(
-        &mut self,
+        &self,
         core: &mut dyn CoreInterface,
         cmdline: &str,
     ) -> Result<()> {
         let mut buf = cmdline.to_owned().into_bytes();
         buf.push(0);
-        self.0.write(core, &buf)
+        self.0.write(core, &buf)?;
+
+        // signal to target: status = success
+        write_status(core, 0)?;
+
+        Ok(())
     }
 }
 
+fn write_status(core: &mut dyn CoreInterface, value: u32) -> Result<()> {
+    let reg = core.registers().get_argument_register(0).unwrap();
+    core.write_core_reg(reg.into(), RegisterValue::U32(value))?;
+
+    Ok(())
+}
+
 // When using some semihosting commands, the target usually allocates a buffer for the host to read/write to.
 // The targets just gives us an address pointing to two u32 values, the address of the buffer and
 // the length of the buffer.
 #[derive(Debug, PartialEq, Eq, Copy, Clone)]
-pub(crate) struct Buffer {
+pub struct Buffer {
     buffer_location: u32, // The address where the buffer address and length are stored
     address: u32,         // The start of the buffer
     len: u32,             // The length of the buffer
@@ -94,7 +110,7 @@ pub(crate) struct Buffer {
 
 impl Buffer {
     /// Constructs a new buffer, reading the address and length from the target.
-    pub(crate) fn from_block_at(core: &mut dyn CoreInterface, block_addr: u32) -> Result<Self> {
+    pub fn from_block_at(core: &mut dyn CoreInterface, block_addr: u32) -> Result<Self> {
         let mut block: [u32; 2] = [0, 0];
         core.read_32(block_addr as u64, &mut block)?;
         Ok(Self {
@@ -105,8 +121,7 @@ impl Buffer {
     }
 
     /// Reads the buffer contents from the target.
-    #[allow(dead_code)] // TODO: remove once embedded-test uses this
-    pub(crate) fn read(&mut self, core: &mut dyn CoreInterface) -> Result<Vec<u8>> {
+    pub fn read(&self, core: &mut dyn CoreInterface) -> Result<Vec<u8>> {
         let mut buf = vec![0u8; self.len as usize];
         core.read(self.address as u64, &mut buf[..])?;
         Ok(buf)
@@ -114,7 +129,7 @@ impl Buffer {
 
     /// Writes the passed buffer to the target buffer.
     /// The buffer must end with \0. Length written to target will not include \0.
-    pub(crate) fn write(&mut self, core: &mut dyn CoreInterface, buf: &[u8]) -> Result<()> {
+    pub fn write(&self, core: &mut dyn CoreInterface, buf: &[u8]) -> Result<()> {
         if buf.len() > self.len as usize {
             bail!("buffer not large enough")
         }
@@ -173,11 +188,21 @@ pub fn decode_semihosting_syscall(
             }
         }
 
-        (SYS_GET_CMDLINE, block_address) => SemihostingCommand::GetCommandLine(
-            GetCommandLineRequest(Buffer::from_block_at(core, block_address)?),
-        ),
+        (SYS_GET_CMDLINE, block_address) => {
+            // signal to target: status = failure, in case the application does not answer this request
+            // 255 or -1 is the error value for SYS_GET_CMDLINE
+            write_status(core, 255)?;
 
+            SemihostingCommand::GetCommandLine(GetCommandLineRequest(Buffer::from_block_at(
+                core,
+                block_address,
+            )?))
+        }
         _ => {
+            // signal to target: status = failure, in case the application does not answer this request
+            // It is not guaranteed that a value of 255 will be treated as an error by the target, but it is a common value to indicate an error.
+            write_status(core, 255)?;
+
             tracing::debug!(
                 "Unknown semihosting operation={operation:04x} parameter={parameter:04x}"
             );
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 00000000..163f7eb9
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,4 @@
+[toolchain]
+channel = "1.77.0"
+
+components = ["rustfmt", "clippy"]


Test patch:
diff --git a/.github/workflows/smoketest.yml b/.github/workflows/smoketest.yml
index 6c9743c0..f9f436e0 100644
--- a/.github/workflows/smoketest.yml
+++ b/.github/workflows/smoketest.yml
@@ -19,10 +19,8 @@ jobs:
       - name: Checkout sources
         uses: actions/checkout@v4
 
-      - uses: ./.github/actions/setup
-        with:
-          targets:
-            aarch64-unknown-linux-gnu
+      - name: Install target for cross-compilation
+        run: rustup target add aarch64-unknown-linux-gnu
 
       - name: Install cross 
         uses: taiki-e/install-action@v2.29.3
diff --git a/changelog/added-embedded-test-integration.md b/changelog/added-embedded-test-integration.md
new file mode 100644
index 00000000..31bd21ec
--- /dev/null
+++ b/changelog/added-embedded-test-integration.md
@@ -0,0 +1,1 @@
+Added support to run embedded-test testcases
diff --git a/probe-rs/src/bin/probe-rs/cmd/run/test_run_mode.rs b/probe-rs/src/bin/probe-rs/cmd/run/test_run_mode.rs
new file mode 100644
index 00000000..8a75ebcc
--- /dev/null
+++ b/probe-rs/src/bin/probe-rs/cmd/run/test_run_mode.rs
@@ -0,0 +1,321 @@
+use crate::cmd::run::{print_stacktrace, OutputStream, ReturnReason, RunLoop, RunMode};
+use anyhow::{anyhow, Result};
+use libtest_mimic::{Arguments, Failed, FormatSetting, Trial};
+use probe_rs::{BreakpointCause, Core, HaltReason, SemihostingCommand, Session};
+use serde::Deserialize;
+use std::path::Path;
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+/// Options only used when in test run mode
+#[derive(Debug, clap::Parser)]
+pub struct TestOptions {
+    /// Filter string. Only tests which contain this string are run.
+    #[clap(
+        index = 2,
+        value_name = "TEST_FILTER",
+        help = "The TEST_FILTER string is tested against the name of all tests, and only those tests whose names contain the filter are run. Multiple filter strings may be passed, which will run all tests matching any of the filters.",
+        help_heading = "TEST OPTIONS"
+    )]
+    pub filter: Vec<String>,
+
+    /// Only list all tests
+    #[clap(
+        long = "list",
+        help = "List all tests instead of executing them",
+        help_heading = "TEST OPTIONS"
+    )]
+    pub list: bool,
+
+    #[clap(
+        long = "format",
+        value_enum,
+        value_name = "pretty|terse|json",
+        help_heading = "TEST OPTIONS",
+        help = "Configure formatting of the test report output"
+    )]
+    pub format: Option<FormatSetting>,
+
+    /// If set, filters are matched exactly rather than by substring.
+    #[clap(long = "exact", help_heading = "TEST OPTIONS")]
+    pub exact: bool,
+
+    /// Options which are ignored, but exist for compatibility with libtest.
+    /// E.g. so that vscode and intellij can invoke the test runner with the args they are used to
+    #[clap(flatten)]
+    _no_op: NoOpTestOptions,
+}
+
+/// Options which are ignored, but exist for compatibility with libtest.
+#[derive(Debug, clap::Parser)]
+struct NoOpTestOptions {
+    // No-op, ignored (libtest-mimic always runs in no-capture mode)
+    #[clap(long = "nocapture", hide = true)]
+    nocapture: bool,
+
+    /// No-op, ignored. libtest-mimic does not currently capture stdout.
+    #[clap(long = "show-output", hide = true)]
+    show_output: bool,
+
+    /// No-op, ignored. Flag only exists for CLI compatibility with libtest.
+    #[clap(short = 'Z', hide = true)]
+    unstable_flags: Option<String>,
+}
+
+/// Test run mode
+pub struct TestRunMode {
+    libtest_args: Arguments,
+}
+
+impl TestRunMode {
+    pub fn new(test_options: &TestOptions) -> Box<Self> {
+        Box::new(Self {
+            libtest_args: Arguments {
+                test_threads: Some(1), // Avoid parallel execution
+                list: test_options.list,
+                exact: test_options.exact,
+                format: test_options.format,
+                filter: if test_options.filter.is_empty() {
+                    None
+                } else {
+                    //TODO: Fix libtest-mimic so that it allows multiple filters (same as std test runners)
+                    Some(test_options.filter.join(" "))
+                },
+                ..Arguments::default()
+            },
+        })
+    }
+
+    /// Asks the target for the tests, and create a "run the test"-closure for each test.
+    /// libtest-mimic is in charge of selecting the tests to run based on the filter and other options
+    fn create_tests(session_and_runloop_ref: Arc<Mutex<SessionAndRunLoop>>) -> Result<Vec<Trial>> {
+        let mut session_and_runloop = session_and_runloop_ref.lock().unwrap();
+        let list = Self::list_tests(&mut session_and_runloop)?;
+
+        let mut tests = Vec::<Trial>::new();
+        for t in &list.tests {
+            let test = t.clone();
+            let session_and_runloop = session_and_runloop_ref.clone();
+            tests.push(
+                Trial::test(&t.name, move || {
+                    let mut session_and_runloop = session_and_runloop.lock().unwrap();
+                    Self::run_test(test, &mut session_and_runloop)
+                })
+                .with_ignored_flag(t.ignored),
+            )
+        }
+        Ok(tests)
+    }
+
+    const SEMIHOSTING_USER_LIST: u32 = 0x100;
+
+    /// Requests all tests from the target via Semihosting back and forth
+    fn list_tests(session_and_runloop: &mut SessionAndRunLoop) -> Result<Tests> {
+        let mut core = session_and_runloop.session.core(0)?;
+
+        let mut cmdline_requested = false;
+
+        // When the target first invokes SYS_GET_CMDLINE (0x15), we answer "list"
+        // Then, we wait until the target invokes SEMIHOSTING_USER_LIST (0x100) with the json containing all tests
+        let halt_handler = |halt_reason: HaltReason, core: &mut Core| {
+            match halt_reason {
+                HaltReason::Breakpoint(BreakpointCause::Semihosting(cmd)) => match cmd {
+                    SemihostingCommand::GetCommandLine(request) if !cmdline_requested => {
+                        tracing::debug!("target asked for cmdline. send 'list'");
+                        cmdline_requested = true;
+                        request.write_command_line_to_target(core, "list")?;
+                        Ok(None) // Continue running
+                    }
+                    SemihostingCommand::Unknown(details)
+                        if details.operation == Self::SEMIHOSTING_USER_LIST
+                            && cmdline_requested =>
+                    {
+                        let buf = details.get_buffer(core)?;
+                        let buf = buf.read(core)?;
+                        let list: Tests = serde_json::from_slice(&buf[..])?;
+                        details.write_status(core, 0)?; // Signal status=success back to the target
+                        tracing::debug!("got list of tests from target: {:?}", list);
+                        if list.version != 1 {
+                            Err(anyhow!(
+                                "Unsupported test list format version: {}",
+                                list.version
+                            ))
+                        } else {
+                            Ok(Some(list))
+                        }
+                    }
+                    other => Err(anyhow!(
+                        "Unexpected semihosting command {:?} cmdline_requested: {:?}",
+                        other,
+                        cmdline_requested
+                    )),
+                },
+                _ => Err(anyhow!("CPU halted unexpectedly.")),
+            }
+        };
+
+        match session_and_runloop.run_loop.run_until(
+            &mut core,
+            true,
+            true,
+            OutputStream::Stderr,
+            Some(Duration::from_secs(5)),
+            halt_handler,
+        )? {
+            ReturnReason::User => Err(anyhow!(
+                "The user pressed ctrl+c before the target responded with the test list."
+            )),
+            ReturnReason::Predicate(tests) => Ok(tests),
+            ReturnReason::Timeout => Err(anyhow!(
+                "The target did not respond with test list until timeout."
+            )),
+        }
+    }
+
+    /// Runs a single test on the target
+    fn run_test(
+        test: Test,
+        session_and_runloop: &mut SessionAndRunLoop,
+    ) -> std::result::Result<(), Failed> {
+        let core = &mut session_and_runloop.session.core(0)?;
+        tracing::info!("Running test {}", test.name);
+        core.reset_and_halt(Duration::from_millis(100))?;
+
+        let timeout = test.timeout.map(|t| Duration::from_secs(t as u64));
+        let timeout = timeout.unwrap_or(Duration::from_secs(60)); // TODO: make global timeout configurable: https://github.com/probe-rs/embedded-test/issues/3
+        let mut cmdline_requested = false;
+
+        // When the target first invokes SYS_GET_CMDLINE (0x15), we answer "run <test_name>
+        // Then we wait until the target invokes SYS_EXIT (0x18) or SYS_EXIT_EXTENDED(0x20) with the exit code
+        let halt_handler = |halt_reason: HaltReason, core: &mut Core| {
+            match halt_reason {
+                HaltReason::Breakpoint(BreakpointCause::Semihosting(cmd)) => match cmd {
+                    SemihostingCommand::GetCommandLine(request) if !cmdline_requested => {
+                        let cmdline = format!("run {}", test.name);
+                        tracing::debug!("target asked for cmdline. send '{}'", cmdline.as_str());
+                        cmdline_requested = true;
+                        request.write_command_line_to_target(core, cmdline.as_str())?;
+                        Ok(None) // Continue running
+                    }
+                    SemihostingCommand::ExitSuccess if cmdline_requested => {
+                        Ok(Some(TestOutcome::Pass))
+                    }
+
+                    SemihostingCommand::ExitError(_) if cmdline_requested => {
+                        Ok(Some(TestOutcome::Panic))
+                    }
+
+                    other => {
+                        // Invalid sequence of semihosting calls => Abort testing altogether
+                        Err(anyhow!(
+                            "Unexpected semihosting command {:?} cmdline_requested: {:?}",
+                            other,
+                            cmdline_requested
+                        ))
+                    }
+                },
+                e => {
+                    // Exception occurred (e.g. hardfault) => Abort testing altogether
+                    Err(anyhow!("The CPU halted unexpectedly: {:?}. Test should signal failure via a panic handler that calls `semihosting::proces::abort()` instead", e))
+                }
+            }
+        };
+
+        match session_and_runloop.run_loop.run_until(
+            core,
+            true,
+            true,
+            OutputStream::Stderr,
+            Some(timeout),
+            halt_handler,
+        ) {
+            Ok(ReturnReason::Timeout) => {
+                Err(Failed::from(format!("Test timed out after {:?}", timeout)))
+            }
+            Ok(ReturnReason::User) => {
+                eprintln!("Test {} was aborted by the user with CTRL + C", test.name);
+                // We do not mark the test as failed and instead exit the process
+                std::process::exit(1);
+            }
+            Ok(ReturnReason::Predicate(outcome)) => {
+                if outcome == test.expected_outcome {
+                    Ok(())
+                } else {
+                    if outcome == TestOutcome::Panic {
+                        print_stacktrace(
+                            core,
+                            Path::new(session_and_runloop.run_loop.path.as_str()),
+                            &mut std::io::stderr(),
+                        )?;
+                    }
+                    Err(Failed::from(format!(
+                        "Test should {:?} but it did {:?}",
+                        test.expected_outcome, outcome
+                    )))
+                }
+            }
+            Err(e) => {
+                // Probe-rs error: We do not mark the test as failed and instead exit the process
+                eprintln!("Error: {:?}", e);
+                std::process::exit(1);
+            }
+        }
+    }
+}
+
+#[derive(Debug, serde::Deserialize)]
+struct Tests {
+    pub version: u32,
+    pub tests: Vec<Test>,
+}
+
+#[derive(PartialEq, Debug, Clone, Copy)]
+enum TestOutcome {
+    Panic,
+    Pass,
+}
+
+#[derive(Debug, Clone, serde::Deserialize)]
+struct Test {
+    pub name: String,
+    #[serde(
+        rename = "should_panic",
+        deserialize_with = "outcome_from_should_panic"
+    )]
+    pub expected_outcome: TestOutcome,
+    pub ignored: bool,
+    pub timeout: Option<u32>,
+}
+
+fn outcome_from_should_panic<'de, D>(deserializer: D) -> std::result::Result<TestOutcome, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let should_panic = bool::deserialize(deserializer)?;
+    Ok(if should_panic {
+        TestOutcome::Panic
+    } else {
+        TestOutcome::Pass
+    })
+}
+
+impl RunMode for TestRunMode {
+    fn run(&self, session: Session, run_loop: RunLoop) -> Result<()> {
+        tracing::info!("libtest args {:?}", self.libtest_args);
+
+        // Unfortunately libtest-mimic wants test functions to live for 'static, so we need to use a mutex to share the session and runloop
+        let session_and_runloop = Arc::new(Mutex::new(SessionAndRunLoop { session, run_loop }));
+
+        let tests = Self::create_tests(session_and_runloop)?;
+        if libtest_mimic::run(&self.libtest_args, tests).has_failed() {
+            Err(anyhow!("Some tests failed"))
+        } else {
+            Ok(())
+        }
+    }
+}
+
+struct SessionAndRunLoop {
+    session: Session,
+    run_loop: RunLoop,
+}