Skip to content

PCIe · Module 26

FPGA Cards — The Half of PCIe You Actually Write

A PCIe core hands you an application interface and everything past it is yours. Doubling the link gave 0% because local DDR was the limit, and removing one epoch check misdelivered 70,976 bytes across a reset boundary.

Every previous chapter in this module described a device somebody else built. This one is about the device you build, where the PCIe core stops and your RTL starts — and where every ownership boundary in Module 25 becomes something you are personally responsible for.

1. Sources, Scope, and What This Chapter Will Not Name

2. What You Actually Build

The most common misconception about FPGA PCIe design is that you implement PCIe. You do not — not the electrical layer, not the LTSSM, not the Data Link Layer, and usually not most of the Transaction Layer.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
──────────── the IP core typically owns ────────────
  PHY / SerDes, electrical
  LTSSM, link training, equalization        (Module 18)
  Data Link Layer: sequence numbers, ACK/NAK, replay   (Module 14)
  Transaction Layer framing, credit accounting          (Modules 15-16)
────────────── the application interface ──────────────
──────────────── you own everything here ──────────────
  BAR decode and register file                          (23.2, 25.5)
  DMA engine, descriptor management                     (23.3, 25.6)
  Tag allocation, Completion matching                   (23.5, 25.7)
  buffering, clock domain crossing                      (§11, §12)
  reset integration and epochs                          (§13)
  interrupt request generation                          (19.x)
  local memory arbitration                              (§14)
  observability                                         (§15, §16)

Every item in the lower block is a Module 25 chapter. That is the sense in which this chapter is the module's synthesis: an FPGA card is where the debugging chapters stop being about other people's silicon.

And the boundary is not fixed. Different cores, and different configurations of the same core, place the line differently — some handle Completion reassembly, some do not; some manage Tags, some hand you the field. The first question in any FPGA PCIe design is where the line is, and it is answered by reading your core's documentation, not by assuming.

3. Hard IP, Soft IP, and the Application Interface

A PCIe core exposes a transaction-level interface with roughly five conceptual channels, whatever a given vendor calls them:

conceptual channelcarries
TX requestrequests this device originates — MemRd, MemWr, messages
TX completionCompletions this device returns for requests it received
RX requestrequests arriving from the host — BAR-targeted MMIO
RX completionCompletions returning for requests this device issued
configuration / statuslink state, negotiated width and speed, error status, credits

Normalizing to these five is the single most valuable structural decision in an FPGA PCIe project, and §8 Block 2 is the adapter that does it. Three reasons:

It makes the design portable. A core change or a vendor change becomes an adapter rewrite rather than a redesign.

It makes the design testable. The application logic can be verified against a normalized interface without instantiating vendor IP at all.

And it makes the design reviewable. A reviewer who does not know that vendor's interface can still check ownership, backpressure and conservation — which is where the bugs are.

What the core reports through the configuration channel matters for debugging and is often ignored: negotiated link width and speed, current LTSSM state, correctable and uncorrectable error status, and available credit. §16's first-failure capture records these, because 25.9 §3 established that they are otherwise invisible from the link side.

4. The Vendor-Interface Trap

Signal names from one vendor's core are not PCIe. This sounds obvious and it produces real bugs, in three forms:

Design bugs. A field the core supplies in one configuration may not exist in another — Tag management being the common case. Code that assumes the core allocates Tags breaks silently when the core is reconfigured to pass them through.

Communication bugs. An engineer describing a problem in one vendor's signal names to someone using a different core is not communicating. The normalized vocabulary of §3 is what makes the conversation possible.

And documentation bugs, which are the most durable. A design document that describes behaviour in vendor terms cannot be checked against the PCIe specification, so errors in it are never caught. This is why §8's RTL uses core_* and app_* prefixes throughout and why this chapter names no vendor signal (§1).

5. The Block Diagram

A host with CPU, memory and root complex connects over PCIe to an FPGA card. On the card a PCIe IP core connects to a normalizing adapter, which feeds a BAR control path to a register file, a DMA engine, a completion receive path and an interrupt request path. A clock domain boundary separates these from the accelerator datapath, on-card block RAM and external DDR memory.HostPCIe IP coreNormalizing adapterBAR control pathDMA engineInterrupt requestCDC boundaryAccelerator datapathOn-chip BRAM/URAMExternal DDR/HBMcontrol12
Figure 1 — an FPGA PCIe card with the clock domain boundary drawn explicitly. The PCIe core and its application interface run in one clock domain; the accelerator and local memory typically run in another. Control, bulk data and interrupt paths cross the boundary by different mechanisms, and the choice of mechanism per path is section 12's subject.

The CDC column is the one that distinguishes this chapter from every other in the module. A GPU, an SSD controller and a NIC all cross clock domains internally, and in each case somebody else solved it. Here it is yours, and §12 is about picking the right mechanism per path rather than one mechanism for everything.

6. Control Path and Data Path, Again

The distinction 26.2 §3 drew for GPUs applies with equal force here, and FPGA designs violate it more often because the BAR path is so easy to build.

A BAR register read is a full host round trip. It is convenient, it is correct, and moving bulk data through it stalls the host for the entire transfer.

The DMA path exists because it is device-initiated and pipelined. The engine issues many outstanding requests, and its throughput is bounded by §17's slowest stage rather than by round-trip latency.

Two rules, both of which §21 sees violated regularly:

Do not benchmark bulk transfer using BAR reads. The result measures round-trip latency, which is a different quantity from streaming throughput (22.2).

And do not optimise BAR register access as if it were bandwidth. A control register read that takes a microsecond is normal; if a design is doing thousands of them per transfer, the architecture is wrong, not the latency.

7. The Instruments, Named

InstrumentAnswers
pcie_core_adapternormalize vendor fields to project types (§3, §4)
bar_control_pathdecode MMIO, hold responses under backpressure
dma_descriptor_engineown a descriptor, chunk it, track Tags
dma_tag_allocTag lifetime, with epochs (§13)
app_stream_bridgeready/valid between PCIe and application domains
cdc_control_bita stable level crossing domains (§12)
cdc_command_handshakea multi-bit command crossing domains (§12)
cdc_async_fifo_ifthe bulk-data contract (§12)
reset_epochreject stale pre-reset responses (§13)
local_mem_arbiterDMA and accelerator sharing local memory (§14)
perf_counterswhich stage is the limit? (§15, §17)
first_failure_captureone snapshot worth more than a trace (§16)

8. RTL — The Application Side

Block 1 — the package. COMPILE-TIME.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
package fpga_pcie_pkg;
 
  // The five conceptual channels of §3, named locally. A vendor core's
  // actual field names never appear above the adapter (Block 2).
  typedef enum logic [2:0] {
    REQ_MEM_RD = 3'd0,
    REQ_MEM_WR = 3'd1,
    REQ_CPL    = 3'd2,
    REQ_CPLD   = 3'd3,
    REQ_MSG    = 3'd4
  } req_kind_e;
 
  // §13: an epoch distinguishes work issued before a reset/retrain from work
  // issued after. 25.7 §7's law — an asynchronous response must carry its own
  // identity, including WHICH USE of that identity.
  localparam int unsigned EPOCH_W = 4;
  typedef logic [EPOCH_W-1:0] epoch_t;
 
  function automatic int unsigned gw(input int unsigned n);
    return (n <= 1) ? 1 : $clog2(n);   // §19: $clog2(1) is 0 in some tools
  endfunction
 
  // A DMA job as the engine owns it: a SNAPSHOT. §18 measured a live-read
  // variant losing 143,872 bytes when the host mutated the descriptor.
  typedef struct packed {
    logic [31:0] job_id;
    logic        to_host;         // 1 = device→host (MemWr), 0 = host→device (MemRd)
    logic [63:0] host_addr;
    logic [31:0] local_addr;
    logic [31:0] bytes;
    epoch_t      epoch;
  } dma_job_t;
 
  typedef struct packed {
    req_kind_e   kind;
    logic [63:0] addr;
    logic [11:0] len_bytes;
    logic [7:0]  tag;
    epoch_t      epoch;
  } app_req_t;
 
endpackage

Block 2 — the PCIe core adapter. SYNTHESIZABLE. §3 and §4's central pattern.

