Debug an AXI handshake that stops making progress

How-to Last tested 24 October 2026

First distinguish a channel that is legally waiting from a source that will never offer its pending work. For AXI Valid-Ready transport, the sender must assert VALID independently of READY, while the receiver may wait for VALID; a sender that waits too can leave both low indefinitely. After a handshake occurs, check transaction identity and capacity separately: progress alone does not prove that the right operation was accepted.

Choose the transport and the observed boundary

This page uses the Valid-Ready transport in Arm IHI 0022 Issue L, dated 27 August 2025. That issue also defines credited transport; the example does not model it. For Valid-Ready, a transfer occurs at the rising edge where both signals are high. The sender must retain VALID and the offered information through a stall, and must not require READY before asserting VALID. The receiver may wait for VALID before asserting READY1.

Those rules identify the first debugging boundary. Look for pending work inside the sender as well as the interface pins. If no operation is pending, low VALID can be entirely unsurprising. If work is pending but VALID waits for READY, a receiver that also waits may expose the dependency error. A monitor that counts only cycles of high VALID would miss that particular deadlock because the illegal sender never raises it.

The example includes a pending flag for exactly that reason. It is an observation of the source model’s intent, not an AXI signal. In a real block, the equivalent might be a queue entry, a command accepted from software or a scheduled response. Choose an observable event that actually creates an obligation to make progress, and record the assumption under which the receiver is expected to accept it.

Run the four channel cases

The complete source below is an original, single-channel test model. The reproduction instructions select all four modes without editing it. It drives test values between rising sample edges and prints the values used by its checks; it is not an implementation template for every AXI channel.

