Skip to content

Latest commit

 

History

History
935 lines (815 loc) · 49.7 KB

composition.md

File metadata and controls

935 lines (815 loc) · 49.7 KB

Composing Ostiole

Choose the highest-level package that already owns the behavior an application needs. Drop to a lower layer only when the lower-level operation is itself the goal.

For example, a program identifying a Cortex-M should call cortexm.Identify rather than read and decode CPUID itself. A program inspecting an access port should call dap.DebugPort.ReadAPIDR rather than manually implement the posted read pipeline with SWD transactions. Use ReadRawAP and WriteRawAP only when the caller understands the selected AP class and will restore any state the access changes. Derive the complete address with APSel.Address. A raw MEM-AP data-register write can write target memory.

Find the right layer

Task Public API Executable reference
Discover and select among registered probe drivers discover.Probes, ProbeInventory.Select, Candidate.Open, or discover.OpenProbe discover/probes integration tests
List every host USB attachment usb.New, usb.AllDevices, Enumerator.List Package tests
List USB attachments understood by the FTDI driver usb.New, ftdi.SupportedDevices, Enumerator.List ost ftdi list
Read metadata from one J-Link usb.New, jlink.SupportedDevices, Enumerator.Open, jlink.Open, Session.Info Package tests
Read metadata from one CMSIS-DAP v2 probe usb.New, usb.AllDevices, cmsisdap.Candidates, Enumerator.Open, cmsisdap.Open, Session.Info Package tests
Open one FTDI MPSSE SWD port Enumerator.Open, ftdi.Open examples/trivial/swd-dpidr
Use an FTDI JTAG chain ftdi.Open or Probe.JTAG, then jtag.New and jtag.NewChain FTDI integration tests
Open one J-Link SWD session Enumerator.Open, jlink.Open, jlink.WithSWD Package tests
Use a J-Link JTAG chain jlink.Open with WithJTAG or Probe.JTAG, then jtag.New and jtag.NewChain J-Link integration tests
Open one CMSIS-DAP SWD session Enumerator.Open, cmsisdap.Open, cmsisdap.WithSWD Package tests
Connect SWD or transfer DP/AP registers swd.New, Conn.Connect, Conn.ReadDP, Conn.WriteDP, Conn.ReadAP, Conn.WriteAP, Conn.NewBatch, Conn.Release examples/trivial/swd-dpidr
Enter SWD, decode a DPIDR, and manage SW-DP power dap.NewDebugPort, DebugPort.Connect, DebugPort.Release ost dap dp id
Identify one explicitly selected AP DebugPort.ReadAPIDR, DecodeAPIDR examples/simple/ap-id
Access another AP register by its full register address DebugPort.ReadRawAP, DebugPort.WriteRawAP Package tests
Read or write one aligned target scalar through a MEM-AP dap.OpenMemAP, MemAP.ReadScalar, MemAP.WriteScalar, MemAP.Release examples/simple/cortexm-info uses ReadWord.
Read or write arbitrary target bytes through a MEM-AP dap.OpenMemAP, MemAP.ReadBlock, MemAP.WriteBlock, MemAP.Release Package tests
Obtain a MEM-AP's advertised debug entry MemAP.ReadDebugBase examples/simple/coresight-info
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, inspect registers, and resume a Cortex-M0 cortexm.Acquire, Target.Halt, Target.ReadRegister, Target.Resume, Target.Release examples/simple/cortexm-control
Step a Cortex-M0/M33 from an owned halt Target.Step examples/simple/cortexm-control -step
Own an explicit Cortex-M group and control selected cores cortexm.AcquireGroup, Group.Halt, Group.Resume, Group.Status, Group.Release Group ownership
Access a group member's registers or step it Group.ReadRegister, Group.WriteRegister, Group.Step Member access
Read or write a halted Cortex-M0/M33 register Target.ReadRegister, Target.WriteRegister Register reads, writes
Test SWD and DAP behavior without hardware swd/sim, dap/sim Package tests

The examples are intentionally small, executable compositions of public packages. ost adds command parsing and output policy, but its internal packages are not a reusable library surface.

Select and open hardware explicitly

armdebug.Connect owns the connection from an already-open probe through an Arm SW-DP. Pass an explicit port configuration:

connected, err := armdebug.Connect(ctx, opened, armdebug.Config{
    Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: 1_000_000}),
})
if connected != nil {
    defer func() { err = errors.Join(err, connected.Close()) }()
}
if err != nil {
    return err
}
port := connected.Port()

For SWD, armdebug checks opened.SupportsRegisterSWD() before activation. An advertised register backend uses opened.RegisterSWD and dap.RegisterDP; otherwise it uses the existing borrowed wire and dap.SWDP. An activation error, including probe.ErrUnsupportedRegisterSWD returned after activation began, ends setup without falling back. Existing armdebug.Config, MEM-AP, Target, and Group calls are unchanged.