Input owner: the vendor core, until a transfer completes on its own terms. Output owner: the application, in project-local types. Handshake: ready/valid on both sides; the adapter never drops a transfer to resolve a mismatch. Beyond this example: real adapters handle header/data alignment, straddled packets and vendor-specific sideband. This one shows the boundary discipline, not a complete conversion.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module pcie_core_adapter (
  input  logic clk,
  input  logic rst_n,
  // ---- vendor side: DELIBERATELY generic names (§4). A real design binds
  // these to the core's actual ports in ONE file, and nothing above this
  // module ever sees a vendor-specific identifier.
  input  logic         core_rx_valid,
  output logic         core_rx_ready,
  input  logic [2:0]   core_rx_kind,
  input  logic [63:0]  core_rx_addr,
  input  logic [11:0]  core_rx_len,
  input  logic [7:0]   core_rx_tag,
  output logic         core_tx_valid,
  input  logic         core_tx_ready,
  output logic [2:0]   core_tx_kind,
  output logic [63:0]  core_tx_addr,
  output logic [11:0]  core_tx_len,
  output logic [7:0]   core_tx_tag,
  // status the core reports, and which 25.9 §3 showed is invisible from
  // the link side — captured by Block 12
  input  logic         core_link_up,
  input  logic [5:0]   core_neg_width,
  input  logic [3:0]   core_neg_speed,
  input  logic [4:0]   core_ltssm,
  // ---- application side: project-local types only
  output logic                       app_rx_valid,
  input  logic                       app_rx_ready,
  output fpga_pcie_pkg::app_req_t    app_rx_req,
  input  logic                       app_tx_valid,
  output logic                       app_tx_ready,
  input  fpga_pcie_pkg::app_req_t    app_tx_req,
  output logic                       app_link_up,
  output logic [5:0]                 app_neg_width,
  output logic [4:0]                 app_ltssm
);
 
  import fpga_pcie_pkg::*;
 
  // A pure translation with NO buffering and NO field invention. The ready
  // signals pass straight through, so the adapter cannot become a hidden
  // storage element whose contents diverge from either side (mutation 4).
  always_comb begin
    app_rx_valid      = core_rx_valid;
    core_rx_ready     = app_rx_ready;
    app_rx_req.kind   = req_kind_e'(core_rx_kind);
    app_rx_req.addr   = core_rx_addr;
    app_rx_req.len_bytes = core_rx_len;
    app_rx_req.tag    = core_rx_tag;
    app_rx_req.epoch  = '0;            // epochs are an APPLICATION concept (§13)
 
    core_tx_valid = app_tx_valid;
    app_tx_ready  = core_tx_ready;
    core_tx_kind  = app_tx_req.kind;
    core_tx_addr  = app_tx_req.addr;
    core_tx_len   = app_tx_req.len_bytes;
    core_tx_tag   = app_tx_req.tag;
 
    app_link_up   = core_link_up;
    app_neg_width = core_neg_width;    // NEGOTIATED, not capability (26.2 §4)
    app_ltssm     = core_ltssm;
  end
 
endmodule

Block 3 — the BAR control path. SYNTHESIZABLE. §6's control side.

Stall: a read response is held stable until accepted; the register file is not re-read.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module bar_control_path #(
  parameter int unsigned NREG = 64
)(
  input  logic clk,
  input  logic rst_n,
  input  logic                           req_valid,
  output logic                           req_ready,
  input  logic                           req_is_write,
  input  logic [31:0]                    req_offset,
  input  logic [31:0]                    req_wdata,
  output logic                           rsp_valid,
  input  logic                           rsp_ready,
  output logic [31:0]                    rsp_rdata,
  output logic                           offset_error,
  output logic [31:0]                    reg_file [NREG]
);
 
  import fpga_pcie_pkg::*;
 
  logic [31:0] captured_rdata;
  logic        rsp_pending;
  logic [gw(NREG)-1:0] idx;
 
  // Range-checked, not wrapped. 25.5 §3's law: an unclaimed offset is an
  // error, never a default. Wrapping turns a host bug into a silent write
  // to an unrelated register (mutation 6).
  assign idx          = gw(NREG)'(req_offset >> 2);
  assign offset_error = req_valid && ((req_offset[1:0] != 2'b00) ||
                                      ((req_offset >> 2) >= NREG));
  assign req_ready    = !rsp_pending;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      rsp_pending <= 1'b0; captured_rdata <= '0;
      for (int i = 0; i < NREG; i++) reg_file[i] <= '0;
    end else begin
      if (req_valid && req_ready && !offset_error) begin
        if (req_is_write) reg_file[idx] <= req_wdata;
        else begin
          // The read value is CAPTURED here and held. Re-reading the register
          // file while the response is stalled returns a value the host never
          // requested — a live-configuration read, which is mutation 7 and
          // the same fault 25.5 §10 guards against.
          captured_rdata <= reg_file[idx];
          rsp_pending    <= 1'b1;
        end
      end
      if (rsp_valid && rsp_ready) rsp_pending <= 1'b0;
    end
  end
 
  assign rsp_valid = rsp_pending;
  assign rsp_rdata = captured_rdata;
 
endmodule

Block 4 — Tag allocation with epochs. SYNTHESIZABLE. 23.5's allocator plus §13's epoch.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module dma_tag_alloc #(
  parameter int unsigned NTAG = 32
)(
  input  logic clk,
  input  logic rst_n,
  input  fpga_pcie_pkg::epoch_t              cur_epoch,
  input  logic                               alloc_req,
  output logic                               alloc_gnt,
  output logic [fpga_pcie_pkg::gw(NTAG)-1:0] alloc_tag,
  output fpga_pcie_pkg::epoch_t              alloc_epoch,
  input  logic                               cpl_valid,
  input  logic [fpga_pcie_pkg::gw(NTAG)-1:0] cpl_tag,
  input  fpga_pcie_pkg::epoch_t              cpl_epoch,
  input  logic                               cpl_last,        // final Cpl of the request
  output logic                               cpl_accept,
  output logic                               cpl_stale,
  output logic [31:0]                        stale_count,
  output logic [fpga_pcie_pkg::gw(NTAG+1)-1:0] free_count
);
 
  import fpga_pcie_pkg::*;
 
  logic [NTAG-1:0] busy;
  epoch_t          tag_epoch [NTAG];
 
  always_comb begin
    alloc_gnt = alloc_req && (busy != '1);
    alloc_tag = '0;
    for (int i = NTAG-1; i >= 0; i--) if (!busy[i]) alloc_tag = gw(NTAG)'(i);
    alloc_epoch = cur_epoch;
 
    // A Completion is accepted only if its Tag is outstanding AND its epoch
    // matches. §18 measured removing this comparison: 62 misdeliveries
    // carrying 70,976 bytes into the wrong job's buffer, across 133 resets.
    cpl_stale  = cpl_valid && (!busy[cpl_tag] || (tag_epoch[cpl_tag] != cpl_epoch));
    cpl_accept = cpl_valid && !cpl_stale;
 
    free_count = '0;
    for (int i = 0; i < NTAG; i++) free_count += gw(NTAG+1)'(!busy[i]);
  end
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      busy <= '0; stale_count <= '0;
      for (int i = 0; i < NTAG; i++) tag_epoch[i] <= '0;
    end else begin
      if (alloc_req && alloc_gnt) begin
        busy[alloc_tag]      <= 1'b1;
        tag_epoch[alloc_tag] <= cur_epoch;
      end
      // The Tag is freed on the LAST Completion, not the first. A split
      // series frees early otherwise, and the remaining Completions arrive
      // for a Tag that has been reissued (mutation 12; 25.7 §7).
      if (cpl_accept && cpl_last) busy[cpl_tag] <= 1'b0;
      if (cpl_stale && stale_count != 32'hFFFF_FFFF)
        stale_count <= stale_count + 32'd1;
    end
  end
 
endmodule

Block 5 — the DMA descriptor engine. SYNTHESIZABLE. Snapshot, chunk, conserve.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module dma_descriptor_engine #(
  parameter int unsigned MAX_CHUNK = 256      // IMPLEMENTATION POLICY (≤ MPS)
)(
  input  logic clk,
  input  logic rst_n,
  input  logic                         desc_valid,
  input  fpga_pcie_pkg::dma_job_t      desc_in,
  output logic                         desc_ready,
  input  fpga_pcie_pkg::epoch_t        cur_epoch,
  output logic                         req_valid,
  output fpga_pcie_pkg::app_req_t      req_out,
  input  logic                         req_ready,
  input  logic                         tag_gnt,
  input  logic [7:0]                   tag_in,
  output logic                         job_done,
  output logic [31:0]                  job_id_done,
  output logic [31:0]                  bytes_issued,
  output logic [31:0]                  bytes_described
);
 
  import fpga_pcie_pkg::*;
 
  dma_job_t    job;
  logic        active;
  logic [31:0] remaining, offset;
  logic [31:0] this_chunk;
 
  always_comb begin
    // The final chunk is PARTIAL whenever bytes is not a multiple of
    // MAX_CHUNK. §18 measured a truncating chunk count dropping it:
    // 4,119 lost tails and a 263,616-byte deficit.
    this_chunk = (remaining > MAX_CHUNK) ? MAX_CHUNK : remaining;
    desc_ready = !active;
    req_valid  = active && (remaining != 0) && tag_gnt;
    req_out    = '{kind:      job.to_host ? REQ_MEM_WR : REQ_MEM_RD,
                   addr:      job.host_addr + 64'(offset),
                   len_bytes: 12'(this_chunk),
                   tag:       tag_in,
                   epoch:     job.epoch};
    job_done    = active && (remaining == 0);
    job_id_done = job.job_id;
  end
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      active <= 1'b0; remaining <= '0; offset <= '0;
      bytes_issued <= '0; bytes_described <= '0; job <= '0;
    end else begin
      if (desc_valid && desc_ready) begin
        // SNAPSHOT. The engine never re-reads the descriptor afterwards.
        // §18's live-read variant lost 143,872 bytes when the host mutated
        // a descriptor it had already handed over.
        job             <= desc_in;
        job.epoch       <= cur_epoch;
        active          <= 1'b1;
        remaining       <= desc_in.bytes;
        offset          <= '0;
        bytes_described <= bytes_described + desc_in.bytes;
      end else if (req_valid && req_ready) begin
        remaining    <= remaining - this_chunk;
        offset       <= offset + this_chunk;
        bytes_issued <= bytes_issued + this_chunk;
      end else if (job_done) begin
        active <= 1'b0;
      end
    end
  end
 
