上周接到一个小需求:用户要求手里的米联客MA703FA开发板能上电后自行启动一个Web页面,用来配置设备参数,还顺带问了一句——能不能支持IPv6访问?说真的,要在FPGA上跑网络服务器,第一反应是硬写RTL,工作量直接劝退;第二个选择就是在MicroBlaze软核上套一个轻量协议栈,也就是本文这套方案。这篇文章把我从Vivado工程搭建、SDK工程、lwIP HTTP服务器,到IPv6支持与QSPI固化的完整过程整理了出来,适合已经有Vivado基础、但第一次在MicroBlaze上碰以太网的朋友,文章尽量做到每一步都能照着走。
先说结论:这套方案可复现、可裁剪,开发板通电后自动从QSPI加载FPGA配置和应用程序,MicroBlaze作为TCP服务器对外提供HTTP页面,同时通过升级lwIP协议栈实现了IPv6访问能力。整个过程涉及的知识点比较密集,但拆开看并不复杂:PL侧搭一个软核处理器+千兆MAC,PS侧跑裸机+lwIP协议栈。下面按我自己调试的顺序一步步讲。
1. 方案选型与整体架构
1.1 为什么用 MicroBlaze + lwIP 做嵌入式服务器
FPGA上做网络通信,通常有三条路:一是纯RTL实现TCP/IP协议栈,比如用Xilinx的XAPP1026或者开源项目,这条路性能最好、时延最低,但调试难度极高,改一个字段就要重新综合,一个TCP状态机写下来能让人脱层皮;二是直接用Zynq的硬核ARM跑Linux或者裸机,这当然最舒服,但MA703FA这种纯FPGA芯片没有硬核处理器;第三条路就是用MicroBlaze软核+软件协议栈,这也是目前绝大多数Artix-7项目在用的方案。
MicroBlaze虽然主频不如硬核,但处理几百Kbps到几十Mbps的控制面流量绰绰有余。配合AXI Ethernet硬核MAC和AXI DMA,数据包搬运不占CPU,MicroBlaze只需要处理TCP重传、ACK、应用逻辑这些“脑力活”。对于Web配置页面、传感器数据上报、固件升级这类嵌入式服务器场景,这套组合在开发效率和性能之间达到了很好的平衡。而且lwIP是开源协议栈,BSD套接字风格的API用起来非常顺手,网上资料也极多。
1.2 MA703FA 板卡资源盘点
米联客MA703FA的核心器件是Xilinx Artix-7系列FPGA,具体型号以板卡丝印和原理图为准,我手上这块是XC7A35T级别。这类板卡的典型配置包括:
| 资源 | 说明 |
|---|---|
| FPGA | Artix-7 XC7A35T / XC7A100T |
| 内存 | 板载DDR3,通常是256MB/512MB颗粒 |
| 存储 | QSPI Flash,用于固化bit和elf |
| 以太网 | 千兆RGMII PHY,常见型号是RTL8211E系列 |
| 调试接口 | USB-UART、JTAG |
这里有一个很重要的习惯:拿到板卡第一件事不是急着开Vivado,而是先把原理图里PHY芯片的型号、MDIO地址、复位引脚、时钟来源查清楚。不同板卡的PHY地址不一样(常见是0x01或0x00),如果地址配错,后面MDIO读写不到PHY寄存器,网口永远Link不上,排查起来非常痛苦。
1.3 整体数据通路与软硬件分工
整个系统的数据通路可以这样理解:以太网帧从外部网线进入PHY芯片,经过RGMII接口进入FPGA内部的AXI Ethernet MAC,MAC把帧解析成AXI4-Stream流,再由AXI DMA直接写入DDR3内存。MicroBlaze CPU通过lwIP协议栈处理这些数据,需要回复时,把待发送数据交给DMA,DMA从DDR3搬运到MAC,打成帧发出去。
用一句话概括:网络收发是“硬件加速”的,协议解析是“软件处理”的。这样的好处是,即使MicroBlaze主频不高,也不会因为大量数据搬运而忙不过来。实际调优时,DMA描述符数量、缓存对齐、MAC接收缓冲大小都会直接影响吞吐,这部分我在后面单独讲。
2. Vivado工程搭建:MicroBlaze最小系统与千兆MAC
2.1 Block Design 中的 IP 清单
在Vivado 2018.3里新建工程后,创建Block Design,下面这些IP基本是跑不掉的:
- MicroBlaze(8.5版本左右)
- AXI Interconnect 或 AXI SmartConnect,用于连接所有外设
- AXI Ethernet IP(注意是axi_ethernet,不是axi_ethernetlite)
- AXI DMA(如果AXI Ethernet选择外部DMA模式才需要单独添加)
- MIG 7 Series DDR3控制器,提供应用运行内存
- AXI UARTlite,用于串口打印日志
- AXI GPIO,控制LED或者读取拨码开关
- AXI Timer,用于系统时钟tick和延时
- AXI Interrupt Controller,汇总中断
- Processor System Reset,统一复位
- Clocking Wizard,生成各模块需要的时钟
需要注意的是,MicroBlaze配置时,Local Memory尽量给足,比如64KB以上,用于启动阶段和堆栈。因为DDR3初始化需要时间,MicroBlaze的启动代码和lwIP初始化可能依赖BRAM里的代码先跑起来。
2.2 AXI Ethernet 与 PHY 芯片对接的细节
AXI Ethernet IP的配置里有几个关键项:接口类型选RGMII,速度选1000Mbps,DMA类型建议选“AXI DMA”让IP内部集成分散收集引擎。如果选了外部DMA,则要额外添加AXI DMA IP并手动连接,连线一下子复杂不少。
RGMII接口的关键信号包括:
| 信号 | 方向 | 说明 |
|---|---|---|
| rgmii_td[3:0] | 输出 | 发送数据,DDR沿采样 |
| rgmii_tx_ctl | 输出 | 发送控制信号 |
| rgmii_txc | 输出 | 发送时钟,1000M时为125MHz |
| rgmii_rd[3:0] | 输入 | 接收数据 |
| rgmii_rx_ctl | 输入 | 接收控制信号 |
| rgmii_rxc | 输入 | 接收时钟,由PHY恢复 |
| mdio / mdc | 双向 | 管理接口,读写PHY寄存器 |
| phy_rst_n | 输出 | PHY复位 |
连线时最容易踩坑的是时钟。RGMII工作在千兆模式时,发送时钟125MHz必须稳定提供给PHY,这个时钟通常由Clocking Wizard产生。具体来说,AXI Ethernet IP会有一个gtx_clk或者gmii_to_rgmii所需的clk输入,我的做法是用MMCM产生125MHz专用时钟,同时保证该时钟与MAC逻辑时钟的相位关系满足IP的约束要求。Vivado的IP集成器里如果时钟连错,综合后时序大概率过不了,或者干脆运行时报错。
PHY的MDIO地址在IP的“PHY Address”参数里设置。以RTL8211E为例,常见地址是0x00或0x01,具体看板卡原理图中PHY_AD[2:0]引脚上下拉。我建议在SDK里写一个简单的MDIO读写函数,上电后先回读PHY寄存器0x0,看能不能读到OUI和型号,这样可以快速定位PHY是否工作。
2.3 时钟与复位链路设计
时钟是整个设计最容易翻车的地方。我给MicroBlaze系统推荐一组时钟规划:
- 200MHz或150MHz系统时钟,用于MicroBlaze、AXI互联、DDR(DDR本身还要额外参考时钟)
- 125MHz GTX时钟,用于千兆RGMII发送
- 125MHz ref_clk,用于AXI Ethernet内部逻辑
- UART波特率时钟一般用系统时钟分频即可
复位链路也很关键。Clocking Wizard输出的locked信号要接到Processor System Reset的aux_reset_in,同时PHY的phy_rst_n也要在系统启动一段时间后再释放,不要在MMCM锁定之前就拉高。否则PHY可能没有完成上电复位,导致Link状态无法建立。
2.4 DDR3 与地址映射规划
DDR3部分我用的是MIG 7 Series IP。创建MIG时需要选择DDR3芯片颗粒型号,这个必须从原理图或者板卡手册里查到,比如常见的MT41K256M16 HA-125等。选错颗粒会导致内存读写不稳定,系统跑起来随机死机。
DDR3在Block Design里的地址分配需要手动规划。我通常把DDR放在0x80000000起始,大小按板载容量设置。MicroBlaze的本地BRAM放在0x00000000,UART、GPIO、Timer、Ethernet等外设放在0x40000000区段附近。地址规划的原则是:高频数据通路(DDR、DMA)尽量地址对齐,避免跨4KB边界。
3. SDK软件工程:从空白工程到 lwIP 通网
3.1 导出硬件并创建SDK工程
Vivado里综合、实现完成后,File -> Export Hardware,勾选Include bitstream,然后Launch SDK。SDK里会自动生成standalone BSP,里面包含了MicroBlaze的所有驱动。这一步一般没问题,但有一点要注意:导出的硬件工程路径不要有中文或者空格,否则后面编译lwIP这种大型库时会出现各种奇怪的include路径错误。
创建应用工程时,模板选择“lwIP Echo Server”或者“Empty Application”。我建议第一次先选lwIP Echo Server,因为它已经把网络通路调通了,编译下载后如果串口能打印IP并且主机能ping通,说明PL侧、BSP、驱动全部正常。这一步是验证L0层的好办法,不要在没跑通echo server之前就急着写HTTP服务器。
3.2 BSP配置中的关键参数
在BSP设置里勾选lwip141库后,有几个参数值得关注:
| 参数 | 位置 | 建议值 |
|---|---|---|
| API mode | BSP Setting | RAW或Socket,建议RAW |
| Enable DHCP | BSP Setting | 调试时关掉,用静态IP |
| IP Address | lwipopts.h或config | 192.168.1.10 |
| Netmask / Gateway | 同上 | 按实际网络填 |
lwIP的内存配置集中在lwipopts.h里。第一次测试时MEM_SIZE、PBUF_POOL_SIZE用默认值即可,跑稳定后再逐步调小以节省BRAM。如果板卡DDR3可用,lwIP的内存可以分配到DDR上,没必要死磕BRAM容量。
3.3 第一个例程:Echo Server 跑通
直接用模板生成的工程,编译下载后,串口通常会打印类似“TCP echo server started”的信息,然后就是初始化DHCP或者静态IP。host电脑配好同一网段的IP后,ping 192.168.1.10应该能通。如果ping不通,优先排查PL侧问题,不要先怀疑lwIP代码。具体排查思路我在第6章详细写。
Echo Server只能说明TCP数据通路通了,下一个建议是跑TCP Throughput Server模板,一边用iperf测性能,一边确认DMA在多包大数据流下是否稳定。这一步能暴露很多潜在问题,比如DMA描述符不足、中断风暴、内存对齐不对等。
3.4 中断、DMA与内存对齐的实际理解
MicroBlaze的AXI Ethernet中断路径是:MAC收到帧后置中断->AXI DMA完成描述符更新->DMA中断上报到中断控制器->MicroBlaze进入ISR->调用lwIP的回调函数处理数据。这里的核心是“中断里不要做重活”,ISR里只做数据接收的登记,真正的协议栈处理放到主循环的sys_check_timeouts和poll机制里去跑。
内存对齐是另一个容易被忽略的点。AXI DMA要求缓冲区地址按总线宽度对齐,一般需要4字节甚至更高对齐。lwIP的PBUF池在分配时通常会保证对齐,但如果自己申请内存做收发缓冲,务必用Xilinx提供的XIL_CACHE_ALIGN或类似宏对齐。我遇到过数据帧偶尔CRC错误,查了半天发现是buffer地址非对齐导致的。
4. 在 lwIP 上搭建千兆 HTTP 服务器
4.1 选择实现方式:用 socket 还是用 httpd
lwIP自带一个httpd模块,支持SSI和CGI,但1.4.1版本的httpd配置比较复杂,而且fsdata生成也很麻烦。对大部分嵌入式设备来说,Web页面只是显示状态和接收配置,我建议直接用lwIP的raw API实现一个极简HTTP服务器,代码清晰,逻辑也容易控制。
raw API的核心是注册一个tcp_pcb,设置accept、recv、err回调,在收到HTTP请求后解析第一行,然后拼装HTTP响应报文发回去。这种方式不需要额外的文件系统,页面直接硬编码在C数组里,简单粗暴,但非常可靠。
4.2 一个最小 HTTP 响应代码
下面是我实际使用的核心回调,功能是当浏览器请求“/”时返回一个状态页,请求“/data”时返回一段JSON:
static err_t http_accept_cb(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, http_recv_cb); return ERR_OK; } static err_t http_recv_cb(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (p == NULL) { tcp_close(pcb); return ERR_OK; } /* 只取第一行请求,例如 GET / HTTP/1.1 */ if (p->len > 0) { char *req = (char *)p->payload; if (strncmp(req, "GET /data", 9) == 0) { const char *json = "{\"temp\":26.5,\"hum\":43}"; char hdr[128]; int len = sprintf(hdr, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", strlen(json)); tcp_write(pcb, hdr, len, TCP_WRITE_FLAG_COPY); tcp_write(pcb, json, strlen(json), TCP_WRITE_FLAG_COPY); } else { const char *html = "<html><body><h1>MA703FA HTTP Server</h1></body></html>"; char hdr[128]; int len = sprintf(hdr, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", strlen(html)); tcp_write(pcb, hdr, len, TCP_WRITE_FLAG_COPY); tcp_write(pcb, html, strlen(html), TCP_WRITE_FLAG_COPY); } } tcp_close(pcb); pbuf_free(p); return ERR_OK; }这里有两个细节:一是必须设置Content-Length,否则浏览器无法判断响应边界;二是TCP_WRITE_FLAG_COPY,因为tcp_write是异步的,数据需要拷贝到lwIP的内存池中,不能直接传栈上指针。否则等发送真正执行时,栈上的数据已经被覆盖了。
主函数里初始化协议栈后,监听80端口:
struct tcp_pcb *pcb = tcp_new(); tcp_bind(pcb, IP_ADDR_ANY, 80); pcb = tcp_listen(pcb); tcp_accept(pcb, http_accept_cb);这样浏览器访问 http://192.168.1.10 就能看到页面了。
4.3 SSI/CGI 与动态页面
如果你的项目想做更复杂的动态页面,我建议用lwIP自带httpd里的SSI功能。SSI的思路是:网页模板里写<!--#temp-->这样的标签,lwIP在发送页面时自动调用用户注册的SSI回调函数,把标签替换成实时值。这个方案的好处是页面和逻辑分离,改HTML结构不需要重新编译C代码(只要重新生成fsdata)。
在1.4.1版本里,要先在lwipopts.h里打开LWIP_HTTPD_SSI宏,然后实现httpd_ssi_handler函数。新版lwIP 2.x的接口略有变化,但思路一致。
4.4 性能观测与优化方向
HTTP服务器跑通后,可以用ab命令或者浏览器多开几个页面观察响应速度。MicroBlaze处理HTTP请求的瓶颈一般在TCP发送窗口和DMA描述符上。如果并发请求多,建议把lwIP的TCP_SND_BUF和TCP_WND调大,同时增加DMA描述符数量。我实测下来,DMA描述符从16增加到64后,大文件下载速度有肉眼可见的提升。
千兆环境中,MicroBlaze的CPU主频是主要瓶颈。如果你要做高性能网关,可以考虑把lwIP跑在DDR中,同时为MicroBlaze开启I-Cache和D-Cache,能提升不少性能。Cache配置在MicroBlaze IP核里勾选,SDK侧不需要额外改动。
5. 让 MicroBlaze 支持 IPv6
5.1 为什么 SDK 自带的 lwIP 1.4.1 对 IPv6 不友好
这里要先说实话:Vivado 2018.3的SDK自带的lwIP库是1.4.1版本,这个版本对IPv6的支持只处于实验状态。虽然lwipopts.h里也能看到LWIP_IPV6宏,但真正打开后很多核心模块(ICMPv6、NDP、地址自动配置)根本不完整,编译能过,功能基本不能正常用。所以如果你必须做IPv6,有两条路可以选。
5.2 路线一:升级工具链,用官方新库开启双栈
最省力的路线是换用Vivado 2020.2及以上版本,从2019.x开始Xilinx已经将lwIP升级到了2.1.x,IPv6已经具备基本可用性。Block Design的搭建步骤和2018.3几乎一致,SDK里创建应用工程时选择lwIP模板,然后在BSP设置里打开IPv6开关。
以lwIP 2.1.2为例,lwipopts.h中需要关注:
#define LWIP_IPV6 1 #define LWIP_IPV6_AUTOCONFIG 1 #define LWIP_ND6 1 #define LWIP_DNS 1 #define LWIP_ICMP6 1 #define LWIP_IPV6_MLD 1 #define LWIP_IPV6_DHCP6 0打开这些宏后,在main里调用:
netif_create_ip6_linklocal_address(&netif, 1); netif.ip6_autoconfig_enabled = 1;开发板上电后,lwIP会自动生成一个fe80::开头的链路本地地址,同时如果有支持SLAAC的路由器,还能自动获取全球单播地址。串口打印出地址后,主机用ping6和浏览器就能访问。
5.3 路线二:在 2018.3 工程内手动移植 lwIP 2.1.2
如果你因为历史原因必须留在2018.3,也可以把lwIP 2.1.2手动移植进现有工程。这是个体力活,主要步骤包括:
- 从lwIP官网下载lwIP 2.1.2源码,复制src下的core、api、netif、include到工程的lwip目录下。
- 参考SDK自带lwip141的arch实现,重写cc.h、sys_arch.h、sys_arch.c等平台相关文件。
- 将BSP设置里的lwip库移除,改为从应用工程直接包含lwIP源码编译。
- 把lwipopts.h换成2.x版本的配置模板,确保LWIP_IPV6宏打开。
- 重点检查网络驱动:Xilinx的xaxiemac/xaxiethernet和lwIP2.x之间的适配层需要自己写或者从官方新版本SDK中移植。
这条路线最大的工作量在驱动适配层。lwIP 1.x到2.x,netif结构体、etharp、pbuf接口都有变化,Xilinx驱动原生是为1.x写的,直接编译会报很多错。如果非要用,我建议从Vivado 2020.2的BSP里把xemacpsif.c、axiethernetif.c这些适配文件整个拷贝过来改,比自己重写靠谱得多。
5.4 Ubuntu 主机侧 IPv6 验证配置
开发板侧支持IPv6后,主机侧也需要配置IPv6。Ubuntu Server 24.04默认是开启IPv6的,但需要确认一下:
sysctl net.ipv6.conf.eth0.disable_ipv6 # 返回 0 表示开启,如果返回 1,执行: sudo sysctl -w net.ipv6.conf.eth0.disable_ipv6=0如果需要静态IPv6地址,编辑/etc/netplan/下的yaml:
network: ethernets: eth0: addresses: - 192.168.1.100/24 - "2001:db8::100/64" routes: - to: default via: 192.168.1.1然后netplan apply。验证开发板和主机是否能互通IPv6,最直接的就是ping6:
ping6 -I eth0 fe80::1如果开发板是SLAAC自动获得地址,也可以用浏览器直接访问:
http://[2001:db8::1]注意IPv6地址在URL中必须加方括号。这一步成功,就说明整条IPv6链路已经打通了。
6. 常见问题与调试经验
6.1 网络不通?先查 PHY 复位与 MDIO
最典型的故障是:SDK打印了IP地址,但主机ping不通。排查思路按顺序来:
第一步,用万用表或者示波器确认PHY的复位引脚是否在启动后正常拉高,以及时钟是否有125MHz输出。第二步,在SDK里写一个裸机程序,直接读取PHY寄存器0x0到0x5,看能不能读到正常的PHY ID。如果读到0xFFFF,说明MDIO通路是断的,可能是PHY地址不对,也可能是MDIO引脚复用配置错了。第三步,查看AXI Ethernet的link状态寄存器,确认PHY是否完成了自动协商。
这类问题90%出在PHY复位时序和MDIO地址上,不要一上来就怀疑lwIP代码。
6.2 lwIP 内存不足导致的随机崩溃
lwIP在内存不足时不会像Linux那样报个错就完事,而是直接断言或者随机跑飞。典型表现是:系统跑几分钟后突然死机,重启后又能跑一段时间。
排查方法是先看串口有没有lwIP的assert打印,然后在lwipopts.h里把MEM_SIZE、PBUF_POOL_SIZE、PBUF_POOL_BUFSIZE这三个参数调大。特别是如果开了TCP窗口扩大和大量并发连接,内存池很快会被耗尽。我的建议是内存充足时,MEM_SIZE至少配到几百KB,PBUF_POOL_SIZE不小于32。在MicroBlaze平台,这部分内存默认会放在DDR上,不会太紧张。
6.3 程序固化到 QSPI Flash 的完整流程
调试阶段通过JTAG下载bit和elf就够用了,但产品化肯定要固化。MicroBlaze的固化流程和Zynq不同,不需要FSBL,但要处理好bitstream和elf的打包。
在SDK中,菜单Xilinx -> Create Boot Image,添加分区时先加入FPGA bitstream,再加入应用程序elf。如果应用运行在DDR3上,务必确认elf的加载地址和链接地址一致。生成MCS文件后,用Xilinx -> Program Flash Memory,选择生成的MCS,Flash Type选QSPI,Config Rate可以按板卡默认来。烧写完成后,把板卡启动模式跳线拨到Flash模式,重新上电,MicroBlaze会自动加载FPGA配置并启动应用。
有一个容易踩的坑:如果启动后串口正常打印但网络ping不通,多半是固化时PHY复位时序和JTAG下加载不一样,需要在应用代码里增加延时等待PHY稳定。
6.4 中断优先级与裸机主循环
最后说一个容易被初学者忽略的点:MicroBlaze裸机跑lwIP,不要把所有逻辑都放在中断里。AXI Ethernet的DMA中断触发频率很高,如果在中断回调里直接解析HTTP请求、发送大量数据,会导致其他中断被阻塞,甚至出现新的以太网帧无法及时接收,形成丢包循环。
正确做法是:中断里只做数据到达通知,主循环里轮询处理lwIP的sys_check_timeouts和网络数据。lwIP 1.4.1的RAW API模式下,可以在主循环里不断调用tcp_poll和tcp_recv的回调,虽然看起来像个大轮询,但实际速度和稳定性都远好于在ISR里泡太久。
我在实际使用中的体会是,这一整套链路里,PL侧的时钟和复位设计决定了链路通不通,而软件侧的DMA描述符、lwIP内存配置决定了链路稳不稳。如果你刚开始跑,建议严格按照“先回读PHY ID,再ping通IPv4,最后再搞HTTP和IPv6”的节奏来。先让最小系统稳定,再逐步加功能,这样每次出问题都能快速定位。
最后再分享一个小技巧:如果你在调IPv6时发现主机ping6一直不通,先不要查协议栈,先把开发板的IPv6地址通过串口完整打出来,对比一下系统打印的地址和主机路由表里的地址是否在同一个前缀下。IPv6的排错,第一步永远是确认地址,第二步才是确认路由,别问我怎么知道的,这条路上我栽过太多次。