The call takes responsibility for opened even on invalid input. Stop using the supplied probe directly. Keep any non-nil returned owner, including on an error, until Close succeeds; the single deferred attempt above reports a failure but does not replace an application's bounded retry policy. The borrowed port is for serialized operations, not independent Connect or Release calls. Stop using it when owner cleanup begins. A manually acquired MEM-AP must be released before closing the owner.

Close releases DAP, which releases SWD or the JTAG chain, before closing the probe. A live release failure retains the probe for another attempt. Cleanup uses fresh bounded contexts, not the operation's possibly canceled context. If the backend reports restoration impossible after device loss or a poisoned stream, Close stops target traffic, reports armdebug.ErrRestorationAbandoned, and continues host cleanup. It preserves any restoration and host-close errors from that attempt. Retrying Close then retries only pending host cleanup; a fully released owner returns nil. Freeing host resources does not resume a halted target or restore its debug state. Backends without the optional restoration contract retain conservative retry behavior. Port() returns nil once cleanup starts; previously returned pointers are governed by the borrowing contract, not forcibly revoked.

connected.RestorationPossible() reports the retained probe's restoration availability without sending traffic. Use it after a borrowed target fails to release, before deciding whether to close its memory owner:

cleanupErr := core.Release(cleanupCtx)
if cleanupErr != nil && connected.RestorationPossible() {
    return errors.Join(err, cleanupErr) // Retain core and connected for retry.
}
return errors.Join(err, cleanupErr, connected.Close())

A false result permits terminal host cleanup through Close; it does not prove that target state was restored. A true result does not validate an invalidated MEM-AP or make a stale client usable. Nil and released connections report false.

Use the owner's method to acquire a MEM-AP whose cleanup it will track:

memory, err := connected.OpenMemAP(ctx, dap.NewAPSel(0))
if err != nil {
    return err
}
processor, err := cortexm.Identify(ctx, memory)

Distinct APs may be acquired and used serially; acquiring the same AP twice fails before traffic. Do not call Release on these borrowed clients. Close releases them in reverse acquisition order before releasing DAP/SWD. If one release fails, the owner retains that client and all lower dependencies for retry. An acquisition error preserves existing clients and ownership; the DAP client's state determines which subsequent operations remain possible. Clients acquired directly with dap.OpenMemAP, rather than this method, are not tracked and remain the caller's cleanup responsibility.

The generic examples/simple/arm-info program uses this ownership path:

go run ./examples/simple/arm-info -provider jlink -serial SERIAL -ap 0

It defaults to a 1 MHz SW-DP; -clock selects the requested ceiling in Hz. 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.

A generic tool enables the bundled providers with these imports:

import (
    "github.com/jon/ostiole/discover"
    _ "github.com/jon/ostiole/discover/probes"
)

For a smaller binary, blank-import just jlink/discovery, ftdi/discovery, or cmsisdap/discovery. The core discover and probe packages import no USB implementation or concrete driver. Registration performs no hardware I/O.

A probe-owned J-Link wire retains its configuration after a complete scan-status error and reconfigures before the next valid nonempty cleanup request. The failed scan is not replayed. Keep the protocol owner for its normal restoration retry; a scan-status error alone does not establish terminal loss. Direct J-Link sessions still require explicit reconfiguration. See the J-Link session boundary.

For an owned Arm debug connection, armdebug.Open combines discovery and Connect, using the same selection and configuration:

connected, err := armdebug.Open(ctx, selection, armdebug.Config{
    Port: armdebug.SWDP(probe.SWDConfig{MaxClockHz: 1_000_000}),
})

Handle the returned owner and error as in the Connect example above. Invalid configuration or incomplete discovery prevents opening. For an explicit registry, use registry.OpenProbe and then armdebug.Connect; if opening fails with a probe, close that probe rather than trying to activate it.

With providers registered, discover.OpenProbe(ctx, selection) combines enumeration, classification, unique selection, and opening. It stops on any discovery error. discover.Probes(ctx) returns a partial inventory alongside errors so an application can inspect those errors before deliberately opening a surviving candidate. An explicit discover.Registry offers the same calls. All paths leave the same cleanup obligations on the returned owner, including when opening returns both an owner and an error.

A discover.ProbeInventory supports direct iteration and exact selection:

for candidate := range inventory {
    fmt.Println(candidate.Info())
}
selected, err := inventory.Select(discover.Selection{Serial: serial})
if err != nil {
    return err
}
opened, err := selected.Open(ctx)
if opened != nil {
    defer func() { err = errors.Join(err, opened.Close()) }()
}
if err != nil {
    return err
}

inventory.Open(ctx, selector) combines the last two operations. If an opener returns an owner with its error, preserve that owner for cleanup. No open path tries another candidate after failure. Selection errors return copied matching metadata in inventory order.

Transport discovery uses registered providers:

