拿到这个标题,脑海里基本就能把场景还原出来:手上有一块 Zynq 开发板,想通过板子上的网口和 PC 通信,大概率是先跑一个 LWIP 协议栈,再用 TCP 调通数据通路。Zynq 的 PS 端自带千兆 MAC,LWIP 又是嵌入式网络里最常用的轻量级协议栈,而 TCP 调试则是整个网络功能落地前最关键的验证环节。这篇把从无到有、从配置到底层排查的完整过程都过一遍,适合正在做 Zynq 网络开发、或者刚接触 LWIP 但对 TCP 调试还不太熟悉的同学参考。
先说一个很多人容易忽视的前提:Zynq 上的网络调试和纯单片机环境不一样。Zynq 的 PS 端有 ARM 核,但硬件上还挂着一套 FPGA 逻辑,网口物理层、时钟、引脚分配这些都需要在硬件工程里就定好。如果硬件阶段没配对,后面软件再怎么写 LWIP 都是白搭。我踩过最典型的坑就是 PHY 芯片的复位引脚没拉对,导致 MDIO 读不到 PHY 寄存器,lwip 初始化直接就卡在自动协商那一步,看起来像协议栈问题,实际上是硬件初始化顺序的问题。
1. 整体设计:Zynq、LWIP、TCP 三者到底是怎么配合的
1.1 Zynq 平台的网络架构特点
Zynq 实际上是一个异构 SoC,PS 端集成双核 ARM Cortex-A9,PL 端是 FPGA 逻辑。千兆以太网 MAC 控制器在 PS 端,叫 GEM(Gigabit Ethernet MAC),通过 MIO 引脚引出到 PHY 芯片,再经 RJ45 和外部通信。调试 TCP 之前,必须把这几个环节打通:PS 端的 GEM、外部的 PHY 芯片、网络变压器和连接器。
这里有一个很关键的寄存器层面的细节:GEM 控制器工作所需的时钟,是由 PS 端的 IOPLL 分频产生的。如果你在硬件工程里把 GEM 的时钟频率设错了,MAC 的工作速率就会出现偏差,表现就是 TCP 能 ping 通但数据吞吐量异常低,或者接收方向丢包严重。很多人在软件层面排查半天,最后发现是时钟配置的问题,这种案例并不少见。
通信链路的结构可以简单理解成三层:最底层是物理链路,由 PHY 芯片负责;中间层是数据链路,由 GEM 完成 MAC 帧的收发;最上层是协议栈层,由 LWIP 实现 TCP、UDP、ARP、ICMP 等协议。TCP 的数据包,实际上要经过这三级才能完成一次收发,调试的时候每一层都应该在心里有个明确的定位。
1.2 LWIP 为什么是嵌入式 TCP 通信的主流选择
LWIP 全称 Lightweight IP,设计目标就是提供一个精简但完整的 TCP/IP 协议栈。它的代码量比 Linux 内核的网络栈小一个数量级,但 TCP 协议的核心机制像三次握手、滑动窗口、重传、拥塞控制都有实现。在裸机或者 RTOS 环境下,LWIP 几乎是唯一能兼顾功能完整性和资源开销的选择。
LWIP 支持三种运行模式:raw API、netconn API 和 socket API。raw API 基于回调机制,适合裸机环境,网站资源占用最低;netconn 是线程化封装,适合配合 FreeRTOS 使用;socket 则是最接近 Linux 开发习惯的接口。Zynq 裸机环境下,最常用的是 raw API,因为不需要依赖操作系统调度,一个 while 循环就能把所有网络事件跑起来。
需要强调的是,LWIP 并不是一个按标准 TCP 协议严格实现的协议栈,它的设计做了很多取舍。比如接收窗口大小、缓冲区策略、超时粒度都和标准 Linux 内核网络栈不同。调试时如果发现 TCP 行为“不太标准”,不要急着怀疑协议栈 BUG,先确认自己的应用层数据和缓冲区配置是否符合 LWIP 的设定。
1.3 裸机 + LWIP 方案和嵌入式 Linux 方案的选择
Zynq 上其实有两种网络方案:裸机跑 LWIP,或者跑嵌入式 Linux 然后使用其自带网络接口。二者的差异非常明显。表里可以直接对照。
| 对比项 | 裸机 + LWIP | 嵌入式 Linux + 网络接口 |
|---|---|---|
| 资源占用 | 内存几十 KB 级别 | 启动内存需求以 MB 计 |
| 开发难度 | 应用逻辑相对简单,配置要点多 | 驱动相对完善,上层编程接口用 UDP socket 即可 |
| 实时性 | 可控制在毫秒级 | 受内核调度影响,只能保证软实时 |
| 调试灵活性 | 可以直接看协议栈内部状态 | 深入底层需要打补丁,定制难度偏高 |
裸机方案的调试难点在于,所有的网络状态都需要自行理解协议栈内部的统计量或者抓包确认;Linux 方案的难点则在于环境构建、系统移植和驱动适配。如果产品功能单一、只需要跑几个 TCP 连接,裸机 LWIP 通常够用,而且启动时间从秒级缩到毫秒级,这种优势在很多工业场景很关键。
2. 调试前准备:搭建最小 LWIP 工程和网络参数配置
2.1 从零构建硬件工程和 SDK 工程
调试 TCP 需要先在 Vivado 或其它硬件设计工具里完成硬件工程。这里面最核心的操作是例化 Zynq 的 PS 配置,在 MIO 配置界面里勾选 Ethernet 接口 GEM0,并确认引脚被分配到合适的 MIO 引脚上。同时也需要检查 PHY 的复位引脚和控制方式,一般默认配置下 SD 或者 GPIO 控制复位,具体要按板卡原理图来确定,这块很容易出错。
硬件工程完成后,导出硬件描述文件并启动软件开发工具。新建应用工程时,SDK 会提供 LWIP 的模板,模板里包含了基本的 LWIP 初始化代码和几个示例应用,最典型的就是 echo server。这个模板是为调试量身定做的,直接用它可以省去大量基础代码编写的时间。
需要注意硬件工程的版本匹配问题。有时换了一个版本的引线设计工具,导出的硬件描述文件在 SDK 中访问时会产生兼容性问题,表现为 LWIP 库的驱动包版本对不上。比较稳妥的做法是保持引线设计工具和 SDK 版本一致,或者确认使用的驱动包版本能兼容当前的硬件描述。
2.2 静态 IP、MAC 地址和内存池的配置
TCP 调试开始前,先确定 IP 地址策略。Zynq 裸机 LWIP 推荐使用静态 IP,这样可以避免调试时还要通过串口交互输 IP 的情况。设置方法是在 LWIP 初始化结构体里配置 IP 地址、子网掩码和网关,例如设计一个 192.168.1.10 的静态地址、255.255.255.0 的子网掩码,再配上 192.168.1.1 网关。ICMP 回显功能默认打开,这样就能用 ping 命令先判断链路层和 IP 层的可用性。
MAC 地址容易被忽视,但实际调试中影响不小。Zynq 板卡如果多块同时上电,MAC 地址都相同,会导致 ARP 缓存混乱,PC 上 ARP 表反复变化,TCP 连接建立不起来。合理的做法是每块板子在应用层代码中指定一个唯一的 MAC 地址,可以从板卡标签上读取,也可以在 Flash 中保存,至少保证实验室环境下各不相同。这点我在同时调试了多个设备时就踩过坑,ARP 翻来覆去就是连接失败,后来才发现两片板子的 MAC 一摸一样。
LWIP 的内存池配置同样重要。LWIP 的内存策略分 MEM_SIZE、MEMP_NUM_TCP_SEG、TCP_SND_BUF 和 TCP_WND 几个维度。如果缓冲区设置太小,TCP 连接建立后发送速度会明显受限,TCP 窗口太小导致吞吐量上不去。如果接收报文段缓冲区太小,则可能出现数据收不全的现象,看起来像是应用层数据长度对不上,实际上协议栈在底层把数据包丢了。
下面是一个常见的内存配置策略参考:
#define MEM_SIZE (16 * 1024) // 堆大小 #define MEMP_NUM_TCP_SEG 32 // TCP 分段缓存 #define TCP_SND_BUF (4 * 1024) // TCP 发送缓冲区 #define TCP_WND (8 * 1024) // TCP 窗口调试初期不建议开太大,内存分配过大反而可能导致内存碎片加剧。实测定下来,调试阶段 16KB 的堆空间、4KB 发送缓冲和 8KB 接收窗口已经够用,先跑通再逐步调优,是我建议的常规顺序。
3. 核心实操:LWIP 初始化与 TCP 服务器实现
3.1 网络接口挂载和 PHY 探测的过程
LWIP 在网络功能启用前需要完成两件事:调用 lwip_init 做协议栈的全局初始化,然后调用 netif_add 将网络接口加入协议栈。Zynq 平台对应的网卡驱动在模板中通常是空壳,需要自行补充 PHY 探测逻辑,而这部分最容易出现时序问题。
一个常见的做法是,在主程序里先完成板级初始化,包括 GEM 控制器的初始化,然后延时一段时间等待 PHY 芯片上电稳定,这步很关键,PHY 芯片的电源稳定需要一定时间,否则 MDIO 读写必然失败。接着通过 MDIO 接口读取 PHY 的 ID 寄存器,确认 PHY 芯片在链路上存在,再用驱动中对应的函数完成自动协商,最后才能真正连接。
Zynq 的 GEM 驱动在裸机环境下一般自带 PHY 初始化逻辑,但有的 PHY 芯片支持能力较差,自动协商起不来,需要在 PHY 寄存器里强行设置速度和双工模式,强制设定为 100Mbps 全双工,这种操作在生产网络中可能因速度不匹配造成严重丢包,但调试环境里反而能快速定位问题。这种情况在真实项目中不止一次遇到,常用 PHY 芯片上电后没等到自动协商完成,LWIP 这里就超时,最终造成网口状态异常。
3.2 一个可用的 TCP echo server 具体实现
TCP echo server 是调试阶段最好的测试程序:客户端发什么数据,服务端就原样返回什么数据。这样测试数据可以直接观察,方便判断双向链路是否通畅,也能顺带验证 TCP 的流控机制。Zynq 裸机环境下,用 LWIP raw API 实现 echo server 核心代码如下:
static struct tcp_pcb *echo_pcb; err_t echo_accept(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, echo_recv); tcp_sent(newpcb, echo_sent); tcp_nodelay(newpcb, 1); tcp_accepted(newpcb); return ERR_OK; } err_t echo_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (p != NULL) { tcp_recved(tpcb, p->tot_len); err_t wr_err = tcp_write(tpcb, p->payload, p->tot_len, TCP_WRITE_FLAG_COPY | TCP_WRITE_FLAG_MORE); if (wr_err == ERR_OK) { tcp_output(tpcb); } pbuf_free(p); } else if (err == ERR_OK) { tcp_close(tpcb); } return ERR_OK; } void lwip_echo_server_init(void) { echo_pcb = tcp_new(); tcp_bind(echo_pcb, IP_ADDR_ANY, 8000); echo_pcb = tcp_listen(echo_pcb); tcp_accept(echo_pcb, echo_accept); }这段代码里,tcp_recved必须在处理完收到的数据后调用,用来告诉 LWIP 已经释放了接收缓冲区,否则 LWIP 的滑动窗口不会推进,很快就会出现收而不发的怪现象。tcp_write使用TCP_WRITE_FLAG_COPY,是把应用层数据拷到 LWIP 内部缓冲,避免异步传输时数据区被覆盖。如果没有这个标志,LWIP 会直接引用传入的指针,而裸机程序后续可能修改了该区域数据,最终发送内容大概率就错乱了。
主循环很简单,只需要持续调用:
while (1) { lwip_service(); }其中lwip_service会调用sys_check_timeouts进行定时器相关处理,并调用各网卡驱动的轮询函数。TCP 的重传、超时、ARP 探测等逻辑,都必须靠这个循环周期驱动,如果主循环被其他耗时操作卡住太长时间,TCP 连接的表现就会出现明显问题。
3.3 RAW API 和 socket API 在 Zynq 上的取舍
裸机环境下使用 raw API 几乎是必然选择,因为 socket API 依赖 netconn 层到线程调度器的调用,裸机没有现成的线程环境,需要自行实现信号量和调度机制。虽然 SDK 模板中也提供了基于 netconn 的 socket 示例,但如果系统本身没有建立多线程调度框架,这类应用在裸机上跑起来很别扭。
如果确实希望使用 socket 风格开发,合理的方案是把 FreeRTOS 引入工程。LWIP 在 FreeRTOS 环境下可以编译为多线程模式,配合 netconn 接口,开发体验更接近桌面系统。代价是内存占用上升,一个任务栈往往就要 4KB 以上,多线程并发时堆压力明显增大。
调试初期我有自己的偏好:先用 raw API 把 TCP 通路的逻辑打通,确认数据收发机制没有问题,再根据产品需要决定是否升级到 RTOS 方案。直接进入 RTOS 会额外引入任务优先级、超时机制、任务间通令等问题,容易掩盖真正的网络调试焦点。
4. TCP 调试实操:从打开终端到抓包验证
4.1 启动服务并用 PC 端工具测试
板卡的程序下载并运行后,第一步一定是先 ping。ping 通了,说明 ARP、IP、ICMP 三个协议层都是正常的,特别是 ARP,因为 TCP 连接建立之前先做地址解析,目标主机不在 ARP 表中几乎没办法发起连接。ping 不通的话,先检查网线、PHY 复位、IP 配置和 MAC 地址有没有设置重复。
ping 通之后,用 PC 端工具连接 TCP 端口,比如在命令行下用 telnet 或者直接用网络调试助手。配置好目标 IP 和端口号(比如 8000),进行连接。此时注意观察板卡的串口日志。LWIP 的回调函数里如果能增加日志打印,真正的调试体验就大不一样。比如在echo_accept和echo_recv里各打印一条日志,可以看到连接是哪里建立的、数据是从哪个途径进来的。
这里分享一个实用技巧:在 PC 端敲一行固定字符串,比如 “hello-zynq”,点击发送。板卡收到后原样返回,PC 端在窗口中能看到相同内容,这就说明 TCP 双向链路完全正常。接着可以尝试连续发送大量数据或者不同长度的报文,验证长途数据传输和缓冲管理逻辑。
4.2 Wireshark 抓包怎么帮助排查问题
Wireshark 是 TCP 调试过程中极其重要的工具。在 PC 端选择与开发板相连的网卡,设置过滤条件tcp.port == 8000后开始捕获,然后让板卡和 PC 建立连接、传输数据,就能看到整个过程。抓包主要观察三个环节:三次握手是否正常、数据包是否有重传、挥手或超时行为是否符合预期。
以三次握手为例,正常情况下先看到客户端发 SYN,然后服务器回 SYN+ACK,接着客户端再回 ACK。如果只有 SYN 发出,没有任何回应,说明板卡侧网络根本没有回应,排查方向在板卡或链路物理层。如果 SYN+ACK 已经发出但客户端没有继续 ACK,则问题方向可能在 PC 侧防火墙或 ARP 缓存。
在实际抓包过程中,我最常看到的现象是 TCP 重传统计频繁。发现大量重复 ACK 或超时重传,通常是单向丢包,或者板卡的 LWIP 发送缓冲设置太小。遇到这种情况,调整 MAC 的接收描述符数量往往比调整应用逻辑更有效。Zynq 的 GEM 驱动的接收队列默认描述符数量值得检查,如果描述符太少,高频数据下丢包率会显著上升,且在协议栈层看不到错误日志,因为帧已经被底层丢了。
4.3 借助串口日志定位协议层问题
裸机程序里,串口日志调试手段的价值真不算低。LWIP 内部维护了不少统计结构体,像lwip_stats里就包含了报文接收计数、发送计数、数据包丢失计数等。在关键代码路径插入打印,可以直接判断数据是否到达协议栈,还是在网卡驱动处就已经丢失。
调试时建议在以下几个位置加入日志:
- PHY 初始化完成后验证 link 状态。
- 网卡驱动轮询包时记录接收描述符使用情况。
echo_recv里记录收到数据长度。echo_sent里记录实际发送成功长度。
光靠这些日志就能判断链路方向问题。如果驱动层接收计数持续增长但应用层完全没有收到数据,多半是驱动和应用之间的缓冲流转逻辑出了错。如果应用层收到数据但发送失败,那要看tcp_write返回的原因,通常是窗口不足或缓冲区不足。
5. 常见问题排查:一个速查表加三个深刻教训
5.1 TCP 调试高频异常速查
TCP 调试中遇到的现象往往能初步分类定位,下表是多年调试下的经验总结,适用于 Zynq + LWIP 环境:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| ping 不通 | GEM/PHY/RJ45 链路未完成初始化 | 检查复位引脚、自动协商、网线 |
| ping 通但 TCP 连接超时 | 应用服务未监听、ARP 异常、板卡回包被阻断 | 检查服务进程、MAC 地址、Wireshark 过滤 ARP |
| 能连上但收发不稳定 | 缓冲区配置过小、内存池崩溃、链路双工不匹配 | 调整 LWIP 内存参数、强制设置 PHY 面对 |
| 数据接收断断续续 | 接收描述符不足、中断延迟太高 | 增加描述符数量、调整驱动轮询频率 |
| TCP 窗口不增长 | tcp_recved未调用、接收缓冲未释放 | 检查应用回调逻辑 |
| 长时间无数据后连接断开 | 保活机制和超时判定 | 调整 LWIP 的 TCP 超时参数或应用层心跳 |
这张表并不是万能的,但覆盖了大部分新手阶段容易遇到的问题,遇到异常时先沿着方向和协议层的思路去查,再结合抓包分析,很快就能看到问题所在。
5.2 实例一:PHY 上电时序带来的初始化失败
某次调试时,程序烧录后串口打印一直停在 PHY 自动协商阶段,读取 PHY ID 失败。查了一圈寄存器配置,SGMII 和 RGMII 接口模式都对,MIO 引脚分配也正确,后来问题定位到是PHY 复位引脚释放太早,PHY 芯片尚未完全上电,MDIO 接口上读取到的都是高阻态,自然也读不出 ID。解决办法是在复位拉高后增加至少 100ms 延时,再执行 MDIO 操作,问题立刻消失。
这个案例说明,Zynq 调试 TCP 前先确认硬件层面 PHY 是否稳定,这比任何软件调试都优先。不少初学者从 LWIP 代码开始找突破,实际上问题出在硬件时序上,白白浪费了很长的排错时间。
5.3 实例二:MAC 地址重复导致的 TCP 连接异常
还有一次现场带着两块板子同时测试,PC 端分别连接两个工程,结果连接状态混乱,一会儿成功一会儿失败,ARP 缓存里同一个 IP 在两个 MAC 地址之间反复跳变。排查日志:两块板子的 LWIP 模板代码完全一样,MAC 地址自然也一样。Zynq 板卡出厂没有固化唯一 MAC,如果不修改就都是模板提供同一个默认值,彼此冲突就在所难免。
解决方法很简单,为每块板子手动指定 MAC 地址,确保最后一位不同。修改后重启,ARP 缓存重新学习,两条 TCP 连接都稳定跑通了。这看起来是一个低级问题,但在实际开发环境中却不少见,多人共用同一套模板代码时尤其容易踩中。要在应用层主动规避,不要指望它自己不会发生。
5.4 实例三:缓冲区配置引发的 TCP 收发停滞
还有一种相对隐蔽的异常:TCP 连接建立很顺利,但持续传输数据 1MB 左右后,收发就会停下来。单独看每次的操作代码没有明显问题,因为 LWIP 的tcp_recved释放逻辑在人手不足的情况下未必能及时通知对端更新窗口。
逐步排查发现,LWIP 的接收回调中虽然调用了tcp_recved,但发送端发送的数据量超出了接收窗口,应用层对tcp_recv的处理又过于简单地回显,没有考虑数据过大时的拆分。把 LWIP 的 TCP_WND 提升到 16KB,并调整应用层一次最多发送小于窗口大小的数据,收发恢复正常。这个现象提醒我们:LWIP 的 TCP 通讯能力直接受内存配置约束,表面上逻辑正确,但是运行到某一个临界值后就会以隐蔽方式暴露配置不足。
TCP 调试的过程,说穿了就是在“链路层 — 协议栈 — 应用层”三层之间反复排查。Zynq 这个平台好在把 ARM 核和通用 MAC 集成在了一起,调试手段比纯 FPGA 方案丰富得多,配合 LWIP 的灵活性,可以快速对协议行为做验证。我个人踩过一轮硬件时序、PHY 寄存器和内存配置的坑之后,最深的感悟是:排查问题要认清现象发生在哪一层,先把底层物理链路确认无误,再调整协议栈参数,最后排查应用逻辑,顺序不能乱。这套顺序确定下来,大部分 TCP 调试问题都可以在半小时内定位方向,剩下的就是把细节打磨好。