一.图书馆存包柜电路设计
内容及要求
(1)存包柜共有128个;
(2)电路分为两部分:主控电路和每个柜的节点电路;
(3)节点电路接受和发送带有地址编码、控制编码的串行数据,也可以采用标准总线结构;
(4)节点电路带有条形码/二维码的扫描功能,直接输出布尔量(不需要设计该部分电路);
(5)其余功能自行设定;完成主控电路和节点电路;
1 翻译
要做一个128 个柜子的存包柜系统,结构分两层:
-
主控电路:像"柜子管理员",负责轮询/下发开锁命令/记录状态
-
节点电路(每个柜一个):像"柜子本体控制板",带地址,能收发串行数据,能读扫描器布尔量(扫码成功=1),能控制开锁执行器、读门磁等(其余功能自己设定)
难点:
-
128 节点怎么挂在一条串行总线上
-
什么时候哪个节点说话,避免总线冲突
-
协议帧怎么设计,怎么仿真证明正确
2 总体方案
主控轮询 + 多点挂载串行总线
-
主控通过 BUS_TX 广播请求(带 addr + cmd)
-
只有地址匹配 的节点在规定时隙通过 BUS_RX 回应(节点未被点名则高阻/不驱动)
-
主控维护 128 个节点的状态表(是否占用/门状态/扫码事件/报警等)
⚠️ 硬件上:如果真要外接 128 个物理节点,BUS_RX 要"共享回传"(三态/开漏)。但课设一般允许"单板演示+仿真证明"。
3 "自定功能"
题目允许"其余功能自行设定",但必须让系统像真的存包柜。给你一套非常合理、实现又不复杂的功能定义:
(1)节点(每柜)本地信号
-
scan_ok:扫码成功布尔输入(题目给的,不用设计扫码器) -
door_closed:门磁(1=关门) -
open_act:开锁执行输出(给电磁铁/继电器一个脉冲)
(2)节点内部状态(寄存器)
-
occupied:占用状态(0空 / 1已存) -
alarm:异常(比如门长时间未关、非法打开等,可选) -
scan_latch:扫描事件锁存(scan_ok 来了先记住,等主控来取)
(3)业务规则
-
空柜
occupied=0时,如果scan_ok=1→ 节点置scan_latch=1 -
主控轮询看到该柜
scan_latch=1 && occupied=0→ 下发OPEN -
节点收到
OPEN→open_act输出一个脉冲(比如 200ms),并清scan_latch -
门打开再关上
door_closed回到 1 → 节点把occupied置 1(表示已存包) -
取包:已占用
occupied=1时再扫码一次scan_ok=1→ 主控下发OPEN→ 关门后occupied置 0
这套逻辑非常"课设友好":既有扫码、又有占用、又有开锁,还能解释为什么需要主控轮询。
(4)两个关键 FSM
主控轮询 FSM(简写)
-
addr=0 -
循环:
-
发
READ(addr) -
等应答,超时则标记
offline(可选) -
更新 map
-
如果该 addr
scan=1→ 决策是否发OPEN(addr) -
addr++ 到 127 后回 0
-
节点命令 FSM(简写)
-
IDLE:监听 SOF=0x55 -
RECV:收够5字节校验 -
若
addr_match:-
CMD=READ→ 组帧应答 -
CMD=OPEN→open_act脉冲 + 清 scan_latch + 应答
-
-
若不匹配:丢弃
4 通信协议怎么设计(决定你后续代码难度)
轮询策略 :主控对 addr=0..127 依次发送 READ,每次等待应答超时再换下一个。
这样可以严格避免冲突:因为只有被点名的节点回应。

