Back

CDC FIFO Buffer

A Clock Domain Crossing First in First Out Buffer. But what does that mean?

20 May 2026 VerilogSimulationAsynchronousCDCFIFOFPGACocotbVerilatorHypothesis

Introduction

The depiction that is a chaotic asynchronous world is, unfortunately, an accurate resemblance of reality. I say unfortunately only because of the engineering complexity that is created from asynchrony, and in honesty I cannot refer to this challenge as a lamentable factor. A common tool used to allow for asynchronous compatibility in hardware is called a CDC FIFO Buffer. For most people this is far to many acronyms. So, before we begin, we must understand what that means.

Demystifying the Title

Rather peculiarly (in my opinion) to understand the role that each term plays this title should really be “Buffer FIFO CDC”. This I am confident, breaks English grammatical rule. However that has never stopped me before and it shall not prevail here. A buffer is as defined in English as: Something that forms a physical barrier or space around something or between two things, to protect them (sourced from the cambridge dictionary). What does a buffer in this context look like you ask? Word 2, acronym 1: FIFO. FIFO is an organization policy that says whatever is First to go In will be the First to go Out. A simple way to visualize this is a line at a grocery store. Whoever is first in line will be the first to be checked out and leave. We now have a buffer and we know how it works, but what is it protecting from? Clock Domain Crossing occurs when information in one clock domain travels to information in a different clock domain. As being that live with constant clock domain crossing this issue may seem trivial. However as with most things in life, there is some complexity masked beneath the comforts of habit.

The Problems that Effulge

Imagine for a moment that you and a complete stranger are attempting to duplicate a text. You can only communicate by writting one word on a chalkboard. For one reason or another the chalk board can only hold a single word and anytime you or the other person attempt to write a second word the first vanishes. Now lets say that you are reading the chalk board and writing it down and the other person is reading the text and writing it on the chalk board.

If you are faster than your counterpart you must write a word and then wait for the next word. If you chose not to wait you would end up with duplicate words. If you are slower than your counterpart they must recognize that you have not finished writing the word before replacing it. If they chose not to, the duplicate text would be missing words. Either scenario corrupts the data going from the stranger to you. This is the first issue which translates to a full or empty buffer.

Now imagine that as you are attempting to write what you see verbatim. The word your counterpart puts up is something long such as “Grandiloquent”. However just as you finished writing the first part of the word and look up to get the second half your counterpart changes it to “Prognosticator”. You are only looking at the second half so that you do not recognize an error has occurred. You write “The Grandosticator” rather than the original “The Grandiloquent Prognosticator”. More than just an opportunity to use large words this shows a very real issue of data tearing. The resulting value is neither the first, nor second value, but rather an amalgamation of the two.

Project Goals

  1. Complete a CDC FIFO Buffer
  2. Create an Effective Test Bench
  3. Successfully Simulate
  4. Test My Verilog Ability

A Starting Point

I have neither made a CDC FIFO Buffer, nor really used Verilog before. I thought that this would be an excellent time to learn both. But first I need to understand what is required in its design. When researching this topic there were two sources that I relied on. The first was a Wikipedia article entitled FIFO (electronics). While I am aware of Wikipedia’s reputation among academics, I found this source to be an exceptional jumping point. As I needed a little more guidance I turned to a paper titled Simulation and Synthesis Techniques for Asynchronous FIFO Design with Asynchronous Pointer Comparisons by Clifford E. Cummings and Peter Alfke. One other source that I particularly liked and stumbled across by complete accident was A short note on useful codes for Fluidic Control Circuits by E.A. Powell.

With my reading material in hand and in mind I could start the design process. I would begin by splitting the problem into smaller bite-size components.

Component NameFile NameDescription
DRAMmemory.vDual Port Random Access Memory allows for a individual reader and writer to act in their own time domains.
Gray Encodergray_encoder.vEncodes memory addresses into a form that prevents data tearing.
Gray Decodergray_decode.vDecodes gray encoded data into usable memory address.
FIFO Bufferfifo_buffer.vThis is the top level module that glues everything together.
Test Benchtb.pyNot really a component but it felt important enough to include with the others. This will be how we know if our design works.

All You Need is DRAM DRAM

