STM32+W5500接入OneNet:不跑协议栈的单路状态上传与远程控制
2026/9/16 14:48:48 网站建设 项目流程

简介:基于STM32F103与W5500以太网模块的物联网实战工程,面向物联网开发者、嵌入式爱好者及项目实践者,解决设备通过RJ45有线网络接入OneNet物联网平台、实现单路状态上报与远程控制的问题。工程采用SPI接口连接STM32与W5500,以W5500作为TCP客户端、OneNet作为服务端,完整演示DHCP动态获取IP、连接服务器、TCP数据交互及断开连接等网络通信流程。代码基于KEIL开发,在STM32F103C8T6上验证,同系列其他型号可通过调整芯片型号与Flash容量快速适配,并需注意J-Link/S-Link调试器选择。资源包共195个文件,大小约6.46MB,包含C语言源码(.c/.h)、KEIL工程配置(.uvprojx/.uvoptx)、编译中间文件(.o/.d/.crf)以及生成的可执行文件(.hex/.axf/.map),便于直接编译或分析固件。截至目前已有1034人学习下载,适用于具备一定STM32基础、希望快速上手以太网与云平台联调的学习者,参考价值较高。

1. 不跑协议栈的 STM32+W5500,怎么把单路状态送进 OneNet 物联网平台

一块 STM32F103C8T6、一个 W5500 模块、一根网线,就能把现场的单路开关状态送进 OneNet 物联网平台,同时还能接收平台下发的控制指令。整套工程里没有 Linux 也没有 RTOS,W5500 用硬件拆解了 TCP/IP 协议栈,MCU 只通过 SPI 读写寄存器,因此不占 Flash 也不依赖烦人的协议移植。对于刚接触 OneNet 云平台接入的开发者来说,这是最直接的以太网落地路径。这篇把 DHCP 动态取址、TCP 客户端连接、数据帧构造、远程控制解析的完整链路拆开讲,中途会给出可照抄的代码片段和排错命令,适合正在做物联网项目实战,又不想一上来就换带网络操作系统的 MCU 的人。

2. W5500 硬件协议栈与 STM32F103 的 SPI 接线和寄存器读写

2.1 选型边界:为什么是 W5500 而不是 ENC28J60 或 CH395

很多 STM32 以太网项目第一反应会选 ENC28J60,理由是便宜。但 ENC28J60 只负责 MAC+PHY,TCP/IP 协议栈必须在 MCU 侧跑 uIP 或 lwIP,这类协议栈对内存余量和代码稳定性要求都不低。在 STM32F103C8T6 只有 20KB RAM 的规格下,跑一个完整 TCP 客户端还要同时处理复位重连,容易在粘包和超时上翻车。W5500 则把 TCP/IP、UDP、ICMPv6、DHCP 等协议全部做进芯片内部,MCU 只需要构造以太网帧以上的数据,硬件会自动完成三次握手和校验。八个独立 Socket 对单路状态控制这类场景绰绰有余。

CH395 同样是硬件协议栈方案,但 W5500 的资料、例程和第三方生态更全。尤其在做 OneNet 接入时,网上能搜到的 STM32 驱动、ioLibrary、乒乓缓存设计基本都是围绕 W5500 展开的。所以这个工程选 W5500,不是因为它比 CH395 快,而是因为在“单片机 + 以太网 + 物联网平台”这个组合里,它的调试成本和移植阻力最小。接口上 W5500 最高支持 80MHz SPI 时钟,模块自身 3.3V 供电,通过 RJ45 座内置的网络变压器隔离信号,物理层几乎不需要额外电路。缺点是模块价格略高于 ENC28J60,但对项目实际开发来说省下的协议栈调试时间远比这几十元差价值钱。

2.2 原理图关键点:SPI1、复位和中断引脚分配