transports, err := discover.Transports(ctx)
if err != nil {
    return err
}
for transport := range transports {
    fmt.Println(transport.Info())
}
inventory, err := transports.Probes(ctx)
if err != nil {
    return err
}
selected, err := inventory.Select(discover.Selection{Provider: provider, Serial: serial})
if err != nil {
    return err
}
opened, err := selected.Open(ctx)
// Retain opened even when err != nil; its Close may still be needed.

For a helper which leaves ownership with its caller, return cleanup explicitly:

func openSelected(ctx context.Context, selection discover.Selection) (_ *probe.Probe, cleanup func() error, err error) {
    opened, err := discover.OpenProbe(ctx, selection)
    if opened == nil {
        return nil, nil, err
    }
    return opened, opened.Close, err
}

Retain a non-nil cleanup function even after an error, and retry failed cleanup. Release higher-level SWD, DAP, and MEM-AP state before calling it. Closing the probe invalidates every borrowed wire, including when transport cleanup fails.

Providers can instead register on a caller-owned discover.Registry with RegisterTransport. EnsureTransport shares an identical provider dependency without accepting a different provider under the same ID. Iteration repeats the detached snapshot without enumerating again. To use slices.Collect, convert the named sequence to iter.Seq[discover.Transport] explicitly.

Probe order is provider ID, serial, function, location, then product, with binding identity breaking final ties. Transport order is provider ID, serial, location, product, then attachment identity. Strings compare lexicographically and case-sensitively; empty strings sort first. USB uses numeric bus/address and VID/PID for its final identity key. Missing or duplicate serials cannot keep physical devices in the same order when replugging changes their addresses.

candidate.Info().Binding is an opaque token for the complete provider, transport, and binding identity. Display it when ordinary metadata cannot distinguish candidates, and pass it back as an exact filter:

selected, err := inventory.Select(discover.Selection{Binding: binding})

The binding filter combines with every other nonempty filter. An unknown binding returns not-found; it never falls back to another candidate. Do not parse the token or assume it survives replugging. An empty Selection{} deliberately selects the sole candidate and reports ambiguity for several.

An application with a probe.SWDBackend can transfer it to a generic owner:

opened := probe.New(info, backend)
defer func() { err = errors.Join(err, opened.Close()) }()
wire, err := opened.SWD(ctx, probe.SWDConfig{MaxClockHz: 1_000_000})
if err != nil {
    return err
}
connection := swd.New(wire)

Use a named error result for this cleanup pattern. Release any SWD or DAP state before closing the probe. The caller must stop using the transferred backend directly; the borrowed wire has no independent close operation.

A probe.RegisterSWDBackend lends completed register operations instead of SWD bits. Query opened.SupportsRegisterSWD() before choosing activation, then call opened.RegisterSWD(ctx, config). The returned probe.RegisterSWD implements dap.RegisterTransport; bind it with dap.RegisterDP(registers). Activation configures the adapter but performs no protocol entry. The backend retains all resources after a failure, and the probe attempts host cleanup; keep it for retry while cleanup remains pending. Nil and typed-nil successful surfaces are rejected through the same cleanup path.

Only one SWD wire, register transport, or JTAG surface may be activated from one probe. Release DAP before closing the probe. The borrowed register surface has no independent host close; closing the probe invalidates every borrowed operation even when host cleanup must be retried. Inactive register reads and writes return dap.RegisterUnsent with an error.

After releasing borrowed protocol and target owners, retain a probe according to its cleanup state while preserving the error from the close attempt:

func closeProbe(p *probe.Probe) (*probe.Probe, error) {
    err := p.Close()
    if p.CleanupPending() {
        return p, err
    }
    return nil, err
}

Backends can implement probe.CleanupState to distinguish retained resources from a final close error; those without it retain conservative retry behavior. For direct USB ownership, use device.CleanupPending() or claim.CleanupPending() after the corresponding close attempt. USB continues returning its cached final error even after releasing its host resources. opened.RestorationPossible() independently reports whether restoration communication remains possible, with conservative behavior for a backend which lacks probe.RestorationState. It performs no restoration and does not confirm that a halted target was restored.

FTDI and J-Link owners can instead lend JTAG with opened.JTAG(ctx, probe.JTAGConfig{MaxClockHz: 100_000}). Pass the wire to jtag.New, supply an explicit layout, and release the chain before closing the probe. Retain both after a failed release so cleanup can be retried. The JTAG guide covers pin wiring and chain ownership.

Concrete drivers can open that owner from an exact USB identity:

opened, err := jlink.OpenProbe(ctx, identity)
// Or: ftdi.OpenProbe(ctx, identity, ftdi.PortA)
if err != nil {
    return err
}
defer func() { err = errors.Join(err, opened.Close()) }()

Start with usb.New and list only the identities accepted by the intended driver. Treat the result as a snapshot rather than a live handle.

When a device family has no numeric identity catalog, usb.AllDevices makes that broader inventory request explicit. The caller can inspect DeviceInfo.Product, but a product string alone does not establish driver or protocol support.