Dual Port Random Access Memory also refereed to as DRAM is the chalkboard in our previous analogy. This memory, unlike normal RAM, will be able to accommodate a reader and writer port. As the reader and writer will be in different clock domains we the reading and writing operations will occur at different times. Along with that, the FIFO buffer should be able to controll when something is written and what address is accessed. This is a perfect opportunity to declare all of our input and output signals.

module memory(
    input wire wclk, //A wire is a single non procedural signal
    input wire wflg,
    input wire full,
    
    input wire [4 : 0] waddr, //A wire [] will create a vector
    input wire [4 : 0] raddr,
    input wire [31 : 0] dataIn,
    
    output wire [31 : 0] dataOut
)

This fills out the input and output data however this code is already infected by magic numbers. As we have not begun writing any of the actual DRAM functionality we should take care of this problem. Our actual port information will look like:

module memory #(
    parameter WORDSIZE = 32,
    parameter addr_size = 5
)(
    input wire wclk,
    input wire wflg,
    input wire full,
    
    input wire [addr_size-1 : 0] waddr,
    input wire [addr_size-1 : 0] raddr,
    input wire [WORDSIZE-1 : 0] dataIn,
    
    output wire [WORDSIZE-1 : 0] dataOut
)

This should cover the DRAM interface however we still have an important signal we need to define. It is of course the actual memory that will be written to and read from. This is simple enough as it is a vector of vectors with the length of WORDSIZE. We need a depth for this vector of vectors that the address can navigate. To easily define this we shall create a local parameter to calculate the size (for cpp users this is equivalent to calculating a const value).

localparam DEPTH = 2 ** addr_size;
reg [WORDSIZE-1 : 0] mem [0 : DEPTH-1]; // A reg is a proceduraly assigned wire

An astute observer would begin to complain as I had mentioned the reader and writer operate in their own domains but in the code I have only defined one time domain. That is the writer time domain. Because the address, full and empty flags, and gray encoding (All functions that require a clock) are handled by fifo_buffer.v the dataOut signal can be assigned concurrently.

assign dataOut = mem[raddr];

Now that we have memory and can read from it we should create a way to write to it. This is where I hit my first major Verilog learning hurdle coming from VHDL. While VHDL is a very type safe language Verilog is not. And for the most part it doesn’t matter what the signals “type” is. That is of course unless you are using an always block. These are the “procedural” sections, and the wires assigned in them are not wires but regs. We need to write data at every wclk tick.

always @(posedge wclk) begin
    if(wflag && !full) 
        mem[waddr] <= dataIn;
end
endmodule

50 Shades of Gray Code

Alright I have thrown around the term Gray Code a bit, and have even promised it would solve the data tearing problem. What I have not done is said how this will help nor where we will use it. The data going into memory is completely in the writing clock domain. The data coming out of memory is taken completely in the reading domain. As this is true the data itself is safe from tearing. The full and empty flags are set by comparing the writer address and the reader address. This is where we experience clock domain crossing.

Gray Code, named after physicist Frank Gray, is an order of binary numbers that only allow for a single consecutive change. As we increase our binary number the Gray encoding will only have one difference from the old to the new. Data tearing cannot happen as the address is either the old address or the new address. This is better visualized in the table below.

Binary AddressGray EncodingChanged Bit
000000100
001001001
010011010
011010001
100110100
101111001
110101010
111100001

The formula used to convert natural binary to its Gray encoded form is fairly simple. This is found by taking the exclusive or of the binary address and the binary adress shifted once to the right. Our code can read:

module gray_encoder #(
    parameter addr_size = 5
)(
    input wire [addr_size : 0] binAddr, //We use the msb for the full and empty calculation which is why it is one more bit than the memory address
    output wire [addr_size : 0] encodedAddr
);

assign encodedAddr = binAddr ^ (binAddr >> 1);
endmodule

Now wile this would fulfill the requirement of encoding the binary address, it does not address any clock domain problems. Lets fix that by including a competing clock and two flip-flops (for metastability, I don’t really have the space to write about it but it is an excellent rabbit hole to go down if you haven’t).

module gray_encoder #(...)(...
// Module stays the same but we add
    input wire CompetingClk,
    input wire cmpRst,
...);
// Main block then becomes
reg [addr_size : 0] domainSynchA, domainSynchB;
wire [addr_size : 0] encodedVal;

always @(posedge CompeteingClk or negedge cmpRst) begin
    if(!cmpRst) begin //This is an active High reset
        domainSynchA <= '0;
        domainSynchB <= '0;
    end else begin
        domainSynchA <= encodedVal;
        domainSynchB <= domainSynchA;
    end