常见的 W5500 模块原理图会在芯片旁边放一枚 25MHz 晶振、一组去耦电容和一个网口带变压器,电源部分需要干净。STM32F103 这边只需要提供 SPI 主模式、片选、复位、中断四类信号。下面是我一般会使用的接线表,基于原子/正点等常见 W5500 模块与 STM32F103C8T6 的映射关系:

W5500 信号STM32F103 引脚功能说明
SCLKPA5SPI1 时钟,模式 0 或 3 均可
MISOPA6SPI1 主入从出
MOSIPA7SPI1 主出从入
SCSPA4片选,低有效
RSTPA3硬件复位,低脉冲有效
INTPA2中断输出,通知 MCU 有事件需要处理
VCC3.3V模块供电,并联 100nF 和 10uF 去耦
GNDGND公共地

SCS 建议接 GPIO 推挽输出,不要直接硬上拉,因为 W5500 的 SPI 帧需要动态拉低才能操作寄存器。RST 引脚在做软件复位时也很有用:当 DHCP 长时间拿不到 IP,或连接进入异常状态时,拉低 RST 再拉高比逐一清寄存器省事。INT 引脚在中断模式下可以高效判断数据到达,但如果主循环足够快,也可以不用 INT。实际项目中我已经不只一次遇到因为没把 INT 接上,导致 W5500 接收缓冲区溢出而不自知的情况,建议保留它。

2.3 SPI 帧格式与寄存器访问封装

W5500 的寄存器映射分成 Common 区、Socket 区和 TX/RX 缓冲三大块。SPI 帧由 16 位地址、8 位控制字段和数据组成,控制字段里包含块选择、读/写标志。手工操作寄存器的位运算比较繁琐,而且不同 ioLibrary 版本对控制字段的定义不完全一致,所以我一般不会在应用层直接拼 SPI 帧,而是用 WIZnet 提供的 ioLibrary 并只实现底层 SPI 回调。以下是在 STM32 HAL 库下的适配代码:

/* w5500_spi.c */ static void W5500_CS_Enable(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); } static void W5500_CS_Disable(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); } static uint8_t W5500_SPI_ReadByte(void) { uint8_t data = 0; HAL_SPI_Receive(&hspi1, &data, 1, 10); return data; } static void W5500_SPI_WriteByte(uint8_t data) { HAL_SPI_Transmit(&hspi1, &data, 1, 10); } void W5500_Init(void) { reg_wizchip_cs_cbfunc(W5500_CS_Enable, W5500_CS_Disable); reg_wizchip_spi_cbfunc(W5500_SPI_ReadByte, W5500_SPI_WriteByte); wizchip_init(NULL, NULL); }

这里reg_wizchip_cs_cbfuncreg_wizchip_spi_cbfunc是 ioLibrary 的回调注册函数,分别绑定片选动作和 SPI 字节收发。wizchip_init(NULL, NULL)会初始化芯片内部 TX/RX 缓冲,并把 8 个 Socket 全部关闭。第一次初始化后,可以读 W5500 版本寄存器,如果返回 0x54 就说明 SPI 链路正常。如果返回 0xFF 或 0x00,优先检查 MISO/MOSI 是否接反,其次检查 CS 极性。

关于控制字节的细节,ioLibrary 已经帮你算好,不必再在应用层关心。但有一个要注意的点:W5500 的 SPI 时钟模式支持模式 0 和模式 3,两者对 CPOL/CPHA 的要求不同。很多模块默认是模式 0,如果你在 CubeMX 里配置成模式 3 也能通信,但要注意是“能通信”而不是“稳定通信”。我会把 SPI1 的时钟频率压到 18MHz 以内,太高的速率在杜邦线下容易出现偶发丢字节,调试时建议先用 1MHz 跑通,再逐步提高频率。

3. DHCP 动态取址到 OneNet TCP 连接:代码里的时序细节

3.1 DHCP 客户端如何嵌进主循环且不卡死

