Introduction

While keeping sight of my target when doing this project, there were some components on the Arty that I don’t really know how to use. One of these was the DRAM mounted on the board. Hence, I wanted to implement the most obvious effect that needs some kind of memory of previous samples.

Basically, at some point in the chain, the delay will take samples at a rate of 48KHz, store them in a circular buffer in RAM, and set up a read pointer at some distance from the write pointer, such that reading yields back audio from the past. The math for this is quite simple in the end: with a sampling rate of 48KHz, every memory address of distance between read and write adds ~20.8 microseconds of delay. So let’s say I want a delay of up to ~2 seconds — we’ll need 192000 bytes of memory. It’s honestly not even that much when the mounted memory is 1GByte.

So, on the specification side, I need a circular buffer that can store the 16-bit samples at 48KBaud and read back with a variable distance. I will treat this module as a FIFO for all purposes from now on.

Talking with the memory controller

Setting up the controller from Vivado was honestly a painful experience. I had to figure out many things along the way, so let me put together a recipe here that will be useful for the next time I have to implement a memory controller.

DDR Configuration

Almost all of the configuration can be intuitive, but Vivado might not recognize or remember that you set up a Digilent board. I had to figure out the pin association from the schematic and place it by hand. If for some reason I had to reconfigure, those settings were lost, so I spent a lot of time re-entering many rows of pins in the configuration form. So, here is the configuration for the Arty A7:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
NET   "ddr3_addr[0]"                           LOC = "R2"    |    ;
NET   "ddr3_addr[10]"                          LOC = "R6"    |    ;
NET   "ddr3_addr[11]"                          LOC = "U6"    |    ;
NET   "ddr3_addr[12]"                          LOC = "T6"    |    ;
NET   "ddr3_addr[13]"                          LOC = "T8"    |    ;
NET   "ddr3_addr[1]"                           LOC = "M6"    |    ;
NET   "ddr3_addr[2]"                           LOC = "N4"    |    ;
NET   "ddr3_addr[3]"                           LOC = "T1"    |    ;
NET   "ddr3_addr[4]"                           LOC = "N6"    |    ;
NET   "ddr3_addr[5]"                           LOC = "R7"    |    ;
NET   "ddr3_addr[6]"                           LOC = "V6"    |    ;
NET   "ddr3_addr[7]"                           LOC = "U7"    |    ;
NET   "ddr3_addr[8]"                           LOC = "R8"    |    ;
NET   "ddr3_addr[9]"                           LOC = "V7"    |    ;
NET   "ddr3_ba[0]"                             LOC = "R1"    |    ;
NET   "ddr3_ba[1]"                             LOC = "P4"    |    ;
NET   "ddr3_ba[2]"                             LOC = "P2"    |    ;
NET   "ddr3_cas_n"                             LOC = "M4"    |    ;
NET   "ddr3_ck_n[0]"                           LOC = "V9"    |    ;
NET   "ddr3_ck_p[0]"                           LOC = "U9"    |    ;
NET   "ddr3_cke[0]"                            LOC = "N5"    |    ;
NET   "ddr3_cs_n[0]"                           LOC = "U8"    |    ;
NET   "ddr3_dm[0]"                             LOC = "L1"    |    ;
NET   "ddr3_dm[1]"                             LOC = "U1"    |    ;
NET   "ddr3_dq[0]"                             LOC = "K5"    |    ;
NET   "ddr3_dq[10]"                            LOC = "U4"    |    ;
NET   "ddr3_dq[11]"                            LOC = "V5"    |    ;
NET   "ddr3_dq[12]"                            LOC = "V1"    |    ;
NET   "ddr3_dq[13]"                            LOC = "T3"    |    ;
NET   "ddr3_dq[14]"                            LOC = "U3"    |    ;
NET   "ddr3_dq[15]"                            LOC = "R3"    |    ;
NET   "ddr3_dq[1]"                             LOC = "L3"    |    ;
NET   "ddr3_dq[2]"                             LOC = "K3"    |    ;
NET   "ddr3_dq[3]"                             LOC = "L6"    |    ;
NET   "ddr3_dq[4]"                             LOC = "M3"    |    ;
NET   "ddr3_dq[5]"                             LOC = "M1"    |    ;
NET   "ddr3_dq[6]"                             LOC = "L4"    |    ;
NET   "ddr3_dq[7]"                             LOC = "M2"    |    ;
NET   "ddr3_dq[8]"                             LOC = "V4"    |    ;
NET   "ddr3_dq[9]"                             LOC = "T5"    |    ;
NET   "ddr3_dqs_n[0]"                          LOC = "N1"    |    ;
NET   "ddr3_dqs_n[1]"                          LOC = "V2"    |    ;
NET   "ddr3_dqs_p[0]"                          LOC = "N2"    |    ;
NET   "ddr3_dqs_p[1]"                          LOC = "U2"    |    ;
NET   "ddr3_odt[0]"                            LOC = "R5"    |    ;
NET   "ddr3_ras_n"                             LOC = "P3"    |    ;
NET   "ddr3_reset_n"                           LOC = "K6"    |    ;
NET   "ddr3_we_n"                              LOC = "P5"    |    ;