The current examples require exactly one supported FTDI attachment. A larger application can present the returned identities to a user or match a known nonempty DeviceInfo.Serial, but it should still make one explicit selection before calling Open. The selected candidate retains its enumerated bus and address; pass the complete value to Open so it can reject a replugged or replaced attachment. Do not silently pick the first result from an ambiguous inventory.

A CMSIS-DAP v2 metadata session begins with the explicit broad inventory:

func openCMSISDAP(ctx context.Context) (_ *cmsisdap.Session, cleanup func() error, err error) {
    enumerator := usb.New()
    devices, err := enumerator.List(ctx, []usb.DeviceFilter{usb.AllDevices()})
    if err != nil {
        return nil, nil, err
    }
    candidates := cmsisdap.Candidates(devices)
    if len(candidates) != 1 {
        return nil, nil, fmt.Errorf("found %d CMSIS-DAP candidates, want one", len(candidates))
    }
    device, err := enumerator.Open(ctx, candidates[0])
    if err != nil {
        return nil, nil, err
    }
    session, err := cmsisdap.Open(ctx, device)
    if err != nil {
        if session != nil && session.CleanupPending() {
            return nil, session.Close, err
        }
        if device.CleanupPending() {
            return nil, device.Close, err
        }
        return nil, nil, err
    }
    return session, session.Close, nil
}

On success the session owns the device, and cleanup calls session.Close. After failed open, a retained session owns its command port, claim, and device; otherwise the caller retains any pending device cleanup. Keep a non-nil cleanup function and report its errors. Final cleanup errors are already part of the open error and do not require another close after resources are released. Product matching is case-sensitive and only shortlists candidates; Open still requires the exact v2 bulk interface. An application which knows a composite probe by serial or another explicit policy may select it from devices even when its device product string is absent from Candidates.

A configured CMSIS-DAP session supports safe explicit SWDIO sequences, with whole-call preflight and direction restrictions. It cannot serve as the wire for swd.New: firmware enables output at command end, while a general SWD wire must also preserve target ownership across calls. Use completed register operations for target compositions:

registers := cmsisdap.NewRegisterSWD(session) // session configured with WithSWD
dp := dap.NewDebugPort(dap.RegisterDP(registers))

Connect the debug port explicitly and release it before closing the session. cmsisdap.OpenProbe advertises RegisterSWD, which armdebug prefers before activation; the examples use this path without assembling a raw SWD wire. Activation errors do not fall back to sequences. See the register ownership contract and sequence contract.

A metadata-only J-Link session follows the same explicit inventory rule:

func readJLinkInfo(ctx context.Context) (_ jlink.Info, cleanup func() error, err error) {
    enumerator := usb.New()
    candidates, err := enumerator.List(ctx, jlink.SupportedDevices())
    if err != nil {
        return jlink.Info{}, nil, err
    }
    if len(candidates) != 1 {
        return jlink.Info{}, nil, fmt.Errorf("found %d supported J-Links, want one", len(candidates))
    }
    device, err := enumerator.Open(ctx, candidates[0])
    if err != nil {
        return jlink.Info{}, nil, err
    }
    session, err := jlink.Open(ctx, device)
    if err != nil {
        closeErr := device.Close()
        if closeErr != nil {
            return jlink.Info{}, device.Close, errors.Join(err, closeErr)
        }
        return jlink.Info{}, nil, err
    }
    info := session.Info()
    if err := session.Close(); err != nil {
        return jlink.Info{}, session.Close, err
    }
    return info, nil, nil
}

Inventory policy still belongs to the application. jlink.Open takes ownership on success. It claims only the J-Link application interface, resolves the active endpoints after selecting its alternate, and drains stale IN data before metadata. It requires a 100 ms quiet interval within a one-second synchronization budget, with separate abort and cleanup bounds. Replies delayed beyond the quiet window remain outside that guarantee. With no options it does not select or configure a target interface. If a close fails, the returned cleanup function keeps the affected device or session reachable. Calling it again either retries a retained interface claim or returns the cached device-close result.

To use the same selected probe as an SWD wire, configure it while opening and pass the session to swd.New. Keep the cleanup closure when the operation fails: it retains the highest owner until release succeeds, and reconfigures after a complete scan error before retrying SWD cleanup.