W5500 的 DHCP 功能是在芯片内部跑的,但 MCU 侧必须持续调用DHCP_run()处理接收到的 DHCP Offer 和 ACK。不能像普通库函数一样一次性等结果,因为 DHCP 是一个需要时间片轮转的状态机。最简单可靠的做法是把它放进 1ms 定时中断或主循环中定期调用,以下是我常用的一段调度逻辑:

/* dhcp_task.c */ #define DHCP_TIMEOUT_MS 10000 uint8_t dhcp_status = DHCP_RUNNING; uint32_t dhcp_start_tick; uint32_t dhcp_last_tick; void dhcp_tick_task(void) { if (HAL_GetTick() - dhcp_last_tick >= 1) { dhcp_tick(); /* 驱动 ioLibrary 的 DHCP 定时器 */ dhcp_last_tick = HAL_GetTick(); } dhcp_status = DHCP_run(); } void dhcp_start(void) { dhcp_start_tick = HAL_GetTick(); dhcp_status = DHCP_RUNNING; while (dhcp_status == DHCP_RUNNING) { dhcp_tick_task(); if (HAL_GetTick() - dhcp_start_tick > DHCP_TIMEOUT_MS) { dhcp_status = DHCP_TIMEOUT; break; } } }

dhcp_tick()是 ioLibrary 要求每 1ms 调用一次的时基函数。DHCP_run()每次调用都会执行一次状态机推进,返回DHCP_RUNNING表示还没完成,返回DHCP_IP_ASSIGN表示拿到 IP,返回DHCP_FAILED表示超时或链路错误。这里有三个常见的坑:一是dhcp_tick()只在主循环调用时,必须确保主循环周期小于 1ms,否则 DHCP 状态机会误判超时;二是DHCP_run()内部会用 socket 2 收发 UDP 报文,所以不要在 DHCP 完成前手动占用 socket 2;三是拿到 IP 后要立即把芯片里的默认网关和子网掩码读出来,后面 TCP 连接会用。

如果 DHCP 一直失败,最常见的原因是路由器没有开 DHCP,或者 W5500 的 MAC 地址全为 0xFF。W5500 出厂时并没有写入 MAC,必须在wizchip_init()后用setSHAR()设置一个有效 MAC。注意同一个局域网内多个开发板不能使用相同 MAC,否则会出现一台能上线,另一台反复掉线的怪故障。

3.2 作为 TCP 客户端连接 OneNet:socket 参数不要照抄

OneNet 接入服务器地址和端口会随平台版本变化,不能拿网上旧例程的 IP 死填。当前常见做法是从 OneNet 控制台对应设备页面获取接入地址,很多旧工程示例使用私有端口进行 EDP 长连接,但新平台也支持标准 MQTT 端口。无论哪种,W5500 侧的动作都是先建立一个 TCP socket,再用connect()指定远端 IP 和端口。以下是一个带本地端口绑定的客户端连接示例:

/* tcp_client.c */ #define LOCAL_PORT 50000 #define SERVER_PORT 8765 /* 替换为 OneNet 控制台显示的端口 */ int8_t tcp_connect_server(uint8_t *server_ip, uint16_t server_port) { int8_t sock = socket(0, Sn_MR_TCP, LOCAL_PORT, 0); if (sock < 0) { return -1; } int32_t ret = connect(sock, server_ip, server_port); if (ret != SOCK_OK) { close(sock); return -1; } return sock; }

socket(0, Sn_MR_TCP, LOCAL_PORT, 0)表示使用 socket 0 建立 TCP 客户端。第三个参数是本地端口,不是远端端口,很多初学者这里会填成 8765。其实本地端口是让 W5500 绑定一个固定来源端口,建议用 40000 到 60000 之间的随机值。第四个参数是 Socket 标志位,TCP 客户端一般传 0。connect()是阻塞型调用,W5500 硬件会在内部完成 SYN 握手,正常情况下几十毫秒内返回SOCK_OK;如果对端 IP 不可达,通常 5 到 10 秒后返回SOCKERR_TIMEOUT,所以主循环在做连接前最好加一个软件超时,避免界面假死。

