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).
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).
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.
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.
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.