简介:面向物联网开发与嵌入式学习者的STM32+W5500实战工程,基于MQTT协议完整演示了接入百度天工云平台的数据收发流程。工程以STM32F103为主控,通过SPI驱动W5500以太网模块,既能主动上报温度、湿度及继电器状态,也能接收云端下发的控制指令并驱动执行,适合具备一定单片机基础的开发者参考。压缩包共207个文件,约6.37MB,以C源码和Keil工程文件为核心,包含标准外设库、编译产物及调试辅助文件,可直观对照源码与生成结果。验证代码基于STM32F103C8T6运行,同系列其他型号只需调整芯片型号与Flash容量即可复用;下载时根据实际调试器选择J-Link或ST-Link,能减少环境配置问题。目前已有884人学习,适合快速搭建物联网平台接入原型,也有助于理解MQTT指令交互、以太网协议栈移植以及板级外设协同的完整工程结构。
1. STM32 网口上云:为什么是 W5500 + MQTT 这条路
给一块 STM32 加网口,方案其实不少:LAN8720 跑 LwIP、串口转网口模块、或者干脆用带以太网控制器的 MCU。但工业现场和毕设里最常见的组合,是 STM32 加 W5500 这颗硬件协议栈芯片。W5500 内部把 MAC、PHY 和 TCP/IP 状态机一次做掉,MCU 只负责通过 SPI 收发 Socket 数据,固件里不需要 LwIP 那套内存池,对只有 20KB RAM 的 F103 也非常友好。这篇短文顺着标题里的“继电器 + 温湿度”展开,以百度云物联网平台作为 MQTT broker,写清楚从 W5500 初始化到 MQTT 收发闭环的完整测试程序怎么落地。适合手里已经有 STM32 开发板,想在一个下午之内把数据发到云端并收到远程命令的工程师。
2. W5500 电路图与寄存器初始化:让 STM32 先打通 TCP 链路
2.1 W5500 电路图与引脚要点:SPI 四线加复位、中断
W5500 不是普通的 PHY 芯片,它把 MAC、PHY、TCP/IP 协议栈和 32KB 收发缓存都封装进了单颗芯片。跟车载以太网那种动辄 TSN、SOME/IP 的复杂度相比,W5500 面向的是传统 10/100M 工业以太网,接线和驱动都简单得多。MCU 侧只需要 SPI 接口,协议栈里的连接、断开、重传、超时全部由芯片硬件处理,这决定了后续写 MQTT 代码时,我们根本不用关心 TCP 滑动窗口和重传定时器。
典型的 W5500 电路图里,信号线基本就是下面六根。网上很多参考设计会给它配一个带网络变压器的 RJ45 座,好处是省掉独立变压器布局的麻烦,差分对布线也更省心。
| 信号 | W5500 引脚 | STM32 引脚 | 说明 |
|---|---|---|---|
| SCLK | SCLK | PA5 | SPI1_SCK,建议分频后不超过 18MHz |
| MISO | MISO | PA6 | SPI1_MISO,W5500 输出 |
| MOSI | MOSI | PA7 | SPI1_MOSI,写入寄存器和数据 |
| SCS | SCSn | PA4 | 片选,软件控制,低电平有效 |
| RST | RSTn | PB0 | 硬件复位,低电平复位 |
| INT | INTn | PB1 | 中断输出,下降沿有效,可选 |
复位时序是个容易翻车的点。RST 拉低至少保持 500us,再拉高,之后等 150ms 再访问寄存器,否则 PHY 还没准备好,读出来的寄存器全是 0xFF。电源部分用 3.3V 给 VCC,芯片内部还会产生 1.8V 内核电压,注意在 VCC 引脚附近放 100nF 和 10uF 的退耦电容。差分线对 TD+/TD- 和 RD+/RD- 要成对走线,长度差控制在 5~10mil 以内,别为了抄近路把差分线拉得一条长一条短。
2.2 用 VERSION 寄存器验证 SPI 引脚接线
W5500 的 SPI 访问帧由三部分组成:16 位寄存器地址、8 位控制字节、数据段。控制字节里高两位是操作模式 OM,bit5 是读/写位 RWB,低 5 位是块选择 BSB。块选择从 0x00 到 0x08,0x00 对应公共寄存器区,0x01 到 0x08 对应 8 个 Socket。读操作时 RWB 置 1,所以控制字节就是0x20 | bsb。
先写一个最基础的读寄存器函数,用它读 VERSION 寄存器验证 SPI 接线是否正确。VERSION 是公共寄存器区地址 0x0039,复位后固定返回 0x04。
static uint8_t w5500_read_reg(uint8_t bsb, uint16_t addr) { uint8_t frame[3]; uint8_t value = 0; frame[0] = (uint8_t)(addr >> 8); // 地址高字节 frame[1] = (uint8_t)(addr & 0xFF); // 地址低字节 frame[2] = 0x20 | (bsb & 0x07); // RWB=1 读 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, frame, 3, 100); HAL_SPI_Receive(&hspi1, &value, 1, 100); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); return value; } uint8_t w5500_read_version(void) { return w5500_read_reg(0x00, 0x0039); // BSB=0x00 公共寄存器 }代码里 CS 在整个读操作期间保持低电平,HAL_SPI_Transmit 和 HAL_SPI_Receive 虽然是两次独立调用,但片选没有释放,W5500 会把它当成同一次寄存器访问。第三个参数 100 是 SPI 超时时间,单位毫秒,调试时如果总线时钟配错,这里会返回 HAL_TIMEOUT。上电后如果读到 0x04,说明 SPI 引脚、时钟和片选逻辑都没问题,再往下调 Socket 才有意义;如果读到 0x00 或 0xFF,先查复位引脚和 SPI 分频。
2.3 Socket 寄存器状态机:从 OPEN 到 ESTABLISHED 的最小代码
网络初始化要写的内容不多,依次是软件复位、网关、子网掩码、MAC 地址和本机 IP。公共寄存器区的地址比较规律:MR 是 0x0000,GAR 是 0x0001,SUBR 是 0x0005,SHAR 是 0x0009,SIPR 是 0x000F。写寄存器时注意不同寄存器的长度,GAR、SUBR、SIPR 都是 4 字节,SHAR 是 6 字节。
void w5500_net_init(const uint8_t *ip, const uint8_t *mask, const uint8_t *gw, const uint8_t *mac) { w5500_write_reg(0x00, 0x0000, 0x80); // MR: RST 软复位 HAL_Delay(10); w5500_write_buf(0x00, 0x0001, gw, 4); // GAR 网关地址 w5500_write_buf(0x00, 0x0005, mask, 4); // SUBR 子网掩码 w5500_write_buf(0x00, 0x0009, mac, 6); // SHAR MAC 地址 w5500_write_buf(0x00, 0x000F, ip, 4); // SIPR 本地 IP }TCP 连接的建立流程是固定的:先设置 Sn_MR 为 TCP 模式,写 Sn_CR 的 OPEN 命令,等到 Sn_SR 变成 SOCK_INIT 即 0x13,再写 CONNECT 命令,最后等 Sn_SR 变成 SOCK_ESTABLISHED 即 0x17。这里对应 Socket0,BSB 就是 0x01,Socket 寄存器的地址偏移从 0x0000 开始。
| 寄存器 | 相对地址 | 作用 |
|---|---|---|
| Sn_MR | 0x0000 | 协议模式,0x01 表示 TCP |
| Sn_CR | 0x0001 | 命令寄存器,写入触发动作 |
| Sn_SR | 0x0003 | 状态寄存器,命令结果反馈 |
void w5500_socket_connect(uint8_t *server_ip, uint16_t port) { w5500_write_reg(0x01, 0x0000, 0x01); // Sn_MR = TCP w5500_write_reg(0x01, 0x000F, 0x10); // Sn_DIPR = 对端 IP w5500_write_reg(0x01, 0x0013, (port >> 8) & 0xFF); // Sn_DPORT w5500_write_reg(0x01, 0x0014, port & 0xFF); w5500_write_reg(0x01, 0x0001, 0x01); // Sn_CR = OPEN while (w5500_read_reg(0x01, 0x0003) != 0x13); // 等 SOCK_INIT w5500_write_reg(0x01, 0x0001, 0x04); // Sn_CR = CONNECT while (w5500_read_reg(0x01, 0x0003) != 0x17); // 等 ESTABLISHED }这段代码里的 Sn_DIPR 是目的 IP 寄存器,相对地址 0x000F,占 4 字节;Sn_DPORT 是目的端口,0x0013 高字节、0x0014 低字节。CONNECT 命令发出后,TCP 三报文握手由 W5500 硬件完成,不需要 MCU 干预。如果状态长时间停在 0x15 也就是 SYN_SENT,说明对端不可达,先检查 IP 配置和网关;如果停在 0x13 不动,检查目的端口是不是被防火墙挡了。
3. 在 STM32 上组 MQTT 报文:温湿度上报与继电器控制的收发闭环
3.1 MQTT 协议详解里的关键报文与 TCP 状态配合
W5500 把 TCP 链路打通之后,MQTT 就是纯粹的应用层协议,这一步不需要移植完整的 MQTT 库。MQTT 3.1.1 的基础报文一共就十几种,收发测试程序里真正用到的只有 6 种。报文的第一个字节高四位是报文类型,低四位是标志位,解析时直接对 0xF0 取掩码就能分辨。
| 报文 | 固定头首字节 | 方向 | 用途 |
|---|---|---|---|
| CONNECT | 0x10 | 设备到云端 | 发起 MQTT 连接 |
| CONNACK | 0x20 | 云端到设备 | 返回连接结果 |
| PUBLISH | 0x30 | 双向 | 发布消息 |
| SUBSCRIBE | 0x82 | 设备到云端 | 订阅主题 |
| SUBACK | 0x90 | 云端到设备 | 订阅确认 |
| PINGREQ / PINGRESP | 0xC0 / 0xD0 | 双向 | 保活心跳 |
QoS 等级在固定头的标志位里体现。PUBLISH 用 QoS0 时首字节是 0x30,QoS1 是 0x32。温湿度上报这种数据,丢一次可以接受,用 QoS0 能省掉 PUBACK 往返;继电器指令必须可靠送达,建议把下行 Topic 的 QoS 设为 1,否则远程开合闸丢一条消息会造成误判。代码里订阅时怎么声明 QoS,发布时就必须匹配这个等级,不能发布端写 0、订阅端期望 1。
3.2 CONNECT 报文组包与剩余长度编码
CONNECT 报文的可变头固定是 10 字节:协议名“MQTT”占用 6 字节、协议级别 1 字节、连接标志 1 字节、Keep Alive 2 字节。后面的 Payload 依次是 Client ID、Username、Password,每段前都有 2 字节长度前缀。一个常见的坑是剩余长度字段:小数据包确实可以单字节表示,但只要 Client ID 和密码一长,剩余长度超过 127,就必须用 MQTT 的多字节编码规则。
uint8_t encode_rem_len(uint32_t len, uint8_t *out) { uint8_t cnt = 0; do { uint8_t b = len % 128; len /= 128; if (len) b |= 0x80; // 后面还有字节 out[cnt++] = b; } while (len); return cnt; }这段编码逻辑就是每次取低 7 位,最高位置 1 表示继续。解码时反过来累加即可。真正组 CONNECT 包时,先把可变头和 Payload 填进一个缓冲区,最后把剩余长度编码后插到固定头后面,这样的顺序不容易算错长度。
uint16_t mqtt_build_connect(uint8_t *out, const char *client_id, const char *username, const char *password) { uint16_t rem_len; uint16_t payload_len = 0; uint8_t *p = out; payload_len += 2 + strlen(client_id); // Client ID payload_len += 2 + strlen(username); // Username payload_len += 2 + strlen(password); // Password rem_len = 10 + payload_len; // 可变头 10 字节 *p++ = 0x10; // CONNECT p += encode_rem_len(rem_len, p); *p++ = 0x00; *p++ = 0x04; // 协议名长度 + "MQTT" *p++ = 'M'; *p++ = 'Q'; *p++ = 'T'; *p++ = 'T'; *p++ = 0x04; // 协议级别 4 *p++ = 0xC2; // Username+Password+CleanSession *p++ = 0x00; *p++ = 0x3C; // Keep Alive 60 秒 *p++ = 0x00; *p++ = strlen(client_id); memcpy(p, client_id, strlen(client_id)); p += strlen(client_id); *p++ = 0x00; *p++ = strlen(username); memcpy(p, username, strlen(username)); p += strlen(username); *p++ = 0x00; *p++ = strlen(password); memcpy(p, password, strlen(password)); p += strlen(password); return (uint16_t)(p - out); }连接标志 0xC2 二进制是 1100 0010,bit7 置 1 表示有 Username,bit6 置 1 表示有 Password,bit1 置 1 表示 Clean Session。如果你的设备管理后台不允许两个连接同时在线,这个标志就不能改成 0。Keep Alive 填写 60 秒,设备必须在 60 秒内发任意报文,通常做法是发 PINGREQ。组合出来的这一包数据就是 MQTT Client 的核心,W5500 把它当成普通的 TCP 发送数据,通过 Sn_CR 的 SEND 命令发出去即可。
3.3 把继电器和温度 Topic 约定好,让收发测试闭环跑起来
温湿度采集和继电器控制是两个独立的功能模块,但都挂在 MQTT 收发循环上。市场上大量成品的“以太网温湿度传感器”也是这个套路:采集端定时发布数据,控制端订阅命令 Topic,本质上就是一套发布订阅模型。测试代码里不需要上 cJSON,用自描述的短文本协议就能跑通闭环。
| 功能 | Topic | QoS | Payload 示例 |
|---|---|---|---|
| 温湿度上报 | user/{productId}/{deviceName}/telemetry | 0 | temperature=26.5,humidity=48.2 |
| 继电器命令 | user/{productId}/{deviceName}/relay | 1 | RELAY:ON |
| 继电器应答 | user/{productId}/{deviceName}/relay_reply | 0 | relay=ON |
温湿度传感器用 DHT11 最省事,GPIO 初始化成开漏输出加 4.7k 上拉,读取前先拉低 18ms 再释放,传感器返回 40bit 数据。DHT11 的时序对中断很敏感,读取期间最好关全局中断,否则偶发一位错位,温度和湿度会出现整段跳变。校验位通过后,sprintf 拼成上面的字符串,调用 mqtt_publish 发送。
float temperature = 0.0f, humidity = 0.0f; char payload[64]; if (dht11_read(&temperature, &humidity) == 0) { snprintf(payload, sizeof(payload), "temperature=%.1f,humidity=%.1f", temperature, humidity); mqtt_publish(TOPIC_TELEMETRY, payload, 0); }继电器控制的命令解析也放在主循环里,收到 MQTT 消息后先判断这是不是自己订阅的 Topic,再对 Payload 做字符串匹配。继电器模块如果是低电平触发,GPIO 初始化时就要注意默认电平,避免上电瞬间继电器误动作。
if (strcmp(topic, TOPIC_RELAY) == 0) { if (strstr(payload, "RELAY:ON")) { HAL_GPIO_WritePin(RELAY_GPIO_Port, RELAY_Pin, GPIO_PIN_SET); mqtt_publish(TOPIC_RELAY_REPLY, "relay=ON", 0); } else if (strstr(payload, "RELAY:OFF")) { HAL_GPIO_WritePin(RELAY_GPIO_Port, RELAY_Pin, GPIO_PIN_RESET); mqtt_publish(TOPIC_RELAY_REPLY, "relay=OFF", 0); } }这段逻辑里 strstr 做匹配简单直接,但如果有两个指令分别是 RELAY:ON 和 RELAY:ONCE,短字符串匹配就会误触发。测试代码用 RELAY:ON 和 RELAY:OFF 这种互斥指令问题不大,正式产品建议改成精确比较或者直接解析 JSON。继电器的线圈端必须并续流二极管,MCU 控制端如果有光耦隔离更好,不然继电器吸合瞬间的浪涌会通过地线干扰 W5500 的以太网收发。
4. 百度云物联网平台接入:三元组、HMAC-SHA256 签名与 MQTT 连接参数
4.1 在控制台建产品、定义物模型,拿到设备三元组
百度云物联网平台实际上帮你把 MQTT 服务器搭建这件事托管了,不需要自己维护 Broker。登录物联网核心套件后,先建产品,再在产品下定义物模型,把温度、湿度、继电器开关这三种属性列出来,系统会自动生成对应的数据模板。接着注册设备,拿到产品 ID、设备名、设备密钥这三个值,固件里所有鉴权都依赖这三元组。
物模型的属性定义会直接影响后续 Topic 的结构,但测试代码里其实不一定用物模型自带的 Topic。我一般习惯在设备详情页把平台生成的接入信息复制到本地,重点关注 Broker 地址、端口和用户名密码样例。百度云的 Broker 地址属于当前创建区域的资源,通常在控制台概览页显示,格式类似{产品ID}.mqtt.iot.{区域}.baidubce.com,端口默认 1883,TLS 用 8883。
4.2 用 deviceSecret 生成 password:HMAC-SHA256 签名在 STM32 上的落地
百度云 MQTT 接入的 password 不是设备密钥本身,而是用设备密钥对一组请求因子做 HMAC-SHA256 签名,再转成十六进制字符串。签名因子通常包含版本号、时间戳、Topic 等因素,具体拼接顺序以控制台“生成密码”工具给出的样例为准。调这个环节最容易出的问题是因子顺序写反,导致云端验签失败。我在 PC 上会先用 OpenSSL 验证一遍签名结果,再搬到固件里。
echo -n "source_string" | openssl dgst -sha256 -hmac "deviceSecret"固件端用 mbedTLS 的 MD 模块实现同样的计算,STM32CubeMX 里勾选 mbedTLS 软件包后就能直接调用。完整计算 HMAC-SHA256 只需要四个步骤:初始化上下文、指定 SHA256 算法、喂入密钥、喂入消息。
void calc_password(const char *secret, const char *source, uint8_t out[32]) { mbedtls_md_context_t ctx; mbedtls_md_init(&ctx); mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1); mbedtls_md_hmac_starts(&ctx, (const uint8_t *)secret, strlen(secret)); mbedtls_md_hmac_update(&ctx, (const uint8_t *)source, strlen(source)); mbedtls_md_hmac_finish(&ctx, out); mbedtls_md_free(&ctx); }计算出来的 out 是 32 字节原始摘要,MQTT 连接参数里需要的是十六进制字符串,还需要一个 hex 转换函数把它变成 64 个字符的小写字符串。转换时注意 sprintf 每次调用开销不小,测试代码里可以预先分配 64 字节的缓冲,用查表法转换,别在每次重连时反复格式化。签名里如果带时间戳,设备还需要有一个准确的时间源,最简单的办法是在固件里固定一个较长的有效期,或者连上平台后先从服务器同步一次时间。
MQTT 连接参数的整理可以做成一张表,方便和平台控制台对比:
| MQTT 参数 | 取值 | 说明 |
|---|---|---|
| Broker 地址 | 控制台概览页展示 | 区域资源地址 |
| TCP 端口 | 1883 | TLS 加密连接用 8883 |
| Client ID | 设备唯一标识 | 同一设备重复连接会互踢 |
| Username | 产品 ID 加设备名的组合 | 按平台样例拼接 |
| Password | HMAC-SHA256 签名后的 hex | 用 deviceSecret 生成 |
4.3 上线、订阅、发布的时序:与 W5500 状态机配合
从 W5500 的角度看,MQTT 的上线过程分两层。先让 TCP Socket 进入 ESTABLISHED,再在应用层发 MQTT CONNECT。TCP 层连接失败和 MQTT 鉴权失败是两类完全不同的错误,调试时不要被“上不了线”一句话混在一起排查。正确顺序是:W5500 的 Sn_SR 变成 0x17 后,把 mqtt_build_connect 生成的数组通过 Socket SEND 发出去,然后等待接收缓冲区出现 0x20 开头的报文。
收到 CONNACK 后,0x20 0x02 这两个字节是固定的,第三个字节不用管,第四个字节才是返回码。返回码为 0 表示连接成功,接着发 SUBSCRIBE 报文订阅继电器 Topic。收 SUBACK 后再进入主循环,定时发布温湿度数据。平台侧在设备列表中能看到设备上线,物模型数据里会出现温度和湿度属性,这就是最直接的验证方式。
百度云控制台的“在线调试”功能可以直接向设备下发命令,测试时用平台调试工具往 relay Topic 发一条 RELAY:ON,看 STM32 的调试串口输出和继电器动作,能省掉反复改固件的时间。如果平台日志里能查到上行消息但设备端没反应,优先查 Topic 是否完全一致,多一个斜杠或者少一个层级都不行。
5. 收发测试排错:用 Wireshark、CONNACK 返回码和 Socket 重连把链路调好
5.1 用 Wireshark 把 TCP 层和 MQTT 层分开看
抓包是这个方案里效率最高的排错手段。电脑直接用网线连设备的 RJ45 口,Wireshark 选择对应网卡,先过滤目标端口,再逐步缩小范围。TCP 层只看三次握手有没有完成,MQTT 层看协议交互过程,两者混在一起很难定位。
tcp.port == 1883如果只看到 SYN 重传,说明 TCP 层就没通,问题在网线、IP 配置或防火墙,跟 MQTT 无关。TCP 握手正常之后再加过滤器mqtt,看 CONNECT 发出后有没有 CONNACK 回来。电脑端如果同时跑着其它 MQTT 调试客户端,过滤器要加&& ip.addr == <设备IP>排除干扰。
5.2 CONNACK 返回码与签名相关报错
CONNACK 返回码是最直接的错误线索。返回码不是 0 时,平台会在这一条报文里明确告诉设备端问题出在哪。
| 返回码 | 含义 | 处理方向 |
|---|---|---|
| 0 | 连接接受 | 正常 |
| 1 | 协议版本不支持 | 检查协议级别是否为 4 |
| 2 | Client ID 不合法 | 检查标识符字符集 |
| 3 | 服务不可用 | 检查平台配额和产品状态 |
| 4 | 用户名或密码错误 | 检查 Username 格式 |
| 5 | 未授权 | 重点检查 HMAC 签名因子顺序 |
返回码 5 是出现频率最高的,问题基本集中在 password 生成环节。先在 PC 上用 OpenSSL 把签名因子算一遍,再和固件里算出的 hex 字符串逐字符对比。时间戳因子的位数和有效期也要一致,如果平台要求秒级时间戳,固件里填了毫秒级,验签必然失败。
5.3 断线重连的 Socket 恢复顺序
设备长时间运行后云端把连接断开,或者网线被拔掉再插回去,测试代码里最容易出现一个错误:W5500 的 Socket 还停留在旧状态就直接重发 CONNECT。TCP 连接已经不存在,Sn_SR 不会自动回到 CLOSED,正确做法是先显式关闭 Socket,等状态归零后重新走一遍 OPEN 和 CONNECT 流程。
void w5500_socket_reopen(void) { w5500_write_reg(0x01, 0x0001, 0x10); // Sn_CR = CLOSE while (w5500_read_reg(0x01, 0x0003) != 0x00); // 等 CLOSED w5500_write_reg(0x01, 0x0001, 0x01); // Sn_CR = OPEN /* 重新设置目的 IP 和端口,再走 CONNECT */ }CLOSE 命令执行后,Socket 的中断标志位会置位,如果启用了 INT 引脚,重连前要把 Sn_IR 对应位清掉,否则 W5500 的 INT 引脚一直拉低,主循环会把残留的中断当成新事件反复处理,表现出来就是重连后第一包数据经常丢失。Keep Alive 期间设备没有数据要发,主动发 PINGREQ 维持链路;如果发出 PINGREQ 后长时间收不到 PINGRESP,才判定链路失效,走上面的关闭流程。通过这种方式,测试代码才能在局域网断电、平台重启、网线松脱这些场景里保持可预期地工作。
本文还有配套的精品资源,点击获取