OneNet 接入地址不要直接写死成某个固定公网 IP,因为平台迁移或负载均衡会改。我一般会在工程配置区放一个宏定义表,把平台返回的域名解析成 IP 或用接入点 IP,然后单独留一个调试入口,方便换场地后重新填。若是从旧例程抄来的 IP 长时间连不上,优先去 OneNet 控制台重新复制最新接入地址。

3.3 保活、超时和断线重连的状态设计

W5500 硬件本身没有 TCP keepalive 机制,所以应用层必须在成功连接后周期发送心跳,OneNet 旧版 EDP 协议通常要求 30 到 60 秒一次心跳。如果协议指定的频率比这更短,以平台文档为准。我在工程里会设置一个全局结构体保存连接状态,把发送心跳和接收响应放在同一个轮询函数里:

typedef enum { NET_IDLE = 0, NET_DHCP, NET_CONNECTING, NET_ONLINE } net_state_t; void net_loop(int8_t sock) { static uint32_t last_heartbeat = 0; switch (net_state) { case NET_ONLINE: if (HAL_GetTick() - last_heartbeat >= 30000) { send(sock, heartbeat_pkt, heartbeat_len); last_heartbeat = HAL_GetTick(); } break; case NET_CONNECTING: /* 连接失败则重置到 IDLE 并整体重试 */ break; default: break; } }

这里没有用while(1)阻塞等待重连,而是将网络状态机放入主循环,由定时器驱动。这样当 OneNet 服务器端主动关闭连接时,recv()会返回SOCKERR_TIMEOUT或 0,主循环检测到后调用close(sock)再重新走 DHCP 或直接重连。断线重连建议加退避策略,比如第一次失败等 3 秒,第二次 5 秒,最多 30 秒,避免服务器还没恢复,开发板已经用高频率 SYN 把自己阻塞住了。良好的状态机设计是这套代码能否长时间稳定跑的关键。

4. 单路状态上传与远程控制闭环:数据帧构造与命令解析

4.1 读取 GPIO 状态并构造 OneNet 上行报文

单路状态最常见的输入是按键、门磁或者光电开关。STM32F103 这边先配置一个 GPIO 输入,读取电平后做一次消抖。然后需要按 OneNet 的接入协议把状态打包成应用层报文。以旧版 EDP 风格为例,报文由类型、剩余长度和消息体组成,消息体里包含设备标识和数据点。下面的代码展示了如何把状态值压缩进一个长度不超 127 字节的简单帧:

/* edp_build.c */ #define EDP_TYPE_DATA 0x03 int build_edp_data(uint8_t *buf, uint8_t device_id, uint8_t api_key_id, uint8_t state) { uint16_t offset = 0; uint8_t *len_pos; buf[offset++] = EDP_TYPE_DATA; len_pos = &buf[offset++]; offset += snprintf((char *)&buf[offset], 16, "%d", device_id); buf[offset++] = api_key_id; offset += snprintf((char *)&buf[offset], 8, "%d", state); *len_pos = (uint8_t)(offset - 2); /* 剩余长度不包含类型和长度字节 */ return offset; }

这个函数把设备 ID、APIKey 编号和状态值按顺序塞进缓冲区,然后用剩余长度字段告诉对端这次收了多少数据。注意剩余长度在 EDP 协议里按 7 位编码,超过 127 字节要使用多字节编码,这里状态数据很小所以直接一字节赋值。实际开发中,如果你用的是 OneNet 的 MQTT 接入,就需要把这块替换成 PUBLISH 报文,但“类型 + 长度 + 消息体”的思路是一致的。

发送时不要直接send()缓冲区里的全部内容,因为 TCP 是流协议,靠长度字段切分数据。我通常的做法是先send(sock, buf, len, 0),然后记录发送时间。OneNet 服务器返回的 ACK 不一定立即到达,主循环里的recv()会负责确认。