Other settings are basically dictated by what you want to do. I didn’t want AXI, so I disabled that interface. Another tricky part was that the exact component on my board wasn’t the one set by default. I set MT41K128M16XX-15E for my memory, with a data width of 16, 4 banks, and normal ordering.

While I don’t need crazy speed, I’ll stick with correctness when possible — hence I set the impedance controls to RZQ/6 and used a BANK-ROW-COLUMN memory address mapping.

In my application I have two clocks: a system clock at 100MHz and a DSP clock at 60MHz. I use the system clock for the controller, so in the configuration I set it to “No Buffer” with reset active low, and disable the XADC because I want exclusive control from the analog front end.

Other settings aren’t complicated to understand, so I’ll skip them. After this you get a memory controller with a native interface that’s not too hard to use.

Communication protocol

Here things got a bit messy on my side. I ended up making a controller that’s integrated with the delay function, instead of implementing the controller separately and having a delay module use it. Hence, what comes next is not necessarily standard.

The memory interface generator (MIG from now on) exposes a few controls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  // MIG interface
  output logic [27:0]  app_addr,
  output logic [2:0]   app_cmd,
  output logic         app_en,
  input  logic         app_rdy,
  
  output logic [127:0] app_wdf_data,
  output logic         app_wdf_wren,
  input  logic         app_wdf_rdy,
  
  input  logic [127:0] app_rd_data,
  input  logic         app_rd_data_valid,
  input  logic         app_rd_data_end,
  input  logic         calib_done,
  output logic         status

The rules are simple, and the best way to implement the control is with an FSM.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
case (ram_state)
    3'b000 : ram_state <= host_wr_ff2                      ? 3'b001 : 3'b000;          // IDLE
    3'b001 : ram_state <= (app_wdf_rdy & app_rdy)          ? 3'b010 : 3'b001;          // WRITE
    3'b010 : ram_state <=                                    3'b011;                   // COMMIT 
    3'b011 : ram_state <=                                    3'b100;                   // FINISH WRITE
    3'b100 : ram_state <=                                    3'b101;                   // SKIP
    3'b101 : ram_state <= (app_rdy & app_en)               ? 3'b110 : 3'b101;          // READ PREPARE
    3'b110 : ram_state <= app_rd_data_valid                ? 3'b111 : ram_state;       // DEASSERT AND READ   
    3'b111 : ram_state <= host_wr_ff2                      ? 3'b111 : 3'b000;          // WAIT ACK
  endcase