func connectJLinkSWD(ctx context.Context, device *usb.Device) (_ uint32, cleanup func() error, err error) {
    session, err := jlink.Open(ctx, device, jlink.WithSWD(1_000_000))
    if err != nil {
        closeErr := device.Close()
        if closeErr != nil {
            return 0, device.Close, errors.Join(err, closeErr)
        }
        return 0, nil, err
    }
    connection := swd.New(session)
    connectionOwned := true
    var abandoned error
    cleanup = func() error {
        cleanupCtx, cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        if connectionOwned {
            if session.ClockHz() == 0 {
                if err := session.ConfigureSWD(cleanupCtx, 1_000_000); err != nil {
                    if !errors.Is(err, jlink.ErrSessionPoisoned) {
                        return err
                    }
                    abandoned = errors.Join(abandoned, fmt.Errorf("reconfigure SWD for release: %w", err))
                    connectionOwned = false
                }
            }
            if connectionOwned {
                if err := connection.Release(cleanupCtx); err != nil {
                    if !errors.Is(err, jlink.ErrSessionPoisoned) {
                        return err
                    }
                    abandoned = errors.Join(abandoned, fmt.Errorf("release SWD connection: %w", err))
                }
                connectionOwned = false
            }
        }
        return errors.Join(abandoned, session.Close())
    }
    raw, err := connection.Connect(ctx)
    return raw, cleanup, err
}

WithSWD selects the advertised SWD interface and requests a whole-kHz clock no greater than its argument. A metadata-only session can instead call ConfigureSWD later. Both forms change volatile probe interface and clock state, which Close does not restore. A complete nonzero scan status requires another ConfigureSWD; an ambiguous transfer requires close and reopen. The returned cleanup closure stops at a transient higher-level release failure and can be retried. After a complete nonzero scan status, ClockHz() returns zero, so the closure reconfigures SWD before retrying swd.Conn.Release; it does not close the session while the connection still owns restorable target state. jlink.ErrSessionPoisoned is different because restoration is no longer possible. The closure records that restoration error, drops the higher-level owner, continues with retryable session cleanup, and keeps returning the error after the session closes.

Pass the opened device to ftdi.Open with the MPSSE port and maximum requested clock. The driver reads and validates the product from the device identity; discovery does not choose the port or clock.

ftdi.Open checks MPSSE synchronization both before and after applying the H-series clock configuration. FT2232 generations share product identifier 0x6010; a matching identifier alone does not establish support. A rejected configuration command leaves a bad-command echo ahead of the second synchronization response, so opening fails rather than treating that echo as protocol data. This check does not select a port or identify an exact chip revision.

ftdi.Open initializes MPSSE and the clock with target pins as inputs. The returned channel supplies both SWDIO and JTAGIO; each call establishes its own directions. A non-nil channel owns the USB device even on error; close that channel and retain it if cleanup fails. Only a nil result leaves Device.Close with the caller. Release higher-level protocol state before closing the channel, and do not mix raw operations underneath a live protocol connection.

Adapter drivers submit USB transfers through the claimed interface and keep their scheduling policy themselves:

claim, err := device.ClaimInterface(0)
if err != nil {
    return err
}
endpoint, err := claim.Endpoint(ctx, 0x81)
if err != nil {
    return errors.Join(err, claim.Close())
}
if endpoint.TransferType != usb.TransferBulk || endpoint.MaxPacketSize == 0 {
    return errors.Join(errors.New("invalid bulk IN endpoint"), claim.Close())
}
buffer := make([]byte, endpoint.MaxPacketSize)
input, err := claim.SubmitBulk(ctx, endpoint.Address, buffer)
if err != nil {
    return errors.Join(err, claim.Close())
}
output, err := claim.SubmitBulk(ctx, 0x02, command)
if err != nil {
    return errors.Join(err, claim.AbortBulk(endpoint.Address), claim.Close())
}
if _, err := output.Wait(ctx); err != nil {
    return errors.Join(err, claim.AbortBulk(0x02), claim.AbortBulk(endpoint.Address), claim.Close())
}
received, err := input.Wait(ctx)
if err != nil {
    return errors.Join(err, claim.Close())
}
completion := buffer[:received]
if err := consume(completion); err != nil {
    return errors.Join(err, claim.Close())
}
return claim.Close()

This example posts one IN request before its OUT request. A protocol which needs a receive window submits several maximum-packet-sized buffers instead; one which does not tolerate read-ahead submits its IN request only when the response is due. Each handle reports its own completion, including a short or zero-length transfer. Ending a Wait context does not cancel the request. If the host transfer engine fails, Wait returns that error while Done can remain open; the caller must not reuse the buffer until Done closes. AbortBulk is endpoint-wide and performs a bounded drain of every pending request on that endpoint. A drain timeout matches context.DeadlineExceeded. If native cancellation or the drain fails, those requests and the claim remain owned so cleanup can be retried. Closing the claim applies the same bound across its endpoints before release. The first endpoint lookup reads the interface's current alternate setting; a new claim does not imply alternate zero. If alternate selection fails, the next endpoint lookup reads the host state again instead of retaining descriptors for the previous alternate.

A successful AbortBulk does not prove that an IN request received no packet: completion can race with the abort. Inspect the stable result with a fresh bounded observation context after the drain:

observeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
received, completionErr := input.Wait(observeCtx)
cancel()
aborted := input.Aborted()

Aborted reports a completed native abort with no transferred data and no other transfer or native cleanup failure. It sends no traffic; nil, zero, and pending transfers report false. Wait retains the original native error. A successful completion can contain a packet even when the earlier wait timed out. The adapter decides whether an abort was expected and what the received data means; the query does not release the claim or establish protocol restoration.

