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 自己开一个网站STM32F103ENC28J60Webserver 这套组合是嵌入式开发者绕不开的入门网络方案一片 20 元左右的 ENC28J60 模块接在 F103 的 SPI 口上就能让单片机变成一个能回答 HTTP 请求的 Web 服务器。你可以用它做设备状态监控页、远程参数配置、传感器数据展示甚至一个简单的控制面板。它解决的是“没有 Linux、没有屏幕也要有网页界面”的需求适合有 F103 基础、想真正搞懂 TCP/IP 协议栈的人——因为这套方案把协议栈放在 MCU 里跑你会看到 ARP、ICMP、TCP、HTTP 是怎么一层层协作的。下面沿着选型、硬件、固件、排错的路径把能直接复现的步骤和坑都写出来。2. 选型与原理为什么是 F103 ENC28J60而不是片上 MAC2.1 F103 没有片上 MAC外挂以太网控制器是常见做法STM32F103 系列内部并没有以太网 MAC 外设只有 F107 和部分 F407 才带 MAC。因此想在 F103 上联网常见做法是外挂一颗“MACPHY 一体”的以太网控制器通过 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 主频 72MHzSPI 跑 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 只有 20KBlwIP 配置精简后也能跑但 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比 SPI2APB136MHz快而且管脚靠近调试口接线方便。一张常用引脚表如下STM32F103 引脚功能接 ENC28J60 模块PA5SPI1 SCKSCKPA6SPI1 MISOSOPA7SPI1 MOSISIPA4软件 CSCSPA0外部中断可选INTPB0复位可选RSTCS 我习惯用 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.3VSTM32 也是 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、ICMPhttpd.cuIP 的应用层回调生成 HTTP 响应tapdev.c协议栈与网卡驱动之间的桥接提供tapdev_init、tapdev_read、tapdev_senduIP 的移植点主要集中在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_SendDataSPI_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 htmlheadtitleSTM32 Web/title/head \ bodyh1Hello from F103/h1 \ pCount: %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 { /* 返回主页 */ } }网页端用 XMLHttpRequestsetInterval(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.icoMCU 端没有处理这个路径返回了错误响应随后 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?led1pwm128。解析时先找?然后按分割再用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 占 6KBTX 占 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% 的“玄学”问题。忘掉寄存器自检直接调试网络栈遇到问题会很难分清是硬件还是协议栈的锅。这套 STM32F103ENC28J60Webserver 方案越是把地基打牢后面越不用返工。希望帮到你。本文还有配套的精品资源点击获取