4.2 解析 OneNet 下发的控制命令并驱动输出

远程控制环节读取recv()缓冲区,按同样的帧结构拆包。这里重点不是如何把rx_buf[2]变成高低电平,而是如何处理 TCP 粘包和拆包。如下代码是根据单包固定长度做解析的简化版:

/* cmd_parse.c */ #define CMD_BUF_SIZE 32 uint8_t rx_buf[CMD_BUF_SIZE]; uint8_t parse_cmd(int8_t sock, GPIO_PinState *out_state) { int32_t len = recv(sock, rx_buf, sizeof(rx_buf) - 1, 0); if (len <= 0) { return 0; } /* 简单处理:一帧只包含一个完整命令 */ if (rx_buf[0] == EDP_TYPE_DATA && len >= 4) { uint8_t cmd = rx_buf[len - 1]; /* 最后一个字节作为命令值 */ if (cmd == 0x01) { HAL_GPIO_WritePin(CTRL_GPIO_Port, CTRL_Pin, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(CTRL_GPIO_Port, CTRL_Pin, GPIO_PIN_RESET); } return 1; } return 0; }

这段代码能跑通单帧命令,但 TCP 底层不保证一次recv()就能收到完整数据。比如 OneNet 下发一个 10 字节的命令,可能前 4 字节先到,后 6 字节隔几毫秒才到。因此正式的工程里要维护环形缓冲区,把收到的不完整帧缓存起来,等长度字段背后的数据全部到达后再解析。这个处理不复杂,但直接影响远程控制的可靠性。我遇到过很多次“平台显示已下发但设备没动作”的现场问题,最后定位都是拆包逻辑把第二条命令的前缀当成了完整帧。

4.3 数据上报策略:状态变化上报还是周期上报

单路状态场景下,常见策略是状态变化时立即上报,同时每 30 秒周期上报一次作为心跳。这样既能保证控制端看到的曲线实时刷新,又能在设备静默时让平台知道链路存活。状态变化立即上报的代码如下:

if (HAL_GPIO_ReadPin(SENSOR_GPIO_Port, SENSOR_Pin) != last_state) { last_state = HAL_GPIO_ReadPin(SENSOR_GPIO_Port, SENSOR_Pin); uint8_t pkt[16]; int len = build_edp_data(pkt, DEVICE_ID, API_KEY_ID, last_state); send(sock, pkt, len, 0); }

周期上报和状态量上报共用同一个打包函数,只是周期上报可以带一个时间戳。平台侧看到的数据点如果长时间没有更新,需要先检查设备是否掉线。在 OneNet 控制台,可以查看设备在线状态和最近一次上报的时间。如果设备显示在线但数据流没有更新,大概率是上报的报文格式不对,平台虽然建立了 TCP 连接但解析失败。

4.4 平台侧创建产品、生成 APIKey 和数据流配置

OneNet 平台的接入信息不是随便填的,需要先注册并登录自己的账号。进入开发者中心后创建产品,选择接入协议时,要注意你手里的 W5500 代码是老版 EDP 还是标准 MQTT。如果协议不匹配,即便 TCP 连接成功,平台也会在鉴权阶段拒绝设备。在设备页面创建单个设备后,会得到唯一的设备 ID 和 APIKey。APIKey 相当于访问密钥,代码里写入时不要提交到公开的 GitHub 仓库,泄露后别人可以往你的设备数据流里灌假数据。

数据流名称要和设备上报协议里写的字段保持一致。比如代码里上报的是on_off,平台就必须提前新建一个叫on_off的数据流,否则上报的数据点不会显示在折线图上。这一步是很多新手最容易忽略的:W5500 已经成功连接,也发了数据,但图表里空空如也。在 OneNet 控制台重新生成 APIKey 后,旧 Key 会立即失效,需要同步改代码并重启设备,不存在热更新。

