简介这是一份面向FPGA开发者的ZYNQ PL与PS通信及TCP网络传输方案资源包适合具备一定Vivado和嵌入式基础、希望实现ZYNQ与上位机网口通信的工程师学习参考。资源围绕ZYNQ的处理器系统与可编程逻辑协同工作展开详细说明AXI总线的几种典型接口Lite、Full、Stream分别适用于简单读写、复杂读写和高速数据流场景接着讲解轻量级TCP/IP协议栈在处理器侧的移植与配置以及可编程逻辑侧以太网MAC接口、DMA直接存储访问和AXI桥接的实现要点。同时涵盖TCP连接建立后的数据收发流程、常见实时性瓶颈与带宽优化策略并介绍基于Vivado的硬件工程搭建和软硬件联合调试、抓包验证的方法。压缩包为ZIP格式大小约56.6MB内容按技术模块整理便于按需查阅。目前已有10883人学习下载适合需要动手实现PL与PS通信并通过网口向PC上位机传输数据的工程师和研究者。1. 从网口看FPGAZYNQ的PL与PS通过TCP把数据拉到上位机用ZYNQ系列FPGA做数据采集时最头疼的不是逻辑本身而是怎么把PL端一直在变的寄存器值“搬”到上位机。接JTAG只能盯着调试器看偶尔抓一拍想要实时波形或者远程控制传统做法还得外挂串口或USB芯片。这个工程把问题换了个思路PL和PS之间用AXI接口做桥PS端直接开TCP服务端于是网线就成了FPGA对上位机的数据通道。PL侧生成的数据写进寄存器PS侧通过内存映射读到再从socket发出去反向上位机也可以通过网口下发控制字到PL寄存器。整套链路下来ZYNQ的异构能力都用上了PL管高速并发PS管协议和网络。适合手里有ZYNQ开发板、想把FPGA数据接进网络生态的硬件工程师也适合想快速熟悉PL-PS通信流程的入门者。2. 架构设计一条数据从PL到上位机的完整链路2.1 为什么用ZYNQ而不是纯FPGA做TCP纯FPGA做TCP有两个坎。一是MAC和PHY需要外挂你要在板上画PHY芯片投入不小二是要在逻辑里实现TCP状态机、重传、超时、滑动窗口这绝不是几百行状态机就能稳定的。ZYNQ把ARM和FPGA封在一颗芯片里PS端自带以太网控制器跑一个精简Linux或裸机协议栈就能用socket调用。这样网络栈不再由PL逻辑维护PL只需要聚焦采样、运算、缓存这些并行活两边各干擅长的部分。在这个工程里PS端采用的是常见做法跑Linux系统用户态C程序直接创建TCP服务。PL端通过AXI-Lite接口挂几个32位寄存器用来暴露数据和接收控制命令。为什么选AXI-Lite而不是AXI-StreamAXI-Stream没有地址适合连续数据流但要配合DMA控制器使用链路复杂对于低速命令和状态采集AXI-Lite的地址映射更直观调试也省事先用它打通链路以后需要高速传输再换成AXI-Stream加DMA。2.2 数据正向链路PL寄存器、AXI-GP、内核虚拟地址正向数据流可以拆成四段。第一段PL内部有一个数据生成逻辑比如每个时钟周期加一也可以替换成ADC采样结果写入约定好的数据寄存器。第二段这个寄存器被挂到AXI-Lite从端口上PS端通过某个AXI-GP主接口访问这组接口的基地址在硬件工程中分配一般放在0x40000000段。第三段Linux内核将这些物理地址映射到用户空间最便捷的方式是打开/dev/mem用mmap映射一页得到用户态虚拟地址就能像访问数组一样访问PL寄存器。第四段C程序循环读取这个映射地址的内容交给socket发送出去。反向链路则是上位机把命令包发给TCP服务端PS端解析命令字把要修改的寄存器值通过mmap映射写回AXI地址PL逻辑对应控制位检测到变化后就会启动或复位某个模块。可以看出通信的“最后一百米”是AXI总线网口只是传输媒介中间经过TCP和Linux进程。正是这种层级叠加让ZYNQ做网络接入比外置协议芯片更灵活。2.3 寄存器映射与数据包定义在设计时我会把功能相关寄存器集中到固定偏移表方便PS端地址映射和上位机协议对齐。这个工程里PL侧只暴露了两个32位寄存器一个用于读取数据一个用于下发控制。偏移名称读写说明0x00reg_data只读当前采样数据每时钟周期更新0x04reg_ctrl读写bit0启动采样bit1复位计数器0x08reg_status只读bit0FIFO满标志bit31版本号对应的TCP数据包不直接发裸4字节因为在TCP流里无法区分边界。我一般在应用层自定义一个帧格式帧头、长度、命令、数据体、校验。这里给出轻量格式帧头固定为2字节0xAA55长度字段是数据体字节数命令字区分读数据、写命令、应答数据体放寄存器的值校验用一字节累加和。这个格式没有引入CRC是因为采集调试对偶发错包不敏感累加和已经能挡住大部分干扰。2.4 实时性与吞吐量的取舍很多初学者以为网口发数据就是越快越好实际上还要考虑延迟和带宽的平衡。第一TCP默认启用Nagle算法会把多个小包合并后再发送导致数据“卡顿”所以PS端socket创建后要设置TCP_NODELAY关闭Nagle。第二每包数据有帧头、长度、校验等开销如果只放4字节数据有效带宽不到一半适合调试场景真要追求吞吐量应改为批量发送把PL寄存器里的多帧数据打包成一个大报文。第三Linux的调度和socket缓冲会造成十几毫秒的抖动对实时控制有要求时需要把进程绑定CPU并使用实时调度策略不能指望默认优先级。这个架构的优点在于PL完全不知道网口的存在它只对外提供寄存器读写PS也不知道PL内部逻辑细节只按偏移访问。两层通过AXI协议解耦后面替换PL逻辑或换上位机协议都不影响另一端。3. 核心模块拆解从PL寄存器到TCP socket的代码实现3.1 PL端AXI-Lite从设备寄存器读写逻辑在硬件工程中自定义IP的AXI-Lite接口可以从模板生成但我们还是要看懂核心代码才能二次开发。这里给出精简的寄存器逻辑注意它只实现了本工程需要的部分。module pl_axi_lite #( parameter C_S_AXI_DATA_WIDTH 32 )( input wire S_AXI_ACLK, input wire S_AXI_ARESETN, input wire [C_S_AXI_ADDR_WIDTH-1:0] S_AXI_AWADDR, input wire S_AXI_AWVALID, output wire S_AXI_AWREADY, input wire S_AXI_WVALID, output wire S_AXI_WREADY, input wire [C_S_AXI_DATA_WIDTH-1:0] S_AXI_WDATA, // 用户逻辑端口 output reg [C_S_AXI_DATA_WIDTH-1:0] ctrl_reg, input wire [C_S_AXI_DATA_WIDTH-1:0] data_reg );上面只列了写地址和写数据相关的信号完整的还包括读通道和响应通道。核心逻辑是处理写握手当AWVALID和WVALID同时拉高时把WDATA写入对应地址的寄存器。这里用简单的一拍状态机完成always (posedge S_AXI_ACLK) begin if (!S_AXI_ARESETN) begin ctrl_reg 32h0; awready 1b0; wready 1b0; end else begin awready S_AXI_AWVALID; wready S_AXI_WVALID; if (S_AXI_AWVALID S_AXI_WVALID) begin case (S_AXI_AWADDR[3:2]) 2b01: ctrl_reg S_AXI_WDATA; // 0x04 控制寄存器 endcase end end end这段代码的关键是用S_AXI_AWADDR[3:2]做地址译码字节地址的低两位是字节通道这里直接跳过。实际工程里还要处理BResp信号否则总线事务状态不完整这个在避坑章节会细说。3.2 数据生成逻辑计数器绑定到读通道PL端要有一个不断变化的数据源否则网口每次收到的都是同一个值。我在用户逻辑里放了一个32位计数器每当采样使能有效时加一同时把它的值绑定到data_reg输出这条线直接接到AXI-Lite的读数据通道。always (posedge S_AXI_ACLK) begin if (!S_AXI_ARESETN) counter 32h0; else if (ctrl_reg[1]) // 复位 counter 32h0; else if (ctrl_reg[0]) // 使能 counter counter 1b1; end assign data_reg counter;这样PS端读0x00就能看到计数器的实时值。如果换成一个ADC模块把data_reg替换成采样结果即可。中断通知在有数据变化时更有效但TCP服务通常需要持续上报这里选择轮询保持代码可读性。3.3 PS端内存映射从/dev/mem到MMAP指针PS端C程序的主要工作是做两件事把AXI物理地址映射到用户空间然后开一个TCP服务端循环收发。先看映射部分。#include fcntl.h #include sys/mman.h #include stdint.h #define AXI_BASE 0x40000000 // 硬件设计分配的基地址 #define MAP_SIZE 0x1000 volatile uint32_t *axi_ptr; int map_pl_registers() { int fd open(/dev/mem, O_RDWR | O_SYNC); if (fd 0) return -1; void *map mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, AXI_BASE); if (map MAP_FAILED) return -1; axi_ptr (volatile uint32_t *)map; return 0; }使用volatile是必须的否则编译器会错误地优化掉重复读取后面会讲一个因此翻车的场景。映射偏移和寄存器偏移直接相加就是对应寄存器地址例如读数据寄存器就是axi_ptr[0]写控制寄存器就是axi_ptr[1]。参数说明MAP_SIZE取0x1000是因为AXI管理通常以4KB页面为粒度映射一页足够覆盖本工程的寄存器区。3.4 TCP服务端发送循环与封帧TCP服务端用标准socket写法先绑定端口然后循环accept。每次拿到连接后设置TCP_NODELAY再进入读寄存器、封帧、发送的循环。int one 1; setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, one, sizeof(one)); while (1) { uint32_t value axi_ptr[0]; // 读0x00 uint8_t frame[10]; frame[0] 0xAA; frame[1] 0x55; frame[2] 0x02; // 命令上报数据 frame[3] 6; // 数据体长度4字节数据 1字节校验 memcpy(frame[4], value, 4); frame[8] checksum(frame, 8); // 累加和占1字节 send(client_fd, frame, 9, 0); usleep(10000); // 10ms 发送一次 }这个发送循环每秒100帧每帧带4字节数据相当于400字节每秒数据量足够看波形变化。如果PL端数据是高速流这个循环要改成批量读取和发送或者交给DMA处理。注意frame数组故意预留了9字节发送第8字节是校验第9字节没有用到只是占位对齐。3.5 上位机Python接收与拆包上位机不需要复杂界面用Python打开一个TCP连接按帧头解析即可。关键函数是拆包因为TCP流没有边界必须循环接收并维护缓冲。import socket def recv_frame(sock): buf b while True: buf sock.recv(4096) if len(buf) 4: continue if buf[0] ! 0xAA or buf[1] ! 0x55: buf buf[1:] # 掉帧头继续找 continue length buf[3] if len(buf) length 4: # 注意还有帧头2字节长度命令校验 continue frame buf[:length 4] buf buf[length 4:] return frame这段代码里length放在偏移3实际表示数据体字节数统一按“帧头2字节 命令1字节 长度1字节 数据体 校验”解析。上位机拿到frame后把frame[4:8]转成长整型就是PL端计数器的值可以直接用matplotlib画动态线。4. 跑起来从硬件工程到上位机显示数据4.1 环境准备板卡、网线、同一网段开始跑这个工程先准备好开发板、电源、串口线和网线。串口用来登录PS端的Linux控制台网线则把板卡和上位机连到同一台交换机或者使用网线直连。建议给板卡设置静态IP比如192.168.1.10上位机用192.168.1.20避免DHCP导致地址漂移。板卡启动后先用串口确认Linux起来了再用ping检查网络通断。提示直连网线时部分板卡的PHY要求使用交叉线现在很多网卡已支持自动翻转普通直通线也能用如果ping不通先看板卡网口灯是否亮起再从串口确认eth0是否已分配地址。4.2 硬件工程处理导入IP、生成比特流、导出硬件把自定义AXI-Lite IP添加到硬件工程中后检查地址映射表确保基地址是0x40000000并且大小为4KB。然后综合、布局布线、生成比特流。生成成功后导出硬件平台文件这一步会生成PS端可用的配置信息。接着在软件工程中为PS端编写应用程序交叉编译成ARM可执行文件。这里有个容易忽略的点硬件导出时如果选择“包含bitstream”则Linux启动时会把位流加载到PL如果系统通过SD卡启动还需要将bit文件更新到启动分区里否则PL不会运行。我第一次做的时候经常改了PL逻辑但上位机收到旧数据原因就是SD卡里的启动镜像没有更新。检查方法是启动后读取PL版本寄存器如果版本号和刚生成的bit流不一致就说明加载的是旧镜像。4.3 编译PS端程序并上传到板卡在PS端Linux里确认0x40000000地址段可访问然后编译C程序。交叉编译环境通常是工具链关联的没有的话也可以在板卡上直接装GCC开发包。编译命令按常规处理arm-linux-gnueabihf-gcc -o tcp_server tcp_server.c -static静态编译体积大一些但部署省事不依赖板卡上的libc版本。动态编译需要板卡上有对应库有时候板卡文件系统裁剪过导致运行时报错静态编译能避开这类坑。编译完成后把tcp_server上传到板卡根目录或/opt目录chmod加执行权限。4.4 启动TCP服务并验证数据链路在板卡上运行服务端程序ifconfig eth0 192.168.1.10 up ./tcp_server 5000 服务端打印出监听地址和端口后上位机运行Python脚本连接192.168.1.10的5000端口。连接成功后上位机每秒能收到约100帧数据打印出来的数值应该是连续递增的。如果看到数值只跳不涨可能是计数器复位了如果完全没数据先回到串口看tcp_server的调试输出。在验证阶段我通常会先打印一帧原始字节确认帧头0xAA55和长度字段正确再去解析数据。这样能把问题分为“PL没数据”和“协议不对”两类不会混在一起排查。4.5 联调时的快速排查套路如果链路不通按顺序检查先ping确认物理链路再telnet端口确认TCP服务在监听再看串口打印确认程序是否进入循环最后用上位机抓包或打印原始字节确认数据方向。这个层级每一层都能定位一半的问题。ping不通时检查静态IP是否冲突网线是否插好能ping通但telnet失败看服务端是否真的在监听或者被防火墙挡了telnet通了却没数据问题基本在PS端映射或PL寄存器。按这个顺序排查至少能省下半天瞎翻代码的时间。5. 避坑记录TCP粘包、AXI时序与Linux映射的四个真问题5.1 TCP粘包上位机收到的是整块乱码现象上位机recv一次返回了超过一帧长度的大块数据解析后命令字全乱。原因TCP是流式传输不保留应用层的消息边界。发送端连续两次send之间间隔太短底层可能合并成一个小段发出接收端一次read可能读到两帧内容。解决应用层必须自己封帧。我在发送端固定用帧头0xAA55加长度字段接收端先找帧头再根据长度字段决定收多少字节。即使粘包也能正确拆开。只要protocol的拆包函数正确TCP粘包就不是致命问题。// 接收端拆包的核心思想 uint8_t buf[2048]; int len recv(fd, buf, sizeof(buf), 0); while (len 0) { if (buf[0] 0xAA buf[1] 0x55) { int frame_len buf[3] 4; if (len frame_len) { process_frame(buf); buf frame_len; len - frame_len; } } else { buf; len--; } }这段代码解释了拆包思路拿到数据后先在缓冲区里找帧头找到之后按长度字段截出完整帧。参数说明buf[3]是数据体长度frame_len是包含帧头、命令、长度的总长注意发送端和接收端对这个数值的定义必须一致。5.2 mmap后寄存器地址读取一直为0现象读axi_ptr[0]总是0但PL逻辑确认计数器在跑。原因最常见的是硬件设计里AXI基地址没对上或者PL端地址译码只匹配了低地址位导致PS访问0x40000000时PL从设备没有响应总线返回0。解决先在串口打印映射的虚拟地址再用devmem工具直接物理地址读取。如果devmem读出来也是0说明问题在硬件层如果devmem能读说明C程序的mmap方式错了检查是不是没有加MAP_SHARED或者偏移计算错误。devmem的用法是devmem 0x40000000它会输出物理地址上的32位值。5.3 volatile缺失导致数据帧不更新现象上位机收到的数据帧恒为第一次连接时读到的那个数但PL端计数明明在飞。原因编译器优化。C代码里这个地址被当作普通内存重复读写时编译器可能认为值没有变化直接把寄存器值保存在CPU寄存器里每次都取旧值。解决把axi_ptr定义为volatile uint32_t *并在每次发送前重新读取。只要访问外设地址的指针一律先声明volatile。这个坑比较隐蔽我第一次联调时排了两天才发现后来养成了习惯所有映射到物理地址的指针都加volatile。volatile uint32_t *axi_ptr; uint32_t value *(volatile uint32_t *)axi_ptr;参数说明volatile在C语言里告诉编译器不要缓存这个地址的值强制每次访问都走内存总线对于外设寄存器这是必须的。5.4 accept阻塞导致第二个上位机连接不上现象第一个上位机连接后工作正常但再开一个客户端连接请求一直得不到响应。原因服务端在单线程里先accept然后进入send循环recv阻塞住了主循环无法回到accept等待新连接。解决改造成每接受一个连接就创建一个线程连接处理在线程里完成。或者用select/poll多路复用。最简单的是用pthread改动量不大。pthread_t th; int *client_sock malloc(sizeof(int)); *client_sock accept(fd, NULL, NULL); pthread_create(th, NULL, handle_client, client_sock); pthread_detach(th);代码里把accept返回的fd传给线程函数线程函数负责读寄存器、发数据。这样主循环能立即回到accept支持多个上位机同时连接。注意需要为每个连接分配独立的socket fd否则线程之间会抢资源。5.5 写控制字后PL不响应现象上位机下发复位命令PL计数器并没有归零。原因写AXI通道的握手没有正确处理BResp信号导致PS认为写成功但PL端实际没有锁存数据。在AXI-Lite协议里写事务完成后从设备必须返回一个BRESP否则总线事务状态不对。解决回到PL代码保证写数据通道在握手完成后拉高BVALID并等待BREADY。如果自定义IP是从模板生成的检查模板里这一部分是否被修改过。有时为了省事把BVALID直接拉高但BREADY没到时事务不算完成PS端驱动会报错或读到旧值。6. 二次开发从裸寄存器到自定义协议与DMA搬运6.1 把多个数据源挂到寄存器偏移上现在的工程里PS端固定读0x00寄存器上位机只能看一个值。做二次开发时最直接的想法是把多个数据源挂到不同偏移。比如0x10放ADC通道0x14放温度传感器。但更好的做法是让协议携带命令路由上位机发“读通道1”命令PS端解析后读取对应偏移回包这样上位机逻辑更统一。if (cmd CMD_READ_CH1) { value axi_ptr[4]; // 0x10 send_response(client_fd, value); } else if (cmd CMD_READ_CH2) { value axi_ptr[5]; // 0x14 send_response(client_fd, value); }同时要在PL端把多个数据源都暴露为只读寄存器这样PS端只需要按命令映射偏移真正采集逻辑的变化不涉及PS端代码。改造时先在寄存器映射表里预留好偏移不要等协议写完再临时加。6.2 高速场景升级为AXI-Stream加DMA如果数据率达到几十MB/s用mmap加寄存器轮询不现实。常见做法是把PL端的采样数据通过AXI-Stream流送到PS端的DMA控制器绕开CPU逐字读取。改造时PL端增加一个AXI-Stream主端口内部维护位宽转换和FIFOPS端使用DMA驱动建立接收缓冲区。TCP协议保留数据源从内存映射改成DMA缓冲吞吐量会有几十倍提升。这个改动会动到三个层面PL逻辑、设备树、上位机协议。建议先用一个小流测试确认数据顺序和长度对齐再挂真正的ADC数据。DMA的页对齐缓冲和中断处理是本方案的最大风险点调试时要先看DMA驱动的中断统计再做TCP发送。6.3 改造验证的固定流程每次改动都走一遍完整验证链重建bit流、更新SD卡、启动PS程序、上位机看波形。曾经我为了省事只改了PL端打包逻辑没同步更新上位机拆包结果两边字段不一致花了整晚排查。从那以后我每次改动都强制走一遍这个过程先把数据集落在串口打印再决定是否动协议。希望帮到你。本文还有配套的精品资源点击获取