endmodule

Block 6 — a single-bit control crossing. SYNTHESIZABLE. CDC. §12.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module cdc_control_bit #(
  parameter int unsigned STAGES = 2
)(
  input  logic dst_clk,
  input  logic dst_rst_n,
  input  logic src_level,      // MUST be a stable level, not a pulse
  output logic dst_level
);
 
  // Correct ONLY for a single bit that is a stable LEVEL. A pulse shorter
  // than the destination clock period can be missed entirely (mutation 20),
  // and a MULTI-BIT bus synchronized this way is incoherent even though
  // every individual bit is metastability-safe — counterexample A, and the
  // reason Block 7 exists.
  (* ASYNC_REG = "TRUE" *) logic [STAGES-1:0] sync;
 
  always_ff @(posedge dst_clk or negedge dst_rst_n)
    if (!dst_rst_n) sync <= '0;
    else            sync <= {sync[STAGES-2:0], src_level};
 
  assign dst_level = sync[STAGES-1];
 
endmodule

Block 7 — a multi-bit command crossing. SYNTHESIZABLE. CDC. §12's correct mechanism for a word.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module cdc_command_handshake #(
  parameter int unsigned WIDTH = 64
)(
  input  logic             src_clk, src_rst_n,
  input  logic             src_valid,
  input  logic [WIDTH-1:0] src_data,
  output logic             src_ready,
  input  logic             dst_clk, dst_rst_n,
  output logic             dst_valid,
  output logic [WIDTH-1:0] dst_data,
  input  logic             dst_ready
);
 
  // The DATA never crosses through a synchronizer. It is held stable in the
  // source domain while a single-bit REQUEST toggle crosses; the destination
  // samples the data only after observing the request, by which time it has
  // been stable for at least two destination clocks.
  //
  // This is the mechanism counterexample A's design should have used.
  logic src_req, dst_req_sync, dst_ack_sync, dst_req;
  logic [WIDTH-1:0] hold;
 
  cdc_control_bit #(.STAGES(2)) u_req (
    .dst_clk(dst_clk), .dst_rst_n(dst_rst_n), .src_level(src_req), .dst_level(dst_req));
  logic src_ack;
  cdc_control_bit #(.STAGES(2)) u_ack (
    .dst_clk(src_clk), .dst_rst_n(src_rst_n), .src_level(dst_ack_sync), .dst_level(src_ack));
 
  always_ff @(posedge src_clk or negedge src_rst_n) begin
    if (!src_rst_n) begin src_req <= 1'b0; hold <= '0; end
    else if (src_valid && src_ready) begin
      hold    <= src_data;          // stable for the whole crossing
      src_req <= ~src_req;
    end
  end
  assign src_ready = (src_req == src_ack);
 
  always_ff @(posedge dst_clk or negedge dst_rst_n) begin
    if (!dst_rst_n) begin dst_req_sync <= 1'b0; dst_ack_sync <= 1'b0; end
    else begin
      dst_req_sync <= dst_req;
      if ((dst_req != dst_req_sync) && dst_ready) dst_ack_sync <= dst_req;
    end
  end
 
  assign dst_valid = (dst_req != dst_ack_sync);
  assign dst_data  = hold;          // sampled only when dst_valid is high
 
endmodule

Block 8 — the bulk-data CDC contract. CONCEPTUAL interface. §12's rule about not writing your own.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// An async FIFO is a solved problem with subtle correctness requirements
// (Gray-coded pointers, correctly synchronized in both directions, depth a
// power of two). This chapter does NOT reimplement one — a homemade async
// FIFO in a PCIe datapath is a liability, and vendor libraries and the
// repository's existing CDC primitives should be used instead.
//
// What IS this chapter's business is the CONTRACT the FIFO must satisfy,
// stated here so §16's assertions can check it and §18 can measure it.
module cdc_async_fifo_if #(
  parameter int unsigned WIDTH = 512,
  parameter int unsigned DEPTH = 32          // MUST be a power of two
)(
  input  logic             wr_clk, wr_rst_n,
  input  logic             wr_en,
  input  logic [WIDTH-1:0] wr_data,
  output logic             wr_full,
  output logic             wr_overflow,      // an attempted write while full
  input  logic             rd_clk, rd_rst_n,
  input  logic             rd_en,
  output logic [WIDTH-1:0] rd_data,
  output logic             rd_empty,
  output logic             rd_underflow      // an attempted read while empty
);
 
  // The contract, and the three things §18 measured breaking:
  //   1. wr_en must never be asserted while wr_full.       (105,801 overflows)
  //   2. rd_en must never be asserted while rd_empty.      (1,653 underflows)
  //   3. DEPTH must be a power of two, or Gray pointers are invalid.
  // Overflow and underflow are REPORTED here rather than being silent,
  // because 25.1 §6's rule applies: a condition detected and discarded is
  // indistinguishable from one never detected.
  initial begin
    if (DEPTH & (DEPTH-1))
      $fatal(1, "cdc_async_fifo_if: DEPTH must be a power of two");
  end
 
endmodule

Block 9 — the reset epoch. SYNTHESIZABLE. §13, and the module §18's key result measures.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module reset_epoch (
  input  logic clk,
  input  logic rst_n,
  input  logic link_down,           // the core reports the link left L0
  input  logic app_reset_req,       // a software-initiated application reset
  output fpga_pcie_pkg::epoch_t cur_epoch,
  output logic                  epoch_changed,
  output logic [31:0]           epoch_count
);
 
  import fpga_pcie_pkg::*;
 
  logic link_down_d;
 
  // The epoch advances on any event that can invalidate in-flight work.
  // PCIe's own reset semantics are SPEC-DEFINED and are not restated here;
  // this local epoch is IMPLEMENTATION POLICY, and its job is narrow:
  // make a response issued before the event distinguishable from one issued
  // after. §18 measured 133 such events producing 165 stale responses, all
  // correctly discarded — and 62 misdeliveries when the check was removed.
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      cur_epoch <= '0; link_down_d <= 1'b0; epoch_count <= '0;
    end else begin
      link_down_d <= link_down;
      if ((link_down && !link_down_d) || app_reset_req) begin
        cur_epoch   <= cur_epoch + 1'b1;
        epoch_count <= epoch_count + 32'd1;
      end
    end
  end
 
  assign epoch_changed = (link_down && !link_down_d) || app_reset_req;
 
endmodule

Block 10 — the application stream bridge. SYNTHESIZABLE. §10's generic interface.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module app_stream_bridge #(
  parameter int unsigned DATA_BYTES = 64
)(
  input  logic clk,
  input  logic rst_n,
  input  logic                        in_valid,
  input  logic [DATA_BYTES*8-1:0]     in_data,
  input  logic [DATA_BYTES-1:0]       in_keep,      // partial final beat
  input  logic                        in_last,
  output logic                        in_ready,
  output logic                        out_valid,
  output logic [DATA_BYTES*8-1:0]     out_data,
  output logic [DATA_BYTES-1:0]       out_keep,
  output logic                        out_last,
  input  logic                        out_ready,
  output logic [63:0]                 bytes_passed
);
 
  // A GENERIC ready/valid stream. AXI is a vendor-ecosystem convention and
  // is NOT required by PCIe (§10); this interface is deliberately protocol-
  // neutral so the ownership properties can be stated without importing a
  // bus specification.
  //
  // `keep` exists because the final beat of a transfer is usually partial.
  // A bridge that ignores it silently rounds every transfer up to a beat
  // boundary — 25.6 §4's dropped tail, in a new place (mutation 17).
  assign out_valid = in_valid;
  assign in_ready  = out_ready;
  assign out_data  = in_data;
  assign out_keep  = in_keep;
  assign out_last  = in_last;
 
  function automatic int unsigned popcount(input logic [DATA_BYTES-1:0] k);
    popcount = 0;
    for (int i = 0; i < DATA_BYTES; i++) popcount += k[i];
  endfunction
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) bytes_passed <= '0;
    else if (out_valid && out_ready)
      bytes_passed <= bytes_passed + 64'(popcount(out_keep));
  end
 
endmodule