Managed JTAG-DP

Select JTAG-DP through the same Open or Connect calls. The configuration contains a complete expected layout, a zero-based TDO-first TAP index, and the probe's clock ceiling:

arm, err := jtag.IDCODE(4, 0x5ba00477)
if err != nil {
    return err
}
xilinx, err := jtag.IDCODE(12, 0x14730093)
if err != nil {
    return err
}
config := armdebug.Config{
    Port: armdebug.JTAGDP(
        probe.JTAGConfig{MaxClockHz: 100_000},
        jtag.Layout{arm, xilinx}, 0,
    ),
    CleanupTimeout: 30 * time.Second,
    DAPOptions: []dap.Option{dap.WithMaxWaits(100)},
}
connected, err := armdebug.Open(ctx, selection, config)

Store any non-nil connected owner before handling err, including when setup failed. For an already-open probe, call armdebug.Connect(ctx, opened, config) instead; ownership of opened transfers even on error. Close any returned owner and retain it until cleanup succeeds. These are the same ownership rules as the SW-DP path.

The configuration copies the layout without traffic. Static layout, selected TAP, clock, and cleanup-timeout validation precede discovery or activation. The selected TAP must have an IDCODE and a four- or eight-bit IR. DAP then validates the exact physical chain and enters baseline ADIv5 JTAG-DP. Board-specific routing must already be enabled; the owner does not infer a layout or activate a hidden DAP. DAP options are checked after probe activation.

The borrowed connected.Port() supplies IDCODE through its cached Identity, and the existing AP and transaction APIs. Acquire memory separately with connected.OpenMemAP(ctx, dap.NewAPSel(1)); the owner tracks that MEM-AP's restoration. Do not release or reconnect borrowed clients yourself. Close restores owned MEM-APs in reverse acquisition order, releases DAP and the chain to BYPASS/Idle, then closes the probe. A failure retains the current owner and its dependencies, and completed releases are not repeated.

CleanupTimeout bounds each owned release attempt. Zero chooses one second for SWD or thirty seconds for JTAG; a negative value is invalid. Each attempt uses a fresh context, independent of the operation context. DAP's own recovery attempts, including chain revalidation, have separate bounds configured with dap.WithCleanupTimeout in DAPOptions; host cleanup also keeps its own limits. CleanupTimeout is not a total deadline for Close.

The opt-in managed JTAG bench test is:

OSTIOLE_ARMDEBUG_JTAG_HIL=1 go test -tags=integration ./armdebug -run '^TestHILArmJTAGOwnership$' -count=1 -v

On Nostalgia, FT4232H 01691/A at 100 kHz completed two fresh Connect sessions and two fresh Open sessions against the externally activated Arm 0x5ba00477/IR4 and Xilinx 0x14730093/IR12 chain. AP1 IDR was 0x44770002; component words at 0x80410ff0 through 0x80410ffc were 0x0d, 0x90, 0x05, and 0xb1. Each owner closed successfully. Fresh sessions found the same initial AP1 CSW/TAR (0x80000042/0) and connected CTRL/STAT (0xf0000000). This exercises managed cleanup and repeated-session AP restoration; it does not independently measure power after closing the probe. No halt, target reset, or target-memory write was performed.

Bind completed register operations

Use dap.NewDebugPort(dap.RegisterDP(transport)) when a caller-supplied dap.RegisterTransport completes each physical DP or AP read or write internally. DAP retains identity, bank selection, power, WAIT policy, transactions, and restoration. The transport owns protocol entry and framing. Give the DP owner exclusive use, retain both values after a cleanup failure, and release the DP before closing its transport. Use dap.OpenMemAP and release each acquired MEM-AP before releasing the DP, as with the SWD-wire and JTAG bindings. See the completed register helper for entry, error handling, and bounded cleanup.

Choose between raw SWD and DAP

Use swd.Conn when the application needs one explicit wire-protocol transaction or is bringing up an SWD path. Call Connect before register access and Release before closing the wire. Connect returns DPIDR, keeps inherited ORUNDETECT or tries to enable it, and records whether the setting was inherited. Release restores only a change made by that connection and can be retried. A register operation returns WAIT, FAULT, parity, and protocol errors without replaying the requested transaction. When a fixed response returns WAIT, the connection clears STICKYORUN before returning it. Use Conn.NewBatch for an ordered group of raw register operations. Queue each operation with the direction-specific DP or AP method, call Commit, then read each direction-specific result. The batch uses the connection's established response grammar. In simple mode it sends one request at a time; in overrun mode it packs complete fixed frames when the wire reports room for more than one. A transport failure makes the operations in that physical chunk indeterminate and leaves later chunks unsent. WAIT and FAULT still stop the batch, and the connection never replays a requested operation. The SWD protocol guide describes the wire transaction and the specification details which are easiest to misread.

