简介:面向嵌入式与物联网初学者的STM32智能家居毕业设计完整源码包,基于STM32+ENC28J60+LWIP协议栈,通过网页控制LED灯并实时刷新时间与温度,解决嵌入式设备Web远程控制问题。包内共567个文件,压缩包10.88MB,以C语言源文件(h、c)为主,另有编译中间文件(o、crf、dep)、HTML网页及图片、Keil工程配置(uvproj、uvopt)等,结构完整可直接编译烧录。网页采用记事本编写的HTML,通过AJAX实现少量数据局部刷新,而非整页重载;网页与图片经makefsdata工具转码为C数组存储在单片机内部,便于理解轻量级嵌入式Web服务器原理。整个项目涵盖LWIP协议栈移植、ENC28J60以太网驱动、STM32外设配置及Web交互逻辑,适合学习物联网远程控制的开发者参考。已有1165人学习,是上手嵌入式Web应用的实战样例。
1. 项目整体架构与选型思考
1.1 为什么用ENC28J60而不是W5500或ESP8266
先说结论:这套方案并不是性能最强的,但却是学习价值最高的。STM32+ENC28J60+LWIP的组合,几乎是嵌入式网络开发的标准教材级搭配。
几年前我做这个智能家居项目时,市面上其实已经有W5500这种全硬件TCP/IP协议栈芯片,也有ESP8266这种自带WiFi的模块。但我最终选了ENC28J60,原因有三个:第一,ENC28J60是纯SPI接口的以太网控制器,所有TCP/IP协议栈都得自己在MCU上跑,这意味着你能真正理解一个数据包从网线到应用层的完整路径;第二,LWIP是开源的轻量级协议栈,源码完全开放,出了问题可以直接跟踪到协议层;第三,这套组合的移植文档和社区案例非常丰富,遇到问题几乎都能搜到解决方案。
再来说说STM32的选型。我用的是一片STM32F103C8T6,也就是大家常说的“蓝色药片”,主频72MHz,Flash 64KB,RAM 20KB。这个配置跑LWIP加上HTTP服务器其实是有点吃紧的,但正因为紧张,你才会被迫去优化内存分配、精简协议栈配置,这个过程学到的东西比用大容量芯片跑通功能多得多。
1.2 整体数据流设计
这套系统的核心思路可以概括为:浏览器作为遥控器,STM32作为管家,ENC28J60作为门铃和电话线。具体的数据流向是这样的:
下行控制链路(网页 -> 设备)
用户在浏览器页面上点击“开灯”按钮,JavaScript发起一个AJAX请求,请求发到ENC28J60的某个端口,LWIP协议栈解析这个HTTP数据包,交给应用层的HTTP服务器程序,程序从URL参数中提取出控制指令,通过GPIO或继电器驱动电路改变设备状态。
上行数据链路(设备 -> 网页)
STM32周期性采集温湿度传感器数据(比如DHT11)、继电器状态、光照强度等,存入全局变量。当浏览器的AJAX请求轮询时,HTTP服务器把这些变量格式化成JSON字符串,拼装成HTTP响应报文返回。浏览器拿到数据后通过DOM操作更新页面显示。
这两条链路一结合,就形成了一个简单但完整的闭环:网页能看状态,能发指令,设备能执行,能回报结果。这就是智能家居最初始、最本质的形态。
2. 硬件接线与关键配置
2.1 ENC28J60模块的接线细节
ENC28J60模块在淘宝上十几块钱就能买到,常见的是绿色的PCB板,板载了HR911105A网络变压器和RJ45座。接线非常简单,主要是SPI四根线加两根控制线。
| STM32引脚 | ENC28J60引脚 | 说明 |
|---|---|---|
| PA5 | SCK | SPI时钟,最高可跑到10MHz以上,但建议先保守用2MHz |
| PA6 | MISO | 主入从出 |
| PA7 | MOSI | 主出从入 |
| PA4 | CS | 片选,低电平有效 |
| PA2 | INT | 中断输出,接STM32外部中断引脚 |
| 3.3V | VCC | 注意模块供电电压,有的模块带3.3V稳压芯片 |
| GND | GND | 共地必须 |
这里有几个容易踩的坑。第一个是模块的供电,市面上很多ENC28J60模块虽然标注VCC接5V也能工作,因为板载了AMS1117-3.3稳压,但强烈建议直接接3.3V,不要让板载稳压电路反复工作,长期运行稳定性更好。第二个是INT引脚,很多人图省事不接中断,用轮询方式检查数据包到达,这在低负载时没问题,但网络压力一大就会丢包。我实际测试下来,中断方式比轮询方式的吞吐量高出将近一倍。
第三个坑是SPI速率,有些教程一上来就配置成18MHz甚至更高的分频,ENC28J60官方手册标称SPI时钟最大是20MHz,但实际布线、杜邦线长度、模块质量都会影响稳定性。我调试时先用2MHz分频跑通功能,再逐级提高频率测试稳定性。最后稳定在9MHz,用网线直连电脑没有出现丢包问题。
2.2 LWIP协议栈的裸机移植配置
LWIP移植到不带操作系统的STM32上,需要重点关注的是内存管理和网卡驱动接口。我使用的是LWIP 1.4.1版本,虽然2.0以上版本也有很多新特性,但1.4.1的资料最丰富,踩坑案例最多,适合学习阶段使用。
内存配置方面,裸机环境下用的是内存池加内存堆的混合模式。关键宏定义如下:
#define MEM_ALIGNMENT 4 #define MEM_SIZE 1024 * 20 #define MEMP_NUM_PBUF 10 #define MEMP_NUM_UDP_PCB 2 #define MEMP_NUM_TCP_PCB 8 #define MEMP_NUM_TCP_SEG 8 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (4 * TCP_MSS)我解释一下这几个参数后面的逻辑。TCP_MSS是单个TCP数据段能携带的最大应用数据长度,以太网帧减去IP头和TCP头之后就是1460字节。TCP_WND是接收窗口大小,我设置为4倍MSS,意味着无需等待确认就能接收约5840字节的数据,这对于HTTP请求响应这种小数据量场景完全够用。TCP_SND_BUF是发送缓冲区大小,同样是4倍MSS,保证发送大数据包时不必频繁等待ACK。
在STM32F103C8T6这种20KB RAM的芯片上,这些配置需要精打细算。我最初把TCP_WND和TCP_SND_BUF都配置成8倍MSS,结果编译后RAM直接溢出。后来逐个参数下调,最终确定上面这组配置,整个系统运行时RAM占用约15KB,还留有一定余量给应用层变量。
网卡驱动接口部分是移植工作的重头戏。LWIP通过low_level_init、low_level_output、low_level_input三个函数与ENC28J60驱动交互。low_level_init负责初始化ENC28J60芯片,设置MAC地址、开启接收过滤器;low_level_output把LWIP封装好的数据包通过SPI写入ENC28J60的发送缓冲区;low_level_input则从接收缓冲区读取数据包并传递给LWIP协议栈。
err_t low_level_output(struct netif *netif, struct pbuf *p) { pbuf_copy_partial(p, enc28j60_tx_buffer, p->tot_len, 0); enc28j60_packet_send(enc28j60_tx_buffer, p->tot_len); return ERR_OK; }在裸机环境下,没有RTOS的任务调度机制,所以在main函数的主循环里需要周期调用LWIP的时间处理函数:
while (1) { sys_check_timeouts(); eth_poll(); handle_http_request(); }sys_check_timeouts负责处理TCP的超时重传、ARP老化等定时任务,eth_poll是底层的收包轮询函数,handle_http_request是我自己写的HTTP应用层处理函数。这种前后台架构虽然朴素,但对这个量级的项目来说响应速度完全够用。
3. HTTP服务器与JSON数据交互实现
3.1 在LWIP之上搭建轻量级HTTP服务器
LWIP协议栈本身只负责到TCP层,HTTP协议需要自己实现。这个环节有很多现成的移植方案,比如lwIP的httpd组件或者GitHub上的各种小型HTTP服务器代码,但我建议自己手写一个精简版,因为智能家居场景下的HTTP需求非常简单,无非就是处理GET和POST请求,返回JSON字符串,自己实现能在代码层面完全掌控整个流程。
一个最基本的HTTP响应报文格式如下:
HTTP/1.1 200 OK\r\n Content-Type: application/json\r\n Content-Length: 58\r\n Connection: keep-alive\r\n \r\n {"temp":26.5,"humidity":42.8,"light":1,"status":"online"}HTTP协议的核心就是这段响应头加空行加响应体的结构。Content-Length必须准确计算JSON字符串的实际长度,否则浏览器会一直等待后续数据直到超时。这里最容易出问题的是中文编码,如果JSON数据中包含中文,必须明确指定charset=utf-8,否则浏览器解析会出现乱码。
实现时用状态机处理TCP数据流更为合理。一个TCP段可能只包含半个HTTP请求头,也可能包含多个请求,所以不能简单地在收到数据后就立即解析,而是需要维护一个缓冲区,把收到的数据拼接起来,逐个判断是否包含完整的HTTP请求头。
struct http_request { char method[8]; char url[128]; char query[128]; int complete; }; static void parse_http_request(char *buffer, int len, struct http_request *req) { // 解析方法:GET /control?cmd=on HTTP/1.1 sscanf(buffer, "%s %s", req->method, req->url); // 分离URL和查询参数 char *q = strchr(req->url, '?'); if (q != NULL) { strcpy(req->query, q + 1); } }3.2 AJAX轮询方案的设计与优化
浏览器端的AJAX请求使用的是标准XMLHttpRequest对象,页面加载完成后启动一个定时器,每隔固定时间请求一次设备状态。核心代码如下:
function fetchStatus() { var xhr = new XMLHttpRequest(); xhr.open('GET', '/status', true); xhr.onreadystatechange = function() { if (xhr.readyState === 4 && xhr.status === 200) { var data = JSON.parse(xhr.responseText); document.getElementById('temp').innerHTML = data.temp; document.getElementById('humidity').innerHTML = data.humidity; updateSwitchStatus(data.relay1); } }; xhr.send(); } // 每800毫秒请求一次设备状态 setInterval(fetchStatus, 800);这里的轮询间隔是一个需要权衡的参数。间隔太长,页面上显示的数据更新不够灵敏,比如你在网页上点开关,要等一两秒才看到状态回显,体验很差;间隔太短,STM32要频繁处理HTTP请求,影响其他任务的执行。
我试过500毫秒、800毫秒、1000毫秒三个档位,最终选定了800毫秒。500毫秒时STM32几乎每刻都在处理网络请求,CPU占用率极高,导致DHT11的温湿度采集时序出现了抖动。1000毫秒时响应感知稍有迟滞,但CPU负载很轻。800毫秒在稳定性和响应速度之间取了一个平衡点。
提示:如果后续扩展了更多传感器或控制设备,建议把轮询改成长连接加推送模式,比如WebSocket或者SSE(Server-Sent Events),这样不仅延迟更低,还能大幅减少无效HTTP请求。
3.3 控制指令的编码与安全校验
设备控制方面,我采用了简单的参数校验机制,防止误操作和明显的非法请求。在HTTP解析完查询参数后,不是直接执行控制动作,而是先通过一个指令映射表进行校验:
static int execute_command(char *cmd, char *param) { if (strcmp(cmd, "relay1") == 0) { if (strcmp(param, "on") == 0) { relay1_on(); return 1; } if (strcmp(param, "off") == 0) { relay1_off(); return 1; } } if (strcmp(cmd, "relay2") == 0) { // 省略同样结构 } return 0; // 未知指令 }这个设计的核心思想是白名单校验,只接受预期内的指令组合。如果请求参数不在映射表里,直接返回400错误,不执行任何操作。对于真正的智能家居产品,还需要加上更严格的鉴权机制,比如Token验证、加密签名等,但对于局域网内部的学习项目,白名单校验已经足够。
还有一个容易被忽略的问题是HTTP请求超时处理。如果浏览器发起一个请求后,设备端迟迟不响应,TCP连接会一直占用PCB资源。LWIP的MEMP_NUM_TCP_PCB我配置成8个,如果被占满了,新的TCP连接就无法建立。所以一方面要合理控制HTTP响应速度,另一方面要配置好TCP超时时间,让异常连接尽快释放。
4. 实操过程与核心环节详解
4.1 从零开始搭建开发环境
整个开发环境我使用的是Keil MDK5 + STM32标准外设库,没有使用HAL库。不是说HAL库不好,而是这个项目的关键在于理解底层寄存器操作和协议栈工作机制,标准库的抽象层更薄,能更清晰地看到SPI、GPIO、中断的完整配置过程。
工程结构上,我按照功能模块拆分了源码目录:
Project/ ├── User/ │ ├── main.c │ ├── http_server.c │ ├── http_server.h │ ├── sensor.c │ └── device_control.c ├── LWIP/ │ ├── api/ │ ├── core/ │ ├── netif/ │ └── port/ │ ├── netif/enc28j60.c │ └── netif/enc28j60.h ├── Hardware/ │ ├── spi.c │ ├── exti.c │ ├── enc28j60_driver.c │ └── stm32f10x_it.c └── Libraries/ └── CMSIS/这里面关键的是LWIP的port目录,它是连接LWIP内核和STM32硬件的纽带。移植步骤分为四步:第一步,实现sys_now函数提供时间戳基准;第二步,实现enetif初始化函数,绑定驱动读包、发包函数和MAC地址;第三步,配置LWIP的opts.h头文件,裁剪功能和调整内存;第四步,在主循环中周期调用sys_check_timeouts和时间戳更新。
4.2 网页界面的编写与调试
网页部分我用的是最朴素的原生HTML+CSS+JavaScript,没有引入任何前端框架。一是考虑到STM32的Flash空间有限,嵌入的HTML文件不能太臃肿;二是原生代码更容易调试,也方便在嵌入式背景下理解整个流程。
整个网页主要包含两个区块:设备状态展示区(温湿度数值、四个继电器开关状态)和控制按钮区(四个继电器的开关按钮、全开全关按钮)。所有的静态资源被编译成C语言数组存放在Flash里,STM32收到浏览器请求后根据URL索引到对应的资源数据,通过HTTP响应发送给浏览器。
把HTML文件转成C数组的方式有现成工具,我用的是Python写的小脚本,自动把HTML文件内容转成如下格式:
const unsigned char index_html[] = { 0x3C, 0x21, 0x44, 0x4F, 0x43, 0x54, 0x59, 0x50, // <!DOCTYPE 0x45, 0x20, 0x68, 0x74, 0x6D, 0x6C, 0x3E, 0x0A, // E html>\n // 后面省略 };在本地调试网页时,直接在浏览器里打开HTML文件,AJAX请求会失败,因为本地文件不能跨域请求一个IP地址。这时候需要先做一个跨域代理,或者干脆把HTML放到一个简单Web服务器上调试。我实际调试时是先让STM32连上路由器,然后通过网络调试助手确认HTTP请求和响应的格式完全正确,最后才调试页面交互逻辑。
4.3 与路由器和其他设备的组网测试
调试过程中我遇到一个很典型的组网问题:开发阶段用网线直连电脑和STM32板子,需要给电脑手动配置一个静态IP(比如192.168.1.100),STM32的IP配置为192.168.1.99,这样才能互通。但到了实际智能家居场景中,设备需要连到家里的路由器上,直接用DHCP自动获取IP。
LWIP的DHCP客户端功能默认是关闭的,需要在opts.h中打开LWIP_DHCP宏,然后在初始化后调用dhcp_start函数:
struct netif enc28j60_netif; ip4_addr_t ipaddr, netmask, gw; IP4_ADDR(&gw, 192, 168, 1, 1); IP4_ADDR(&ipaddr, 192, 168, 1, 99); IP4_ADDR(&netmask, 255, 255, 255, 0); netif_add(&enc28j60_netif, &ipaddr, &netmask, &gw, NULL, ethernetif_init, tcpip_input); netif_set_default(&enc28j60_netif); netif_set_up(&enc28j60_netif); dhcp_start(&enc28j60_netif);这里有一个很隐蔽的坑:DHCP请求需要一定时间才能完成,如果在DHCP尚未获得IP号时就尝试建立TCP连接,连接会失败。所以我设计了一个状态标志,在重新获取到IP地址后提示DHCP分配成功,但实际处理过程中,我只是在main函数里延时一段时间等待DHCP完成。
建议是在调试阶段直接使用静态IP,排查网络问题更直观。等所有功能跑通后再切换到DHCP模式。
5. 常见问题与排查技巧实录
5.1 ETH初始化失败:enc28j60版本差异
开发中最常见的故障是ENC28J60初始化失败。我遇到的情况是,运行初始化函数后读取芯片版本寄存器,返回值是0x00而不是期望的版本号。
排查步骤是先用万用表量SPI四根线的电压和波形,确认初始化时芯片有没有被正确拉低CS信号。然后是检查模块供电,如果VCC引脚电压低于3.2V,芯片内部电路可能无法正常工作。排除这些硬件问题后,问题可能出在SPI初始化时序上。ENC28J60的某些寄存器操作需要等待芯片内部状态机就绪,如果连续操作间隔过短就可能失败。
我还遇到过一个比较隐蔽的问题:买的ENC28J60模块芯片丝印是Rev.B7,而网上很多驱动代码是针对Rev.B5版本写的。两个版本的中断状态寄存器和数据包接收格式有细微差异,导致能初始化成功但收不到数据包。后来我找到了对应的新版驱动文件,问题才解决。
5.2 LWIP内存不足导致网页加载慢
如果浏览器的网页加载特别慢,甚至超过10秒钟,排查优先级最高的就是LWIP的内存配置。我用串口打印了LWIP的内存使用统计信息,发现pbuf池耗尽得非常严重,TCP接收窗口又小,一个HTTP响应报文被拆分成了多个TCP段传输,每段都要等待ACK确认。
优化方案分两步:第一,增大MEMP_NUM_PBUF和MEMP_NUM_TCP_SEG的数量;第二,启用TCP的延迟确认和Nagle算法配合,减少小包数量。调整后网页加载时间从大约6秒降到了2秒左右。
提示:LWIP的内存统计信息在函数mem_stats中,通过串口打印或者调试器查看,是排查网络性能问题的第一手段。
5.3 浏览器显示CORS跨域错误
用浏览器直接打开本地HTML文件调试时,控制台会报“No 'Access-Control-Allow-Origin' header is present on the requested resource”的CORS错误。这是因为HTML页面来源是file://协议,而AJAX请求是http://协议,浏览器认为这是跨域请求。
解决方法是两种:一种是给HTTP服务器增加Access-Control-Allow-Origin响应头,但这属于临时的解决方法;另一种更合理的方式是统一将HTML文件和AJAX请求放在同一域名下,也就是直接从STM32的IP地址访问网页,这样是同源请求,不存在跨域问题。
5.4 ST-Link连接报错排查
实际开发中很多刚接触STM32的读者会遇到一个很让人抓狂的报错:ST-Link下载程序时提示“Error: No STM32 target found! If your product embeds Debug Authentication please ...”。这个错误和我们的网络功能无关,但属于嵌入式开发中的高频问题,一并说一下。
排查步骤是:第一步,确认ST-Link的驱动正常,如果设备管理器里ST-Link出现了感叹号或者“Virtual COM Port”发黄,多半是驱动问题,重装驱动可以解决;第二步,确认STM32板子的供电正常,部分板子只通过ST-Link供电时电流不够;第三步,确认BOOT0引脚的电平状态,正常情况下BOOT0接GND,从Flash启动,如果BOOT0悬空或者被拉到高电平,芯片会进入ISP模式,这时ST-Link连接会失败。
5.5 传感器数据周期性失效
DHT11温湿度传感器在系统运行一段时间后,偶尔读出的数据是固定值(比如温度恒为0或湿度恒为255),这个故障不是网络侧的问题,而是MCU的时序冲突。
排查发现,由于LWIP的定时任务占用了比较长的CPU时间,有时DHT11的18毫秒起始信号会被网络中断打断,导致传感器没有正确进入测量模式。解决方案是关闭DHT11读取期间的所有中断,或者在主循环里把DHT11的读取操作放在网络任务之前执行,保证时序完整。考虑到DHT11的反应要求每秒钟最多读取一次,我在读取期间设置了一个临界区保护,确保操作不会被网络中断干扰。
6. 项目后续可扩展的方向
这套系统跑通之后,等于掌握了嵌入式物联网开发的核心骨架,扩展方向非常多。接口方面,我后来把串口接了一个ESP8266模块,通过AT指令把数据转发到云平台,实现了手机远程查看和控制;协议方面,在LWIP之上还实验过MQTT协议,相比HTTP轮询,MQTT的实时性和流量效率更高,但需要事先搭好MQTT Broker。
如果你的STM32芯片Flash和RAM更大,可以尝试把这边的静态网页做成SPA(单页应用),配合Vue或React等框架。也可以考虑加一块TFT屏幕,在设备端显示传感器数据和网络状态,这样不依赖浏览器也能查看设备状态。
如果要做成一个相对完整的产品原型,还需要考虑掉线重连机制、AP模式配网、固件在线升级(OTA)等功能。STM32的Flash空间虽然紧张,但也能通过IAP方式实现远程固件升级,这样系统在部署后还能持续迭代,具备更强的实用性。
本文还有配套的精品资源,点击获取