Quickscope — A quick, but not dirty Integrated Logic Analyzer
Posted 2025-11-10 by Frans Skarman
How long would it take you to write an integrated logic analyzer? With Spade, I was able to do it in around 3 hours and less than 100 lines of code! In this post I will describe how it works.
Since the code is relatively short, we can put all of it here. Don't worry, we'll walk through the whole thing together one piece at a time, but this gives a brief overview
use ready_valid::Rv;
entity quickscope<T, #uint NumBytes, #uint SampleBuffer>(
clk: clock,
rst: bool,
trigger: bool,
data: T,
) -> Rv<uint<8>> {
let empty = port;
let full = port;
reg(clk) triggered reset (rst: false) = {
match (triggered, *empty#0, *full#0) {
// We only trigger if the fifo is empty
(false, true, false) => trigger,
// If the fifo is full, we un-trigger, i.e. we stop feeding more samples
// into the FIFO
(true, _, true) => false,
// In any other case, we keep the trigger state
(_, _, _) => triggered
}
};
let triggered_now = inst std::io::rising_edge(clk, trigger);
let data_in = if triggered { Some(data) } else { None };
Rv(&data_in, full#1)
.inst fifo_buffer::<SampleBuffer>(clk, rst)
.read_empty(empty#1)
.data
.inst map(fn (sample) {
unsafe{ std::conv::transmute::<_, [uint<8>; NumBytes]>(sample) }
})
.inst into_element_stream(clk, rst)
.inst map(fn (byte) { ready_valid::escape_byte::Escaped::Yes(byte) })
// Emit end of packet header
.inst append_lower_priority(
inst emit_once(clk, rst, if triggered_now {Some([0xff, 0x01])} else {None})
.inst into_element_stream(clk, rst)
.inst map(fn (byte) {ready_valid::escape_byte::Escaped::No(byte)})
)
// Emit start of packet header
.inst append_higher_priority(
inst emit_once(clk, rst, if triggered_now {Some([0xff, 0x00, NumBytes])} else {None})
.inst into_element_stream(clk, rst)
.inst map(fn (byte) {ready_valid::escape_byte::Escaped::No(byte)})
)
.inst escape_bytes$(
clk,
rst,
escapees: [0xff, 0xfe],
escape_fn: fn (byte) {byte ^ 0x80},
escape_prefix: 0xfe,
)
}
impl<T, #uint N> Rv<[T; N]> {
entity into_element_stream(self, clk: clock, rst: bool) -> Rv<T>{
let Rv(data, ready) = self;
let ds_ready = port;
reg(clk) (array, num_left): ([T; N], uint<{uint_bits_to_fit(N)}>) reset(rst: (std::undef::undef(), 0)) =
match (*data, num_left, *ds_ready#0) {
(Some(data), 0, _) => (data, N),
(_, _, false) => (array, num_left),
(None, 0, _) => (array, num_left),
(Some(data), 1, _) => (data, N),
(_, _, _) => (array[1..N].concat([std::undef::undef()]), trunc(num_left-1))
};
set ready = &(num_left == 0 || num_left == 1 && *ds_ready#0);
Rv(
&match num_left {
0 => None,
_ => Some(array[0]),
},
ds_ready#1
)
}
}
entity emit_once<T>(clk: clock, rst: bool, value: Option<T>) -> Rv<T> {
let ds_ready = port;
reg(clk) to_emit reset(rst: None) = match (to_emit, *ds_ready#0, value) {
(None, _, Some(new)) => Some(new),
(None, _, None) => None,
(Some(value), true, None) => None,
(Some(value), true, Some(new)) => Some(new),
(Some(value), false, _) => None
};
Rv(&to_emit, ds_ready#1)
}
The source code for the whole project including the client is available at https://gitlab.com/TheZoq2/quickscope/.
External Interface and Use🔗
The first line: use ready_valid::Rv is perhaps the most important line in the entire thing. It imports the Rv type from a library called ready_valid. As the name implies, this provides a type for ready/valid interfaces with a ton of functions for working with it. As we will see, this is what does most of the heavy lifting here.
Next, we define the interface to the logic analyzer:
entity quickscope<T, #uint NumBytes, #uint SampleBuffer>(
clk: clock,
rst: bool,
trigger: bool,
data: T,
) -> Rv<uint<8>> {
First, are a few generic parameters:
T: The type of the data samples we want to analyze. Since it is generic, we can give any Spade type we want and it will work.#uint NumBytes: The number of bytes required to store the samples. (This is required because Spade currently does not have something likesizeof, but the compiler ensures that the number is correct)#uint SampleBuffer: The number of samples we want to fit in the sample FIFO
Next are the signals going into the analyzer:
clkandrstshould be self-explanatorytrigger: When this is true, the analyzer will trigger and send a batch of consecutive samplesdata: The data we want to analyze
Finally, is the output of the entity is Rv<uint<8>>: An 8-bit integer wrapped in a
ready/valid interface. This means that the raw bytes that this outputs can be
handled by anything that can consume ready/valid bytes, for example a UART
device, a UDP streamer, or whatever you can dream up.
In use, this means that we can use quickscope by simply instantiating it with the data of interest, for example:
inst quickscope::quickscope::<_, 11, {65536 / 8}>(
clk,
rst,
// Trigger signal
state.is_read(),
// The data we want to analyze, here we're passing quite a large structure
(
state,
DramPins$(
dq_out: (*pins.dq_out).map(fn (val) {trunc(val)}),
dq_in: (*dq_read).map(fn (val) {trunc(val)}),
a: *pins.a,
ba: *pins.ba,
n_we: *pins.n_we,
n_ras: *pins.n_ras,
n_cas: *pins.n_cas,
),
value_expected_pairs.map(fn ((v, e)) {(trunc::<_, uint<10>>(v[0]), trunc::<_, uint<10>>(e[0]))}),
is_ok,
is_expected,
)
)
// In principle, we can transmit the bytes with anything, but the client currently only works
// with uart so let's use that
.inst into_uart(
clk,
rst,
protocols::uart::UartConfig$(bit_time: 234, parity: false, stop_bits: 2),
uart_txp,
);
With this unit instantiated and uploaded to our FPGA, we can run quickscope /dev/ttyUSB0 115200 which will wait for a trigger, then write a .vcd file with the resulting samples. Finally, we can open the .vcd in surfer which, since it has Spade integration, will show the traced signals as a hierarchical structure. Here is a simple example of how that looks:

Overall Architecture🔗
The architecture of the scope is relatively straight forward: the primary piece is a FIFO where samples are stored before being exfiltrated via whatever byte consumer is used. When the scope triggers, this FIFO starts filling up until it reaches its maximum capacity. Since the samples are not single bytes, some logic is required to convert the samples into bytes and stream them out, all of which is orchestrated by ready-valid interfacing.
There is one more thing that must be dealt with: we want to make sure that the client can see the start and end of triggers, and ensure that the data is contiguous, i.e. one batch of data comes from only a single sample. This is done by only allowing triggering when the FIFO is completely empty, and inserting headers for the start and end of data.
The rest of the blog post will go through how this architecture is implemented, but you may want to go back to the big source code listing to see how much of it you can infer without any additional context.
Implementation🔗
The first part of the implementation deals with triggering:
let empty = port;
let full = port;
reg(clk) triggered reset (rst: false) = {
match (triggered, *empty#0, *full#0) {
// We only trigger if the fifo is empty
(false, true, false) => trigger,
// If the fifo is full, we un-trigger, i.e. we stop feeding more samples
// into the FIFO
(true, _, true) => false,
// In any other case, we keep the trigger state
(_, _, _) => triggered
}
};
The first two lines define empty and full which we will connect to the sample FIFO later.
Next is the trigger logic which looks at the trigger
signal and the state of the FIFOs in order to start pushing samples into the
FIFO. Once the analyzer is triggered, we leave it triggered until the FIFO is
full at which point we have to stop, otherwise we will get gaps in our
samples. We're not quite done yet however, we prevent triggering again until
the FIFO is empty again, in order to ensure that each batch of samples is contiguous and separated with a header/footer.
For now, let's skip the triggered_now signal.
let triggered_now = inst std::io::rising_edge(clk, trigger);
Next, we use the triggered signal in order to define a data_in signal as
Some(data) when triggered, and None when not. Some and None are the
Spade way to write data with valid signals.
let data_in = if triggered { Some(data) } else { None };
Next, this data is wrapped in an Rv, a ready/valid interface where we connect the ready signal to the full signal we defined earlier. With this, we can push samples into a FIFO
Rv(data_in, full#1)
.inst fifo_buffer::<SampleBuffer>(clk, rst)
On the read side of the FIFO, there are quite a few method instantiations:
.read_empty(empty#1)
.data
.inst map(fn (sample) {
unsafe{ std::conv::transmute::<_, [uint<8>; NumBytes]>(sample) }
})
.inst into_element_stream(clk, rst)
First, we connect the empty signal we defined earlier to the empty signal of the FIFO. Next, we take the samples, which can have any type, and transform them into an array of bytes. Since arbitrary transformations of data is something one should be careful about, Spade requires this operation to be done inside an unsafe block, indicating that extra care is required.
The final method in this block is the .into_element_stream method. It transforms a ready/valid-interface of an array with N elements Rv<[T; N] into one of single array elements, T. Here, T is uint<8>, i.e. a byte.
While we could simply send these bytes straight off to the host, the hosts job becomes easier if it receives start and end headers, which is what the next piece of code does:
.inst map(fn (byte) { ready_valid::escape_byte::Escaped::Yes(byte) })
// Emit end of packet header
.inst append_lower_priority(
inst emit_once(clk, rst, if triggered_now {Some([0xff, 0x01])} else {None})
.inst into_element_stream(clk, rst)
.inst map(fn (byte) {ready_valid::escape_byte::Escaped::No(byte)})
)
// Emit start of packet header
.inst append_higher_priority(
inst emit_once(clk, rst, if triggered_now {Some([0xff, 0x00, NumBytes])} else {None})
.inst into_element_stream(clk, rst)
.inst map(fn (byte) {ready_valid::escape_byte::Escaped::No(byte)})
)
.inst escape_bytes$(
clk,
rst,
escapees: [0xff, 0xfe],
escape_fn: fn (byte) {byte ^ 0x80},
escape_prefix: 0xfe,
)
To do this unambiguously we want to have a few values we can use for these control signals. The escape_bytes method at the end of the chain allows us to do this. We pass it a list of escapees which we do not want to see in the data stream. Next, we give it an escape_fn which is used to transform any of the escapee bytes when encountered, here we flip the most significant bit. Of course, now we have duplicate bytes, both 0xff and 0x7f will result in 0x7f, so we insert an escape_prefix before any escaped byte.
Of course, we do not want to escape any headers, only data, so the first method in the chain marks the raw data as bytes to escape.
This leaves two method calls, apped_lower_priority and
append_higher_priority, which as the names imply, put data into the stream
with different priorities. The emit_once instances create Rv streams with
some headers if the scope was triggered now, and since these are headers they
are wrapped in Escaped::No.
The effect of all this logic is that once triggered, all three streams will
have data. The highest priority stream containing the headers will emit its
bytes first, then the main data stream which has higher priority will emit its
data, and only once that is done will the lowest priority "footer" be emitted.
Wait, is this HLS?🔗
A natural question that often arises when talking about Spade is "Wait, is this high level synthesis?", there sure are a lot of high level things going on here. The answer is no. While things like method chains on ready/valid interfaces allow a pretty high level description of the architecture, behind each method is some RTL level description that you have full control over. An example of this is the .into_element_stream method which we used earlier. It transforms a ready/valid-interface of an array with N elements Rv<[T; N] into one of single array elements, T. Most methods used in the project are implemented in the ready_valid library, but this one is defined in the quickscope project itself, in the
impl<T, #uint N> Rv<[T; N]> {
entity into_element_stream(self, clk: clock, rst: bool) -> Rv<T>{
block. The details here are not super interesting to talk about, it is essentially an FSM that pulls one value from the upstream stream, then emits each element in turn, before pulling another element
Rv ecosystem🔗
This project clearly leans heavily on that Rv library which you can find at https://gitlab.com/spade-lang/lib/ready_valid. For this project, I ended up adding a few more features to that library, as well as moving a few utilities I wrote elsewhere into the "mainline" project. Being able to have a set of reusable components like this is a big enabler for projects like this, and requires support both from the build tool for dependency management, and from the language which must be able to write generic and re-usable libraries.
The client🔗
The client is less interesting to discuss here, and even took longer to write than the hardware implementation. If you are curious, the source code is available at https://gitlab.com/TheZoq2/quickscope/-/tree/main/client?ref_type=heads