Use dap.DebugPort when the application needs debug-port identity, power ownership, bank selection, or AP access. Call Connect before AP operations and Release afterward. DebugPort.Connect also connects its underlying SWD stream, and DebugPort.Release releases it after restoring DAP state; do not connect or release that stream separately. Give the debug port exclusive, serialized use of its swd.Conn; direct transfers on that connection can invalidate cached DAP state. DP, AP, transaction, and MEM-AP operations require an active connection. ReadDP and WriteDP take logical ADIv5 register names and manage DPBANKSEL without exposing a current-bank API. NewAPSel constructs an ADIv5 index; APAt constructs an ADIv6 base-address selector. Both return APSel values; the zero APSel remains invalid. APSel.Address combines a selector with an eight-bit ADIv5 or twelve-bit ADIv6 register offset; the resulting APAddress also has an invalid zero value. ReadAPIDR reads and decodes the common read-only AP identity. EnumerateAPs scans every ADIv5 AP selector without reading class-specific registers. Raw AP access rejects an invalid or unaligned address before traffic. Use it only when the caller understands the selected AP class and will restore any state the access changes. A raw MEM-AP data-register write can write target memory. This layer owns AP read and write completion. Construct an SWD binding with dap.NewDebugPort(dap.SWDP(conn)); the operation context bounds WAIT retry. Adding dap.WithMaxWaits(1) stops at the first clean WAIT, reporting both dap.ErrWait and its underlying swd.ErrWait. SetMaxWaits changes the limit before Connect or after a successful Release; it rejects the change while the port is connected or cleanup is pending. The count is per physical request and does not bound host I/O. A raw AP read or write which completes, or might have completed, invalidates existing MemAP values. If the limit or context ends after an AP WAIT, dap.DebugPort issues DAPABORT; existing dap.MemAP values reject further reads, though dap.MemAP.Release still attempts to restore their saved state.

For an explicit JTAG composition, pass dap.JTAGDP(chain, tapIndex) instead. The chain supplies the complete expected layout; the index is zero-based and TDO-first. Connect validates it and enters baseline ADIv5 JTAG-DP. The same AP, Txn, and OpenMemAP APIs then apply. JTAG polls accepted requests without replaying them and checks CTRL/STAT after each AP operation. It temporarily disables inherited ORUNDETECT and restores it during release. Release MEM-APs, then DAP and its chain, before closing the probe. Independent recovery defaults to thirty seconds for JTAG, versus one second for SWD; use dap.WithCleanupTimeout for slower clocks. The DAP guide shows the binding and the read-only FTDI bench procedure. armdebug.JTAGDP composes these same owners through the managed path above.

The SWD connection reads DPIDR, clears supported sticky conditions with ABORT, establishes bank zero through RDBUFF, and establishes its response grammar before DAP requests power. Debug-port CTRL/STAT writes must preserve ORUNDETECT. DAP settles a new SELECT through RDBUFF before sending AP traffic. If WAIT cleanup or a later retry leaves framing unknown, dap.DebugPort invalidates those values and later DP and AP calls stop before sending traffic. Connect performs bounded cleanup after failed setup. When cleanup succeeds, the debug port can connect again immediately. If cleanup also fails, or if Release fails, ordinary DP, AP, and MEM-AP operations remain blocked. Call MemAP.Release before retrying DebugPort.Release; cleanup re-enters SWD when necessary and verifies that DPIDR still identifies the connection being cleaned up before restoring state. DebugPort.Release settles its final bank-zero SELECT through RDBUFF, releases power, and restores connection-owned ORUNDETECT before returning success. The DAP guide describes the ADIv5 register protocol behind that lifecycle.

Use DebugPort.NewTxn when several DP or AP accesses need ordered results. Commit validates the complete queue, settles an earlier immediate DP write if necessary, then lets the SWD connection pack fixed frames within its wire limit. Sticky-exempt DPIDR, CTRL/STAT, and ABORT operations remain separate so an earlier WAIT or FAULT cannot hide behind one of them. DP writes and AP operations settle through RDBUFF before reporting success. Queued reads return a ReadResult, whose Value method returns the data. Queued writes return a WriteResult, whose Err method reports completion without a placeholder value. If the earlier write cannot be settled, none of the queued operations runs. Otherwise, confirmed results remain available after a failure; a clocked operation whose completion is uncertain reports ErrIndeterminate, and later operations report ErrNotExecuted. A transaction acquires no additional state and does not change the release order.

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. 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, framing, or cleanup becomes uncertain, repair is required. A FAULT returns the contiguous prefix read before the fault; a configured WAIT limit, cancellation, and transport or protocol failures can also interrupt the read. The rest of the destination remains unchanged. No auto-incrementing word run crosses a 1 KiB TAR boundary; unaligned edges still require the MEM-AP to accept byte or halfword CSW sizes. If the MEM-AP does not accept single address increment, ReadBlock and WriteBlock write TAR before each word.

