简介:一套基于STM32与ENC28J60以太网控制器、采用UIP轻量级协议栈实现的嵌入式WEB服务器示例,适合物联网爱好者、嵌入式初学者及需要快速搭建远程控制页面的开发者。项目中STM32通过SPI驱动ENC28J60,UIP负责解析HTTP请求,可实现网页实时显示RTC时间、读取温度传感器,并远程控制LED与蜂鸣器。资源共252个文件,包括C/H源代码、Keil工程文件(uvproj/uvopt)、编译生成文件(o/axf/hex)以及网页素材(shtml/html/css/png),另含PDF说明与使用指南,压缩包约56.68MB,结构清晰便于对照学习。目前已有1779人学习下载。借助该示例可掌握MCU外设配置、网络协议栈移植、HTTP服务搭建及外设驱动等关键技能,为开发完整物联网节点提供可直接参考的工程模板。
STM32+ENC28J60+UIP实现WEB服务器的完整实战笔记:从硬件连接到页面控制
最近在一个环境监测的小项目里,需要在STM32上开一个网页,让用户在浏览器里直接看传感器数据、远程开关继电器。对比了几种方案之后,我选了ENC28J60加UIP协议栈这套经典组合,把WEB服务器跑在了没有操作系统的裸机上。整个过程从原理图确认到网页能稳定控制LED,踩了不少坑,也把协议栈的工作原理摸了个透。这篇文章就把这套项目的完整落地过程写出来,包括硬件连接的注意事项、UIP协议栈的移植逻辑、HTTP请求怎么解析、动态页面怎么做,以及常见的排错思路,给正准备在STM32上做Web功能的朋友一个可以直接抄作业的参考。
1. 为什么选ENC28J60+UIP协议栈:一个了解嵌入式网络入门的黄金组合
先说选型。STM32要做以太网功能,市面上常见的有三条路:一是用自带MAC控制器的STM32芯片配PHY芯片(比如LAN8720),配合LWIP使用;二是用SPI接口的以太网模块,ENC28J60或W5500;三是在外部接一个完整的网络协议处理芯片,比如CH395。这三个方案没有绝对的好坏之分,只看你的需求场景。
这个项目选择ENC28J60+UIP,核心原因有两个:成本低,资源占用小。ENC28J60是一颗Microchip出品的独立以太网控制器芯片,内部集成了MAC和PHY,MCU只需要通过SPI接口读写寄存器,剩下的以太网链路层收发工作交给芯片处理。实际采购价比W5500模块便宜不少,而且驱动逻辑清晰,非常适合学习嵌入式网络原理。W5500的优点是内部硬件实现了TCP/IP协议栈,MCU不用跑协议栈软件,但反过来讲,如果想理解TCP状态机、ARP缓存、数据报分片这些底层机制,W5500遮蔽的东西就太多了。而UIP协议栈是Adam Dunkels写的轻量级TCP/IP协议栈,专门为8位和16位MCU设计,代码量很小,完整编译后ROM占用通常不到20KB,RAM占用取决于配置的连接数量,一般几KB就够用,这对主控芯片的选择非常友好。如果你用STM32F103C8T6这种64KB Flash、20KB RAM的芯片,跑UIP加一个简单的WEB服务器,资源是完全足够的。
另一个让我最终确定这套方案的考虑因素是资料生态。UIP协议栈虽然是老代码,但这么多年积累下来的移植经验非常多,网上能搜到各种芯片平台上的适配案例;ENC28J60的驱动在Arduino社区、STM32社区都有大量验证过的代码可以参考。这意味着即使遇到奇怪的问题,也能找到相似场景的讨论。而且STM32标准库或者HAL库都能很容易地写出SPI驱动的底层接口,整个项目从零到能ping通设备,快的两三天就能跑通。
既然是做WEB服务器,就要先理清楚每个组件在这个系统里的角色。STM32负责运行逻辑:读取传感器、控制GPIO、解析HTTP请求、生成HTML响应;ENC28J60负责物理链路:把MCU发过来的数据帧变成网线上的电信号,以及把网线上的数据帧收进来交给MCU;UIP协议栈居中调度:负责维护IP地址、TCP连接状态、处理ARP请求、组装和拆分TCP/UDP数据报。三者配合起来,本质上就是一台微型但五脏俱全的HTTP服务器,只是性能不能和云服务器类比,同一时刻维持的TCP连接数有限,但用在局域网设备控制上绰绰有余。
2. 硬件连接与电路设计:SPI接线、供电和复位中的关键细节
ENC28J60的硬件连接乍一看很简单,就是一根SPI总线加中断引脚,但有几个地方如果没处理好,后面调试会非常痛苦。我直接把最终验证可用的接线方案整理成了一张表,以STM32F103C8T6为例(注意不同型号的SPI引脚映射可能有差异):
| ENC28J60模块引脚 | STM32引脚 | 说明 |
|---|---|---|
| VCC | 3.3V | 必须3.3V供电,严禁接5V |
| GND | GND | 共地 |
| SCK | PA5 | SPI1_SCK |
| MISO | PA6 | SPI1_MISO,ENC28J60的数据输出 |
| MOSI | PA7 | SPI1_MOSI,数据输入 |
| CS | PA4 | SPI1_NSS,软件控制片选 |
| INT | PA8 | 中断输出引脚,配置为外部中断输入 |
| RST | PB0 | 模块硬件复位脚,可接普通GPIO |
第一处容易出问题的是供电。ENC28J60核心工作电压是3.3V,但很多在网上买的模块板上已经集成了电平转换和稳压电路,有些模块标注可以接5V,这是因为模块自带AMS1117-3.3稳压,5V进去会先降到3.3V给芯片供电,SPI引脚信号也做了电平匹配。但如果你是自己画板子,直接把5V接到VCC上,芯片必烧。这里给一条我自己的习惯性经验:不管模块上有没有稳压芯片,只要芯片手册里标注的VDD最大值是3.6V,就统一用3.3V供电,稳压芯片由模块厂家负责,而我们只需要对自己的电路负责。
第二处是SPI通信速率。ENC28J60物理层最高支持10Mbps,理论上是半双工的10BASE-T以太网,SPI接口的时钟频率在数据手册里标注最高可以到20MHz,但实际操作中不要一上来就直接拉满SPI速率。我用SPI1,分频配置从8分频开始,也就是9MHz左右的主频跑SPI,实测下来非常稳定。至于为什么不能直接跑20MHz,其实这是很多SPI外设的通病,高速率下对PCB布线、杜邦线质量、电源纹波的要求都会变高,电磁干扰稍微一大就会导致寄存器读写错位,出故障时排查起来非常麻烦。低速先把功能跑通,再逐步提速,是嵌入式调试的基本思路。
第三处是中断引脚。ENC28J60收到数据帧后,通过INT引脚向MCU通知,MCU配置外部中断,在中断服务函数里读取接收缓冲区。也有人用轮询方式读取中断标志位,但那会占用大量CPU时间。这里要注意的是,INT引脚必须配上拉电阻,因为ENC28J60的INT输出是开漏结构,不加上拉的话电平状态不确定,中断触发就会时好时坏。如果你买的模块板上没预留上拉电阻位置,可以额外用一个10K欧姆电阻把INT引脚接到3.3V。
还有一个非常容易忽略的硬件细节是复位时序。ENC28J60模块上电后,需要等待芯片内部的PLL锁定时钟,在可靠复位之前不应该对芯片执行任何SPI操作。最稳妥的做法是:MCU先配置一个GPIO连接模块的RST引脚,上电后把RST拉低至少10ms再释放,然后延时50ms再启动SPI初始化。很多现成的代码库只做了一件事,就是直接调用ENC28J60的初始化函数,没有在上电时对模块做一次硬件复位,导致偶尔出现第一次上电初始化失败、复位一下又正常了的怪现象,其实就是复位时序不规范。
3. UIP协议栈移植的关键步骤:理解但不神化uip_task
任何一款开源协议栈移植的第一步都不是写代码,而是先把协议栈的配置文件读懂。UIP协议栈的配置集中在uipopt.h里,其中最关键的是UIP_CONF_MAX_CONNECTIONS、UIP_CONF_BUFFER_SIZE和UIP_CONF_TCP这几个宏。
#define UIP_CONF_MAX_CONNECTIONS 4 #define UIP_CONF_MAX_LISTENPORTS 2 #define UIP_CONF_BUFFER_SIZE 420 #define UIP_CONF_TCP 1 #define UIP_CONF_UDP 0UIP_CONF_MAX_CONNECTIONS决定同一时刻最多支持的TCP连接数。浏览器访问网站时会建立多个连接,有的是主页面请求,有的是页面里面的资源请求,如果这个值设成1,可能页面还没加载完连接就被关闭了。设成4在小型嵌入式Web服务器里比较合理,既保证能同时服务少量客户端,又不会让RAM消耗过多。每个TCP连接在UIP内部会占用一块连接结构体内存,连接数和内存消耗直接挂钩。
UIP_CONF_BUFFER_SIZE是协议栈收发数据的缓冲区大小,这个值的设置直接影响能处理的以太网帧大小。标准的以太网MTU是1500字节,UIP协议栈为了节省内存,缓冲区可以配置得比这个值小,但至少要能容纳一个完整的TCP数据段,常用的大小是420字节左右。基于这个配置,每个TCP分片最大只能携带约360字节的应用数据,对于控制指令和简单的HTML页面来说完全够用。如果你的页面比较大、一次响应超过缓冲区大小,UIP会自动拆成多个TCP分片发送,这个过程对上层是透明的。
接下来是网卡驱动的适配。ENC28J60的驱动要做的事情就是UIP协议栈和芯片之间搭一座桥,具体包括四个函数:
uint8_t enc28j60_init(uint8_t *mac_addr); // 初始化芯片,设置MAC地址 void enc28j60_packet_send(uint8_t *packet, uint16_t len); // 发送数据帧 uint16_t enc28j60_packet_receive(uint8_t *packet, uint16_t max_len); // 接收数据帧 void enc28j60_irq_handler(void); // 中断处理,置位接收标志这些函数本身不难写,难的是理解UIP协议栈的调用方式是事件驱动的。UIP本身不是抢占式操作系统,它靠一个主循环反复调用uip_periodic()和uip_poll()来驱动TCP状态机。标准的UIP主循环结构是这样:
while (1) { if (enc28j60_receive_flag) { uip_len = enc28j60_packet_receive(uip_buf, UIP_CONF_BUFFER_SIZE); if (uip_len > 0) { uip_input(); // 处理接收到的网络数据包 if (uip_len > 0) { enc28j60_packet_send(uip_buf, uip_len); } } enc28j60_receive_flag = 0; } for (i = 0; i < UIP_CONF_MAX_CONNECTIONS; i++) { uip_periodic(i); // 周期处理每个TCP连接 if (uip_len > 0) { enc28j60_packet_send(uip_buf, uip_len); } } }uip_input()处理的是网卡收到的数据包,uip_periodic()处理的是协议栈内部定时触发的事件,比如重传超时、连接保持等。两个函数处理完成后,如果uip_len仍然大于0,说明协议栈有数据要回发到网络上,这时取uip_buf里的内容调用发送函数即可。这个循环结构是整个嵌入式网络程序的核心骨架,把它理解了,后面加TCP服务逻辑就顺理成章了。
还有一个在上层应用中必须处理的事情,就是UIP与MCU的时间基准。UIP协议栈内部的定时器参数(比如重传超时时间)默认是按100Hz的时基设计的,也就是说需要系统提供一个10ms的周期调用uip_periodic()来驱动。这个周期可以用STM32的定时器中断产生,也可以用操作系统的tick,在裸机环境下,我用TIM2产生1ms时基,然后在中断里累加计数,每到10ms就设置一个标志,主循环检测标志后执行连接轮询。
4. WEB服务器的实现:页面里的实时时间与远程控制背后的逻辑
UIP协议栈本身只能处理TCP/IP层的通信,HTTP是应用层协议,需要自己在应用层回调函数里解析和生成HTTP报文。UIP处理机制是这样的:当一个TCP连接收到数据并完成重组后,协议栈会调用uip_appcall(),在这个函数里通过uip_newdata()判断是否有新数据到达,通过uip_connected()判断是否有新连接建立,通过uip_rexmit()判断是否需要重传数据。应用层代码就围绕这几个回调条件做分支处理。
我的设计里,WEB服务器需要完成两件事:在网页上显示STM32内部的实时时钟,以及通过网页上的按钮远程控制GPIO输出。
先说实时时间是怎么实现的。最简单直观的方案是:每次浏览器请求页面的时候,STM32把当前的RTC时间格式化成为字符串,拼接到HTML页面的指定位置,然后整体作为HTTP响应返回。这样用户打开页面看到的是请求那一刻的时间,页面如果不刷新,时间就一直停在某个值。为了让页面显示"有生命力的"时间而不用频繁刷新页面,我还加了一个辅助接口/time,它只返回一串纯文本时间。
页面里的JavaScript通过定时器,比如每秒钟向/time发起一次AJAX请求,拿到STM32返回的新时间字符串,更新到页面元素里。这样实现出来的效果就是网页上的时间在持续走动,而处理器资源消耗很小。相比整体刷新页面,这种异步请求的方式体验要好得多,这也是嵌入式WEB服务里"动态部分接口化"的一个常用模式。
再来看远程控制部分,我采用的是GET请求带参数的方式。点击网页上的"LED开"按钮,浏览器向设备发送一个GET /ctrl?led=1 HTTP/1.1的请求,STM32在解析HTTP请求报文时,发现请求行中的URL路径是/ctrl,就去参数里找led字段,取出值后执行GPIO操作,然后返回一个简短的确认结果。这个方式的优点是逻辑非常简单,不需要POST请求体解析,降低了对协议栈解析能力的要求。
这里给出一个经过精简的HTTP请求解析逻辑,直接在uip_appcall()里处理:
static uint8_t is_http_request(char *data, uint16_t len) { return (data[0] == 'G' && data[1] == 'E' && data[2] == 'T'); } static void http_server_handler(void) { char *request = (char *)uip_appdata; uint16_t len = uip_datalen(); if (uip_newdata()) { if (is_http_request(request, len)) { if (strncmp(request + 4, "/ctrl", 5) == 0) { handle_ctl_request(request, len); } else if (strncmp(request + 4, "/time", 5) == 0) { handle_time_request(); } else { handle_html_request(); } } } }handle_ctl_request的解析方法也很直观,在请求行里找到问号后面的参数字符串,用strstr()在字符串中查找"led=",再根据等号后面的字符判断是1还是0:
static void handle_ctl_request(char *request, uint16_t len) { char *param = strstr(request, "led="); if (param != NULL) { if (param[4] == '1') { GPIO_SetBits(GPIOB, GPIO_Pin_1); } else { GPIO_ResetBits(GPIOB, GPIO_Pin_1); } } send_simple_response("OK"); }需要注意一个细节,UIP的uip_appdata指针指向的是TCP负载数据的起始地址,但这个缓冲区并不是以\0结尾的,很多人在解析时把接口数据当字符串用导致越界读取,这是个很经典的错误。安全做法是先复制到本地数组并手动加\0结尾,再执行字符串函数。
响应数据怎么回发,同样有个套路要学习。UIP是可重入的协议栈,应用层不能直接向连接写入数据,而是要调用uip_send(),把要发送的数据地址和长度告知协议栈,协议栈会在合适的时机把数据封装成TCP段发出。在回调函数里,需要先判断连接当前能否发送数据:
static void send_html_page(void) { static char html_buf[512]; uint16_t len = generate_html(html_buf); uip_send(html_buf, len); }generate_html函数负责用sprintf拼接HTML字符串,把当前时间、开关状态等动态数据填充到模板里。由于是裸机环境,没有文件系统,HTML页面只能以C字符串常量加格式化输出的方式维护,这也是这类轻量嵌入式WEB服务器的通法。
页面本身我做得比较克制,核心是三个部分:一个显示当前时间的区域、两个控制按钮(开和关)、一个状态显示标签。JavaScript部分直接在HTML字符串里内联,浏览器端的定时器每隔1秒去访问/time接口,用XMLHttpRequest对象异步获取时间字符串,再更新到DOM节点上。这部分逻辑跟PC端的WEB开发没有本质区别,唯一要注意的是嵌入式设备的TCP并发能力有限,定时器请求频率不要太高,每秒钟请求一次已经算是比较频繁的了。
5. 调试过程中最常见的五个问题与完整排查链路
我把这套系统跑通前后遇到的典型问题整理了一遍,都是网络上反复出现的高频问题,这里给出我的排查思路和实际解决办法。
5.1 网页打不开,但串口打印显示设备在跑
最常见的情况是SPI通信失败导致ENC28J60没有正常工作。我排查时先在代码里检查ENC28J60的版本寄存器EREVID,正确值应该是0x06左右,如果读出来的值不对,直接去看硬件连接。SPI接线还有一个隐蔽问题,就是MISO和MOSI接反了,很多模块引脚标注不清晰,一眼看上去容易搞错。另外确认STM32的SPI主机模式是不是配置正确,极性、相位要和ENC28J60匹配,STM32的SPI配置为模式0即可,即CPOL=0、CPHA=0。
5.2 能ping通,但浏览器访问不到页面的排查链路
这里的有效排查路径是:先ping通表明ARP和ICMP协议已经正常工作了,IP层的收发没问题,问题出在TCP层或者HTTP层。我按照"连接建立→收到请求→发出响应"的顺序排查,先看端口号,UIP配置的监听端口默认是UIP_HTONS(80),注意字节序问题,主机字节序的80转换成网络字节序就是0x0050,在代码里写端口时必须用UIP_HTONS(80)而不是直接给80。然后检查TCP校验和计算,UIP协议栈自带软件校验和函数,但校验和计算依赖底层网卡提供的正确数据,如果MAC地址配置有问题,数据帧可能被路由器丢弃。
还有一个容易忽略的地方是连接数配置。如果UIP_CONF_MAX_CONNECTIONS设置太小,比如只有1,浏览器发起一个新请求时会发现设备无法再接受新连接,表现为页面一直转圈加载不出来。
5.3 连接建立了,但页面显示乱码或只显示了一半
这个问题几乎可以肯定是HTTP响应格式不对。浏览器按标准HTTP协议解析数据,如果响应头里没写Content-Type,浏览器不知道返回的是HTML,可能直接把源码显示出来;如果没有正确设置Content-Length,或者传输结束后没有正常关闭连接,浏览器就不知道响应何时结束。我用的简化方案是设置Connection: close,处理完一次请求后主动断开TCP连接,让浏览器根据连接关闭判断响应结束,这种方法实现简单、不需要计算Content-Length,非常契合UIP这种简洁协议栈的风格。
5.4 页面能打开,但点击控制按钮没反应
如果打开页面正常,说明HTTP读路径通了,问题出在写路径。我先用浏览器开发者工具看点击按钮后发出的请求路径,再用串口把接收到的请求内容打印出来,核对URL参数。这里很容易遇到一个编码陷阱,就是浏览器会对URL特殊字符做百分号编码,如果参数值里有空格或中文,解析逻辑没做解码就会匹配失败。我的解决方案是参数值都用纯ASCII字符,比如1、0、on、off,从源头杜绝编码问题。
5.5 设备运行一段时间后网络卡死或者ping不通
典型的UIP协议栈资源泄漏问题。UIP的连接表在连接关闭后如果没有正确清理,旧连接状态会一直占用连接槽位,时间久了所有连接槽位都被占满,设备就拒绝新连接了。排查方法是周期打印连接表状态,看看空闲连接数是不是在持续减少。另一个常见原因是HTTP响应数据量过大,超出了缓冲区大小,重传逻辑又没处理好,导致TCP发送队列卡住。把页面精简到每个响应不超过一个TCP分片能承载的大小,能规避绝大多数发送异常。
排查网络问题时我自己的一个习惯分享给大家:先用串口在应用层回调里打印每个连接事件的uip_flags值,再用电脑端的网络抓包工具(比如Wireshark)看报文交互过程,两边对照就能很快定位问题在协议栈内部还是外部的报文交互。只要建立起"链路层通→网络层通→传输层通→应用层通"逐层排查的思维框架,这类问题都不难找到突破口。
做完这个项目之后我自己最大的体会是,嵌入式WEB服务器最值钱的部分并不是"能打开一个网页"这个结果,而是理解了从物理层的信息到网线再到浏览器渲染,中间每一层做了什么、为什么要这么设计。基于这套基础,后续想扩展成通过网页远程配置设备参数、显示传感器实时曲线,或者接入更上层的物联网平台,都有了一条清晰的技术路径。如果大家在自己复现的过程中也遇到了奇怪的现象,欢迎带着你们抓到的报文数据来交流,一起把这个方案玩得更透。
本文还有配套的精品资源,点击获取