23 KiB
Maze Solver Chip Design Report
An explanation of the design of our 16x16 expandable maze solver chip, including a detailed dive into the SystemVerilog.
Table of Contents
1. High-Level Chip Purpose & Design
1. High-Level Chip Purpose & Design
The chip is able to solve a maze, outputting the correct sequence of moves (up, down, left, right) needed to traverse the maze without any dead ends. It accomplishes this by requesting cell locations from off-chip memory and accepting their wall configurations as a 4-bit input.
- As the chip moves around the maze, it requests new memory locations until it reaches the end of the maze.
- The chip may take dead-end paths, but its solution does not include any dead ends due to the implementation of a hardware stack.
- Once it reaches the end, the chip outputs the solution as a series of 2-bit numbers corresponding to the series of moves (up, right, down, left) needed to successfully traverse the maze.
Inputs & Outputs
We have a total of 21 I/O pins.
- Inputs
- 1
startpin - 1
rstpin - 1
clkpin - 1
input_enablepin - 4 bits for the
cur_wallinput
- 1
- Outputs
- 1
doneoutput pin - 1
req_next_addroutput pin - 1
out_valpin - 4 bits for the
next_xoutput - 4 bits for the
next_youtput - 2 bits for the
solutionoutput
- 1
2. FSM Design & General Flow
FSM Implementation
The chip's controller is a 14-state FSM. The FSM state diagram is shown below.
Note: only inputs and outputs to the FSM are shown, not the datapath.
General Flow
This is the general flow of the chip:
Setup
IDLE (S0): Wait for the external start input pin to go high before proceeding. Then go toINPUT (S1).
Intake Walls
INPUT (S1): Wait for the external input_enable input pin to go high, indicating that inputcur_wall [3:0]is valid for the requested addressreq_addr [7:0]output. Then go toLOAD (S2).LOAD (S2): Savecur_wallinput at the start of the datapath. If the input is0000, we've reached the end so jump to LAST (S11); otherwise, continue toRIGHT (S3).
Move Decision
RIGHT (S3): Check to see if a right turn is possible. If so, go toCHECK (S7); otherwise, continue toUP (S4).UP (S4): Check to see if going straight is possible. If so, go toCHECK (S7); otherwise, continue toLEFT (S5).LEFT (S5): Check to see if a left turn is possible. If so, go toCHECK (S7); otherwise, continue toDOWN (S6).DOWN (S6): This move will always be possible (for details, see datapath section). Go toCHECK (S7).
Backtrack Detection
CHECK (S7): Check to see if current move is is backtracking on a previous move. If so, go toPOP (S9); otherwise, go toPUSH (S8).PUSH (S8): Push the current move to the stack. Go toUPDATE (S10).POP (S9): Pop from the stack. Go toUPDATE (S10).UPDATE (S10): Update internal registers in preparation for next cycle. Go toINPUT (S2).
Output Solution
LAST (S11): Check to see if the last move has been read. If so, go toDONE (S13); otherwise, go toOUTPUT (S12).OUTPUT (S12): Read value from stack, starting from the bottom, to thesolution [1:0]output. Go toLAST (S11).DONE (S13): Wait for external rst signal to go back toIDLE (S0).
3. Datapath Design
Absolute vs. Relative Directions/Moves
In a maze, there are two ways of thinking about movement.
- The absolute view, from the perspective of someone looking at the maze from the outside.
- The relative view, from the perspective of someone who is walking around the maze themselves.
The chip uses both of these perspectives. In the datapath, we start with an absolute wall configuration, which is the walls surrounding a cell when viewing it from the perspective of the whole maze. This data is then translated into the relative wall configuration, which is the walls from the perspective of the current direction. For example, if the walls above and to the right a cell looking at it from the outside (absolute configuration), then if the chip is currently moving right through the maze, from its perspective there are walls above and to the left (relative configuration).
The relative move can then be decided based on this relative configuration, and the relative move can finally be translated back into an absolute move Using the pervious example, if we were moving right, and had walls above and to the left, we would turn right (relative move). But because we were already going right, turning right again would actually mean we're now going down, so the absolute move would be down.
Loading an Input
The cur_wall input is saved at the beginning of the datapath. This input represents the absolute wall configuration of a cell. Walls in the datapath are 4 bits, each representing a wall surrounding the cell - up, right, down, left.
Example: A cell with walls above and to the left of it would have the encoding
1001.
If the wall input = 0000, a NOR gate called wall_check sets the no_walls flag to 1. This flag tells the FSM that the chip has reached the end of the maze.
The current absolute direction is saved in the abs_dir_reg register in the datapath. Direction, both absolute and relative, is represented as follows:
00 - Up01 - Right10 - Down11 - Left
The absolute direction value is used to perform a barrel shift left on the current wall input, transforming it from an absolute configuration to a relative configuration. This is accomplished by the rotate_wall module in the datapath.
Example: We are moving left, which is direction
11. Shifting1001by11results in1100, which is the relative configuration. We now know we have walls above and to our right from our perspective of moving left
Deciding a Move
Since we transformed the absolute wall configuration to a relative one, there is no need to account for every combination of walls for every direction. The move decision is now easy. We follow this pattern:
- Try to move right
- Try to move up (go stright)
- Try to move left
- Go down (backwards)
In the datapath, this is as easy as selecting the right bit, then the up bit, then the left bit, etc. of the relative wall config to see if there's a wall there (this happens in the wall_check module of the datapath). We invert the wall input so if there is no wall and the move is valid, the val_move flag goes high. This flag is an input to the FSM.
Example: Our relative wall config is
1100, which means we can't go right or up. We can go left (11), so we'll choose that move.
The relative move is saved to the rel_move_reg register. Now we can tranform our relative move to an absolute move. This is done simply by adding our relative move to our absolute direction, and happens in the rel_to_abs_alu module.
Example: The selected move is left (
11), and our current direction is left (11).11+11= (1)10, so our absolute move is10(down).
Detecting a Backtrack
We now have our absolute move, which is saved to the abs_move_buf register and is an input to the back_test module. The second input to the back_test module is the value at the top of the stack, representing the last valid move pushed to the stack. The back_test module checks to see if these moves are opposite; in other words, if the current move and the top move of the stack are inverse (up & down, or left & right), we know we are backtracking. If so, the back_test module sets the back_flag to 1.
Example: If the last absolute valid move was left (
11), and our current absolute move is down (10), then we are not backtracking because these are not inverses. Theback_flagis not set to1.
Storing the Correct paths
If we aren't backtracking, we can store the current move to the stack as a valid move. (This move could later become invalid if we backtrack over it, but for now it's valid.) To do this, the FSM sets stack_en to 1 and stack_op to 0, which corresponds to a push operation. The current move is pushed to the stack.
If we are backtracking, the FSM sets stack_en to 1 and stack_op to 1, corresponding to a pop operation. The previous move is popped from the stack and the current move is not pushed since they "cancel out".
Example: We push our down (
10) move to the stack since we aren't backtracking.
Updating the Next Address & Internal Registers
Regardless of a push or pop, we still need to decide which address to request next from external memory. For a 16x16 maze, the address is stored as a single 8-bit number, with bits 7-4 for the x coordinate and bits 3-0 for the y coordinate. The cur_addr_reg register stores the current address. When the chip resets, this address is set to 0.
The current move is translated into an ALU operation and input like this:
00(Up) - Add1to the y coordinate (add_sub=0,x_y=00000001)01(Right) - Add1to the x coordinate (add_sub=0,x_y=00010000)10(Down) - Subtract1from the y coordinate (add_sub=1,x_y=00000001)11(Left) - Subtract1from the x coordinate (add_sub=1,x_y=00010000)
The x_y bus is added to or subtracted from the current address to form the next address, stored in the next_addr_reg register and split into its x and y chip outputs with the addr_split module. This address is requested from memory.
Example: Let's say our previous address was [x,y] = [13,5] (
11010101), and our current move is down (10). We subtract 1 from the y coordinate:11010101-00000001=11010100, which is [13, 4]. The outputs x = 13 and y = 4 are requested from external memory.
The final part of the main FSM loop involves updating the internal registers. The current move is written to the abs_dir_reg, since it is not the absolute direction we are travelling through the maze. The current address is updated to the next address. The FSM then returns to S1 where it waits for the next input.
Example: The current move, down (
10), is written to theabs_dir_regregister. The address stored in thenext_addr_regregister,11010100, is stored in thecur_addr_regregister.
Outputting the Solution
When the chip has reached the end of the maze, it's time to send the solution. This is all implemented in the FSM and stack. Details on the stack implementation can be found here.
The stack has an internal pointer, separate from the stack pointer, which starts at stack address 0 and can be incremented by the FSM with the stack_sol_inc signal. The FSM increments this pointer and checks to see if it has reached the end of the path with the stack_ptrs_eq flag, set by the stack when the output pointer has reached the top of the stack, marked by the stack pointer.
On each increment of the solution pointer, the FSM...
- enables the sol_reg register, writing to the
solution [1:0]output pins, and - sets
out_valto 1, marking the output valid.
This marks the end of the datapath.
4. Code Implementation
Our maze solver code is organized hierarchically as follows:
// FSM declaration
module fsm_design;
// Datapath component declarations
module reg_nbit;
module add_sub_wrap_nbit;
module not_left_nbit;
module bit_sel_4bit;
module not_4bit;
module nor_4bit;
module move_conv_logic;
module back_test_logic;
module split_1_to_2_nbit;
module stack_ncell;
// Datapath declaration
module datapath_design (
reg_nbit cur_wall_reg,
rot_left_4bit rotate_wall,
reg_nbit abs_dir_reg,
not_4bit wall_invert,
bit_sel_4bit test_move,
nor_4bit wall_check,
reg_nbit rel_move_reg,
reg_nbit abs_move_buf,
reg_nbit rel_to_abs_alu,
move_conv_logic move_conv,
add_sub_wrap_nbit cur_to_next_alu,
reg_nbit cur_addr_reg,
reg_nbit next_addr_reg,
split_1_to_2_nbit addr_split,
back_test_logic back_test,
stack_ncell stack,
reg_nbit sol_reg
);
module chip_design (
fsm_design controller,
datapath_design datapath
);
Our highest-level module, chip_design, is at the bottom of the .sv file to preserve Verilog hierarchical organization rules, where the components of a module are declared above the module. Most of this breakdown will concern the datapath components, since this is where the actual processing is happening.
Registers
Our variable-size register file is defined as follows:
module reg_nbit #(
parameter N = 8 // default to 8 bits
) (
input wire reg_clk,
input wire reg_en,
input wire reg_rst,
input wire [N-1:0] reg_in,
output logic [N-1:0] reg_out
);
always_comb begin
if (reg_rst) begin
reg_out <= {N{1'b0}}; // zero register on rst signal
end
else if (reg_en) begin
reg_out <= reg_in; // update reg on en signal
end
end
endmodule
We made this register of variable size so it can be used for all internal registers, and so chip can be expanded to accommodate larger mazes according to the MAX_SIZE parameter. These registers are:
- 2-bit:
abs_dir_reg,rel_mov_reg,abs_move_buf,sol_reg - 4-bit:
cur_wall_reg - *8-bit:
cur_addr_reg,next_addr_reg*for 16x16 mazes only
We used a always_comb block instead of an always_ff block to avoid the 1-cycle delay that is inherent to a flip-flop. Our register implementation is only a problem if we have a register feeding directly back into itself, but we have everything buffered so this isn't an issue.
The register is zeroed when it receives a rst signal, and when it receives a reg_en it saves the input.
Rotate_Wall Module
The rotate_wall module takes a 4-bit wall input (rot_in) and rotates it by the 2-bit input rot_val. This is accomplished by rotating the input left and appending the bits that rotated out of bounds to the right side with an assign statement:
assign rot_out = (rot_in << rot_val) | (rot_in >> (4 - rot_val));
Adder/Subtractor
We made one ALU (really just an adder & subtractor) module to use for the two ALUs in the datapath:
- The
rel_to_abs_alu, which takes the relative move and converts it to an absolute move by adding the current absolute direction[1:0] - The
cur_to_next_alu, which increments or decrements the x/y coordinates stored in thecur_addr_regregister to calculate the next address[7:0]
We needed our implementation to be able to overflow, since the rel_to_abs_alu relies on this behavior to function. We also needed to make the module expandable to account for the two different sizes needed.
Here is the SystemVerilog code:
module add_sub_wrap_nbit #(
parameter N = 2
) (
input wire [N-1:0] a_in,
input wire [N-1:0] b_in,
input wire add_sub_sel, // 0 = add, 1 = sub
output logic [N-1:0] result
);
assign result = add_sub_sel ? (a_in - b_in) : (a_in + b_in);
endmodule
The add_sub_sel input is used to select the operation. The rel_to_abs_alu does not use the subtract, so for its module instantiation, this signal is hardcoded as 0:
add_sub_wrap_nbit #(.N(2)) rel_to_abs_alu (
.a_in (relative_move_wire),
.b_in (absolute_dir_wire),
.add_sub_sel (1'b0),
.result (absolute_move_wire)
);
Stack
Our expandable stack has 6 inputs and 3 outputs.
- Inputs
clk: connected to external clk input pinrst_stack: resets stack's internal pointers and memorystack_en: when1, stack operation will be performed onclksignalstack_op:0= push,1= popstack_in [1:0]: data input to the stackstack_sol_inc: incrementsstack_sol_ptrfor output
- Outputs
stack_out [1:0]: memory location pointed to bystack_ptr, inc/dec internallystack_sol [1:0]: memory location pointed to bystack_sol_ptr + 1, incremented bystack_sol_incinputstack_ptrs_eq: set to1whenstack_ptr=stack_sol_ptr
Here is the SystemVerilog code:
module stack_ncell #(
parameter CELLS = 256
) (
input wire clk,
input wire rst_stack,
input wire stack_en, // High to enable push/pop operations
input wire stack_op, // 0 for push, 1 for pop
input wire [1:0] stack_in,
input wire stack_sol_inc,
output logic [1:0] stack_out,
output logic [1:0] stack_sol,
output logic stack_ptrs_eq
);
localparam PTR_WIDTH = $clog2(CELLS);
logic [1:0] mem [0:CELLS-1];
logic [PTR_WIDTH-1:0] stack_ptr;
logic [PTR_WIDTH-1:0] stack_sol_ptr;
always_ff @(posedge clk or posedge rst_stack) begin
if (rst_stack) begin
stack_ptr <= 0;
stack_sol_ptr <= 0;
mem <= '{default:2'b00};
end else if (stack_sol_inc) begin
stack_sol_ptr <= stack_sol_ptr + 1;
end else if (stack_en) begin
if (!stack_op) begin // Push Operation (stack_op is 0)
// 1. Increment the stack pointer
stack_ptr <= stack_ptr + 1;
// 2. Write the input data to the new top location
mem[stack_ptr + 1] <= stack_in;
end else begin // Pop Operation (stack_op is 1)
stack_ptr <= stack_ptr - 1;
end
end
end
assign stack_out = mem[stack_ptr];
assign stack_sol = mem[stack_sol_ptr + 1];
assign stack_ptrs_eq = (stack_ptr == stack_sol_ptr);
endmodule
If stack_en = 1 and stack_op = 0, this indicates a push operation. We increment the stack pointer and write the stack input to the new memory location. If stack_op = 1, this is a pop operation and we decrement the stack pointer. We don't actually pop data out of the stack to use; we're essentially deleting the top value of the stack by making it out of bounds.
The stack_sol output is the stack_sol_ptr + 1 because the stack_sol_ptr starts at address 0, but the first valid move will be at address 1, not 0. The FSM checks stack_ptrs_eq and then increments the pointer, so when stack_sol_ptr = stack_ptr - 1, the FSM will...
- Read the value at
stack_sol_ptr + 1, which is the top of the stack (mem[stack_ptr]) - Increment the
stack_sol_ptr - Check
stack_ptrs_eq, which is now1 - Exit
This does mean that we only have 255 usable locations for a 16x16 solver instead of 256, but this doesn't matter since 255 is the maximum number of moves within a 16x16 grid. The stack is expandable to accommodate the MAX_SIZE parameter.
Test_Move Module
The test_move module is a simple 4-bit bit selector, which sets the 1-bit output equal to the selected bit of the 4-bit input.
module bit_sel_4bit (
input wire [3:0] sel_in,
input wire [1:0] sel_val, // bit to select
output logic sel_out
);
always_comb begin
case (sel_val)
2'b00: sel_out = sel_in[3]; // "up" direction
2'b01: sel_out = sel_in[2]; // "right" direction
2'b10: sel_out = sel_in[1]; // "left" direction
2'b11: sel_out = sel_in[0]; // "down" direction
default: sel_out = 1'b0; // default case
endcase
end
endmodule
Back_Test Module
The back_test module implements clever logic to simplify the detection of backtracking. Instead of checking the four possible cases for current and previous values:
- Up (00), Down (10)
- Down (10), Up (00)
- Right (01), Left (11)
- Left (11), Right (01)
it instead checks two conditions with an assign statement:
- The inputs are different
- The inputs have the same least significant bit
module back_test_logic (
input wire [1:0] cur_in,
input wire [1:0] prev_in,
output logic backtrack
);
// If cur_in and prev_in are different but have the same LSB, chip is backtracking
assign backtrack = (cur_in != prev_in) && (cur_in[0] == prev_in[0]);
endmodule
Move_Conv Module
The move_conv logic block takes in a 2-bit move and outputs a 1-bit ALU operation (add_sub) and the data output x_y.
module move_conv_logic #(
parameter COOR_WIDTH = 4
) (
input wire [1:0] move_in,
output logic [(COOR_WIDTH*2)-1:0] x_y,
output logic add_sub // 0 for add, 1 for sub
);
logic [COOR_WIDTH-1:0] add_x;
logic [COOR_WIDTH-1:0] add_y;
always_comb begin
case (move_in)
2'b00: begin // up
add_sub = 0;
add_x = 0;
add_y = 1;
end
2'b01: begin // right
add_sub = 0;
add_x = 1;
add_y = 0;
end
2'b10: begin // down
add_sub = 1;
add_x = 0;
add_y = 1;
end
2'b11: begin // left
add_sub = 1;
add_x = 1;
add_y = 0;
end
default: begin // default case
add_sub = 0;
add_x = 0;
add_y = 0;
end
endcase
end
assign x_y = {add_x, add_y}; // recombine x and y into one output
endmodule
The module has two separate internal wires for x and y called add_x and add_y, which it combines into the single x_y output with an assign statement. We chose this design so the module can be expanded according to the MAX_SIZE parameter.
See the previous section for details on the move conversion logic.
Splitter
The addr_split module takes the 8-bit next address output (for a 16x16 maze) and splits it into its x and y components with two assign statements:
assign split_high_out = split_in[N-1:N/2];
assign split_low_out = split_in[(N/2)-1:0];
Wall_Invert & Wall_Check
The wall_invert and wall_check modules are simple NOT and NOR gates, respectively.
The wall_invert module takes the 4-bit absolute wall configuration and inverts each bit with an assign statement:
assign not_out = ~not_in;
Similarly, the wall_check module uses an assign statement to produce the NOR output of the 4-bit relative wall configuration:
assign nor_out = ~(|nor_in);