// SPDX-License-Identifier: MIT
`timescale 1ns/1ps
module tb;
  bit clk = 0;
  bit reset = 1;
  bit valid = 0;
  bit ready = 0;
  bit pending = 0;
  bit [1:0] sent_id = 0;
  bit [1:0] received_id = 0;
  bit [7:0] payload = 0;
  bit previous_stall = 0;
  bit [7:0] held_payload = 0;
  int mode = 0;
  int transfers = 0;
  int outstanding = 0;
  int wait_cycles = 0;
  int progress_alarms = 0;
  int id_errors = 0;
  int limit_errors = 0;
  localparam int WAIT_BUDGET = 4;
  localparam int ACCEPT_CAPACITY = 1;
  always #5 clk = ~clk;

  initial begin
    if (!$value$plusargs("MODE=%d", mode)) mode = 0;
    repeat (2) @(negedge clk);
    reset = 0;
    pending = 1;
    payload = 8'h5a;
    sent_id = mode == 2 ? 2'd2 : 2'd0;
    $display("MODE %0d budget=%0d capacity=%0d", mode,
      WAIT_BUDGET, ACCEPT_CAPACITY);
    for (int cycle = 0; cycle < 8; cycle++) begin
      @(negedge clk);
      if (mode == 1) begin
        // Deliberately illegal source waits for READY.
        valid = pending && ready;
        // Receiver is allowed to wait for VALID.
        ready = valid;
      end else begin
        valid = pending;
        ready = cycle >= 2;
      end
      received_id = mode == 2 ? {1'b0, sent_id[0]} : sent_id;
      @(posedge clk);
      $display("TRACE cycle=%0d pending=%0b valid=%0b ready=%0b id=%0d wire_id=%0d data=%02x",
        cycle, pending, valid, ready, sent_id, received_id, payload);
      if (previous_stall && (!valid || payload != held_payload))
        $fatal(1, "Stalled payload or VALID changed");
      previous_stall = valid && !ready;
      held_payload = payload;
      if (valid && ready) begin
        transfers++;
        outstanding++;
        wait_cycles = 0;
        if (received_id != sent_id) begin
          id_errors++;
          $display("ID_MISMATCH sent=%0d received=%0d", sent_id, received_id);
        end
        if (outstanding > ACCEPT_CAPACITY) begin
          limit_errors++;
          $display("OUTSTANDING_LIMIT count=%0d", outstanding);
        end
        if (mode == 3 && transfers == 1) begin
          // A second request arrives before any response.
          pending = 1;
          sent_id = 2'd1;
        end else pending = 0;
      end else if (pending) begin
        wait_cycles++;
        if (wait_cycles == WAIT_BUDGET) begin
          progress_alarms++;
          $display("BOUNDED_WAIT cycle=%0d waited=%0d", cycle, wait_cycles);
        end
      end
      if (cycle == 6) outstanding = 0;
    end
    $display("RESULT transfers=%0d bounded_wait=%0d id_errors=%0d limit_errors=%0d",
      transfers, progress_alarms, id_errors, limit_errors);
    if (mode == 0 && (transfers != 1 || progress_alarms != 0))
      $fatal(1, "Legal backpressure result differs");
    if (mode == 1 && (transfers != 0 || progress_alarms != 1))
      $fatal(1, "Expected deadlock alarm");
    if (mode == 2 && id_errors != 1) $fatal(1, "Expected ID mismatch");
    if (mode == 3 && limit_errors != 1) $fatal(1, "Expected capacity mismatch");
    $finish;
  end
endmodule

Mode 0 presents pending work immediately and keeps READY low for two samples. Mode 1 deliberately makes the sender wait for READY and the receiver wait for VALID. Mode 2 truncates the two-bit sent ID to one bit at the modeled boundary. Mode 3 accepts a second request before a response even though the declared capacity is one. The last two cases are integration-contract mutations, not alternative correct interpretations of the same transfer.

The final checks assert the expected outcome for each mode. That means the deliberate-error cases finish successfully when their monitors find the intended problem. A test runner must retain the mode and the RESULT fields; a process exit code alone cannot tell whether the modeled transfer was legal. Both simulators produced the same result counts, with only their ordinary finish messages differing.

In mode 0, the first two trace rows show pending=1, valid=1 and ready=0, with the payload unchanged at 5a. The third row shows both handshake signals high. One transfer is counted, no bounded-wait alarm appears and the source removes its pending work. Waiting did not itself violate the model’s rule; the sender preserved its offer until acceptance.

CaseFirst relevant samplesTransfer countAlarm or mismatch
Legal backpressureVALID 1; READY 0, 0, then 11None
Mutual waitingPending 1; VALID 0; READY 00Bounded wait at cycle 3
ID truncationSent ID 2; received ID 01One ID mismatch
Capacity mismatchTwo accepts before a response2Outstanding count reaches 2

The example checks that a stalled payload does not change and that VALID does not disappear before acceptance. That protects an offered transfer from being replaced while the receiver waits. It does not check every payload signal a real channel carries: address, strobes, burst information and attributes need their own stability set. Define that set from the interface you actually implement rather than copying the single byte in this model.

Find the dependency before extending the timeout

Mode 1 produces eight rows of pending work with neither handshake signal asserted. At cycle 3, the fourth waiting sample, it prints BOUNDED_WAIT cycle=3 waited=4. The model’s sender condition is the mistake: valid = pending && ready. The receiver’s decision to wait for VALID is permitted by the cited transport rule. Raising the test’s timeout would postpone the symptom without removing the circular dependency.

The four-cycle budget is an explicit example assumption. AXI does not turn this arbitrary number into a universal response deadline. A receiver can stall for reasons outside this small model, so a bounded monitor must be tied to the system’s own progress requirement, fairness assumptions or known resource availability. An alarm means that declared bound was exceeded; it does not by itself prove that every continued execution will deadlock.

A useful investigation follows the wait condition backward. Determine what makes the sender pending, what lets it assert VALID, what makes the receiver ready, and which other resources those conditions require. Then look for a cycle in those dependencies. The assertion guide explains why a property triggered only by VALID can pass without checking the never-presented request that matters here.

Preserve identities across integration

Mode 2 transfers successfully but reports ID_MISMATCH sent=2 received=0. The mutation keeps only the low bit, so the original identifier is lost even though VALID and READY agree. This is why a successful handshake is an acceptance observation, not an end-to-end transaction-correlation result. A real adapter may intentionally translate IDs, but it needs a reversible mapping and matching response handling, not silent truncation.

Check identifier width at both sides of the boundary and include the interconnect’s treatment of that identifier. The test demonstrates one deliberate truncation; it does not simulate a response channel or show a complete ID-remapping implementation. For a real scoreboard, compare accepted requests with responses using the agreed mapping and any ordering constraints. Retain enough trace context to distinguish two outstanding operations that would otherwise collapse to the same visible value.

Do the same for widths that are not IDs. Address widths can discard decode bits; data widths can change lane interpretation; strobe widths can alter which bytes are written. A correctly transferred vector with the wrong interpretation is still an integration failure. The IP handoff checklist makes those interface parameters part of a versioned release record rather than a diagram annotation.

State the outstanding limit as a contract

Mode 3 counts two accepted requests while the modeled capacity is one. It prints OUTSTANDING_LIMIT count=2 on the second acceptance. The example intentionally leaves the receiver ready despite that limit, so it demonstrates a capacity disagreement rather than a compliant admission policy. The limit of one belongs to this model; it is not presented as an AXI-wide maximum.

An integration should specify which event increments the outstanding count and which response retires it. Also state whether limits apply per source, per ID, per destination or to a shared pool. A test that decrements on the wrong event may appear to free capacity while a response is still required. This model clears its counter at a stated sample only to bound the demonstration; it does not implement that response protocol.

Read capacity checks alongside dependency checks. A full downstream queue can legitimately stop READY, but a response path that needs a resource held by the blocked request may create a larger cycle. The four-mode model isolates individual mistakes so their trace signatures remain understandable. It does not establish that combining individually passing blocks eliminates every cross-channel dependency.

Keep implementation and evidence scopes separate

The literal traces are available in the Icarus run record and the Verilator run record. Both runs used the same source bytes, fixed eight-sample traces and four-cycle monitor bound. No randomized traffic, formal liveness proof or full protocol checker was run.

Use those boundaries when carrying a result into a design review. A passing legal trace supports this model’s stable-offer check; it does not establish electrical timing, reset integrity or all-channel ordering. The standard also places clock/reset and interface-path requirements outside the simple trace comparison1. Use the clock-domain guide when the integration crosses a sampling boundary, and retain the exact protocol issue and transport selection in the handoff.

Not covered on this page

Sources

  1. Arm AMBA AXI Protocol Specification, IHI 0022 Issue L, 27 August 2025, sections A2.1–A2.3; read 24 October 2026. ↩ ↩2

Code on this page is original and MIT licensed.

How we test: methodology. Found a mistake? Corrections.