diff --git a/README.md b/README.md index 24bcab0..bd62c3f 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ The [examples](examples) begin with a raw SWD debug-port identity read, then 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. +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). 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 @@ -131,16 +132,21 @@ root. See [Linux USB access](docs/linux-usb.md) for udev rules and a bounded Debug and programming interfaces can reset processors, halt execution, modify memory, reconfigure programmable logic, and change persistent device state. -The shipped examples and `ost` commands avoid reset, halt, target-memory -writes, and persistent changes. The `dap.MemAP` API does expose effectful -scalar writes; callers choose the addresses and own the consequences. +The SWD inspection examples and `ost` commands request a 1 MHz clock ceiling. +The `arm-info`, `coresight-info`, and `cortexm-control` examples accept +`-clock` in Hz for targets that require another rate. + +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 +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. ## SWD DPIDR example The program expects exactly one supported FTDI H-series attachment and uses -MPSSE port A at 400 kHz. Connect it to a powered SWD target as follows: +MPSSE port A at 1 MHz. Connect it to a powered SWD target as follows: | Adapter signal | Target signal | | --- | --- | diff --git a/cmd/ost/internal/app/session.go b/cmd/ost/internal/app/session.go index 5157cca..e58c718 100644 --- a/cmd/ost/internal/app/session.go +++ b/cmd/ost/internal/app/session.go @@ -48,7 +48,7 @@ func openSWDTransport(ctx context.Context) (*swdSession, error) { if err != nil { return nil, err } - channel, err := ftdi.Open(ctx, device, ftdi.Config{Port: ftdi.PortA, MaxClockHz: 400_000}) + channel, err := ftdi.Open(ctx, device, ftdi.Config{Port: ftdi.PortA, MaxClockHz: 1_000_000}) if err != nil { closeOwner := device.Close if channel != nil { diff --git a/cmsisdap/session_integration_test.go b/cmsisdap/session_integration_test.go index 2e1ebdc..24a9568 100644 --- a/cmsisdap/session_integration_test.go +++ b/cmsisdap/session_integration_test.go @@ -101,13 +101,13 @@ func observeCMSISDAPTarget(t *testing.T, ctx context.Context, readyOnOpen bool) t.Helper() var options []cmsisdap.Option if readyOnOpen { - options = append(options, cmsisdap.WithSWD(100_000)) + options = append(options, cmsisdap.WithSWD(1_000_000)) } session := openCMSISDAPSession(t, ctx, options...) cleanup := newCMSISDAPCleanup(t) cleanup.retain("CMSIS-DAP session", func(context.Context) error { return session.Close() }) if !readyOnOpen { - if err := session.ConfigureSWD(ctx, 100_000); err != nil { + if err := session.ConfigureSWD(ctx, 1_000_000); err != nil { t.Fatal(err) } } diff --git a/coresight/component_integration_test.go b/coresight/component_integration_test.go index 7162db5..45f78b1 100644 --- a/coresight/component_integration_test.go +++ b/coresight/component_integration_test.go @@ -32,7 +32,7 @@ func TestHILComponentIdentity(t *testing.T) { base uint64 class uint8 }{ - {"microbit", discover.Selection{Provider: "cmsisdap", Serial: "9900360140124e4500279015000000360000000097969901"}, armdebug.SWDP(probe.SWDConfig{MaxClockHz: 100_000}), 0, 0xe00ff000, 1}, + {"microbit", discover.Selection{Provider: "cmsisdap", Serial: "9900360140124e4500279015000000360000000097969901"}, armdebug.SWDP(probe.SWDConfig{MaxClockHz: 1_000_000}), 0, 0xe00ff000, 1}, {"zcu104", discover.Selection{Provider: "ftdi", Serial: "01691", Function: "A"}, armdebug.JTAGDP(probe.JTAGConfig{MaxClockHz: 100_000}, jtag.Layout{arm, xilinx}, 0), 1, 0x80410000, 9}, } { t.Run(bench.name, func(t *testing.T) { @@ -53,7 +53,7 @@ func TestHILComponentIdentity(t *testing.T) { if got.Class() != bench.class { t.Fatalf("class=%#x, want %#x", got.Class(), bench.class) } - t.Logf("session=%d AP%d 100 kHz identity=%+v", session, bench.ap, got) + t.Logf("session=%d AP%d identity=%+v", session, bench.ap, got) }) { return } diff --git a/coresight/walk_integration_test.go b/coresight/walk_integration_test.go index ddb4161..6885b40 100644 --- a/coresight/walk_integration_test.go +++ b/coresight/walk_integration_test.go @@ -33,7 +33,7 @@ func TestHILROMWalk(t *testing.T) { arm, _ := jtag.IDCODE(4, 0x5ba00477) xilinx, _ := jtag.IDCODE(12, 0x14730093) for _, bench := range []romBench{ - {"microbit", discover.Selection{Provider: "cmsisdap", Serial: "9900360140124e4500279015000000360000000097969901"}, armdebug.SWDP(probe.SWDConfig{MaxClockHz: 100_000}), 0, 6, 0}, + {"microbit", discover.Selection{Provider: "cmsisdap", Serial: "9900360140124e4500279015000000360000000097969901"}, armdebug.SWDP(probe.SWDConfig{MaxClockHz: 1_000_000}), 0, 6, 0}, {"zcu104", discover.Selection{Provider: "ftdi", Serial: "01691", Function: "A"}, armdebug.JTAGDP(probe.JTAGConfig{MaxClockHz: 100_000}, jtag.Layout{arm, xilinx}, 0), 1, 18, 0x803e0000}, } { t.Run(bench.name, func(t *testing.T) { @@ -69,7 +69,7 @@ func observeROMWalk(t *testing.T, bench romBench, session int) { t.Logf("visit=%d parent=%d entry=%d base=%#x: %v", i, v.Parent, v.Index, v.Entry.Base, v.Err) } } - t.Logf("session=%d AP%d 100 kHz root=%#x visits=%d complete=%t error=%v", session, bench.ap, base, len(visits), err == nil, err) + t.Logf("session=%d AP%d root=%#x visits=%d complete=%t error=%v", session, bench.ap, base, len(visits), err == nil, err) checkROMWalkObservation(t, bench, visits, err) } diff --git a/dap/memap.go b/dap/memap.go index 0c61aac..1594607 100644 --- a/dap/memap.go +++ b/dap/memap.go @@ -128,6 +128,12 @@ func (m *MemAP) ReadWord(ctx context.Context, addr uint32) (uint32, error) { return uint32(value), err } +// WriteWord writes one aligned 32-bit target word and waits for AP completion. +// It has the same effects and failure rules as WriteScalar with Size32. +func (m *MemAP) WriteWord(ctx context.Context, addr, value uint32) error { + return m.WriteScalar(ctx, uint64(addr), Size32, uint64(value)) +} + // ReadScalar performs one aligned, sized target-memory read. The returned // value is right-justified regardless of target byte order or address lane. func (m *MemAP) ReadScalar(ctx context.Context, addr uint64, size TransferSize) (uint64, error) { diff --git a/dap/memap_word_test.go b/dap/memap_word_test.go new file mode 100644 index 0000000..1b5f2cb --- /dev/null +++ b/dap/memap_word_test.go @@ -0,0 +1,26 @@ +package dap_test + +import ( + "testing" + + "github.com/jon/ostiole/dap" + "github.com/jon/ostiole/dap/sim" +) + +func TestMEMAPWriteWord(t *testing.T) { + target := sim.New(0x2ba01477) + addMEMAP(t, target, 0, 0x00010001, nil) + mem, err := dap.OpenMemAP(t.Context(), enteredDAPClient(t, target), apSel(0)) + if err != nil { + t.Fatal(err) + } + if err := mem.WriteWord(t.Context(), 0x100, 0x12345678); err != nil { + t.Fatal(err) + } + if got, err := mem.ReadWord(t.Context(), 0x100); err != nil || got != 0x12345678 { + t.Fatalf("ReadWord = %#x, %v", got, err) + } + if err := mem.WriteWord(t.Context(), 0x101, 0); err == nil { + t.Fatal("unaligned WriteWord succeeded") + } +} diff --git a/docs/README.md b/docs/README.md index d002b6b..c317672 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,8 @@ today and how to assemble them without duplicating lower-level behavior. posted AP access, power handshakes, and MEM-AP details worth testing. - [CoreSight component inspection](coresight.md) describes identification registers, ROM entry decoding, bounded traversal, and the inspection example. +- [Cortex-M control](cortexm.md) describes Cortex-M0 acquisition, halt/resume, + restoration, and the explicitly gated control example. - [Composition](composition.md) maps common tasks to the narrowest public package that implements them and gives coding agents a selection checklist. - [Capabilities](capabilities.md) distinguishes implemented behavior from diff --git a/docs/architecture.md b/docs/architecture.md index c5300f7..7321084 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,7 +23,7 @@ USB host access Arm Debug Port and MEM-AP | v - Cortex-M identity + Cortex-M identity and Cortex-M0 control | v examples and ost @@ -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` | Read and decode the architectural Cortex-M CPUID value. | +| `target/cortexm` | Identify Cortex-M processors and own Cortex-M0 halting debug over borrowed word memory. | | `examples/...` | Demonstrate public package compositions as executable programs. | | `cmd/ost` | Provide a small command hierarchy over the same public packages. | @@ -334,8 +334,12 @@ children with power-domain metadata and reports an incomplete result. It uses DAP transfer sizes but owns no DAP or MEM-AP state. See [CoreSight component identity](coresight.md) for its register and failure boundaries. -`target/cortexm` depends only on a compatible word reader. It knows the CPUID -address and encoding, but it does not know about USB, FTDI, or SWD. +`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. +It does not know about USB, adapters, or wire protocols. See +[Cortex-M control](cortexm.md) for restoration and failure boundaries. ## Host implementations @@ -376,10 +380,11 @@ replaceable while exercising the public protocol and DAP layers. ## Safety effects -The current examples and `ost` inspection commands do not reset or halt the -target, write target memory, or change persistent state. The `dap.MemAP` API -does expose scalar and block target-memory writes; applications choose the -affected addresses and own the consequences. +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. +The `dap.MemAP` API does expose scalar and block target-memory writes; +applications choose the affected addresses and own the consequences. The layers are not entirely passive: diff --git a/docs/capabilities.md b/docs/capabilities.md index 42db91f..2d90cce 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -114,7 +114,7 @@ does not prove that the board connects those pins to a debug target. | FT232H | Yes | Port A; full MPSSE and SWD HIL on Linux and macOS. | | FT2232H | Yes | Ports A and B using the standard H-series interface and endpoint layout. | | FT4232H | Yes | Ports A and B using the standard H-series interface and endpoint layout. | -| Explicit clock | Yes | `MaxClockHz` is a ceiling; `Channel.ClockHz` reports the attainable configured rate. Examples request 400 kHz. | +| Explicit clock | Yes | `MaxClockHz` is a ceiling; `Channel.ClockHz` reports the attainable configured rate. SWD examples request 1 MHz. | | MPSSE lifecycle | Yes | Claim, reset bit mode, purge stale traffic, synchronize, and configure the clock with target pins as inputs. Close drains pending bulk OUT work before resetting bit mode, setting the latency timer to 16 ms, purging the receive and transmit paths, releasing, and closing. | | SWD bit streams | Yes | Direction-safe output and input runs. Enough maximum-packet-sized IN transfers remain posted to cover the worst-case response admitted by the shared 8,192-clock wire limit, including FTDI status bytes. That requires seventeen requests for a 512-byte endpoint and 133 for a 64-byte endpoint. The receive path consumes them in submission order, replenishes each before delivering its payload, and discards status-only packets independently of OUT completion. | | Ambiguous transfer handling | Yes | A USB error, including an asynchronous receive failure, invalid transfer count, malformed FTDI packet, or surplus payload poisons the channel. A call which observes the poisoned channel returns the first cause and matches `ErrChannelPoisoned`; later SWD traffic requires a fresh channel. `Close` remains available and retryable. | @@ -178,7 +178,7 @@ requires a supported v2 interface when SWD is activated. | Ownership and cleanup | Yes | Successful open owns the USB device. After failed SWD configuration, `Open` makes a bounded cleanup attempt; if a synchronized disconnect remains pending, it returns the session with the error. After a poisoned exchange, `Close` reports the abandoned port and continues USB cleanup without sending another command. Interface release remains retryable, and device close runs once. When failed open returns no session, the caller closes the device to finish or repeat cleanup. | | Passive v1 rejection | HIL | The Linux all-device inventory reported the `0d28:0204` DAPLink product and serial. HIL selected it by serial, then rejected it from the v2 path before interface claim. Its command interface is HID; no CMSIS-DAP command or target traffic was sent. | | v2 metadata reopen | HIL | The macOS all-device inventory found a `0d28:0204` micro:bit by its `BBC micro:bit CMSIS-DAP` product string. Two fresh sessions returned protocol `2.1.0`, firmware `0257`, packet size 64, packet count 5, and capabilities `0x11`. No target command was sent. | -| SWD target access | HIL | Two fresh sessions against the same micro:bit used `ConfigureSWD` and `WithSWD` at 100 kHz. Both returned DPIDR `0x0bb11477`, AP0 IDR `0x04770021`, and CPUID `0x410cc200`; `DHCSR.S_HALT` was unchanged. Each restored the saved AP0 CSW and TAR before releasing the debug port and disconnecting. OpenOCD 0.12.0 independently selected the same serial and v2 bulk interface, returned the same DPIDR and AP0 IDR, and identified the target as Cortex-M0. This is read-only evidence from one probe and target; CMSIS-DAP does not report the attained clock. | +| SWD target access | HIL | Two fresh sessions against the same micro:bit used `ConfigureSWD` and `WithSWD` at 100 kHz. Both returned DPIDR `0x0bb11477`, AP0 IDR `0x04770021`, and CPUID `0x410cc200`; `DHCSR.S_HALT` was unchanged. Each restored the saved AP0 CSW and TAR before releasing the debug port and disconnecting. OpenOCD 0.12.0 independently selected the same serial and v2 bulk interface, returned the same DPIDR and AP0 IDR, and identified the target as Cortex-M0. That 100 kHz run used an active debug interface. A later 1 MHz run connected first after physical replug and repeated both sessions; see [nRF51 startup](protocols/cmsisdap.md#nrf51-startup-clock). CMSIS-DAP does not report the attained clock. | | JTAG or SWO | No | The current session does not connect JTAG or use the optional SWO endpoint. | The [CMSIS-DAP v2 session guide](protocols/cmsisdap.md) gives the descriptor, @@ -245,7 +245,7 @@ See [JTAG](protocols/jtag.md) for effects and ownership. | MEM-AP acquisition | Yes | `OpenMemAP` performs AP traffic, rejects an absent or non-MEM AP, and snapshots the state which `Release` restores. | | MEM-AP debug entry | Yes | `ReadDebugBase` decodes ADIv5 and legacy BASE formats, distinguishes absence from address zero, and reads the upper word only for a present entry with CFG.LA. It preserves the memory client on success and does not access target memory. Behavioral tests cover formats, malformed values, cancellation, failure, retry, and shared SWD/JTAG access. | | MEM-AP configuration | Yes | `OpenMemAP` reads CFG, models BE, LA, and LD, and includes TARHI in retryable restoration when large addresses are available. | -| Scalar target-memory access | Yes | `ReadScalar` and `WriteScalar` support aligned 8-, 16-, and 32-bit values and verify the implementation-defined CSW.Size before using the byte lane selected by CFG.BE. CFG.LA permits addresses above 32 bits; CFG.LD makes 64-bit access eligible for the same CSW check. Oversized write values fail before traffic, and writes finish with an AP completion barrier. If the first DRW access of a failed Size64 transfer might have started, ordinary traffic remains blocked until cleanup. `ReadWord` provides the 32-bit convenience operation. | +| Scalar target-memory access | Yes | `ReadScalar` and `WriteScalar` support aligned 8-, 16-, and 32-bit values and verify the implementation-defined CSW.Size before using the byte lane selected by CFG.BE. CFG.LA permits addresses above 32 bits; CFG.LD makes 64-bit access eligible for the same CSW check. Oversized write values fail before traffic, and writes finish with an AP completion barrier. If the first DRW access of a failed Size64 transfer might have started, ordinary traffic remains blocked until cleanup. `ReadWord` and `WriteWord` provide 32-bit convenience operations. | | MEM-AP restoration | Yes | Saves and restores CSW, TAR, and TARHI when present; failed restoration remains retryable. MEM-AP restoration remains available while debug-port cleanup is pending. If framing is unknown, `Release` re-enters the bound protocol and verifies identity before restoration. It terminates a possibly incomplete Size64 transfer through CSW before touching TAR or TARHI. If DAPABORT interrupts cleanup, the next `Release` retries every saved value. The invalidated handle remains invalid. | | Managed target-memory writes | Yes | `WriteScalar` and `WriteBlock` are effectful. The caller selects the address; the API checks alignment and range, not whether that address is safe to modify. `WriteRawAP` remains an unmanaged escape hatch. | | Block reads | Yes | Accepts empty, unaligned, and mixed-width ranges. No auto-incrementing word run crosses a 1 KiB TAR boundary. If the MEM-AP does not accept single address increment, the reader writes TAR before each word. It uses the ordinary DAP WAIT policy. If selection, framing, or cleanup becomes uncertain, repair is required. A FAULT returns only the confirmed prefix. Cancellation and transport or protocol failures can also interrupt the read. Unread destination bytes remain untouched. | @@ -302,14 +302,15 @@ layouts and power-domain skips have hardware-independent test coverage. | --- | --- | --- | | CPUID read and decode | Yes | Accepts any aligned-word reader and validates a plausible Arm Cortex-M identity. | | Physical identity read | HIL | Opt-in FTDI/SWD/DAP/MEM-AP integration test. | -| Halt, resume, or step | No | No target run-control API exists. | +| 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. | | 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. | -The package identifies a processor; it is not yet a complete Cortex-M target -driver. +Identity covers Cortex-M; acquired control currently accepts Cortex-M0 only. +See [Cortex-M control](cortexm.md) for its effects and cleanup limits. ## Executable surfaces @@ -325,6 +326,9 @@ Available examples: - `examples/simple/arm-info` reports the same identities through generic 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`. + Available `ost` commands: ```text @@ -335,7 +339,8 @@ ost dap ap id --ap N ost target cortex-m id --ap N ``` -These hardware operations are read-only with respect to target memory and do +The inspection examples and these commands are read-only with respect to +target memory and do not halt or reset the target. They still claim the adapter, clock SWD, and use the volatile DAP and MEM-AP state described above. diff --git a/docs/composition.md b/docs/composition.md index 3fdf642..adb8c9b 100644 --- a/docs/composition.md +++ b/docs/composition.md @@ -36,6 +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` | | Test SWD and DAP behavior without hardware | `swd/sim`, `dap/sim` | Package tests | The examples are intentionally small, executable compositions of public @@ -49,7 +50,7 @@ Arm SW-DP. Pass an explicit port configuration: ```go connected, err := armdebug.Connect(ctx, opened, armdebug.Config{ - Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: 100_000}), + Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: 1_000_000}), }) if connected != nil { defer func() { err = errors.Join(err, connected.Close()) }() @@ -100,7 +101,10 @@ The generic `examples/simple/arm-info` program uses this ownership path: go run ./examples/simple/arm-info -provider cmsisdap -serial SERIAL -ap 0 ``` -It requests a 100 kHz SW-DP, reads DPIDR, AP IDR, and Cortex-M identity, and +It defaults to a 1 MHz SW-DP; `-clock` selects the requested ceiling in Hz. +The default meets the micro:bit nRF51's +[startup clock requirement](protocols/cmsisdap.md#nrf51-startup-clock). +It reads DPIDR, AP IDR, and Cortex-M identity, and attempts owner cleanup up to three times. It does not halt, reset, or write target memory. Probe filters may be omitted only when selection remains unique; the AP argument is required. @@ -123,7 +127,7 @@ For an owned Arm debug connection, `armdebug.Open` combines discovery and ```go connected, err := armdebug.Open(ctx, selection, armdebug.Config{ - Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: 100_000}), + Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: 1_000_000}), }) ``` @@ -233,7 +237,7 @@ An application with a `probe.SWDBackend` can transfer it to a generic owner: ```go opened := probe.New(info, backend) defer func() { err = errors.Join(err, opened.Close()) }() -wire, err := opened.SWD(ctx, probe.SWDConfig{MaxClockHz: 100_000}) +wire, err := opened.SWD(ctx, probe.SWDConfig{MaxClockHz: 1_000_000}) if err != nil { return err } @@ -322,7 +326,7 @@ return a cleanup function even when `connection.Connect` fails: ```go func connectCMSISDAPSWD(ctx context.Context, device *usb.Device) (_ uint32, cleanup func() error, err error) { - session, err := cmsisdap.Open(ctx, device, cmsisdap.WithSWD(100_000)) + session, err := cmsisdap.Open(ctx, device, cmsisdap.WithSWD(1_000_000)) if err != nil { if session != nil { return 0, session.Close, err @@ -414,7 +418,7 @@ after a complete scan error before retrying SWD cleanup. ```go func connectJLinkSWD(ctx context.Context, device *usb.Device) (_ uint32, cleanup func() error, err error) { - session, err := jlink.Open(ctx, device, jlink.WithSWD(100_000)) + session, err := jlink.Open(ctx, device, jlink.WithSWD(1_000_000)) if err != nil { closeErr := device.Close() if closeErr != nil { @@ -430,7 +434,7 @@ func connectJLinkSWD(ctx context.Context, device *usb.Device) (_ uint32, cleanup defer cancel() if connectionOwned { if session.ClockHz() == 0 { - if err := session.ConfigureSWD(cleanupCtx, 100_000); err != nil { + if err := session.ConfigureSWD(cleanupCtx, 1_000_000); err != nil { if !errors.Is(err, jlink.ErrSessionPoisoned) { return err } @@ -705,7 +709,8 @@ Use `dap.MemAP` for aligned 8-, 16-, or 32-bit target-memory reads and writes through an explicitly selected MEM-AP. Support for the non-word sizes is implementation-defined, so each access verifies that CSW accepted its size before touching memory. CFG.LD makes 64-bit access possible; CFG.LA permits -addresses above 32 bits. `target/cortexm` uses `ReadWord` for its 32-bit reads. +addresses above 32 bits. `ReadWord` and `WriteWord` supply aligned 32-bit +convenience calls over the scalar operations. `MemAP.ReadBlock` accepts empty, unaligned, and mixed-width ranges. It uses the same configured WAIT policy as the scalar and raw DAP operations. If selection, @@ -739,7 +744,10 @@ 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. +independent of the host, adapter, and wire protocol. `cortexm.Acquire` also +uses `WriteWord` to enable Cortex-M0 halting debug. Release that target before +its memory owner and retain both after failed target restoration. See +[Cortex-M control](cortexm.md) for the full composition and effects. ## Release in reverse order diff --git a/docs/coresight.md b/docs/coresight.md index 392ab72..84edb55 100644 --- a/docs/coresight.md +++ b/docs/coresight.md @@ -186,11 +186,11 @@ go run ./examples/simple/coresight-info \ -provider cmsisdap -serial SERIAL -ap 0 ``` -To inspect another known page, supply `-base ADDRESS`; this bypasses the -BASE read. The override must name an accessible, 4 KiB aligned -identification page. The example requests a 100 kHz clock and applies a -ten-second operation deadline. The library also accepts memory clients -reached through JTAG; the example configures SWD only. +To inspect another known page, supply `-base ADDRESS`; this bypasses the BASE +read. The override must name an accessible, 4 KiB aligned identification page. +The example defaults to a 1 MHz clock, accepts `-clock` in Hz, and applies a +ten-second operation deadline. The library also accepts memory clients reached +through JTAG; the example configures SWD only. Add `-walk` to follow the advertised root with depth 8, at most 256 visits, and at most 4096 entry reads across the hierarchy: @@ -253,9 +253,9 @@ OSTIOLE_ROM_HIL=1 \ ``` The test uses the same exact probe selections and externally enabled ZCU104 -chain described above. Each path opens two fresh sessions at 100 kHz, reads -its MEM-AP's advertised root, and walks with depth 8, 256 visits, 4096 entry -reads, and a 120-second operation deadline. +chain described above. In that run, each path opened two fresh sessions at +100 kHz, read its MEM-AP's advertised root, and walked with depth 8, 256 +visits, 4096 entry reads, and a 120-second operation deadline. The micro:bit SWD AP0 walk completed with six identities. Its root at `0xf0000000` led to the nested table at `0xe00ff000`, components at @@ -332,3 +332,15 @@ writes, halt, reset, component unlock, or component power requests. The walks cover advertised entries, not every component in the RP2350 debug address space. Addresses above 32 bits, memory writes, and injected failures have simulation coverage but were not exercised on this bench. + +On September 26, the micro:bit identity and ROM-walk tests repeated both +sessions at a requested 1 MHz and reproduced the identities and six visits +above. Both tests and the SWD example now use 1 MHz by default to meet the +nRF51's [startup clock requirement](protocols/cmsisdap.md#nrf51-startup-clock). +The ZCU104 test clock remains 100 kHz. Run only the micro:bit paths with: + +```sh +OSTIOLE_CORESIGHT_HIL=1 OSTIOLE_ROM_HIL=1 \ +go test -tags integration ./coresight \ + -run 'TestHIL(ComponentIdentity|ROMWalk)/microbit' -count=1 -v +``` diff --git a/docs/cortexm.md b/docs/cortexm.md new file mode 100644 index 0000000..c26845d --- /dev/null +++ b/docs/cortexm.md @@ -0,0 +1,158 @@ +# Cortex-M control + +`target/cortexm.Identify` reads CPUID through any aligned-word reader. +`Acquire` additionally enables halting debug on Cortex-M0 through a borrowed +`Memory`, whose `ReadWord` and `WriteWord` methods are supplied by `dap.MemAP`. +Other processor parts are rejected before a debug-register write. + +## Ownership + +Acquire sends target traffic but does not request a halt. It preserves an +inherited halt and rejects active stepping, interrupt masking, or an unfinished +halt transition. When debug is disabled, the other control bits are unknown; +acquisition initializes them to zero when enabling debug. + +The target requires exclusive control of the processor's debug registers. Do +not use another debugger or write those registers through raw memory while it +is acquired. Serialize the target and its memory connection. `Identity` returns +the cached CPUID after release; the zero target cannot access memory. + +`Halt` waits for Debug state. `Resume` accepts only a halt requested by this +target. Observing an already-halted processor does not acquire permission to +resume it. `Halted` reads the current status without acquiring halt ownership. + +Release the target before its MEM-AP or Arm debug owner. `Release` restores +the debug control changed by the target and leaves an inherited halt alone. +Each operation is bounded to five seconds or the caller's earlier deadline. +Failed acquisition attempts cleanup with a fresh five-second context; a +non-nil target returned with an error must be retained for release retries. +Once release starts, or a control write fails, ordinary target calls stop. +Failed cleanup retains the restoration state for another `Release`. + +Cleanup needs a usable memory connection. A poisoned transport or invalidated +MEM-AP can prevent restoration; retaining the target does not repair either. +Retain both the target and its memory owner after a release failure so +restoration can be retried. + +## Effects + +Enabling halting debug changes how the processor handles debug events, even +before an explicit halt. Halting does not stop peripheral clocks. Resuming +can execute instructions before a later failure is reported, and release +cannot undo those instructions or recover elapsed time. + +A successful memory write does not prove that the halt request cleared. If +readback never shows it clear, cleanup stays pending without repeating resume: +an ignored write cannot be distinguished from an immediate new halt. + +A completed resume can immediately encounter a new debug event. The target +reports that halt and never repeats the completed resume during cleanup. If +debug was initially disabled, observing a new halt prevents restoration until +the processor runs again. After an uncertain halt or resume write, cleanup +likewise refuses to resume an observed halt: the memory interface cannot +establish whether the failed write caused that stop. A later running observation +permits cleanup to continue. The package has no forced-resume escape hatch. +Debug events racing with restoration of disabled debug can still affect +execution. + +If halt readback shows that the request was lost, the target relinquishes +halt ownership. Cleanup leaves an independent stop alone; restoring initially +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 +[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. + +## Composition + +For a MEM-AP borrowed from `armdebug.Conn`, acquire the target and retain any +non-nil result before checking the error: + +```go +core, err := cortexm.Acquire(ctx, memory) +// Retain core for Release even when err is non-nil. +if err == nil { + err = core.Halt(ctx) +} +if err == nil { + err = core.Resume(ctx) +} +cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +cleanupErr := core.Release(cleanupCtx) +cancel() +err = errors.Join(err, cleanupErr) +// Close the Arm debug owner only after target release succeeds. +// On failure retain both owners for a later cleanup attempt. +``` + +The [control example](../examples/simple/cortexm-control/main.go) selects one +probe and AP, halts, resumes, then releases the target before closing the +connection. It requires explicit consent to control execution: + +```sh +go run ./examples/simple/cortexm-control \ + -provider cmsisdap -serial SERIAL -ap 0 -clock 1000000 -allow-control +``` + +Hardware-independent tests model DHCSR control and execution state, including +partial writes, canceled operations, ignored writes, failed cleanup, and +retry. They do not establish physical halt/resume behavior on a bench program. + +## Hardware procedure + +The opt-in integration test selects the CMSIS-DAP micro:bit with serial +`9900360140124e4500279015000000360000000097969901`, AP0, and a requested +1 MHz clock. The nRF51 needs at least 125 kHz during debug activation after +power-on; see the [startup evidence](protocols/cmsisdap.md#nrf51-startup-clock). +It requires a known firmware program with an aligned 32-bit RAM +counter incremented by the CPU at least once per 200 milliseconds. The counter +must not be updated by DMA or another processor. Loading firmware is outside +the test. + +The [counter firmware](../target/cortexm/testdata/counter/README.md) supplies +a loop that increments the counter at `0x20000000`, with build instructions +and a separate programming procedure. Loading it replaces the target program +and resets the processor. + +```sh +OSTIOLE_CORTEXM_HIL_CONTROL=1 \ +OSTIOLE_CORTEXM_HIL_PROGRAM='program name and build identity' \ +OSTIOLE_CORTEXM_HIL_COUNTER=0xRAM_ADDRESS \ +go test -tags integration ./target/cortexm -run '^TestHILCortexM0Control$' -v +``` + +Two fresh sessions check counter progress before control, no progress during +a halt, and renewed progress after resume and after release from a second +halt. The test compares inherited debug-enable and halt status before closing +the Arm debug owner. It refuses an already-halted bench. These observations +do not establish peripheral behavior, register preservation, reset, stepping, +or restoration after a physical transport failure. + +## Hardware evidence + +On September 26, 2026, Nostalgia (macOS) completed two control sessions at +1 MHz on the selected micro:bit, with Cortex-M0 CPUID `0x410cc200`. The +counter image had been programmed and verified with OpenOCD 0.12.0. Its +Intel HEX SHA-256 was +`ee294cc06ab6e8228161b49506675b065c0148b26421cf1f83c8e45e35cd4e5d`. +After a physical replug, Ostiole's read-only test connected first at 1 MHz, +then the control test ran without any intervening OpenOCD session. + +In both control sessions, the CPU counter advanced before acquisition, +remained unchanged across ten samples 20 milliseconds apart while halted, +and advanced after resume and after release from a second halt. The halted +values were `0x0d8abd3d` and `0x0db618ae`. DHCSR was `0x01000000` before +acquisition and after release in each session: debug was initially disabled, +acquisition enabled it, and release restored disabled debug with the processor +running. Both target releases and Arm debug owner closes completed. + +An earlier pair of sessions at 100 kHz, after OpenOCD had activated the +interface, preserved initially enabled debug. Those sessions do not establish +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. diff --git a/docs/protocols/cmsisdap.md b/docs/protocols/cmsisdap.md index 02ec257..a5b151e 100644 --- a/docs/protocols/cmsisdap.md +++ b/docs/protocols/cmsisdap.md @@ -134,3 +134,38 @@ the v2 opener rejected it before claiming an interface. No CMSIS-DAP command or target traffic was sent. The Linux bench still exercises only passive v1 rejection. + +## nRF51 startup clock + +The nRF51 on the micro:bit requires at least 125 kHz when entering debug +interface mode after power-on. Nordic also specifies at least 150 SWCLK cycles +with SWDIO high to guarantee that the DAP captures 50 cycles while its power +domain starts; see section 11.1.2 of the +[nRF51 reference manual](https://docs-be.nordicsemi.com/bundle/nRF51-Series/raw/resource/enus/nRF51_RM_v3.0.1.pdf). +A slower clock can work once another debugger has activated the interface. +The CMSIS-DAP driver accepts the caller's clock ceiling; it does not know the +target's startup requirements or silently raise that ceiling. + +On Nostalgia, Ostiole connected first after a physical micro:bit replug at 1 MHz +and passed both read-only sessions. At 100 kHz, both Ostiole and OpenOCD 0.12.0 +failed to read DPIDR after replug, though Ostiole passed after OpenOCD activated +the interface at 1 MHz. Only the test's requested clock changed; the CMSIS-DAP +driver was unchanged. DPIDR was `0x0bb11477`, AP0 IDR was `0x04770021`, and +CPUID was `0x410cc200`. Both sessions restored AP0 CSW and TAR and closed their +owners. + +The read-only HIL now requests 1 MHz: + +```sh +OSTIOLE_CMSISDAP_HIL=1 \ +OSTIOLE_CMSISDAP_HIL_SERIAL=9900360140124e4500279015000000360000000097969901 \ +go test -tags integration ./cmsisdap \ + -run '^TestHILCMSISDAPSWDReadOnlyStateRestoration$' -count=1 -v +``` + +For a startup check, physically unplug/replug before running the command and +leave other debuggers stopped. Reopening a session alone does not reproduce +power-on state. The earlier 100 kHz evidence above covers an active interface; +it does not establish startup from power-on. The new result covers one board +and firmware revision, with no measurement of the attained clock or guarantee +of target-specific activation timing on other devices. diff --git a/examples/README.md b/examples/README.md index 7674369..5b6969b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,8 +5,8 @@ Ostiole examples are grouped by how much of the library they compose. `trivial/` contains small protocol demonstrations. They make one narrow operation visible and are primarily useful for learning or hardware bring-up. -`simple/` is reserved for focused inspection tools that could be useful on -their own, such as processor, CoreSight, or ROM-table discovery. +`simple/` contains focused inspection and control tools, such as processor +identity, CoreSight discovery, and Cortex-M0 halt/resume. `advanced/` is reserved for composed workflows such as loading ELF payloads, programming firmware or FPGA bitstreams, and extracting data through a @@ -28,8 +28,17 @@ an example has been implemented. - [`simple/coresight-info`](simple/coresight-info) reads the advertised debug entry of a selected MEM-AP, or a known component page, through a managed SWD connection. Add `-walk` for bounded ROM traversal with partial-result reporting. +- [`simple/cortexm-control`](simple/cortexm-control) enables Cortex-M0 halting + debug, halts and resumes the processor, then restores debug control. It + requires `-allow-control`; see [Cortex-M control](../docs/cortexm.md) for + effects and cleanup limits. For ADIv6 SW-DP targets, `coresight-info -debug-space -walk` inspects the DP's advertised discovery tree. Use `-ap-base ADDRESS` instead of `-ap INDEX` to inspect memory through one ADIv6 MEM-AP. The same probe selection and cleanup rules apply. See the [RP2350 procedure](../docs/coresight.md#adiv6-and-the-rp2350). + +The `arm-info`, `coresight-info`, and `cortexm-control` examples accept +`-clock` in Hz and default to 1 MHz. This also suits the micro:bit +nRF51: its debug interface needs at least 125 kHz during startup. See the +[startup evidence](../docs/protocols/cmsisdap.md#nrf51-startup-clock). diff --git a/examples/simple/ap-id/main.go b/examples/simple/ap-id/main.go index e4b83b2..0169099 100644 --- a/examples/simple/ap-id/main.go +++ b/examples/simple/ap-id/main.go @@ -57,7 +57,7 @@ func readIdentity(ctx context.Context) (_ identity, err error) { if err != nil { return identity{}, err } - ch, err := ftdi.Open(ctx, dev, ftdi.Config{Port: ftdi.PortA, MaxClockHz: 400_000}) + ch, err := ftdi.Open(ctx, dev, ftdi.Config{Port: ftdi.PortA, MaxClockHz: 1_000_000}) if err != nil { closeOwner := dev.Close if ch != nil { diff --git a/examples/simple/arm-info/main.go b/examples/simple/arm-info/main.go index d03175b..9567214 100644 --- a/examples/simple/arm-info/main.go +++ b/examples/simple/arm-info/main.go @@ -28,7 +28,11 @@ func run() (err error) { serial := flag.String("serial", "", "exact probe serial") function := flag.String("function", "", "exact probe function") ap := flag.Int("ap", -1, "required MEM-AP index (0..255)") + clock := flag.Uint64("clock", 1_000_000, "maximum SWD clock in Hz") flag.Parse() + if *clock < 1000 || *clock > 1<<32-1 { + return errors.New("require -clock 1000..4294967295 Hz") + } if *ap < 0 || *ap > 255 || flag.NArg() != 0 { return errors.New("require -ap 0..255 and no positional arguments") } @@ -36,7 +40,7 @@ func run() (err error) { defer cancel() c, err := armdebug.Open(ctx, discover.Selection{ Provider: discover.ProviderID(*provider), Serial: *serial, Function: *function, - }, armdebug.Config{Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: 100_000})}) + }, armdebug.Config{Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: uint32(*clock)})}) if c != nil { defer func() { err = errors.Join(err, closeConnection(c)) }() } diff --git a/examples/simple/coresight-info/main.go b/examples/simple/coresight-info/main.go index dc16311..e9d1a89 100644 --- a/examples/simple/coresight-info/main.go +++ b/examples/simple/coresight-info/main.go @@ -33,7 +33,11 @@ func run() (err error) { ap := flag.Int("ap", -1, "ADIv5 MEM-AP index (0..255)") address := flag.String("base", "", "override the MEM-AP debug base with a known identification page") walk := flag.Bool("walk", false, "walk ROM tables with depth 8, 256 visits, and 4096 entry reads") + clock := flag.Uint64("clock", 1_000_000, "maximum SWD clock in Hz") flag.Parse() + if *clock < 1000 || *clock > 1<<32-1 { + return errors.New("require -clock 1000..4294967295 Hz") + } base, err := parseBase(*address) if err != nil { return err @@ -49,7 +53,7 @@ func run() (err error) { defer cancel() c, err := armdebug.Open(ctx, discover.Selection{ Provider: discover.ProviderID(*provider), Serial: *serial, Function: *function, - }, armdebug.Config{Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: 100_000})}) + }, armdebug.Config{Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: uint32(*clock)})}) if c != nil { defer func() { err = errors.Join(err, closeConnection(c)) }() } diff --git a/examples/simple/cortexm-control/main.go b/examples/simple/cortexm-control/main.go new file mode 100644 index 0000000..00785c5 --- /dev/null +++ b/examples/simple/cortexm-control/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "time" + + "github.com/jon/ostiole/armdebug" + "github.com/jon/ostiole/dap" + "github.com/jon/ostiole/discover" + _ "github.com/jon/ostiole/discover/probes" + "github.com/jon/ostiole/probe" + "github.com/jon/ostiole/target/cortexm" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + provider := flag.String("provider", "", "required probe provider") + serial := flag.String("serial", "", "required probe serial") + ap := flag.Int("ap", -1, "required MEM-AP index (0..255)") + allow := flag.Bool("allow-control", false, "allow enabling debug, halting, and resuming the processor") + clock := flag.Uint64("clock", 1_000_000, "maximum SWD clock in Hz") + flag.Parse() + if *clock < 1000 || *clock > 1<<32-1 { + return errors.New("require -clock 1000..4294967295 Hz") + } + if !*allow || *provider == "" || *serial == "" || *ap < 0 || *ap > 255 || flag.NArg() != 0 { + return errors.New("require -allow-control, -provider, -serial, and -ap 0..255") + } + selection := discover.Selection{Provider: discover.ProviderID(*provider), Serial: *serial} + return runControl(selection, dap.NewAPSel(uint8(*ap)), uint32(*clock)) +} + +func runControl(selection discover.Selection, ap dap.APSel, clock uint32) (err error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + c, err := armdebug.Open(ctx, selection, armdebug.Config{ + Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: clock}), + }) + var core *cortexm.Target + if c != nil { + defer func() { err = errors.Join(err, release(core, c)) }() + } + if err != nil { + return err + } + memory, err := c.OpenMemAP(ctx, ap) + if err != nil { + return err + } + core, err = cortexm.Acquire(ctx, memory) + if err != nil { + return err + } + return control(ctx, core) +} + +func control(ctx context.Context, core *cortexm.Target) error { + if err := core.Halt(ctx); err != nil { + return err + } + fmt.Printf("CPUID=%#08x halted\n", core.Identity().Raw) + if err := core.Resume(ctx); err != nil { + return err + } + fmt.Println("resumed") + return nil +} + +func release(core *cortexm.Target, c *armdebug.Conn) error { + var err error + for range 3 { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err = core.Release(ctx) + cancel() + if err == nil { + break + } + } + if err != nil { + return fmt.Errorf("target restoration remains pending; memory owner retained: %w", err) + } + for range 3 { + if err = c.Close(); err == nil { + return nil + } + } + return fmt.Errorf("connection cleanup remains pending: %w", err) +} diff --git a/examples/simple/cortexm-info/main.go b/examples/simple/cortexm-info/main.go index b35ab61..cafc2de 100644 --- a/examples/simple/cortexm-info/main.go +++ b/examples/simple/cortexm-info/main.go @@ -94,7 +94,7 @@ func openChannel(ctx context.Context) (*ftdi.Channel, error) { if err != nil { return nil, err } - ch, err := ftdi.Open(ctx, dev, ftdi.Config{Port: ftdi.PortA, MaxClockHz: 400_000}) + ch, err := ftdi.Open(ctx, dev, ftdi.Config{Port: ftdi.PortA, MaxClockHz: 1_000_000}) if err != nil { closeOwner := dev.Close if ch != nil { diff --git a/examples/trivial/swd-dpidr/main.go b/examples/trivial/swd-dpidr/main.go index 3a04872..79111bc 100644 --- a/examples/trivial/swd-dpidr/main.go +++ b/examples/trivial/swd-dpidr/main.go @@ -48,7 +48,7 @@ func readDPIDR(ctx context.Context) (value uint32, err error) { if err != nil { return 0, err } - ch, err := ftdi.Open(ctx, dev, ftdi.Config{Port: ftdi.PortA, MaxClockHz: 400_000}) + ch, err := ftdi.Open(ctx, dev, ftdi.Config{Port: ftdi.PortA, MaxClockHz: 1_000_000}) if err != nil { closeOwner := dev.Close if ch != nil { diff --git a/target/cortexm/acquire_cancellation_test.go b/target/cortexm/acquire_cancellation_test.go new file mode 100644 index 0000000..c1ba558 --- /dev/null +++ b/target/cortexm/acquire_cancellation_test.go @@ -0,0 +1,36 @@ +package cortexm_test + +import ( + "context" + "errors" + "testing" + + "github.com/jon/ostiole/target/cortexm" +) + +type cancelReadMemory struct { + *controlMemory + cancel context.CancelFunc +} + +func (m cancelReadMemory) ReadWord(ctx context.Context, addr uint32) (uint32, error) { + value, err := m.controlMemory.ReadWord(ctx, addr) + if addr == dhcsr { + m.cancel() + } + return value, err +} + +func TestAcquireCancellationAfterReadNeedsNoRestoration(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + m := newControlMemory() + m.failWrite = 1 + core, err := cortexm.Acquire(ctx, cancelReadMemory{m, cancel}) + if core != nil || !errors.Is(err, context.Canceled) { + t.Fatalf("core=%v err=%v", core, err) + } + if m.reads != 2 || m.writes != 0 || m.control != 0 { + t.Fatalf("reads=%d writes=%d control=%#x", m.reads, m.writes, m.control) + } +} diff --git a/target/cortexm/cancellation_test.go b/target/cortexm/cancellation_test.go new file mode 100644 index 0000000..702a3a2 --- /dev/null +++ b/target/cortexm/cancellation_test.go @@ -0,0 +1,45 @@ +package cortexm_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestHaltCancellationBeforeWritePreservesRestorationState(t *testing.T) { + for _, initial := range []uint32{0, debugEnable} { + t.Run(fmt.Sprintf("debug=%d", initial), func(t *testing.T) { + m := newControlMemory() + m.control = initial + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + m.onRead = cancel + writes := m.writes + if err := core.Halt(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("halt error = %v", err) + } + if m.writes != writes { + t.Fatal("canceled halt attempted a write") + } + m.onRead = nil + if initial == debugEnable { + m.failWrite = writes + 1 + } else { + writes++ // Acquisition still requires restoration. + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if m.writes != writes || m.control != initial { + t.Fatalf("writes=%d want=%d control=%#x", m.writes, writes, m.control) + } + }) + } +} diff --git a/target/cortexm/control.go b/target/cortexm/control.go new file mode 100644 index 0000000..4256a49 --- /dev/null +++ b/target/cortexm/control.go @@ -0,0 +1,169 @@ +package cortexm + +import ( + "context" + "errors" + "fmt" + "time" +) + +const ( + dhcsrAddress = uint32(0xe000edf0) + debugKey = uint32(0xa05f0000) + cDebugEnable = uint32(1) + cHalt = uint32(2) + cStep = uint32(4) + cMaskInts = uint32(8) + sHalt = uint32(1 << 17) + controlTimeout = 5 * time.Second +) + +// Memory reads and writes aligned 32-bit target words. A successful write must +// include completion of the underlying memory access, as dap.MemAP does. +// Implementations must honor context cancellation and deadlines. +type Memory interface { + WordReader + WriteWord(context.Context, uint32, uint32) error +} + +// Target owns Cortex-M0 halting debug state through borrowed memory. Do not copy +// it. Calls and all access to the underlying memory must be serialized. Keep +// exclusive control of the processor's debug registers until Release succeeds, +// then release the memory owner. The zero value is inactive. +type Target struct { + memory Memory + identity Identity + saved uint32 + closing bool + changed bool + haltOwned bool + haltUncertain bool + resumeUncertain bool +} + +// Acquire enables Cortex-M0 halting debug without requesting a halt. It reads +// CPUID and DHCSR, rejecting other cores, active stepping or interrupt masking, +// and an unfinished halt transition before writing. DHCSR reads consume its +// sticky reset and instruction-retirement indicators. +// +// Calls are bounded to five seconds or the caller's earlier deadline. Failed +// setup attempts restoration with an independent five-second context. A non-nil +// target returned with an error retains cleanup obligations; only Release is +// then available. Memory remains borrowed on every return. +func Acquire(ctx context.Context, memory Memory) (*Target, error) { + if memory == nil { + return nil, errors.New("cortexm: nil memory") + } + if err := liveContext(ctx); err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, controlTimeout) + defer cancel() + identity, err := Identify(ctx, memory) + if err != nil { + return nil, err + } + if identity.Part != 0xc20 || identity.Architecture != 0xc { + return nil, fmt.Errorf("cortexm: control requires Cortex-M0, got CPUID %#08x", identity.Raw) + } + saved, err := memory.ReadWord(ctx, dhcsrAddress) + if err != nil { + return nil, err + } + if err := validateControl(saved); err != nil { + return nil, err + } + t := &Target{memory: memory, identity: identity, saved: saved & (cDebugEnable | cHalt)} + if saved&cDebugEnable != 0 { + return t, nil + } + t.saved = 0 + if err := t.writeControl(ctx, cDebugEnable); err != nil { + cleanup, cancel := context.WithTimeout(context.Background(), controlTimeout) + defer cancel() + if releaseErr := t.Release(cleanup); releaseErr != nil { + return t, errors.Join(err, releaseErr) + } + return nil, err + } + return t, nil +} + +func validateControl(value uint32) error { + if value&cDebugEnable == 0 { + return nil + } + if value&(cStep|cMaskInts) != 0 { + return errors.New("cortexm: inherited stepping or interrupt masking is unsupported") + } + if value&cHalt != 0 && value&sHalt == 0 { + return errors.New("cortexm: halt transition is incomplete") + } + return nil +} + +// Identity returns the acquired CPUID, including after release. +func (t *Target) Identity() Identity { + if t == nil { + return Identity{} + } + return t.identity +} + +// Release restores the inherited debug control. A target initially halted +// remains halted. Failed restoration is retryable and blocks ordinary calls. +// Nil and released targets need no cleanup. Use a fresh context after operation +// cancellation; each attempt is capped at five seconds. Release requires usable +// 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. +func (t *Target) Release(ctx context.Context) error { + if t == nil || t.memory == nil { + return nil + } + t.closing = true + if err := liveContext(ctx); err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, controlTimeout) + defer cancel() + if t.changed { + if err := t.restore(ctx); err != nil { + return fmt.Errorf("cortexm: restore debug control: %w", err) + } + } + t.memory = nil + return nil +} + +func (t *Target) writeControl(ctx context.Context, control uint32) error { + if err := ctx.Err(); err != nil { + return err + } + t.changed = true + if err := t.memory.WriteWord(ctx, dhcsrAddress, debugKey|control); err != nil { + return err + } + value, err := t.memory.ReadWord(ctx, dhcsrAddress) + if err != nil { + return err + } + if value&cDebugEnable == 0 && t.saved == 0 { + t.changed = false + } + mask := cDebugEnable + if control&cDebugEnable != 0 { + mask |= cHalt | cStep | cMaskInts + } + if value&mask != control&mask { + return fmt.Errorf("cortexm: DHCSR control %#x, want %#x", value&mask, control&mask) + } + return nil +} + +func liveContext(ctx context.Context) error { + if ctx == nil { + return errors.New("cortexm: nil context") + } + return ctx.Err() +} diff --git a/target/cortexm/control_integration_test.go b/target/cortexm/control_integration_test.go new file mode 100644 index 0000000..8976935 --- /dev/null +++ b/target/cortexm/control_integration_test.go @@ -0,0 +1,163 @@ +//go:build integration + +package cortexm_test + +import ( + "context" + "errors" + "os" + "strconv" + "testing" + "time" + + "github.com/jon/ostiole/armdebug" + "github.com/jon/ostiole/dap" + "github.com/jon/ostiole/discover" + _ "github.com/jon/ostiole/discover/probes" + "github.com/jon/ostiole/probe" + "github.com/jon/ostiole/target/cortexm" +) + +func TestHILCortexM0Control(t *testing.T) { + if os.Getenv("OSTIOLE_CORTEXM_HIL_CONTROL") != "1" { + t.Skip("OSTIOLE_CORTEXM_HIL_CONTROL is not 1") + } + program := os.Getenv("OSTIOLE_CORTEXM_HIL_PROGRAM") + counter, err := strconv.ParseUint(os.Getenv("OSTIOLE_CORTEXM_HIL_COUNTER"), 0, 32) + if err != nil || counter%4 != 0 || program == "" { + t.Fatal("require a known bench PROGRAM and aligned RAM COUNTER address") + } + for range 2 { + if !t.Run("session", func(t *testing.T) { controlHIL(t, uint32(counter), program) }) { + return + } + } +} + +func controlHIL(t *testing.T, counter uint32, program string) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + c := openControlBench(t, ctx) + var core *cortexm.Target + t.Cleanup(func() { releaseControlBench(t, core, c) }) + memory, err := c.OpenMemAP(ctx, dap.NewAPSel(0)) + if err != nil { + t.Fatal(err) + } + 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, counter, "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) + } + checkCounterHIL(t, ctx, memory, counter, "halted", true) + if err := core.Resume(ctx); err != nil { + t.Fatal(err) + } + checkCounterHIL(t, ctx, memory, counter, "resumed", false) + if err := core.Halt(ctx); err != nil { + t.Fatal(err) + } + if err := core.Release(ctx); err != nil { + t.Fatal(err) + } + after, err := memory.ReadWord(ctx, dhcsr) + if err != nil { + t.Fatal(err) + } + mask := debugEnable | haltStatus + if after&mask != before&mask { + t.Fatalf("DHCSR before=%#x after=%#x", before, after) + } + checkCounterHIL(t, ctx, memory, counter, "released", false) + t.Logf("micro:bit CMSIS-DAP 1 MHz AP0 CPUID=%#x program=%q counter=%#x DHCSR before=%#x after=%#x", + core.Identity().Raw, program, counter, before, after) +} + +func checkCounterHIL(t *testing.T, ctx context.Context, memory *dap.MemAP, addr uint32, phase string, stopped bool) { + t.Helper() + first, err := memory.ReadWord(ctx, addr) + if err != nil { + t.Fatal(err) + } + changed := false + last := first + for range 10 { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(20 * time.Millisecond): + } + value, err := memory.ReadWord(ctx, addr) + if err != nil { + t.Fatal(err) + } + changed = changed || value != first + last = value + } + t.Logf("%s: counter[%#x] first=%#x last=%#x changed=%v", phase, addr, first, last, changed) + if changed == stopped { + t.Fatalf("counter at %#x: changed=%v stopped=%v", addr, changed, stopped) + } +} + +func openControlBench(t *testing.T, ctx context.Context) *armdebug.Conn { + t.Helper() + inventory, err := discover.Probes(ctx) + if err != nil { + t.Fatal(err) + } + candidate, err := inventory.Select(discover.Selection{ + Provider: "cmsisdap", Serial: "9900360140124e4500279015000000360000000097969901", + }) + if errors.Is(err, discover.ErrCandidateNotFound) || errors.Is(err, discover.ErrCandidateAmbiguous) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + c, err := armdebug.Open(ctx, discover.Selection{Binding: candidate.Info().Binding}, armdebug.Config{ + Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: 1_000_000}), + }) + if err != nil { + if c != nil { + releaseControlBench(t, nil, c) + } + t.Fatal(err) + } + return c +} + +func releaseControlBench(t *testing.T, core *cortexm.Target, c *armdebug.Conn) { + t.Helper() + var err error + for range 3 { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err = core.Release(ctx) + cancel() + if err == nil { + break + } + } + if err != nil { + t.Errorf("target cleanup remains pending; retaining memory owner: %v", err) + return + } + for range 3 { + if err = c.Close(); err == nil { + t.Log("target released and Arm debug owner closed") + return + } + } + t.Errorf("Arm debug cleanup remains pending: %v", err) +} diff --git a/target/cortexm/control_test.go b/target/cortexm/control_test.go new file mode 100644 index 0000000..d7bd0da --- /dev/null +++ b/target/cortexm/control_test.go @@ -0,0 +1,223 @@ +package cortexm_test + +import ( + "context" + "errors" + "testing" + + "github.com/jon/ostiole/target/cortexm" +) + +const ( + dhcsr = uint32(0xe000edf0) + debugEnable = uint32(1) + haltRequest = uint32(2) + haltStatus = uint32(1 << 17) +) + +var errMemory = errors.New("memory failure") + +type controlMemory struct { + cpuid uint32 + control uint32 + halted bool + reads, writes int + failRead, failWrite int + afterWrite bool + ignoreWrites bool + rejectWrites bool + stall bool + rehaltAfter int + rehaltPending int + onWrite func() + onRead func() +} + +func newControlMemory() *controlMemory { return &controlMemory{cpuid: 0x410cc200} } + +func (m *controlMemory) ReadWord(ctx context.Context, addr uint32) (uint32, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + m.reads++ + if m.reads == m.failRead { + return 0, errMemory + } + if addr == 0xe000ed00 { + return m.cpuid, nil + } + if addr != dhcsr { + return 0, errors.New("unexpected read address") + } + if m.rehaltPending > 0 { + m.rehaltPending-- + if m.rehaltPending == 0 { + m.control |= haltRequest + m.halted = true + } + } + value := m.control + if m.halted { + value |= haltStatus + } + if m.onRead != nil { + m.onRead() + } + return value, nil +} + +func (m *controlMemory) WriteWord(ctx context.Context, addr, value uint32) error { + if err := ctx.Err(); err != nil { + return err + } + if addr != dhcsr || value>>16 != 0xa05f { + return errors.New("invalid DHCSR write") + } + m.writes++ + fail := m.rejectWrites || m.writes == m.failWrite + if fail && !m.afterWrite { + return errMemory + } + if !m.ignoreWrites { + wasHalted := m.halted + m.control = value & 15 + if !m.stall { + m.halted = m.control&(debugEnable|haltRequest) == debugEnable|haltRequest + } + if wasHalted && m.control == debugEnable && m.rehaltAfter > 0 { + m.rehaltPending = m.rehaltAfter + m.halted = true + } + } + if m.onWrite != nil { + m.onWrite() + } + if fail { + return errMemory + } + return nil +} + +func TestAcquireRestoresDebugEnable(t *testing.T) { + for _, initial := range []uint32{0, debugEnable, debugEnable | haltRequest} { + t.Run(string(rune('0'+initial)), func(t *testing.T) { + m := newControlMemory() + m.control, m.halted = initial, initial&haltRequest != 0 + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if core.Identity().Raw != m.cpuid || m.control&debugEnable == 0 { + t.Fatalf("identity/control = %#v/%#x", core.Identity(), m.control) + } + if m.halted != (initial&haltRequest != 0) { + t.Fatal("acquisition changed halt state") + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if m.control != initial { + t.Fatalf("restored control = %#x, want %#x", m.control, initial) + } + writes := m.writes + if err := core.Release(t.Context()); err != nil || m.writes != writes { + t.Fatal("release was not idempotent") + } + }) + } +} + +func TestAcquirePreservesEventInducedHalt(t *testing.T) { + m := newControlMemory() + m.control, m.halted = debugEnable, true + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if m.control != debugEnable || !m.halted || m.writes != 0 { + t.Fatalf("control=%#x halted=%t writes=%d", m.control, m.halted, m.writes) + } +} + +func TestAcquireRejectsUnsupportedStateBeforeWrites(t *testing.T) { + for _, control := range []uint32{5, 9, 3} { + m := newControlMemory() + m.control = control + if core, err := cortexm.Acquire(t.Context(), m); err == nil || core != nil || m.writes != 0 { + t.Fatalf("control %#x: core=%v error=%v writes=%d", control, core, err, m.writes) + } + } + m := newControlMemory() + m.cpuid = 0x410fc241 + if _, err := cortexm.Acquire(t.Context(), m); err == nil || m.writes != 0 { + t.Fatal("accepted another architecture") + } +} + +func TestAcquireFailureCleanupAndRetry(t *testing.T) { + for _, after := range []bool{false, true} { + m := newControlMemory() + m.failWrite, m.afterWrite = 1, after + core, err := cortexm.Acquire(t.Context(), m) + if core != nil || !errors.Is(err, errMemory) || m.control != 0 { + t.Fatalf("after=%v: core=%v err=%v control=%#x", after, core, err, m.control) + } + } + m := newControlMemory() + m.failRead, m.failWrite = 3, 2 + core, err := cortexm.Acquire(t.Context(), m) + if core == nil || !errors.Is(err, errMemory) { + t.Fatalf("core=%v err=%v", core, err) + } + if err := core.Release(t.Context()); err != nil || m.control != 0 { + t.Fatalf("release = %v; control=%#x", err, m.control) + } +} + +func TestAcquireCleanupOutlivesOperationCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + m := newControlMemory() + m.onWrite = cancel + core, err := cortexm.Acquire(ctx, m) + if core != nil || !errors.Is(err, context.Canceled) || m.control != 0 { + t.Fatalf("core=%v err=%v control=%#x", core, err, m.control) + } +} + +func TestAcquireIgnoresUnknownDisabledControlBits(t *testing.T) { + m := newControlMemory() + m.control = 14 + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if m.control != debugEnable { + t.Fatalf("control = %#x", m.control) + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } +} + +func TestAcquireRequiresLiveContextAndMemory(t *testing.T) { + m := newControlMemory() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := cortexm.Acquire(ctx, m); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + var nilContext context.Context + if _, err := cortexm.Acquire(nilContext, m); err == nil { + t.Fatal("nil context accepted") + } + if _, err := cortexm.Acquire(t.Context(), nil); err == nil { + t.Fatal("nil memory accepted") + } + if m.reads != 0 || m.writes != 0 { + t.Fatal("invalid input reached memory") + } +} diff --git a/target/cortexm/failure_test.go b/target/cortexm/failure_test.go new file mode 100644 index 0000000..292ebcd --- /dev/null +++ b/target/cortexm/failure_test.go @@ -0,0 +1,134 @@ +package cortexm_test + +import ( + "context" + "errors" + "testing" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestControlReadFailuresRetainCleanup(t *testing.T) { + for fail := 1; fail <= 6; fail++ { + m := newControlMemory() + m.failRead = fail + core, err := cortexm.Acquire(t.Context(), m) + if err == nil { + err = core.Halt(t.Context()) + } + if err == nil { + err = core.Resume(t.Context()) + } + if !errors.Is(err, errMemory) { + t.Fatalf("read %d: %v", fail, err) + } + if core != nil { + if err := core.Release(t.Context()); err != nil { + t.Fatalf("read %d cleanup: %v", fail, err) + } + } + if m.control&debugEnable != 0 || m.halted { + t.Fatalf("read %d left control=%#x halted=%v", fail, m.control, m.halted) + } + } +} + +func TestResumeFailureRetainsCleanup(t *testing.T) { + for _, after := range []bool{false, true} { + m := newControlMemory() + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + m.failWrite, m.afterWrite = m.writes+1, after + if err := core.Resume(t.Context()); !errors.Is(err, errMemory) { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err == nil { + t.Fatal("halt during cleanup") + } + err = core.Release(t.Context()) + if !after { + if err == nil { + t.Fatal("replayed uncertain resume") + } + continue + } + if err != nil { + t.Fatal(err) + } + if m.halted || m.control != 0 { + t.Fatalf("restored halted=%v control=%#x", m.halted, m.control) + } + } +} + +func TestCanceledReleaseKeepsTargetUnavailable(t *testing.T) { + m := newControlMemory() + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := core.Release(ctx); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err == nil { + t.Fatal("halt after release started") + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if err := core.Resume(t.Context()); err == nil { + t.Fatal("resume after release") + } +} + +func TestAcquireDetectsIgnoredWrite(t *testing.T) { + m := newControlMemory() + m.ignoreWrites = true + if core, err := cortexm.Acquire(t.Context(), m); err == nil || core != nil { + t.Fatalf("core=%v err=%v", core, err) + } +} + +func TestReleaseRefusesUnsafeExternalModeChange(t *testing.T) { + m := newControlMemory() + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + m.control |= 8 + writes := m.writes + if err := core.Release(t.Context()); err == nil || m.writes != writes { + t.Fatal("release changed live interrupt masking") + } + m.control &^= 8 + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } +} + +func TestReleaseAfterResumePreservesNewHalt(t *testing.T) { + m := newControlMemory() + m.control = debugEnable + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + if err := core.Resume(t.Context()); err != nil { + t.Fatal(err) + } + m.control, m.halted = debugEnable|haltRequest, true + writes := m.writes + if err := core.Release(t.Context()); err != nil || !m.halted || m.writes != writes { + t.Fatal("release resumed a new unowned halt") + } +} diff --git a/target/cortexm/halt_uncertain_test.go b/target/cortexm/halt_uncertain_test.go new file mode 100644 index 0000000..6b84b61 --- /dev/null +++ b/target/cortexm/halt_uncertain_test.go @@ -0,0 +1,27 @@ +package cortexm_test + +import ( + "testing" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestUncertainHaltDoesNotAcquireAnIndependentStop(t *testing.T) { + for _, initial := range []uint32{0, debugEnable} { + m := newControlMemory() + m.control = initial + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + m.failWrite = m.writes + 1 + if err := core.Halt(t.Context()); err == nil { + t.Fatal("halt succeeded") + } + m.control, m.halted = debugEnable|haltRequest, true + writes := m.writes + if err := core.Release(t.Context()); err == nil || m.writes != writes || !m.halted { + t.Fatal("cleanup resumed an unattributed halt") + } + } +} diff --git a/target/cortexm/identity.go b/target/cortexm/identity.go index ea9f59d..db3aad9 100644 --- a/target/cortexm/identity.go +++ b/target/cortexm/identity.go @@ -1,4 +1,5 @@ -// Package cortexm identifies Cortex-M processors through target memory. +// Package cortexm identifies Cortex-M processors and controls Cortex-M0 +// halting debug through target memory. package cortexm import ( diff --git a/target/cortexm/ignored_enable_test.go b/target/cortexm/ignored_enable_test.go new file mode 100644 index 0000000..4190bd1 --- /dev/null +++ b/target/cortexm/ignored_enable_test.go @@ -0,0 +1,64 @@ +package cortexm_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestAcquireIgnoredEnableNeedsNoRestoration(t *testing.T) { + for _, initial := range []uint32{0, 14} { + t.Run(fmt.Sprintf("control=%d", initial), func(t *testing.T) { + m := newControlMemory() + m.control, m.ignoreWrites, m.failWrite = initial, true, 2 + core, err := cortexm.Acquire(t.Context(), m) + if core != nil || err == nil || errors.Is(err, errMemory) { + t.Fatalf("core=%v err=%v", core, err) + } + if m.reads != 3 || m.writes != 1 || m.control != initial { + t.Fatalf("reads=%d writes=%d control=%#x", m.reads, m.writes, m.control) + } + }) + } +} + +func TestAcquireFailedEnableNeedsNoRestoration(t *testing.T) { + for _, initial := range []uint32{0, 14} { + t.Run(fmt.Sprintf("control=%d", initial), func(t *testing.T) { + m := newControlMemory() + m.control, m.rejectWrites = initial, true + core, err := cortexm.Acquire(t.Context(), m) + if core != nil || !errors.Is(err, errMemory) { + t.Fatalf("core=%v err=%v", core, err) + } + if m.reads != 3 || m.writes != 1 || m.control != initial { + t.Fatalf("reads=%d writes=%d control=%#x", m.reads, m.writes, m.control) + } + }) + } +} + +func TestReleaseRetryConfirmsDisabledDebugWithoutWrite(t *testing.T) { + m := newControlMemory() + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + m.failWrite, m.afterWrite = 2, true + if err := core.Release(t.Context()); !errors.Is(err, errMemory) { + t.Fatalf("first release = %v", err) + } + m.failRead = m.reads + 1 + if err := core.Release(t.Context()); !errors.Is(err, errMemory) || m.writes != 2 { + t.Fatalf("failed confirmation: err=%v writes=%d", err, m.writes) + } + m.failRead, m.rejectWrites = 0, true + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + if m.writes != 2 || m.control != 0 { + t.Fatalf("writes=%d control=%#x", m.writes, m.control) + } +} diff --git a/target/cortexm/ignored_resume_test.go b/target/cortexm/ignored_resume_test.go new file mode 100644 index 0000000..7b74f77 --- /dev/null +++ b/target/cortexm/ignored_resume_test.go @@ -0,0 +1,105 @@ +package cortexm_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestIgnoredResumeRetainsCleanup(t *testing.T) { + for _, initial := range []uint32{0, debugEnable} { + for _, cleanup := range []bool{false, true} { + for _, failure := range []string{"none", "read", "cancel"} { + t.Run(fmt.Sprintf("debug=%d/cleanup=%t/failure=%s", initial, cleanup, failure), func(t *testing.T) { + checkIgnoredResume(t, initial, cleanup, failure) + }) + } + } + } +} + +func checkIgnoredResume(t *testing.T, initial uint32, cleanup bool, failure string) { + t.Helper() + m := newControlMemory() + m.control = initial + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if failure == "read" { + m.failRead = m.reads + 1 + if cleanup { + m.failRead++ + } + } + if failure == "cancel" { + m.onWrite = cancel + } + m.ignoreWrites = true + if cleanup { + err = core.Release(ctx) + } else { + err = core.Resume(ctx) + } + if err == nil { + t.Fatal("ignored resume was not reported") + } + if failure == "read" && !errors.Is(err, errMemory) { + t.Fatalf("read failure = %v", err) + } + if failure == "cancel" && !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation = %v", err) + } + m.onWrite = nil + writes := m.writes + if err := core.Release(t.Context()); err == nil || m.writes != writes || !m.halted { + t.Fatalf("release=%v writes=%d halted=%t", err, m.writes, m.halted) + } + m.ignoreWrites = false + m.control, m.halted = debugEnable, false + if err := core.Release(t.Context()); err != nil || m.control != initial { + t.Fatalf("resolved release=%v control=%#x", err, m.control) + } +} + +func TestLaterResumeConfirmationPreservesIndependentHalt(t *testing.T) { + for _, initial := range []uint32{0, debugEnable} { + m := newControlMemory() + m.control = initial + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + m.failRead = m.reads + 1 + if err := core.Resume(t.Context()); !errors.Is(err, errMemory) { + t.Fatal(err) + } + m.halted = true + writes := m.writes + err = core.Release(t.Context()) + if m.writes != writes || !m.halted { + t.Fatal("cleanup resumed an independent halt") + } + if initial == debugEnable && err != nil { + t.Fatal(err) + } + if initial == 0 && err == nil { + t.Fatal("disabled debug despite an independent halt") + } + m.halted = false + if err := core.Release(t.Context()); err != nil || m.control != initial { + t.Fatalf("release retry=%v control=%#x", err, m.control) + } + } +} diff --git a/target/cortexm/lost_halt_test.go b/target/cortexm/lost_halt_test.go new file mode 100644 index 0000000..b407014 --- /dev/null +++ b/target/cortexm/lost_halt_test.go @@ -0,0 +1,96 @@ +package cortexm_test + +import ( + "fmt" + "testing" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestLostHaltRequestPreservesIndependentStop(t *testing.T) { + for _, initial := range []uint32{0, debugEnable} { + for _, stopped := range []bool{false, true} { + t.Run(fmt.Sprintf("debug=%d/stopped=%t", initial, stopped), func(t *testing.T) { + checkLostHaltRequest(t, initial, stopped) + }) + } + } +} + +func checkLostHaltRequest(t *testing.T, initial uint32, stopped bool) { + t.Helper() + m := newControlMemory() + m.control = initial + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + m.onWrite = func() { + m.control, m.halted = debugEnable, stopped + } + if err := core.Halt(t.Context()); err == nil { + t.Fatal("lost halt request was not reported") + } + m.onWrite = nil + m.halted = true + writes := m.writes + err = core.Release(t.Context()) + if m.writes != writes || !m.halted { + t.Fatal("cleanup resumed an independent halt after losing its request") + } + if initial == debugEnable && err != nil { + t.Fatalf("release with no remaining control changes: %v", err) + } + if initial == 0 && err == nil { + t.Fatal("disabled debug despite an independent halt") + } + m.halted = false + if err := core.Release(t.Context()); err != nil || m.control != initial { + t.Fatalf("release retry=%v control=%#x", err, m.control) + } +} + +func TestLaterObservationRelinquishesLostHaltRequest(t *testing.T) { + for _, initial := range []uint32{0, debugEnable} { + for _, status := range []bool{false, true} { + t.Run(fmt.Sprintf("debug=%d/status=%t", initial, status), func(t *testing.T) { + checkLaterHaltLoss(t, initial, status) + }) + } + } +} + +func checkLaterHaltLoss(t *testing.T, initial uint32, status bool) { + t.Helper() + m := newControlMemory() + m.control = initial + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + m.control, m.halted = debugEnable, false + writes := m.writes + if status { + if halted, err := core.Halted(t.Context()); err != nil || halted { + t.Fatalf("halted=%t err=%v", halted, err) + } + m.control, m.halted = debugEnable|haltRequest, true + if err := core.Resume(t.Context()); err == nil || m.writes != writes { + t.Fatal("resume retained ownership after observing the lost request") + } + } + m.halted = true + err = core.Release(t.Context()) + if m.writes != writes || !m.halted { + t.Fatal("release resumed a stop after observing the lost request") + } + if initial == debugEnable && err != nil { + t.Fatal(err) + } + if initial == 0 && err == nil { + t.Fatal("disabled debug despite an independent halt") + } +} diff --git a/target/cortexm/reentry_test.go b/target/cortexm/reentry_test.go new file mode 100644 index 0000000..62404b5 --- /dev/null +++ b/target/cortexm/reentry_test.go @@ -0,0 +1,74 @@ +package cortexm_test + +import ( + "testing" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestCompletedResumeNeverReleasesNewHalt(t *testing.T) { + for _, cleanup := range []bool{false, true} { + for _, delay := range []int{1, 3} { + for _, initial := range []uint32{0, debugEnable} { + checkNewHalt(t, cleanup, delay, initial) + } + } + } +} + +func checkNewHalt(t *testing.T, cleanup bool, delay int, initial uint32) { + t.Helper() + m := newControlMemory() + m.control = initial + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + m.rehaltAfter = delay + if cleanup { + err = core.Release(t.Context()) + } else { + err = core.Resume(t.Context()) + } + if err == nil { + t.Fatal("new debug event was not reported") + } + writes := m.writes + err = core.Release(t.Context()) + if initial == debugEnable && delay > 1 && err != nil { + t.Fatal(err) + } + if (initial == 0 || delay == 1) && err == nil { + t.Fatal("released despite an unresolved halt") + } + if m.writes != writes || !m.halted { + t.Fatalf("replayed resume: initial=%d cleanup=%v delay=%d", initial, cleanup, delay) + } +} + +func TestUncertainResumeDoesNotReplayWhileHalted(t *testing.T) { + m := newControlMemory() + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + m.failWrite = m.writes + 1 + if err := core.Resume(t.Context()); err == nil { + t.Fatal("resume succeeded") + } + writes := m.writes + if err := core.Release(t.Context()); err == nil || m.writes != writes { + t.Fatal("uncertain resume was replayed") + } + // A later running observation resolves the uncertainty without another run request. + m.control, m.halted = debugEnable, false + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } +} diff --git a/target/cortexm/restore.go b/target/cortexm/restore.go new file mode 100644 index 0000000..d9cf3c9 --- /dev/null +++ b/target/cortexm/restore.go @@ -0,0 +1,69 @@ +package cortexm + +import ( + "context" + "errors" +) + +func (t *Target) restore(ctx context.Context) error { + value, err := t.memory.ReadWord(ctx, dhcsrAddress) + if err != nil { + return err + } + if t.saved == 0 && value&cDebugEnable == 0 { + return nil + } + if err := t.checkRestoreState(value); err != nil { + return err + } + if t.haltOwned { + if err := t.resume(ctx); err != nil { + return err + } + } + if !t.changed { + return nil + } + value, err = t.memory.ReadWord(ctx, dhcsrAddress) + if err != nil { + return err + } + if value&(cHalt|sHalt) != 0 && value&cDebugEnable != 0 { + return errors.New("cortexm: new halt prevents restoring disabled debug") + } + return t.writeControl(ctx, t.saved) +} + +func (t *Target) resume(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + t.resumeUncertain = true + if err := t.memory.WriteWord(ctx, dhcsrAddress, debugKey|cDebugEnable); err != nil { + t.closing = true + return err + } + t.haltOwned = false + if err := t.waitHalt(ctx, false); err != nil { + t.closing = true + return err + } + return nil +} + +func (t *Target) checkRestoreState(value uint32) error { + if value&cDebugEnable != 0 && value&(cStep|cMaskInts) != 0 { + return errors.New("cortexm: cannot restore externally changed stepping or interrupt masking") + } + if !t.haltUncertain { + t.observeHaltRequest(value) + } + if t.resumeUncertain || t.haltUncertain { + if value&(cHalt|sHalt) != 0 { + return errors.New("cortexm: control write completion is unknown; refusing to resume this halt") + } + t.resumeUncertain, t.haltUncertain, t.haltOwned = false, false, false + t.changed = t.saved&cDebugEnable == 0 + } + return nil +} diff --git a/target/cortexm/run.go b/target/cortexm/run.go new file mode 100644 index 0000000..4ccb495 --- /dev/null +++ b/target/cortexm/run.go @@ -0,0 +1,130 @@ +package cortexm + +import ( + "context" + "errors" + "time" +) + +// Halt requests a halt and waits for Debug state. A halt already present when +// observed remains unowned. Failure after a write requires Release; the request +// might have taken effect. An unconfirmed write cannot establish ownership of +// an observed halt, so it can prevent cleanup. Halting does not stop peripheral +// clocks or undo instructions executed before the halt. +func (t *Target) Halt(ctx context.Context) error { + if err := t.active(ctx); err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, controlTimeout) + defer cancel() + value, err := t.readControl(ctx) + if err != nil || value&sHalt != 0 { + return err + } + return t.requestHalt(ctx) +} + +// Resume releases a halt requested by this target and waits to leave Debug +// state. It refuses inherited halts. Failure can mean execution has already +// resumed; only Release remains available. Until readback confirms that the +// halt request cleared, cleanup stays pending without repeating resume. +// No instruction effects are undone. +func (t *Target) Resume(ctx context.Context) error { + if err := t.active(ctx); err != nil { + return err + } + if !t.haltOwned { + return errors.New("cortexm: no owned halt to resume") + } + ctx, cancel := context.WithTimeout(ctx, controlTimeout) + defer cancel() + if err := t.resume(ctx); err != nil { + return err + } + return nil +} + +// Halted reads the current Debug state. It consumes DHCSR's sticky reset and +// instruction-retirement indicators, and does not establish halt ownership. +func (t *Target) Halted(ctx context.Context) (bool, error) { + if err := t.active(ctx); err != nil { + return false, err + } + ctx, cancel := context.WithTimeout(ctx, controlTimeout) + defer cancel() + value, err := t.readControl(ctx) + return value&sHalt != 0, err +} + +func (t *Target) active(ctx context.Context) error { + if t == nil || t.memory == nil || t.closing { + return errors.New("cortexm: target is unavailable; release may be pending") + } + return liveContext(ctx) +} + +func (t *Target) readControl(ctx context.Context) (uint32, error) { + value, err := t.memory.ReadWord(ctx, dhcsrAddress) + if err == nil && (value&cDebugEnable == 0 || value&(cStep|cMaskInts) != 0) { + err = errors.New("cortexm: halting debug control changed outside the target") + } + if err != nil { + t.closing = true + } else { + t.observeHaltRequest(value) + } + return value, err +} + +func (t *Target) observeHaltRequest(value uint32) { + if (t.haltOwned || t.resumeUncertain) && value&cDebugEnable != 0 && value&cHalt == 0 { + t.haltOwned, t.resumeUncertain = false, false + t.changed = t.saved&cDebugEnable == 0 + } +} + +func (t *Target) requestHalt(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + t.changed = true + t.haltUncertain = true + if err := t.memory.WriteWord(ctx, dhcsrAddress, debugKey|cDebugEnable|cHalt); err != nil { + t.closing = true + return err + } + t.haltUncertain, t.haltOwned = false, true + if err := t.waitHalt(ctx, true); err != nil { + t.closing = true + return err + } + return nil +} + +func (t *Target) waitHalt(ctx context.Context, halted bool) error { + for { + if err := ctx.Err(); err != nil { + return err + } + value, err := t.readControl(ctx) + if err != nil { + return err + } + if value&cHalt == 0 && halted { + return errors.New("cortexm: halt request was not retained") + } + if value&cHalt != 0 && !halted { + return errors.New("cortexm: halt request remains set after resume") + } + if (value&sHalt != 0) == halted { + return nil + } + timer := time.NewTimer(time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} diff --git a/target/cortexm/run_test.go b/target/cortexm/run_test.go new file mode 100644 index 0000000..298d333 --- /dev/null +++ b/target/cortexm/run_test.go @@ -0,0 +1,132 @@ +package cortexm_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jon/ostiole/target/cortexm" +) + +func TestHaltResumeAndRelease(t *testing.T) { + for _, initial := range []uint32{0, debugEnable} { + m := newControlMemory() + m.control = initial + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + if halted, err := core.Halted(t.Context()); err != nil || !halted { + t.Fatalf("halted=%v err=%v", halted, err) + } + if err := core.Resume(t.Context()); err != nil || m.halted { + t.Fatalf("resume=%v halted=%v", err, m.halted) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + if err := core.Release(t.Context()); err != nil || m.halted || m.control != initial { + t.Fatalf("release=%v control=%#x halted=%v", err, m.control, m.halted) + } + } +} + +func TestInheritedHaltCannotBeResumed(t *testing.T) { + for _, control := range []uint32{debugEnable, debugEnable | haltRequest} { + m := newControlMemory() + m.control, m.halted = control, true + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + if err := core.Halt(t.Context()); err != nil { + t.Fatal(err) + } + if halted, err := core.Halted(t.Context()); err != nil || !halted { + t.Fatalf("halted=%t err=%v", halted, err) + } + if err := core.Resume(t.Context()); err == nil { + t.Fatal("resumed inherited halt") + } + if err := core.Release(t.Context()); err != nil || !m.halted || m.control != control || m.writes != 0 { + t.Fatalf("release=%v halted=%v control=%#x writes=%d", err, m.halted, m.control, m.writes) + } + } +} + +func TestHaltReadbackFailureRequiresRelease(t *testing.T) { + m := newControlMemory() + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + m.failRead, m.afterWrite = m.reads+2, true + if err := core.Halt(t.Context()); !errors.Is(err, errMemory) { + t.Fatal(err) + } + reads, writes := m.reads, m.writes + if err := core.Resume(t.Context()); err == nil { + t.Fatal("resume after uncertain halt succeeded") + } + if _, err := core.Halted(t.Context()); err == nil { + t.Fatal("status after uncertain halt succeeded") + } + if reads != m.reads || writes != m.writes { + t.Fatal("ordinary traffic during cleanup") + } + m.failWrite = m.writes + 1 + if err := core.Release(t.Context()); !errors.Is(err, errMemory) { + t.Fatal(err) + } + if err := core.Release(t.Context()); err != nil || m.control != 0 || m.halted { + t.Fatalf("release=%v control=%#x", err, m.control) + } +} + +func TestControlCancellationBeforeTrafficAndPolling(t *testing.T) { + m := newControlMemory() + core, err := cortexm.Acquire(t.Context(), m) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + reads, writes := m.reads, m.writes + if err := core.Halt(ctx); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if reads != m.reads || writes != m.writes { + t.Fatal("canceled halt reached memory") + } + m.stall = true + ctx, cancel = context.WithTimeout(t.Context(), 10*time.Millisecond) + defer cancel() + if err := core.Halt(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("halt = %v", err) + } + m.stall = false + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } +} + +func TestInactiveTargetRejectsControl(t *testing.T) { + for _, core := range []*cortexm.Target{nil, {}} { + if err := core.Halt(t.Context()); err == nil { + t.Fatal("inactive halt") + } + if err := core.Resume(t.Context()); err == nil { + t.Fatal("inactive resume") + } + if _, err := core.Halted(t.Context()); err == nil { + t.Fatal("inactive status") + } + if err := core.Release(t.Context()); err != nil { + t.Fatal(err) + } + } +} diff --git a/target/cortexm/testdata/counter/README.md b/target/cortexm/testdata/counter/README.md new file mode 100644 index 0000000..54f93e2 --- /dev/null +++ b/target/cortexm/testdata/counter/README.md @@ -0,0 +1,42 @@ +# Cortex-M0 counter firmware + +This micro:bit v1 bench program increments the 32-bit word at `0x20000000` +in a CPU loop. It disables interrupts and uses no peripheral or DMA engine. +The vector table starts at flash address zero and uses the top of the first +16 KiB of RAM for the initial stack pointer. Every unexpected exception loops +without changing the counter. + +Build from the repository root with Clang's Arm assembler, LLD, and GNU Arm +objcopy: + +```sh +clang --target=arm-none-eabi -mcpu=cortex-m0 -mthumb -c \ + target/cortexm/testdata/counter/counter.S -o /tmp/ostiole-counter.o +ld.lld -T target/cortexm/testdata/counter/counter.ld \ + /tmp/ostiole-counter.o -o /tmp/ostiole-counter.elf +arm-none-eabi-objcopy -O ihex /tmp/ostiole-counter.elf /tmp/ostiole-counter.hex +``` + +Loading this image replaces the target program and resets the processor. It +does not update the DAPLink interface firmware. Select the exact probe when +using an external programmer. Programming is bench preparation, separate from +the Ostiole [control test](../../../../docs/cortexm.md#hardware-procedure). + +For the selected micro:bit, use OpenOCD's CMSIS-DAP v2 transport and nRF51 +flash driver at 1 MHz. The nRF51 requires at least 125 kHz when entering +debug interface mode after power-on; 100 kHz can work after another debugger +has already activated it. See the +[startup evidence](../../../../docs/protocols/cmsisdap.md#nrf51-startup-clock). + +```sh +openocd -f interface/cmsis-dap.cfg \ + -c 'cmsis_dap_backend usb_bulk' \ + -c 'adapter serial 9900360140124e4500279015000000360000000097969901' \ + -f target/nrf51.cfg -c 'adapter speed 1000' \ + -c 'gdb_port disabled; tcl_port disabled; telnet_port disabled' \ + -c 'program /tmp/ostiole-counter.hex verify reset exit' +``` + +After successful verification and reset, run the control test with +`OSTIOLE_CORTEXM_HIL_COUNTER=0x20000000`. Use the image's SHA-256 as the program +identity in `OSTIOLE_CORTEXM_HIL_PROGRAM`. diff --git a/target/cortexm/testdata/counter/counter.S b/target/cortexm/testdata/counter/counter.S new file mode 100644 index 0000000..3709816 --- /dev/null +++ b/target/cortexm/testdata/counter/counter.S @@ -0,0 +1,27 @@ +.syntax unified +.cpu cortex-m0 +.thumb + +.section .vectors, "a", %progbits +.word 0x20004000 +.word reset_handler +.rept 46 +.word default_handler +.endr + +.section .text, "ax", %progbits +.thumb_func +.global reset_handler +reset_handler: + cpsid i + ldr r1, =0x20000000 + movs r0, #0 +counter_loop: + adds r0, #1 + str r0, [r1] + b counter_loop + +.thumb_func +default_handler: + b default_handler +.ltorg diff --git a/target/cortexm/testdata/counter/counter.ld b/target/cortexm/testdata/counter/counter.ld new file mode 100644 index 0000000..5e25095 --- /dev/null +++ b/target/cortexm/testdata/counter/counter.ld @@ -0,0 +1,9 @@ +ENTRY(reset_handler) +MEMORY { + FLASH (rx) : ORIGIN = 0, LENGTH = 256K +} +SECTIONS { + .vectors : { KEEP(*(.vectors)) } > FLASH + .text : { *(.text*) } > FLASH + /DISCARD/ : { *(.comment) *(.ARM.attributes) } +}