5 单板如何"演示128柜"
5.1 概述
(1)板子上没有128个真实柜子,但我们可以"逻辑上有128个节点",外部只用少量输入/输出去选中某一个柜子来演示。
(2)在 FPGA 里你真的实例化了:
-
locker_node[0] ... locker_node[127](共 128 个) -
主控
locker_main_ctrl真的在轮询addr_ptr=0..127 -
总线
bus_master真的在和这 128 个节点做通信
所以电路是完整的 128 柜系统。
但现实问题是:你不可能把 128 个扫码头、门磁、锁舌都接到一块板子上。
因此演示时就用"选择地址 + 注入事件 + 观察状态"的方式,让老师相信你实现了"128个柜子",只不过外设是模拟的。
(3)"选择地址 + 注入事件 + 观察状态":
-
选一个柜子地址
sel_addr(0~127)-
用拨码/按键改这个地址
-
或者更简单:直接用 PC 串口发
ADDR
-
-
对
sel_addr这个柜子制造事件-
"模拟扫码成功" → 给该节点一个
scan_ok脉冲 -
"模拟门开/关" → 改该节点的
door_closed
-
-
把这个柜子的状态显示出来
-
用 LED/数码管显示
door/occ/scan/alarm -
或者直接用串口回包把状态打印在 PC 上(更稳更清楚)
-
现在的工程选:PC 串口演示。
5.2 PC 串口演示
PC 不是在直接控制每个 node 的内部寄存器;PC 是在控制主控/模拟输入,然后主控仍然按系统结构去跟节点交互。
-
PC协议:只是给你一个"外部演示入口",让你能指定某个柜子、注入事件、读回状态。
-
128节点总线:才是课设要求的"主控+节点通信结构"的核心。
(1)协议概述(PC ↔ FPGA 串口命令/应答)
-
物理层:UART(异步串口),默认
BAUD=115200,8N1(8数据位/无校验/1停止位) -
帧长度:固定 5 字节,便于解析与仿真验证
-
校验方式:加和校验(sum & 8'hFF),快速、硬件开销低
(2)帧格式定义

说明:
A.CHK:
校验 = (SOF+CMD+ADDR+DATA) & 0xFF,用来防止串口噪声导致误触发
B.STATUS 的 bit 定义:
-
bit0 = door_closed:1 关门 / 0 开门 -
bit1 = occupied:1 占用 / 0 空闲 -
bit2 = scan_latch:1 表示"曾扫码成功但还没消费" -
bit3 = alarm:报警(你目前可能没怎么用它) -
bit7..4保留为 0
也就是说,PC 收到一个 STATUS=0b0000_0111 这种,就能解读:
-
door=1(门关)
-
occ=1(占用)
-
scan=1(有扫码待处理)
C.CMD命令:
- CMD=0x01 READ(读状态)
用途:看现在这个柜子的状态是什么,演示时你可以随时 READ 某个 addr 来证明"128个柜子 都有状态"。
- CMD=0x02 OPEN(紧急开锁)
用途:强制让主控给某个节点发 OPEN(插队优先)
效果(在你的 node 里):节点收到 OPEN 会把 scan_latch 清 0,并进入 opened=1(等待 关门翻转占用)
- CMD=0x03 SET_SCAN(模拟扫码)
用途:不接真实扫码头,也能让某个柜子产生"扫码成功事件",在 top 里实现为scan_pulse_in[addr] 拉高 1 个 clk(脉冲)。
效果(在 node 里):scan_latch 变 1 并保持。
- CMD=0x04 SET_DOOR(模拟门磁)
用途:不接真实门磁,也能控制门开/关,DATA bit0=1 表示关门,0 表示开门。
这个会直接改 top 里的 door_closed_in[addr],节点的 door_closed 跟着变。
5.3 说明
理清"两套通信"
A. PC ↔ FPGA:UART 5 字节协议
-
你已经有:
SOF=0xC3/0x3C + CMD + ADDR + DATA/STATUS + CHK -
这套协议只负责:PC下命令、FPGA回状态
B. FPGA 内部:bus_master ↔ 128个 locker_node:40bit 同步串行总线
-
每次事务固定:40bit TX(主控发请求) + 40bit RX(节点回应答)
-
这套协议只负责:主控轮询/开锁,与每个柜节点交换状态
6 代码
6.1 uart_rx.v子模块
这份接收器默认假设是最常见的 UART 帧:
-
空闲:
rx=1 -
起始位:
rx=0(1 bit) -
数据位:8 bit(通常 LSB 先发)
-
停止位:
rx=1(1 bit) -
无校验(因为没写 parity)
所以你会看到它在 bit_idx==0 处理起始位,bit_idx=1..8 收 8 个数据位,bit_idx==9 检查停止位并输出。
module uart_rx #(
parameter integer CLK_HZ = 100_000_000,
parameter integer BAUD = 115200
)(
input wire clk,
input wire rst_n,
input wire rx,
output reg [7:0] data,
output reg valid
);
localparam integer CLKS_PER_BIT = (CLK_HZ / BAUD);
localparam integer HALF_BIT = (CLKS_PER_BIT / 2);
/*CLKS_PER_BIT:一个 UART 比特持续多少个 clk 周期
比如 CLK_HZ=100MHz, BAUD=115200:
CLKS_PER_BIT ≈ 100,000,000 / 115,200 ≈ 868
也就是每个 UART bit 大约 868 个系统时钟周期。
HALF_BIT:半个 bit 的长度,用来在起始位中间做"确认",因为起始沿可能带点抖动,在半个 bit 处再看一次 rx,确认它真的还是 0。*/
reg [15:0] clk_cnt;
reg [3:0] bit_idx;
reg [7:0] shreg;
reg busy;
/*核心寄存器
busy=0:在等起始位
busy=1:正在接收这一帧
clk_cnt:对着 CLKS_PER_BIT 计数,到了末尾就采样/推进 bit_idx
bit_idx:控制当前处于起始位/数据位/停止位哪个阶段
shreg:每个数据位采样一次,拼成 8bit*/
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
clk_cnt <= 0;
bit_idx <= 0;
shreg <= 0;
data <= 0;
valid <= 1'b0;
busy <= 1'b0;
end else begin
valid <= 1'b0;//这里有个非常典型的写法:每个时钟周期一开始 valid <= 0,当完整收完一帧那一拍,才把 valid <= 1 拉高一次。
//即: valid 是一个"单周期脉冲",表示"此刻 data 更新有效"。所以你在波形里应该看到 valid 只跳一下,不会一直保持 1。
if (!busy) begin/*空闲态如何抓到起始位:长时间 rx=1:busy 一直 0,一旦外部发送起始位拉低 rx=0:busy 变 1(进入接收状态)。
clk_cnt=0(开始对这个 bit 计时),bit_idx=0(处于起始位阶段);*/
if (rx == 1'b0) begin
busy <= 1'b1;
clk_cnt <= 0;
bit_idx <= 0;
end
end else begin
clk_cnt <= clk_cnt + 1'b1;
if (bit_idx == 0) begin
if (clk_cnt == HALF_BIT) begin/*起始位阶段:在 HALF_BIT 处再确认一次:刚检测到 rx=0,先别急着相信它真是起始位,等半个 bit(clk_cnt==HALF_BIT)再看:
如果这时 rx 已经回到 1:说明刚才只是毛刺/抖动 → busy=0 放弃;如果还在 0:确认起始位真实存在。
然后等满一个 bit(clk_cnt==CLKS_PER_BIT-1):清零计数器,进入 bit_idx=1(准备采样第1个数据位)*/
if (rx != 1'b0) begin
busy <= 1'b0;
end
end
if (clk_cnt == CLKS_PER_BIT-1) begin
clk_cnt <= 0;
bit_idx <= 1;
end
end else if (bit_idx >= 1 && bit_idx <= 8) begin
if (clk_cnt == CLKS_PER_BIT-1) begin
clk_cnt <= 0;
shreg <= {shreg[6:0], rx}; // 新位进入最低端,LSB first
bit_idx <= bit_idx + 1'b1;
end
end else if (bit_idx == 9) begin/*当你收完 8 个数据位后,bit_idx 会变成 9,进入停止位阶段
再等 1 个 bit 时间:接收结束:busy=0,输出并锁存:data <= shreg,给一个"脉冲通知":valid <= 1,bit_idx 回到 0,准备下一帧
注意:你这里并没有检查停止位是否为 1(只是等完时间就结束),严格版可以在停止位中点采样确认 rx==1。*/
if (clk_cnt == CLKS_PER_BIT-1) begin
clk_cnt <= 0;
busy <= 1'b0;
data <= shreg;
valid <= 1'b1;
bit_idx <= 0;
end
end
end
end
end
endmodule
6.2 uart_tx.v子模块
你这里组帧写的是:frame <= {1'b1, data, 1'b0};
frame[9:0] 从高到低分别是:
-
frame[9] = 1停止位(stop) -
frame[8:1] = data[7:0]8 个数据位 -
frame[0] = 0起始位(start)
接下来模块会把 tx 依次输出 frame[0]..frame[9](起始位→数据→停止位),这就是标准的 1 start + 8 data + 1 stop(无校验)。
module uart_tx #(
parameter integer CLK_HZ = 100_000_000,
parameter integer BAUD = 115200
)(
input wire clk,
input wire rst_n,
output reg tx,
input wire [7:0] data,
input wire valid,
output reg ready
);
localparam integer CLKS_PER_BIT = (CLK_HZ / BAUD);//一个 UART 比特持续多少个系统时钟周期。比如 100MHz/115200 ≈ 868,那么每输出一位(start/data/stop),都要保持 tx 不变 868 个 clk 周期。
reg [15:0] clk_cnt;
reg [3:0] bit_idx;
reg [9:0] frame;
reg busy;/*寄存器:busy=0:空闲,可以接收上层的 valid,busy=1:正在发送,忽略新的 valid
ready:对外的"我现在能不能接新数据"的握手信号*/
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin//tx=1(UART 空闲线为高),ready=1(告诉上层:我空闲),busy=0,frame=全1 只是个初始化,不影响实际发送
tx <= 1'b1;
ready <= 1'b1;
clk_cnt <= 0;
bit_idx <= 0;
frame <= 10'h3FF;
busy <= 1'b0;
end else begin
if (!busy) begin/*空闲态:等待 valid 来触发一次发送,平时 busy=0,模块一直举着牌子:ready=1(我能接单)
当上层把 valid=1 拉高(表示 data 有效):把 data 打包成 frame:{stop, data, start},进入忙状态:busy=1,ready=0(告诉上层:别再塞了)
从第 0 位开始发:bit_idx=0,直接把 tx=0 拉低 → 立刻发起始位(start bit)
注意:你这里 valid 没有要求必须是单拍脉冲,但如果上层一直把 valid 拉着不放,也没事:因为一旦 busy=1,就不会再进这个分支了。*/
ready <= 1'b1;
if (valid) begin
frame <= {1'b1, data, 1'b0};
busy <= 1'b1;
ready <= 1'b0;
clk_cnt <= 0;
bit_idx <= 0;
tx <= 1'b0;
end
end else begin
ready <= 1'b0;
clk_cnt <= clk_cnt + 1'b1;
if (clk_cnt == CLKS_PER_BIT-1) begin//"节拍逻辑",当 clk_cnt 数到 CLKS_PER_BIT-1:说明当前这一位已经"保持够久了",该切到下一位输出
clk_cnt <= 0;
bit_idx <= bit_idx + 1'b1;
tx <= frame[bit_idx+1];//在"位边界"更新下一位,第一次到达位边界(bit_idx还=0):tx<=frame[1](输出第 1 个数据位)
if (bit_idx == 9) begin ////最终会输出到 frame[9](stop=1)
busy <= 1'b0;
tx <= 1'b1;
ready <= 1'b1;
end
end
end
end
end
endmodule
这份代码在工程里常见的小改进
valid 的握手更严格
有时会写 if (valid && ready) 才装载,避免上层乱拉 valid 导致重复装载(你这份不会重复,因为 busy=1 后不装载,但规范上还是建议)。
6.3 pc_packet_rx.v子模块
它是一个5 字节固定帧 的接收器,把串口/上游流进来的 rx_data(配 rx_valid)拼成一包,然后做 SOF+校验,最后打一拍 pkt_valid 输出 cmd/addr/data。
接收包格式:

校验规则是:checksum = b0 + b1 + b2 + b3(8bit 相加自然溢出取低 8 位)
只有同时满足:
-
b0 == SOF (8'hC3) -
sum4(b0,b1,b2,b3) == 第5字节
才认为收到了有效包。
module pc_packet_rx(
input wire clk,
input wire rst_n,
input wire [7:0] rx_data,
input wire rx_valid,
output reg pkt_valid,
output reg [7:0] cmd,
output reg [7:0] addr,
output reg [7:0] data
);
localparam [7:0] SOF = 8'hC3;
reg [2:0] cnt;//cnt 就是"收包进度条":cnt=0:等待第 1 个字节(b0)
//cnt=1:等待第 2 个字节(b1),cnt=2:等待第 3 个字节(b2),cnt=3:等待第 4 个字节(b3),cnt=4:等待第 5 个字节(checksum)
reg [7:0] b0,b1,b2,b3,b4;
function [7:0] sum4;/*sum4 函数:怎么算校验,它就是把 4 个字节相加,返回 8 位结果。
Verilog 里 8 位加法会产生更宽的中间值,但最后赋值到 [7:0] 会自动截断(相当于 mod 256)。*/
input [7:0] x0,x1,x2,x3;
begin sum4 = x0 + x1 + x2 + x3; end
endfunction
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cnt <= 0;
b0<=0; b1<=0; b2<=0; b3<=0; b4<=0;
pkt_valid <= 1'b0;
cmd<=0; addr<=0; data<=0;
end else begin
pkt_valid <= 1'b0;//典型"单周期脉冲"写法,pkt_valid 只会在某一拍被置 1,下一拍必回 0
if (rx_valid) begin//如果上游没有给新字节(rx_valid=0),模块就"停在原地",不乱计数。
case (cnt)
0: begin b0 <= rx_data; cnt <= 1; end//cnt=0:收帧头 b0,把 rx_data 存到 b0
1: begin b1 <= rx_data; cnt <= 2; end//cnt=1:收 cmd 字节 b1
2: begin b2 <= rx_data; cnt <= 3; end//cnt=2:收 addr 字节 b2
3: begin b3 <= rx_data; cnt <= 4; end//cnt=3:收 data 字节 b3
4: begin //收 checksum(第5字节)并做校验、出包
b4 <= rx_data;
cnt <= 0;
if (b0 == SOF && sum4(b0,b1,b2,b3) == rx_data) begin/*校验判断:b0 == 8'hC3 -合法包,sum4(b0,b1,b2,b3) == rx_data 校验对才算成功
校验通过才输出:cmd<=b1, addr<=b2, data<=b3。pkt_valid<=1(只这一拍为 1),所以 pkt_valid 就是"第五个字节到来且校验通过的那一拍"打一下。*/
cmd <= b1;
addr <= b2;
data <= b3;
pkt_valid <= 1'b1;
end
end
default: cnt <= 0;
endcase
end
end
end
endmodule
风险 :它不"找帧头",只按 5 字节切块
也就是说:如果中间丢了 1 个字节、或者上游从任意位置开始喂数据,这个模块会"错位拼包",直到某一组 5 字节刚好碰巧校验过才会出 pkt_valid。
更健壮的写法通常是:
-
在
cnt=0时如果rx_data != SOF就一直留在 0(等待真正帧头) -
或者任何时候发现不对就回到 0 重新找 SOF
6.4 pc_packet_tx.v子模块
1.发的包格式:

2.标准的 valid/ready 握手:
-
只有当
tx_ready==1时 ,模块才会拉tx_valid==1并推出一个字节 -
tx_ready==0时,模块暂停,不丢字节不跳 idx
对应接口信号:
输入
-
clk, rst_n:时钟与复位 -
send:发送触发(你希望开始发一帧) -
cmd, addr, status:要塞进包里的字段 -
tx_ready:下游是否准备好接收一个字节(典型握手 ready)
输出
-
tx_data[7:0]:给下游的字节数据 -
tx_valid:这个周期tx_data有效(你要把这个字节交给下游) -
busy:模块内部忙,表示"正在发送/还有字节没吐完"
3.工作过程(分两大阶段:组包阶段 vs 吐字节阶段)
阶段 A:空闲等待(busy=0)
A1)默认现象
-
busy=0时模块不主动输出任何东西 -
每拍
v<=0,所以tx_valid=0
A2)当 send=1 来了(开始发一帧)
这一拍发生的事(非常关键):
-
把整帧5字节先缓存下来
-
b0 <= SOF(帧头 3C) -
b1 <= cmd -
b2 <= addr -
b3 <= status -
b4 <= sum4(SOF,cmd,addr,status)(校验)
-
-
进入忙状态
-
busy <= 1 -
idx <= 0(准备从第 0 个字节开始发)
-
注意:这一拍只是"装弹夹",还没真正把字节推出去(因为吐字节是在 busy=1 分支里,且要等 tx_ready)。
阶段 B:发送进行中(busy=1)
B1)每拍先把 tx_valid 默认清零
说明:tx_valid 是单周期脉冲,只有握手成功时拉高 1 拍。
B2)只有当 tx_ready=1 才真正发一个字节
如果下游没准备好(tx_ready=0):
-
这一拍什么都不发
-
idx不变 -
busy仍然保持 1 -
tx_valid=0 -
这就是"背压":下游慢,发送端就暂停,不会丢字节。
B3)当 tx_ready=1:发出当前 idx 对应的字节
B4)idx 推进与结束条件
-
若
idx != 4:idx <= idx+1(准备下一字节) -
若
idx == 4:说明刚发完最后一个字节:-
busy <= 0(回到空闲) -
idx <= 0(复位指针)
-
所以 busy 会持续为 1,直到 checksum 字节发送完成那一拍之后变回 0。
module pc_packet_tx(
input wire clk,
input wire rst_n,
input wire send,
input wire [7:0] cmd,
input wire [7:0] addr,
input wire [7:0] status,
output wire tx_valid,
output wire [7:0] tx_data,
input wire tx_ready,
output reg busy
);
localparam [7:0] SOF = 8'h3C;
reg [2:0] idx;
reg [7:0] b0,b1,b2,b3,b4;
reg v;//内部valid
reg [7:0] d;//内部data
assign tx_valid = v;//只是把内部寄存器换个名字输出
assign tx_data = d;
function [7:0] sum4;//它返回 8 位结果(溢出自动截断),就是"简单加和校验"。
input [7:0] x0,x1,x2,x3;
begin sum4 = x0 + x1 + x2 + x3; end
endfunction
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
busy <= 1'b0;
idx <= 0;
b0<=0; b1<=0; b2<=0; b3<=0; b4<=0;
v <= 1'b0;
d <= 8'h00;
end else begin
v <= 1'b0;//每拍先把 tx_valid 默认清零, assign tx_valid = v;
if (!busy) begin
if (send) begin
b0 <= SOF;
b1 <= cmd;
b2 <= addr;
b3 <= status;
b4 <= sum4(SOF,cmd,addr,status);
busy <= 1'b1;
idx <= 0;
end
end else begin
if (tx_ready) begin/*你会看到这一拍:v <= 1 ⇒ tx_valid=1,d <= b[idx] ⇒ tx_data 更新成当前字节,其中assign tx_data = d;
idx=0 → d=b0 (SOF),idx=1 → d=b1 (cmd),idx=2 → d=b2 (addr),idx=3 → d=b3 (status),idx=4 → d=b4 (checksum)*/
v <= 1'b1;
case (idx)
0: d <= b0;
1: d <= b1;
2: d <= b2;
3: d <= b3;
4: d <= b4;
default: d <= 8'h00;//重要:这段代码是时序赋值,所以从仿真波形看:在某个 clk 上升沿后,tx_valid 和 tx_data 一起更新
//下游一般在同一个 clk 周期里用 tx_valid && tx_ready 采样 tx_data(这是标准做法)
endcase
if (idx == 4) begin
idx <= 0;
busy <= 1'b0;
end else begin
idx <= idx + 1'b1;
end
end
end
end
end
endmodule
改进建议:send 只在 ready/idle 接收一次
你现在是:只要 busy=0 且 send=1 就装载一帧。
如果上层把 send 拉高不止一拍,会导致每拍都重新装载同一帧(虽然 busy 立刻变 1,通常只装载一次,但边界上最好做成脉冲/握手更规范)。
6.5 locker_node.v子模块
1.核心功能
locker_node 是 128 个柜子中某一个柜子的节点电路 (参数 ADDR 决定它的地址)。它做两件事:
-
业务状态维护(本地逻辑)
-
记住有没有扫码:
scan_latch -
记住柜子是否"占用":
occupied -
记住是否报警:
alarm -
追踪门磁从开到关:
door_closed+door_closed_d -
记录一次"已开锁等待关门完成":
opened
-
-
总线通信(40bit 收请求 + 40bit 发应答)
-
每个
bus_tick来一次,节点把bus_mosi串行移入rx_sh -
当识别到"命中自己地址 + 校验对 + SOF对"时:
-
执行命令(开锁/清报警等)
-
组装应答帧到
tx_sh -
拉高
node_miso_en开始驱动回传位node_miso_bit
-
-
2.帧格式与关键常量

