rigless 0.1.0

Virtual hardware-in-the-loop runtime: run unmodified firmware on an emulated MCU with every peripheral, chip model and unit of virtual time in Rust, and a CPU core of its own that translates the firmware's instructions to host code
Documentation
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Arm's semihosting on the M profile, from "Semihosting for AArch32 and AArch64", release 2025Q4:
//! the call the core stopped at with `BKPT #0xAB`, read from `r0`, the operation number, and `r1`,
//! the parameter or the address of a block of 32-bit fields in the part's byte order, as "The
//! semihosting interface" gives them. A section here is the operation's own in that document.

use crate::emulator::{HostCall, OpenMode};
use crate::soc::Width;

/// Each operation the run serves, by its number.
pub(super) mod operation {
    pub(super) const OPEN: u32 = 0x01;
    pub(super) const CLOSE: u32 = 0x02;
    pub(super) const WRITEC: u32 = 0x03;
    pub(super) const WRITE0: u32 = 0x04;
    pub(super) const WRITE: u32 = 0x05;
    pub(super) const READ: u32 = 0x06;
    pub(super) const ISTTY: u32 = 0x09;
    pub(super) const SEEK: u32 = 0x0A;
    pub(super) const FLEN: u32 = 0x0C;
    pub(super) const CLOCK: u32 = 0x10;
    pub(super) const TIME: u32 = 0x11;
    pub(super) const ERRNO: u32 = 0x13;
    pub(super) const EXIT: u32 = 0x18;
    pub(super) const EXIT_EXTENDED: u32 = 0x20;
    pub(in super::super) const ELAPSED: u32 = 0x30;
    pub(super) const TICKFREQ: u32 = 0x31;
}

/// `SYS_EXIT`'s reason for an application that's done, `ADP_Stopped_ApplicationExit`.
const APPLICATION_EXIT: u64 = 0x2_0026;

/// The exit status of a run a firmware ended for any other reason `SYS_EXIT` gives.
const FAILED: u32 = 1;

/// `SYS_OPEN`'s name for the console.
const CONSOLE: &[u8] = b":tt";

/// The console's handles: 0 to read it and 1 to write it, GDB's own descriptors for its console,
/// so a call on one goes to GDB as it is.
// ceiling: `:tt` opened for reading answers 0 where `SYS_OPEN` says a handle is nonzero; upgrade
// when a firmware that takes 0 as a failure reads the console
const CONSOLE_IN: u64 = 0;
const CONSOLE_OUT: u64 = 1;

/// The longest string `SYS_WRITE0` writes before the run takes it as unterminated.
// ceiling: a SYS_WRITE0 of 64 KiB; upgrade when a firmware prints more in one call
const LONGEST_STRING: u64 = 64 << 10;

/// The call `operation` asks for with `parameter`, reading the guest's memory with `read`, or
/// `None` for one the run doesn't serve or whose block isn't in memory.
pub(super) fn decode(
    operation: u32,
    parameter: u32,
    read: &mut dyn FnMut(u64, Width) -> Option<u64>,
) -> Option<HostCall> {
    let block = u64::from(parameter);
    let mut field = |index: u64| read(block.checked_add(4 * index)?, Width::W32);
    Some(match operation {
        // SYS_OPEN: the name, the mode from 0 to 11, and the name's length.
        operation::OPEN => {
            let (path, mode, length) = (field(0)?, field(1)?, field(2)?);
            let mode = open_mode(mode)?;
            if is_console(path, length, read) {
                let handle = if matches!(mode, OpenMode::Read | OpenMode::ReadWrite) {
                    CONSOLE_IN
                } else {
                    CONSOLE_OUT
                };
                HostCall::OpenConsole { handle }
            } else {
                HostCall::Open { path, length, mode }
            }
        }
        operation::CLOSE => HostCall::Close { handle: field(0)? },
        // SYS_WRITEC: the character at the parameter, to the debug channel.
        operation::WRITEC => HostCall::Write {
            handle: CONSOLE_OUT,
            address: block,
            length: 1,
        },
        // SYS_WRITE0: the NUL-terminated string at the parameter.
        operation::WRITE0 => HostCall::Write {
            handle: CONSOLE_OUT,
            address: block,
            length: string_length(block, read)?,
        },
        operation::WRITE => HostCall::Write {
            handle: field(0)?,
            address: field(1)?,
            length: field(2)?,
        },
        operation::READ => HostCall::Read {
            handle: field(0)?,
            address: field(1)?,
            length: field(2)?,
        },
        operation::ISTTY => HostCall::IsTty { handle: field(0)? },
        operation::SEEK => HostCall::Seek {
            handle: field(0)?,
            position: field(1)?,
        },
        operation::FLEN => HostCall::Length { handle: field(0)? },
        operation::CLOCK => HostCall::Clock,
        operation::TIME => HostCall::Time,
        operation::ERRNO => HostCall::Errno,
        // SYS_EXIT: on a 32-bit caller the parameter is the reason itself, with no status.
        operation::EXIT => HostCall::Exit {
            code: if block == APPLICATION_EXIT { 0 } else { FAILED },
        },
        // SYS_EXIT_EXTENDED: the reason, then the status an application that's done exits with.
        operation::EXIT_EXTENDED => HostCall::Exit {
            code: if field(0)? == APPLICATION_EXIT {
                u32::try_from(field(1)?).ok()?
            } else {
                FAILED
            },
        },
        operation::ELAPSED => HostCall::Elapsed { block },
        operation::TICKFREQ => HostCall::TickFrequency,
        _ => return None,
    })
}

