Lab Assignment 2: Firewall Filter FSM
1. Overview
In this assignment, you will design and implement a finite-state machine (FSM) in Verilog that behaves as a simple hardware firewall. Unlike the previous lab, which focused on combinational logic, this project introduces sequential logic, where your circuit must process a stream of input data over multiple clock cycles.
The firewall receives a packet one byte at a time and must parse its structure, validate its format, and apply a set of predefined security rules. Based on this analysis, the FSM will decide whether the packet should be accepted, dropped, or marked as invalid.
2. Sequential Logic in Verilog
2.1 Combinational vs Sequential Logic
In digital circuit design, logic is broadly divided into two categories: combinational and sequential.
Combinational logic circuits have outputs that depend strictly on the current state of their inputs. They have no memory. In the previous lab, you built an ALU using purely combinational logic. Whenever the inputs changed, the output updated immediately (ignoring physical propagation delays).
Here is a simple example of combinational logic in Verilog representing a 2-to-1 multiplexer:
module mux2_1 (
input wire a,
input wire b,
input wire sel,
output wire out
);
wire not_sel, w_a, w_b;
// The output 'out' updates immediately whenever 'a', 'b', or 'sel' changes.
not (not_sel, sel);
and (w_a, a, not_sel);
and (w_b, b, sel);
or (out, w_a, w_b);
endmodule
Sequential logic circuits, on the other hand, have memory. Their outputs depend not only on the current inputs but also on the past history of inputs (the current state). Sequential circuits rely on a clock signal (clk) to synchronize state changes, typically using memory elements like flip-flops.
Here is an example of a simple sequential logic circuit (a D-type flip-flop) in Verilog:
module d_flip_flop (
input wire clk,
input wire d,
output reg q
);
// The output 'q' only updates on the positive (rising) edge of 'clk'
always @(posedge clk) begin
q <= d;
end
endmodule
Key differences to note:
- The combinational example uses the
wiredata type. - The sequential example introduces the
regdata type (which can hold a value over time) and analways @(posedge clk)block. The value ofqwill only change when the clock signal transitions from 0 to 1, regardless of how many timesdchanges in between clock edges.
2.2 Verilog Operators
Verilog provides a rich set of operators, many of which will look familiar if you have programmed in C, C++, or Java. Because you will be using behavioral modeling in this lab, you can use these built-in operators instead of manually wiring up individual logic gate primitives.
Here are the most common operator categories:
- Arithmetic:
+(add),-(subtract),*(multiply),/(divide),%(modulo) - Bitwise:
~(NOT),&(AND),|(OR),^(XOR). These operate on each bit of the vector independently. - Logical:
!(NOT),&&(AND),||(OR). These evaluate the entire vector as a boolean value (zero is false, non-zero is true) and return a 1-bit result. - Relational & Equality:
==(equal),!=(not equal),<(less than),>(greater than),<=(less or equal),>=(greater or equal) - Reduction:
&(AND),|(OR),^(XOR). These are unary operators applied to a single vector to reduce it to a single bit (e.g.,&aANDs all bits ofatogether). - Shift:
<<(logical shift left),>>(logical shift right) - Concatenation:
{ }Combines multiple vectors or bits into a larger vector. - Conditional:
? :The ternary operator acts as a compact if-else for assignments.
Example Usage:
wire [7:0] a = 8'hA5; // 10100101 in binary
wire [7:0] b = 8'h0F; // 00001111 in binary
// Arithmetic
wire [7:0] sum = a + b;
// Bitwise vs Logical
wire [7:0] bitwise_and = a & b; // Result: 8'b00000101 (Bitwise AND)
wire logical_and = a && b; // Result: 1'b1 (Both are non-zero/true)
// Relational and Reduction
wire is_equal = (a == b); // Result: 1'b0 (False)
wire reduce_and = &a; // Result: 1'b0 (Not all bits of 'a' are 1)
// Shift, Concatenation, and Conditional
wire [7:0] shifted = a << 2; // Result: 8'b10010100
wire [15:0] combined = {a, b}; // Result: 16'hA50F
wire [7:0] max_val = (a > b) ? a : b; // Result: 8'hA5 (Since a > b)
2.3 If-Else and Case Statements
In Verilog, conditional statements like if-else and case allow you to describe complex logic behaviorally. Note: These constructs must always be placed inside an always (or initial) block; they cannot be used directly in the module body like assign statements or gate primitives.
If-Else Statement: The if-else statement evaluates a condition and executes the corresponding block of code. It is often used for priority routing or when checking specific control signals like resets.
always @(*) begin
if (reset == 1'b1) begin
out = 8'h00;
end else if (enable == 1'b1) begin
out = data_in;
end else begin
out = 8'hFF;
end
end
Case Statement: The case statement is used to execute a specific block of code based on the exact value of a variable. It acts like a large multiplexer and is heavily used in Finite State Machines (FSMs) to determine the next state or output based on the current state.
always @(*) begin
case (state)
2'b00: next_state = 2'b01;
2'b01: next_state = 2'b10;
2'b10: next_state = 2'b00;
default: next_state = 2'b00; // Always include a default case!
endcase
end
Important: Always include a default branch in your case statements and a final else branch in your if-else blocks when writing combinational logic (e.g., inside always @(*)). Failing to cover all possible conditions can result in the compiler inferring unwanted memory elements known as latches. Unlike proper edge-triggered flip-flops, latches are level-sensitive memory elements. They can introduce severe timing violations, cause unpredictable glitchy behavior in your circuit, and are generally poorly supported by FPGA synthesis tools, which are optimized for synchronous design.
2.4 Registers as State Elements
In Verilog, the reg data type is used to declare variables that can hold their value over time, acting as state elements or memory. This makes them essential for sequential logic.
Wire vs. Register:
- A
wirerepresents a physical electrical connection. It continuously reflects the value driven by a gate or anassignstatement. It cannot store a value; if nothing drives awire, its state is unknown. - A
regacts more like a variable in traditional programming. It stores the last value assigned to it until a new assignment is made. Crucially, aregmust be assigned its value inside analwaysorinitialblock. It cannot be driven by anassignstatement or directly connected to the output of a gate primitive.
Despite its name, a reg in Verilog does not always synthesize into a physical hardware register (like a D flip-flop).
- If a
regis assigned inside a clockedalways @(posedge clk)block, it will synthesize into a hardware flip-flop (memory). - If a
regis assigned inside a combinationalalways @(*)block, it will simply synthesize into combinational logic gates, acting essentially like a wire in the final hardware.
Example:
wire [7:0] w_result; // Must be driven continuously
reg [7:0] r_result; // Stores value assigned in an always block
// Continuous assignment drives the wire immediately
assign w_result = a & b;
// Procedural assignment drives the reg whenever the inputs change
always @(*) begin
r_result = a & b;
end
In the example above, w_result and r_result hold the exact same logical value, but they use different Verilog paradigms (continuous vs. procedural) to achieve it.
2.5 Initial and Always Blocks
In Verilog, procedural blocks are used to define the behavior of your digital circuits step-by-step. The two most common procedural blocks are initial and always. Any variable assigned inside these blocks must be declared as a reg.
The initial Block: An initial block executes exactly once at the beginning of a simulation (time zero). It is primarily used in testbenches to apply stimulus to the design or to initialize variables. While some modern FPGAs allow initial blocks to set the power-on state of registers, they are generally not used for synthesizable hardware logic.
initial begin
clk = 0;
reset = 1;
// Wait for 10 time units, then release reset
#10 reset = 0;
end
The always Block: An always block executes concurrently and continuously loops throughout the simulation. It relies on a sensitivity list (the part inside the @(...)) to determine exactly when the block should execute.
There are two primary ways you will use always blocks:
-
Combinational Logic (
always @(*)): The*symbol is a wildcard that tells the compiler to automatically add all signals read inside the block to the sensitivity list. The block will re-evaluate whenever any of those inputs change.always @(*) begin // Re-evaluates immediately whenever 'a', 'b', or 'sel' changes out = sel ? b : a; end -
Sequential Logic (
always @(posedge clk)): By specifying a clock edge (e.g.,posedgefor positive/rising edge), the block will only execute when that specific transition occurs. This is how you create flip-flops and registers.always @(posedge clk) begin // Executes only when the 'clk' signal transitions from 0 to 1 q <= d; end
2.6 Blocking vs Non-Blocking Assignments
Verilog features three primary types of assignments. Understanding the difference between them—especially the two types of procedural assignments—is critical to preventing hardware synthesis bugs.
1. Continuous Assignments (assign) As seen previously, these are used outside of procedural blocks to continuously drive a value onto a wire. They represent persistent electrical connections.
2. Blocking Assignments (=) These are used inside procedural (always or initial) blocks. They execute sequentially, step-by-step. The assignment "blocks" the execution of the next line of code until it finishes, exactly like variables in standard programming languages (C, Python, Java).
- Golden Rule: Always use blocking assignments (
=) when describing combinational logic (always @(*)).
3. Non-Blocking Assignments (<=) These are also used inside procedural blocks, but they behave differently. When a non-blocking assignment executes, it evaluates the right-hand side immediately, but it defers the actual assignment to the left-hand side variable until the very end of the current time step (e.g., the end of the always block). It does not block the execution of subsequent lines.
- Golden Rule: Always use non-blocking assignments (
<=) when describing sequential logic (always @(posedge clk)).
Why the difference matters: Consider trying to build a 2-stage shift register, where data passes from d to q1, and then from q1 to q2 on the next clock cycle:
// INCORRECT: Using blocking assignments for sequential logic
always @(posedge clk) begin
q1 = d; // q1 gets the value of d immediately
q2 = q1; // q2 gets the NEW value of q1 (which is just d!)
// BUG: Both q1 and q2 update to 'd' in a single clock cycle.
// This does not synthesize into a shift register.
end
Now, let's write it correctly:
// CORRECT: Using non-blocking assignments for sequential logic
always @(posedge clk) begin
q1 <= d; // q1 will get the value of d at the end of the cycle
q2 <= q1; // q2 will get the OLD value of q1 at the end of the cycle
// SUCCESS: d shifts into q1, and the previous state of q1 shifts into q2.
// This perfectly models flip-flop behavior.
end
Mixing blocking and non-blocking assignments within the same always block is generally bad practice and should be avoided.
2.7 Counters and Simple Sequential Circuits
Now that we understand non-blocking assignments and clock edges, we can build practical sequential building blocks. When processing a stream of network packet bytes, you will need auxiliary sequential circuits to help your FSM keep track of where it is in the packet and to remember past data.
Example 1: A Byte Counter Because the firewall receives one byte per clock cycle, a counter is essential for knowing exactly which part of the packet header is currently being processed.
reg [7:0] byte_count;
always @(posedge clk) begin
if (reset) begin
byte_count <= 8'd0; // Reset the counter to 0
end else if (packet_start) begin
byte_count <= 8'd1; // Restart counting on a new packet
end else if (byte_valid) begin
byte_count <= byte_count + 1'b1; // Increment on valid bytes
end
end
Example 2: Data Register with Enable You often need to capture and store a specific byte (like an IP address or protocol number) so the FSM can use it during later clock cycles.
reg [7:0] protocol_reg;
always @(posedge clk) begin
if (reset) begin
protocol_reg <= 8'd0;
end else if (capture_protocol_en) begin
protocol_reg <= data_in; // Store 'data_in' only when the enable signal is high
end
end
2.8 Finite State Machines (FSMs)
A Finite State Machine (FSM) is a sequential circuit used to control complex sequences of events. It consists of three main components: a state register (to hold the current state), next-state logic (to decide what the next state should be), and output logic.
In Verilog, the most robust way to implement an FSM is the two-block design pattern:
- A sequential
always @(posedge clk)block dedicated only to updating the state register. - A combinational
always @(*)block to compute the next state and the outputs based on the current state and inputs.
Here is a small, complete implementation of an FSM that waits for a start signal, processes something, and flags when it is done. Note the use of localparam to define state encodings—these are strictly local constants that make the FSM code much more readable and prevent the use of confusing "magic numbers" for state transitions:
module simple_fsm(
input wire clk,
input wire reset,
input wire start,
output reg done
);
// 1. State Encoding using localparam for readability and maintainability
localparam STATE_IDLE = 2'b00;
localparam STATE_PROCESS = 2'b01;
localparam STATE_DONE = 2'b10;
reg [1:0] current_state;
reg [1:0] next_state;
// 2. State Register (Sequential Logic)
// Only responsible for transitioning the current state on the clock edge
always @(posedge clk) begin
if (reset) begin
current_state <= STATE_IDLE;
end else begin
current_state <= next_state;
end
end
// 3. Next State & Output Logic (Combinational Logic)
// Computes where to go next and what the outputs should be
always @(*) begin
// Default assignments to prevent latches!
next_state = current_state;
done = 1'b0;
case (current_state)
STATE_IDLE: begin
if (start) begin
next_state = STATE_PROCESS;
end
end
STATE_PROCESS: begin
// Do processing here...
next_state = STATE_DONE; // Move to DONE immediately for this example
end
STATE_DONE: begin
done = 1'b1; // Set the output flag
next_state = STATE_IDLE; // Loop back
end
default: next_state = STATE_IDLE;
endcase
end
endmodule
Notice how the combinational block uses blocking assignments (=) and default values, while the sequential block uses non-blocking assignments (<=). You will use this exact structural pattern to build your firewall!
3. Downloading the assignment
To start the lab, you must first accept the assignment in GitHub Classroom. Accepting the assignment on GitHub Classroom is a 2-step process:
- Step 1: click on the invitation link from the assignment description from D2L.
- Step 2: check the inbox of the email address that you used when you created your GitHub account (it will probably be your personal email address), and look for an invitation email from GitHub. Click accept.
Now you can clone your repository in your home directory on the matrix.cdm.depaul.edu machine (replace USER with your GitHub account name):
$ cd ~
$ git clone https://github.com/transcendental-software/csc-397-595-lab2-USER.git
This command creates a directory named csc-397-595-lab2-USER containing all the necessary files for the assignment. You can then navigate into it using cd csc-397-595-lab2. You only need to modify the rtl/fw.v file. Use the make command to compile your code and the make test to run all the tests.
4. Firewall Filter Specifications
4.1 Network Packet Structure
The firewall FSM processes incoming network packets as a continuous stream of 8-bit bytes ([7:0]), receiving exactly one byte per clock cycle. Each packet follows a strict structural sequence consisting of a header, an optional payload, and a trailing checksum.
Packet Format Overview:
| Byte Index | Field | Size | Description |
|---|---|---|---|
0 | SOF | 8 bits | Start of Frame marker (must be 8'hAA) |
1 | SRC | 8 bits | Source address (8'h00 to 8'hFF) |
2 | DST | 8 bits | Destination address (8'h00 to 8'hFF) |
3 | TYPE | 8 bits | Packet type identifier |
4 | LEN | 8 bits | Payload length (number of bytes, 8'h00 to 8'h08) |
5..(4+LEN) | PAYLOAD | Variable | Payload data (LEN bytes) |
| Last Byte | CHK | 8 bits | Checksum |
Field Details:
- Start of Frame (SOF): This byte indicates the beginning of a new packet. Its value must be exactly
8'hAA. If the FSM detects any other value when expecting a new packet, the packet is considered invalid. - Source (SRC) & Destination (DST): Standard 8-bit values identifying the sender and receiver.
- Packet Type (TYPE): Defines the packet's purpose. Valid types are:
8'h01: DATA (Contains payload data)8'h02: PING (Connectivity check, no payload)8'h03: CONTROL (Control/management message)
Any other type value renders the packet invalid.
- Payload Length (LEN): Specifies the number of payload bytes. The valid range is
8'h00 <= LEN <= 8'h08. - Payload (PAYLOAD): Contains the actual data. Its length is entirely dependent on the
LENfield. IfLEN == 8'h00, there is no payload, and the very next byte received will be the checksum. -
Checksum (CHK): Used to verify the integrity of the packet. The checksum is computed as the bitwise XOR (
^) of all header and payload bytes, excluding the SOF byte:CHK = SRC ^ DST ^ TYPE ^ LEN ^ PAYLOAD[0] ^ ... ^ PAYLOAD[LEN-1]The computed checksum must match the received
CHKbyte at the end of the packet. If it does not match, the packet is considered invalid.
Total Packet Length: Because the payload length is variable, the total number of bytes in a packet is 6 + LEN (1 byte SOF + 4 bytes header + LEN bytes payload + 1 byte checksum). The FSM must use a counter to track its position within the packet and interpret fields accordingly.
Example DATA Packet Stream: If a DATA packet has LEN = 8'h02 and a payload of 8'h11 and 8'h22, the sequence of bytes received cycle-by-cycle would be:
8'hAA, 8'h05, 8'h10, 8'h01, 8'h02, 8'h11, 8'h22, 8'h25
(Note: 8'h25 is the checksum, calculated as 8'h05 ^ 8'h10 ^ 8'h01 ^ 8'h02 ^ 8'h11 ^ 8'h22)
4.2 Firewall Filter Rules
After a packet has been fully received and parsed, the firewall must classify it into exactly one of three possible outcomes: ACCEPT, DROP, or INVALID.
The firewall FSM must evaluate conditions in the following strict priority order:
- Check packet validity → if violated → INVALID
- If valid, apply policy rules → if violated → DROP
- Otherwise → ACCEPT
1. INVALID (Malformed Packet)
A packet is classified as INVALID if it violates any structural or format rule. If any of the following conditions are met, the packet must be marked invalid:
- Incorrect Start of Frame (SOF): The first byte is not
8'hAA. - Unknown Packet Type: The
TYPEfield is not one of:8'h01(DATA)8'h02(PING)8'h03(CONTROL)
- Invalid Payload Length: The
LENfield is strictly greater than 8 (LEN > 8'h08). - Type-Specific Format Violations:
TYPE == 8'h02(PING) andLEN != 8'h00(PING packets cannot have a payload).TYPE == 8'h01(DATA) andLEN == 8'h00(DATA packets must have a payload).
- Checksum Mismatch: The locally computed checksum does not match the final received
CHKbyte.
2. DROP (Policy Violation)
A packet is classified as DROP if it is structurally valid but violates one or more firewall security policies. Assuming the packet is valid, if any of the following conditions are met, it must be dropped:
- Blocked Source Address:
SRCis either8'hE0or8'hE1. - Disallowed Destination Address:
DSTis not in the set of allowed internal addresses:{8'h10, 8'h20, 8'h30}. - Unauthorized CONTROL Packet: The packet is a CONTROL message (
TYPE == 8'h03) but does not originate from the admin source (SRC != 8'h01).
3. ACCEPT (Allowed Packet)
A packet is classified as ACCEPT strictly when:
- It satisfies all structural validity rules, AND
- It does not violate any firewall policy rules.
4. Summary Table
| Condition Type | Outcome |
|---|---|
| Format violation (SOF, TYPE, LEN, checksum, etc.) | INVALID |
| Valid format but policy violation | DROP |
| Valid format and passes all rules | ACCEPT |
5. Implementation Notes
- The final decision (ACCEPT, DROP, or INVALID) must be made only after the entire packet is received, including the checksum byte.
- Exactly one of the decision output signals (
accept,drop, orinvalid) must be asserted for exactly one clock cycle after the packet has been fully evaluated. - The
doneoutput signal must be asserted simultaneously with the chosen decision flag for that exact same clock cycle. - The FSM must clearly distinguish between malformed packets (INVALID) and disallowed packets (DROP). This distinction is essential for correct firewall behavior and will be strictly checked by the grading testbench.
5. Implementation Details
5.1 The Skeleton Code
All of your implementation will take place in the provided rtl/fw.v file. The top-level module is defined as follows:
module fw (
input wire clk,
input wire reset,
input wire valid_in,
input wire [7:0] data_in,
output reg accept,
output reg drop,
output reg invalid,
output reg done
);
// Your FSM implementation goes here
endmodule
Inputs:
clk: The system clock. All sequential logic should be synchronized to the positive edge (posedge clk).reset: A synchronous active-high reset signal. Whenresetis high, your FSM should immediately return to its initial idle state and clear all internal registers.valid_in: A control signal indicating that the data ondata_inis valid during the current clock cycle. Your FSM should only processdata_inwhenvalid_in == 1'b1. Ifvalid_inis0, the FSM should maintain its current state and wait.data_in: An 8-bit bus carrying the current byte of the incoming network packet stream.
Outputs:
accept,drop,invalid: The final decision flags. Exactly one of these should be asserted (1'b1) for exactly one clock cycle immediately after the final byte of a packet (the checksum) has been processed. At all other times, they must be1'b0.done: A flag asserted for exactly one clock cycle to indicate the FSM has finished evaluating a packet. This signal must update synchronously at the exact same time as the final decision flags, transitioning to1'b1when a decision is reached and immediately returning to1'b0on the next clock cycle.
5.2 Implementation Strategies
Building an FSM that processes streaming data requires careful planning. Here are some strategies to help you implement the firewall successfully:
1. Use the Two-Block FSM Pattern Stick to the best practice discussed in Section 2.8. Use one sequential always @(posedge clk) block strictly for updating the state register (and any auxiliary registers), and one combinational always @(*) block for calculating the next_state and the outputs.
2. Define Clear States Use localparam to define your states. Rather than having a massive unreadable FSM, break the packet processing down. You will likely need states for:
- Waiting for the SOF byte (IDLE).
- Parsing the header fields (SRC, DST, TYPE, LEN).
- Processing the payload (this state might loop on itself over multiple cycles).
- Evaluating the checksum and asserting the final decision.
3. Capture Important Fields in Auxiliary Registers Because the packet arrives one byte at a time, but your final decision (ACCEPT, DROP, INVALID) depends on multiple fields (like TYPE, SRC, and LEN), you must "remember" these values. Create internal reg variables to capture these specific bytes when they arrive so you can evaluate them at the end of the packet.
4. Track Your Position with a Counter You need to know which byte of the payload you are currently processing so the FSM knows when the payload ends and the checksum byte is expected. Implement an internal byte counter that resets at the start of a packet and increments on each valid payload byte.
5. Compute the Checksum On-the-Fly Do not wait until the end of the packet to compute the checksum. Create an internal 8-bit reg (e.g., calc_checksum). When the first header byte (SRC) arrives, initialize it. For every subsequent byte (until the received checksum), XOR the new byte with calc_checksum. By the time you receive the packet's CHK byte, your locally calculated checksum will be ready for a direct comparison!
6. Beware of Latches In your combinational always @(*) block, always provide default assignments for your next_state and output flags at the very top of the block. This guarantees that every variable is assigned a value under all possible conditions, preventing the synthesis tool from generating unwanted latches.
6. Running Tests Locally
Before submitting your code, it is highly recommended that you run the test suite locally to ensure your Firewall Filter FSM implementation works correctly and passes all requirements. We have provided a Makefile to simplify this process.
From within your repository folder, run the following command to compile your Verilog code and execute the testbench:
$ make test
If there are any syntax errors or failing test cases, the output will help you identify which operation or flag is incorrect. The GitHub Classroom autograder will run this exact same test suite when you push your code.
7. Debugging the FSM
If your local tests are failing, you can use a waveform viewer like GTKWave to inspect the internal signals of your design and identify where the logic went wrong. When you run make test, the testbench automatically calls vvp bin/fw.vvp and passes an input to the simulation. Each vpp call generates a waveform dump file named fw.vcd.
To open the waveform file, run the following command in your terminal:
$ gtkwave fw.vcd
Tracing the signals visually makes it much easier to tell if a bug is caused by a flipped bit, a disconnected wire, or an incorrectly mapped primitive.
8. Submitting Your Code
Once you have completed your implementation and tested it locally, you need to push your code to your GitHub Classroom repository. Pushing your code will automatically trigger the autograding tests.
To submit your work, use the following git commands from within your repository folder:
$ git add rtl/fw.v
$ git commit -m "Completed Firewall Filter FSM implementation"
$ git push
You can verify your submission and see if your tests passed by checking the "Actions" tab in your GitHub repository online. If any tests fail, you can make additional changes to your code and repeat the git add, git commit, and git push steps to update your submission.