请求帧(主控 → 节点)
你把请求也当成 5 字节(40bit):
-
req0 = rx_sh[39:32]:SOF,必须是REQ_SOF = 8'h55 -
req1 = rx_sh[31:24]:地址,必须等于ADDR -
req2 = rx_sh[23:16]:命令 CMD -
req3 = rx_sh[15:8]:参数/保留(这版没用) -
req4 = rx_sh[7:0]:校验 checksum = req0+req1+req2+req3
你验证请求合法的三个条件是:
-
sof_ok:req0 == 8'h55 -
addr_ok:req1 == ADDR -
req_chk_ok:sum4(req0,req1,req2,req3) == req4
命令字
-
CMD_READ = 8'h01(读状态,虽然代码没分支处理它,但会回状态) -
CMD_OPEN = 8'h02(开锁) -
CMD_CLR = 8'h03(清报警)
应答帧(节点 → 主控)
你组应答也是 5 字节:
-
r0 = RESP_SOF = 8'hAA -
r1 = ADDR -
r2 = status(节点状态字节) -
r3 = 8'h00(保留) -
r4 = checksum = r0+r1+r2+r3module locker_node #(
parameter integer ADDR = 0
)(
input wire clk,
input wire rst_n,// 业务输入(课设允许模拟) input wire scan_ok, // 扫码成功(布尔) input wire door_closed, // 门磁:1关门 0开门 // 总线 input wire bus_tick, input wire bus_mosi, output wire node_miso_bit, output reg node_miso_en);
localparam [7:0] REQ_SOF = 8'h55;
localparam [7:0] RESP_SOF = 8'hAA;
localparam [7:0] CMD_READ = 8'h01;
localparam [7:0] CMD_OPEN = 8'h02;
localparam [7:0] CMD_CLR = 8'h03;reg [39:0] rx_sh; reg [39:0] tx_sh; reg [6:0] tx_cnt; reg occupied; reg alarm; reg scan_latch; reg door_closed_d; reg opened; assign node_miso_bit = tx_sh[39]; function [7:0] sum4; input [7:0] x0,x1,x2,x3; begin sum4 = x0 + x1 + x2 + x3; end endfunction wire [7:0] req0 = rx_sh[39:32]; wire [7:0] req1 = rx_sh[31:24]; wire [7:0] req2 = rx_sh[23:16]; wire [7:0] req3 = rx_sh[15:8]; wire [7:0] req4 = rx_sh[7:0]; wire req_chk_ok = (sum4(req0,req1,req2,req3) == req4); wire sof_ok = (req0 == REQ_SOF); wire addr_ok = (req1 == ADDR[7:0]);// 状态字节定义:status[0] = door_closed(门磁:1关门/0开门),status[1] = occupied(占用状态:1表示柜子目前被占用)
//status[2] = scan_latch(是否曾经扫码成功且尚未清除),status[3] = alarm(报警),status[7:4] = 0(保留)
//你在波形里看 status 的变化,就能一眼判断业务逻辑有没有按预期改。
wire [7:0] status = {4'b0000, alarm, scan_latch, occupied, door_closed};// 组应答帧 wire [7:0] r0 = RESP_SOF; wire [7:0] r1 = ADDR[7:0]; wire [7:0] r2 = status; wire [7:0] r3 = 8'h00; wire [7:0] r4 = sum4(r0,r1,r2,r3); // 业务:扫码锁存:scan_latch <= scan_latch | scan_ok//scan_ok 是"扫码成功"布尔量(可能只是短脉冲),不希望它一闪就没,所以用 scan_latch 把它锁存住
//一旦 scan_ok=1 出现过:scan_latch 就会变成 1,并保持 1,只有执行开锁命令(后面总线逻辑里)才会清掉 scan_latch <= 0
//现象:扫码一下后,status[2] 会一直为 1,直到开锁命令到来。
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
occupied <= 1'b0;
alarm <= 1'b0;
scan_latch <= 1'b0;
door_closed_d <= 1'b1;
opened <= 1'b0;
end else begin
scan_latch <= scan_latch | scan_ok;// 开锁后:检测门从开->关,切换占用状态,//当收到开锁命令后,你会设置 opened=1(在总线逻辑里做),表示:"已经给你开过锁了,接下来等你开门再关门"
//用户打开门(door_closed=0),再关门(door_closed=1),在关门那一拍,你检测到 0→1:
//把 occupied 翻转一次(表示存/取一次完成),把 opened 清零(结束这次开锁流程)
//现象:每次"开锁→关门完成"会让 occupied 翻转一次。
door_closed_d <= door_closed;
if (opened) begin
if (door_closed_d == 1'b0 && door_closed == 1'b1) begin
occupied <= ~occupied;
opened <= 1'b0;
end
end
end
end// 总线收发:40bit请求收齐后,若命中则准备40bit应答 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_sh <= 40'd0; tx_sh <= 40'd0; tx_cnt <= 0; node_miso_en <= 1'b0; end else begin if (bus_tick) begin // 接收:每个 bus_tick 把 bus_mosi 移进 rx_sh,rx_sh 是 40bit 移位寄存器//每次 bus_tick 来,说明主控"又送来了1bit",节点把这一位塞到 rx_sh 的最低位,同时整体左移,所以过了 40 个 bus_tick,rx_sh 就会包含最近的 40bit "窗口"。
//随后把它分成 5 个字节:req0=req_sh[39:32] ... req4=req_sh[7:0](前面的隐式assign语句wire [7:0] req0 = rx_sh[39:32];等)
//现象:req0..req4 会随着 rx_sh 的移位不断变化。
rx_sh <= {rx_sh[38:0], bus_mosi};// 发送:只有 node_miso_en=1 才驱动回传//你输出回传位的方式是:assign node_miso_bit = tx_sh[39];即:要发的当前位永远是 tx_sh[39](MSB 先发)。
//而发送推进也在 bus_tick 上进行:node_miso_en=1,每次 bus_tick:把 tx_sh[39] 通过 node_miso_bit 输出给总线
//然后左移一位,准备下一位,tx_cnt 从 0 数到 39,数满 40bit 后停止驱动:node_miso_en=0
//现象:在应答阶段,你会看到:node_miso_en 持续为 1(40 个 tick),tx_cnt 0→39,node_miso_bit 输出 tx_sh 的位流
if (node_miso_en) begin
tx_sh <= {tx_sh[38:0], 1'b0};
if (tx_cnt == 39) begin
tx_cnt <= 0;
node_miso_en <= 1'b0;
end else begin
tx_cnt <= tx_cnt + 1'b1;
end
end
end
if (!node_miso_en && sof_ok && req_chk_ok && addr_ok) begin//什么时候开始应答?(命中判定)
//只要当前不在发送(node_miso_en=0),且此时 rx_sh 的窗口内容看起来像一帧合法请求:req0==0x55(SOF对),checksum 对,地址等于自己,!node_miso_en && sof_ok && req_chk_ok && addr_ok
//那就立刻认为"主控发给我的请求已经收齐了",于是执行命令(OPEN/CLR),装载应答帧到 tx_sh,node_miso_en=1 开始驱动输出(从下一次 bus_tick 起)
if (req2 == CMD_OPEN) begin
scan_latch <= 1'b0;
opened <= 1'b1;
end else if (req2 == CMD_CLR) begin
alarm <= 1'b0;
end// 准备应答帧 tx_sh <= {r0,r1,r2,r3,r4}; tx_cnt <= 0; node_miso_en <= 1'b1;//命令执行逻辑,如果主控发来 CMD_OPEN:scan_latch 清零(表示"扫码已被消费/使用"),opened 置 1(进入"已开锁等待关门完成"状态)
//如果主控发来 CMD_CLR:清报警 alarm<=0;对于 CMD_READ:你没有额外动作,但仍然会回 status(因为应答帧总会被装载发送)
end
end
endendmodule
3.注意
"当刚好收满一帧...这里用请求SOF对齐的简化办法...只要满足条件就立刻装载应答"
也就是说:你没有用一个"严格的 40bit 计数器"去判断"我已经收够40bit了" ,而是用"滑动窗口"方式:只要某一刻 rx_sh 恰好呈现一帧合法请求,就触发应答。
优点:
- 简单,不用额外计数状态机
风险:
-
如果总线上比特流错位/噪声/上电随机,滑窗可能偶然匹配(概率小但存在)
-
多节点共享回传时,更稳的做法通常会配合主控的"固定时序窗口"(只有在 RX 窗口才允许 node_miso_en)
你现在的系统若主控严格"先发40bit请求,再切RX收40bit应答",那这个简化办法一般能跑通课设。
6.6 bus_master.v
1.核心功能
bus_master 是一个串行总线主控(类似简化 SPI,但只有 MOSI/MISO,没有 SCLK/CS):
-
你给它一次
start=1,并提供tx_payload[39:0] -
它进入
busy=1,内部先进行 TX阶段:每个 bit 发一次(共 40 bit) -
TX发完自动切到 RX阶段 :每个 bit 采一次
bus_miso(共 40 bit) -
收完后:
-
rx_payload给出完整 40bit 接收结果 -
done拉高 1 个 clk(脉冲) -
busy回到 0
-
同时它对外提供:
-
bus_tick:每个 bit 边界打一个 1clk 脉冲(可当作"位时钟") -
bus_mosi:当前要输出的发送位(MSB先)
说明:(1)设计点(设计选择)
点A:TX 与 RX 是"分两段"顺序进行
也就是:先把 40bit 全发完,再开始收 40bit。
如果你希望像 SPI 那样"边发边收(全双工)",那结构会不同:TX/RX 会同一 tick 同时 shift。
点B:没有显式时钟线/片选线
你只输出 bus_tick(脉冲)和 bus_mosi,没有 bus_clk 方波,也没有 cs_n。
这意味着从机必须用同样的 bus_tick 做采样和推进,或者你另有外部逻辑生成"真实物理时钟"。
关于SPI接口:https://blog.csdn.net/u010783226/article/details/108753904
2.核心状态机:busy + phase + bitcnt
这模块的"状态"其实就两个变量:
-
busy:0=空闲,1=正在处理事务 -
phase:0=TX阶段,1=RX阶段 -
bitcnt:当前阶段已经处理了第几个 bit(0..39)
另外两个移位寄存器:
-
sh_tx:待发送移位寄存器 -
sh_rx:接收累积移位寄存器module bus_master #(
parameter integer CLK_HZ = 100_000_000,
parameter integer BUS_BIT_HZ = 2_000_000
)(
input wire clk,
input wire rst_n,input wire start, input wire [39:0] tx_payload, output reg busy, output reg done, output reg [39:0] rx_payload, output reg bus_tick, output wire bus_mosi, // 当前要发送的bit(MSB先) input wire bus_miso);
//"总线位速率"生成
//上面:parameter integer BUS_BIT_HZ = 2_000_000,每秒进行 2,000,000 次 bit(2Mbps)
localparam integer DIV = (CLK_HZ / BUS_BIT_HZ);//每个 bit 需要的 clk 周期数 ≈ DIV = CLK_HZ / BUS_BIT_HZ
reg [$clog2(DIV):0] divcnt;//divcnt 从 0 数到 49,触发一次"位边界事件"reg [6:0] bitcnt; reg phase; // 0=TX, 1=RX reg [39:0] sh_tx; reg [39:0] sh_rx; assign bus_mosi = sh_tx[39]; always @(posedge clk or negedge rst_n) begin//复位 busy <= 1'b0; done <= 1'b0; rx_payload <= 40'd0; bus_tick <= 1'b0; divcnt <= 0; bitcnt <= 0; phase <= 1'b0; sh_tx <= 40'd0; sh_rx <= 40'd0; end else begin done <= 1'b0; bus_tick <= 1'b0; if (!busy) begin//阶段A:空闲等待 start (busy=0),平时 busy=0 就是"站岗",一旦 start=1://"装弹":sh_tx <= tx_payload,"清空收件箱":sh_rx <= 0;进入事务:busy=1;从 TX 阶段开始:phase=0;从第0位开始:bitcnt=0
if (start) begin
busy <= 1'b1;
phase <= 1'b0;
bitcnt <= 0;
divcnt <= 0;
sh_tx <= tx_payload;
sh_rx <= 40'd0;
end
end else begin //阶段B:忙状态(busy=1),由 bus_tick 驱动推进,只要 busy=1,每当 divcnt 数到 DIV-1,就触发一次 bus_tick,然后做 TX 或 RX 的一步。
if (divcnt == DIV-1) begin//bus_tick 每隔 DIV 个 clk 出现 1 拍脉冲(bit节拍),所有 shift / bitcnt / phase 的推进都只发生在 bus_tick 那一拍
divcnt <= 0;
bus_tick <= 1'b1;if (!phase) begin//B1)TX阶段(phase=0):发40位(MSB先),总是把 sh_tx 的最高位当成下一位要发出去的 bit。如上assign bus_mosi = sh_tx[39];//每个 bus_tick:左移一位(MSB-first 推出),TX阶段共 40 次 bus_tick,当 bitcnt 做到 39(第40位已发送):phase 变成 1(切到 RX),bitcnt 清零(准备收第 0 位)
sh_tx <= {sh_tx[38:0], 1'b0};
if (bitcnt == 39) begin
phase <= 1'b1;
bitcnt <= 0;
end else begin
bitcnt <= bitcnt + 1'b1;
end
end else begin//B2)RX阶段(phase=1):收40位(把 bus_miso shift 进来)
//每次 bus_tick 采样一次 bus_miso,把采样到的位塞到 sh_rx 的最低位(右侧),旧的内容左移一位(向高位挪)
//最后收满40位时:事务结束:busy=0(回空闲),done=1(打一拍完成脉冲),rx_payload 锁存最终接收的 40bit,phase 回到 0(下次从 TX 开始)
sh_rx <= {sh_rx[38:0], bus_miso};
if (bitcnt == 39) begin
busy <= 1'b0;
done <= 1'b1;
rx_payload <= {sh_rx[38:0], bus_miso};
bitcnt <= 0;
phase <= 1'b0;
end else begin
bitcnt <= bitcnt + 1'b1;
end
end
end else begin
divcnt <= divcnt + 1'b1;
end
end
end
end
endmodule
6.7 locker_main_ctrl.v
1.核心功能
locker_main_ctrl 是"128柜子系统"的大脑,它做三件核心事:
-
轮询读取每个节点状态(CMD_READ)
通过总线主机
bus_master一次读一个节点(地址addr_ptr) -
维护四张状态表(map)
把每次读取到的
STATUS字节拆成 4 个 bit,写进:-
door_map[i]:门磁状态 -
occ_map[i]:占用状态 -
scan_map[i]:扫码锁存状态 -
alarm_map[i]:报警状态
-
-
根据策略触发开锁(CMD_OPEN)
-
若 PC 发来紧急开锁:
urgent_open_valid→ 优先 open 指定urgent_open_addr -
否则轮询过程中遇到
scan_map[addr_ptr]=1→ 自动 open 该柜
-
2.总线帧格式(和节点一致)
请求帧(主控发)是 5 字节(40bit):
{REQ_SOF, addr, cmd, 8'h00, checksum}
其中:
-
REQ_SOF = 8'h55 -
cmd = CMD_READ(01) / CMD_OPEN(02) -
checksum = sum4(REQ_SOF, addr, cmd, 8'h00)
应答帧(节点回)是 5 字节:
{RESP_SOF, addr, status, 8'h00, checksum}
其中:
-
RESP_SOF = 8'hAA -
status:bit0 door, bit1 occ, bit2 scan, bit3 alarm -
checksum = sum4(r0,r1,r2,r3)
3.总览流程与接口信号
3.1 总览流程
四大模块:
1)四大模块
locker_top.v:顶层(系统集成)
它就是顶层:把 UART、PC协议、主控、总线、128节点全部实例化并连线,还做了两件"现实演示用"的事:
-
把 PC 命令转成"模拟输入" :例如
SET_SCAN变成scan_pulse_in[addr]一拍;SET_DOOR改door_closed_in[addr] -
把内部 map 状态打包回 PC :形成
STATUS回包
locker_main_ctrl:主控调度器(大脑)
它不管"每一位怎么串行移",它只做决策:
-
轮询
addr_ptr=0..127 -
对每个地址发
CMD_READ -
收到应答后更新四张表:
door_map/occ_map/scan_map/alarm_map -
如果发现需要开锁(自动或 urgent),再发
CMD_OPEN
它和 bus_master 之间的接口,就是下面模块介绍里的握手。
bus_master:串行发动机(总线时序产生器)
它把主控给的 bus_tx_payload(40bit) 真的"按位"打出去,并在规定节拍下收回 40bit:
-
产生
bus_tick:每个 bit 来一次 1clk 脉冲 -
发送:
bus_mosi(MSB先) -
接收:
bus_miso -
事务状态:
busy/done/rx_payload
主控只管"发什么40bit",bus_master管"怎么把40bit一位一位发完并收回来"。
locker_node:每个柜子的节点(柜体的小脑)
每个 node 在总线上做三件事:
-
跟着
bus_tick把bus_mosi移进来,攒够/对齐出一帧请求 -
如果发现请求
addr==自己ADDR且校验正确,就:-
执行命令(READ/OPEN/CLR...)
-
装载 40bit 应答帧到
tx_sh
-
-
发送应答时把
node_miso_en=1,输出node_miso_bit
2)接口信号
(1)与 bus_master 的握手
-
主控输出:
-
bus_start:打一拍,告诉 bus_master "开始一次40bit TX + 40bit RX" -
bus_tx_payload:这次要发的 40bit 请求帧
-
-
主控输入:
-
bus_busy:bus_master 正在干活 -
bus_done:一次事务完成(打一拍) -
bus_rx_payload:收到的 40bit 应答帧
-
主控的典型使用方式是:
-
只有
!bus_busy才能bus_start=1 -
等
bus_done=1再去解析bus_rx_payload
(2) PC 紧急开锁
-
输入:
-
urgent_open_valid:PC 请求开某柜(通常是一拍脉冲) -
urgent_open_addr:要开的柜号
-
-
输出:
urgent_open_taken:主控"接单成功"的确认脉冲(打一拍)
(3) 四张 map
都是 [N_NODES-1:0] 的 bit 向量,i 号柜对应 bit i。( parameter integer N_NODES = 128)
4.状态机(st)结构:READ → 解析 → 决策 → OPEN → 回到READ
你定义了 5 个状态:
-
ST_SEND_READ:准备发送 READ 请求 -
ST_WAIT_READ:等待 bus_done,然后解析应答并更新 map -
ST_DECIDE:决定下一步:紧急 open?自动 open?还是继续轮询下一个地址 -
ST_SEND_OPEN:发送 OPEN 请求 -
ST_WAIT_OPEN:等待 OPEN 完成,再继续轮询
这就是主控的大脑循环。详见代码注释。
module locker_main_ctrl #(
parameter integer N_NODES = 128
)(
input wire clk,
input wire rst_n,
// 总线主机控制
output reg bus_start,
output reg [39:0] bus_tx_payload,
input wire bus_busy,
input wire bus_done,
input wire [39:0] bus_rx_payload,
// PC urgent open
input wire urgent_open_valid,
input wire [7:0] urgent_open_addr,
output reg urgent_open_taken,
// 状态表给上层/PC读
output reg [N_NODES-1:0] door_map,
output reg [N_NODES-1:0] occ_map,
output reg [N_NODES-1:0] scan_map,
output reg [N_NODES-1:0] alarm_map
);
localparam [7:0] REQ_SOF = 8'h55;
localparam [7:0] RESP_SOF = 8'hAA;
localparam [7:0] CMD_READ = 8'h01;
localparam [7:0] CMD_OPEN = 8'h02;
reg [7:0] addr_ptr;
reg [7:0] target_addr;
reg [2:0] st;
localparam ST_SEND_READ = 3'd0,
ST_WAIT_READ = 3'd1,
ST_DECIDE = 3'd2,
ST_SEND_OPEN = 3'd3,
ST_WAIT_OPEN = 3'd4;
function [7:0] sum4;
input [7:0] x0,x1,x2,x3;
begin sum4 = x0 + x1 + x2 + x3; end
endfunction
//应答解析:从 bus_rx_payload 拆出 r0..r4 并校验,bus_done 来时,你把回来的 40bit 当成 5 个字节
//resp_ok 要求:帧头 r0 必须是 0xAA,校验和必须对
//r2 是状态字节,把 0..3 位拆出来写入 map,后面有door_map[r1] <= resp_door;occ_map[r1] <= resp_occ;scan_map[r1] <= resp_scan;等
wire [7:0] r0 = bus_rx_payload[39:32];
wire [7:0] r1 = bus_rx_payload[31:24];
wire [7:0] r2 = bus_rx_payload[23:16]; // STATUS
wire [7:0] r3 = bus_rx_payload[15:8];
wire [7:0] r4 = bus_rx_payload[7:0];
wire resp_ok = (r0 == RESP_SOF) && (sum4(r0,r1,r2,r3) == r4);
wire resp_door = r2[0];
wire resp_occ = r2[1];
wire resp_scan = r2[2];
wire resp_alarm= r2[3];
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
bus_start <= 1'b0;
bus_tx_payload <= 40'd0;
door_map <= {N_NODES{1'b1}};
occ_map <= {N_NODES{1'b0}};
scan_map <= {N_NODES{1'b0}};
alarm_map<= {N_NODES{1'b0}};
addr_ptr <= 8'd0;
target_addr <= 8'd0;
urgent_open_taken <= 1'b0;
st <= ST_SEND_READ;
end else begin
bus_start <= 1'b0;
urgent_open_taken <= 1'b0;
case (st)
ST_SEND_READ: begin//ST_SEND_READ:发 READ 请求给 addr_ptr
if (!bus_busy) begin
bus_tx_payload <= {REQ_SOF, addr_ptr, CMD_READ, 8'h00, sum4(REQ_SOF, addr_ptr, CMD_READ, 8'h00)};
bus_start <= 1'b1;
st <= ST_WAIT_READ;
end
end
//主控看看 bus_master是否空闲:bus_busy==0,空闲就把这一帧请求准备好:addr = addr_ptr,cmd = CMD_READ,
//bus_start=1 打一拍告诉 bus_master 开始干,然后主控自己进入等待状态 ST_WAIT_READ
ST_WAIT_READ: begin//ST_WAIT_READ:等 bus_done,然后更新四张 map
if (bus_done) begin
if (resp_ok && (r1 < N_NODES[7:0])) begin
door_map[r1] <= resp_door;
occ_map[r1] <= resp_occ;
scan_map[r1] <= resp_scan;
alarm_map[r1] <= resp_alarm;
end
st <= ST_DECIDE;
end
end
//bus_done 到来:这次"读某节点"的事务结束,bus_rx_payload 已经稳定可用,如果应答合法 resp_ok=1,并且 r1 地址落在范围内:parameter integer N_NODES = 128
//把节点上报的 status 四个 bit 写进对应 map 的 bit 位 map[r1],不管应答对不对,主控都会进入 ST_DECIDE 做下一步决策
ST_DECIDE: begin//ST_DECIDE:决定下一步(紧急 open 优先,其次扫码 open,否则继续轮询)
if (urgent_open_valid && (urgent_open_addr < N_NODES[7:0])) begin//优先处理 PC 紧急开锁,urgent_open_valid=1:主控立刻把 target_addr 设成该柜号
//urgent_open_taken 打一拍,告诉 PC:我接单了(PC接口信号,输出:urgent_open_taken:主控"接单成功"的确认脉冲)跳到 ST_SEND_OPEN 去发 OPEN,这就是"插队",比轮询扫码更优先。
target_addr <= urgent_open_addr;
urgent_open_taken <= 1'b1;
st <= ST_SEND_OPEN;
end else if (addr_ptr < N_NODES[7:0] && scan_map[addr_ptr]) begin//否则,如果当前轮询地址出现扫码锁存 scan=1,则自动开锁。
//没有紧急任务,就看"当前柜 addr_ptr 是否有人扫码":如果 scan_map[addr_ptr]==1:说明该柜节点上报"扫码成功且未消费",主控就把它设为 target_addr,准备发 OPEN,这是"扫码触发自动开锁"。
target_addr <= addr_ptr;
st <= ST_SEND_OPEN;
end else begin//都没有,就 addr_ptr++ 继续轮询
//没紧急、当前也没扫码,主控就去轮询下一个柜号,波形现象:你会看到 addr_ptr 周期性 0→1→2→...→127→0 循环。
addr_ptr <= (addr_ptr == N_NODES-1) ? 8'd0 : (addr_ptr + 1'b1);
st <= ST_SEND_READ;
end
end
ST_SEND_OPEN: begin//ST_SEND_OPEN:发 OPEN 请求给 target_addr
//等 bus_master 空闲,打包 OPEN 帧(地址是 target_addr),bus_start 打一拍,转去 ST_WAIT_OPEN
if (!bus_busy) begin
bus_tx_payload <= {REQ_SOF, target_addr, CMD_OPEN, 8'h00, sum4(REQ_SOF, target_addr, CMD_OPEN, 8'h00)};
bus_start <= 1'b1;
st <= ST_WAIT_OPEN;
end
end
ST_WAIT_OPEN: begin//ST_WAIT_OPEN:等 OPEN 完成,然后继续轮询
if (bus_done) begin//OPEN 事务完成后,不在这里强行改 map,因为节点那边会清 scan_latch,主控下一轮 READ 自然会读到 scan=0
//主控直接 addr_ptr++,继续轮询,这是一种"最终一致性"的做法:状态以 READ 为准,不在 OPEN 后猜测状态。
addr_ptr <= (addr_ptr == N_NODES-1) ? 8'd0 : (addr_ptr + 1'b1);
st <= ST_SEND_READ;
end
end
default: st <= ST_SEND_READ;
endcase
end
end
endmodule
5. 波形时应该看到的"完整故事线"(一眼验收)
给你三个典型场景:
场景A:纯轮询(没人扫码、无紧急)
-
st:SEND_READ → WAIT_READ → DECIDE → SEND_READ ...
-
addr_ptr:0→1→2→...→127→0
-
每次 bus_done 都会更新一格 map(若 resp_ok)
场景B:某柜扫码(scan_map[k]=1)
-
轮询到 k 后 READ 更新
scan_map[k]=1 -
DECIDE 看到
scan_map[addr_ptr]=1 -
st 跳去 SEND_OPEN/WAIT_OPEN
-
OPEN 完成后回到轮询
-
下一轮读到该柜 scan 清零

场景C:PC 紧急开锁插队
-
不管轮询到哪,DECIDE 都优先处理 urgent
-
urgent_open_taken会打一拍 -
target_addr=urgent_open_addr
-
发 OPEN 给该柜,然后继续轮询
6.8 locker_top.v
PC 通过 UART 发命令 → FPGA 解析成 PC 包 →(可选)触发紧急开锁/模拟扫码门磁 → 主控轮询节点并维护 map → PC 再通过 UART 收到回包状态。
它内部有两条"通信链"同时存在:
-
PC ↔ FPGA(UART 包) :
uart_rx/uart_tx + pc_packet_rx/pc_packet_tx -
主控 ↔ 128节点(总线40bit) :
bus_master + locker_main_ctrl + locker_node[0..127]module locker_top #(
parameter integer CLK_HZ = 100_000_000,
parameter integer BAUD = 115200,
parameter integer BUS_BIT_HZ = 2_000_000,
parameter integer N_NODES = 128
)(
input wire clk,
input wire rst_n,// PC UART input wire uart_rx_i, output wire uart_tx_o);
// UART
wire [7:0] urx_data;
wire urx_valid;//UART 接收链:把串口电平变成字节流(urx_data/urx_valid)
//PC 串口线进来是 uart_rx_i(一根线的电平波形),uart_rx 每收到一个完整字节,就输出:
//urx_data[7:0]:这个字节的值,urx_valid=1:打一拍告诉下游"这个字节有效"
uart_rx #(.CLK_HZ(CLK_HZ), .BAUD(BAUD)) u_rx (
.clk(clk), .rst_n(rst_n),
.rx(uart_rx_i),
.data(urx_data), .valid(urx_valid)
);wire [7:0] utx_data; wire utx_valid; wire utx_ready; uart_tx #(.CLK_HZ(CLK_HZ), .BAUD(BAUD)) u_tx ( .clk(clk), .rst_n(rst_n), .tx(uart_tx_o), .data(utx_data), .valid(utx_valid), .ready(utx_ready) ); wire pc_pkt_valid; wire [7:0] pc_cmd, pc_addr, pc_data;//PC 包解析链:把 5 字节拼成 "pc_cmd/pc_addr/pc_data",pc_packet_rx 会连续吃 5 次 urx_valid:第1字节:SOF,第2字节:cmd,第3字节:addr,第4字节:data,第5字节:checksum
//校验通过那一拍,它输出:pc_pkt_valid=1,pc_cmd/pc_addr/pc_data 更新,波形里应看到:pc_pkt_valid 只在"第5字节校验通过"那拍跳一下。pc_packet_rx u_pcrx( .clk(clk), .rst_n(rst_n), .rx_data(urx_data), .rx_valid(urx_valid), .pkt_valid(pc_pkt_valid), .cmd(pc_cmd), .addr(pc_addr), .data(pc_data) ); // maps wire [N_NODES-1:0] door_map, occ_map, scan_map, alarm_map; // emulate inputs for nodes (可由PC命令设置) reg [N_NODES-1:0] door_closed_in; reg [N_NODES-1:0] scan_pulse_in;//你没有真的接扫描头/门磁,而是在 locker_top 里用两个寄存器向量模拟:door_closed_in[N_NODES-1:0]:每个柜子的门磁输入(默认全1=关门),scan_pulse_in[N_NODES-1:0]:每个柜子的扫码脉冲(每次只打一拍)
integer ii; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin door_closed_in <= {N_NODES{1'b1}}; // 默认都关门 scan_pulse_in <= {N_NODES{1'b0}}; end else begin scan_pulse_in <= {N_NODES{1'b0}}; // 每拍把 scan_pulse_in 清零(确保它是"1clk 脉冲") if (pc_pkt_valid) begin//PC 命令驱动模拟输入//当 PC 发来一个合法包(pc_pkt_valid=1):cmd=0x03:把某个柜子的 scan_pulse_in[addr] 拉高1拍(模拟扫码成功);cmd=0x04:把某个柜子的 door_closed_in[addr] 直接改成 data[0](模拟门开/关)
//这样你在 PC 上发指令就能"操控仿真/板子行为",非常适合课设演示。
if (pc_cmd == 8'h03 && pc_addr < N_NODES[7:0]) begin
scan_pulse_in[pc_addr] <= 1'b1; // SET_SCAN
end else if (pc_cmd == 8'h04 && pc_addr < N_NODES[7:0]) begin
door_closed_in[pc_addr] <= pc_data[0]; // SET_DOOR
end
end
end
end// urgent open from PC reg urgent_valid; reg [7:0] urgent_addr; wire urgent_taken;//紧急开锁通道:PC cmd=0x02 → urgent_valid/urgent_addr → 主控插队 OPEN
//PC 如果发来 pc_cmd=0x02(你定义为紧急开锁):urgent_valid 被置 1(一直保持),urgent_addr 记住要开的柜号
//主控 locker_main_ctrl 在 DECIDE 状态看到 urgent_valid=1 就会接单:并输出 urgent_taken=1 打一拍,这拍 urgent_taken 回来后,top 把 urgent_valid 清 0
//→ 完成一次"可靠握手":PC 触发 → 主控确认接到任务 → top 清除请求
//波形现象:urgent_valid 会维持为1,直到 urgent_taken 出现那一拍才清零(不怕丢脉冲)。
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
urgent_valid <= 1'b0;
urgent_addr <= 8'd0;
end else begin
if (pc_pkt_valid && pc_cmd == 8'h02 && pc_addr < N_NODES[7:0]) begin
urgent_valid <= 1'b1;
urgent_addr <= pc_addr;
end
if (urgent_taken) begin
urgent_valid <= 1'b0;
end
end
end// bus master wire bus_tick; wire bus_mosi; wire bus_miso; wire bus_start; wire [39:0] bus_tx_payload; wire bus_busy; wire bus_done; wire [39:0] bus_rx_payload;//总线主机 bus_master:把"主控的40bit请求"变成 bus_tick/bus_mosi,并收 bus_miso
//主控给 bus_start 打一拍 + bus_tx_payload 一帧,bus_master 在 busy=1 期间:
//产生 bus_tick(每bit一次),用 bus_mosi 串行发出 40bit,再用 bus_miso 串行收回 40bit,收完:bus_done=1 打一拍,bus_rx_payload 更新为收到的一帧
bus_master #(.CLK_HZ(CLK_HZ), .BUS_BIT_HZ(BUS_BIT_HZ)) u_busm (
.clk(clk), .rst_n(rst_n),
.start(bus_start),
.tx_payload(bus_tx_payload),
.busy(bus_busy),
.done(bus_done),
.rx_payload(bus_rx_payload),
.bus_tick(bus_tick),
.bus_mosi(bus_mosi),
.bus_miso(bus_miso)
);
//主控 locker_main_ctrl:轮询 + map维护 + 决策 OPEN,不断对 addr_ptr 发 READ,bus_done 后解析应答写进 door_map/occ_map/scan_map/alarm_map
//DECIDE 阶段:有 urgent_valid 就插队 OPEN urgent_addr,否则如果 scan_map[addr_ptr]=1 就 OPEN addr_ptr,否则 addr_ptr++ 继续轮询
//door_map/occ_map/scan_map/alarm_map 是 top 用来"回 PC 状态"的核心数据源。
locker_main_ctrl #(.N_NODES(N_NODES)) u_main (
.clk(clk), .rst_n(rst_n),
.bus_start(bus_start),
.bus_tx_payload(bus_tx_payload),
.bus_busy(bus_busy),
.bus_done(bus_done),
.bus_rx_payload(bus_rx_payload),
.urgent_open_valid(urgent_valid),
.urgent_open_addr(urgent_addr),
.urgent_open_taken(urgent_taken),
.door_map(door_map),
.occ_map(occ_map),
.scan_map(scan_map),
.alarm_map(alarm_map)
);////128 个节点生成:每个节点吃同一条总线,地址不同 wire [N_NODES-1:0] node_miso_bit; wire [N_NODES-1:0] node_miso_en;//所有节点都能看到主控发的同一条 bus_mosi 位流与 bus_tick,每个节点内部用 ADDR 判断"这帧是不是发给我"命中后它会准备应答,并把:
//node_miso_en[gi]=1(表示我在驱动回包),node_miso_bit[gi](当前输出位)推到总线上
genvar gi;
generate
for (gi=0; gi<N_NODES; gi=gi+1) begin : GEN_N
locker_node #(.ADDR(gi)) u_node (
.clk(clk), .rst_n(rst_n),
.scan_ok(scan_pulse_in[gi]),
.door_closed(door_closed_in[gi]),
.bus_tick(bus_tick),
.bus_mosi(bus_mosi),
.node_miso_bit(node_miso_bit[gi]),
.node_miso_en(node_miso_en[gi])
);
end
endgenerate//MISO 合并:把"某一个节点的回包"合成 bus_miso
assign bus_miso = |(node_miso_bit & node_miso_en);
//这是一个"有线或"式的合并逻辑:node_miso_bit & node_miso_en:只有 en=1 的节点,其 bit 才会被保留;其他节点被屏蔽成 0,再对 128 路做 |:得到最终的 bus_miso
//重要前提:任何时刻只能有一个节点 node_miso_en=1。否则两个节点同时驱动就会发生"线与/线或冲突",bus_miso 会变得不可预测。//把 4 张 map 的某一位打包成 1 个字节回给 PC,pc_addr:PC 命令里带的柜号 0~127,door_map/occ_map/scan_map/alarm_map:都是 [N_NODES-1:0] 的 bit 向量
//{...} 是拼接:拼成 8bit 的 status_byte,bit7..bit4 固定为 0,外层三目运算 (pc_addr < N_NODES) ? ... : 8'h00 是为了 防越界:如果 PC 给了非法 addr,就回 0。
wire [7:0] status_byte =
(pc_addr < N_NODES[7:0]) ? {4'b0000,
alarm_map[pc_addr], scan_map[pc_addr], occ_map[pc_addr], door_map[pc_addr]} : 8'h00;reg resp_send; wire resp_busy;//只要收到一个合法 PC 包(pc_pkt_valid=1)且上一次 PC 回包发送器不忙(resp_busy=0),就打一拍 resp_send=1 触发 pc_packet_tx 组包并开始吐字节
//pc_packet_tx 把 {SOF, cmd, addr, status, checksum} 逐字节输出:用 utx_valid/utx_data 与 utx_ready 握手,uart_tx 把这些字节变成串口波形从 uart_tx_o 发出去
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
resp_send <= 1'b0;
end else begin
resp_send <= 1'b0;
if (pc_pkt_valid && !resp_busy) begin
resp_send <= 1'b1;
end
end
endpc_packet_tx u_pct( .clk(clk), .rst_n(rst_n), .send(resp_send), .cmd(pc_cmd), .addr(pc_addr), .status(status_byte), .tx_valid(utx_valid), .tx_data(utx_data), .tx_ready(utx_ready), .busy(resp_busy) );endmodule
调试·
你波形里 bus_rx_payload 头是 0x54,而正常应答头应该是 0xAA。
0xAA = 1010_1010
整体 右移/错位 1bit 就很容易变成 0101_0100 = 0x54。
根因:locker_node 识别请求帧"晚了一拍"才打开 node_miso_en,导致 bus_master 在 RX 的第一个 bit 就开始采样,但节点还没开始驱动 → 采样提前 1bit → 全帧错位。
修改后代码:

//关键改动
// ===== 总线收发:用 rx_sh_next 先算好,再写入 rx_sh =====
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rx_sh <= 40'd0;
tx_sh <= 40'd0;
tx_cnt <= 0;
node_miso_en <= 1'b0;
end else begin
// 默认:如果没 tick,rx_sh_next==rx_sh,相当于保持不变
rx_sh <= rx_sh_next;
if (bus_tick) begin
// 发应答(如果正在发送)
if (node_miso_en) begin
tx_sh <= {tx_sh[38:0], 1'b0};
if (tx_cnt == 39) begin
tx_cnt <= 0;
node_miso_en <= 1'b0;
end else begin
tx_cnt <= tx_cnt + 1'b1;
end
end
end
// ✅ 命中判定:用"rx_sh_next拆出来"的字段,确保不晚一拍
// 建议加 bus_tick 限定:只在新bit到来的那一拍尝试"装载应答"
if (!node_miso_en && bus_tick && sof_ok_n && req_chk_ok_n && addr_ok_n) begin
// 执行命令(把 req2 改成 req2_n)
if (req2_n == CMD_OPEN) begin
scan_latch <= 1'b0;
opened <= 1'b1;
end else if (req2_n == CMD_CLR) begin
alarm <= 1'b0;
end
// 准备应答帧(下一次bus_tick开始输出第1bit)
tx_sh <= {r0,r1,r2,r3,r4};
tx_cnt <= 0;
node_miso_en <= 1'b1;
end
end
end
通过网盘分享的文件:lock.zip
链接: https://pan.baidu.com/s/1wcFH1WZ3YN1SpjkXyKlCfg?pwd=1234 提取码: 1234
修改方案A(推荐):改 locker_node.v ------ 用 rx_sh_next 当拍判定,改 locker_node.v 一个文件。
目标:在"最后 1bit 刚移入那一拍"就能命中请求并立刻 node_miso_en<=1,这样下一次 bus_tick 输出的就是应答第 1bit,bus_master 采样就对齐了。
A1)在 locker_node 里加"下一拍移位值"的组合线(替换你原来的 req0..req4)
A2)在 always 块里:接收用 rx_sh_next,命中判断也用 *_n
仿真结果
第 1 幕:主控发起一次轮询 READ(主控 → 总线)
角色: locker_main_ctrl + bus_master
locker_main_ctrl在轮询到某个地址(你这张图是 addr=0x06)时,先组一个 40bit 请求帧:
-
请求帧格式:
{REQ_SOF, addr, CMD_READ, 8'h00, chk} -
你波形里对应的是(看
req*_n那几行):
req0_n=8'h55 (SOF)
req1_n=8'h06 (ADDR=06)
req2_n=8'h01 (CMD_READ)
req3_n=8'h00 (DATA=00)
req4_n=8'h5C(CHK=55+06+01+00=5C)
2.主控对 bus_master 打一拍开始:
-
bus_start=1(1clk 脉冲) -
bus_tx_payload装载这 40bit 请求 -
bus_busy进入忙态
第 2 幕:bus_master 按位吐出请求(bus_tick / bus_mosi)
角色: bus_master
-
bus_master每到一个比特节拍就产生:-
bus_tick=1(1clk 脉冲:标志"这一拍是一个 bit") -
bus_mosi输出当前要发的 bit(MSB 先)
-
你波形里能看到 bus_tick 周期性脉冲,bus_mosi 随着帧内容在跳变------这就是"把 40bit 请求拆成 40 次 bit 发送"。
第 3 幕:节点边收边移位,"收齐那一拍"立刻认出来(关键点)
角色: locker_node
每次 bus_tick=1 时,节点做:
-
rx_sh_next = {rx_sh[38:0], bus_mosi}(把这一 bit 移进来) -
然后用
rx_sh_next拆出字节:req0_n..req4_n -
并且立即得到三个判定信号:
在你图里,这三个都已经是 1 了:
-
sof_ok_n=1(req0_n==8'h55) -
addr_ok_n=1(req1_n==ADDR==8'h06) -
req_chk_ok_n=1(sum4(req0_n..req3_n)==req4_n==8'h5C)
这说明:请求帧在这一拍"刚好收齐",节点已经认出"这是发给我(06号)的合法帧"。
第 4 幕:节点准备应答:装载 tx_sh + 拉高 node_miso_en
角色: locker_node
4.1 先解释你最困惑的点:"为啥 tx_sh 是 0?"
原因 A(最常见):你截到的时刻节点还没进入"发送态"。
-
只有当节点决定要回包时,才会执行:
-
tx_sh <= {r0,r1,r2,r3,r4}; -
node_miso_en <= 1;
-
-
在此之前,
tx_sh保持复位后的值(常常就是 0),一点毛病没有。
原因 B:你看的光标正卡在"判定成立"那一拍附近,但非阻塞赋值更新要到时序块结束才反映到波形上。
- 你把光标往后挪 半个 clk / 一个 delta ,往往就能看到
tx_sh从 0 变成应答帧。
判断到底有没有进入发送态,看哪个信号最准?
看
node_miso_en和tx_cnt:
-
node_miso_en拉高后,会持续一段时间(发 40bit) -
tx_cnt会从 0 数到 39
只要你看到 node_miso_en 连续为 1 且 tx_cnt 在走,那就说明 tx_sh 已经装载并在移位发送了。
4.2 节点应答帧内容(你这张图里已经算出来了)
节点应答帧格式:{RESP_SOF, addr, status, 8'h00, chk}
你图里对应的"应答字节常量"已经非常清楚:
-
r0 = 8'hAA(RESP_SOF) -
r1 = 8'h06(ADDR=06) -
r2 = 8'h01(status=01) -
r3 = 8'h00 -
r4 = 8'hB1(AA+06+01+00=B1)
其中 status=8'h01 的含义(你的注释定义):
-
bit0
door_closed=1 -
bit1
occupied=0 -
bit2
scan_latch=0 -
bit3
alarm=0
所以 01 完全合理:门关着,没占用、没扫码、没报警。
第 5 幕:总线回读:bus_master 采样 bus_miso 拼成 bus_rx_payload
角色: bus_master + 顶层 bus_miso = |(node_miso_bit & node_miso_en)
当 node_miso_en=1 时:
-
节点开始驱动
node_miso_bit = tx_sh[39](MSB先) -
顶层把所有节点的输出"线与/线或"合并成
bus_miso -
bus_master在 RX 阶段每个bus_tick采样bus_miso,移进自己的sh_rx -
40bit 收完:
-
bus_done=1(打一拍) -
bus_rx_payload变成{AA,06,01,00,B1}
-
然后 locker_main_ctrl 在 ST_WAIT_READ 里看到 bus_done:
-
resp_ok=1(SOF=AA 且校验对) -
更新
door_map[6]=1, occ_map[6]=0, scan_map[6]=0, alarm_map[6]=0 -
进入下一轮轮询

上面现在盯的这个 tx_sh 很可能是"某一个 node 实例"的 tx_sh,但这拍并没有命中它的地址,所以它当然还是 0。

不进行详细分析,感兴趣可以自己研究哈,整体对叭这里不分析了💔,
通过网盘分享的文件:lock.zip
链接: https://pan.baidu.com/s/1wcFH1WZ3YN1SpjkXyKlCfg?pwd=1234 提取码: 1234
这张波形结论:
对的,你这张图已经同时满足:
-
请求帧在节点侧:
-
sof_ok_n=1 -
addr_ok_n=1 -
req_chk_ok_n=1
-
-
应答帧字节已经被正确"定义出来":
-
r0..r4 = AA 06 01 00 B1 -
校验关系也对:
B1 = AA+06+01+00
-
二.乒乓球游戏电路设计

2.1 翻译
硬件资源
-
输入:两个按键(左方 L、右方 R)
-
输出:8 个 LED(LED[7:0])
-
工作节拍:100 Hz(也就是 10 ms 一拍)
游戏规则(工程语言)
-
初始:最左边 LED 亮(球在左端)。
-
发球 :按下某一方按键表示"击球/发球"(题目初始在左端,所以一般默认左键发球最符合原描述)。
-
球移动:亮灯从一端逐个移动到另一端(像"跑马灯")。
-
到达端点判定 :当球到达对方端点后,给对方一个 0.1 s 的击球窗口 (在 100Hz 下就是 10 拍)。
-
对方在窗口内按下自己的键 → 击回(方向反转,继续移动)
-
没按 / 超时 → 输球,结束
-
-
犯规 :球没到自己端点 就按键 → 犯规,结束(按键者输)。
关键点:这本质就是 一个有限状态机 FSM + 一个位置寄存器 pos + 两个计数器(移动速度计数、击球窗口计数)。
2.2 核心数据怎么表示
球的位置
-
pos:0~7(0 表示最左 LED,7 表示最右 LED) -
LED 输出做 one-hot(独热型):
led = (8'b1 << pos)
球方向
-
dir:0=向右,1=向左-
dir=0时 pos++ -
dir=1时 pos--
-
时间窗口换算
-
100 Hz → 10 ms/拍
-
0.1 s → 100 ms → 10 拍
所以:
HIT_WINDOW_TICKS = 10
球移动速度
题目没规定,你可以"自定一个合理值",否则 100Hz 每拍移动 1 格会太快:8 格只要 70ms,人按键来不及。
建议你设成参数,比如:
-
每 0.2 s 移动一格 →
MOVE_TICKS = 20 -
或每 0.1 s 移动一格 →
MOVE_TICKS = 10
即系统时钟 100Hz,内部再用计数实现"球速"。
2.3 状态机怎么设计(最推荐的结构)
S0:IDLE(等待发球)
-
初始:
pos=0,dir=向右(准备打到右边) -
行为:
-
允许:左键按下 → 发球开始,进 S1
-
不允许:右键按下 →(可判犯规,也可忽略;为了规则清晰建议判犯规:右方抢发)
-
S1:MOVE(球在路上跑)
-
每累计到
MOVE_TICKS,pos 按方向移动一格 -
任何时刻,如果有人按键:
-
如果球不在端点 → 按键者 犯规,进 S3
-
(因为题目写死"没到顶头按键犯规",所以 MOVE 状态里按键基本都算犯规)
-
-
当 pos 移到端点:
-
到右端
pos==7→ 进 S2,等待右方击球窗口 -
到左端
pos==0→ 进 S2,等待左方击球窗口
-
S2:WAIT_HIT(端点 0.1s 击球窗口)
-
进入时
window_cnt=0 -
在 10 拍内:
-
若在右端(pos=7):
-
右键按下 → 合法击回:
dir=向左,进 S1 -
左键按下 → 左方犯规:进 S3(左输)
-
-
若在左端(pos=0):
-
左键按下 → 合法击回:
dir=向右,进 S1 -
右键按下 → 右方犯规:进 S3(右输)
-
-
-
若
window_cnt==HIT_WINDOW_TICKS-1还没等到正确按键 → 超时,该端点对应玩家输,进 S3 -
说明:HIT_WIN_TICKS = 10 // 0.1s = 10 * 10ms,对应于后面仿真波形
S3:GAME_OVER(结束)
-
保持最终显示(比如显示赢家端点 LED 常亮,或全灯闪烁)
-
等待复位重来
2.4 按键要怎么处理
按键直接进 FPGA 会抖动,所以你至少做两件事:
-
同步:防亚稳(两级触发器)
-
消抖 + 产生脉冲 :让"按一下"只出一个周期的
key_pulse
因为你整个系统以 100Hz(10ms)更新,其实可以把消抖简化成:
-
每 10ms 采样一次按键电平
-
连续 N 次相同才认为稳定(例如 N=3~5)
-
稳定后做上升沿检测得到
pulse
100Hz 天然适合按键消抖。
2.5 模块划分
(1) clk_div / tick_gen(如果开发板主时钟不是 100Hz)
-
把 50MHz / 100MHz 分频成 100Hz 的
tick_10ms使能 -
FSM 在
tick_10ms到来时更新一次
(2) key_filter(两路)
-
输入 raw key
-
输出
keyL_pulse、keyR_pulse
(3) pingpong_fsm(核心)
-
输入:
tick_10ms、两个 pulse -
输出:
pos、game_over、winner/loser
(4) led_driver
led = onehot(pos)或 game_over 显示模式
2.6 代码
默认参数已按上面规则配置好(100Hz 节拍、0.1s=10tick 窗口、球速 0.2s/格)。按键 低有效(按下=0,松开=1)
通过网盘分享的文件:pingpong (2).zip
链接: https://pan.baidu.com/s/1VKuhJpAG2QUb4HKjjZdM6Q?pwd=1234 提取码: 1234
2.7 仿真结果
-
CASE1:正常来回 + 超时结束
-
左键按一下(发球)→ 球开始向右移动,
led变成 01→02→04→...→80(每格约 0.2s) -
等球到右端
led=0x80→ 右键按一下(合法击回)→ 球改向左 -
等球到左端
led=0x01→ 左键按一下(合法击回)→ 再向右 -
再到右端这次不按 → 等 0.1s(10 个 tick)窗口过去 →
game_over=1,winner=0(左胜)
-
-
CASE2:复位后,中途乱按犯规
-
再复位一次
-
左键发球
-
等球到中间(比如
led=0x02) -
右键此时按下(球没到端点)→ 立刻判犯规结束 →
game_over=1,winner=0(左胜)
-



注意state变量,左键发球state=00,正常移动state=01,到最左或者最右state=10击打窗口等 0.1s(10 个 tick),没有规定Cnt内按键打回去一方赢,state=11。