/// `SYS_OPEN`'s modes, 0 to 11, as the ISO C `fopen` modes "Value of mode" gives them: `r`, `rb`,
/// `r+`, `r+b`, then the same four for `w` and for `a`. The binary ones are the same here.
fn open_mode(mode: u64) -> Option<OpenMode> {
    Some(match mode {
        0 | 1 => OpenMode::Read,
        2 | 3 => OpenMode::ReadWrite,
        4 | 5 => OpenMode::Write,
        6 | 7 => OpenMode::WriteRead,
        8 | 9 => OpenMode::Append,
        10 | 11 => OpenMode::AppendRead,
        _ => return None,
    })
}

/// Whether the `length` bytes at `path` are the console's name.
fn is_console(path: u64, length: u64, read: &mut dyn FnMut(u64, Width) -> Option<u64>) -> bool {
    length == CONSOLE.len() as u64
        && (path..)
            .zip(CONSOLE)
            .all(|(at, &byte)| read(at, Width::W8) == Some(u64::from(byte)))
}

/// How many bytes the string at `address` has before its NUL, or `None` when it has none within
/// [`LONGEST_STRING`] or runs out of memory first.
fn string_length(address: u64, read: &mut dyn FnMut(u64, Width) -> Option<u64>) -> Option<u64> {
    (0..LONGEST_STRING).find_map(
        |offset| match read(address.checked_add(offset)?, Width::W8) {
            Some(0) => Some(Some(offset)),
            Some(_) => None,
            None => Some(None),
        },
    )?
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::laid::Laid;

    /// Memory of words and bytes laid out by hand.
    fn memory(words: &[(u64, u32)], bytes: &[(u64, &[u8])]) -> Laid {
        let mut memory = Laid::default();
        for &(at, word) in words {
            memory.word(at, word);
        }
        for &(at, string) in bytes {
            memory.bytes(at, string);
        }
        memory
    }

    fn reader(memory: &Laid) -> impl Fn(u64, Width) -> Option<u64> + '_ {
        |address, width| memory.read(address, width)
    }

    const BLOCK: u32 = 0x2000_0100;
    const NAME: u32 = 0x2000_0200;

    #[test]
    fn each_operation_decodes_as_the_specification_gives_it() {
        let laid = memory(
            &[
                (u64::from(BLOCK), NAME),
                (u64::from(BLOCK) + 4, 6),
                (u64::from(BLOCK) + 8, 4),
            ],
            &[(u64::from(NAME), b"data\0hello\0")],
        );
        let mut read = reader(&laid);
        let mut call = |operation, parameter| decode(operation, parameter, &mut read);
        let path = u64::from(NAME);
        assert_eq!(
            call(0x01, BLOCK),
            Some(HostCall::Open {
                path,
                length: 4,
                mode: OpenMode::WriteRead
            })
        );
        assert_eq!(call(0x02, BLOCK), Some(HostCall::Close { handle: path }));
        assert_eq!(
            call(0x03, NAME),
            Some(HostCall::Write {
                handle: 1,
                address: path,
                length: 1
            })
        );
        assert_eq!(
            call(0x04, NAME + 5),
            Some(HostCall::Write {
                handle: 1,
                address: path + 5,
                length: 5
            }),
            "SYS_WRITE0 up to its NUL"
        );
        let fields = HostCall::Write {
            handle: path,
            address: 6,
            length: 4,
        };
        assert_eq!(call(0x05, BLOCK), Some(fields));
        assert_eq!(
            call(0x06, BLOCK),
            Some(HostCall::Read {
                handle: path,
                address: 6,
                length: 4
            })
        );
        assert_eq!(call(0x09, BLOCK), Some(HostCall::IsTty { handle: path }));
        assert_eq!(
            call(0x0A, BLOCK),
            Some(HostCall::Seek {
                handle: path,
                position: 6
            })
        );
        assert_eq!(call(0x0C, BLOCK), Some(HostCall::Length { handle: path }));
        assert_eq!(call(0x10, 0), Some(HostCall::Clock));
        assert_eq!(call(0x11, 0), Some(HostCall::Time));
        assert_eq!(call(0x13, 0), Some(HostCall::Errno));
        assert_eq!(
            call(0x30, BLOCK),
            Some(HostCall::Elapsed {
                block: u64::from(BLOCK)
            })
        );
        assert_eq!(call(0x31, 0), Some(HostCall::TickFrequency));
        assert_eq!(call(0x18, 0x2_0026), Some(HostCall::Exit { code: 0 }));
        assert_eq!(call(0x18, 0x2_0023), Some(HostCall::Exit { code: 1 }));
        for unserved in [0x07, 0x08, 0x0D, 0x0E, 0x0F, 0x12, 0x15, 0x16, 0x32, 0x100] {
            assert_eq!(call(unserved, BLOCK), None, "{unserved:#x}");
        }
        assert_eq!(call(0x05, 0x3000_0000), None, "a block outside memory");
        assert_eq!(
            call(0x04, NAME + 11),
            None,
            "a string with no NUL in memory"
        );
    }

    /// `SYS_EXIT_EXTENDED` exits with the status an application that's done gives, and with 1 for
    /// any other reason.
    #[test]
    fn an_extended_exit_carries_its_status() {
        let exit = |reason, status| {
            let laid = memory(
                &[(u64::from(BLOCK), reason), (u64::from(BLOCK) + 4, status)],
                &[],
            );
            decode(0x20, BLOCK, &mut reader(&laid))
        };
        assert_eq!(exit(0x2_0026, 3), Some(HostCall::Exit { code: 3 }));
        assert_eq!(exit(0x2_0024, 3), Some(HostCall::Exit { code: 1 }));
    }

    /// The modes pair up, binary and not, and `:tt` is the console, 0 to read it and 1 to write
    /// or append to it.
    #[test]
    fn open_takes_each_mode_and_the_console_by_its_name() {
        let open = |mode, name: &[u8]| {
            let length = u32::try_from(name.len()).unwrap();
            let laid = memory(
                &[
                    (u64::from(BLOCK), NAME),
                    (u64::from(BLOCK) + 4, mode),
                    (u64::from(BLOCK) + 8, length),
                ],
                &[(u64::from(NAME), name)],
            );
            decode(0x01, BLOCK, &mut reader(&laid))
        };
        let modes = [
            OpenMode::Read,
            OpenMode::ReadWrite,
            OpenMode::Write,
            OpenMode::WriteRead,
            OpenMode::Append,
            OpenMode::AppendRead,
        ];
        for (mode, expected) in (0..12).zip(modes.iter().flat_map(|&mode| [mode, mode])) {
            assert_eq!(
                open(mode, b"log"),
                Some(HostCall::Open {
                    path: u64::from(NAME),
                    length: 3,
                    mode: expected
                }),
                "mode {mode}"
            );
        }
        assert_eq!(open(12, b"log"), None);
        assert_eq!(open(0, b":tt"), Some(HostCall::OpenConsole { handle: 0 }));
        assert_eq!(open(4, b":tt"), Some(HostCall::OpenConsole { handle: 1 }));
        assert_eq!(open(8, b":tt"), Some(HostCall::OpenConsole { handle: 1 }));
        assert!(matches!(open(0, b":tty"), Some(HostCall::Open { .. })));
    }
}