简介:面向嵌入式网络开发与单片机入门的读者,基于STM32F103和ENC28J60搭建了一个极简Webserver,访问网页即可配置IP地址、子网掩码和默认网关,代码量很小,ROM占用仅6KB左右。压缩包共91个文件,包含38个C源码、36个头文件、6个汇编文件,以及Keil工程文件、hex固件、脚本和说明文档,整体仅321KB,目录按Libraries、TCPIP、SPI、Timer等模块分门别类,便于阅读。已有332人学习下载,适合需要快速上手以太网应用或借鉴精简协议栈实现思路的开发者。资源内附使用说明与相关配置文档,可直接打开工程编译,烧录到板子即可体验网页配置效果;代码从主循环、协议栈处理到底层SPI读写ENC28J60链路完整,并包含RTOS调度、定时器、LED反馈等可复用模块,既能作为毕业设计参考,也可作为课程实验拓展。通过分析HTTP请求与响应流程,还能进一步理解嵌入式Web配置的原理。
1. 一块 20 元的以太网卡,能让 STM32F103 自己开一个网站
STM32F103+ENC28J60+Webserver 这套组合,是嵌入式开发者绕不开的入门网络方案:一片 20 元左右的 ENC28J60 模块,接在 F103 的 SPI 口上,就能让单片机变成一个能回答 HTTP 请求的 Web 服务器。你可以用它做设备状态监控页、远程参数配置、传感器数据展示,甚至一个简单的控制面板。它解决的是“没有 Linux、没有屏幕,也要有网页界面”的需求,适合有 F103 基础、想真正搞懂 TCP/IP 协议栈的人——因为这套方案把协议栈放在 MCU 里跑,你会看到 ARP、ICMP、TCP、HTTP 是怎么一层层协作的。下面沿着选型、硬件、固件、排错的路径,把能直接复现的步骤和坑都写出来。
2. 选型与原理:为什么是 F103 + ENC28J60,而不是片上 MAC
2.1 F103 没有片上 MAC,外挂以太网控制器是常见做法
STM32F103 系列内部并没有以太网 MAC 外设,只有 F107 和部分 F407 才带 MAC。因此想在 F103 上联网,常见做法是外挂一颗“MAC+PHY 一体”的以太网控制器,通过 SPI 连接。相当于给 MCU 加了一张网卡,MCU 通过 SPI 读写这张网卡的寄存器,网卡替你把数据变成以太网帧发包收包。
为什么选 ENC28J60 而不是其他方案?因为它结构简单,SPI 接口,引脚少,模块成熟便宜。相比之下,一些内置 TCP/IP 硬件协议栈的芯片(例如 W5500)虽然让 MCU 省心,但价格高,而且协议栈是黑匣子,调试时没法看到 TCP 状态机。ENC28J60 只负责收发链路层帧,TCP/IP 协议栈必须由 MCU 用软件实现——这反而成了学原理的最佳路径。实际产品中,如果你只是需要“网页开关灯”,W5500 可能更快;但如果你要的是可控、可学习、能灵活裁剪,ENC28J60 更合适。
2.2 ENC28J60 的内部结构:10Mbps 半双工,8KB 缓冲要精打细算
ENC28J60 内置 MAC 和 PHY,支持 10BASE-T,半双工或全双工(但大多数模块默认半双工)。它内部有一片 8KB 的 TX/RX 共用 SRAM,由 SPAM 寄存器分配。TCP/IP 协议栈跑在 F103 上,需要为每个连接分配发送缓冲和接收缓冲,整个内存管理要围绕这 8KB 来设计。
SPI 命令主要有四类:读控制寄存器(RCR)、写控制寄存器(WCR)、读缓冲存储器(RBM)、写缓冲存储器(WBM)。控制寄存器地址是 5 位,通过命令字节的低 5 位指定。缓冲区读写则先在 ERDPST/EWRPT 寄存器里设置 13 位地址指针,然后连续 SPI 读写。这个机制和普通 SPI Flash 很像,但要注意:每次访问缓冲区之前都要重新设置指针,不自动递增到下一个地址区域。常见失误是把缓冲区地址读写当成了普通内存操作。
10Mbps 对 Web 服务器来说够用吗?一个 HTTP 响应页面如果 2KB,理论上 10Mbps 需要约 1.6ms,但事实上 TCP 协议栈处理、SPI 传输、MCU 频率都会拉低实际吞吐。F103 主频 72MHz,SPI 跑 10MHz 左右,实测网页单次请求的延迟在几毫秒到几十毫秒之间,完全能满足浏览器点击刷新的场景。但不要想着用它传大文件,超过几十 KB 的响应会让缓冲区和内存都吃紧。
2.3 协议栈选型:uIP、lwIP 还是手写?
这是整个方案最关键的决策。常见做法是移植 uIP,原因如下表:
| 方案 | 内存占用 | 难度 | 功能 | 适用场景 |
|---|---|---|---|---|
| uIP | 约 5~8KB RAM | 低 | TCP/UDP/ICMP/ARP,事件驱动 | F103 裸机 Web 服务器 |
| lwIP | 15~30KB RAM,依赖配置 | 中高 | BSD socket 接口,多线程可选 | 有 RTOS 或 RAM 较大的 MCU |
| 手写协议栈 | 可极小 | 极高 | 仅能跑通几帧 | 教学实验,不适合产品 |
F103 的 RAM 只有 20KB,lwIP 配置精简后也能跑,但 uIP 更小更直接。uIP 是事件驱动的,它维护一个全局状态,每次协议栈轮询时检查是否有新数据、是否有定时事件,然后调用上层回调。你不需要管理多线程,回调函数里做完一件事就返回,这符合裸机编程习惯。
uIP 的典型限制是每个连接只有一个 TCP 窗口(约 2 字节?不,uIP 默认窗口大小可配置,常设为 2~6 字节,实际是 TCP 分段大小)。它会缓存一部分发送数据,应用层要自己控制发送节奏。对于 HTTP 响应这种“一次性发送整页”的场景,uIP 提供可重载的 uip_send 接口,配合缓冲区能比较顺畅。实际项目中,我在多个模拟项目X 里用 uIP,稳定运行几年没出过协议栈崩溃,反而因为驱动层的小坑翻过车。
3. 硬件连接与最小电路:从原理图到上电检查
3.1 STM32F103 与 ENC28J60 的 SPI 引脚分配
我用的是 SPI1,因为 SPI1 挂在 APB2 上,时钟最高 72MHz,比 SPI2(APB1,36MHz)快,而且管脚靠近调试口,接线方便。一张常用引脚表如下:
| STM32F103 引脚 | 功能 | 接 ENC28J60 模块 |
|---|---|---|
| PA5 | SPI1 SCK | SCK |
| PA6 | SPI1 MISO | SO |
| PA7 | SPI1 MOSI | SI |
| PA4 | 软件 CS | CS |
| PA0 | 外部中断(可选) | INT |
| PB0 | 复位(可选) | RST |
CS 我习惯用 PA4,因为 PA4 是片选引脚且不占用 SPI 硬件的 NSS,纯软件控制更自由。INT 引脚建议必接,接到 STM32 的外部中断输入,这样 ENC28J60 一有接收事件就能立刻通知 MCU,避免用轮询浪费 CPU。如果引脚紧张,INT 可以不接,通过周期读操作符判断接收缓冲区数据,但响应延迟会高,且容易漏包。
复位引脚 RST 不能悬空。ENC28J60 有内部上电复位,但外部复位引脚悬空容易受干扰,导致偶发性寄存器和状态错乱。我一般接到 MCU 的 GPIO,上电初始化时拉低 10ms 再拉高,让芯片状态可预期。
3.2 供电、晶振与网络隔离:三个不能省的地方
ENC28J60 是 3.3V 器件,但内部有 1.8V 稳压器,所以功耗不低。模块工作电流大约 150~180mA,如果用一个线性稳压器从 5V 降到 3.3V,注意稳压器必须能提供至少 200mA 电流,并且输入输出电容不能省。我见过某开发板因为把模块和 MCU 共用一个 50mA 的 LDO,结果一插网线就复位。
模块上的晶振必须是 25MHz,不是常见的 8MHz。ENC28J60 的 PHY 需要 25MHz 基准时钟,有些模块用无源晶振,有些用有源振荡器。如果模块能跑通寄存器读写但 ping 不通,先怀疑晶振是否起振,用示波器或万用表频率档测引脚。
网络变压器集成在 RJ45 座子里,大部分模块都有。注意区分带不带磁珠或共模电感。如果自己画板子,网口变压器的中心抽头接地方式要严格按手册,接错会导致连不上或发热。
供电滤波:模块电源脚附近加 10uF 钽电容多 0.1uF 陶瓷电容,离 SCK 和 MISO 远一点。SPI 信号是 3.3V,STM32 也是 3.3V,不需要电平转换,但信号线长度尽量控制在 5cm 以内,线太长反射会导致高速位错误。
3.3 上电自检:先读寄存器,再谈协议栈
硬件完成后,不要急着烧协议栈,先写一个 20 行的寄存器自检程序确认 SPI 通信正常。常见做法是:初始化 SPI,然后读 ENC28J60 的版本寄存器(寄存器地址 0x1F),返回值通常是 0x06(对应 Rev B7)。如果读到 0xFF 或 0x00,说明 SPI 时序、引脚配置或供电有问题。
下面是最小自检代码:
#include "spi.h" #define ENC_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define ENC_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4) uint8_t enc_read_reg(uint8_t reg) { uint8_t val; ENC_CS_LOW(); spi_xfer(0x00 | (reg & 0x1F)); // 读寄存器命令,低5位为地址 val = spi_xfer(0xFF); // 从机返回数据 ENC_CS_HIGH(); return val; } int main(void) { spi1_init(SPI_BaudRatePrescaler_8); // 72MHz / 8 = 9MHz uint8_t rev = enc_read_reg(0x1F); if (rev == 0x06) { // LED 亮:SPI 通信正常 } else { // 死循环,方便查硬件 while (1); } }逻辑说明:SPI 时钟先设成 9MHz,够稳定也够快。读寄存器命令的高 3 位是 000,低 5 位是地址。写入命令字节后,从机在下一个时钟沿返回数据。如果返回值不是 0x06,重点排查 MISO 线是否接对、模块是否供电、CS 极性是否正确。有些模块的 SO 引脚没有推挽输出,需要把 SPI 的 MISO 配置为浮空输入,这个在 STM32 里是默认的,但如果你用库函数配置成上拉输入,电平逻辑不会受影响,不过上拉电阻可能跟 ENC28J60 的输出打架,遇到 MISO 一直被拉低的情况检查这里。
4. 固件实现:移植协议栈并跑通一个最小 Web 服务器
4.1 工程结构:协议栈源码与你自己的驱动文件
我见过很多人直接把 uIP 源码整个扔进工程,然后不知道怎么裁剪。实际常用做法是保留uip.c、uip_arp.c、uipopt.h、clock.c,再加上你写的enc28j60.c和httpd.c。工程文件至少包含以下几类:
| 文件 | 作用 |
|---|---|
stm32f10x_spi.c等 HAL/StdPeriph 驱动 | 初始化 SPI、GPIO、中断 |
enc28j60.c | 实现寄存器读写、缓冲区读写、MAC 控制 |
uip.c/uip_arp.c | 协议栈核心:处理 IP、TCP、ARP、ICMP |
httpd.c | uIP 的应用层回调:生成 HTTP 响应 |
tapdev.c | 协议栈与网卡驱动之间的桥接,提供tapdev_init、tapdev_read、tapdev_send |
uIP 的移植点主要集中在uipopt.h里配置内存、窗口大小,以及tapdev.c中调用你的 ENC28J60 驱动。协议栈每 100ms 会调用一次uip_periodic()处理超时,每次轮询服务器回调httpd_appcall。
4.2 编写 SPI 底层驱动:读写寄存器与缓冲区
EN C28J60 驱动最关键的函数只有三个:寄存器写、缓冲区读、缓冲区写。下面是一个精简但完整的驱动片段:
/* enc28j60.c */ void enc_write_reg(uint8_t reg, uint8_t val) { ENC_CS_LOW(); spi_xfer(0x40 | (reg & 0x1F)); // 写寄存器命令:010xxxxx spi_xfer(val); ENC_CS_HIGH(); } uint8_t enc_read_reg(uint8_t reg) { uint8_t val; ENC_CS_LOW(); spi_xfer(0x00 | (reg & 0x1F)); // 读寄存器命令 val = spi_xfer(0xFF); ENC_CS_HIGH(); return val; } void enc_read_buf(uint16_t addr, uint8_t *buf, uint16_t len) { ENC_CS_LOW(); enc_write_reg(ERDPT_L, addr & 0xFF); enc_write_reg(ERDPT_H, addr >> 8); spi_xfer(0x3B); // RBM 命令 while (len--) { *buf++ = spi_xfer(0xFF); } ENC_CS_HIGH(); } void enc_write_buf(uint16_t addr, const uint8_t *buf, uint16_t len) { ENC_CS_LOW(); enc_write_reg(EWRPT_L, addr & 0xFF); enc_write_reg(EWRPT_H, addr >> 8); spi_xfer(0x7B); // WBM 命令 while (len--) { spi_xfer(*buf++); } ENC_CS_HIGH(); }逻辑说明:enc_write_reg和enc_read_reg是最基本的操作,测试时先调通它们。enc_read_buf先设置读指针ERDPT,然后发 RBM 命令连续读。注意ERDPT是两个寄存器:低位和高位,分别对应地址的低 8 位和高 8 位。缓冲区地址是 13 位,最大 8191,对应 8KB 的 SRAM。每次读操作都要重新设置指针,因为协议栈可能任意切换读地址。enc_write_buf同理,先设置写指针。
参数说明:ERDPT_L的地址是 0x00?不,具体寄存器编号由你的头文件定义,例如#define ERDPT_L 0x00等,我这里省略宏定义。实际项目中,我喜欢把命令字节和寄存器地址分离成可读宏,方便调试。spi_xfer是标准库里的SPI_I2S_SendData+SPI_I2S_ReceiveData循环,但要注意发送后要等待SPI_I2S_GetFlagStatus(SPI_I2S_FLAG_TXE)清空,否则数据会错位。
4.3 实现 HTTP 应答:解析请求、返回页面
uIP 的应用层回调结构是固定的:当协议栈收到一个连接的新数据,会调用你的httpd_appcall。在这个函数里,你可以用uip_newdata()判断是否有新数据,然后用uip_appdata指针读取请求内容。下面是一个最小 HTTP 服务器核心:
/* httpd.c */ #include "uip.h" #include "httpd.h" #define HTTP_OK "HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n" #define HELLO_PAGE "<html><head><title>STM32 Web</title></head>" \ "<body><h1>Hello from F103</h1>" \ "<p>Count: %d</p></body></html>" void httpd_appcall(void) { static unsigned int count = 0; if (uip_newdata()) { /* 请求内容在 uip_appdata,例如 GET / ... */ count++; /* 清空发送缓冲区 */ uip_send(NULL, 0); /* 先用一个固定发送缓冲区装入响应 */ char buf[256]; unsigned len = snprintf(buf, sizeof(buf), HTTP_OK HELLO_PAGE, count); /* 注意:uip_send 必须在协议栈每次轮询时调用一次 */ uip_send(buf, len); } if (uip_acked()) { /* 确认收到数据 */ } if (uip_closed() || uip_timedout()) { /* 连接关闭或超时,协议栈会自动释放 */ } }逻辑说明:uip_send是协议栈的发送接口,它会把数据复制到发送缓冲区,并在后续 TCP 状态中分片发送。这里有个坑:uip_send一次发送的数据量不能超过UIP_TCP_MSS(一般在uipopt.h配置为 200~500 字节)。如果页面超过 MSS,会被截断。正确的做法是分多次发送,每轮协议栈轮询时发一段。上面的例子用了 256 字节的缓冲区,页面小,刚好能放进一个 MSS。
参数说明:HTTP_OK中的Connection: close很重要。因为 uIP 不支持 HTTP 长连接,浏览器收到Connection: close后会在关闭 TCP 连接前解析完数据,而 uIP 会在发送完响应后由应用层主动调用uip_close()。如果不写这个头,浏览器可能一直等你放完页面,导致刷新卡顿。
另一个要点:uip_send(NULL, 0)这一行是 uIP 的标准“打招呼”方式,它告诉协议栈本次轮询没有实际数据,但协议栈会尝试发送之前缓存的未确认数据。如果你在uip_newdata()里直接uip_send大包而不处理上次发送未完成的情况,数据会覆盖,必须遵循“每个 TCP 连接,在收到 ACK 并进入uip_acked()回调后,才能发送下一段”的规则。新手项目建议一次请求只发一页,发完就uip_close()。
4.4 让网页动起来:AJAX 轮询与 JSON 输出
静态页面只能点一次显示一次,如果要做温度曲线、开关状态实时刷新,最常见的做法是用 AJAX 轮询。你可以在网页里用 JavaScript 定时请求/data,服务器返回一小段 JSON,浏览器解析后更新 DOM。这种方法对 ENC28J60 这种低性能网卡很友好,因为每次请求只返回几十字节。
MCU 端在请求 URL 里判断路径:
if (uip_newdata()) { char *req = (char *)uip_appdata; if (strstr(req, "GET /data")) { char json[80]; int len = snprintf(json, sizeof(json), "{\"temp\":%.1f,\"hum\":%.1f,\"led\":%d}", temp, hum, led_state); uip_send(json, len); uip_close(); } else { /* 返回主页 */ } }网页端用 XMLHttpRequest:
setInterval(function () { var xhr = new XMLHttpRequest(); xhr.open("GET", "/data", true); xhr.onload = function () { var obj = JSON.parse(this.responseText); document.getElementById("temp").innerText = obj.temp; document.getElementById("led").className = obj.led ? "on" : "off"; }; xhr.send(); }, 1000);逻辑说明:轮询间隔不要小于 500ms,因为 ENC28J60 + uIP 建立一次 TCP 连接需要至少几次协议栈轮询,太快会产生大量 TIME_WAIT 连接,最终耗尽连接表。JSON 输出要小而精,不要包含多余的空格,节省带宽和处理时间。uip_close()放在发送后,让协议栈在数据确认后自动关闭,这是最干净的方式。
5. 联调避坑:ENC28J60 网络不通的常见问题与排查
5.1 现象:SPI 能读写寄存器,但 ping 不通
原因一:MAC 地址没配置或配置为全零。uIP 和 ENC28J60 驱动的 ARP 缓冲会广播 MAC 地址请求,如果 MAC 是全零,路由器会静默丢弃 ARP 响应。解决:在初始化时写一个独特的 MAC,例如{0x02, 0x00, 0x00, 0x12, 0x34, 0x56},第一个字节低 2 位必须有 1,确保是本地 MAC,而不是组播地址。原因二:ENC28J60 的接收过滤器没开。默认只接收单播和广播,但如果初始化代码把 ERXFCON 写错,可能过滤掉 ARP 包。解决:读 ERXFCON,确认UCEN和BCEN位为 1。原因三:网络变压器引脚虚焊,看起来连接但实际没有参与链路协商。解决:用网线测试仪或换一个确认好的网口模块交叉测试。
5.2 现象:能 ping 通,但浏览器打不开页面
这是最常见的翻车点。能 ping 通说明 ARP 和 ICMP 正常,TCP 连接建立没问题,问题出在 HTTP 层。原因:响应头不完整或没有Content-Length。uIP 发送完数据后就关闭连接,浏览器不知道内容长度,可能一直等待,直到超时显示错误。解决:参考 4.3 节,在响应头里加Content-Length字段,并确认页面字节数正确。我通常先发 HTTP 头,再发页面,不拼成一个字符串,因为页面可能很大,分两段发送更符合 uIP 的 MSS 限制。
另一个原因:uip_close()调用时机不对。如果在uip_send之后立即uip_close(),协议栈可能还没把数据通过 TCP 分段发出去就关闭了连接。正确做法是设置一个标志位,当发送回调完成后在uip_acked()里再调用uip_close()。uIP 提供uip_close()只在连接状态为 ESTABLISHED 时有效,如果发送缓冲没发完就调用,剩余数据会丢失。
5.3 现象:网页刷新几次后就死机,必须复位
原因:内存碎片或连接表耗尽。uIP 支持的并发 TCP 连接数是固定的(默认 4 个),如果浏览器比 uIP 更早发起连接或重复连接,连接表满了,未释放的旧连接占用缓冲。解决:在httpd_appcall中处理uip_timedout()和uip_aborted(),在这些回调里释放连接。uIP 会自动回收,但如果你在回调里依赖了全局指针,可能没清空。另一个死机原因:HTTP 请求数据超过 uIP 缓冲,导致越界写。解决:在接收数据时检查uip_len,超过UIP_BUFSIZE - MSS就主动关闭连接。
我在某图像处理 Demo 里遇到过:浏览器预取了/favicon.ico,MCU 端没有处理这个路径,返回了错误响应,随后 TCP 状态机卡住。解决:在 HTTP 解析里对未识别的路径统一返回 404,不要让协议栈挂起。
5.4 现象:数据传输偶尔丢包,刷新时快时慢
原因:SPI 速率过高。ENC28J60 的最高 SPI 时钟约 20MHz,但实际 PCB 布线和模块质量决定了上限。STM32 SPI1 使用 APB2 时钟 72MHz,预分频 2 就是 36MHz,超出芯片能力,会产生读写错误。解决:把 SPI 分频设到 4 或 8,也就是 18MHz 或 9MHz。优先用 9MHz,稳定性和速度兼顾。原因二:中断优先级错误。外部中断 PA0 触发后,协议栈轮询在定时器中断里,如果外部中断优先级太低,可能在读取接收缓冲区时被其他中断打断,导致缓冲区指针错乱。解决:把 EXTI0 的抢占优先级设为最高或次高(在 Cortex-M3 只有 4 位优先级时,设为 1 即可),并保持协议栈轮询时关闭中断。
5.5 现象:ENC28J60 芯片发热明显,甚至烫手
原因:内部 1.8V 稳压器功耗大,这是正常现象,但超过 70°C 就有问题。检查点一:电源电压。如果模块标称 3.3V 却接到了 5V,内部稳压器会发热严重。检查点二:网络变压器中心抽头。有些模块的差分线电压异常,导致 PHY 过流,发热且连接不稳定。检查点三:SPI 线的灌电流。如果 MISO 引脚同时被上拉电阻拉到 3.3V,而 ENC28J60 输出又试图拉低,会在电平转换中形成大电流。解决:拿掉 MISO 上的上拉电阻,确保 SPI 信号线没有外部上拉。
6. 让 Web 服务器更实用:动态参数、网页配网与性能优化技巧
6.1 用 GET/POST 处理表单提交
静态页面只能看,不能控制。常见做法是把需要修改的参数通过 URL 查询字符串传进来,例如GET /set?led=1&pwm=128。解析时先找?,然后按&分割,再用strstr匹配字段名,把值转成整数或浮点。注意 URL 编码:浏览器会把空格和中文编码成%20、%E4%B8%AD,需要写一个urldecode函数转换。
设置完成后,要把参数保存到掉电不丢失的存储。F103 内部 Flash 有页擦除操作,通常把参数放在最后一个 Flash 页。写入前先擦除旧页,再把所有参数一起写回。注意 Flash 写入前必须擦除整页,而擦除和写入时不能同时访问代码,所以这段过程要关中断或放在 RAM 中执行。参数结构体建议用__attribute__((packed))定义,方便偏移计算。
网页端表单提交代码:
function sendParam(name, value) { var xhr = new XMLHttpRequest(); xhr.open("GET", "/set?" + name + "=" + value, true); xhr.onload = function () { /* 显示结果 */ }; xhr.send(); }6.2 性能与可靠性优化:SPI 时钟、缓冲区和看门狗
如果你要把这个服务器用在现场,三个优化点必须处理。第一,SPI 时钟在 9MHz 以上时必须检查读寄存器回读一致性,连续读 100 次,如果出现一次错误就降频。第二,ENC28J60 的接收缓冲区分配要对称:把 RX 缓冲区设在低地址,TX 缓冲区设在高地址,避免 TX 覆盖 RX。我建议 RX 占 6KB,TX 占 2KB,因为 Web 服务器接收请求数据小,发送页面可能较大,但 uIP 的 MSS 很小,2KB TX 足够。第三,看门狗策略:独立看门狗(IWDG)必须喂,但只能在协议栈轮询完成后喂,不要在中断里喂,否则一个卡死的循环也能不断喂狗,导致系统永远不重启。
6.3 验证方法:用 curl 和抓包工具检查 HTTP 时序
联调最后一步,用电脑命令行工具验证,比浏览器更准确。在电脑上执行curl -v http://192.168.1.100/,输出会显示 TCP 握手、HTTP 响应头和页面内容。重点看响应头是否包含Connection: close,以及页面字节数是否和Content-Length一致。如果 curl 显示Empty reply from server,说明 TCP 连接被提前关闭,数据没发出去。
抓包工具推荐 Wireshark,过滤tcp.port == http and ip.src == 192.168.1.100。观察 TCP 会话是否存在重复 ACK、重传现象。如果在三次握手后就有重传,多半是 MSS 或 TX 缓冲配置问题。稳定的联调结果是:一次请求对应一个 TCP 连接,数据段发送一次后收到 ACK,然后正常 FIN。没有重传、没有乱序,就说明 uIP 和 ENC28J60 驱动已经磨合到位。
我自己的血泪经验是:每次烧录新固件前,先跑一遍 3.3 节的寄存器自检,然后用 curl 测一遍基本页面。这么做能排查掉 80% 的“玄学”问题。忘掉寄存器自检直接调试网络栈,遇到问题会很难分清是硬件还是协议栈的锅。这套 STM32F103+ENC28J60+Webserver 方案,越是把地基打牢,后面越不用返工。希望帮到你。
本文还有配套的精品资源,点击获取