做物联网设备接入,免不了要和MQTT打交道。最近在做一个4G Cat 1设备接入的项目,模组用的是SIMCOM A7670C_FASL,一开始只打算跑一路MQTT,把温度、电压、信号强度这些数据传到阿里云。结果产品经理又提了新需求:要支持远程参数配置和固件升级,而且不能影响正常的数据上报。于是我把方案改成了双路MQTT——一个client专门走业务数据,另一个client走设备管理和指令下发,两条链路都挂在阿里云物联网平台上。A7670C_FASL这个模组内置MQTT AT命令,不用自己移植协议栈,串口发命令就能跑,我把AT交互和状态机封装成了一套C代码,整个调试过程大概花了三天。这篇文章就把这套方案的选型思路、AT命令流程和源码结构完整记录下来,给正在用Cat 1模组接阿里云的朋友一个可以直接抄的作业。
1. 这块模组到底能干什么:A7670C_FASL与双路MQTT的选型逻辑
1.1 Cat 1模组凭什么适合这类接入项目
LTE Cat 1这个品类这几年在物联网终端里非常火,原因很直接:成本比Cat 4低,速率又比NB-IoT高不少,理论上行5Mbps、下行10Mbps,跑MQTT这类小包协议绰绰有余。A7670C属于SIMCOM的Cat 1系列,全网通,LCC封装,国内三大运营商的卡都能用。相比WiFi方案,它不受网络环境限制,插上SIM卡就能出网;相比NB-IoT,它对移动性的支持更好,数据交互的实时性也更强。像共享设备、充电桩、农业监测这类需要在户外移动或者分散部署的场景,Cat 1基本是性价比最优解。
选型的时候我也对比过移远的EC20,EC20是Cat 4模组,性能没问题,但价格和功耗都会高一些。而且EC20的MQTT命令体系是QMTCFG那一套,和SIMCOM的CMQTT命令完全不同,代码不能复用。如果你手头是EC20,这篇文章里讲AT命令的部分就只能当思路参考,不能直接照抄。
1.2 A7670C_FASL的固件标识说明
模组丝印上写的是A7670C_FASL,这是A7670C系列里的一个变体标识,实际使用时重点看固件版本,不要只看丝印。我拿到手第一件事就是插电后发ATI查版本信息,确认固件带MQTT扩展命令集。A7670C_FASL这个固件版本的特点是:支持CMQTT开头的完整MQTT AT命令,可以创建多个MQTT client,每个client独立连接服务器、独立订阅topic。也就是说双路MQTT在硬件和固件层面是原生支持的,不需要自己维护两套协议栈。
有一点需要特别注意:SIMCOM A7670C系列不同批次的固件可能会有差异,同一个AT命令在不同固件上参数格式可能不同。调试前一定要去官网下载对应固件版本的《MQTT Application Note》,把命令语法核对一遍再上手,别直接拿网上的旧命令硬试。
1.3 双路MQTT解决的实际问题
为什么一定要双路?一开始我也想偷懒,一路MQTT既要高频上报业务数据,又要处理下行指令。实际跑起来发现两个麻烦:上行数据量一旦增大,连续PUBLISH的时候,下行的实时性很难保证,因为AT串口是串行的,发送长payload会占用通道;另一个问题是逻辑耦合,业务上报和指令处理混在一个回调里,改起来很容易互相牵连。
拆成两路之后,每路职责非常清楚:
| 通道 | client_index | 用途 | 典型QoS |
|---|---|---|---|
| 通道0 | 0 | 物模型属性上报、事件上报 | QoS 0 |
| 通道1 | 1 | 远程配置下发、固件升级指令 | QoS 1 |
这种拆法还有一个隐藏好处:如果一路因为云端topic配置错误导致异常,另一路还能保持在线,设备不至于完全失联。对于远程维护场景,留一条保底通道是非常值得的。
1.4 与其他模组方案的取舍
除了EC20,市面上还有合宙Air724UG、有人物联的Cat 1模组等方案。每个方案都有自己的AT命令体系,没有统一标准。选SIMCOM的原因主要是:A7670C资料相对齐全,MQTT命令语义简单直接,踩坑之后能快速定位问题。而且SIMCOM的CMQTT命令把MQTT的connect、publish、subscribe直接映射成AT指令,学习成本低,适合要在短时间内出原型的情况。
2. 硬件接线和串口调通:通电之后先把这些基础做扎实
2.1 供电和电平转换
很多朋友拿到模组,第一步就栽在供电上。A7670C的VBAT电压范围是3.4V到4.2V,典型值3.8V,峰值电流能到2A以上。千万别用USB转串口模块上的3.3V去给模组的VBAT供电,带不动,一发射就掉电压,直接表现为模组反复重启或者网络注册失败。
我调试时用的是5V/3A的开关电源,经过DC-DC降压到3.8V供给模组。如果你买的是带底板的评估板,板上通常已经做了电源转换和电平转换,直接用USB线供电就能跑。如果是自己画板,注意VBAT端的滤波电容要靠近模组引脚放置,建议用钽电容加陶瓷电容组合。
还有一个容易忽略的点是IO电平。A7670C的UART接口电平是1.8V,很多USB转TTL模块是3.3V电平,直接怼上去可能存在兼容风险。评估板一般都有电平转换芯片,问题不大;自己接线时最好确认一下两边电平,必要时加电平转换电路。
2.2 开机、串口和AT基本操作
模组开机后,串口默认波特率通常是115200,8N1,无流控。把USB转TTL模块的TX接到模组的RX,RX接到模组的TX,GND共地。上电后在串口工具里发AT回车,能返回OK就说明通路没问题。
我习惯先把这些基础AT命令跑一遍,确认模组状态:
AT OK ATI A7670C_FASL_V02 AT+CSQ +CSQ: 24,99AT+CSQ后面的第一个数字是信号强度,24在4G网络里属于中等偏上的水平,如果是0或者99就要检查天线和SIM卡了。注意模组必须接LTE主天线,很多初玩者觉得“用网线内芯怼一下就行”,结果信号一直很差,实际上天线的接触和位置对效果影响非常明显。
2.3 网络注册与PDP上下文激活
MQTT连接之前,模组必须已经注册上网络,并且PDP上下文已经激活。网络注册状态用AT+CGREG?查询,状态0,1表示注册上网络;PDP激活状态用AT+CNACT?查询。
不同运营商的APN设置也要处理,实测常用的配置:
| 运营商 | APN |
|---|---|
| 中国移动 | cmnet |
| 中国联通 | 3gnet |
| 中国电信 | ctnet |
如果模组没有自动激活PDP,需要手动执行:
AT+CGDCONT=1,"IP","cmnet" OK AT+CNACT=1,"cmnet" OK有些固件在AT+CNACT返回ERROR,其实是PDP已经激活了,用AT+CNACT?查询就能看到状态。网络这块是MQTT连接的地基,PDP不激活,后面所有CMQTT命令都会卡在连接阶段,而且报错信息往往只有CONNECT FAIL,排查起来很烦。
3. 阿里云端的设备、三元组和Topic:一处写错就连不上平台
3.1 创建产品和设备
登录阿里云物联网平台控制台,在公共实例里创建产品。这一步的关键参数是“节点类型”,我选的“设备”,连网方式选“蜂窝网络”。创建完产品之后,在产品下添加设备,平台会生成三元组:ProductKey、DeviceName、DeviceSecret。控制台添加设备时如果你自定义DeviceName,注意别用特殊字符,建议全小写字母加数字,后期拼Topic的时候不容易出错。
我的规划是双路用两个身份,所以在同一个产品下添加了两个设备:dev001用于业务数据上报,dev002用于管理指令。这样两个client在阿里云上就是完全独立的两个设备,各自认证,各自订阅topic,互不干扰。
3.2 三元组换算成MQTT连接参数
A7670C模组内置的MQTT协议栈需要你提供标准MQTT连接参数,而阿里云给的是三元组,所以中间需要做一步换算。这一步非常关键,连接失败八成是这里出的问题。
我采用的换算方式是阿里云标准的一机一密签名流程:
- clientId =
{deviceName}|securemode=3,signmethod=hmacsha256| - username =
{deviceName}&{productKey} - password =
HMAC-SHA256({deviceSecret}, content),其中content按字符串拼接:{clientId}{deviceName}{productKey}
如果你不想在代码里实现HMAC-SHA256,调试阶段可以先在PC上用Python把三个连接参数算出来,直接写死在配置文件里。
import hmac import hashlib product_key = "a1xxxxx" device_name = "dev001" device_secret = "xxxxxxxx" client_id = f"{device_name}|securemode=3,signmethod=hmacsha256|" username = f"{device_name}&{product_key}" content = client_id + device_name + product_key password = hmac.new(device_secret.encode(), content.encode(), hashlib.sha256).hexdigest() print("clientId:", client_id) print("username:", username) print("password:", password)这里有几个细节需要注意。第一,HMAC输出是小写十六进制字符串,不是Base64,很多人算出来连接鉴权失败,就是输出的编码不对。第二,签名内容拼接顺序是clientId在前,接着是deviceName,最后是productKey,顺序不能变。第三,我的clientId里没有带timestamp字段,因为A7670C模组没有可靠的RTC时钟,带timestamp反而要额外处理校时;不带时间戳在公共实例上验证是可以通过的。如果你用的是新版企业版实例,控制台文档里对clientId格式可能另有说明,以文档为准。
3.3 Topic规划与权限配置
阿里云的Topic分系统Topic和自定义Topic。系统Topic像/sys/{productKey}/{deviceName}/thing/event/property/post,用于上报属性,这个默认就能发布,不需要额外配置权限。
我这里两路用的是同一个系统Topic但设备身份不同:
| 通道 | 设备身份 | 发布Topic | 订阅Topic |
|---|---|---|---|
| 通道0 | dev001 | /sys/a1xxxxx/dev001/thing/event/property/post | /sys/a1xxxxx/dev001/thing/service/property/set |
| 通道1 | dev002 | /sys/a1xxxxx/dev002/thing/event/property/post | /sys/a1xxxxx/dev002/thing/service/property/set |
如果你要用自定义Topic,比如/a1xxxxx/dev001/user/mgmt,需要在产品里先定义Topic类,并勾选设备的发布、订阅权限。很多新手到了订阅那一步一直失败,去控制台一看,Topic类里根本没有定义这个topic,属于权限没开。
3.4 双路连接在云端的身份规划
双路的身份规划有两种方式。一种是同一个产品下建两个设备,就是我上面的方案,好处是产品维度统一,物模型一致,代码里只需要改DeviceName和DeviceSecret。另一种是两个产品各一个设备,适合两个业务方完全隔离的场景,比如A团队只管数据上报,B团队只管设备管理。
我个人建议优先用同一个产品两个设备,因为管理和维护都简单。但有一个坑必须提醒:同一时刻、同一个设备证书只允许一个MQTT连接在线,重复连接会出现互踢。所以双路必须用两个不同DeviceName,千万不要试图用同一套三元组开两路连接。
4. 双路MQTT的AT命令完整对话:从CMQTTSTART到CMQTTPUB
4.1 需要用到的SIMCOM MQTT AT命令
A7670C_FASL这版固件上,MQTT功能由一组CMQTT开头的AT命令完成,核心命令如下:
| 命令 | 作用 |
|---|---|
| AT+CMQTTSTART | 启动MQTT服务 |
| AT+CMQTTACCQ | 创建MQTT client |
| AT+CMQTTUSER | 设置MQTT用户名 |
| AT+CMQTTPASS | 设置MQTT密码 |
| AT+CMQTTTOPIC | 设置发布Topic |
| AT+CMQTTCONNECT | 连接MQTT服务器 |
| AT+CMQTTSUB | 订阅Topic |
| AT+CMQTTPUB | 发布消息 |
| AT+CMQTTDISC | 断开MQTT连接 |
| AT+CMQTTREL | 释放MQTT client |
| AT+CMQTTSTOP | 停止MQTT服务 |
每个命令都有细分的参数格式,比如AT+CMQTTTOPIC需要先传topic长度,然后等待符号“>”出现之后再发送topic内容。这个交互方式容易踩坑,后面细说。
4.2 第一路client:业务数据发布流程
这是整个AT对话最核心的部分。我按实际调试时的顺序列出来,每一步都对应前面章节计算的参数:
AT+CMQTTSTART OK AT+CMQTTACCQ=0,"client_upload" OK AT+CMQTTUSER=0,"dev001&a1xxxxx" OK AT+CMQTTPASS=0,"5f4dcc3b5a..." OK AT+CMQTTTOPIC=0,44 > /sys/a1xxxxx/dev001/thing/event/property/post OK AT+CMQTTCONNECT=0,"a1xxxxx.iot-as-mqtt.cn-shanghai.aliyuncs.com",1883,120,1 OK这一步默认情况下clean session我填1。连接成功后,阿里云控制台的设备状态会变成在线。接下来就可以发消息了:
AT+CMQTTTOPIC=0,44 > /sys/a1xxxxx/dev001/thing/event/property/post OK AT+CMQTTPUB=0,31,0,0 > {"temperature":25.6,"voltage":3.85} OK注意payload长度是按字节算的,不是按字符数。JSON里如果带中文,一个中文字符在UTF-8下是三个字节,长度很容易算错,建议上报内容统一用英文键名和数字值,避免编码问题。
4.3 第二路client:管理指令订阅流程
第二路的流程和第一路基本一样,区别是client_index要改成1,确认使用另一套三元组:
AT+CMQTTACCQ=1,"client_mgmt" OK AT+CMQTTUSER=1,"dev002&a1xxxxx" OK AT+CMQTTPASS=1,"签名后的password" OK AT+CMQTTTOPIC=1,44 > /sys/a1xxxxx/dev002/thing/event/property/post OK AT+CMQTTCONNECT=1,"a1xxxxx.iot-as-mqtt.cn-shanghai.aliyuncs.com",1883,120,1 OK AT+CMQTTSUB=1,48,0 > /sys/a1xxxxx/dev002/thing/service/property/set OK这里有一个细节:如果先发CMQTTSUB但没有收到OK,很可能Topic没有订阅权限,或者Topic长度不对。订阅成功之后,阿里云平台往下发指令时,模组串口上才会出现对应的URC主动上报。
4.4 下行消息的URC解析
阿里云下行指令到达模组后,不会直接打印payload,而是先出现一段URC,再通过专门的读消息命令把内容取出来。不同固件的URC格式不完全一样,我的做法是在串口接收线程里按行匹配包含“CMQTT”关键字的行,然后交给解析函数处理。
这里提醒一句:URC是主动上报的,可能会有半包、粘包的情况。真正做产品时,串口接收需要用环形缓冲区加状态机,不能简单用一次读取完事。我的源码里就是用环形FIFO先把整行收齐,再按行解析URC。
4.5 断开重连的正确顺序
MQTT连接断开后,重连不是简单再发一次CMQTTCONNECT就行。如果旧client资源没释放,再次CONNECT会失败。标准的重连顺序是:
AT+CMQTTDISC=0 OK AT+CMQTTREL=0 OK AT+CMQTTACCQ=0,"client_upload" OK AT+CMQTTCONNECT=0,"...",1883,120,1 OK我实际测试过,跳过CMQTTDISC直接重新ACCQ,有时能成功,但连续掉线几次之后就会出现资源泄漏,最终连不上了。所以重连逻辑务必按DISC到REL到ACCQ再到CONNECT的流程走。
5. 源码设计思路与核心代码解读:完整工程怎么组织
5.1 工程目录与模块划分
整个工程按功能拆成了几个独立模块,这样双路通道可以共用同一套逻辑:
simcom_a7670c_mqtt/ ├── main.c // 状态机主循环 ├── hal_uart.c // 串口驱动与环形FIFO ├── simcom_mqtt.c // CMQTT命令封装 ├── simcom_mqtt.h ├── aliyun_auth.c // 阿里云三元组转MQTT参数 ├── aliyun_auth.h └── mqtt_config.h // 双路通道配置分模块的主要原因是reconnect逻辑、发布逻辑都是双路共用的,如果写在一起,后面维护会非常痛苦。我把“通道配置”和“通道行为”分离,新增一路只需要在配置结构体里加一组参数,不用改任何业务逻辑。
5.2 连接参数与数据结构设计
mqtt_config.h里定义了双路通道的三元组和Topic信息:
#ifndef MQTT_CONFIG_H #define MQTT_CONFIG_H #define MQTT_SERVER_HOST "a1xxxxx.iot-as-mqtt.cn-shanghai.aliyuncs.com" #define MQTT_SERVER_PORT 1883 #define MQTT_KEEPALIVE 120 typedef struct { uint8_t client_idx; const char *device_name; const char *product_key; const char *device_secret; const char *pub_topic; const char *sub_topic; } mqtt_channel_cfg_t; #if 1 /* 双路配置:通道0业务上报,通道1管理指令 */ static const mqtt_channel_cfg_t g_mqtt_channels[] = { { .client_idx = 0, .device_name = "dev001", .product_key = "a1xxxxx", .device_secret = "xxxxxxxx", .pub_topic = "/sys/a1xxxxx/dev001/thing/event/property/post", .sub_topic = "/sys/a1xxxxx/dev001/thing/service/property/set", }, { .client_idx = 1, .device_name = "dev002", .product_key = "a1xxxxx", .device_secret = "yyyyyyyy", .pub_topic = "/sys/a1xxxxx/dev002/thing/event/property/post", .sub_topic = "/sys/a1xxxxx/dev002/thing/service/property/set", }, }; #define MQTT_CH_MAX (sizeof(g_mqtt_channels) / sizeof(g_mqtt_channels[0])) #endif #endif这个设计的核心是:所有和云端身份相关的参数都集中在配置表里,业务代码只关心“第几路通道要发什么数据”。
5.3 HMAC-SHA256签名模块
如果不想在MCU里引入mbedTLS,调试阶段最简单的方式是用Python计算好三个连接参数,直接写成宏。但产品化肯定要在设备端算,因为DeviceSecret不能明文丢在代码里让人一抓包就看出来。
最稳妥的做法是在MCU里集成一个轻量级HMAC-SHA256实现,只保留SHA256和HMAC功能,不引入完整TLS库,占用资源非常少:
#include <stdio.h> #include <string.h> #include "sha256.h" void aliyun_build_conn_params(const char *pk, const char *dn, const char *ds, char *cid, int cid_len, char *user, int user_len, char *pwd, int pwd_len) { char content[128]; snprintf(cid, cid_len, "%s|securemode=3,signmethod=hmacsha256|", dn); snprintf(user, user_len, "%s&%s", dn, pk); snprintf(content, sizeof(content), "%s%s%s", cid, dn, pk); hmac_sha256_hex(ds, content, pwd, pwd_len); }使用时有两点经验:第一,content缓冲区长度要留足,deviceName加上几十字节的clientId扩展字段很容易超过64字节;第二,HMAC输出是小写hex,不要转成大写,阿里云校验区分大小写。
5.4 双路状态机调度
双路MQTT不能同时发AT命令,因为底层是同一个串口。我的方案是让每路通道各自维护一个状态,但真正发送AT命令的入口统一由调度主循环控制。
核心状态机如下:
typedef enum { CH_IDLE = 0, CH_NET_CHECK, CH_PDP_ACTIVE, CH_MQTT_START, CH_MQTT_ACCQ, CH_MQTT_CONNECT, CH_MQTT_READY, CH_MQTT_DISCONNECT, } channel_state_t; void mqtt_loop(void) { for (int i = 0; i < MQTT_CH_MAX; i++) { switch (g_state[i]) { case CH_IDLE: /* 检查网络和PDP状态,就绪后进入CH_MQTT_START */ if (net_ready()) g_state[i] = CH_MQTT_START; break; case CH_MQTT_START: send_at("AT+CMQTTSTART"); g_state[i] = CH_MQTT_ACCQ; break; case CH_MQTT_ACCQ: send_at_with_idx("AT+CMQTTACCQ=%d,\"%s\"", i, client_name); g_state[i] = CH_MQTT_CONNECT; break; case CH_MQTT_CONNECT: if (mqtt_connected[i]) g_state[i] = CH_MQTT_READY; else if (retry_cnt[i] > 3) mqtt_reconnect(i); break; case CH_MQTT_READY: if (need_report[i]) mqtt_publish(i); break; default: break; } } }主循环每10毫秒跑一次,通过系统tick做超时判断。AT命令发送统一走一个带超时的同步发送函数,接收URC则由串口中断完成,两者之间通过队列解耦。
6. 实测数据、掉线重连和AT命令文档没写的坑
6.1 实测连接耗时与稳定性
我这边用了一张移动物联网卡,在室内窗边信号强度CSQ在20到26之间。冷启动到网络注册大概需要5到8秒,PDP激活后,单路MQTT连接耗时在0.5到1秒左右,双路顺序连接总耗时不超过2秒。连续运行7天,一共掉线3次,都是模组侧的TCP连接断开,系统检测到MQTT_DISC的URC后触发重连,平均4秒恢复在线。
连接耗时的瓶颈基本不在模组,而在阿里云域名解析和TCP握手。如果你直接填IP而不是域名,连接会快一些,但阿里云接入IP可能变化,不建议写死。调试时用域名,产品化时如果对入网速度敏感,可以考虑加DNS缓存。
6.2 我踩过的几个AT命令的坑
第一个坑是AT+CMQTTTOPIC的长度计算。最初我直接用strlen(topic)算长度,topic全英文数字的时候没问题,后来换了一个带中文的topic,strlen算出来是字节数,而命令要求的是字符数,两者不一致导致subscribe一直失败。我的经验是:阿里云平台生成的自定义Topic尽量全用英文和数字,避免非ASCII字符。
第二个坑是模组掉线后直接重连报错。很多情况下模组侧并不知道TCP已经断了,还会认为旧client还在。这时候要先发AT+CMQTTDISC把旧连接断开,再发AT+CMQTTREL释放client资源,最后再重新ACCQ和CONNECT。跳过资源释放,连续掉线多几次就会出现client index被占用的情况。
第三个坑是password的大小写。HMAC-SHA256输出的十六进制默认是小写字母,我用Python算的时候没问题,但有一次在MCU端实现的HMAC函数返回了全大写字符串,结果阿里云一直报“鉴权失败”。这个问题控制台日志不会直接告诉你是大小写问题,排查了好久。
6.3 阿里云日志服务排查连接失败
阿里云物联网平台控制台内置日志服务,设备连接失败时,在“监控运维-日志服务”里按DeviceName和时间段查询,能看到每次连接尝试的详细结果。日志里如果出现“device auth check failed”,基本就是三元组换算的password、username或clientId不对;如果出现“connection refused”,要先检查模组侧的网络状态和接入域名端口。
记住一个排查顺序:先看模组串口的AT返回,确认CMQTTSTART和CMQTTACCQ都OK;再看CMQTTCONNECT的返回值;如果CONNECT返回错误或没有回OK,最后去阿里云控制台日志服务看云端记录的失败原因。这个顺序能帮你快速把问题定位在模组侧还是云端配置侧。
6.4 keepalive、QoS与低功耗调优
双路MQTT的keepalive要分别设置,我统一用的120秒。阿里云服务端的超时机制一般是1.5倍心跳时间,120秒的keepalive意味着近3分钟才会判定离线,对绝大多数场景足够了。如果你对“离线”状态的实时性有要求,可以设到60秒,但会增加少量流量。
QoS方面,阿里云只支持0和1,不支持2。我通道0用QoS 0,因为温度电压这类遥测数据即使丢一帧也无所谓;通道1用QoS 1,确保远程配置指令不丢失。实测QoS 1的消息在模组信号差的时候会有重传,下行送达率明显提升。
功耗方面,双路长连接肯定比单路费电,因为每路都要按keepalive周期发PINGREQ心跳。做电池供电设备时,如果不需要管理通道一直在线,可以让通道1在空闲时主动DISC,需要下发指令时再建立连接。我的源码里保留了mqtt_channel_disconnect接口,方便在低功耗模式下把管理通道动态启停。
如果你后续想让这套代码支持更多通道,扩展方式很简单,在g_mqtt_channels数组里再增加一个配置项,同时把client_index改成2就行。但要注意模组MQTT client的数量是有上限的,A7670C_FASL具体能开几路,要以你手上固件的应用笔记为准,不要盲目叠加。我个人在实际项目里最多开到三路,再多就会碰到内存和资源管理的问题。