From cb05d603d61b14777cc43bf36e46191d8e19d23c Mon Sep 17 00:00:00 2001 From: Jon Olson Date: Sat, 26 Sep 2026 15:59:36 -0700 Subject: [PATCH 1/3] Read Cortex-M0 registers through the acquired target. Callers could halt a Cortex-M0 but could not inspect its registers through the target owner. Add typed register reads while halted, including when the halt belongs to another debugger. Track transfers before selecting a register so cleanup cannot resume the processor or disable debug while completion is uncertain. Release waits for a pending transfer without replaying the selection; reset or loss of Debug state prevents automatic cleanup. --- README.md | 3 +- docs/architecture.md | 7 +- docs/capabilities.md | 3 +- docs/composition.md | 4 +- docs/cortexm.md | 35 +++++- target/cortexm/control.go | 9 ++ target/cortexm/identity.go | 4 +- target/cortexm/register.go | 136 +++++++++++++++++++++++ target/cortexm/register_failure_test.go | 123 +++++++++++++++++++++ target/cortexm/register_memory_test.go | 118 ++++++++++++++++++++ target/cortexm/register_read_test.go | 141 ++++++++++++++++++++++++ 11 files changed, 572 insertions(+), 11 deletions(-) create mode 100644 target/cortexm/register.go create mode 100644 target/cortexm/register_failure_test.go create mode 100644 target/cortexm/register_memory_test.go create mode 100644 target/cortexm/register_read_test.go diff --git a/README.md b/README.md index bd62c3f..d5e60d8 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ add posted access-port reads and a Cortex-M identity read through a MEM-AP. They compose the public packages explicitly without duplicating their framing. The `target/cortexm` package reads and decodes the architectural CPUID value through any compatible target-word reader. It also provides acquired Cortex-M0 -halt/resume control over word memory; see [Cortex-M control](docs/cortexm.md). +halt/resume control and halted register reads over word memory; see +[Cortex-M control](docs/cortexm.md). The FTDI path uses the standard H-series MPSSE port and endpoint layout. Descriptor-driven FTDI port binding is not implemented yet. J-Link instead diff --git a/docs/architecture.md b/docs/architecture.md index 7321084..db35fff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,7 @@ debugger service. | `dap` | Bind SW-DP or baseline ADIv5 JTAG-DP, manage identity and power, execute ordered DP/AP transactions, and provide scalar or block MEM-AP access. | | `dap/sim` | Model the DP, AP, and byte-addressed target-memory state consumed by `dap`. | | `coresight` | Identify debug components and walk ROM tables through borrowed scalar memory, with explicit bounds and no resource acquisition or target-memory writes. | -| `target/cortexm` | Identify Cortex-M processors and own Cortex-M0 halting debug over borrowed word memory. | +| `target/cortexm` | Identify Cortex-M processors and own Cortex-M0 halting debug and register reads over borrowed word memory. | | `examples/...` | Demonstrate public package compositions as executable programs. | | `cmd/ost` | Provide a small command hierarchy over the same public packages. | @@ -336,8 +336,9 @@ component identity](coresight.md) for its register and failure boundaries. `target/cortexm` identifies processors through a word reader. Cortex-M0 control also requires a word writer that waits for each access to complete. -The target owns DHCSR control and its halt requests, and must be released -before the memory owner. +The target owns DHCSR control, its halt requests, and pending register +transfers. Release settles a pending transfer before restoring debug control; +the target must be released before the memory owner. It does not know about USB, adapters, or wire protocols. See [Cortex-M control](cortexm.md) for restoration and failure boundaries. diff --git a/docs/capabilities.md b/docs/capabilities.md index 2d90cce..0b62162 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -304,7 +304,8 @@ layouts and power-domain skips have hardware-independent test coverage. | Physical identity read | HIL | Opt-in FTDI/SWD/DAP/MEM-AP integration test. | | Cortex-M0 acquisition and halt/resume | HIL | Two CMSIS-DAP micro:bit sessions at a requested 1 MHz stopped a CPU counter during halt and observed progress after resume and release. Both restored initially disabled debug and running state before Arm debug owner close. Earlier sessions preserved initially enabled debug. Cleanup failures remain covered only by behavioral tests; see the [control evidence](cortexm.md#hardware-evidence). | | Step | No | No single-step API exists. | -| Register access | No | CPUID decoding is not a general core-register interface. | +| Register reads | Yes | Halted Cortex-M0 R0–R12, SP, LR, PC, XPSR, MSP, and PSP through `ReadRegister`. Behavioral tests cover transfer completion and cleanup; no physical register evidence yet. | +| Register writes | No | No core-register write API exists. | | Reset | No | No architectural or pin-reset operation exists. | | Breakpoints or watchpoints | No | No target instrumentation API exists. | | Firmware or runtime loading | No | No ELF loader, image-placement policy, or flash driver exists. | diff --git a/docs/composition.md b/docs/composition.md index adb8c9b..647b521 100644 --- a/docs/composition.md +++ b/docs/composition.md @@ -37,6 +37,7 @@ data-register write can write target memory. | Inspect ROM entries or a bounded component hierarchy | `Component.ROMTable`, `ROMTable.ReadEntry`, `coresight.Walk` | `examples/simple/coresight-info -walk` | | Identify a Cortex-M through any compatible word reader | `cortexm.Identify` | `examples/simple/cortexm-info` | | Acquire, halt, and resume a Cortex-M0 | `cortexm.Acquire`, `Target.Halt`, `Target.Resume`, `Target.Release` | `examples/simple/cortexm-control` | +| Read a halted Cortex-M0 register | `Target.ReadRegister` | [Register reads](cortexm.md#register-reads) | | Test SWD and DAP behavior without hardware | `swd/sim`, `dap/sim` | Package tests | The examples are intentionally small, executable compositions of public @@ -745,7 +746,8 @@ CSW, then release and reconnect the debug port. Use `target/cortexm` when the desired result is processor identity. It accepts the word-reader behavior supplied by `dap.MemAP`, so target code remains independent of the host, adapter, and wire protocol. `cortexm.Acquire` also -uses `WriteWord` to enable Cortex-M0 halting debug. Release that target before +uses `WriteWord` to enable Cortex-M0 halting debug. Use `ReadRegister` for +halted core registers so the target can track transfer completion. Release it before its memory owner and retain both after failed target restoration. See [Cortex-M control](cortexm.md) for the full composition and effects. diff --git a/docs/cortexm.md b/docs/cortexm.md index c26845d..21e9aad 100644 --- a/docs/cortexm.md +++ b/docs/cortexm.md @@ -62,10 +62,39 @@ disabled debug waits until the processor is running. DHCSR reads consume the sticky reset and instruction-retirement indicators. The package does not restore those indicators or clear DFSR event flags. -The implementation follows Arm DDI 0419E, sections C1.5 and C1.6.3 of the +The implementation follows Arm DDI 0419E, sections C1.5 and C1.6.3–C1.6.5 of the [Armv6-M Architecture Reference Manual](https://documentation-service.arm.com/static/5f8ff05ef86e16515cdbf826). -It does not implement reset, single-step, general register access, breakpoints, -or watchpoints. +It does not implement reset, single-step, register writes, breakpoints, or +watchpoints. + +## Register reads + +`ReadRegister` reads R0–R12, SP, LR, PC, XPSR, MSP, or PSP from a halted +processor. SP selects the current stack pointer; MSP and PSP select its banks. +PC is the debug return address. An inherited halt permits inspection without +acquiring permission to resume. Invalid `Register` identifiers, including +zero, are rejected before memory traffic. + +```go +pc, err := core.ReadRegister(ctx, cortexm.PC) +``` + +Reads write DCRSR and replace DCRDR; these transfer registers are not restored. +The target waits for S_REGRDY before and after selecting a register, with the +same five-second bound as control operations. It does not require observing +S_REGRDY clear, since a transfer may finish before the first status read. + +A failed transfer leaves only `Release` available. Release waits for any +pending transfer, including one found busy before selection, before resuming +or disabling debug. It never replays a selector write whose completion is +uncertain. A failed precondition or cancellation before selection leaves the +target usable when no transfer is pending. An error returns no register value. + +Reset or loss of Debug state during a pending transfer prevents automatic +cleanup, even if a later status read would show ready. The target cannot prove +that the original transfer completed. Retain both owners; there is no forced +cleanup operation for this state. These failures have behavioral test coverage, +not physical failure-injection evidence. ## Composition diff --git a/target/cortexm/control.go b/target/cortexm/control.go index 4256a49..91fa1d6 100644 --- a/target/cortexm/control.go +++ b/target/cortexm/control.go @@ -39,6 +39,8 @@ type Target struct { haltOwned bool haltUncertain bool resumeUncertain bool + registerPending bool + registerLost bool } // Acquire enables Cortex-M0 halting debug without requesting a halt. It reads @@ -117,6 +119,8 @@ func (t *Target) Identity() Identity { // memory and cannot repair a disconnected or invalidated memory client. It // never repeats a completed resume. An unconfirmed control change, or a new halt while // restoring disabled debug, can prevent cleanup until execution resumes. +// Pending register transfers must settle first. Reset or loss of Debug state +// during a transfer prevents automatic cleanup. func (t *Target) Release(ctx context.Context) error { if t == nil || t.memory == nil { return nil @@ -127,6 +131,11 @@ func (t *Target) Release(ctx context.Context) error { } ctx, cancel := context.WithTimeout(ctx, controlTimeout) defer cancel() + if t.registerPending { + if err := t.waitRegister(ctx); err != nil { + return err + } + } if t.changed { if err := t.restore(ctx); err != nil { return fmt.Errorf("cortexm: restore debug control: %w", err) diff --git a/target/cortexm/identity.go b/target/cortexm/identity.go index db3aad9..4ec80eb 100644 --- a/target/cortexm/identity.go +++ b/target/cortexm/identity.go @@ -1,5 +1,5 @@ -// Package cortexm identifies Cortex-M processors and controls Cortex-M0 -// halting debug through target memory. +// Package cortexm identifies Cortex-M processors and provides Cortex-M0 +// halting debug and register reads through target memory. package cortexm import ( diff --git a/target/cortexm/register.go b/target/cortexm/register.go new file mode 100644 index 0000000..b099fa4 --- /dev/null +++ b/target/cortexm/register.go @@ -0,0 +1,136 @@ +package cortexm + +import ( + "context" + "errors" + "time" +) + +// Register identifies a Cortex-M0 core register. Zero and unnamed values are +// invalid. The numeric values are not hardware register selectors. +type Register uint8 + +// Core registers accessible through an acquired, halted target. SP is the +// current stack pointer; MSP and PSP select its banks explicitly. PC is the +// debug return address, not a Thumb function pointer. XPSR includes status. +const ( + R0 Register = iota + 1 + R1 + R2 + R3 + R4 + R5 + R6 + R7 + R8 + R9 + R10 + R11 + R12 + SP + LR + PC + XPSR + MSP + PSP +) + +const ( + dcrsrAddress = uint32(0xe000edf4) + dcrdrAddress = uint32(0xe000edf8) + sRegReady = uint32(1 << 16) + sReset = uint32(1 << 25) +) + +// ReadRegister reads a register while halted, without acquiring halt ownership. +// It writes debug transfer registers and consumes DHCSR's sticky status. Calls +// are bounded to five seconds or the caller's earlier deadline. An uncertain +// transfer blocks ordinary calls; Release must settle it before changing debug +// control. Loss of Debug state or reset during a pending transfer prevents +// automatic cleanup. An error returns no valid register value. +func (t *Target) ReadRegister(ctx context.Context, reg Register) (uint32, error) { + if reg < R0 || reg > PSP { + return 0, errors.New("cortexm: invalid register") + } + if err := t.active(ctx); err != nil { + return 0, err + } + ctx, cancel := context.WithTimeout(ctx, controlTimeout) + defer cancel() + if err := t.waitRegister(ctx); err != nil { + return 0, err + } + if err := t.selectRegister(ctx, uint32(reg-1)); err != nil { + return 0, err + } + value, err := t.memory.ReadWord(ctx, dcrdrAddress) + if err != nil { + t.closing = true + return 0, err + } + return value, nil +} + +func (t *Target) selectRegister(ctx context.Context, selector uint32) error { + if err := ctx.Err(); err != nil { + return err + } + t.registerPending = true + if err := t.memory.WriteWord(ctx, dcrsrAddress, selector); err != nil { + t.closing = true + return err + } + return t.waitRegister(ctx) +} + +func (t *Target) waitRegister(ctx context.Context) (err error) { + defer func() { + if err != nil && t.registerPending { + t.closing = true + } + }() + for { + if err := ctx.Err(); err != nil { + return err + } + if err := t.registerStatus(ctx); err != nil { + return err + } + if !t.registerPending { + return nil + } + timer := time.NewTimer(time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +func (t *Target) registerStatus(ctx context.Context) error { + if t.registerLost { + return errors.New("cortexm: register transfer lost its debug state; cleanup cannot continue") + } + value, err := t.memory.ReadWord(ctx, dhcsrAddress) + if err != nil { + t.closing = true + return err + } + halted := value&(cDebugEnable|sHalt) == cDebugEnable|sHalt + if t.registerPending && (!halted || value&sReset != 0) { + t.registerLost = true + return errors.New("cortexm: debug state changed during register transfer") + } + if value&cDebugEnable == 0 || value&(cStep|cMaskInts) != 0 { + t.closing = true + return errors.New("cortexm: debug mode changed during register access") + } + t.observeHaltRequest(value) + if !halted { + return errors.New("cortexm: register access requires a halted processor") + } + t.registerPending = value&sRegReady == 0 + return nil +} diff --git a/target/cortexm/register_failure_test.go b/target/cortexm/register_failure_test.go new file mode 100644 index 0000000..0aad682 --- /dev/null +++ b/target/cortexm/register_failure_test.go @@ -0,0 +1,123 @@ +package cortexm_test + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestRegisterReadFailures(t *testing.T) { + for _, inherited := range []bool{false, true} { + for _, phase := range []string{"status", "selector-before", "selector-after", "poll", "data", "cancel"} { + t.Run(fmt.Sprintf("inherited=%t/%s", inherited, phase), func(t *testing.T) { + checkReadFailure(t, inherited, phase) + }) + } + } +} + +func checkReadFailure(t *testing.T, inherited bool, phase string) { + t.Helper() + m := newRegisterMemory() + core := acquireRegisters(t, m, inherited) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch phase { + case "status": + m.failRead = m.reads + 1 + case "selector-before", "selector-after": + m.failWrite, m.afterWrite = m.writes+1, phase == "selector-after" + case "poll": + m.failRead = m.reads + 2 + case "data": + m.failRead = m.reads + 3 + case "cancel": + m.afterSelector = cancel + } + value, err := core.ReadRegister(ctx, cortexm.R4) + expected := errMemory + if phase == "cancel" { + expected = context.Canceled + } + if !errors.Is(err, expected) || value != 0 { + t.Fatalf("value=%#x error=%v", value, err) + } + transfers := m.transfers + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if m.transfers != transfers || m.halted != inherited { + t.Fatal("cleanup replayed transfer or changed inherited halt") + } +} + +func TestRegisterReadInheritsBusyTransfer(t *testing.T) { + m := newRegisterMemory() + core := acquireRegisters(t, m, true) + m.pending, m.block = true, true + writes := m.writes + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Millisecond) + _, err := core.ReadRegister(ctx, cortexm.R0) + cancel() + if !errors.Is(err, context.DeadlineExceeded) || m.writes != writes { + t.Fatal("overwrote inherited transfer") + } + ctx, cancel = context.WithTimeout(t.Context(), 5*time.Millisecond) + err = core.Release(ctx) + cancel() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("released inherited busy transfer") + } + m.block = false + if err := core.Release(t.Context()); err != nil || m.writes != writes || !m.halted { + t.Fatal("cleanup changed inherited state") + } +} + +func TestRegisterReadCancellationBeforeSelector(t *testing.T) { + m := newRegisterMemory() + core := acquireRegisters(t, m, false) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + m.onRead = cancel + writes := m.writes + if _, err := core.ReadRegister(ctx, cortexm.R0); !errors.Is(err, context.Canceled) || m.writes != writes { + t.Fatal("canceled read issued selector") + } + m.onRead = nil + if _, err := core.ReadRegister(t.Context(), cortexm.R0); err != nil { + t.Fatal(err) + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } +} + +func TestPendingRegisterTransferCannotSurviveLostState(t *testing.T) { + for _, change := range []string{"running", "disabled", "reset"} { + m := newRegisterMemory() + core := acquireRegisters(t, m, false) + m.afterSelector = func() { + switch change { + case "running": + m.halted = false + case "disabled": + m.control = 0 + case "reset": + m.reset = true + } + } + if _, err := core.ReadRegister(t.Context(), cortexm.R0); err == nil { + t.Fatal("lost state returned data") + } + writes := m.writes + m.control, m.halted = debugEnable|haltRequest, true + if err := core.Release(t.Context()); err == nil || m.writes != writes { + t.Fatal("later halt disguised lost transfer") + } + } +} diff --git a/target/cortexm/register_memory_test.go b/target/cortexm/register_memory_test.go new file mode 100644 index 0000000..038ec39 --- /dev/null +++ b/target/cortexm/register_memory_test.go @@ -0,0 +1,118 @@ +package cortexm_test + +import ( + "context" + "errors" +) + +const ( + dcrsr = uint32(0xe000edf4) + dcrdr = uint32(0xe000edf8) + registerReady = uint32(1 << 16) + resetStatus = uint32(1 << 25) +) + +type registerMemory struct { + *controlMemory + registers [19]uint32 + data, selector uint32 + pending bool + delay, remaining int + block bool + reset bool + transfers int + beforeStatus func() + afterSelector func() +} + +func newRegisterMemory() *registerMemory { + m := ®isterMemory{controlMemory: newControlMemory()} + for i := range m.registers { + m.registers[i] = 0x12340000 + uint32(i)*4 + } + return m +} + +func (m *registerMemory) ReadWord(ctx context.Context, addr uint32) (uint32, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + if addr == dcrdr { + m.reads++ + if m.reads == m.failRead { + return 0, errMemory + } + if m.pending || !m.halted { + return 0, errors.New("data read while unavailable") + } + return m.data, nil + } + if addr == dhcsr && m.beforeStatus != nil { + m.beforeStatus() + } + value, err := m.controlMemory.ReadWord(ctx, addr) + if err != nil || addr != dhcsr { + return value, err + } + if m.pending && !m.block { + if m.remaining == 0 { + m.complete() + } else { + m.remaining-- + } + } + if !m.pending { + value |= registerReady + } + if m.reset { + value |= resetStatus + m.reset = false + } + return value, nil +} + +func (m *registerMemory) WriteWord(ctx context.Context, addr, value uint32) error { + if addr != dcrsr && addr != dcrdr { + return m.controlMemory.WriteWord(ctx, addr, value) + } + if err := ctx.Err(); err != nil { + return err + } + m.writes++ + fail := m.rejectWrites || m.writes == m.failWrite + if fail && !m.afterWrite { + return errMemory + } + if !m.halted || m.pending { + return errors.New("register write while unavailable") + } + if addr == dcrdr { + m.data = value + } else { + if value&0xffff >= uint32(len(m.registers)) { + return errors.New("invalid selector") + } + m.selector, m.pending, m.remaining = value, true, m.delay + m.transfers++ + if m.afterSelector != nil { + m.afterSelector() + } + } + if m.onWrite != nil { + m.onWrite() + } + if fail { + return errMemory + } + return nil +} + +func (m *registerMemory) complete() { + selector := m.selector & 0xffff + if m.selector&(1<<16) != 0 { + m.registers[selector] = m.data + } else { + m.data = m.registers[selector] + } + m.pending = false +} diff --git a/target/cortexm/register_read_test.go b/target/cortexm/register_read_test.go new file mode 100644 index 0000000..c987ce6 --- /dev/null +++ b/target/cortexm/register_read_test.go @@ -0,0 +1,141 @@ +package cortexm_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jon/ostiole/target/cortexm" +) + +func acquireRegisters(t *testing.T, m *registerMemory, inherited bool) *cortexm.Target { + t.Helper() + if inherited { + m.control, m.halted = debugEnable, true + } + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if !inherited { + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + } + return core +} + +func TestReadRegisters(t *testing.T) { + for _, delay := range []int{0, 2} { + for _, inherited := range []bool{false, true} { + m := newRegisterMemory() + m.delay = delay + core := acquireRegisters(t, m, inherited) + for reg := cortexm.R0; reg <= cortexm.PSP; reg++ { + value, err := core.ReadRegister(t.Context(), reg) + if err != nil || value != m.registers[int(reg)-1] { + t.Fatalf("reg=%d value=%#x err=%v", reg, value, err) + } + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if m.halted != inherited { + t.Fatal("halt ownership changed") + } + } + } +} + +func TestRegisterReadRejectsBeforeTransfer(t *testing.T) { + m := newRegisterMemory() + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + for _, reg := range []cortexm.Register{0, 20, 255} { + reads, writes := m.reads, m.writes + if _, err := core.ReadRegister(t.Context(), reg); err == nil || m.reads != reads || m.writes != writes { + t.Fatal("invalid register reached memory") + } + } + writes := m.writes + if _, err := core.ReadRegister(t.Context(), cortexm.R0); err == nil || m.writes != writes { + t.Fatal("running read started transfer") + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + reads := m.reads + if _, err := core.ReadRegister(ctx, cortexm.R0); !errors.Is(err, context.Canceled) || m.reads != reads { + t.Fatal("canceled read reached memory") + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if _, err := core.ReadRegister(t.Context(), cortexm.R0); err == nil { + t.Fatal("released target read") + } + var zero cortexm.Target + if _, err := zero.ReadRegister(t.Context(), cortexm.R0); err == nil { + t.Fatal("zero target read") + } +} + +func TestRegisterReadPendingCleanup(t *testing.T) { + for _, inherited := range []bool{false, true} { + m := newRegisterMemory() + core := acquireRegisters(t, m, inherited) + m.block = true + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Millisecond) + _, err := core.ReadRegister(ctx, cortexm.R0) + cancel() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + writes := m.writes + ctx, cancel = context.WithTimeout(t.Context(), 5*time.Millisecond) + err = core.Release(ctx) + cancel() + if !errors.Is(err, context.DeadlineExceeded) || m.writes != writes || !m.halted { + t.Fatal("pending transfer released") + } + if _, err := core.ReadRegister(t.Context(), cortexm.R0); err == nil { + t.Fatal("ordinary call during cleanup") + } + m.block = false + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if m.transfers != 1 || m.halted != inherited { + t.Fatal("transfer replay or halt ownership changed") + } + } +} + +func TestRegisterReadObservesLostControl(t *testing.T) { + for _, disabled := range []bool{false, true} { + m := newRegisterMemory() + core := acquireRegisters(t, m, false) + m.control, m.halted = debugEnable, false + if disabled { + m.control = 0 + } + if _, err := core.ReadRegister(t.Context(), cortexm.R4); err == nil { + t.Fatal("lost control accepted") + } + m.control, m.halted = debugEnable|haltRequest, true + writes := m.writes + if err := core.Resume(t.Context()); err == nil || m.writes != writes { + t.Fatal("resumed independent stop") + } + if disabled { + if _, err := core.ReadRegister(t.Context(), cortexm.R4); err == nil { + t.Fatal("ordinary call after disabled debug") + } + } + } +} From 7e73839845d38847295039bb3d06076ecf18133f Mon Sep 17 00:00:00 2001 From: Jon Olson Date: Sat, 26 Sep 2026 16:02:55 -0700 Subject: [PATCH 2/3] Write Cortex-M0 registers while halted. The acquired target can now change core registers as well as read them. Keep XPSR read-only and reject unaligned stack pointers and odd debug return addresses before sending traffic. A failed write may already have changed the register. Keep only cleanup available after data staging fails or later completion is uncertain, without replaying the selection. Writes persist after release, and writing during an inherited halt does not grant resume ownership. --- README.md | 2 +- docs/architecture.md | 5 +- docs/capabilities.md | 2 +- docs/composition.md | 5 +- docs/cortexm.md | 23 ++- target/cortexm/identity.go | 2 +- target/cortexm/register_memory_test.go | 10 ++ target/cortexm/register_write.go | 57 +++++++ target/cortexm/register_write_test.go | 226 +++++++++++++++++++++++++ 9 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 target/cortexm/register_write.go create mode 100644 target/cortexm/register_write_test.go diff --git a/README.md b/README.md index d5e60d8..5e8aff7 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ add posted access-port reads and a Cortex-M identity read through a MEM-AP. They compose the public packages explicitly without duplicating their framing. The `target/cortexm` package reads and decodes the architectural CPUID value through any compatible target-word reader. It also provides acquired Cortex-M0 -halt/resume control and halted register reads over word memory; see +halt/resume control and halted register access over word memory; see [Cortex-M control](docs/cortexm.md). The FTDI path uses the standard H-series MPSSE port and endpoint layout. diff --git a/docs/architecture.md b/docs/architecture.md index db35fff..af83b1f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,7 @@ debugger service. | `dap` | Bind SW-DP or baseline ADIv5 JTAG-DP, manage identity and power, execute ordered DP/AP transactions, and provide scalar or block MEM-AP access. | | `dap/sim` | Model the DP, AP, and byte-addressed target-memory state consumed by `dap`. | | `coresight` | Identify debug components and walk ROM tables through borrowed scalar memory, with explicit bounds and no resource acquisition or target-memory writes. | -| `target/cortexm` | Identify Cortex-M processors and own Cortex-M0 halting debug and register reads over borrowed word memory. | +| `target/cortexm` | Identify Cortex-M processors and own Cortex-M0 halting debug and register access over borrowed word memory. | | `examples/...` | Demonstrate public package compositions as executable programs. | | `cmd/ost` | Provide a small command hierarchy over the same public packages. | @@ -338,7 +338,8 @@ component identity](coresight.md) for its register and failure boundaries. control also requires a word writer that waits for each access to complete. The target owns DHCSR control, its halt requests, and pending register transfers. Release settles a pending transfer before restoring debug control; -the target must be released before the memory owner. +the target must be released before the memory owner. Register writes persist +after release. It does not know about USB, adapters, or wire protocols. See [Cortex-M control](cortexm.md) for restoration and failure boundaries. diff --git a/docs/capabilities.md b/docs/capabilities.md index 0b62162..32560d8 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -305,7 +305,7 @@ layouts and power-domain skips have hardware-independent test coverage. | Cortex-M0 acquisition and halt/resume | HIL | Two CMSIS-DAP micro:bit sessions at a requested 1 MHz stopped a CPU counter during halt and observed progress after resume and release. Both restored initially disabled debug and running state before Arm debug owner close. Earlier sessions preserved initially enabled debug. Cleanup failures remain covered only by behavioral tests; see the [control evidence](cortexm.md#hardware-evidence). | | Step | No | No single-step API exists. | | Register reads | Yes | Halted Cortex-M0 R0–R12, SP, LR, PC, XPSR, MSP, and PSP through `ReadRegister`. Behavioral tests cover transfer completion and cleanup; no physical register evidence yet. | -| Register writes | No | No core-register write API exists. | +| Register writes | Yes | Halted Cortex-M0 writes except XPSR; aligned SP/MSP/PSP and even PC values. Writes persist after release. Behavioral tests cover staging, uncertain selection, and pending cleanup. No physical register-write evidence yet. | | Reset | No | No architectural or pin-reset operation exists. | | Breakpoints or watchpoints | No | No target instrumentation API exists. | | Firmware or runtime loading | No | No ELF loader, image-placement policy, or flash driver exists. | diff --git a/docs/composition.md b/docs/composition.md index 647b521..558b156 100644 --- a/docs/composition.md +++ b/docs/composition.md @@ -37,7 +37,7 @@ data-register write can write target memory. | Inspect ROM entries or a bounded component hierarchy | `Component.ROMTable`, `ROMTable.ReadEntry`, `coresight.Walk` | `examples/simple/coresight-info -walk` | | Identify a Cortex-M through any compatible word reader | `cortexm.Identify` | `examples/simple/cortexm-info` | | Acquire, halt, and resume a Cortex-M0 | `cortexm.Acquire`, `Target.Halt`, `Target.Resume`, `Target.Release` | `examples/simple/cortexm-control` | -| Read a halted Cortex-M0 register | `Target.ReadRegister` | [Register reads](cortexm.md#register-reads) | +| Read or write a halted Cortex-M0 register | `Target.ReadRegister`, `Target.WriteRegister` | [Register reads](cortexm.md#register-reads), [writes](cortexm.md#register-writes) | | Test SWD and DAP behavior without hardware | `swd/sim`, `dap/sim` | Package tests | The examples are intentionally small, executable compositions of public @@ -747,7 +747,8 @@ Use `target/cortexm` when the desired result is processor identity. It accepts the word-reader behavior supplied by `dap.MemAP`, so target code remains independent of the host, adapter, and wire protocol. `cortexm.Acquire` also uses `WriteWord` to enable Cortex-M0 halting debug. Use `ReadRegister` for -halted core registers so the target can track transfer completion. Release it before +halted core registers and `WriteRegister` for intentional changes. The target +tracks transfer completion but does not roll back writes. Release it before its memory owner and retain both after failed target restoration. See [Cortex-M control](cortexm.md) for the full composition and effects. diff --git a/docs/cortexm.md b/docs/cortexm.md index 21e9aad..5bd8e9b 100644 --- a/docs/cortexm.md +++ b/docs/cortexm.md @@ -64,7 +64,7 @@ The package does not restore those indicators or clear DFSR event flags. The implementation follows Arm DDI 0419E, sections C1.5 and C1.6.3–C1.6.5 of the [Armv6-M Architecture Reference Manual](https://documentation-service.arm.com/static/5f8ff05ef86e16515cdbf826). -It does not implement reset, single-step, register writes, breakpoints, or +It does not implement reset, single-step, breakpoints, or watchpoints. ## Register reads @@ -96,6 +96,27 @@ that the original transfer completed. Retain both owners; there is no forced cleanup operation for this state. These failures have behavioral test coverage, not physical failure-injection evidence. +## Register writes + +`WriteRegister` writes the same register set except XPSR, which is read-only. +SP, MSP, and PSP require word-aligned values; PC requires bit zero clear. PC +writes change the debug return address without changing Thumb state. Writing +SP changes whichever stack bank is active. The API rejects invalid identifiers +and values before traffic; it does not check whether an address is mapped or +suitable for the program. + +```go +err := core.WriteRegister(ctx, cortexm.R4, 42) +``` + +A write stages DCRDR, selects the register, and waits for transfer completion. +An error after attempting to stage data leaves only `Release` available. If +selection was attempted, the register may have changed even when the call +returns an error. Release settles a pending transfer without replaying it. +Successful writes are intentional changes to processor state: release does +not roll them back, and resumed execution uses the changed values. An inherited +halt permits writes but still does not grant permission to resume. + ## Composition For a MEM-AP borrowed from `armdebug.Conn`, acquire the target and retain any diff --git a/target/cortexm/identity.go b/target/cortexm/identity.go index 4ec80eb..aa4e021 100644 --- a/target/cortexm/identity.go +++ b/target/cortexm/identity.go @@ -1,5 +1,5 @@ // Package cortexm identifies Cortex-M processors and provides Cortex-M0 -// halting debug and register reads through target memory. +// halting debug and register access through target memory. package cortexm import ( diff --git a/target/cortexm/register_memory_test.go b/target/cortexm/register_memory_test.go index 038ec39..912aad9 100644 --- a/target/cortexm/register_memory_test.go +++ b/target/cortexm/register_memory_test.go @@ -23,6 +23,7 @@ type registerMemory struct { transfers int beforeStatus func() afterSelector func() + processStack bool } func newRegisterMemory() *registerMemory { @@ -30,6 +31,7 @@ func newRegisterMemory() *registerMemory { for i := range m.registers { m.registers[i] = 0x12340000 + uint32(i)*4 } + m.registers[13] = m.registers[17] return m } @@ -109,10 +111,18 @@ func (m *registerMemory) WriteWord(ctx context.Context, addr, value uint32) erro func (m *registerMemory) complete() { selector := m.selector & 0xffff + bank := uint32(17) + if m.processStack { + bank = 18 + } + if selector == 13 { + selector = bank + } if m.selector&(1<<16) != 0 { m.registers[selector] = m.data } else { m.data = m.registers[selector] } + m.registers[13] = m.registers[bank] m.pending = false } diff --git a/target/cortexm/register_write.go b/target/cortexm/register_write.go new file mode 100644 index 0000000..f6ca826 --- /dev/null +++ b/target/cortexm/register_write.go @@ -0,0 +1,57 @@ +package cortexm + +import ( + "context" + "errors" +) + +// WriteRegister changes a register while halted, without acquiring halt +// ownership. XPSR is read-only. SP, MSP, and PSP require word alignment; PC +// requires bit zero clear and does not change Thumb state. Invalid identifiers +// and values are rejected before traffic. Release does not undo register writes. +// +// The transfer and cleanup bounds are those of ReadRegister. An error after +// staging data leaves only Release available; the register may have changed +// if selection was attempted. Release settles that transfer without replaying +// it. Callers own the consequences when execution resumes. +func (t *Target) WriteRegister(ctx context.Context, reg Register, value uint32) error { + if err := validateRegisterWrite(reg, value); err != nil { + return err + } + if err := t.active(ctx); err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, controlTimeout) + defer cancel() + if err := t.waitRegister(ctx); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if err := t.memory.WriteWord(ctx, dcrdrAddress, value); err != nil { + t.closing = true + return err + } + if err := t.selectRegister(ctx, uint32(reg-1)|1<<16); err != nil { + t.closing = true + return err + } + return nil +} + +func validateRegisterWrite(reg Register, value uint32) error { + if reg < R0 || reg > PSP { + return errors.New("cortexm: invalid register") + } + if reg == XPSR { + return errors.New("cortexm: XPSR is read-only") + } + if (reg == SP || reg == MSP || reg == PSP) && value&3 != 0 { + return errors.New("cortexm: stack pointer must be word-aligned") + } + if reg == PC && value&1 != 0 { + return errors.New("cortexm: PC must have bit zero clear") + } + return nil +} diff --git a/target/cortexm/register_write_test.go b/target/cortexm/register_write_test.go new file mode 100644 index 0000000..3411759 --- /dev/null +++ b/target/cortexm/register_write_test.go @@ -0,0 +1,226 @@ +package cortexm_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestWriteRegisters(t *testing.T) { + for _, inherited := range []bool{false, true} { + for _, delay := range []int{0, 2} { + t.Run("registers", func(t *testing.T) { writeRegisters(t, inherited, delay) }) + } + } +} + +func writeRegisters(t *testing.T, inherited bool, delay int) { + t.Helper() + m := newRegisterMemory() + m.delay = delay + core := acquireRegisters(t, m, inherited) + for reg := cortexm.R0; reg <= cortexm.PSP; reg++ { + if reg == cortexm.XPSR { + continue + } + value := uint32(0xa5a50000) + uint32(reg)*4 + if err := core.WriteRegister(t.Context(), reg, value); err != nil { + t.Fatal(err) + } + got, err := core.ReadRegister(t.Context(), reg) + if err != nil || got != value { + t.Fatalf("register %d: %#x, %v", reg, got, err) + } + } + saved := m.registers + err := core.Resume(t.Context()) + if (err != nil) != inherited { + t.Fatalf("resume inherited=%v: %v", inherited, err) + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if m.registers != saved || m.halted != inherited { + t.Fatal("release rolled back registers or changed halt ownership") + } +} + +func TestRegisterWriteRejectsBeforeTraffic(t *testing.T) { + m := newRegisterMemory() + core := acquireRegisters(t, m, true) + for _, tc := range []struct { + reg cortexm.Register + value uint32 + }{ + {0, 0}, {255, 0}, {cortexm.XPSR, 0}, {cortexm.PC, 1}, + {cortexm.SP, 1}, {cortexm.MSP, 2}, {cortexm.PSP, 3}, + } { + reads, writes := m.reads, m.writes + if err := core.WriteRegister(t.Context(), tc.reg, tc.value); err == nil || reads != m.reads || writes != m.writes { + t.Fatalf("invalid write reached memory: %+v", tc) + } + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + reads, writes := m.reads, m.writes + if err := core.WriteRegister(ctx, cortexm.R4, 42); !errors.Is(err, context.Canceled) || reads != m.reads || writes != m.writes { + t.Fatal("canceled write reached memory") + } + m.halted = false + if err := core.WriteRegister(t.Context(), cortexm.R4, 42); err == nil || writes != m.writes { + t.Fatal("running write reached staging") + } + m.halted = true + if err := core.WriteRegister(t.Context(), cortexm.R4, 42); err != nil { + t.Fatal(err) + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if err := core.WriteRegister(t.Context(), cortexm.R4, 42); err == nil { + t.Fatal("released write") + } + var zero cortexm.Target + if err := zero.WriteRegister(t.Context(), cortexm.R4, 42); err == nil { + t.Fatal("zero target write") + } +} + +func TestRegisterWriteFailures(t *testing.T) { + for _, phase := range []string{"stage-before", "stage-after", "select-before", "select-after", "poll", "cancel-stage", "cancel-select"} { + t.Run(phase, func(t *testing.T) { + m := newRegisterMemory() + core := acquireRegisters(t, m, false) + old := m.registers[4] + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + want := errMemory + switch phase { + case "stage-before", "stage-after": + m.failWrite = m.writes + 1 + m.afterWrite = phase == "stage-after" + case "select-before", "select-after": + m.failWrite = m.writes + 2 + m.afterWrite = phase == "select-after" + case "poll": + m.failRead = m.reads + 2 + case "cancel-stage": + m.onWrite = cancel + want = context.Canceled + case "cancel-select": + m.afterSelector = cancel + want = context.Canceled + } + if err := core.WriteRegister(ctx, cortexm.R4, 42); !errors.Is(err, want) { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err == nil { + t.Fatal("ordinary call after uncertain write") + } + m.onWrite, m.afterSelector = nil, nil + transfers := m.transfers + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + expected := old + if phase == "select-after" || phase == "poll" || phase == "cancel-select" { + expected = 42 + } + if m.registers[4] != expected || m.transfers != transfers || m.halted { + t.Fatal("wrong write effect, replay, or failed resume") + } + }) + } +} + +func TestRegisterWritePendingCleanup(t *testing.T) { + m := newRegisterMemory() + core := acquireRegisters(t, m, false) + m.block = true + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Millisecond) + err := core.WriteRegister(ctx, cortexm.R4, 42) + cancel() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + writes := m.writes + ctx, cancel = context.WithTimeout(t.Context(), 5*time.Millisecond) + err = core.Release(ctx) + cancel() + if !errors.Is(err, context.DeadlineExceeded) || m.writes != writes || !m.halted { + t.Fatal("pending write resumed") + } + m.block = false + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if m.registers[4] != 42 || m.halted || m.transfers != 1 { + t.Fatal("write failed or replayed") + } +} + +func TestRegisterStackBanks(t *testing.T) { + for _, process := range []bool{false, true} { + m := newRegisterMemory() + m.processStack = process + core := acquireRegisters(t, m, true) + if err := core.WriteRegister(t.Context(), cortexm.MSP, 0x20001000); err != nil { + t.Fatal(err) + } + if err := core.WriteRegister(t.Context(), cortexm.PSP, 0x20002000); err != nil { + t.Fatal(err) + } + want := uint32(0x20001000) + bank, other := cortexm.MSP, cortexm.PSP + otherWant := uint32(0x20002000) + if process { + want, otherWant = otherWant, want + bank, other = other, bank + } + got, err := core.ReadRegister(t.Context(), cortexm.SP) + if err != nil || got != want { + t.Fatalf("SP=%#x err=%v", got, err) + } + if err := core.WriteRegister(t.Context(), cortexm.SP, 0x20003000); err != nil { + t.Fatal(err) + } + got, err = core.ReadRegister(t.Context(), bank) + if err != nil || got != 0x20003000 { + t.Fatalf("active SP=%#x err=%v", got, err) + } + got, err = core.ReadRegister(t.Context(), other) + if err != nil || got != otherWant { + t.Fatalf("inactive SP=%#x err=%v", got, err) + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + } +} + +func TestRegisterWriteObservesLostControl(t *testing.T) { + for _, disabled := range []bool{false, true} { + m := newRegisterMemory() + core := acquireRegisters(t, m, false) + m.control, m.halted = debugEnable, false + if disabled { + m.control = 0 + } + if err := core.WriteRegister(t.Context(), cortexm.R4, 42); err == nil { + t.Fatal("lost control accepted") + } + m.control, m.halted = debugEnable|haltRequest, true + writes := m.writes + if err := core.Resume(t.Context()); err == nil || m.writes != writes { + t.Fatal("resumed independent stop") + } + if disabled { + if err := core.WriteRegister(t.Context(), cortexm.R4, 42); err == nil { + t.Fatal("ordinary call after disabled debug") + } + } + } +} From 36d1c00e74bdcebe0164b61693b33b520ccb314f Mon Sep 17 00:00:00 2001 From: Jon Olson Date: Sat, 26 Sep 2026 16:08:53 -0700 Subject: [PATCH 3/3] Exercise Cortex-M0 registers on the micro:bit bench. Show register reads between halt and resume in the control example. The gated bench checks the known counter firmware, reads the selected register set, and writes R4 patterns plus temporary stack and PC values. It restores and verifies all registers before allowing execution again; unconfirmed restoration retains both owners without requesting resume. Two fresh CMSIS-DAP sessions at 1 MHz passed on the micro:bit. The CPU counter stopped while halted and progressed after resume and release. Both sessions restored disabled debug and closed both owners. This does not establish execution with the temporary PC or stack values, or cleanup after a physical transport failure. --- README.md | 3 +- docs/architecture.md | 3 +- docs/capabilities.md | 6 +- docs/composition.md | 2 +- docs/cortexm.md | 36 +++- examples/simple/cortexm-control/main.go | 10 ++ target/cortexm/register_integration_test.go | 184 ++++++++++++++++++++ 7 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 target/cortexm/register_integration_test.go diff --git a/README.md b/README.md index 5e8aff7..8d4943d 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,8 @@ The `arm-info`, `coresight-info`, and `cortexm-control` examples accept The inspection examples and `ost` commands avoid reset, halt, target-memory writes, and persistent changes. The separately gated `cortexm-control` example -enables halting debug and halts and resumes a Cortex-M0. The `dap.MemAP` API +enables halting debug, halts a Cortex-M0, reads PC, SP, R0, and R4, then resumes +it. The `dap.MemAP` API does expose effectful scalar writes; callers choose the addresses and own the consequences. Establishing an ADIv5 connection also changes volatile debug-port control state; the connection releases its own power requests before return. diff --git a/docs/architecture.md b/docs/architecture.md index af83b1f..bd85e48 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -384,7 +384,8 @@ replaceable while exercising the public protocol and DAP layers. The inspection examples and `ost` commands do not reset or halt the target, write target memory, or change persistent state. The explicitly gated -`cortexm-control` example enables debug and halts and resumes Cortex-M0. +`cortexm-control` example enables debug, halts Cortex-M0, reads PC, SP, R0, +and R4, then resumes it. The `dap.MemAP` API does expose scalar and block target-memory writes; applications choose the affected addresses and own the consequences. diff --git a/docs/capabilities.md b/docs/capabilities.md index 32560d8..816a971 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -304,8 +304,8 @@ layouts and power-domain skips have hardware-independent test coverage. | Physical identity read | HIL | Opt-in FTDI/SWD/DAP/MEM-AP integration test. | | Cortex-M0 acquisition and halt/resume | HIL | Two CMSIS-DAP micro:bit sessions at a requested 1 MHz stopped a CPU counter during halt and observed progress after resume and release. Both restored initially disabled debug and running state before Arm debug owner close. Earlier sessions preserved initially enabled debug. Cleanup failures remain covered only by behavioral tests; see the [control evidence](cortexm.md#hardware-evidence). | | Step | No | No single-step API exists. | -| Register reads | Yes | Halted Cortex-M0 R0–R12, SP, LR, PC, XPSR, MSP, and PSP through `ReadRegister`. Behavioral tests cover transfer completion and cleanup; no physical register evidence yet. | -| Register writes | Yes | Halted Cortex-M0 writes except XPSR; aligned SP/MSP/PSP and even PC values. Writes persist after release. Behavioral tests cover staging, uncertain selection, and pending cleanup. No physical register-write evidence yet. | +| Register reads | Yes | Halted Cortex-M0 R0–R12, SP, LR, PC, XPSR, MSP, and PSP through `ReadRegister`. Two fresh CMSIS-DAP micro:bit sessions read all 19 registers; transfer failures and cleanup have behavioral coverage. | +| Register writes | Yes | Halted Cortex-M0 writes except XPSR; aligned SP/MSP/PSP and even PC values. Writes persist after release. Behavioral tests cover staging, uncertain selection, and pending cleanup. Two micro:bit sessions wrote and restored R4, SP, MSP, PSP, and PC before resuming; see the [register bench](cortexm.md#register-bench). | | Reset | No | No architectural or pin-reset operation exists. | | Breakpoints or watchpoints | No | No target instrumentation API exists. | | Firmware or runtime loading | No | No ELF loader, image-placement policy, or flash driver exists. | @@ -328,7 +328,7 @@ Available examples: probe discovery and one Arm debug owner, with explicit AP selection. `examples/simple/cortexm-control` separately demonstrates effectful Cortex-M0 -halt/resume and requires `-allow-control`. +halt/resume with PC, SP, R0, and R4 reads, and requires `-allow-control`. Available `ost` commands: diff --git a/docs/composition.md b/docs/composition.md index 558b156..5a5eeed 100644 --- a/docs/composition.md +++ b/docs/composition.md @@ -36,7 +36,7 @@ data-register write can write target memory. | Identify one debug component through scalar memory | `coresight.Identify` | `examples/simple/coresight-info` | | Inspect ROM entries or a bounded component hierarchy | `Component.ROMTable`, `ROMTable.ReadEntry`, `coresight.Walk` | `examples/simple/coresight-info -walk` | | Identify a Cortex-M through any compatible word reader | `cortexm.Identify` | `examples/simple/cortexm-info` | -| Acquire, halt, and resume a Cortex-M0 | `cortexm.Acquire`, `Target.Halt`, `Target.Resume`, `Target.Release` | `examples/simple/cortexm-control` | +| Acquire, halt, inspect registers, and resume a Cortex-M0 | `cortexm.Acquire`, `Target.Halt`, `Target.ReadRegister`, `Target.Resume`, `Target.Release` | `examples/simple/cortexm-control` | | Read or write a halted Cortex-M0 register | `Target.ReadRegister`, `Target.WriteRegister` | [Register reads](cortexm.md#register-reads), [writes](cortexm.md#register-writes) | | Test SWD and DAP behavior without hardware | `swd/sim`, `dap/sim` | Package tests | diff --git a/docs/cortexm.md b/docs/cortexm.md index 5bd8e9b..02c920b 100644 --- a/docs/cortexm.md +++ b/docs/cortexm.md @@ -140,7 +140,8 @@ err = errors.Join(err, cleanupErr) ``` The [control example](../examples/simple/cortexm-control/main.go) selects one -probe and AP, halts, resumes, then releases the target before closing the +probe and AP, halts, prints PC, SP, R0, and R4, resumes, then releases the +target before closing the connection. It requires explicit consent to control execution: ```sh @@ -206,3 +207,36 @@ startup at 100 kHz. The later 1 MHz run covers initially disabled debug on the same board. Neither run verifies state after closing the Arm debug owner or cleanup after a physical transport failure. Peripheral behavior, register preservation, reset, and stepping are outside this test. + +### Register bench + +`TestHILCortexM0Registers` uses the same micro:bit, AP0, and 1 MHz clock. It +requires the exact counter image above and checks its vectors and instruction +words before acquiring the processor. A second gate authorizes register writes: + +```sh +OSTIOLE_CORTEXM_HIL_CONTROL=1 \ +OSTIOLE_CORTEXM_HIL_REGISTERS=1 \ +OSTIOLE_CORTEXM_HIL_PROGRAM=sha256:ee294cc06ab6e8228161b49506675b065c0148b26421cf1f83c8e45e35cd4e5d \ +go test -tags integration ./target/cortexm -run '^TestHILCortexM0Registers$' -count=1 -v +``` + +On September 26, 2026, both fresh sessions passed on Nostalgia with CPUID +`0x410cc200`. Each read R0–R12, SP, LR, PC, XPSR, MSP, and PSP while halted. +R4 accepted `0x55aa55aa` and `0xaa55aa55`; SP, MSP, PSP, and PC accepted +temporary aligned values. SP and MSP aliased as expected for this firmware. +The test restored each written value and compared all 19 registers with the +saved snapshot before resuming. + +The CPU counter remained unchanged across ten samples 20 milliseconds apart +after register restoration, then advanced after resume and release. DHCSR was +`0x01000000` before acquisition and after release in both sessions, with debug +disabled and the processor running. Both target releases and Arm owner closes +completed. If register restoration cannot be confirmed, the test retains both owners +without requesting resume. + +These sessions exercised register transfers while halted, not execution using +the temporary PC or stack values. Writes to the other general registers and LR, +process-stack selection, inherited halts, and failure cleanup have behavioral +test coverage only. XPSR writes, stepping, reset, and state after Arm owner +close were not tested. diff --git a/examples/simple/cortexm-control/main.go b/examples/simple/cortexm-control/main.go index 00785c5..cb9089c 100644 --- a/examples/simple/cortexm-control/main.go +++ b/examples/simple/cortexm-control/main.go @@ -69,6 +69,16 @@ func control(ctx context.Context, core *cortexm.Target) error { return err } fmt.Printf("CPUID=%#08x halted\n", core.Identity().Raw) + for _, reg := range []struct { + name string + id cortexm.Register + }{{"PC", cortexm.PC}, {"SP", cortexm.SP}, {"R0", cortexm.R0}, {"R4", cortexm.R4}} { + value, err := core.ReadRegister(ctx, reg.id) + if err != nil { + return err + } + fmt.Printf("%s=%#08x\n", reg.name, value) + } if err := core.Resume(ctx); err != nil { return err } diff --git a/target/cortexm/register_integration_test.go b/target/cortexm/register_integration_test.go new file mode 100644 index 0000000..d5266cc --- /dev/null +++ b/target/cortexm/register_integration_test.go @@ -0,0 +1,184 @@ +//go:build integration + +package cortexm_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jon/ostiole/dap" + "github.com/jon/ostiole/target/cortexm" +) + +func TestHILCortexM0Registers(t *testing.T) { + if os.Getenv("OSTIOLE_CORTEXM_HIL_CONTROL") != "1" || os.Getenv("OSTIOLE_CORTEXM_HIL_REGISTERS") != "1" { + t.Skip("require OSTIOLE_CORTEXM_HIL_CONTROL=1 and OSTIOLE_CORTEXM_HIL_REGISTERS=1") + } + if os.Getenv("OSTIOLE_CORTEXM_HIL_PROGRAM") != "sha256:ee294cc06ab6e8228161b49506675b065c0148b26421cf1f83c8e45e35cd4e5d" { + t.Fatal("require the documented counter image identity in OSTIOLE_CORTEXM_HIL_PROGRAM") + } + for range 2 { + if !t.Run("session", registerHIL) { + return + } + } +} + +func registerHIL(t *testing.T) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + c := openControlBench(t, ctx) + var core *cortexm.Target + dirty := false + t.Cleanup(func() { + if dirty { + t.Error("register restoration unconfirmed; retaining both owners without requesting resume") + return + } + releaseControlBench(t, core, c) + }) + memory, err := c.OpenMemAP(ctx, dap.NewAPSel(0)) + if err != nil { + t.Fatal(err) + } + checkRegisterFirmware(t, ctx, memory) + before, err := memory.ReadWord(ctx, dhcsr) + if err != nil { + t.Fatal(err) + } + if before&haltStatus != 0 { + t.Fatal("bench is already halted; refusing to resume it") + } + checkCounterHIL(t, ctx, memory, 0x20000000, "before acquisition", false) + core, err = cortexm.Acquire(ctx, memory) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(ctx); err != nil { + t.Fatal(err) + } + saved := readRegistersHIL(t, ctx, core) + checkCounterRegisters(t, saved) + exerciseRegistersHIL(t, ctx, core, saved, &dirty) + for reg, want := range saved { + if got := readRegisterHIL(t, ctx, core, reg); got != want { + t.Fatalf("register %d changed: %#x, want %#x", reg, got, want) + } + } + dirty = false + checkCounterHIL(t, ctx, memory, 0x20000000, "after restored registers, still halted", true) + if err := core.Resume(ctx); err != nil { + t.Fatal(err) + } + checkCounterHIL(t, ctx, memory, 0x20000000, "resumed", false) + if err := core.Release(ctx); err != nil { + t.Fatal(err) + } + after, err := memory.ReadWord(ctx, dhcsr) + if err != nil { + t.Fatal(err) + } + if after&(debugEnable|haltStatus) != before&(debugEnable|haltStatus) { + t.Fatalf("DHCSR before=%#x after=%#x", before, after) + } + checkCounterHIL(t, ctx, memory, 0x20000000, "released", false) + t.Logf("micro:bit CMSIS-DAP 1 MHz AP0 CPUID=%#x DHCSR before=%#x after=%#x", core.Identity().Raw, before, after) +} + +func checkRegisterFirmware(t *testing.T, ctx context.Context, memory *dap.MemAP) { + t.Helper() + // Match the linked vectors and instructions before changing processor state. + for addr := uint32(0); addr < 0xc0; addr += 4 { + want := uint32(0xcd) + if addr == 0 { + want = 0x20004000 + } else if addr == 4 { + want = 0xc1 + } + got, err := memory.ReadWord(ctx, addr) + if err != nil || got != want { + t.Fatalf("counter vector %#x=%#x, want %#x: %v", addr, got, want, err) + } + } + for i, want := range []uint32{0x4903b672, 0x30012000, 0xe7fc6008, 0x0000e7fe, 0x20000000} { + addr := uint32(0xc0 + i*4) + got, err := memory.ReadWord(ctx, addr) + if err != nil || got != want { + t.Fatalf("counter code %#x=%#x, want %#x: %v", addr, got, want, err) + } + } +} + +func readRegisterHIL(t *testing.T, ctx context.Context, core *cortexm.Target, reg cortexm.Register) uint32 { + t.Helper() + value, err := core.ReadRegister(ctx, reg) + if err != nil { + t.Fatal(err) + } + return value +} + +func readRegistersHIL(t *testing.T, ctx context.Context, core *cortexm.Target) map[cortexm.Register]uint32 { + t.Helper() + saved := make(map[cortexm.Register]uint32) + for _, reg := range []cortexm.Register{ + cortexm.R0, cortexm.R1, cortexm.R2, cortexm.R3, cortexm.R4, cortexm.R5, + cortexm.R6, cortexm.R7, cortexm.R8, cortexm.R9, cortexm.R10, cortexm.R11, + cortexm.R12, cortexm.SP, cortexm.LR, cortexm.PC, cortexm.XPSR, cortexm.MSP, cortexm.PSP, + } { + saved[reg] = readRegisterHIL(t, ctx, core, reg) + t.Logf("register %d=%#08x", reg, saved[reg]) + } + return saved +} + +func exerciseRegistersHIL(t *testing.T, ctx context.Context, core *cortexm.Target, saved map[cortexm.Register]uint32, dirty *bool) { + t.Helper() + for _, tc := range []struct { + reg cortexm.Register + value uint32 + }{ + {cortexm.R4, 0x55aa55aa}, {cortexm.R4, 0xaa55aa55}, + {cortexm.SP, 0x20003ff0}, {cortexm.MSP, 0x20003fe0}, + {cortexm.PSP, 0x20003fd0}, {cortexm.PC, saved[cortexm.PC] ^ 2}, + } { + *dirty = true + if err := core.WriteRegister(ctx, tc.reg, tc.value); err != nil { + t.Fatal(err) + } + if got := readRegisterHIL(t, ctx, core, tc.reg); got != tc.value { + t.Fatalf("write register %d: %#x, want %#x", tc.reg, got, tc.value) + } + if tc.reg == cortexm.SP || tc.reg == cortexm.MSP { + if got := readRegisterHIL(t, ctx, core, cortexm.SP); got != tc.value { + t.Fatal("SP does not alias MSP") + } + if got := readRegisterHIL(t, ctx, core, cortexm.MSP); got != tc.value { + t.Fatal("MSP does not alias SP") + } + } + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := core.WriteRegister(cleanup, tc.reg, saved[tc.reg]) + cancel() + if err != nil { + t.Fatal(err) + } + if got := readRegisterHIL(t, ctx, core, tc.reg); got != saved[tc.reg] { + t.Fatalf("register %d restoration: %#x, want %#x", tc.reg, got, saved[tc.reg]) + } + t.Logf("register %d wrote %#08x and restored %#08x", tc.reg, tc.value, saved[tc.reg]) + } +} + +func checkCounterRegisters(t *testing.T, saved map[cortexm.Register]uint32) { + t.Helper() + if pc := saved[cortexm.PC]; pc != 0xc6 && pc != 0xc8 && pc != 0xca { + t.Fatalf("PC outside counter loop: %#x", pc) + } + if saved[cortexm.SP] != 0x20004000 || saved[cortexm.MSP] != 0x20004000 || saved[cortexm.XPSR]&0x010001ff != 0x01000000 { + t.Fatal("unexpected counter stack or execution state") + } +}