end

assign encodedVal =  binAddr ^ (binAddr >> 1);
assign encodedAddr = domainSynchA;

This is far better as we are taking an encoded value and bringing it into a new domain. Not quite perfect however as glitches can still occur on the port side. To prevent this we will add one more flip flop controlled by a port clock for glitch prevention.

module gray_encoder #(...)(...
// Module input stays the same but we add
    input wire PortClk,
    input wire prtRst,
// We will split the output into the different clock domains
    output wire [addr_size : 0] grayAddrPORT, grayAddrCOMP
);
// Main block then becomes
reg [addr_size : 0] glchSup, domainSynchA, domainSynchB;
wire [addr_size : 0] encodedVal;

// Our new flip flop
always @(posedge PortClk or negedge prtRst) begin
    if(!cmpRst)
        glchSup <= '0;
    else 
        glchSup <= encodedVal;
end

// And our old becomes
always @(posedge CompeteingClk or negedge cmpRst) begin
    if(!cmpRst) begin
        domainSynchA <= '0;
        domainSynchB <= '0;
    end else begin
        domainSynchA <= glchSup;
        domainSynchB <= domainSynchA;
    end
end

//Our output is then split into the different domains
assign grayAddrPort = glchSup;
assign grayAddrComp = domainSynchB;
endmodule

This fulfills the gray encoder requirements. Now we must tackle the issue of decoding. For this we can refer to the equation outlined by A short note on useful codes for Fluidic Control Circuits. For a value at position x the combinational logic will look like:

2x=(2x+1yx)+(2x+1yx)2^x = (\overleftharpoon{2^{x+1}}\cdot y^x) + (2^{x+1}\cdot\overleftharpoon{y^x})

Where 2x+12^{x+1} is the calculated value of more significance, and yxy^x is the encoded value at position xx. Helpfully to us however this is not the most efficient way of writing the formula. Those familiar with combinational logic will know that (AB)+(AB)(\overleftharpoon{A}\cdot B) + (A\cdot\overleftharpoon{B}) simplifies to an exclusive or statement. As this is a procedural calculation we shall again use an always block.

module gray_decoder #(
    parameter addr_size = 5
)(
    input wire[addr_size : 0] grayAddr,
    output wire[addr_size : 0] bitAddr
);

reg[addr_size : 0] decodedVal;
integer i;

always @(*) begin
    decodedVal[addr_size] = grayAddr[addr_size];
    for(i = addr_size; i>0; --i) begin
        decodedVal[i-1] = grayAddr[i-1] ^ decodedVal[i];
    end
end

assign bitAddr = decodedVal;
endmodule

More Buffer

Finally it is time to talk about the FIFO Buffer itself. I found when working on this project, that the problem became more digestable once I had defined the input and output signals.

module fifo_buffer#(
    parameter WORDSIZE = 32,
    parameter addr_size = 5
)(
    input wire wclk,
    input wire wreset,
    input wire WR,
    input wire [WORDSIZE-1 : 0] dataIn,

    input wire rclk,
    input wire rreset,
	input wire RD,

    output wire [WORDSIZE-1 : 0] dataOut,
    output wire [addr_size-1 : 0] rdLevel,
    output wire rdFull,
    output wire rdEmpty,

    output wire [addr_size-1 : 0] wrLevel,
    output wire wrFull,
    output wire wrEmpty
);

For a quick naming convention note, wr or wDom refers to “In the writer clock domain” and rd or rDom refers to “In the reader clock domain”.

With the signals all laid out they are fairly simple to group up. wclk, rclk, wreset, and rreset are all signals that we will use to synchronize the chaos. Our data related signals consist of WR, RD, dataIn, and dataOut. These are what we will use to control address increment and data flow. This leaves rdLevel, wrLevel, rdFull, rdEmpty, wrFull, and wrEmpty for buffer health. From this we can see that there only really three tasks that the fifo_buffer module needs to handle:

TaskDescription
Time SynchronizationAdding some order to the asynchronous chaos around.
Data FlowIts Moving! Get the DuckTape!
Space ManagementIs the buffer full? And what do we do if it is?

Time Synchronization

