ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F103+EC200U接入阿里云物联网:AT指令MQTT实战

STM32F103+EC200U接入阿里云物联网:AT指令MQTT实战 简介这是一套面向单片机开发者的STM32F103连接移远EC200U-4G模块的物联网项目例程核心解决设备通过4G网络以JSON格式上传多参数数据至阿里云物联网平台并实现平台下发指令控制设备的需求。代码采用KEIL标准库开发适用于STM32F103系列多数型号包含模块接线定义、云平台交互逻辑和详细注释方便二次开发与学习。压缩包共230个文件主要包含C源码、H头文件、KEIL工程配置文件及编译生成文件另有操作参考图片与辅助清除脚本包体大小6.47MB。已有113人浏览学习适合正在做4G通信、阿里云IoT接入或单片机项目开发的初中级工程师参考。资料内含完整的JSON数据发送与指令下发示例可帮助读者快速掌握从硬件接线到云平台联调的关键流程节省项目开发时间。若需接入其他传感器还可结合账号发布的同类资料进行扩展。1. 从串口到云端的最后一公里为什么选 EC200U 而不是 4G DTU做嵌入式物联网项目时最常遇到的不是传感器读不出来而是数据到了云平台之后格式不对、指令下发不下去。很多开发者习惯用现成的 4G DTU 透传模块但 DTU 的价格、体积和灵活性在批量产品里往往不占优势。把 STM32F103 直接通过串口接移远 EC200U用 AT 指令自己封装 JSON 数据包发到阿里云物联网平台看起来多写了不少代码其实换来的是对每一帧数据的完全控制——这在需要频繁调整上报字段、处理下行控制指令的项目里收益远大于成本。这套例程基于 KEIL 标准库开发当前跑在 STM32F103 上核心思路是串口 1 接调试、串口 3 接 EC200U通过 AT 指令完成 MQTT 连接、主题订阅和数据发布。如果你用的是 STM32F103 的其他型号只需要在 KEIL 里改芯片型号和 Flash 容量即可外设驱动几乎不用动。整个工程拿到手之后第一件事不是打开 main.c 从头读而是先确认 KEIL 的下载器选项是 J-Link 还是 ST-Link这个选错了编译再通过也烧不进去。下文会从 AT 指令的 MQTT 建连流程、JSON 组包与解析、下行控制指令的状态机处理三个层面拆解这套工程的完整链路。2. EC200U 的 MQTT 建连与 AT 指令时序设计2.1 EC200U 的 AT 指令分层结构EC200U 是移远推出的 LTE Cat 1 模块相比 EC20 系列它的封装更小、功耗更低而且内置了 MQTT 协议栈。这意味着我们不需要在 STM32F103 上移植 paho 之类的 MQTT 客户端库只需要通过串口向模块发送标准 AT 指令模块就会自己完成 TCP 连接、MQTT CONNECT 报文发送和心跳维持。这个设计大幅降低了 MCU 侧的资源占用也避免了单片机内存不足导致 MQTT 报文分片出错的问题。工程里处理 AT 指令的方式是典型的「发送—等待应答—超时重试」状态机。每个指令都有独立的应答超时时间比如网络附着查询ATCEREG?需要等待 3 秒而 MQTT 连接指令ATMQTTCONN需要等待 10 秒因为模块要完成 DNS 解析和 TCP 三次握手耗时波动很大。代码中用一个全局的AT_CMD_Status枚举变量记录当前指令的状态串口接收中断里每收到一个完整的行就进行一次匹配判断匹配成功则置位对应的状态标志主循环检测到标志后再发送下一条指令。// EC200U MQTT 连接核心指令序列 ATCGDCONT1,IP, // 设置 PDP 上下文APN 留空表示自动获取 ATCEREG? // 查询网络注册状态期望返回 CEREG: 0,1 ATMQTTCONNaliyun.com,1883,deviceName,productKey,deviceSecret,30,1,0这段序列中CGDCONT是附网的前提APN 留空由运营商自动分配实测在移动和联通卡下都能正常获取 IP。MQTTCONN的参数依次是 MQTT 服务器地址、端口、设备名deviceName、产品标识productKey、设备密钥deviceSecret、保活时间秒、cleanSession 标志和遗嘱标志。阿里云物联网平台的 MQTT 连接不是直接填明文密码的需要先通过 HMAC-SHA1 算法计算签名但例程里通过三元组直接连接的方式是因为模块的 MQTT 指令内部封装了 TLS 选项注意这里MQTTCONN最后一个参数0表示不启用 SSL因为阿里云 for 物联网平台的 1883 端口支持明文 MQTT不需要走加密链路。2.2 串口接收缓冲区的环形队列设计EC200U 的 AT 应答是异步到达的如果使用简单的strstr轮询很容易在处理一条长响应时丢掉后续数据。例程中为串口 3 配置了一个 512 字节的环形缓冲区接收中断只负责往缓冲区写数据主循环中由AT_Process函数按行提取完整数据帧。环形队列的读写指针都用了volatile修饰避免编译器优化导致读写顺序错乱。// 串口 3 接收中断处理EC200U 数据到达 void USART3_IRQHandler(void) { uint8_t data; if (USART_GetITStatus(USART3, USART_IT_RXNE)) { data USART_ReceiveData(USART3); uint16_t next (rx3_head 1) % RX3_BUFFER_SIZE; if (next ! rx3_tail) { // 缓冲区未满 rx3_buffer[rx3_head] data; rx3_head next; } } }代码里有个细节值得注意环形队列只在队头等于队尾时认为缓冲区为空但在写入前就先检查next是否等于tail这是为了防止写入后 head 追上 tail 导致数据覆盖。这种「预留一个空位」的做法比维护一个 count 计数器更简单也不会出现在满缓冲区时还要额外处理 count 溢出的问题。RX3_BUFFER_SIZE设置成 512是因为阿里云物联网平台下发的一条 JSON 指令最长可能在 300 字节左右加上 AT 指令自身的MQTTSUBRECV:前缀和长度字段512 字节能完整容纳一条订阅消息而不被截断。2.2.1 指令应答匹配的边界条件AT 指令应答不是每行都有OK结尾比如CEREG: 0,1这种主动上报的 URC 数据也可能在网络状态变化时随时到达。因此例程里不能只匹配OK而是要先判断当前处于哪个等待状态。代码中用一个current_expect变量来记录期望的应答特征串每次收到新行时先检查是否包含current_expect指定的字符串如果包含则进入下一步如果收到的是ERROR则直接复位当前状态机重新发送上一条指令。重试次数限制为 3 次超过后点亮板载错误 LED 并停止后续操作这个处理方式对生产环境排查硬件接线问题非常有效——如果是串口 TX/RX 接反模块根本不会有任何应答状态机就会卡在第一条指令上通过串口 1 调试口可以看到AT_SEND_TIMEOUT的日志输出。3. JSON 数据组包多参数上报的格式规划与转义处理3.1 为什么不用 sprintf 直接拼接 JSON很多初学者做 JSON 上报时习惯用sprintf直接格式化字符串例如sprintf(buf, {\temp\:%.1f}, temp)。在小数据量、固定格式的场景下这样确实可以但本项目的实际需求是动态采集多个传感器参数比如温度、湿度、电压、信号强度且每个参数的启用与否由外部配置决定。如果全用sprintf拼接每增加一个可选参数就要在格式化字符串里加上一段固定的%s代码维护成本很高而且浮点数转字符串时sprintf默认的%f格式在不同库实现下行为不一致可能输出1.200000这种冗长数字浪费 MQTT 带宽。例程里采用分段组包的方式定义了一个 JSON 键值对结构体数组每个元素包含键名、数据类型和值指针然后按顺序写入一个静态字符缓冲区。这样做的另一个好处是可以动态控制哪些参数参与上报——比如电池供电的设备在低功耗模式下可以不发电压值只需要跳过对应数组项。// JSON 参数项定义 typedef struct { const char* key; // JSON 键名 uint8_t type; // 0整数, 1浮点, 2字符串 const void* value; // 指向实际数据 } JSON_Item_t; // 组包函数将 items 数组中 n 个参数打包成 JSON 字符串 void JSON_Build_Packet(char* out, uint16_t out_size, JSON_Item_t* items, uint8_t n) { uint16_t len 0; len snprintf(out len, out_size - len, {\params\:{); for (uint8_t i 0; i n; i) { if (i 0) { len snprintf(out len, out_size - len, ,); } if (items[i].type 0) { len snprintf(out len, out_size - len, \%s\:%d, items[i].key, *(int*)items[i].value); } else if (items[i].type 1) { len snprintf(out len, out_size - len, \%s\:%.2f, items[i].key, *(float*)items[i].value); } } len snprintf(out len, out_size - len, },\version\:\1.0\}); }3.2 阿里云物联网平台 JSON 格式的约束阿里云物联网平台对设备上报的 JSON 数据有严格的格式约束顶层必须是params字段且每个参数的值只能是整数、浮点、字符串或 JSON 对象不能是数组。例程里在params之外额外挂了version字段这个字段不是必须的但加上之后在云端日志工具里能直接看到数据版本便于排查前后端格式不一致的问题。注意这里整形和浮点的输出差异如果传感器采集到的温度值本身是整数用type0输出为22用type1输出为22.00云端两者都能解析但如果你在阿里云控制台为这个属性定义了float类型那么上报22也会被自动转为22.0不会有问题反之以整数类型接收22.00却可能出现类型转换警告。再来看字符串类型参数的处理。例程中字符串值来自 EC200U 的ATCSQ指令返回的信号强度这个值本质上是整数但某些场景下我们需要上报模块的 ICCID 或固件版本号这些就必须作为字符串处理。在拼接 JSON 时字符串必须带双引号而且如果字符串内部含有引号或反斜杠需要先做转义。4G 模块返回的 ICCID 是纯数字不需要转义处理但如果接入了 GPS 模块返回的 NMEA 语句里面的逗号和引号就会破坏 JSON 结构这时候需要在组包前调用一个JSON_Escape函数先把特殊字符替换为\和\\。3.2.1 MQTT 发布指令中的长度字段陷阱组好的 JSON 字符串最终要通过ATMQTTPUB指令发送出去这可能是整个例程中坑最多的地方。该指令的格式是ATMQTTPUBtopic,payload_len,qos,retainpayload_len必须准确等于实际 JSON 字符串的字节数不能用strlen的返回值直接替代因为如果 JSON 中含有中文字符比如设备名称用了中文strlen返回的是字节数而非字符数但 MQTT payload 长度本身就是以字节为单位计算的所以strlen在这个场景下反而是正确的。真正的问题出在换行符上ATMQTTPUB指令发送完头部之后模块会等待你输入 payload 数据并以0x1ACtrlZ作为结束符。如果在组包时不小心在 JSON 字符串末尾附带了\r\n长度字段就必须包含这两个字节否则模块会把换行符当作 payload 的一部分发送到云端导致 JSON 解析失败。下面是例程中发送 MQTT 数据的关键代码注意USART3_SendString发送完 JSON 后紧接着发送0x1A// 发布 JSON 数据到指定 topic void MQTT_Publish_JSON(const char* topic, char* payload) { uint16_t payload_len strlen(payload); char cmd[128]; // 拼接发布指令设置 QoS0retain0 sprintf(cmd, ATMQTTPUB\%s\,%d,0,0\r\n, topic, payload_len); USART3_SendString(cmd); // 等待模块返回 字符后再发送数据 while (!mqtt_pub_ready_flag); mqtt_pub_ready_flag 0; USART3_SendString(payload); USART3_SendByte(0x1A); // 发送结束符告知模块 payload 结束 }这个while (!mqtt_pub_ready_flag)等待的是模块返回的提示符它会覆盖在完整指令应答之前。如果模块还有上一条指令的应答没有发送完会被延迟所以这里不能用固定延时替代否则在高频发布时会出现 payload 和指令头部黏在一起的问题模块解析时把 payload 的前几个字符当成指令参数返回ERROR。例程中mqtt_pub_ready_flag在串口接收中断中置位每当收到单独一行的就置位主循环发送完数据后清除该标志这套机制保证每条发布指令在时序上是干净的。4. 下发控制指令的解析从云端 Topic 到 GPIO 电平的完整链路4.1 订阅主题与 URC 数据到达MCP 设备接入阿里云物联网平台后云端指令下发是通过一个固定的/user/xxx主题实现的。设备需要先发送ATMQTTSUB指令订阅这个主题之后模块收到云端消息时会自动在串口输出一条 URC——形如MQTTSUBRECV: 0,/user/device01,23,{method:thing.service.property.set,params:{LightSwitch:1}}其中0是消息 id后面是主题名23是 payload 的字节数精确到字节再往后是一段原始 JSON 字符串。需要注意的是如果 payload 长度超过串口缓冲区剩余空间模块可能分两次输出第二次输出的行首没有MQTTSUBRECV:前缀。例程中的串口解析代码针对这种情况做了处理如果当前没有处于一条完整的 URC 接收过程中就必须在行首匹配MQTTSUBRECV:如果已经在接收过程中下一行的内容直接追加到缓冲区尾部直到累积长度达到 URC 中声明的字节数。// URC 数据接收状态机 typedef enum { URC_IDLE, // 空闲状态等待 MQTTSUBRECV 头 URC_HEADER_RCVD, // 已收到头部正在接收 payload URC_COMPLETE // payload 接收完成等待处理 } URC_State_t; // 在串口接收处理中逐字节或逐行调用 void URC_Process_Line(char* line, uint16_t len) { if (urc_state URC_IDLE) { // 检查是否为新消息到达 if (strncmp(line, MQTTSUBRECV:, 13) 0) { // 解析出 payload 长度字段 urc_expected_len 0; sscanf(line, MQTTSUBRECV: %d,%[^,],%d,, urc_msg_id, urc_topic, urc_expected_len); urc_state URC_HEADER_RCVD; urc_recv_len 0; } } else if (urc_state URC_HEADER_RCVD) { // 第二行开始才是 payload 内容 // 实际项目中这里需要把 line 拷贝到 urc_payload 缓冲区 strcpy(urc_payload urc_recv_len, line); urc_recv_len len; if (urc_recv_len urc_expected_len) { urc_state URC_COMPLETE; URС_Parse_And_Execute(urc_payload); } } }4.2 JSON 解析轻量级手工遍历而不是引入 cJSON看到这里你可能会想STM32F103 上解析 JSON 不是可以直接移植 cJSON 库吗例程没有这么做原因有两个。第一cJSON 库虽然成熟但动态内存分配在 F103 这种 20KB RAM 的芯片上必须非常谨慎频繁的malloc和free会产生内存碎片而嵌入式设备通常是 7x24 小时运行的碎片累积到一定程度就会导致解析失败。第二云端下发的控制指令格式是固定的params下层的键值对数量和字段名都是设备物模型定义好的不需要一个通用解析器去处理任意嵌套结构。因此例程实现了一个专门的解析函数先查找params后的第一个{然后在这个子串中逐个匹配预定义好的键名。匹配方式使用strstr找到键名字符串出现的位置再结合sscanf提取冒号后面的值。这个方案的局限是嵌套层级不能太深键名不能重复出现但对于解析控制指令来说完全够用。// 解析云端下发的属性设置指令 void URC_Parse_And_Execute(char* json) { char* params strchr(json, {); if (params NULL) return; int light_switch -1; char* key_pos strstr(params, \LightSwitch\); if (key_pos ! NULL) { // 找到冒号解析后面的整数值 char* colon strchr(key_pos, :); if (colon ! NULL) { light_switch atoi(colon 1); } } if (light_switch 0) { // 控制 GPIO if (light_switch 1) { GPIO_SetBits(GPIOB, GPIO_Pin_0); // 打开 LED } else { GPIO_ResetBits(GPIOB, GPIO_Pin_0); // 关闭 LED } } }这段代码中strstr(params, \LightSwitch\)里的转义引号是为了精确匹配键名避免错误匹配到LightSwitchValue之类的子串。使用strchr寻找冒号然后atoi把数字字符串转成整数整个过程没有动态内存分配栈上只消耗几十字节。这里需要注意atoi在字符串开头是非数字字符时返回 0所以用light_switch的初始值-1来判断是否真的收到了合法指令这样即使云端误下发了一个空指令也不会误触 GPIO 操作。4.2.1 多参数控制指令的扩展实践如果物模型里有多个布尔控制量比如LightSwitch、FanSwitch、BeepSwitch不需要把解析代码复制多遍可以用一个查找表来组织键名和对应的控制函数指针。例程在main.c中定义了一个Control_Item_t结构体数组每个元素包含键名字符串和回调函数在URC_Parse_And_Execute中遍历这个数组命中键名后调用对应的回调函数。这种设计把「通信解析」和「业务控制」解耦开来后续添加新控制项时只需要增加数组项和回调实现主循环逻辑完全不用改动。以下是扩展后的查找表定义方式typedef struct { const char* key; void (*set_handler)(int value); } Control_Item_t; // 具体的控制函数 void Set_Light(int val) { val ? GPIO_SetBits(GPIOB, GPIO_Pin_0) : GPIO_ResetBits(GPIOB, GPIO_Pin_0); } void Set_Fan(int val) { val ? GPIO_SetBits(GPIOB, GPIO_Pin_1) : GPIO_ResetBits(GPIOB, GPIO_Pin_1); } void Set_Beep(int val) { val ? GPIO_SetBits(GPIOB, GPIO_Pin_2) : GPIO_ResetBits(GPIOB, GPIO_Pin_2); } // 条目定义命中后调用 set_handler(value) const Control_Item_t control_table[] { {LightSwitch, Set_Light}, {FanSwitch, Set_Fan}, {BeepSwitch, Set_Beep}, };5. KEIL 工程配置、下载器选型与串口调试三板斧5.1 KEIL 芯片型号和 Flash 容量调整拿到例程后如果使用的是 STM32F103C8T6 或 STM32F103ZET6编译前必须检查 KEIL 的 Device 选项。例程默认是基于某一款 F103 芯片编译的如果你用的芯片 Flash 容量比默认值大比如从 C8T664KB换成 ZET6512KB直接在 Options for Target - Device 里切换到对应型号即可。但这里有个隐藏坑F103 系列不同型号的启动文件可能不同比如startup_stm32f10x_hd.s对应高密度芯片startup_stm32f10x_md.s对应中等密度。如果 Device 型号改了而启动文件没跟着换程序可能跑飞。KEIL 在切换 Device 后通常不会自动更新启动文件需要你手动在项目管理器中删除旧的启动文件再从Libraries\CMSIS\CM3\DeviceSupport\ST\STM32F10x\startup\arm目录下添加对应文件。Flash 容量设置也需要同步调整。点击 Options for Target - Target 选项卡在IROM1中确认起始地址0x08000000Size 要和芯片匹配。C8T6 是 0x1000064KBRCT6 是 0x40000256KBZET6 是 0x80000512KB。如果你的代码编译后超过了这个范围链接器会直接报错不会烧录损坏芯片这一点不用担心。5.2 J-Link 与 ST-Link 的驱动选择工程下载时的下载器选项在 Options for Target - Debug 选项卡中。使用 J-Link 时选择J-LINK/J-TRACE Cortex使用 ST-Link 时选择ST-Link Debugger然后在右侧 Settings 里确认 SW Device 能识别到芯片 ID。注意 J-Link 和 ST-Link 的接线方式差异J-Link 常用 20 针 JTAG 接口但连接 STM32F103 最小系统板时通常只用 SWD 模式的 4 根线——SWDIO、SWCLK、GND、3.3VST-Link 的 SWD 接口则是 4 针排针两者引脚定义不一样接错会提示No target connected。还有一个容易忽略的问题是下载速度。ST-Link 在高速模式4MHz 以上下连接某些 F103 芯片时可能不稳定表现为Flash Download failed - Cortex-M3错误。此时把 Settings - SWD - Max Clock 降到 1MHz通常就能稳定烧录。而 J-Link 遇到这个问题的情况较少但如果线缆过长超过 20cm也建议降低时钟频率。5.3 三个串口的角色分配与常见接线错误例程中使用了两路串口串口 1 作为调试日志输出发送 MCU 的运行状态和 AT 指令交互日志串口 3 连接 EC200U用于 AT 指令通信。很多复制该工程的人犯的第一个错误就是把串口 1 和串口 3 的 TX/RX 接反。EC200U 模块侧的 TX 必须接 STM32 的 RXPA11模块侧 RX 接 STM32 的 TXPA10如果反了模块收不到任何指令。排查方法很简单打开串口调试助手例程中串口 1 输出日志波特率设为 115200如果看到系统启动日志但没有任何 AT 指令发送记录多半是串口 3 的发送引脚配置有问题。另一种典型接线错误是串口 3 的 TX/RX 引脚被复用冲突。STM32F103 的串口 3 默认引脚是 PB10TX和 PB11RX但如果你的板子把 PB10/PB11 用作其他功能比如 SPI2 或 I2C2需要改用重映射配置把串口 3 映射到 PD8/PD9。例程中如果在GPIO_PinRemapConfig(GPIO_Remap_USART3, ENABLE)处设置重映射但代码里的时钟开启只使能了 GPIOB 而没使能 GPIOD串口一样不工作。调试 AT 指令时我一般会在串口 1 的输出日志里同时打印发送指令和收到的应答字符串方便对照模块的实际行为。例程中用了一个简单的AT_Log(const char* str)函数发送指令前调用收到应答后也会调用一次输出格式类似[TX]:ATMQTTCONN...和[RX]:OK。这个日志在写自己的应用层协议时特别有用——你可以清晰看到每一条 AT 指令的交互时序是否和模块手册一致。6. 高频上报场景下的保活参数调整与模块异常恢复技巧设备接入阿里云物联网平台后如果长时间没有数据交互平台会主动断开 MQTT 连接。EC200U 内部有 keep-alive 机制保活时间在ATMQTTCONN指令的第 7 个参数中设置。例程默认用的是 30 秒这个值在大多数项目里没问题但如果你做的是低频上报设备比如每小时上报一次温湿度30 秒的保活意味着每小时要额外发 120 个空心跳包不仅浪费流量还额外占用模块的射频资源。更合理的做法是把保活时间设置到 300 秒阿里云平台允许的最长保活时间前提是模块支持长周期心跳保活。实测 EC200U 在休眠模式下300 秒保活基本不会掉线如果使用 PSM省电模式则保活时间不能超过 60 秒否则平台会判定设备离线。这个值需要在功耗和在线稳定性之间取平衡我一般建议优先使用 120 秒覆盖大多数网络环境。如果模块因为信号抖动或运营商网络重启导致 MQTT 断开EC200U 会返回MQTTDISC: 0的 URC。例程中的状态机在收到这个 URC 后不会自动重连而是置位一个mqtt_disconnected标志。主循环检测到这个标志后先发送ATMQTTCLEAN清理旧的 MQTT 会话然后重新走一遍ATMQTTCONN建连流程最后重新订阅控制主题。这里必须先把旧会话清理干净否则模块可能返回ERROR提示 MQTT 客户端 ID 已被占用。清理会话的代码序列如下// 检测到 mqtt_disconnected 标志后的重连流程 AT_SendCmd(ATMQTTCLEAN, OK, 3000); // 清理残留会话 delay_ms(500); // 等待模块释放资源 AT_SendCmd(ATMQTTCONN..., OK, 10000); // 重新建立连接 AT_SendCmd(ATMQTTSUB..., OK, 3000); // 重新订阅下行主题 mqtt_disconnected 0; // 清除标志在实际项目中重连机制还需要考虑失败退避。如果模块处于无信号区域ATMQTTCONN会在 10 秒超时后返回ERROR此时如果立即重试模块的射频栈可能还没完全释放反而延长恢复时间。例程中在主循环里加了一个简单的退避计数连续重连失败超过 3 次后每次重连间隔递增到 30 秒直到从ATCEREG?查询到网络注册成功后才恢复 10 秒间隔的重试节奏。这个方法避免设备在信号盲区反复做无效的建连尝试也减少了功耗损耗。最后要说的是整个工程里最容易被忽略的一个问题ATMQTTPUB发布 QoS 为 0 的消息时模块返回的OK只代表指令被接受不代表数据已经到达云端。如果需要确认平台确实收到了数据可以在阿里云物联网控制台的「日志服务」中按设备查询上行消息分析通过对比消息内容与设备上报时间就能反推是哪一环出了问题——是模块发送失败、topic 写错还是 JSON 格式不对。结合串口 1 的 AT 指令日志几乎能定位到所有链路问题。本文还有配套的精品资源点击获取
返回列表