This FSM starts the moment there’s a request from the host side to write a sample — by setting host_wr_ff2 — then waits for the memory controller to be ready and available for writing. For writing into memory, there’s a limit of writing in packets of 128 bits, so I have to pack 8 of my 16-bit samples before executing a write operation. For this, you have to set a memory address (which has to move in steps of 16, because a byte is 8 bits) and the content to be written, and when the memory controller notifies that it’s ready to receive commands, set app_wdf_wren and app_en. Something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  if(ram_state == 3'b001) begin
    ram_wr_flag  <= '0;
    app_cmd      <= '0;
    app_addr     <= ram_addr;
    app_wdf_data <= {fifo_wr_mem[7],fifo_wr_mem[6],fifo_wr_mem[5],fifo_wr_mem[4],fifo_wr_mem[3],fifo_wr_mem[2],fifo_wr_mem[1],fifo_wr_mem[0]};
    if (app_wdf_rdy & app_rdy) begin
      app_wdf_wren <= '1;
      app_en       <= '1;
    end else begin
      app_wdf_wren <= '0;
      app_en       <= '0;
    end
  end

A consequence of writing in steps of 8 bytes is that the delay’s granularity is in multiples of that many samples. Before writing/reading I have to collect those 8 samples. I could just insert a single sample every 8 memory addresses, but I consider the granularity good enough for the application, sitting at ~166us.

Reading is similar, but app_cmd changes in this case:

1
2
3
4
5
6
  if(ram_state == 3'b101) begin
    ram_wr_flag  <= '0;
    app_en       <= '1;         // set the command and wait until app_rdy is set
    app_cmd      <= 3'b001;
    app_addr     <= ram_addr_delay;
  end

The MIG will process the request and will notify when data is ready to be fetched.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  if(ram_state == 3'b110) begin
    ram_wr_flag  <= '0;
    app_en       <= '0;
    if(app_rd_data_valid) begin
      ram_addr <= (ram_addr == 28'b1111_1111_1111_0000) ? '0 : ram_addr + 28'h10;
      fifo_rd_mem[0] <= app_rd_data[15:0];
      fifo_rd_mem[1] <= app_rd_data[31:16];
      fifo_rd_mem[2] <= app_rd_data[47:32];
      fifo_rd_mem[3] <= app_rd_data[63:48];
      fifo_rd_mem[4] <= app_rd_data[79:64];
      fifo_rd_mem[5] <= app_rd_data[95:80];
      fifo_rd_mem[6] <= app_rd_data[111:96];
      fifo_rd_mem[7] <= app_rd_data[127:112];
    end
  end

This isn’t particularly complicated, to be honest — worth avoiding the AXI interface for such a simple application.

Cross-Domain Clocks

As an analog designer, the fact that the controller and host run on different clocks was kind of an obvious problem to be solved: at some point one clock will sample the flip-flops when the data isn’t ready, resulting in what’s defined as metastability.

The trick to solving the problem? Just add another flip-flop. Basically, a metastable latch takes a few clock cycles to resolve (depending on the architecture), so the additional flip-flop gives the previous one time to resolve the metastability (which is an undefined state, not necessarily mid-range between zero and one). This is almost a stochastic process, and usually one FF is enough to avoid audible glitches, but if that weren’t the case, a third one would reduce the probability of a bad state propagating to extremely low values. I’ve discovered this is something digital designers have to think about a lot. For me, it was expected.

The synchronizer has to live in the clock domain that’s receiving the cross-domain signal, so for example on the host side, the piece of module will look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
always @(posedge clk_host) begin
  ram_wr_ff1 <= ram_wr_flag;
  ram_wr_ff2 <= ram_wr_ff1;
  // wr_en edge detection
  wr_en_last <= wr_en;
  if(wr_en & ~wr_en_last) begin
    fifo_wr_mem[fifo_ptr]  <= wr_data;
    rd_data                <= fifo_rd_mem[fifo_ptr];
    sample_ready           <= wr_en;
    fifo_ptr               <= fifo_ptr + 3'b001;
  end
  else if(~wr_en & wr_en_last) begin
    // Fire the FSM only right after the host FIFO is done sampling
    // Consider the sampling happens once every ~1250 clock cycles
    // (i.e. 60MHz clock to sample 48KHz audio)
    host_wr_flag <= (fifo_ptr == '0);
  end
  else begin
    host_wr_flag <= ram_wr_ff2 ? '0 : host_wr_flag;
    sample_ready <= '0;
  end
end

So ram_wr_flag is getting generated on the MIG clock domain, but it passes through two registers ram_wr_ff1 and ram_wr_ff2 sampled on the host domain before being used (ram_wr_ff2 being the useful bit, never use ram_wr_ff1). Same for the MIG domain, just reasoning in the opposite direction.

Implementation at the top

The MIG will launch a calibration step at power-up, so on the top module, we have to hold the controller in reset until the calibration is done. Usually there is a flag to be read, but in my case I just wait for some time.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// -------------------------------------------------------------------------
// Reset - active HIGH for MIG, held until PLL locked + 200us
// -------------------------------------------------------------------------
logic [15:0] rst_cnt  = '0;
logic        mig_rst;                       // active HIGH
localparam   HOLD_CYCLES = 16'd12000;       // 200us @ 60MHz

always_ff @(posedge ck_main) begin
    if (!pll_locked)
        rst_cnt <= '0;
    else if (rst_cnt < HOLD_CYCLES)
        rst_cnt <= rst_cnt + 1'b1;
end

assign mig_rst = ~(rst_cnt < HOLD_CYCLES);  // HIGH while counting, LOW when done

with the actual instantiation of the module being like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// -------------------------------------------------------------------------
// MIG instantiation
// -------------------------------------------------------------------------

// Temperature: tie to a safe mid-range constant until XADC temp
// readback is wired properly (25C aprox 12'h2C0 in Xilinx format)
localparam logic [11:0] TEMP_CONST = 12'h2C0;

mig_7series_0 u_mig (
    // DDR3 physical
    .ddr3_dq            (ddr3_dq),
    .ddr3_dqs_n         (ddr3_dqs_n),
    .ddr3_dqs_p         (ddr3_dqs_p),
    .ddr3_addr          (ddr3_addr),
    .ddr3_ba            (ddr3_ba),
    .ddr3_ras_n         (ddr3_ras_n),
    .ddr3_cas_n         (ddr3_cas_n),
    .ddr3_we_n          (ddr3_we_n),
    .ddr3_reset_n       (ddr3_reset_n),
    .ddr3_ck_p          (ddr3_ck_p),
    .ddr3_ck_n          (ddr3_ck_n),
    .ddr3_cke           (ddr3_cke),
    .ddr3_cs_n          (ddr3_cs_n),
    .ddr3_dm            (ddr3_dm),
    .ddr3_odt           (ddr3_odt),

    // Clocks and reset
    .sys_clk_i          (ck_100),
    .clk_ref_i          (ck_200),
    .sys_rst            (mig_rst),
    .ui_clk             (ui_clk),
    .ui_clk_sync_rst    (ui_rst),
    .init_calib_complete(calib_done),

    // User interface - placeholder, delay FSM goes here
    .app_addr           (app_addr),
    .app_cmd            (app_cmd),
    .app_en             (app_en),
    .app_rdy            (app_rdy),
    .app_wdf_data       (app_wdf_data),
    .app_wdf_wren       (app_wdf_wren),
    .app_wdf_end        (app_wdf_wren),   // always last word for BL8
    .app_wdf_mask       (16'h0000),        // write all bytes
    .app_wdf_rdy        (app_wdf_rdy),
    .app_rd_data        (app_rd_data),
    .app_rd_data_valid  (app_rd_data_valid),
    .app_rd_data_end    (app_rd_data_end),

    // Maintenance - tied off
    .app_sr_req         (1'b0),
    .app_ref_req        (1'b0),
    .app_zq_req         (1'b0),
    .app_sr_active      (),
    .app_ref_ack        (),
    .app_zq_ack         (),

    // Temperature
    .device_temp_i      (TEMP_CONST),
    .device_temp        ()
);

Remember, I wanted the XADC module reserved exclusively for the front end, so the temperature is just hard-coded into the module, and we disregard its effect on calibration for this application.

Conclusion

This is almost a recipe for a MIG implementation, where the key lessons are:

  • Next time, separate the controller from the user. The way I did it, I can’t really use the memory for anything else.
  • The CDC was an expected behavior, with an intuitive solution that happened to also be the standard one.
  • As the project increases in complexity, I will need some way of testing (DFT). I will be writing about my solution to this in the next post.