FPGA实现TCP通信:从状态机设计到调试实战的完整代码解析

发布时间:2026/10/21 17:51:07
FPGA实现TCP通信:从状态机设计到调试实战的完整代码解析 FPGA做TCP通信代码层面的工作量和难度往往比大多数人预想的要大。上一篇把硬件平台、总体架构和协议分层聊完了这篇直接进入代码篇。我会把整个TCP协议栈在FPGA里的模块划分、核心代码逻辑、状态机设计思路以及调试过程中踩过的坑全部梳理出来。我管这个工程叫tcp_demo功能目标很明确实现一个完整的TCP服务器PC上位机能够通过网口连接FPGA互相收发数据同时支持ping通、三次握手、数据ACK、连接关闭这几个基本能力。想移植到新项目的可以直接照着我这套代码结构搭。1. 先理清代码架构五个模块各管一段FPGA做协议栈最大的误区是一上来就埋头写TCP状态机。实际上一套能稳定跑起来的TCP通信代码应该按协议层次和功能域拆成多个模块这样调试时定位问题会轻松很多。1.1 为什么TCP要拆成“底层协议状态机应用接口”三层软件里写TCP是调socket操作系统帮你把缓存、重传、流量控制全包了。FPGA里没有这些软件资源一切都要用状态机、FIFO、计数器来表达。如果所有逻辑塞在一个模块里综合后时序很难收敛调试时稍微加点波形就要看上百个信号非常痛苦。我建议的模块划分是五层结构MAC核负责RGMII接口对接单独处理PHY芯片的时序ARP模块管链路层的地址解析和应答IP模块管IP头封装、解析和校验和处理ICMP模块管ping回显TCP状态机模块管连接管理和数据传输。应用层接口单独留一组FIFO和握手信号方便对接你自己的业务逻辑。这五层各管一段哪个环节出问题直接看对应模块的仿真波形就行。1.2 顶层模块端口设计一个信号都不能少顶层模块的端口设计决定了下游所有的调试便利性。我习惯把PHY接口、FIFO接口、调试接口分开避免所有信号都拉出来挤在一起。module tcp_demo_top #( parameter SYS_CLK_FREQ 125_000_000, parameter LOCAL_MAC 48h12_34_56_78_9A_BC, parameter LOCAL_IP {8d192,8d168,8d1,8d10}, parameter LOCAL_PORT 16d8080 )( input wire sys_rst_n, // PHY RGMII 接口 input wire phy_rxc, input wire phy_rx_ctl, input wire [3:0] phy_rxd, output wire phy_txc, output wire phy_tx_ctl, output wire [3:0] phy_txd, // 应用层接口这里只是示例具体按你的业务扩展 output wire app_tx_ready, input wire app_tx_valid, input wire [31:0] app_tx_data, input wire app_rx_valid, output wire [31:0] app_rx_data, output wire link_established );端口参数用parameter定义而不是硬编码这样换IP、换端口、换MAC时不用改逻辑代码只改顶层参数就行。我实际测试中发现把LOCAL_IP放到参数里调试时批量换IP特别方便。1.3 工程目录与综合设置代码管理上我习惯把每个协议层独立成文件目录大致是这样tcp_demo/ rtl/ tcp_demo_top.v arp_ctrl.v ip_rx.v ip_tx.v icmp_echo.v tcp_stack.v tcp_rx_path.v tcp_tx_path.v mac_rx_fifo.v mac_tx_fifo.v sim/ tcp_demo_tb.v arp_tb.v tcp_pkt_gen.v constraint/ tcp_demo.xdc综合设置上有一个关键点不要在顶层把RGMII的时钟直接拿来驱动所有逻辑。PHY提供的RXC往往是恢复时钟抖动和相位不好控制我会把RXC单独作为接收侧时钟域用户逻辑统一用一个稳定的125MHz或100MHz时钟中间用异步FIFO隔离。这个设计在后面跨时钟域章节再细讲。2. 从ARP和ICMP开始先把链路层跑通先别急着碰TCP。TCP是建立在IP之上的IP又依赖ARP来解析MAC地址。如果ARP都通不了后面的工作全白搭。2.1 ARP应答状态机怎么写得稳ARP模块不需要发主动请求大部分场景FPGA作为服务器只需要被动应答。当一个ARP请求进来目标IP等于本机IP时交换源和目的MAC/IP把op字段改成2塞回以太网帧里发出去。ARP应答帧的格式我写一下参考。以太网头14字节目的MAC是请求方的源MAC源MAC是本机MAC类型字段是0x0806。ARP体28字节硬件类型1、协议类型0x0800、硬件地址长度6、协议地址长度4、op2、发送端MAC就是本机MAC、发送端IP就是本机IP、目标MAC是请求方的MAC、目标IP是请求方的IP。状态机方面我的做法是维护一个四状态机IDLE、WAIT_FIFO、SEND_REPLY、DONE。收到合法ARP请求后拉高请求标志状态机从FIFO里读取完整的请求帧边读边记录发送方的MAC和IP读完马上生成应答帧写入发送FIFO然后回到IDLE。这个“边读边记录”的操作看似简单实际是很多ARP模块容易出错的地方因为帧数据是流式的位拼接顺序和字节顺序一定要和以太网帧格式严格对应。localparam ARP_IDLE 2d0; localparam ARP_READ 2d1; localparam ARP_BUILD 2d2; localparam ARP_DONE 2d3; always (posedge clk) begin if (!rst_n) begin arp_state ARP_IDLE; end else begin case (arp_state) ARP_IDLE: begin if (arp_valid arp_type 16h0806) begin arp_state ARP_READ; end end ARP_READ: begin if (rx_fifo_empty) arp_state ARP_BUILD; end ARP_BUILD: begin arp_req_send 1b1; arp_state ARP_DONE; end ARP_DONE: begin arp_req_send 1b0; arp_state ARP_IDLE; end endcase end end实际代码里还要加一个帧过滤条件ARP包的操作码必须是1请求并且发送端IP不能是本机IP否则会陷入自己回自己的死循环。我调试时遇到过这个问题现象是FPGA在不断往外发ARP应答Wireshark里看着像广播风暴。2.2 自动学习对端MAC省掉手动配地址TCP通信必须要知道PC端的MAC地址才能发包。这个地址可以通过接收到的ARP请求或TCP数据包自动学习不需要手动配置。我的做法是维护一个寄存器peer_mac每收到一个合法的ARP请求就更新一次。这样做的稳定性还不错因为PC在发起TCP连接前通常都会先发ARP请求询问FPGA的MAC地址我们正好利用这个时机把PC的MAC记下来。如果PC端做了静态ARP绑定不走ARP请求那我们就在TCP握手阶段学习收到任何IP包时把以太网头的源MAC存下来因为IP包一定是从对端发来的这个MAC就是最新的对端MAC。这两个策略写在一个always块里逻辑上互不冲突。关键是要加一个“合法帧”判断帧CRC由MAC核验过了我们只需要保证收到的目标IP是本机就行。2.3 ICMP回显响应不用抓包也能判断通没通ICMP模块其实很小就是把收到的echo request原样改个类型码换成echo reply发回去。但别小看这个功能它是整个调试链路里最便宜的通断测试工具。TCP和UDP都依赖IP层转发而ICMP能帮你确认二层和三层是否已经打通。ICMP报文格式是IP头20字节ICMP类型8字节类型、代码、校验和、标识符、序号后面跟着数据区。应答时把类型从8改成0重新算ICMP校验和IP头重新封装。ICMP校验和的计算方法和IP校验和一样都是16位累加取反这个后面细讲。// 收到ping请求类型字段是8 // 回显时改成0其他字段原样返回 icmp_reply_type 8d0; icmp_reply_sum calc_checksum(icmp_payload);这里有个小坑ICMP数据区长度是可变的有的操作系统发的ping包带了一堆填充数据有的就发几个字节。如果只针对固定长度实现会出现“小包能ping通大包不通”的诡异现象。正确做法是先解析IP头里的总长度字段用解析出的长度来界定ICMP数据区的结束位置。3. TCP核心逻辑握手、状态机、收发与ACKTCP状态机是整个工程的核心部分也是代码量最大、最容易出bug的地方。我建议把它当成一个独立的子模块来写不要和IP层的代码混在一起。3.1 三次握手用状态机怎么表达TCP在硬件里的状态机就像一个自动售货机你投币收到SYN我响铃回SYN/ACK你再确认一下交易就成立了。用代码描述就是上电后状态在LISTEN收到SYN包时进入SYN_RCVD状态回一个SYN/ACK包等收到ACK包后进入ESTABLISHED状态这时候连接才真正建立。我实际实现的状态机是这个样子localparam TCP_ST_IDLE 4d0; localparam TCP_ST_LISTEN 4d1; localparam TCP_ST_SYN_RCVD 4d2; localparam TCP_ST_ESTABLISHED 4d3; localparam TCP_ST_FIN_WAIT 4d4; localparam TCP_ST_CLOSE_WAIT 4d5;从IDLE到LISTEN只需要一个系统复位释放。收到有效SYN包后提取对方的初始序号赋值给本地变量rcv_nxt同时把自己的初始序号写到snd_nxt寄存器里。这里有个非常容易错的细节SYN包本身会占一个序号所以回复SYN/ACK时确认号必须是对方序号加1而不是原样返回。收到对方的ACK后确认号也应该是自己的序号加1这个再写错就会导致连接建立失败。3.2 序号和确认号最容易算错的两个字段序号和确认号是TCP报文头里的两个32位字段硬件里处理时稍微不留神就容易出错。我的经验是在代码里定义两个寄存器snd_nxt表示“下一个要发送的字节序号”rcv_nxt表示“下一个期望接收的字节序号”。发送数据包时TCP头的序号字段填snd_nxt确认号字段填rcv_nxt。收到带数据的数据包时rcv_nxt要加上payload长度。收到ACK包时如果ACK序号大于等于snd_nxt说明对端已确认收到这时更新snd_nxt。这听起来很简单但实际调试时Wireshark里最常看到的错误就是接收方的确认号总是比预期少1。原因多半是没把SYN/FIN标志占序号这点算进去或者payload长度解析错了。我的建议是在每个TCP头处理逻辑旁边加注释写清楚当前状态下“这个包的序号应该是谁、确认号应该是谁”代码改起来一目了然。3.3 收到数据回ACK发完数据等ACK一个完整的收发闭环一个完整的TCP数据收发理论上要比握手复杂得多但最小实现可以简化为两条规则收到对方带payload的数据包剥掉以太网、IP、TCP头把payload写进接收FIFO同时生成一个纯ACK包回给对端。纯ACK包不带payload只包含确认号确认号是收到的TCP序号加payload长度。发送数据时从应用接口读数据打包成TCP段填上序号和确认号发出去之后启动一个超时重传计数器。计数器溢出前如果收到ACK且确认号覆盖了已发送的末尾就认为发送成功否则重传这段数据。这里有一个工程层面的选择要不要做超时重传。纯软件的TCP栈非常依赖重传机制来保证可靠传输但硬件里实现完整重传逻辑会比较复杂包括滑动窗口、乱序重排等等。我的方案是先做固定重传发送后开一个10毫秒的窗口在窗口内没收到ACK就重传一次最多重传8次8次之后直接重置连接。这在局域网环境下完全够用因为丢包率很低握手后极少有重传发生。收到数据后回ACK的逻辑要特别注意ACK包也要走IP层封装不能直接裸发TCP头。也就是说收到数据的完整路径是MAC RX - IP解析 - TCP解析 - 数据写入FIFO - 生成ACK - IP封装 - MAC TX。这条路径上的每一步都有条件判断任何一个条件不满足ACK就会漏发对端就会一直重传数据。我调试时发现漏ACK往往比错ACK更常见而且表现是PC端显示连接正常但数据就是不更新。3.4 连接关闭和超时重传怎么处理连接关闭的完整状态流转也有几种情况我的代码只实现了被动关闭和主动关闭两种常用路径。被动关闭是收到对端的FIN包回ACK进入CLOSE_WAIT等应用层把剩余数据处理完再发FIN包等对端的最终ACK关闭连接。主动关闭是应用层请求断连发FIN包等对端回ACK再等对端也发FIN包。这整个流程用一组额外的状态位来控制。超时重传方面我加了一个计数器模块数据包发出时启动计数收到ACK后清零。这个计数器可以同时用于检测对端长时间不发数据如果连接建立后超过一定时间没收到任何TCP包我就主动发一个ACK包作为保活探测几次探测无响应就关闭连接。这个功能看起来不起眼但实际应用非常有用否则对端机器掉线后FPGA端会一直卡在ESTABLISHED状态应用层怎么等都不会有反应。4. 工程细节校验和、FIFO与RGMIITCP通信里的数据通路除了协议解析还有一堆工程向的细节这些细节才是真正决定“能不能稳定跑起来”的关键。4.1 校验和的进位回卷Verilog里怎么写IP头校验和和TCP校验和是复用同一套算法把协议头按16位一个字累加累加过程中产生的进位要回卷加到低位最后取反得到校验和。这里最核心的是“进位回卷”这个操作Verilog里用37位的中间变量可以优雅地实现。// 伪代码示意先拼16bit累加检测进位并回卷 reg [16:0] sum_tmp; always (posedge clk) begin if (rst_n) sum_tmp 17d0; else sum_tmp sum_tmp {1b0, data_word}; end // 累加结束后处理进位回卷 wire [15:0] checksum ~(sum_tmp[15:0] {15d0, sum_tmp[16]});这个写法有个前提所有16位字的累加必须在同一个时钟节拍内完成或者用组合逻辑把累加环路做完。如果一次累加不够要分多拍累加那么每次累加完都要做一次进位回卷否则最终结果会错。TCP校验和比IP校验和多一个伪头参与计算。这个伪头包含了源IP、目的IP、协议号和TCP报文长度。我第一次实现时把它按容易理解的方式拼成多个16位字逐个累加仿真没问题但上板后PC一直报TCP校验和错误。排查了好几个小时最后发现是伪头里面协议号应该左移12位放在高字节我填到了低字节导致整个伪头的字节序反了。这里分享一个通用技巧TCP校验和在硬件里可以在数据写入FIFO或RAM时边写边累加数据存完长度也知道了再补上伪头部分的累加值。这样不用先算长度再回头遍历数据能省一遍读RAM的带宽。我实现tcp_tx_path时就是用这个思路吞吐量能提高不少。4.2 FIFO深度和跨时钟域设计MAC核输出的数据是在PHY时钟域里的而TCP状态机运行在用户逻辑时钟域这两个时钟频率、相位都不一致。我用异步FIFO在中间做缓冲原理上跟跨时钟域处理异步握手信号是一样的。FIFO深度选择方面以太网一个标准MTU帧最大1518字节考虑整帧缓冲我用的FIFO深度是2048×8bit留了余量。如果数据包更长或者要处理突发大带宽深度可以加大到4096。我的代码里收发各一个异步FIFO写时钟和读时钟分别接PHY时钟和用户时钟。注意异步FIFO的空满信号有固有延迟读空判断要等一拍设计状态机时要防止读出不存在的空数据。此外应用层接口的FIFO我用了同步FIFO用FWFTFirst Word Fall Through模式这样应用层可以提前看到第一个数据字配合valid/ready握手信号流控非常简单。4.3 RGMII时钟采样窗口的教训RGMII这个接口数据线数量比GMII少一半但它用DDR方式在时钟上下沿都传数据时序预算非常紧。PHY输出的RXC和RXD之间是边沿对齐的而FPGA内部通常需要的是中心对齐采样说白了就是要在bit位中间取样。如果直接拿RXC的边沿去采RXD在高速率下极大概率采到边沿抖动的位导致偶发性错包。解决的方案是加IODELAY把RXD数据链路上加一个固定的延迟让采样点落在bit窗口中心。这是我调试中最耗时的一部分也是很多开发板用户重新综合就断网的常见原因。约束文件里要加上合适的输入延迟约束// 示例约束实际值根据PCB走线长度和PHY手册调整 set_input_delay -clock [get_clocks phy_rxc] -max 2.5 [get_ports phy_rxd[*]] set_input_delay -clock [get_clocks phy_rxc] -min 1.0 [get_ports phy_rxd[*]]还有一个更省心的玩法很多FPGA的三速以太网MAC IP核自带RGMII转GMII的逻辑直接在IP配置界面打开就能处理采样窗口和DDR分离不需要自己写。代价是IP核占资源多一些延迟也大一点。我自己的工程里为了教学方便是用原语直接写的但对时间紧的项目用IP核快速跑通不丢人。5. 调试实录从“连不通”到稳定传输代码写完后调试是绕不开的关卡。这里记录几个我在实际调试中遇到的典型问题以及定位思路。5.1 典型问题速查表现象可能原因排查方法ARP请求能收到回复PC收不到应答帧的源MAC填错或者发送FIFO没被正确触发抓包核对源MAC是否等于本机MAC检查发送状态机流程ping不通但ARP能通ICMP回显的校验和错误或者IP头校验和错误抓包看ICMP checksum字段重算一次TCP连接建立失败一直SYN_SENTSYN/ACK包没发出来或ACK确认号错误抓包看FPGA是否回了SYN/ACK数值是否符合预期PC能连上但发数据收不到RX路径的payload解析长度错误或ACK漏发检查rcv_nxt更新逻辑是否加了payload长度数据发送成功但PC一直显示重传发送序号没有按字节数递增或ACK接收逻辑写错抓包对比发送序号是否和PC确认号匹配偶发错包时好时坏RGMII采样窗口没调好或跨时钟域没隔离好用固定pattern发包压测调整IODELAY值查看丢包率这个表是我实际调试中反复用到的每次新板卡出问题我都会先对照一遍再做深入定位。5.2 同一个坑我踩了两次校验和第一次是IP校验和我写完发现ping总是超时抓包一看FPGA发出的IP包checksum显示“incorrect”。代码上我确实做了16位累加但我忽略了一个细节IP头长度不一定是20字节如果带选项字段就是24字节甚至更长。我的模块只处理了固定20字节头部把选项字节当成了payload累加时自然就错了。解决方法是先解析IHL字段再决定累加多少字节。第二次是TCP校验和这次更隐蔽。TCP校验和要算伪头伪头里的“TCP报文长度”字段是指TCP头加数据的长度不包含IP头。我一开始把整个IP包长度填进去了导致校验和跟软件计算的总对不上。这个问题用Wireshark的自动计算功能一秒钟就能确认但当时因为不太熟练排查了很久。5.3 状态机卡死的定位方法状态机卡死最常见的表现是FPGA不发任何TCP包Wireshark里只有PC单方面的重传。定位方法我习惯在状态机里加一个debug输出寄存器把当前状态和关键事件通过串口或LED状态灯显示出来。这种方法虽然土但在板级调试时往往比逻辑分析仪更高效。比如我遇到过的一个问题PC发送数据到FPGA后FPGA回了一个ACK但之后就再也没动静了。查状态机发现接收路径的解析状态机在处理完一个包后没有清掉valid标志下一包进来时状态机认为自己还在处理中所以不触发新的ACK。修复方式很简单就是在状态机空闲分支里加一个“帧结束”判断把临时标志全部复位。另一个经典问题多个模块同时在争用发送FIFO。ARP模块、ICMP模块和TCP模块都有可能往发送FIFO里写数据如果三个模块的写请求同时出现而仲裁逻辑写得不够严谨就会丢帧。这种问题在综合后仿真里很难复现因为仿真里时序完全确定但上板后偶发。解决办法是给发送FIFO加一个请求仲裁模块保证同一时刻只有一个写请求被接受。5.4 用Wireshark配合调试的小技巧Wireshark在这个调试过程中真的帮了大忙。抓包时我会同时开着网卡混杂模式和“校验和校验”功能只要FPGA发出来的包有错误Wireshark立刻会在Packet Details窗口用红字标注。另一个技巧是在TCP握手阶段观察Window Size和MSS。如果对端发来的SYN里带有MSS选项而我们回SYN/ACK时不带客户端一般没问题但如果客户端发来一个带时间戳选项的包而我方不处理选项Wireshark里会显示TCP Timestamp mismatch。这个不影响功能可以不用管。最有用的一个技巧先在PC上用网络调试助手发一个固定内容的数据包比如一连串0xAA 0x55然后FPGA端收到后原样回发。用Wireshark对比发送和接收的数据帧只要payload一致说明整条数据通路是通的。这个“回环测试”方法对定位两端的字节序、位序问题特别有效建议先做这个再做复杂的业务逻辑。6. 代码还能往哪个方向扩这个tcp_demo工程目前是一个最小可用的TCP服务器但如果要在真实项目里落地还有几个方向值得扩展。6.1 提升链路利用率MSS、窗口与突发现在的实现是典型的“发一个包等一个ACK”这在1Gbps链路下利用率很低。要提升吞吐率可以做两件事一是在SYN/ACK包里带上MSS选项显示本端支持的最大段长度这样对端会按MSS值发数据避免超大分段二是实现小范围的发送窗口发送端可以连续发多个包再统一等待ACK接收端通过窗口字段告诉对端自己能接收多少数据。我实际做了一个8KB窗口的版本吞吐率比单包停等方式能提升30%左右。做窗口的逻辑核心是管理一个发送缓冲区记录哪些包已经发出但未确认ACK到达后从缓冲区里删除已确认的部分。这个逻辑放到Verilog里就是用一块RAM加一组读写指针比状态机稍微复杂一点但对吞吐提升明显。6.2 多连接与TCP选项支持如果产品需要同时服务多个设备可以扩展连接表支持4个或8个并发连接。每个连接单独维护snd_nxt、rcv_nxt和状态通过源IP、目的IP、端口四元组来区分连接。这部分代码量会明显增加但架构上可以在现有基础上加一组寄存器文件状态机按连接ID索引。TCP选项方面如果要做高稳定性的传输需要支持PAWS防止序号回绕和SACK选择性确认。这些都属于进阶功能建议先把基础连接调稳了再考虑。我自己的经验是局域网环境下这些选项完全可以省略只有公网或长链路传输时才需要。6.3 可以留在顶层的小工具我在顶层模块里放了几个调试用寄存器自己平时调板子习惯用一个链路状态寄存器记录当前连接是否建立一个收发帧计数寄存器统计收到的总包数和发出的总包数一个错误计数寄存器记录校验和错误和状态机异常次数。这些寄存器可以被内部的调试总线读出来通过PC端软件直接访问。有了这些计数器定位问题时就不用总盯着抓包了。比如你怀疑收包丢帧直接看错误计数是不是在涨怀疑ACK没回去看发送帧计数和收到帧计数的比例。这种从“症状到原因”的排查效率远远高于一头扎进代码里看波形。最后再分享一个我自己的习惯。每次调新板卡第一次上板前都会强制自己用回环测试把PHY的收发通路先验证一遍也就是把FPGA的发送数据线直接短接到接收数据线。如果回环通了说明物理链路和PHY配置没问题后面再来排查协议逻辑。虽然这会让第一次上板多花十分钟但能省下后面至少半天的摸索时间。协议栈这种东西最怕的不是逻辑复杂而是底层硬件有问题却浑然不觉。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询