简介:基于STM32F103与W5500以太网模块的物联网实战源码,面向单片机开发者、物联网爱好者及智慧养老、智慧医疗、智慧农业等场景从业者,解决设备快速接入阿里云平台并实现远程控制与数据上报的需求。资源包含完整KEIL工程,支持MQTT协议主动上报温湿度与继电器状态,同时接收云平台下发指令执行控制,并附有硬件设计、软件开发与联网调试的联络支持。资源包共218个文件,以C源码(48个.c)、头文件(50个.h)、编译中间文件及Keil工程配置为主,另有hex烧录文件、uvprojx工程文件与调试辅助文件,整体约6.99MB,目录结构清晰,便于二次开发与移植。已有1587人学习浏览,适合具备基础STM32开发经验、希望参考完整MQTT上云例程的工程师快速上手。
1. 为什么是W5500而不是ESP8266:有线接入阿里云的工程逻辑
做物联网设备接入阿里云,很多开发者第一反应是ESP8266,但在这个项目里选择W5500是基于一个很重要的工程现实:当设备处于工业现场、养鸡棚、机房或者医疗设备旁边时,Wi-Fi的稳定性往往撑不起7×24小时的数据上报任务。W5500内置了完整的TCP/IP协议栈,MCU只需要通过SPI接口读写寄存器就可以完成网络通信,不占用STM32的协议栈开销,也不会因为Wi-Fi信道拥塞导致掉线。本项目以STM32F103C8T6为主控,通过SPI驱动W5500,再以MQTT协议接入阿里云物联网平台,实现温湿度采集上报、继电器状态同步和云端下发控制指令。整个代码基于KEIL工程,直接烧录即可运行。适合正在做智慧农业、智慧养老、智慧医疗等场景的单片机开发者,尤其是那些需要可靠网络链路的设备接入方案。
2. W5500从寄存器到Socket:SPI驱动与以太网链路搭建
2.1 W5500的SPI帧格式和寄存器映射
W5500和STM32之间只有4根线:SCS、SCLK、MOSI、MISO。它是标准的SPI从设备,但和普通SPI外设不同的是,W5500的SPI帧不是简单的地址加数据,而是固定为地址段(16位)+ 控制段(8位)+ 数据段(可变长度)。
uint8_t W5500_ReadByte(uint32_t reg_addr, uint8_t block) { uint8_t data = 0; SPI_CS_LOW(); SPI_SendByte((uint8_t)(reg_addr >> 8)); // 地址高8位 SPI_SendByte((uint8_t)(reg_addr & 0xFF)); // 地址低8位 SPI_SendByte((block << 3) | 0x00); // 控制字节:读模式 data = SPI_ReceiveByte(); SPI_CS_HIGH(); return data; }这段代码看起来简单,但有一个关键参数容易被忽略:控制字节的BIT2-BIT0决定当前操作的是哪个寄存器块。W5500内部寄存器分为公共寄存器块、Socket 0-7寄存器块和TX/RX Buffer块。在后续的驱动封装中,block参数非常重要。比如读写Socket 0的发送缓冲区时,控制字节是(block=2) << 3,对应0x10,而不是0x00。如果你把Socket寄存器当成公共寄存器来读写,最典型的故障就是W5500的Socket状态一直停留在SOCK_CLOSED,无法进入SOCK_INIT。
2.2 Socket方式的网络层封装
W5500和STM32的分工是明确且合理的:网络层、传输层由W5500硬件处理,应用层数据的产生和解析由STM32处理。在这种架构下,STM32程序里不会出现TCP三次握手的状态机代码,你需要做的是按照W5500的Socket API流程来操作。
uint8_t W5500_Socket_Connect(uint8_t sock, uint8_t *dest_ip, uint16_t dest_port) { // 设置目的IP和端口 W5500_WriteByte(SOCK_REG(sock, Sn_DIPR0), dest_ip[0]); W5500_WriteByte(SOCK_REG(sock, Sn_DPORT0), (uint8_t)(dest_port >> 8)); W5500_WriteByte(SOCK_REG(sock, Sn_DPORT1), (uint8_t)(dest_port & 0xFF)); // 执行OPEN命令 W5500_WriteByte(SOCK_REG(sock, Sn_CR), SOCK_CR_OPEN); DelayMs(10); // 检查状态 uint8_t status = W5500_ReadByte(SOCK_REG(sock, Sn_SR), 1); if (status == SOCK_INIT) { // 执行CONNECT命令 W5500_WriteByte(SOCK_REG(sock, Sn_CR), SOCK_CR_CONNECT); // 等待连接建立 uint32_t timeout = 0; do { DelayMs(5); status = W5500_ReadByte(SOCK_REG(sock, Sn_SR), 1); timeout++; } while ((status != SOCK_ESTABLISHED) && (timeout < 100)); return (status == SOCK_ESTABLISHED) ? 1 : 0; } return 0; }SOCK_REG(sock, Sn_CR)是一个宏定义,等价于(sock * 0x100 + Sn_CR),也就是把Socket 0-7的寄存器偏移算出来。这里有一个容易忽略的细节:SOCK_REG的偏移计算中,Sn_CR是相对于该Socket基地址的偏移,实际读取时要把sock * 0x100加上。
上面的SOCK_CR_OPEN和SOCK_CR_CONNECT命令值分别是0x01和0x04。如果你的代码执行了SOCK_CR_CONNECT但一直返回超时,重点检查是不是在SOCK_INIT状态下才发的CONNECT指令。
2.3 DHCP静态IP的策略选择
W5500自带的硬字库里有DHCP客户端功能,但在这个项目里推荐使用静态IP,原因如下:一是设备放在内网,固定IP有助于远程调试时直接抓包诊断;二是如果把分配到的IP写入配置参数,反而增加了外部依赖。静态IP配置只需要四段字节:
uint8_t local_ip[4] = {192, 168, 1, 88}; uint8_t subnet[4] = {255, 255, 255, 0}; uint8_t gateway[4] = {192, 168, 1, 1}; W5500_WriteByte(COMMON_REG(SHAR), local_ip[0]); // 源IP地址 W5500_Set_Subnet(subnet); W5500_Set_Gateway(gateway);阿里云MQTT要求的网络链路是设备出公网,所以网关地址必须写对,否则TCP SYN包无法出局域网。很多开发者在本地测试时使用192.168.31.x网段,如果无线路由器做了AP隔离,即使IP、掩码都正确,也无法和阿里云建立MQTT连接。我一般会在初始化完成后用PING命令验证一下网关的连通性,但这需要额外代码在串口输出,调试时很有用。
3. MQTT客户端协议解析与阿里云连接参数封装
3.1 CONNECT报文的结构拆解
MQTT协议本身不复杂,难点在于把报文按协议规范逐字节组装,并保证KeepAlive逻辑能在W5500的Socket上正常工作。MQTT报文的最小单位是固定报头(1字节控制包类型+1字节剩余长度),然后才是变长报头和数据区。这就是为什么网上很多移植代码里会看到一大串uint8_t buffer[256]的赋值语句——实际上就是逐字节组装。
以CONNECT报文为例,连接阿里云时需要进行三处改编:
| 报文段 | 阿里云要求 | 说明 |
|---|---|---|
| Payload中的ClientID | 固定格式:`deviceName | securemode=3,signmethod=hmacsha1,timestamp=xxx |
| Username | 格式:deviceName&productKey | 两个参数用&连起来,不是下划线 |
| Password | 对productKey、deviceName等组合参数做HMAC-SHA1签名后转十六进制字符串 | 签名串的拼接顺序不能错 |
关键代码体现在密码签名那一步,因为STM32F103没有硬件加密外设,需要引入一个轻量级的HMAC-SHA1算法文件。很多下载包里已经包含了hmac_sha1.c,你在KEIL工程里不要漏掉添加。
// 签名内容:deviceName + productKey + deviceSecret + timestamp char sign_source[128]; sprintf(sign_source, "deviceName=%s&productKey=%s×tamp=%s", deviceName, productKey, timestamp); uint8_t mac[20]; HmacSha1((uint8_t*)sign_source, strlen(sign_source), (uint8_t*)deviceSecret, strlen(deviceSecret), mac); // 将mac[20]转换为40字节的十六进制字符串作为Password for (int i = 0; i < 20; i++) { sprintf(&password[2*i], "%02x", mac[i]); }这里的deviceSecret是三元组中最敏感的字段,如果你是直接烧录调试,可以写死在config.h里,但如果是给别人交付源码,建议做成一个独立的参数配置文件,并明确标注需要用户自行替换。HMAC-SHA1签名结果的十六进制需要全部小写,这一点阿里云的签名校验是区分大小写的,一旦写成了大写,连接服务器会直接返回CONNACK拒绝。
3.2 PUBLISH报文的主题与QoS选择
阿里云的物模型通信遵循一个固定的Topic格式:
- 设备上报属性:
/sys/{productKey}/{deviceName}/thing/event/property/post - 云端下发属性设置:
/sys/{productKey}/{deviceName}/thing/service/property/set
在这个项目里,温湿度就是两个属性,继电器的开关状态也是属性。Topic里的productKey和deviceName和CONNECT报文里填的是同一组,一旦不匹配就会被阿里云直接断开。
QoS建议全部设为0。原因有两个:其一,W5500的硬件TCP协议栈本身就带了ACK重传机制,MQTT层的QoS1会在此基础上再增加一次PUBACK握手,双重确认在高延迟链路上反而带来更多的交互数据量;其二,阿里云物联网平台对设备端和云端之间的QoS1报文数量有一定限制,如果设备每次上报都使用QoS1,在频繁采集数据的场景下容易触发流控策略,导致连接被服务端短暂拉黑。
上报属性的MQTT报文,我来写一个最小可用的组装函数:
static void Mqtt_PublishProperty(float temp, float humi, uint8_t relay_status) { char payload[256]; // 按阿里云物模型JSON格式组织属性数据 sprintf(payload, "{\"id\":\"123\",\"version\":\"1.0\",\"params\":{\"Temperature\":%.2f,\"Humidity\":%.2f,\"RelayStatus\":%d},\"method\":\"thing.event.property.post\"}", temp, humi, relay_status); uint8_t pkt[512]; // 固定报头:PUBLISH(0x30) + QoS0,主题名后面跟payload uint8_t topic_len = strlen(topic_property_post); pkt[0] = 0x30; // bit3: DUP=0, QoS=0, RETAIN=0 pkt[1] = (uint8_t)(2 + topic_len + strlen(payload)); // 剩余长度 pkt[2] = (uint8_t)(topic_len >> 8); pkt[3] = (uint8_t)(topic_len & 0xFF); memcpy(&pkt[4], topic_property_post, topic_len); memcpy(&pkt[4 + topic_len], payload, strlen(payload)); W5500_Socket_Send(sock_mqtt, pkt, 4 + topic_len + strlen(payload)); }剩余长度字段的编码方式是MQTT里最容易出错的点之一。当前例子中剩余长度小于128,所以1字节就能表示。但如果你的payload很大(超过127字节),剩余长度就需要用可变长编码:低7位存储数据,第8位作为连续位。建议在工程里写一个Mqtt_EncodeLength函数来统一处理,这样后续添加OTA或文件上传功能时不用返工。
3.3 KeepAlive心跳和连接保活的状态机
阿里云物联网平台的服务端会在45秒内没有收到任何报文的情况下主动关闭连接。所以客户端需要在保活时间内持续发送PINGREQ报文。W5500的Socket连接一旦被服务端断开,自己不会主动感知,只有在发送数据时才会通过Sn_SR状态反映出来。这里就需要在MCU的主循环里维护一个MQTT层的状态机。
typedef enum { MQTT_STATE_DISCONNECTED = 0, MQTT_STATE_CONNECTING, MQTT_STATE_CONNECTED, MQTT_STATE_RECONNECT_WAIT } mqtt_state_t; // 在主循环中每100ms调用一次 void Mqtt_Task(void) { static uint32_t last_heartbeat = 0; switch (mqtt_state) { case MQTT_STATE_CONNECTED: // 每60秒发送一次PINGREQ if (GetTickMs() - last_heartbeat > 60000) { W5500_Socket_Send(sock_mqtt, (uint8_t*)"\xC0\0", 2); last_heartbeat = GetTickMs(); } break; case MQTT_STATE_RECONNECT_WAIT: // 重连退避,避免频繁撞上服务端的限流 if (GetTickMs() - last_heartbeat > 30000) { Mqtt_ConnectServer(); } break; default: break; } }PINGREQ报文本身就是固定报头\xC0,剩余长度\x00,总共2字节。服务端回应的是\xD0\x00的PINGRESP。在调试时用Wireshark抓包是最直观的,比如抓包后你会看到每个约60秒出现一次"PINGREQ"和"PINGRESP"的往返。值得留意的是:阿里云在TCP半开连接检测上相当积极,如果你的W5500长时间没有发送数据且网络中间设备断了链路,等你想发数据时Socket状态还是ESTABLISHED,只有实际发送时才会触发重传继而发现连接已死。所以建议在MQTT_STATE_CONNECTED状态下,每次上报数据后不要清空心跳计时器,而是让PINGREQ独立运行,这样故障发现时间可控。
4. 阿里云物模型定义与JSON数据流的双向链路
4.1 阿里云控制台的产品创建与功能定义
要让设备接上阿里云,代码只占一半,另一半在云端的物模型设计和Topic配置上。在物联网平台控制台创建产品时,节点类型选择"设备",联网方式选择"以太网",数据格式推荐选择"ICA标准数据格式"(也就是JSON格式),因为W5500的RAM有限,不适合透传模式还要自己设计物模型解析逻辑。
定义三个属性即可满足这个项目的核心功能:
| 属性标识符 | 数据类型 | 读写类型 | 说明 |
|---|---|---|---|
| Temperature | float | 只读 | 上报温度值,精度0.01 |
| Humidity | float | 只读 | 上报湿度值,精度0.01 |
| RelayStatus | int(Bool) | 读写 | 云端可下发0或1控制继电器 |
三个属性的标识符和你在代码里拼JSON时的key必须完全一致,包括大小写。很多初学者在测试时发现数据上报成功,但控制台看不到数据,通常就是这里不一致。还有个细节是你需要在产品详情页找到productKey和productSecret,并把设备注册后生成的deviceSecret填到STM32的代码里。
4.2 设备端如何接收云端下发的指令
接收云端下发指令有两种典型方式:第一种是在MQTT层订阅属性设置Topic,第二种是让设备上报数据后云端通过服务调用下发。本项目用的是订阅模式,设备端在连接成功后要立即发送SUBSCRIBE报文订阅下行Topic:
// 订阅下行Topic: /sys/{productKey}/{deviceName}/thing/service/property/set uint8_t sub_pkt[256]; uint16_t topic_len = strlen(topic_property_set); sub_pkt[0] = 0x82; // SUBSCRIBE报文,固定报头0x82 sub_pkt[1] = (uint8_t)(2 + 2 + topic_len + 1); // 报文标识符2字节 + Topic长度2字节 + Topic + QoS1字节 sub_pkt[2] = 0x00; sub_pkt[3] = 0x01; // 报文标识符 sub_pkt[4] = (uint8_t)(topic_len >> 8); sub_pkt[5] = (uint8_t)(topic_len & 0xFF); memcpy(&sub_pkt[6], topic_property_set, topic_len); sub_pkt[6 + topic_len] = 0x00; // 请求的QoS级别 W5500_Socket_Send(sock_mqtt, sub_pkt, 6 + topic_len + 1);SUBSCRIBE报文的剩余长度计算里容易漏掉报文标识符的2字节,这会导致报文结构错乱,服务端返回SUBACK的报文标识符不匹配,设备端可能一直收不到控制指令。收到SUBACK后(报文类型为0x90),再确认返回码是否为0x00(成功),然后再进入主循环的接收解析流程。
4.3 W5500接收环形缓冲与JSON字段解析
W5500的RX Buffer默认8KB,如果收到的数据量不大,可以简化成一个线性缓冲逐个字节读取。实际项目中我建议直接在Socket的接收函数中处理。
uint16_t len = W5500_Socket_Recv(sock_mqtt, rx_buffer, sizeof(rx_buffer)); if (len > 0) { // 逐条取出MQTT报文 switch (rx_buffer[0] & 0xF0) { case 0x30: // PUBLISH // 从报文里提取Topic和Payload Mqtt_HandlePublish(rx_buffer, len); break; case 0xD0: // PINGRESP,心跳保活通了 break; default: break; } }Mqtt_HandlePublish里要做的是:找到Topic字段的位置,判断是不是property/set这个Topic,如果是就提取Payload中的JSON,然后用cJSON库解析出params下的RelayStatus值。cJSON在STM32上跑完全没问题,但注意cJSON的malloc/free在高频调用下会产生内存碎片,建议在任务里复用JSON解析对象,并使用cJSON_Delete及时释放。
解析到RelayStatus的值后,通过GPIO控制PB12引脚的继电器。
cJSON *root = cJSON_Parse(payload); if (root != NULL) { cJSON *params = cJSON_GetObjectItem(root, "params"); cJSON *relay = cJSON_GetObjectItem(params, "RelayStatus"); if (cJSON_IsNumber(relay)) { GPIO_WriteBit(GPIOB, GPIO_Pin_12, relay->valueint ? Bit_SET : Bit_RESET); // 更新本地状态,等待下一次上报时同步到云端 local_relay_status = relay->valueint; } cJSON_Delete(root); }注意这里的GPIO用的是GPIO_WriteBit,而不是GPIO_SetBits或GPIO_ResetBits,这是为了统一控制逻辑。典型的错误是在条件判断中把RelayStatus误写成relay_status,和物模型标识符大小写不一致,解析永远返回NULL。
4.4 上报频率和QoS的进阶取舍
温湿度传感器使用DHT11或SHT30,采集周期建议不低于2秒一次。DHT11本身的数据更新周期就是1秒,太快没有意义,而且高频上报会给MQTT链路带来不必要的负载。在阿里云平台的"设备-日志服务"里可以看到每条消息的记录,如果上报频率过高,排查问题时日志刷新太快,很难定位有效信息。我一般会设置一个5秒的上报周期,既能满足WEB控制页面的实时性要求,又不会因为频繁发送导致W5500的TX Buffer溢出。
5. KEIL工程移植的硬件边界与调试手法
5.1 芯片型号切换和FLASH容量配置
这个工程原本在STM32F103C8T6上运行,如果换到其他型号,只需要在KEIL的魔术棒界面修改Device栏的芯片型号,以及Target标签页里的IROM1起始地址和大小。比如从C8T6的64KB Flash换到RCT6的256KB Flash,就需要把IROM1的Size从0x10000改成0x40000,起始地址都是0x08000000不变。如果工程里开了USE_STDPERIPH_DRIVER宏,还要检查芯片型号对应的stm32f10x.h中的器件系列宏,比如STM32F10X_MD还是STM32F10X_HD,这决定了启动文件使用的是startup_stm32f10x_md.s还是hd.s。宏定义选错最直观的表现是程序下进去不跑或跑飞,且调试时看不出明显问题。
下载器方面,工程需要对应KEIL的Debug选项卡中ULINK/JLINK/ST-Link的选择。当前代码如果用ST-Link,在Flash Download里勾选Reset and Run,地址范围要和IROM1匹配。如果在烧录时看到Flash Download failed - Cortex-M3,优先检查芯片型号和Utilities中的Settings里的Flash大小是否匹配。
5.2 Wireshark抓包验证MQTT连接全过程
验证整个链路是否正确,最直接的方法不是看串口打印,而是用一个通道把W5500的报文镜像出来。这里有一个可执行的调试技巧:在STM32和W5500之间加一个SPI逻辑分析仪,或者直接在代码里把W5500_Socket_Send和Socket_Recv中的所有数据通过串口打印到PC端,在PC上用Wireshark打开串口捕获的数据。
如果你用串口打印报文,建议使用XCOM或SSCOM工具,将串口接收到的数据保存为二进制文件,然后用Wireshark的"Import from Hex Dump"功能导入。注意导入时要选择封装类型为"Raw IP",Wireshark才会自动解析TCP/IP报文。这样你可以清晰看到CONNECT报文的Payload里是否包含正确格式的ClientId和Username。如果看到服务端回的是CONNACK的第二字节为0x05(未授权),说明三元组或签名有问题,去检查ProductKey和DeviceSecret的拼接字符。
另一种验证手段是直接看W5500的Sn_SR寄存器值。连接成功时Sn_SR为0x17(SOCK_ESTABLISHED),数据发送后变为SOCK_SEND等短暂状态然后恢复。如果Sn_SR一直停留在SOCK_INIT或SOCK_CLOSED,说明TCP三次握手都没完成,这个时候问题大概率出在网络层而不是MQTT层。这是判断方向的分水岭:停留在SOCK_INIT查MAC和IP配置,若是SYNSENT则查路由和防火墙,若是ESTABLISHED但发不出数据才查MQTT代码。
5.3 一个容易被忽略的重连机制缺陷
在MQTT连接断开后重连时,W5500的Socket需要先执行DISCONNECT命令,然后重新走OPEN -> CONNECT流程。如果W5500的硬件缓冲里还有未发送完的数据,直接重新OPEN可能导致TCP序列号错乱,服务端丢弃连接或触发RST。所以在每次重连前要执行。
W5500_WriteByte(SOCK_REG(sock, Sn_CR), SOCK_CR_DISCON); DelayMs(50); W5500_WriteByte(SOCK_REG(sock, Sn_CR), SOCK_CR_CLOSE); DelayMs(50);另外,阿里云平台的设备连接有时间戳的有效窗口,timestamp参数在CONNECT报文中和当前UTC时间偏差超过一定阈值时,签名验证会直接失败。如果设备没有带RTC或没有做NTP时间同步,那你的CONNECT报文里的timestamp就会是一个固定的历史时间,第一次连接也许不会失败,但在设备冷启动后再次连接、或者云端时间偏移超过限制后,会收到错误码。处理方式一是用阿里云的时间服务器做一个简单的SNTP请求,二是直接用0值并开启securemode=2的TLS模式(但W5500硬件不支持TLS),更务实的方案是每次设备上电时先通过UDP访问ntp.aliyun.com获取当前时间戳,再填入CONNECT报文,ntp.aliyun.com的解析上可以用阿里云的公共DNS固定IP203.107.6.88。
本文还有配套的精品资源,点击获取