brng.dev

Brandon's Bytes


Project: lc3-hw [1]: Building some building blocks

02 Jun 2018


This post is a copy from my old website and blog. I have kept its original post date.

Before I head off and implement the CPU itself, it’s important to realize that we’re going to need the other parts of the computer system, such as memory, registers and buses etc. In this post, I’ll be discussing the first few Verilog modules that I created in order to enable implementing the overall system. Since there is already a baseline microarchitecture done complete with control signals, I thought it’d be prudent to come up with modules that corresponded to the various structures present. However, I most likely will be implementing the CPU logic in behavioral Verilog.

I’m intending on enabling a structural implementation of the LC-3 system overall, with the components being modeled as behavioral or as dataflow. Since this is intended on being put onto an FPGA, I’ll leave it at those two, since the synthesizer should be able to infer the structures and figure out the optimal way to implement them (e.g. FPGAs already have flip-flop and mux resources: no need to define them at the gate level).

Tristate Buffer

First off is a tristate buffer: this nifty guy is what keeps buses from being a polluted mess of signals with all the things that could be writing to it. It effectively acts as a switch allowing data to pass through when enabled and outputting a high-impedance when not enabled. While there is a tri type in Verilog, it really is just an aliased wire and is more for documentation purposes. I’ll be giving it two versions: an active-high enable and an active-low enable.

Pictured here is an active-high tristate buffer.

module tsb_h #(parameter WIDTH = 1)
(
    input  wire en,
    input  wire [WIDTH-1:0] in,
    output wire [WIDTH-1:0] out
);
    assign out = en ? in : {WIDTH{1'bz}};
endmodule

This here is the active-high version. It simply assigns the output as either the input or a bunch of high-impedance signals (z).

Register

Next is the register: while there literally is a reg type in Verilog, it may be helpful if the system level (where it’lll be structural Verliog where I’m just hooking up modules together) requires a register. It gets a clock, write enable, data in and data out.

Pictured here is a D flip-flop. While registers aren’t necessarily implemented using D flip-flops, the figure more or less describes the interface the register module exposes: it has a clock, data in (D), data out (Q), and write enable (CE).

module register #(parameter WIDTH = 1)
(
    input  wire clk,             // clock
    input  wire wr_en,           // write enable
    input  wire [WIDTH-1:0] d_i, // data in
    output wire [WIDTH-1:0] d_o, // data in
);
    reg [WIDTH-1] data;
    assign d_o = data;

    always @(posedge clk) begin
        if (wr_en) data <= d_i;
    end
endmodule

Here you can see that the internal data is constantly being exposed to the data out wires. The data is updated only when write enable is high.

RAM (Random Access Memory)

Next, I have a simple RAM implementation. This is structured so that it’s inferrable by Vivado to utilize Block RAM. It’ll be helpful whenever I need low-latency memory access (e.g. caches). Note that reads are synchronous: when you provide an address, the data will appear after the next clock edge.

I currently have two implementations: single-port and a dual-port RAM. For a single-port RAM, you can only read or write with one address at any given time: as such, you only have one set of address lines and depending on the implementation, shared data lines or separate data in and data out lines with the write enable determining what operation is happening (reading always happens when write isn’t occurring). In contrast the dual-port RAM allows you to work with two addresses, with both addresses having separate data and address lines and writing still being controlled by write enable(s). Dual-ported RAM is useful in applications such as VRAM (video RAM), where it enables you to draw to the framebuffer while it’s being scanned out to the display.

module spram #(parameter ADDR_WIDTH = 8, DATA_WIDTH = 8, DATA_DEPTH = 256)
(
    input  wire                  clk,
    input  wire                  en,
    input  wire                  wr_en,
    input  wire [ADDR_WIDTH-1:0] addr,
    input  wire [DATA_WIDTH-1:0] wr_data,
    output reg  [DATA_WIDTH-1:0] rd_data
);
    reg [DATA_WIDTH-1:0] mem[0:DATA_DEPTH-1];

    always @(posedge clk) begin
        if (en) begin
            if (wr_en) begin
                mem[addr] <= wr_data;
            end else begin
                rd_data <= mem[addr];
            end
        end
    end
endmodule

Here we see the single-port RAM. In this implementation, there is a separate data in (rd_data) and data out (wr_data). Note that rd_data is a reg`: after being presented with an address and in read mode, the RAM will write the data to an output register, with the data being visible after the next clock edge. This allows the data to be stable for the duration of the cycle and free from any combination mishaps.

module dpram #(parameter ADDR_WIDTH = 8, DATA_WIDTH = 8, DATA_DEPTH = 256)
(
    input  wire                  clk,
    input  wire                  en,
    input  wire                  wr_en,
    input  wire [ADDR_WIDTH-1:0] wr_addr,
    input  wire [DATA_WIDTH-1:0] wr_data,
    input  wire [ADDR_WIDTH-1:0] rd_addr,
    output reg  [DATA_WIDTH-1:0] rd_data
);
    reg [DATA_WIDTH-1:0] mem[0:DATA_DEPTH-1];

    always @(posedge clk) begin
        if (en) begin
            if (wr_en) begin
                mem[wr_addr] <= wr_data;
            end 
            rd_data <= mem[rd_addr];
        end
    end
endmodule

Here we see the dual-port RAM. Note that this implementation has one address fixed as read and the other for write: a true dual-port RAM allows you to do either operation. For simplicity, I’ve kept this paradigm; I originally wrote this for its use as a VRAM for a VGA controller.

ROM (Read Only Memory)

Let’s finish up with something much more simple: the ROM. In this case, I’m referring to a true read-only memory: it has no capability to be reprogrammed from the system standpoint. ROMs act pretty much like a bank of registers: you can asynchronously read from them with a given address. As such, they only have an address in and a data out.

module rom #(parameter ADDR_WIDTH = 8, DATA_WIDTH = 8, DATA_DEPTH = 8, DATA_FILE = "null", HEX = 0)
(
    input wire [ADDR_WIDTH-1:0] addr,
    output wire [DATA_WIDTH-1:0] data
);

    reg [DATA_WIDTH-1:0] contents [0:DATA_DEPTH-1];
    assign data = contents[addr];

    integer i;
    initial begin
        if (DATA_FILE != "null") begin
            if (HEX) begin
                $readmemh(DATA_FILE, contents);
            end else begin
                $readmemb(DATA_FILE, contents);
            end
        end else begin
            for (i = 0; i < DATA_DEPTH; i = i + 1) begin
                contents[i] = 0;
            end
        end
    end
endmodule

This module gets a bit more special treatment from the system tasks: since the ROM isn’t writable, the only way to get data into it is by preloading it via the $readmemh() and $readmemb() system tasks. This is actually able to be handled by Vivado for synthesis due to the nature of FPGAs: FPGA bitstreams can configure the RAM/flip-flops to hold initial values In addition, Vivado can infer ROM usage. I may actually introduce the ability to initialize the above RAM modules with a given data file. We’ll see in the future if that is actually needed.


Github repository