news 2026/10/22 5:42:39

STM32F103+ENC28J60实现嵌入式Web服务器:从原理到排错

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103+ENC28J60实现嵌入式Web服务器:从原理到排错

简介:面向嵌入式网络开发与单片机入门的读者,基于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 服务器
lwIP15~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 模块
PA5SPI1 SCKSCK
PA6SPI1 MISOSO
PA7SPI1 MOSISI
PA4软件 CSCS
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.cuIP 的应用层回调:生成 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 方案,越是把地基打牢,后面越不用返工。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 5:42:39

2S电池组主动均衡实战:MP2642GR-Z与STM32F407方案详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 5:42:38

C++数论基础:质因数分解、最大公约数、最小公倍数与因数求解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 16:01:26

JS UUID生成全指南:四种方案对比与避坑实操

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/19 5:24:12

视觉SLAM中Sophus库SO(3)与SE(3)位姿表示及优化实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 10:58:24

C/C++课程设计:从能跑通到可交付的工程能力实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/18 9:28:37

Unity3D FPS完整版工程下载避坑指南:从选型到跑通

简介&#xff1a;这是一份基于Unity3D的FPS游戏完整源代码包&#xff0c;面向希望掌握第一人称射击游戏开发的中高级开发者&#xff0c;可用于毕业设计、课程项目或独立游戏起步参考。资源围绕Unity3D在FPS领域的关键技术展开&#xff0c;从场景构建、角色控制器、射击机制、网…

作者头像 李华