Block 11 — the local memory arbiter. SYNTHESIZABLE. §14.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module local_mem_arbiter #(
  parameter int unsigned NREQ = 2      // DMA and accelerator
)(
  input  logic clk,
  input  logic rst_n,
  input  logic [NREQ-1:0]                    req_valid,
  input  logic [NREQ-1:0]                    req_is_write,
  input  logic [31:0]                        req_addr [NREQ],
  output logic [NREQ-1:0]                    req_grant,
  output logic                               mem_valid,
  output logic [31:0]                        mem_addr,
  output logic                               mem_is_write,
  input  logic                               mem_ready,
  // responses must return to the RECORDED owner, not the current requester
  input  logic                               rsp_valid,
  input  logic [fpga_pcie_pkg::gw(NREQ)-1:0] rsp_id,
  output logic [NREQ-1:0]                    rsp_route,
  output logic                               rsp_misroute
);
 
  import fpga_pcie_pkg::*;
 
  logic [gw(NREQ)-1:0] rr, sel;
  logic found;
  logic [NREQ-1:0] outstanding;
 
  always_comb begin
    sel = rr; found = 1'b0;
    for (int k = 0; k < NREQ; k++) begin
      automatic logic [gw(NREQ)-1:0] i = gw(NREQ)'((rr + k) % NREQ);
      if (!found && req_valid[i]) begin sel = i; found = 1'b1; end
    end
    mem_valid    = found;
    mem_addr     = req_addr[sel];
    mem_is_write = req_is_write[sel];
    req_grant    = '0;
    if (found && mem_ready) req_grant[sel] = 1'b1;
 
    // The response is routed by the RECORDED requester id carried with the
    // transaction — never by whoever is being served now. Counterexample E
    // is the alternative, and it is the same fault 26.4 §16's identity
    // oracle exists to catch.
    rsp_route    = '0;
    if (rsp_valid) rsp_route[rsp_id] = 1'b1;
    rsp_misroute = rsp_valid && !outstanding[rsp_id];
  end
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      rr <= '0; outstanding <= '0;
    end else begin
      if (found && mem_ready) begin
        rr <= (sel == gw(NREQ)'(NREQ-1)) ? '0 : sel + 1'b1;
        outstanding[sel] <= 1'b1;
      end
      if (rsp_valid) outstanding[rsp_id] <= 1'b0;
    end
  end
 
endmodule

Block 12 — performance counters and first-failure capture. VERIFICATION-ONLY / diagnostic. §15 and §16.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module perf_and_capture #(
  parameter int unsigned NTAG = 32
)(
  input  logic clk,
  input  logic rst_n,
  // per-stage activity, so §17's limiting stage can be identified in silicon
  input  logic        pcie_beat,  input logic [15:0] pcie_bytes,
  input  logic        app_beat,   input logic [15:0] app_bytes,
  input  logic        stall_credit, stall_tag, stall_fifo_full,
  input  logic        stall_fifo_empty, stall_localmem,
  // first-failure inputs
  input  logic        fifo_overflow, fifo_underflow, cpl_stale,
  input  logic        byte_mismatch, rsp_misroute,
  input  logic [4:0]  ltssm,
  input  logic [7:0]  tag_snapshot,
  input  logic [31:0] job_snapshot,
  input  logic [15:0] fifo_level,
  input  logic        clear,
  output logic [63:0] c_pcie_bytes, c_app_bytes,
  output logic [31:0] c_credit, c_tag, c_full, c_empty, c_localmem,
  output logic        captured,
  output logic [3:0]  first_cause,
  output logic [4:0]  cap_ltssm,
  output logic [7:0]  cap_tag,
  output logic [31:0] cap_job,
  output logic [15:0] cap_fifo_level,
  output logic [31:0] cap_cycle
);
 
  logic [31:0] cycle;
  logic [3:0]  this_cause;
 
  // §16: in hardware, ONE well-chosen snapshot at the first failure is worth
  // more than hours of trace capture — 25.9 §3 measured that the link-side
  // view cannot see any of these signals at all.
  always_comb begin
    if      (fifo_overflow)  this_cause = 4'd1;
    else if (fifo_underflow) this_cause = 4'd2;
    else if (byte_mismatch)  this_cause = 4'd3;
    else if (rsp_misroute)   this_cause = 4'd4;
    else if (cpl_stale)      this_cause = 4'd5;
    else                     this_cause = 4'd0;
  end
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n || clear) begin
      c_pcie_bytes <= '0; c_app_bytes <= '0;
      c_credit <= '0; c_tag <= '0; c_full <= '0; c_empty <= '0; c_localmem <= '0;
      captured <= 1'b0; first_cause <= '0; cycle <= '0;
      cap_ltssm <= '0; cap_tag <= '0; cap_job <= '0; cap_fifo_level <= '0; cap_cycle <= '0;
    end else begin
      cycle <= cycle + 32'd1;
      if (pcie_beat && c_pcie_bytes != '1) c_pcie_bytes <= c_pcie_bytes + 64'(pcie_bytes);
      if (app_beat  && c_app_bytes  != '1) c_app_bytes  <= c_app_bytes  + 64'(app_bytes);
      // Counting BEATS ACCEPTED, never `valid` cycles. A counter incremented
      // on valid alone overstates throughput whenever the sink stalls, which
      // is mutation 33 and makes every §17 measurement wrong.
      if (stall_credit     && c_credit  != '1) c_credit  <= c_credit + 1'b1;
      if (stall_tag        && c_tag     != '1) c_tag     <= c_tag + 1'b1;
      if (stall_fifo_full  && c_full    != '1) c_full    <= c_full + 1'b1;
      if (stall_fifo_empty && c_empty   != '1) c_empty   <= c_empty + 1'b1;
      if (stall_localmem   && c_localmem!= '1) c_localmem<= c_localmem + 1'b1;
 
      if (!captured && (this_cause != 4'd0)) begin
        captured       <= 1'b1;
        first_cause    <= this_cause;
        cap_ltssm      <= ltssm;
        cap_tag        <= tag_snapshot;
        cap_job        <= job_snapshot;
        cap_fifo_level <= fifo_level;
        cap_cycle      <= cycle;
      end
    end
  end
 
endmodule

9. Same-Cycle Audit

10. Interfaces: What PCIe Requires and What It Does Not

PCIe requires nothing about your internal buses. It defines what crosses the link.

AXI is a vendor-ecosystem convention, extremely common in FPGA flows and entirely absent from the PCIe specification. Using it is a reasonable choice; describing it as a PCIe requirement is the §4 trap, and it misleads anyone reading the design who has not used that ecosystem.

§8 Block 10 therefore uses a generic ready/valid stream with data, keep and last. Those three concepts are what the ownership properties actually need:

  • valid/ready — the transfer contract (§20's P-series);
  • keep — the partial final beat, without which every transfer silently rounds up to a beat boundary;
  • last — the transfer boundary, so byte conservation can be checked per job.

If your design uses AXI, map to it in one place, exactly as Block 2 maps the vendor PCIe interface in one place. The reasoning above the mapping stays portable.

11. Clock Domains: Pick the Mechanism Per Path

The multi-bit case is the one that produces the chapter's signature bug, and it is worth stating precisely why it fails.

Two-flop synchronizing every bit of a bus makes each bit individually metastability-safe. That is true and it is not sufficient. The bits do not all resolve on the same clock edge: when the source word changes, some bits may propagate in cycle N and others in cycle N+1. The destination then samples a word that is a mixture of the old and new values — a value that never existed in the source domain.

A 64-bit descriptor address crossing this way can land anywhere. Every bit is safe; the address is garbage. This is counterexample A, and §22 case 4 is what it looks like in the lab.

Do not write your own async FIFO for the bulk path. Gray-coded pointers, correctly synchronized in both directions, and a power-of-two depth are all required and all easy to get subtly wrong. Use the vendor library or the repository's existing CDC primitive — Block 8 deliberately specifies the contract rather than reimplementing the mechanism.

12. Reset and the Epoch

An FPGA card has several resets that are not the same thing: the PCIe core's reset, the application clock's reset, a software-initiated function reset, and any local block reset. Assuming they are identical is a design bug, and the exact PCIe reset semantics are spec-defined and not restated here (§1).

What this chapter owns is the consequence for in-flight work. After a link event or an application reset, Completions for requests issued before the event may still arrive. They are not errors — they are the normal result of an asynchronous system being interrupted.

An epoch makes them distinguishable (§8 Block 9). Each Tag records the epoch it was allocated under; a Completion whose epoch does not match the Tag's current epoch is discarded and counted.

§18 measured this exactly. Across 133 reset events, the correct design saw 165 stale responses and misdelivered none. Removing the epoch comparison produced 62 misdeliveries carrying 70,976 bytes into the wrong job's buffer — measured by a ground-truth job serial that no modelled mechanism reads, which is 25.7 §13's oracle discipline.

The epoch is IMPLEMENTATION POLICY. Its width bounds how many resets can occur before a very old response could alias onto a matching epoch again, and P30 is the property that bounds it.

13. Local Memory Arbitration

The DMA engine and the accelerator both want local memory, and §17 measured that this path is very often the actual throughput limit.

Three requirements, all in §8 Block 11:

Ownership. A request carries the identity of its requester, and the response routes by that recorded identity — never by whoever the arbiter is serving when the response returns (counterexample E).

Fairness, stated explicitly. Round robin here; a weighted scheme is equally valid. What matters is that the policy is written down and asserted (P24), because an emergent policy under saturation starves someone.

And stall stability. A request that has been presented and not accepted must not change (P8's family).

This chapter does not build a memory controller. DDR timing, refresh, bank management and scheduling are a separate discipline; Block 11 shows the arbitration and ownership boundary only.

14. Observability

25.9 §13 measured that three of five fault classes produced a byte-identical link trace. Every one of those faults lives on the application side of this chapter's boundary.

So the instrumentation is not optional, and §8 Block 12 provides two kinds:

Per-stage counters, so §17's limiting stage can be identified in silicon rather than guessed. Count beats accepted, never valid cycles — mutation 33 is that error, and it overstates throughput exactly when the sink is stalling, which is precisely when you are looking.

And first-failure capture. One snapshot — LTSSM state, Tag, job id, FIFO level, cycle, and the first cause — taken at the first anomaly and held. In hardware debugging this is frequently worth more than hours of trace capture, because a trace tells you what happened around the moment you triggered, and the first failure usually happened long before.

15. Throughput Is the Minimum Stage

This is §17's measurement and the most practically useful result in the module.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
internal throughput = bytes_per_beat × accepted_beats_per_cycle × clock_frequency

Every stage in the chain has such a rate, and the end-to-end result is the minimum. A faster PCIe generation does not fix a narrow local interface, and §17 measured that the improvement from doubling the PCIe interface was exactly 0.0% when local DDR was the limit.

Latency and throughput are different questions. A control MMIO read's latency is a round trip; a streaming transfer's throughput is the minimum stage. Optimising BAR register reads to improve bulk throughput is optimising the wrong quantity (§6), and 22.2 owns the distinction.

16. Measured Behaviour — The Throughput Pipeline

stagebytes/beataccept rateMHzGB/s
PCIe application interface641.0025016.00
DMA engine640.9025014.40
CDC async FIFO641.0025016.00
local DDR path320.852005.44
accelerator input640.7530014.40

End-to-end limit: 5.44 GB/s, set by the local DDR path.

changenew stage rateend-to-endimprovement
double the PCIe interface (64→128 B/beat)32.00 GB/s5.44 GB/s0.0%
widen the local DDR path (32→64 B/beat)10.88 GB/s10.88 GB/s+100%

Three readings.

Doubling PCIe produced no improvement whatsoever, and produced no error message either. The design simply performs identically — the least informative possible outcome, and the reason §14's per-stage counters exist.

The limiting stage is 34% of the PCIe interface's rate. A design specified from the link's capability would be sized nearly three times too optimistically.

And after widening DDR, DDR is still the limit at 10.88 GB/s. The bottleneck moved within the same stage rather than to another one, which is a useful reminder that one fix does not necessarily relocate the constraint.

17. Measured Behaviour — The Ownership Boundaries

configurationdescribedmoveddeficitFIFO ovfunderflowstale eptail lossearly intmisdelivered
correct7,034,3047,034,304000165000
descriptor read live7,171,9047,028,032143,87200189000
stale-epoch accepted7,036,3527,036,3520001600062
FIFO full check removed7,027,9047,027,9040105,8010179000
FIFO empty check removed7,067,1367,067,136001,653157000
final partial beat dropped7,296,8967,033,280263,616001844,11900
interrupt before status7,034,3047,034,30400016505,1780

Baseline verified clean: deficit 0, overflow 0, underflow 0, tail loss 0, early interrupt 0, misdelivery 0 — asserted before any other row was computed. 133 reset events during the run produced 165 stale responses, all correctly discarded.

Four readings.

Removing the epoch check misdelivered 62 responses carrying 70,976 bytes into the wrong job's buffer. Every one is a Completion that was legitimately issued before a reset and legitimately arrived after it — the design's error is accepting it, not the link's error in delivering it.

A live descriptor read lost 143,872 bytes when the host mutated a descriptor it had already handed over.

The dropped partial beat cost 263,616 bytes across 4,119 transfers — every transfer whose length was not a beat multiple.

And one mutation produced a measured non-result. Freeing a Tag before job retirement generated 5,169 events and 0 misdeliveries in this topology, because the response had already arrived by the time the Tag was freed. It is a real hazard that this configuration does not expose — reported as measured rather than adjusted until it produced a number, which is the same discipline 25.8 §13 applied to its individually-safe dependencies.

18. Assertions

Adapter and interface properties — §3, §10.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// P1 — the adapter buffers nothing: ready passes straight through.
property p1_adapter_transparent;
  @(posedge clk) disable iff (!rst_n)
    (core_rx_ready == app_rx_ready) && (app_tx_ready == core_tx_ready);
endproperty
a_p1: assert property (p1_adapter_transparent);
 
// P2 — a transfer offered and not accepted stays stable and stays offered.
property p2_stream_stable;
  @(posedge clk) disable iff (!rst_n)
    (out_valid && !out_ready) |=> (out_valid && $stable(out_data) &&
                                   $stable(out_keep) && $stable(out_last));
endproperty
a_p2: assert property (p2_stream_stable);
 
// P3 — valid does not depend on ready.
property p3_valid_independent;
  @(posedge clk) disable iff (!rst_n)
    (out_valid && !out_ready) |=> out_valid;
endproperty
a_p3: assert property (p3_valid_independent);
 
// P4 — a non-final beat is full; only the last beat may be partial.
property p4_keep_only_last;
  @(posedge clk) disable iff (!rst_n)
    (out_valid && out_ready && !out_last) |-> (out_keep == '1);
endproperty
a_p4: assert property (p4_keep_only_last);
 
// P5 — negotiated width is reported, never capability (26.2 §4).
property p5_negotiated_reported;
  @(posedge clk) disable iff (!rst_n)
    app_link_up |-> (app_neg_width == core_neg_width);
endproperty
a_p5: assert property (p5_negotiated_reported);

BAR control-path properties — §8 Block 3.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// P6 — an out-of-range or misaligned offset is reported, never wrapped.
property p6_offset_checked;
  @(posedge clk) disable iff (!rst_n)
    (req_valid && (((req_offset >> 2) >= NREG) || (req_offset[1:0] != 2'b00)))
      |-> offset_error;
endproperty
a_p6: assert property (p6_offset_checked);
 
// P7 — a captured read response is held stable under backpressure and is
// NOT re-read from the live register file.
property p7_read_response_stable;
  @(posedge clk) disable iff (!rst_n)
    (rsp_valid && !rsp_ready) |=> (rsp_valid && $stable(rsp_rdata));
endproperty
a_p7: assert property (p7_read_response_stable);
 
// P8 — no new request is accepted while a response is pending.
property p8_one_outstanding;
  @(posedge clk) disable iff (!rst_n)
    rsp_pending |-> !req_ready;
endproperty
a_p8: assert property (p8_one_outstanding);

Tag and epoch properties — §12.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// P9 — a Tag is allocated only when free.
property p9_tag_free_on_alloc;
  @(posedge clk) disable iff (!rst_n)
    (alloc_req && alloc_gnt) |-> !busy[alloc_tag];
endproperty
a_p9: assert property (p9_tag_free_on_alloc);
 
// P10 — a Tag is freed on the LAST Completion, not the first.
property p10_free_on_last;
  @(posedge clk) disable iff (!rst_n)
    (cpl_accept && !cpl_last) |=> busy[$past(cpl_tag)];
endproperty
a_p10: assert property (p10_free_on_last);
 
// P11 — no Tag leaks: an allocated Tag is eventually freed.
property p11_no_tag_leak;
  @(posedge clk) disable iff (!rst_n)
    (alloc_req && alloc_gnt) |-> s_eventually !busy[$past(alloc_tag)];
endproperty
a_p11: assert property (p11_no_tag_leak);
 
// P12 — a Completion whose epoch does not match its Tag's epoch is rejected.
// §17 measured removing this: 62 misdeliveries, 70,976 bytes.
property p12_stale_epoch_rejected;
  @(posedge clk) disable iff (!rst_n)
    (cpl_valid && busy[cpl_tag] && (tag_epoch[cpl_tag] != cpl_epoch)) |-> !cpl_accept;
endproperty
a_p12: assert property (p12_stale_epoch_rejected);
 
// P13 — a Completion for a Tag that is not outstanding is rejected.
property p13_orphan_cpl_rejected;
  @(posedge clk) disable iff (!rst_n)
    (cpl_valid && !busy[cpl_tag]) |-> !cpl_accept;
endproperty
a_p13: assert property (p13_orphan_cpl_rejected);
 
// P14 — a stale Completion is COUNTED, never silently dropped.
property p14_stale_counted;
  @(posedge clk) disable iff (!rst_n)
    cpl_stale |=> (stale_count > $past(stale_count));
endproperty
a_p14: assert property (p14_stale_counted);
 
// P15 — the epoch advances on a reset or link-down event.
property p15_epoch_advances;
  @(posedge clk) disable iff (!rst_n)
    epoch_changed |=> (cur_epoch != $past(cur_epoch));
endproperty
a_p15: assert property (p15_epoch_advances);
 
// P16 — the epoch does not change otherwise.
property p16_epoch_stable;
  @(posedge clk) disable iff (!rst_n)
    !epoch_changed |=> $stable(cur_epoch);
endproperty
a_p16: assert property (p16_epoch_stable);

DMA conservation properties — 25.6 §4's laws, applied here.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// P17 — the descriptor is snapshotted; the job record never changes.
// §17 measured a live read losing 143,872 bytes.
property p17_descriptor_immutable;
  @(posedge clk) disable iff (!rst_n)
    (active && !job_done) |=> (active && $stable(job));
endproperty
a_p17: assert property (p17_descriptor_immutable);
 
// P18 — bytes issued equal bytes described, at job completion.
property p18_bytes_conserved;
  @(posedge clk) disable iff (!rst_n)
    job_done |-> (bytes_issued == bytes_described);
endproperty
a_p18: assert property (p18_bytes_conserved);
 
// P19 — the engine never issues MORE than described. Two-sided, per
// 25.6 §4 law 1: a one-sided check reports an overrun as success.
property p19_no_overrun;
  @(posedge clk) disable iff (!rst_n)
    (bytes_issued <= bytes_described);
endproperty
a_p19: assert property (p19_no_overrun);
 
// P20 — the final chunk may be partial and must not be dropped.
// §17 measured 4,119 lost tails and a 263,616-byte deficit.
property p20_final_chunk;
  @(posedge clk) disable iff (!rst_n)
    (active && (remaining > 0) && (remaining < MAX_CHUNK))
      |-> (req_out.len_bytes == 12'(remaining));
endproperty
a_p20: assert property (p20_final_chunk);
 
// P21 — a zero-byte job issues no request.
property p21_zero_bytes;
  @(posedge clk) disable iff (!rst_n)
    (desc_valid && desc_ready && (desc_in.bytes == 0)) |=> !req_valid;
endproperty
a_p21: assert property (p21_zero_bytes);
 
// P22 — nothing is issued without a Tag.
property p22_tag_before_issue;
  @(posedge clk) disable iff (!rst_n)
    req_valid |-> tag_gnt;
endproperty
a_p22: assert property (p22_tag_before_issue);

FIFO and CDC properties — §11.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// P23 — never write a full FIFO. §17 measured 105,801 overflows.
property p23_no_overflow;
  @(posedge clk) disable iff (!rst_n)
    wr_en |-> !wr_full;
endproperty
a_p23: assert property (p23_no_overflow);
 
// P24 — never read an empty FIFO. §17 measured 1,653 underflows.
property p24_no_underflow;
  @(posedge clk) disable iff (!rst_n)
    rd_en |-> !rd_empty;
endproperty
a_p24: assert property (p24_no_underflow);
 
// P25 — full and empty are never simultaneously true.
property p25_full_empty_exclusive;
  @(posedge clk) disable iff (!rst_n)
    !(wr_full && rd_empty && (DEPTH > 0));
endproperty
a_p25: assert property (p25_full_empty_exclusive);
 
// P26 — the handshake holds its data stable for the whole crossing.
// Counterexample A is what happens without this.
property p26_cdc_data_stable;
  @(posedge src_clk) disable iff (!src_rst_n)
    (src_req != src_ack) |=> $stable(hold);
endproperty
a_p26: assert property (p26_cdc_data_stable);
 
// P27 — the destination samples data only when dst_valid is asserted.
property p27_cdc_sample_qualified;
  @(posedge dst_clk) disable iff (!dst_rst_n)
    dst_valid |-> (dst_req != dst_ack_sync);
endproperty
a_p27: assert property (p27_cdc_sample_qualified);

Local memory and notification properties — §13.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// P28 — a memory response routes to the RECORDED owner.
property p28_response_to_owner;
  @(posedge clk) disable iff (!rst_n)
    rsp_valid |-> (rsp_route == (1 << rsp_id));
endproperty
a_p28: assert property (p28_response_to_owner);
 
// P29 — a response for a requester with nothing outstanding is reported.
property p29_misroute_reported;
  @(posedge clk) disable iff (!rst_n)
    (rsp_valid && !outstanding[rsp_id]) |-> rsp_misroute;
endproperty
a_p29: assert property (p29_misroute_reported);
 
// P30 — at most one grant per cycle, and only to a requester.
property p30_grant_onehot0;
  @(posedge clk) disable iff (!rst_n)
    $onehot0(req_grant) and ((req_grant & ~req_valid) == '0);
endproperty
a_p30: assert property (p30_grant_onehot0);
 
// P31 — the interrupt follows the status record, never precedes it.
// §17 measured 5,178 events raised ahead of their record.
property p31_int_after_status;
  @(posedge clk) disable iff (!rst_n)
    evt_valid |-> $past(status_accepted);
endproperty
a_p31: assert property (p31_int_after_status);

Cover — the anti-vacuity set.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// P32 — every DMA job reaches a terminal state: it completes, or it is
// invalidated by an epoch change. A job that reaches neither holds its Tag
// forever, and §21 case 6's leak is exactly this failure accumulating.
property p32_job_terminal;
  @(posedge clk) disable iff (!rst_n)
    (desc_valid && desc_ready) |-> s_eventually (job_done || epoch_changed);
endproperty
a_p32: assert property (p32_job_terminal);
 
// P32's covers — the rare states must occur. §17's model needed randomly injected
// resets to produce any stale Completions at all; without them P12, P14,
// P15 and P16 are all vacuously satisfied.
c1_epoch_change: cover property (@(posedge clk) disable iff (!rst_n) epoch_changed);
c2_stale_cpl:    cover property (@(posedge clk) disable iff (!rst_n) cpl_stale);
c3_fifo_full:    cover property (@(posedge clk) disable iff (!rst_n) wr_full);
c4_fifo_empty:   cover property (@(posedge clk) disable iff (!rst_n) rd_empty);
c5_partial_beat: cover property (@(posedge clk) disable iff (!rst_n)
                   out_valid && out_last && (out_keep != '1));
c6_tag_exhaust:  cover property (@(posedge clk) disable iff (!rst_n) free_count == 0);
c7_mem_conflict: cover property (@(posedge clk) disable iff (!rst_n) req_valid == '1);
c8_offset_err:   cover property (@(posedge clk) disable iff (!rst_n) offset_error);
c9_status_stall: cover property (@(posedge clk) disable iff (!rst_n)
                   status_valid && !status_ready);

19. Executable Counterexamples

Counterexample A — two-flop synchronizing a multi-bit command (violates P26).

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// Every bit of a 64-bit descriptor address crosses through its own
// two-flop synchronizer.
module ce_a_multibit_two_flop (
  input  logic dst_clk, dst_rst_n,
  input  logic [63:0] src_addr,
  output logic [63:0] dst_addr
);
  (* ASYNC_REG = "TRUE" *) logic [63:0] s1, s2;
  always_ff @(posedge dst_clk or negedge dst_rst_n)
    if (!dst_rst_n) begin s1 <= '0; s2 <= '0; end
    else begin s1 <= src_addr; s2 <= s1; end
  assign dst_addr = s2;              // <-- 64 independent crossings
endmodule
 
// Failing stimulus: src_addr changes from 64'h0000_0000_FFFF_FFFF to
// 64'h0000_0001_0000_0000 — 33 bits change at once.
// Golden: the destination sees one value or the other.
// This:   bits resolve on different edges, so the destination can sample
//         64'h0000_0001_FFFF_FFFF — a value that NEVER EXISTED in the
//         source domain.
// P26 fails.
// Observable consequence: a DMA write to an address neither the host nor
// the FPGA ever computed. Every bit is metastability-safe and the word is
// incoherent. §22 case 4 is what this looks like in hardware, and it is
// invisible in simulation without CDC-aware modelling.

Counterexample B — the stale pre-reset response (violates P12).

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// Completions are matched on Tag alone, with no epoch comparison.
module ce_b_no_epoch (
  input  logic       cpl_valid,
  input  logic [4:0] cpl_tag,
  input  logic [31:0] busy,
  output logic       cpl_accept
);
  assign cpl_accept = cpl_valid && busy[cpl_tag];    // <-- no epoch
endmodule
 
// Failing stimulus: job A issues a read on Tag 9; the link drops and
// recovers, reclaiming outstanding Tags; job B is issued on Tag 9; job A's
// Completion arrives.
// Golden: rejected as stale, counted, discarded.
// This:   accepted and credited to job B.
// P12 fails.
// §17 measured 62 misdeliveries carrying 70,976 bytes into wrong buffers
// across 133 reset events. The correct design saw 165 stale responses and
// misdelivered none — late responses are NORMAL, and discarding them is
// the design's job.

Counterexample C — the FIFO full check one cycle late (violates P23).

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// The full flag is registered, so the write enable is qualified by a
// value that is one cycle old.
module ce_c_late_full_check (
  input  logic clk, rst_n,
  input  logic [15:0] level, input logic DEPTH_i,
  input  logic wr_req,
  output logic wr_en
);
  logic full_r;
  always_ff @(posedge clk or negedge rst_n)
    if (!rst_n) full_r <= 1'b0;
    else        full_r <= (level >= 16'd32);      // <-- registered
  assign wr_en = wr_req && !full_r;
endmodule
 
// Failing stimulus: the FIFO reaches depth in cycle N with a write also
// requested in cycle N+1.
// Golden: wr_en is low in N+1.
// This:   full_r still reflects cycle N-1, so wr_en is high and a write
//         lands in a full FIFO.
// P23 fails.
// §17 measured 105,801 overflows. Observable consequence: silently
// corrupted payload, with byte conservation still balancing because the
// bytes were counted as written.

Counterexample D — the Tag freed on the first Completion (violates P10).

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// A read answered by several split Completions frees its Tag on the first.
module ce_d_free_on_first (
  input  logic clk, rst_n,
  input  logic cpl_accept, input logic [4:0] cpl_tag,
  output logic [31:0] busy
);
  always_ff @(posedge clk or negedge rst_n)
    if (!rst_n) busy <= '0;
    else if (cpl_accept) busy[cpl_tag] <= 1'b0;   // <-- ignores cpl_last
endmodule
 
// Failing stimulus: a 256-byte read split into four Completions at RCB.
// Golden: the Tag is freed on the fourth.
// This:   freed on the first. The Tag is reissued while three Completions
//         for the old request are still in flight, and they are credited
//         to the new one.
// P10 fails, and P12 cannot save it — the epoch is unchanged, so the stale
// Completions match. This is 25.7 §7's misdelivery window reproduced by a
// design error rather than by a timeout.

Counterexample E — the memory response routed by current selection (violates P28).

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// The local memory response is delivered to whichever requester the
// arbiter is serving at the moment the response returns.
module ce_e_route_by_current (
  input  logic       rsp_valid,
  input  logic [0:0] arbiter_sel_now,
  output logic [1:0] rsp_route
);
  always_comb begin
    rsp_route = '0;
    if (rsp_valid) rsp_route[arbiter_sel_now] = 1'b1;   // <-- not the owner
  end
endmodule
 
// Failing stimulus: the accelerator issues a read; the arbiter then grants
// the DMA engine; the accelerator's read data returns.
// Golden: routed to the accelerator, which recorded the request.
// This:   routed to the DMA engine.
// P28 fails, P29 fails.
// Observable consequence: the DMA engine writes the accelerator's data to
// host memory and the accelerator waits forever. This is 26.4 §16's
// identity fault in a new place, and a conservation check on outstanding
// counts does not see it.

20. Verification — Mutations

Thirty-eight mutations. Every "Caught by" entry names a property from §18.

#MutationSymptomCaught by
1User RTL assumed to own the LTSSMduplicated state machine fights the coreP5
2Vendor signal names used above the adaptera core change becomes a redesign (§4)P1
3AXI presented as a PCIe requirementdesign reasoning imports a bus spec (§10)P2
4Adapter buffers a transferits contents diverge from both sidesP1
5Capability width reported instead of negotiatedbandwidth budgets built on a wrong numberP5
6BAR offset wrapped instead of range-checkedhost bug becomes a silent writeP6
7Read response re-read from the live register filehost receives a value it never requestedP7
8Second request accepted while a response is pendingresponses interleave and mismatchP8
9Bulk data moved through the BAR pathhost stalled for the whole transfer (§6)P8
10Tag allocated while busytwo requests share a TagP9
11Tag freed on the first split Completionmisdelivery (counterexample D)P10
12Tag never freed on an error Completionthe pool drains; the engine stallsP11
13Epoch check removed62 misdeliveries, 70,976 bytes (§17)P12
14Completion accepted for a non-outstanding Tagorphan data written into a live bufferP13
15Stale Completion discarded silentlyindistinguishable from never arrivingP14
16Epoch not advanced on link-downpre-reset responses match post-reset TagsP15
17Epoch advanced on unrelated eventsvalid Completions rejected; transfers lostP16
18Epoch advanced combinationallya legitimate same-cycle Completion rejected (§9 audit C)P15
19Descriptor read live from host memory143,872-byte deficit (§17)P17
20Byte conservation checked one-sidedan overrun reported as successP19
21Chunk count by truncating division4,119 lost tails, 263,616 bytes (§17)P20
22Zero-byte job issues one requesta transfer for a job describing noneP21
23Request issued before a Tag is grantedTag field is stale or duplicatedP22
24Multi-bit command two-flop synchronizedincoherent word (counterexample A)P26
25Pulse crossed by a level synchronizerthe event is missed entirelyP26, P27
26CDC data not held stable across the crossingdestination samples a changing wordP26
27Destination samples data without qualifying on validreads a stale or partial wordP27
28FIFO full flag registered105,801 overflows (counterexample C, §17)P23
29FIFO read without checking empty1,653 underflows (§17)P24
30FIFO pointer width one bit too smallwrap aliases full with emptyP25
31FIFO depth not a power of twoGray-coded pointers become invalidP25
32Non-final beat has a partial keepbytes silently dropped mid-transferP4
33Counters increment on valid, not on accepted beatsthroughput overstated exactly when stalling (§14)P2
34Local memory response routed by current selectionwrong requester receives the data (counterexample E)P28
35Misroute detected but not reporteda silent data swapP29
36Arbiter grants a non-requesting porta port owns work it never asked forP30
37Interrupt raised with the status write5,178 early events (§17)P31
38Testbench never injects a reset or link-downP12, P14, P15, P16 all vacuous (§17)P32 (c1, c2)

Mutations 13 and 24 are the two that define this chapter. One is invisible without reset injection; the other is invisible without CDC-aware simulation. Both pass ordinary functional verification completely.

21. Debugging

22. Misconceptions

"An FPGA PCIe design implements PCIe." The core owns the PHY, LTSSM, Data Link Layer and much of the Transaction Layer (§2). You own everything above the application interface.

"The core's interface is the PCIe standard." It is one vendor's transaction-level interface (§4). Normalizing it in one adapter is what keeps the design portable and reviewable.

"AXI is required by PCIe." It is a vendor-ecosystem convention and appears nowhere in the PCIe specification (§10).

"The card is x8, so we get x8 bandwidth." Capability is not the negotiated width (26.2 §4, P5), and §16 measured the negotiated link being irrelevant when a later stage is slower.

"A faster PCIe generation will fix the throughput." §16 measured doubling the PCIe interface giving 0.0% because local DDR was the limit.

"The bottleneck is where the design is most complex." It is wherever bytes_per_beat × accept_rate × frequency is smallest (§15). In §16 that was a 32-byte DDR path at 200 MHz — the simplest stage in the chain.

"Two flip-flops make any signal safe to cross." They make each bit metastability-safe. A multi-bit bus becomes incoherent (§11, counterexample A) — a word that never existed in the source domain.

"A pulse can cross through a synchronizer." Only if it is at least as long as the destination clock period. Otherwise it is missed entirely (mutation 25).

"We'll write our own async FIFO." Gray pointers, dual-direction synchronization and power-of-two depth are all required and all easy to get subtly wrong (§8 Block 8). Use the vendor or repository primitive.

"It works in simulation, so the CDC is fine." Ordinary RTL simulation shows synchronizers as clean flops (§21 case 3). Counterexample A's failure cannot appear without CDC-aware modelling.

"All the resets are the same reset." The core reset, the application reset, a function-level reset and local resets are distinct (§12). Assuming they coincide is a design bug.

"Stale Completions after a reset mean something is broken." The correct design saw 165 of them across 133 resets and misdelivered none (§17). They are the normal result of interrupting an asynchronous system.

"Tag plus Requester ID is enough to match a Completion." Not across a reset boundary (§12). Without an epoch, a pre-reset Completion matches a post-reset Tag — §17 measured 62 misdeliveries and 70,976 bytes.

"Freeing the Tag when data arrives is fine." Only on the last Completion of a split series (P10, counterexample D). Freeing on the first reissues the Tag while three replies are still in flight.

"Byte conservation balanced, so the transfer was correct." Not if the FIFO overflowed — the bytes were counted as written and then destroyed (counterexample C). Conservation and overflow are different oracles, which is 26.4 §16's result again.

"The counters show we are at full rate." Check whether they count accepted beats or valid cycles (mutation 33). Counting valid overstates throughput exactly when the sink is stalling, which is when you are looking.

"An interrupt means the result is ready." Only if it was sequenced after the status write was accepted (P31). §17 measured 5,178 that were not.

"A memory response obviously goes back to whoever asked." Only if it is routed by a recorded owner id (P28, counterexample E). Routing by current arbiter selection silently swaps two requesters' data.

"An ILA trace will find it." 25.9 §13 measured three of five fault classes leaving a byte-identical link trace, and a trace begins where you triggered it. One first-failure snapshot is usually worth more (§14, §8 Block 12).

23. Understanding Check

Q1. A command bus crosses from the PCIe user clock to the accelerator clock through two flip-flops on every bit. Why can every individual bit be metastability-safe while the command word is still logically incoherent?

Because the bits do not all resolve on the same clock edge (§11, counterexample A). Each two-flop chain independently guarantees that its bit settles to a stable 0 or 1 before being used — that is what metastability protection means, and it is genuinely provided per bit. But when the source word changes, some bits may propagate in destination cycle N and others in cycle N+1, so the destination samples a bitwise mixture of the old and new values — a word that never existed in the source domain. A 64-bit address changing from 0x0000_0000_FFFF_FFFF to 0x0000_0001_0000_0000 can be sampled as 0x0000_0001_FFFF_FFFF. The correct mechanism is a handshake with the data held stable in the source domain while a single-bit request toggle crosses (§8 Block 7, P26).

Q2. Doubling your PCIe interface width produced no measurable improvement. What went wrong, and what should have been measured first?

Nothing went wrong — the PCIe interface was not the limit (§15, §16). End-to-end throughput is the minimum of bytes_per_beat × accept_rate × clock_frequency across every stage, and §16 measured local DDR at 5.44 GB/s against a PCIe interface at 16.00. Doubling the interface to 32.00 GB/s changed the end-to-end result by 0.0%; widening the DDR path instead gave +100%. The per-stage counters should have been read first (§8 Block 12) — and note that improving the wrong stage produces no error and no signal, just identical performance, which is the least informative outcome available.

Q3. After a link retrain your DMA writes land in the wrong buffer. The Completions are well-formed and carry valid Tags. Explain.

They were issued before the retrain and arrived after it, and the Tag has been reissued (§12, counterexample B). (Requester ID, Tag) is the entire identity of a Completion, so a pre-reset reply is indistinguishable from the current job's. An epoch — a generation number recorded per Tag at allocation and compared on every Completion — is what separates them. §17 measured the correct design seeing 165 stale responses across 133 resets and misdelivering none, against 62 misdeliveries carrying 70,976 bytes with the comparison removed. The stale responses are not the fault; accepting them is.

Q4. Your byte conservation check balances and the data is corrupt. What else must be checked?

FIFO overflow (counterexample C, P23). A write into a full FIFO is counted as written — the bytes appear on both sides of the conservation equation — and then destroyed. §17 measured 105,801 overflows with byte conservation reporting zero deficit. Conservation and overflow are independent oracles, which is 26.4 §16's result appearing again: an invariant that holds under a fault is not evidence the fault is absent, and two fault classes need two oracles.

Q5. Why must a Tag be freed on the last Completion of a split series rather than the first?

Because the remaining Completions are still in flight and still carry that Tag (P10, counterexample D). Freeing on the first reply makes the Tag available for reissue while three replies to the old request are outstanding; those replies then match the new request and are credited to it. The epoch does not save you here — no reset occurred, so the epoch is unchanged and the stale Completions compare equal. This is 25.7 §7's misdelivery window produced by a design error rather than by a timeout.

Q6. A design works in simulation and fails in hardware. Name the two fault classes most likely responsible and why simulation missed them.

CDC and reset behaviour (§21 case 3). Ordinary RTL simulation models a two-flop synchronizer as two clean flops, so counterexample A's incoherent word cannot occur — every bit propagates on the same edge in the simulator and on different edges in silicon. And a directed testbench typically injects no link-down or reset events, so the entire stale-Completion path is unreachable: §17's model required randomly injected resets to generate any stale responses at all, and mutation 38 makes P12, P14, P15 and P16 vacuous without them.

Q7. Your performance counters report full rate while throughput is visibly low. What is the likely instrumentation error?

Counting valid cycles instead of accepted beats (§14, mutation 33). valid asserts whenever data is offered; a transfer only occurs when valid && ready. A counter incremented on valid alone therefore overstates throughput exactly in proportion to how much the sink is stalling — which is precisely the condition you are investigating. The instrument reports its best numbers when the design is at its worst. P2's stable-under-stall contract is the related property, and §8 Block 12 counts accepted beats for this reason.

Q8. Local memory responses are being delivered to the wrong requester, and your outstanding-count check reports clean. Why?

Because the response is routed by current arbiter selection rather than by a recorded owner (counterexample E, P28). The count of outstanding transactions is unchanged — one request was issued and one response returned — so a conservation-style check on counts sees nothing. The information that was lost is the identity pairing, and detecting it requires carrying the requester id with the transaction and routing by that recorded value (P29 reports the mismatch). This is 26.4 §16's conservation-vs-identity result in a third setting.

Q9. Why does this chapter tell you to normalize the vendor PCIe interface in a single adapter module?

Because it separates three things that otherwise fuse together (§3, §4, §8 Block 2). It makes the design portable — a core or vendor change becomes an adapter rewrite rather than a redesign. It makes it testable — application logic can be verified against normalized types with no vendor IP instantiated. And it makes it reviewable — a reviewer who does not know that vendor's interface can still check ownership, backpressure and conservation, which is where the bugs actually are. The failure mode without it is documentation that cannot be checked against the PCIe specification (§4), so errors in it are never caught.

24. The Five Systems Compared

Not a feature matrix — a comparison of engineering ownership.

26.1 CPU26.2 GPU26.3 SSD26.4 NIC26.5 FPGA
PCIe roleRoot ComplexEndpointEndpointEndpointEndpoint
who initiates DMAdevices doGPU copy enginesthe controllerthe adapteryour engine
who decides when work happenssoftwaresoftware submitsthe controller fetchesthe networksoftware submits
control pathMMIO from coresBAR + doorbellsdoorbell registersBAR + doorbellsBAR register file
bulk data pathdevice DMAcopy enginescontroller DMARX/TX DMAyour DMA engine
local memoryhost DRAMGPU VRAM/HBMcontroller buffer + NANDpacket buffersBRAM + DDR/HBM
what software queuesnothing directlycommand buffersNVMe SQ entriesdescriptor ringsdescriptors
where buffering isfabric + memoryGPU memorycontroller bufferpacket buffersyour FIFOs
"completion" meansa translation responsea copy status recordan NVMe CQ entrya descriptor statusyour status record
likely non-PCIe limitmemory path, NUMAGPU local memorymedia, or the hosthost drain ratelocal DDR
what you ownnothing — you are the hostnothingnothingnothingall of it

Read the last two rows together. In every system the likely bottleneck is not PCIe, and in exactly one of them you are responsible for finding it yourself.

And read the "who decides when work happens" row. It orders the chapters by how much control the device has: software submits and the device consumes (GPU, FPGA); the device fetches its own work (SSD); or the work simply arrives (NIC). That ordering predicts the failure modes — the last case is the only one where being slow costs you data rather than time.

25. The Reusable System Pattern

Most PCIe endpoints instantiate the same skeleton, and recognising it is what makes an unfamiliar device tractable:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
software writes control / builds work in memory
   → doorbell or configuration write               (a small BAR access)
      → device takes OWNERSHIP of the work         (by snapshot, not reference)
         → device generates PCIe requests          (Tags, credits, chunking)
            → data is placed                       (host memory or device memory)
               → local execution                   (media, compute, network)
                  → durable STATUS record          (a Posted write)
                     → NOTIFICATION                (MSI/MSI-X, strictly after)

This is DERIVED, not specified. No standard requires it, and real devices vary substantially: an SSD's queues are standard-defined while a GPU's are vendor-defined; a NIC's work arrives rather than being submitted; an FPGA card's structure is whatever you built.

But the ownership obligations at each arrow are the same everywhere, and every one was measured in this module:

arrowobligationmeasured
doorbell → ownershipthe work must be visible before it is announced26.2: 126 empty fetches
ownershipsnapshot, never reference26.5: 143,872-byte deficit
requestsreserve before issue; identity on every reply26.1: 11,857 misroutes
data placementconserve bytes, two-sided26.5: 263,616-byte deficit
statuspublish durably before notifying26.5: 5,178 early events
notificationcarries no information; the record does26.3: 99,604 early interrupts

A device that violates any one of these will fail in a way that looks like a PCIe problem and is not.

26. Module 26 Complete

Five chapters, five systems, one boundary seen from both sides.

ChapterAsks
26.1 CPUswho owns this address, and what translated it?
26.2 GPUsis this the control path or the data path?
26.3 SSD Controllerswhich layer's "completion" are we discussing?
26.4 Network Adapterswhy did a PCIe limit become a packet drop?
26.5 (this)which stage is the limit, and which boundary is mine?

Three results run through all five, and each was measured rather than asserted.

The bottleneck is almost never PCIe. §16 measured doubling the link giving 0.0%; 26.2 §12 measured doubling GPU-local bandwidth giving nothing when PCIe was the limit and everything when it was not; 26.3 §16 measured a controller stalled on the host's completion consumption with the media idle. In every system, "PCIe is slow" was a hypothesis that per-stage instrumentation refuted.

Every boundary is an ownership transfer that needs an identity. An address to a window, a request to a translation context, a descriptor to an engine, a completion to a queue, a response to a Tag. 26.1 §14 measured 11,857 misroutes, 26.4 §16 measured 3,284, and §17 here measured 62 — three different systems, one missing identity check.

And one oracle is never enough. 26.4 §16 measured two ownership faults where each oracle was structurally blind to the fault the other caught; §23 Q4 is the same result for conservation versus overflow. This is the fifth time across Modules 25 and 26 that the fix has been an oracle sharing no machinery with the mechanism under test — and it is the most transferable thing in either module.

Module 26 has one chapter left to be written — 26.6, AI Accelerators — which takes up accelerator-oriented PCIe architecture, host-to-accelerator movement patterns, scale-out topologies, and the PCIe/CXL relationship at accelerator-system level. This chapter deliberately left all of that alone (§1), and 26.2 did the same.