MemAP.WriteBlock accepts the same ranges and uses the same geometry. Its returned prefix includes only chunks whose RDBUFF completion requests were accepted. If a failed chunk might already have changed memory, the error includes dap.ErrIndeterminate; the method does not retry that chunk automatically. An indeterminate chunk invalidates the MemAP, but Release remains available to restore its saved state. An accepted write is not replayed; if its RDBUFF completion request returns WAIT, only that request is retried.

MemAP.WriteScalar and MemAP.WriteBlock change target memory at the selected addresses. The library rejects a scalar value which does not fit the selected size and validates alignment, range, and advertised extensions, but deciding whether an address is safe to write belongs to the application. Keep the debug port connected until MemAP.Release restores CSW, TAR, and TARHI when present. Calls sharing a MEM-AP, its debug port, or their SWD connection must be serialized; these values do not add locking. If a Size64 transfer fails after its first DRW access might have started, ordinary debug-port traffic remains blocked. Release the MEM-AP so it can terminate the incomplete transfer through CSW, then release and reconnect the debug port.

Use target/cortexm when the desired result is processor identity. It accepts the word-reader behavior supplied by dap.MemAP, so target code remains independent of the host, adapter, and wire protocol. cortexm.Acquire also uses WriteWord to enable Cortex-M0 or Cortex-M33 halting debug. Use ReadRegister for halted core registers and WriteRegister for intentional changes. The target tracks transfer completion but does not roll back writes. Release it before its memory owner and retain both after failed target restoration. See Cortex-M control for the full composition and effects.

For independent RP2350 processors, borrow both MEM-APs from one Arm owner and acquire a separate target for each. Serialize calls over that connection and release both targets before closing their memory owner. The two-core composition and bench show the per-core ownership boundary and physical observations.

Use cortexm.AcquireGroup when one owner should acquire those targets and retain per-member results after partial failure. Supply explicit IDs and borrowed memory clients; access the targets through group methods. Serialize group and other connection calls. Release the group before closing the Arm owner, and retain both when target restoration remains pending. See group ownership and control for the composition and failure rules.

Release in reverse order

A complete Cortex-M identity composition acquires and releases state in one of these orders:

acquire: USB device → FTDI channel → debug port (enters SWD) → MEM-AP
release: MEM-AP → debug port → FTDI channel

acquire: USB device → J-Link session (configures SWD) → debug port → MEM-AP
release: MEM-AP → debug port → J-Link session

Use a fresh, bounded cleanup context if the operation context may already be canceled. Join cleanup errors with the operation error so a restoration or close failure is not lost.

Enumerator.Open cleans up native resources before returning an error. ftdi.Open attempts cleanup, then leaves the original device with the caller for the Device.Close described above. A successfully returned value belongs to the caller until its documented release or close method succeeds.

Identify a debug component

After acquiring a MEM-AP through the owner above, obtain its advertised identification page and pass it to the component reader:

base, present, err := memory.ReadDebugBase(ctx)
if err != nil {
    return err
}
if !present {
    return errors.New("MEM-AP advertises no debug entry")
}
component, err := coresight.Identify(ctx, memory, base)
if err != nil {
    return err
}
fmt.Printf("class=%#x part=%#x\n", component.Class(), component.Part())

This borrows the same memory client and adds no cleanup owner. An advertised address still requires component power and access permissions. A caller that already knows another accessible identification page can pass that address directly to coresight.Identify. Use coresight.Walk with explicit WalkLimits to follow ROM entries. Retain partial visits when it returns an error, and leave power-domain children skipped until access has been established separately. The same memory owner retains cleanup responsibility. The component guide describes entry decoding, traversal bounds, power metadata, and incomplete results.

Keep policy at the application edge

Applications own choices that depend on their users:

  • which enumerated attachment to open;
  • which explicit FTDI port, J-Link session, or CMSIS-DAP session and clock to request;
  • which access port to inspect;
  • operation and cleanup deadlines;
  • how errors and identities are presented.

Libraries own reusable hardware and protocol behavior. USB requests, MPSSE commands, SWD frames, AP posted reads, MEM-AP register restoration, and CPUID decoding should not be recreated in an application.

If a needed capability is absent, add it at the layer that can express and test it as a reusable mechanism. Do not hide a second hardware stack in an example, an ost subcommand, or another application's command package.

Guidance for coding agents

Before writing a hardware composition:

  1. Read the capability boundaries and confirm the behavior exists.
  2. Select the highest package in the task table that owns the desired result.
  3. Follow the matching executable example for acquisition and cleanup.
  4. Import public packages only; do not import cmd/ost/internal/....
  5. Preserve explicit selection, deadlines, cleanup, and safety effects.
  6. If the capability is missing, make that gap explicit instead of reimplementing lower-level framing in application code.

The architecture guide is the authority for current package ownership. The examples are the authority for compact, executable composition.