Code generation
The same BramAccess class that ran in SimPy is the source three artifacts are generated from: the
Vitis HLS kernel, the memory placed beside it, and the wrapper that joins the two. This page
walks all three, and the one thing that has to be reconciled between them.
cd examples/bram_access
python bram_access_build.py --through csynth
The messages’ headers come first
Before anything else, the build emits one C++ header per message schema, from the same Python declarations the model reads through:
from examples.bram_access.bram_access import SCHEMA_CLASSES
for cls in SCHEMA_CLASSES:
print(f"{cls.__name__:19s} -> include/{cls.include_filename}")
BramStatusEnumField -> include/bram_status.h
BramOpEnumField -> include/bram_op.h
WriteComputeCmd -> include/bram_write_compute_cmd.h
WriteResp -> include/bram_write_resp.h
ReadCmd -> include/bram_read_cmd.h
ReadResp -> include/bram_read_resp.h
Each carries the struct, nwords<W>(), and the read_stream<W> / write_stream<W> pair the task
bodies use — so the field order and the widths have one author, and the kernel cannot disagree
with the model about them. BramStatusField is listed in its own right so the status arrives as a
real enum class rather than an integer literal:
enum class BramStatus {
OK = 0,
OUT_OF_RANGE = 1,
};
src/ is what a human wrote and include/ is what the build produced; no message layout appears in
both. What is hand-written is the range check (src/bram_cmd_range.h) — that is the design’s logic
rather than its message layout, and layout is the thing that must have exactly one author.
The kernel
render_top walks the elaborated graph and emits a free-running ap_ctrl_none top. Every port,
every pragma and the one internal channel come from the graph; the task bodies do not — they are
hand-written headers this file includes.
void bram_access(
hls::stream<ap_uint<64> >& cmd_w,
hls::stream<ap_uint<64> >& data_w,
ap_uint<64> buf_w[1024],
hls::stream<ap_uint<64> >& resp_w,
hls::stream<ap_uint<64> >& cmd_r,
ap_uint<64> buf_r[1024],
hls::stream<ap_uint<64> >& data_r,
hls::stream<ap_uint<64> >& resp_r
) {
#pragma HLS INTERFACE axis port=cmd_w
#pragma HLS INTERFACE axis port=data_w
#pragma HLS INTERFACE mode=bram port=buf_w storage_type=ram_1p latency=1
#pragma HLS INTERFACE axis port=resp_w
#pragma HLS INTERFACE axis port=cmd_r
#pragma HLS INTERFACE mode=bram port=buf_r storage_type=ram_1wnr latency=1
#pragma HLS INTERFACE axis port=data_r
#pragma HLS INTERFACE axis port=resp_r
#pragma HLS INTERFACE ap_ctrl_none port=return
hls_thread_local hls::stream<ap_uint<64> > go;
#pragma HLS STREAM variable=go depth=1
hls_thread_local hls::task t0(bram_write_compute_task<64, 1024>, buf_w, cmd_w, data_w, resp_w, go);
hls_thread_local hls::task t1(bram_read_cmd_task<64, 1024>, buf_r, go, cmd_r, data_r, resp_r);
}
Four things to notice:
ap_uint<64> buf_w[1024]is a sized array, never a pointer.mode=bramon an unsized pointer silently degrades to anap_vldscalar port: no warning, a cleancsynth, and a design elaborated against a memory that is not there. The size comes fromBramIFMaster.nelem, and the element type fromBramIFMaster.element_type—ap_uint<64>isWord64spelled in C++.latency=1is the memory’s own number. It is emitted frombram_t2p.v’slocalparam READ_LATENCY = 1, reached through the boundBramIF— so the pragma and the Verilog cannot be authored independently and therefore cannot desynchronize. Nothing in any Python file states it.storage_typeis derived, and the two ports differ.buf_ris read-only and getsram_1wnr;buf_wis declaredaccess="readwrite"— theCOMPUTEopcode reads the words it rewrites — and getsram_1p. The wrapper wires one physical memory port per declaredbramport, andram_1wnrwould let Vitis take a second one it never wired. The visible price is that the in-place loop schedules at II=2: one port, a read and a write per element. That is the mechanism, not a limitation — seeplans/typed_transfer_codec.mdS5b for the measurement.ram_1pchanges the port list, and the wrapper follows. Aram_1wnrport emits all fourteen signals (an A/B pair of seven);ram_1pemits only the A half, sobram_access_top.vinstantiatesbuf_wwith seven pins andbuf_rwith fourteen. Naming a pin Vitis did not emit is an xvlog error, which is why the wrapper asks the port’s ownstorage_typerather than assuming a pair.gois anhls::streaminside the kernel, at depth 1, and does not appear in the signature. That is theadd_ifregistry doing its job.- The task bodies are includes. They stay hand-written because they own a
bramarray parameter, which the extractor has no vocabulary for. Their Python twins are the pysim golden, never the source of the C++.
The port list and the pragmas come from the same TopSpec the wrapper is derived from:
from waveflow.build.composite_gen import composite_top_spec
from waveflow.build.elaborate import elaborate
from examples.bram_access.bram_access import BramAccess
spec = composite_top_spec(elaborate(BramAccess, {"bitwidth": 64, "depth": 1024},
name="bram_access"), width=64)
for p in spec.ports:
if p.kind == "bram":
print(p.decl, "|", p.pragmas[0])
ap_uint<64> buf_w[1024] | #pragma HLS INTERFACE mode=bram port=buf_w storage_type=ram_1p latency=1
ap_uint<64> buf_r[1024] | #pragma HLS INTERFACE mode=bram port=buf_r storage_type=ram_1wnr latency=1
The memory
bram_t2p.v is copied verbatim from waveflow/build/rtl/ into the example’s xsi/ directory. It is
not generated and it is never rewritten — DW and AW ride on the instantiation, because a
Verilog parameter is not a code generator.
always @(posedge clk) begin
if (a_en) begin
if (|a_we) mem[a_addr[AW-1:0]] <= a_din;
a_dout <= mem[a_addr[AW-1:0]];
end
if (b_en) begin
if (|b_we) mem[b_addr[AW-1:0]] <= b_din;
b_dout <= mem[b_addr[AW-1:0]];
end
Port A is the write side and port B the read side, which is what the kernel’s two unidirectional
bram interfaces expect — and it is the assignment the file’s own $error assertion is written
against.
The wrapper, and the convention it reconciles
render_wrapper emits a module that instantiates the kernel, instantiates each memory the graph
declares, and joins them. From outside it looks like a kernel with only its AXI-Stream ports.
bram_t2p #(.DW(64), .AW(10)) mem (
.clk(ap_clk),
.a_addr(buf_w_addr_a >> 3),
.a_din(buf_w_din_a),
.a_dout(buf_w_dout_a),
.a_en(buf_w_en_a),
.a_we(|buf_w_we_a),
.b_addr(buf_r_addr_a >> 3),
.b_din(buf_r_din_a),
.b_dout(buf_r_dout_a),
.b_en(buf_r_en_a),
.b_we(|buf_r_we_a)
);
Two of those connections are not pass-throughs, and both were bugs before they were features:
>> 3— the address. Vitis addresses amode=bramport in bytes;bram_t2pindexes words. Joining them straight through scales every address by the element’s byte width, and everything pastdepth / (W/8)aliases onto a live word — silently.|we— the write enable. Vitis drives a byte-lane mask, one bit per byte of the word. The memory takes a single enable.
The shift is log2(width / 8), and the emitter refuses a width whose scaling is not a shift
rather than guessing:
from waveflow.build.hwcodegen import LoweringError
from waveflow.build.wrapper_gen import _bram_addr_shift
print([(w, _bram_addr_shift(w)) for w in (8, 16, 32, 64)])
try:
_bram_addr_shift(24)
except LoweringError:
print("24 bits: refused")
[(8, 0), (16, 1), (32, 2), (64, 3)]
24 bits: refused
The full convention — including why a design that never wraps will not notice if any of this is wrong — is in the interface guide.
The wrapper also has to deal with a B half it does not use. Vitis emits a full A/B pair per
bram interface whether the kernel uses both or not, so its unused outputs are left open and its
Dout — which is a kernel input — must be driven, or the elaboration carries an undriven X
into the design:
assign buf_w_dout_b = 64'd0;
assign buf_r_dout_b = 64'd0;
What a simulator elaborates is not the kernel
The wrapper is the top. csynth’s project, its report and its generated Verilog keep the kernel’s
name; the .f, the snapshot and the shared library are named for the wrapper. One artifact keeps
the name it has, and the new one is visibly the outer layer.
from waveflow.build.composite_gen import composite_top_spec, render_ports_h
from waveflow.build.elaborate import elaborate
from examples.bram_access.bram_access import BramAccess
spec = composite_top_spec(elaborate(BramAccess, {"bitwidth": 64, "depth": 1024},
name="bram_access"), width=64)
print(spec.top_name, "->", spec.elab_top)
h = render_ports_h(spec)
print("bram ports on the elaborated design:", "buf_w" in h or "buf_r" in h)
bram_access -> bram_access_top
bram ports on the elaborated design: False
A bram port is not a pin on the elaborated design — a testbench binding to it would be driving
a wire that does not exist on the module it loaded. That is why the RTL harness sees only AXI-Stream,
and why the BFM library needs no memory model.
What csynth does not count
The synthesis report is for the kernel, and the memory is outside it:
import xml.etree.ElementTree as ET
from pathlib import Path
rep = Path("examples/bram_access/bram_access_proj/solution1/syn/report/csynth.xml")
res = ET.parse(rep).find(".//AreaEstimates/Resources")
print("kernel BRAM_18K:", res.find("BRAM_18K").text)
from waveflow.hw.bram import ramb18_count
print("memory beside it:", ramb18_count(1024, 64), "RAMB18")
kernel BRAM_18K: 0
memory beside it: 4 RAMB18
Zero, and it is not zero. A resource estimate taken from csynth alone would miss the entire
memory. That is why T2pBram declares its footprint by geometry rather than waiting for a run —
depth × width maps to a primitive count by construction, and the alternative here is not a less
precise number, it is no number at all.
The file list
The last thing csynth does is re-emit rtl_bram_access_top.f from the RTL that is actually on
disk: every file csynth generated, then the memory, then the wrapper, in elaboration reading order.
../bram_access_proj/solution1/syn/verilog/bram_access.v
...
bram_t2p.v
bram_access_top.v
Re-emitted rather than committed-and-trusted, because a stale file list plus a cached xsimk.dll is
exactly how an XSI run goes green while proving nothing — a renamed module leaves the .f naming a
file that no longer exists, and xvlog does not mind.
See also
- RTL simulation — running what this page generated.
- A module realized as Verilog —
rtl_module(), the port-name chain, and the latency single-source rule. - Free-running composite — the generated top’s shape, and the wrapper’s place in it.