FPGA学习笔记-图书馆存包柜,乒乓球游戏电路设计

一.图书馆存包柜电路设计

内容及要求

(1)存包柜共有128个;

(2)电路分为两部分:主控电路和每个柜的节点电路;

(3)节点电路接受和发送带有地址编码、控制编码的串行数据,也可以采用标准总线结构;

(4)节点电路带有条形码/二维码的扫描功能,直接输出布尔量(不需要设计该部分电路);

(5)其余功能自行设定;完成主控电路和节点电路;

1 翻译

要做一个128 个柜子的存包柜系统,结构分两层:

  • 主控电路:像"柜子管理员",负责轮询/下发开锁命令/记录状态

  • 节点电路(每个柜一个):像"柜子本体控制板",带地址,能收发串行数据,能读扫描器布尔量(扫码成功=1),能控制开锁执行器、读门磁等(其余功能自己设定)

难点:

  1. 128 节点怎么挂在一条串行总线上

  2. 什么时候哪个节点说话,避免总线冲突

  3. 协议帧怎么设计,怎么仿真证明正确

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

  • 节点收到 OPENopen_act 输出一个脉冲(比如 200ms),并清 scan_latch

  • 门打开再关上 door_closed 回到 1 → 节点把 occupied 置 1(表示已存包)

  • 取包:已占用 occupied=1 时再扫码一次 scan_ok=1 → 主控下发 OPEN → 关门后 occupied 置 0

这套逻辑非常"课设友好":既有扫码、又有占用、又有开锁,还能解释为什么需要主控轮询。

(4)两个关键 FSM

主控轮询 FSM(简写)

  • addr=0

  • 循环:

    1. READ(addr)

    2. 等应答,超时则标记 offline(可选)

    3. 更新 map

    4. 如果该 addr scan=1 → 决策是否发 OPEN(addr)

    5. addr++ 到 127 后回 0

节点命令 FSM(简写)

  • IDLE:监听 SOF=0x55

  • RECV:收够5字节校验

  • addr_match

    • CMD=READ → 组帧应答

    • CMD=OPENopen_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)"选择地址 + 注入事件 + 观察状态":

  1. 选一个柜子地址 sel_addr(0~127)

    • 用拨码/按键改这个地址

    • 或者更简单:直接用 PC 串口发 ADDR

  2. sel_addr 这个柜子制造事件

    • "模拟扫码成功" → 给该节点一个 scan_ok 脉冲

    • "模拟门开/关" → 改该节点的 door_closed

  3. 把这个柜子的状态显示出来

    • 用 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 来了(开始发一帧)

这一拍发生的事(非常关键):

  1. 把整帧5字节先缓存下来

    • b0 <= SOF(帧头 3C)

    • b1 <= cmd

    • b2 <= addr

    • b3 <= status

    • b4 <= sum4(SOF,cmd,addr,status)(校验)

  2. 进入忙状态

    • 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 != 4idx <= 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=0send=1 就装载一帧。

如果上层把 send 拉高不止一拍,会导致每拍都重新装载同一帧(虽然 busy 立刻变 1,通常只装载一次,但边界上最好做成脉冲/握手更规范)。

6.5 locker_node.v子模块

1.核心功能

locker_node 是 128 个柜子中某一个柜子的节点电路 (参数 ADDR 决定它的地址)。它做两件事:

  1. 业务状态维护(本地逻辑)

    • 记住有没有扫码:scan_latch

    • 记住柜子是否"占用":occupied

    • 记住是否报警:alarm

    • 追踪门磁从开到关:door_closed + door_closed_d

    • 记录一次"已开锁等待关门完成":opened

  2. 总线通信(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_okreq0 == 8'h55

  • addr_okreq1 == ADDR

  • req_chk_oksum4(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+r3

    module 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
    end

    endmodule

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柜子系统"的大脑,它做三件核心事:

  1. 轮询读取每个节点状态(CMD_READ)

    通过总线主机 bus_master 一次读一个节点(地址 addr_ptr

  2. 维护四张状态表(map)

    把每次读取到的 STATUS 字节拆成 4 个 bit,写进:

    • door_map[i]:门磁状态

    • occ_map[i]:占用状态

    • scan_map[i]:扫码锁存状态

    • alarm_map[i]:报警状态

  3. 根据策略触发开锁(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节点全部实例化并连线,还做了两件"现实演示用"的事:

  1. 把 PC 命令转成"模拟输入" :例如 SET_SCAN 变成 scan_pulse_in[addr] 一拍;SET_DOORdoor_closed_in[addr]

  2. 把内部 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 在总线上做三件事:

  1. 跟着 bus_tickbus_mosi 移进来,攒够/对齐出一帧请求

  2. 如果发现请求 addr==自己ADDR 且校验正确,就:

    • 执行命令(READ/OPEN/CLR...)

    • 装载 40bit 应答帧到 tx_sh

  3. 发送应答时把 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 收到回包状态。

它内部有两条"通信链"同时存在:

  1. PC ↔ FPGA(UART 包)uart_rx/uart_tx + pc_packet_rx/pc_packet_tx

  2. 主控 ↔ 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
    end

    复制代码
     pc_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

  1. 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=1req0_n==8'h55

  • addr_ok_n=1req1_n==ADDR==8'h06

  • req_chk_ok_n=1sum4(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_entx_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_ctrlST_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=0dir=向右(准备打到右边)

  • 行为:

    • 允许:左键按下 → 发球开始,进 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 会抖动,所以你至少做两件事:

  1. 同步:防亚稳(两级触发器)

  2. 消抖 + 产生脉冲 :让"按一下"只出一个周期的 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_pulsekeyR_pulse

(3) pingpong_fsm(核心)

  • 输入:tick_10ms、两个 pulse

  • 输出:posgame_overwinner/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:正常来回 + 超时结束

    1. 左键按一下(发球)→ 球开始向右移动,led 变成 01→02→04→...→80(每格约 0.2s)

    2. 等球到右端 led=0x80 → 右键按一下(合法击回)→ 球改向左

    3. 等球到左端 led=0x01 → 左键按一下(合法击回)→ 再向右

    4. 再到右端这次不按 → 等 0.1s(10 个 tick)窗口过去 → game_over=1winner=0(左胜)

  • CASE2:复位后,中途乱按犯规

    1. 再复位一次

    2. 左键发球

    3. 等球到中间(比如 led=0x02

    4. 右键此时按下(球没到端点)→ 立刻判犯规结束 → game_over=1winner=0(左胜)

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

相关推荐
从此不归路2 小时前
FPGA 结构与 CAD 设计(第3章)下
ide·fpga开发
Gary Studio2 小时前
simulink simscape(机器人方向)学习笔记
笔记·学习
Zeku2 小时前
20260111 - Linux驱动学习笔记:异步通知
笔记·stm32·freertos·linux驱动开发·linux应用开发
wdfk_prog2 小时前
[Linux]学习笔记系列 -- 内存管理与访问
linux·笔记·学习
go_bai2 小时前
Linux-网络基础
linux·开发语言·网络·笔记·学习方法·笔记总结
laocooon5238578862 小时前
学习计算机知识的量变质变关系模态分析
学习
崎岖Qiu2 小时前
【OS笔记38】:设备管理 - I/O 设备原理
笔记·操作系统·os·设备管理·io设备
我命由我123453 小时前
Photoshop - Photoshop 工具栏(58)锐化工具
学习·ui·职场和发展·求职招聘·职场发展·学习方法·photoshop
前端小菜袅3 小时前
AI时代,新的技术学习方式
学习·ai编程