Perhaps the most pressing matter to complete. This keeps data transfer in the correct domains. First we must ask what needs to cross time domains? The data being written stays in the writing domain, the same goes for the read data in the reading domain. The write and read flags also remain separate from conflicting clocks. This leaves the space management data, the address data, and the reset signals. The space management data is derived from the addresses so that really that list is just address data and reset signals. The address data is simple enough to implement as the heavy lifting is done by the gray encoder:

// needed signals
reg  [addr_size : 0] wrPointer, rdPointer;

wire [addr_size : 0] rgrayRdDomain, rgrayWrDomain,
                     wgrayRdDomain, wgrayWrDomain;

reg rDomRst, wDomRst;
// encoding
gray_encoder #(.addr_size(addr_size)) readerEncoder
(
    .PortClk(rclk),
    .CompetingClk(wclk),
    .prtRst(rDomRst),
 	.cmpRst(wDomRst),
    .binAddr(rdPointer),

    .grayAddrPORT(rgrayRdDomain),
    .grayAddrCOMP(rgrayWrDomain)
);

gray_encoder #(.addr_size(addr_size)) writerEncoder
(
    .PortClk(wclk),
    .CompetingClk(rclk),
    .prtRst(wDomRst),
    .cmpRst(rDomRst),
    .binAddr(wrPointer),

    .grayAddrPORT(wgrayWrDomain),
    .grayAddrCOMP(wgrayRdDomain)
); 

The gray decoding follows a similar format. This resolves all of the domain issues for addresses however we still must worry about the reset. Because the reset is active high and controlled by both the writer and reader port we must first create a common reset signal.

assign rstAll = wreset & rreset;

This is only partially helpful as though rstAll does combine the two it is now somewhere between domains. To synchronize it we need to use flip flops.

always @(posedge rclk or negedge rstAll) begin
	if(!rstAll) begin
       rStblRst <= rstAll;
       rDomRst <= rstAll;
    end else begin
	   rStblRst <= rstAll;
       rDomRst <= rStblRst;
    end
end

always @(posedge wclk or negedge rstAll) begin
	if(!rstAll) begin
       wStblRst <= rstAll;
       wDomRst <= rstAll;
    end else begin
       wStblRst <= rstAll;
       wDomRst <= wStblRst;
    end
end

The multiple flip flops are again for metastability. With that the CDC portion of the project is completed.

Space Management

We need a way of signaling when the queue is full and empty so that the memory is neither overwritten nor read multiple times. We know that when writer address equals the reading address then it is either full or empty. To achieve this first part we will subtract the two pointers and check if it equals 00.

assign level = wrPointer[addr_size-1:0] - rdPointer[addr_size-1:0];
assign valMatch = ~|(level);

You may notice that we are only checking the bits on the tail. This is because the address is one bit less then the container. The reason for the most significant bit is to track weather the buffer is full or empty. As the pointer increments at some point it will wrap around to 0 again. By adding the extra bit we are signaled when it does wrap around. If the writer and reader pointer have the same value in their most significant bit then they are on the same lap which means that the buffer is empty. If the bits are different then the writer has lapped the reader and the buffer is full.

assign rdFull = (rDomWDecode[addr_size] ^ rdPointer[addr_size]) & valMatch;
assign wrFull = (wrPointer[addr_size] ^ wDomRDecode[addr_size]) & valMatch;

assign rdEmpty = (~(rDomWDecode[addr_size] ^ rdPointer[addr_size]) & valRdMatch) || !rDomRst;
assign wrEmpty = (~(wrPointer[addr_size] ^ wDomRDecode[addr_size]) & valWrMatch) || !wDomRst;

Ducktape Our Route

I will not bore you with the details of ducktaping as all of the technical pieces we have already defined. All we have done in this section is added the necessary sub-modules into the FIFO buffer such as the Gray decoder and the DRAM.

A Bench Worthy of Testing

I believe it was my second project goal to “Create an Effective Test Bench”. As a first stab at a testbench I cannot confidently state its effectiveness. It did however uncover several bugs that we will cover in the next segment. Recently I had learned of a Python library designed for property based testing called Hypothesis. This seemed like an excellent time to learn to use it. As I am simulating in Verilator (a cpp simulation) and hypothesis is a python library I needed a way of gluing the two together. I found one last library called Cocotb that allows one to write test benches in python that interfaces with Verilator.

The part I chose to tackle first was the Cocotb interface. I started by defining a few async functions that the tests could easily call to mimic some behavior.