5. 用 Wireshark 验证 OneNet 通信,以及 Keil 工程迁移要点

5.1 抓包验证:先看 TCP 握手,再看应用层数据

把开发板通过网线接到交换机,PC 也接同一交换机,Wireshark 选择对应网卡,抓包过滤器直接写tcp.port == 8765,其中 8765 替换成你自己的接入端口。如果有数据,立刻能看到 SYN、SYN-ACK、ACK 三条报文,这说明 W5500 的 TCP 客户端已经建连成功。再往后每 30 秒会出现一次应用层数据包,点开 Payload 看十六进制,能明显识别出类型字段和剩余长度字段是否正常。

如果只看到 SYN 但不断重发,说明服务器端口不通,或者服务器回包被防火墙拦截。这时先确认板子能 ping 通网关,再用arp -a看是否有正确的 W5500 MAC。W5500 出厂的 MAC 如果写成全 F,可能导致网关拒绝转发。抓包时不要混淆 PC 自己的网卡流量,最好在过滤器里加上eth.srcip.addr限定 W5500 的实际 IP。

5.2 Keil 工程从其他 STM32F103 型号迁移到 C8T6

原工程如果是基于 STM32F103ZET6 或别的型号,拿到手后第一件事不是改代码,而是改芯片型号。Keil 中点击 Options for Target,在 Device 页选择 STM32F103C8,然后到 Target 页把 ROM 起始地址设为 0x08000000,Size 填 0x10000,也就是 64KB Flash。如果这一步不修改,烧录器会用错误的 Flash 大小擦写,轻则下载失败,重则让芯片进入锁死状态。

在 C/C++ 选项卡里,STM32F10X_HD要改成STM32F10X_MD。这是 ST 标准外设库里的型号宏,C8T6 属于中等密度,如果用成高密度宏,ADC、Flash 等外设的地址映射会错乱。对于 RM0008 系列的库,还需要检查stm32f10x_conf.h里是否启用了所有用到的外设头文件。最后在 Flash Download 设置里选 ST-Link 或 J-Link 对应的下载算法,C8T6 的算法文件是 STM32F10x Med-density Flash。

如果打开工程后 Device 列表里找不到 STM32F103C8,说明 Keil 还没装 STM32 芯片包。安装完成后重启 Keil,再检查 Utilities 页的 Debug 驱动。工程里如果同时存在stm32f10x_flash.cstm32f10x_adc.c,不要直接删除,C8T6 资源有限,不用的外设文件可以移除,但前提是stm32f10x_conf.h中对应宏要注释干净。

5.3 J-Link 与 ST-Link 选择、No STM32 Target Found 的处理

在 Project 的 Options for Debug 里,右侧下拉框可以选择 ST-Link Debugger 或 J-LINK。选择后点击 Settings,SW Device 一栏如果能看到一个 ARM 内核 ID 说明正常。如果烧录时提示No STM32 Target Found,先检查 BOOT0 引脚是否被拉高到 3.3V。调试时 BOOT0 应该接地,否则芯片上电后进入 Bootloader,SWD 接口通常还能识别,但有些板子会因为供电问题导致无法进入调试模式。

另一个实用技巧是把 SWD 速率从 4MHz 或 8MHz 降到 1MHz。杜邦线连接 ST-Link 和板载 SWD 接口时,过高的时钟容易受寄生电容影响。按住复位键点击下载,等软件提示连接后再松开,这个“压枪式”烧录方法能解决很多接触不良问题。如果还是不行,用逻辑分析仪检查 ST-Link 的 SWDIO/SWCLK 是否分别连着 PA13/PA14,接反会直接烧写失败。跑 OneNet 连接时,建议把USE_DHCP宏打开,但调试阶段先用静态 IP,排除 DHCP 超时导致的网络状态异常,再切换回动态获取。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询