async def rreset(dut):
    dut.rreset.value = 0;
    for n in range(3): 
        await RisingEdge(dut.rclk)
    dut.rreset.value = 1;
    await RisingEdge(dut.rclk)
...

async def driveWrite(dut, dataIn):
    dut.WR.value = 1
    i = 0
    max_i = len(dataIn)
    dut.dataIn.value = dataIn[0] 
    while i < max_i:
        await RisingEdge(dut.wclk)
        if dut.wrFull.value != 1:
            i += 1
            if i < max_i:
                dut.dataIn.value = dataIn[i]
    dut.WR.value = 0

async def driveRead(dut, output_data, count, output_addr, input_addr):
    dut.RD.value = 1
    i = 0;
    while i < count:
        await RisingEdge(dut.rclk)
        if (dut.rdEmpty.value == 0):
            output_data.append(int(dut.dataOut.value))
            output_addr.append(dut.rdPointer.value)
            input_addr.append(dut.wrPointer.value)
            i += 1
    dut.RD.value = 0

Above we begin to see a few design patterns that will continue to reemerge. dut stands for Design Under Test and is how we control and read signals from the top level design. By typing dut.rreset we get a refernce to rreset. By adding .value we can retrieve and set the value of rreset. The keyword await suspends a current task until the completion of the awaited task. This means that as it reads await RisingEdge(dut.wclk) waits for the rising edge of the clock. There are a few more fancier tricks we must use when assigning new clock frequencies however perhaps those are for another time.

The tests themselves use assert statements to trigger on failure. Let us use the gray test to show how that comes together. First we should figure out how to identify what a correct value is:

async def grayTest(dut, addr, encoded_addr):
    enter_addr = int(addr.value)
    expected_gray = util.gray_encoder(enter_addr)

We are passing in references here because this test will be used for both the writer and reader pointers. Here we get the address and compute what the Gray code should be. With what we assume to be the correct answer we can now use assert to check the current version:

async def grayTest(dut, addr, encoded_addr, decoded_addr, vers = "None"):
    enter_addr = int(addr.value)
    expected_gray = util.gray_encoder(enter_addr)

    assert int(encoded_addr.value) == expected_gray, f"{vers} Got: {encoded_addr.value}, Expected: {expected_gray}, Address: {enter_addr}"
    assert int(decoded_addr.value) == enter_addr, f"{vers} Enter: {enter_addr}, Exit: {decoded_addr.value}"

If you remember the Gray encoder uses both clock domains to allow for the data to safely cross into new frequencies. This test will never pass because the graycode coming out of the design is delayed. This means we must now synchronize the test with the design:

async def grayTest(dut, addr, encoded_addr, decoded_addr, portclk, compclk, vers = "None"):
    await RisingEdge(portclk)
    enter_addr = int(addr.value)
    expected_gray = util.gray_encoder(enter_addr)

    for n in range(2):
        await RisingEdge(compclk)

    assert int(encoded_addr.value) == expected_gray, f"{vers} Got: {encoded_addr.value}, Expected: {expected_gray}, Address: {enter_addr}"
    assert int(decoded_addr.value) == enter_addr, f"{vers} Enter: {enter_addr}, Exit: {decoded_addr.value}"

With the behavior and the tests taken care of we need data. This is where Hypothesis will shine for us. With a few lines we can set up several lists of random inputs that we can use:

# Hypothesis strategies
clock_strategy = st.tuples(
    st.integers(min_value = 10, max_value = 500),
    st.integers(min_value = 10, max_value = 500)
)

queue_data = st.lists(st.integers(min_value = 0, max_value = 2**word_size-1), min_size = depth)

clock_list = st.lists(clock_strategy, min_size=2, max_size=8)
queue_list = st.lists(queue_data, min_size=2, max_size=8)

And finally it is time to glue the pieces together:

@cocotb.test()
async def fullSysTest(dut):
     clock_batches = []
     queue_batches = []

     #Black Magic Hypothesis Work Around
     @given(clock_list, queue_list)
     @settings(
         max_examples = 10,
         deadline = None,
         database = None,
         suppress_health_check = [HealthCheck.too_slow, HealthCheck.function_scoped_fixture, HealthCheck.large_base_example, HealthCheck.data_too_large],
     )
     def collect(clocks, queues):
         nonlocal clock_batches, queue_batches
         clock_batches = clocks
         queue_batches = queues

     collect()
    
     cocotb.start_soon(grayTest(dut, dut.wrPointer, dut.wgrayRdDomain, dut.rDomWDecode, dut.wclk, dut.rclk, "wr to rd domain wrPointer"))
     cocotb.start_soon(grayTest(dut, dut.rdPointer, dut.rgrayWrDomain, dut.wDomRDecode, dut.rclk, dut.wclk, "rd to wr domain wrPointer"))
     for clock_data in clock_batches:
         #Set Async Clocks
         await adjust_clk(dut, clock_data[0], clock_data[1])
         
         for input_batch in queue_batches:
            output_data = []
            output_addr = []
            input_addr = []
            
            read_task = cocotb.start_soon(driveRead(dut, output_data, len(input_batch), output_addr, input_addr))
            await driveWrite(dut, input_batch)
            await read_task

This code will only check the gray code once however we would like it to be constantly monitored. To do so we will add a while true statement for the monitor. This would normally be an issue as the program wouldn’t end however Cocotb ends all start_soon tasks when the main function ends.

async def grayTest(dut, addr, encoded_addr, decoded_addr, portclk, compclk, vers = "None"):
    while True:
        await RisingEdge(portclk)
        enter_addr = int(addr.value)
        expected_gray = util.gray_encoder(enter_addr)

        for n in range(2):
            await RisingEdge(compclk)

        assert int(encoded_addr.value) == expected_gray, f"{vers} Got: {encoded_addr.value}, Expected: {expected_gray}, Address: {enter_addr}"
        assert int(decoded_addr.value) == enter_addr, f"{vers} Enter: {enter_addr}, Exit: {decoded_addr.value}"

With that we have a test! I will not bore you with the other tests I wrote but I will offer some advice. I have all of my tests run by this single function. Your quality of life will increase if you DO NOT do that. If I were to rewrite this test bench I would create several tests that run and stitch them together in the makefile.

Ew A Bug!!

How amazing it would have felt if I ran the test bench and everything had passed. Granted I would probably look at what I had messed up in the test bench. Regardless there were bugs of plenty that needed fixing. As to keep this write up a reasonable length I will limit myself to one bug.

The data I was putting in wasn’t the data I was getting out. More specifically the first word was wrong. After some time exploring a combination of code code error messages I had figured it out. The only event that could differentiate the first word with later words is the reset. After the reset was lifted some don’t care value would be written into the first buffer slot and the writer would move on to begin writting the correct data. The solve was to add a reset to the memory.

This was an easy enough fix as the DRAM already is set up for it. With a quick addition I had:

always @(posedge wclk) begin
    if(wflag && !full && wDomRst) 
        mem[waddr] <= dataIn;
end
endmodule

And while this certainly helped my problem It revealed a larger issue of unread data getting overwritten. You may ask “Did we not set up the full and empty flags to prevent this very situation?” and you would be right to ask. Some of you perhaps had caught my error early and have been screaming at the character walking down into the spooky basement of non clock domain crossing errors. While we had accounted for the seperate clock domains for the full and empty differentiation we had forgotten one piece. That is the level of the buffer.

This to was a simple solve as it was mealy calculating the level twice:

assign wlevel = wrPointer[addr_size-1:0] - wDomRDecode[addr_size-1:0];
assign rlevel = rDomWDecode[addr_size-1:0] - rdPointer[addr_size-1:0];

assign valRdMatch = ~|(rlevel);
assign valWrMatch = ~|(wlevel);

assign rdFull = (rDomWDecode[addr_size] ^ rdPointer[addr_size]) & valRdMatch;
assign wrFull = (wrPointer[addr_size] ^ wDomRDecode[addr_size]) & valWrMatch;

assign rdEmpty = (~(rDomWDecode[addr_size] ^ rdPointer[addr_size]) & valRdMatch) || !rDomRst;
assign wrEmpty = (~(wrPointer[addr_size] ^ wDomRDecode[addr_size]) & valWrMatch) || !wDomRst;

Did I Learn Anything?

Strange choice for a segment title but yet I learned a lot. I had heard of clock domain crossing but had no idea what that entailed before working on this project. Along with that I feel far more comfortable writing Verilog as really its just VHDL that lacks type safeness. The Cocotb, Hypothesis combination is certainly one I will continue to use in the future. Also I can now ditch ModelSim for Verilator, which is very exciting to me. Overall the project itself was well worth the time and effort.

Now Orbiting New Problems (NONP),

Simon