news 2026/10/3 1:14:50

MQTT设备接入阿里云IoT平台与OTA远程升级调试全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MQTT设备接入阿里云IoT平台与OTA远程升级调试全解析

一次MQTT设备接入阿里云物联网平台并跑通OTA远程升级的调试,我前后折腾了将近两天。白天看设备日志,晚上翻协议文档,最后发现好几处问题都不是代码逻辑错误,而是连接参数、Topic权限和OTA消息时序这些“边缘细节”没对齐。这篇文章就是那两天“MQTT + 阿里云 + OTA”调试过程的完整复盘,把中间踩过的坑、排查链路以及最后稳定运行的方案都写清楚。

如果你正在做嵌入式设备联网、用ESP32/STM32/4G模组接阿里云,或者已经接入了但OTA一直不成功,这篇文章应该能帮你省下不少时间。即使你只是刚接触物联网的小白,按下面的步骤走一遍,也能把设备接入和升级流程串起来。

1. 设备接入前的关键准备

调试阿里云MQTT,第一个拦路虎不是代码,而是设备身份认证。我见过很多人在这一步卡了一两天,基本都是对三元组和MQTT连接参数生成规则不熟。

1.1 三元组到底是什么

阿里云物联网平台的每一个物理设备,在云端都对应一个“产品-设备”的层级结构。产品是一类设备的抽象,设备是具体实例。每个设备注册后,平台会分配三个关键参数:

  • ProductKey:产品唯一标识,类似设备的“型号编码”。
  • DeviceName:设备在某个产品下的唯一名称,由用户自己定义。
  • DeviceSecret:设备密钥,相当于设备的“密码”。

这三个参数合称三元组。设备连云时,平台就是靠三元组来完成身份认证的。你可能会问:为什么不是直接用DeviceSecret做密码,还要搞签名?因为直接用密钥裸传容易被抓包泄露,签名方案可以保证密钥不出现在报文里,安全性高很多。

在控制台创建产品和设备时,有几点需要注意。产品选择“直连设备”模式,不要选“网关子设备”,否则后续Topic权限和OTA流程会多一层代理逻辑。设备名称建议提前规划好,不要用中文,不要带特殊符号,因为DeviceName会直接拼接进MQTT的clientId和Topic路径里,特殊字符容易引发解析问题。

1.2 MQTT连接参数的生成规则

很多设备端SDK的教程里会写“填入三元组就能连”,但实际连接时,MQTT协议的三个核心参数中,有两个是动态生成的。具体规则如下:

Broker地址格式:

${ProductKey}.iot-as-mqtt.${RegionId}.aliyuncs.com

其中RegionId是地域ID,比如上海是cn-shanghai。端口方面,1883端口用于非加密TCP连接,443端口用于TLS加密连接。

clientId格式:

${DeviceName}|securemode=3,signmethod=hmacsha1,timestamp=${timestamp}|

username格式:

${DeviceName}&${ProductKey}

password格式:

HmacSHA1(DeviceSecret, "clientId${clientId}deviceName${DeviceName}productKey${ProductKey}timestamp${timestamp}")

注意上面签名内容里的${clientId},是整个拼接好的clientId字符串,包括管道符和后面的参数段。很多人在这里踩坑,以为clientId只填DeviceName,结果签名算出来怎么都不对。

我举一个具体的例子。假设:

  • ProductKey = a1BxTest1x
  • DeviceName = dev001
  • DeviceSecret = 0123456789abcdef
  • timestamp = 789

那么clientId拼接为:

dev001|securemode=3,signmethod=hmacsha1,timestamp=789|

待签名字符串为:

clientIddev001|securemode=3,signmethod=hmacsha1,timestamp=789|deviceNamedev001productKeya1BxTest1xtimestamp789

用DeviceSecret做密钥,对上面这串文本做HMAC-SHA1,结果转成十六进制字符串,就是password。

我当时在MQTT X里手动填入这些参数时,发现一个容易忽略的点:timestamp必须和clientId里的值保持一致,而且要用毫秒级时间戳。如果你代码里先生成了时间戳A用于签名,clientId里拼接的却是时间戳B,云端校验一定会失败。

1.3 初始化阶段最容易配错的地方

除了签名,还有几个常见问题值得单独拎出来说。

securemode的含义。securemode=3是非TLS加密直连,走1883端口;securemode=2是TLS加密,走443端口。我看过不少人的代码里写securemode=2,却连1883端口,导致连接一直断。

设备本地时间不对。签名里的timestamp参与校验,如果设备本地时间比云端时间偏差太大,云端会认为是非法请求。这个问题在带RTC的板子上容易出现,建议在连云前做一次NTP校时,或者使用模组自带的网络时间。

Broker地址拼错。这个看起来低级,但真的很常见。尤其要注意RegionId,有的人产品在广州,Broker却写成了上海,结果连接超时。控制台“设备详情”页里会明确展示接入地址,直接复制最稳妥。

2. MQTT连接调试:从“连不上”到“稳定在线”

接入参数算对了,不代表就能稳定在线。调试MQTT连接过程中,我强烈建议先用通用MQTT客户端验证云端连通性,再改设备端代码,这能大幅减少变量。

2.1 用MQTT X做一次云端预演

MQTT X是一个跨平台的MQTT客户端工具,界面简洁,支持自定义连接参数,非常适合做设备接入验证。我在调试阿里云时,都是先在MQTT X里把设备模拟出来。

打开MQTT X,新建连接时配置:

  • Broker地址填:a1BxTest1x.iot-as-mqtt.cn-shanghai.aliyuncs.com
  • 端口填:1883
  • Client ID填:dev001|securemode=3,signmethod=hmacsha1,timestamp=789|
  • Username填:dev001&a1BxTest1x
  • Password填:计算出的HMAC-SHA1十六进制字符串

点击连接后,观察右上角状态。如果连接成功,说明三元组、签名、Broker地址都是对的,问题不在云端侧。

如果连接失败,MQTT协议会返回CONNACK报文,里面有返回码。常见返回码对照如下:

返回码含义常见原因
0连接成功无
2标识符错误clientId格式不对
4用户名或密码错误password签名算错、timestamp不一致
5未授权设备不存在、设备被禁用、ProductKey写错

2.2 订阅与发布权限的规划

MQTT连接成功只是第一步,真正干活靠的是Topic。阿里云平台上的Topic不是随便发的,你必须在控制台的“产品 -> Topic类列表”里提前定义好,并给设备配置收发权限。

权限分两种:发布(设备可以向该Topic发消息)和订阅(设备可以接收发往该Topic的消息)。在实际设备接入时,常见组合是:

  • 数据上报:设备发布消息到Topic A。
  • 指令下发:设备订阅Topic B,云端向Topic B发消息。

有个坑是权限配置了但忘了点“应用”,或者配了发布权限却去订阅它。控制台里定义Topic后,一定要检查“操作权限”列,发布和订阅权限要按实际方向设置。

2.3 借助云端日志定位问题

当设备和云端之间的消息链路出现问题,最有效的定位手段是控制台里的“日志服务”,也叫“云端运行日志”。这里能看到设备端上行的原始报文和云端下行的推送记录。

我在调试时,基本流程是:设备端发一条消息,然后立刻去控制台看日志,确认消息有没有到达云端、payload是不是完整、云端有没有返回reply。如果日志里什么都没有,说明消息根本没到云端,问题出在连接层或权限层;如果日志里有请求但没有响应,问题出在Topic或服务端逻辑。

日志里还能直接看到设备上报的Topic全名,一旦设备端拼写错了Topic,这里一眼就能发现。

3. 打通MQTT消息链路

连接稳定了,下一步就是把业务消息跑通。这一节讲的不是某个具体业务,而是理解阿里云MQTT的消息链路设计,以及如何避免在Topic和Payload上踩坑。

3.1 系统Topic与自定义Topic怎么选

阿里云物联网平台的Topic可以分为两大类。

一类是系统预定义的Topic,也叫物模型Topic。它们有固定的路径格式,比如:

/sys/${ProductKey}/${DeviceName}/thing/event/property/post

这是属性上报的Topic,设备把属性值用JSON格式发到这个Topic,平台会自动解析并更新物模型数据。对应的还有属性设置、服务调用等,适合标准化场景。

另一类是自定义Topic。你需要自己在控制台定义路径,比如:

/${ProductKey}/${DeviceName}/user/update

这类Topic不绑定物模型,消息体完全由你自定义,灵活性更高。

我的建议是:如果业务属于“设备上传数据、云端下发指令”这种通用模式,用自定义Topic验证链路更快,因为不用遵循物模型的JSON规范。等链路稳定了,再逐步切换到物模型Topic,享受平台的数据解析、告警规则等服务。

有人会问,能不能只靠自定义Topic做完整业务?可以,但会丢失掉物模型带来的规则引擎、数据可视化等能力,长期来看不划算。

3.2 通配符、权限与Payload细节

MQTT订阅Topic时,支持两个通配符:

  • +:匹配单层。
  • #:匹配多层。

比如设备订阅/${ProductKey}/+/user/get,就能收到该产品下所有设备的用户指令。这在批量调试时很方便,但生产环境要小心数据隔离问题,别让设备收到别人的消息。

发布Topic时不支持通配符,只能发到具体的Topic路径。另外要检查Topic权限是否符合实际使用方向,别把“订阅”权限当“发布”用。

Payload格式方面,自定义Topic的消息体建议统一用JSON。我在设备端解析时,遇到了一个典型问题:设备端代码只处理固定字段,一旦云端在payload里新增了字段,设备端解析失败。这个问题看似小,但在OTA这样的长链路流程里很致命。

一个稳妥的做法是:设备端解析JSON时忽略未知字段,只关心自己需要的字段,并且对缺失字段给出默认值。

3.3 一个生产可用的消息协议设计思路

这里分享一个我调试通过的消息设计模板,做设备上行和下行消息时可以直接套用。

设备上报消息:

{ "id": "123456", "method": "thing.event.property.post", "params": { "temperature": 25.6, "humidity": 60 } }

云端指令下发:

{ "id": "654321", "method": "thing.service.property.set", "params": { "switch": 1 } }

其中id字段是消息序号,用于请求和响应的关联。设备端每发一条消息,id递增,云端处理完成后返回的消息里会带上同样的id,这样设备端就能知道哪条请求对应哪条响应。method字段表示消息类型,解析时根据method分流处理。

这套设计看上去简单,但实际调试中非常实用。因为阿里云的部分系统Topic会在下行消息中要求设备回复指定格式的response,有了id关联,设备端回复时能正确匹配到原请求。

4. OTA功能的完整调用链

OTA是整个物联网设备生命周期管理里最重要的能力之一。很多人以为OTA就是把固件文件传给设备,实际在阿里云平台上,OTA是靠一组MQTT上下行Topic的指令交互来完成的。理解这组Topic的配合关系,是调试OTA的核心。

4.1 OTA Topic分组与职责

阿里云OTA相关的Topic主要有以下几组:

Topic路径方向用途
/ota/device/inform/${ProductKey}/${DeviceName}上行设备上报当前固件版本
/ota/device/upgrade/${ProductKey}/${DeviceName}下行云端推送升级指令给设备
/ota/device/progress/${ProductKey}/${DeviceName}上行设备上报下载/安装进度
/ota/device/request/${ProductKey}/${DeviceName}上行设备主动请求升级任务

这个设计思路是这样的:MQTT保持长连接,云端可以随时主动向设备推送升级指令;设备通过上行Topic反馈自己的版本、进度和状态。固件文件本身不走MQTT,而是通过下行指令中的下载地址走HTTP获取,这样设计是为了避免大文件传输占用MQTT长连接的带宽和稳定性。

4.2 一次成功升级的报文时序

下面是一次典型OTA升级过程的报文交互。

第一步,设备上电后主动上报当前版本号到inform Topic:

{ "id": "1", "params": { "version": "1.0.0" } }

第二步,用户在控制台发起了升级批次,云端匹配到设备后,向upgrade Topic推送升级指令:

{ "id": "123", "code": 200, "message": "success", "data": { "module": "MCU", "size": 362510, "version": "1.0.1", "url": "https://iot-ota-bucket.oss-cn-shanghai.aliyuncs.com/firmware.bin?Expires=...&Signature=...", "sign": "d41d8cd98f00b204e9800998ecf8427e", "signMethod": "Md5" } }

第三步,设备端收到升级指令后,从url字段下载固件文件,下载过程中通过progress Topic上报进度:

{ "id": "2", "params": { "step": "50", "desc": "" } }

下载完成并校验通过后,上报step为100,然后设备重启,bootloader启动新的App分区。设备再次上线后,上报新版本号1.0.1,云端确认升级完成。

这里面有一个关键点:url是带签名和时效性的临时下载地址,有效期通常比较短。设备收到升级指令后应立即开始下载,不要等太久。如果下载太慢导致url过期,需要走重新请求的逻辑。

4.3 固件下载、校验与升级落地的细节

MD5校验。云端下发的sign字段是固件文件的MD5值。设备下载完固件,要对整个二进制文件计算MD5,与云端下发的sign比对。要注意计算的是原始bin文件的MD5,不是base64字符串的MD5,也不是压缩包的MD5。我见过一个同事把固件压缩成了zip再算MD5,结果校验永远失败。

下载模块的选择。设备端如果MCU资源充足,可以直接用HTTP客户端流式下载到外置Flash。如果资源紧张,建议分块下载、边写边校验。千万不要一次性把整个固件读入内存,小内存设备直接溢出。

Bootloader与App的分区设计。OTA升级的一个核心设计原则是:保证任何一次升级失败后,设备都能恢复运行。常见做法是Flash分区设置为Bootloader + App + Download区。Bootloader负责检查App区是否有效,Download区存放新固件并做校验。升级流程是:先将固件下载到Download区,校验通过后再标记App区更新,最后重启。这样即使Download区数据损坏,Bootloader还能启动旧的App。

没有这个分区设计的OTA,本质上是在赌升级永远一次成功。实际调试中,因网络中断、flash擦写失败导致的升级失败非常常见,分区设计不是可选项,是必需品。

4.4 主动请求升级任务

正常情况下,云端推送升级指令后设备就能收到。但如果设备在云端发起升级批次时处于离线状态,或者网络波动导致消息丢失,设备就收不到升级通知。

为了解决这个问题,OTA协议里设计了设备主动请求机制。设备上电后,可以先发布一条消息到request Topic,请求云端下发待执行的升级任务:

{ "id": "3", "params": { "version": "1.0.0" } }

云端收到请求后,如果该设备有待执行的升级批次,会通过upgrade Topic再次下发升级指令。这个机制相当于给OTA加了一层“补偿”,防止指令丢失导致升级卡死。

5. 调试期的真实瓶颈与排查手法

这一节是这篇调试记录的精华部分。我把自己实际遇到的问题按场景整理出来,每个场景都给出完整的排查链路,而不是直接甩结论。这样你遇到相似问题时,能自己顺着思路定位。

5.1 场景一:设备状态一直“离线”

现象很典型:MQTT X里连接是成功的,能发布消息,但控制台的设备状态显示“离线”。

我当时的第一反应是怀疑设备没连上,于是反复检查连接参数。后来在日志服务里看到设备其实一直在上报消息,才意识到问题不在连接,而在于“状态判定”的机制。

阿里云判定设备在线,看的是MQTT层面的心跳保活。设备必须按照连接时协商的keepalive间隔发送心跳报文,平台才会持续认为设备在线。如果设备端代码只是“连上了”但后续没有正确处理心跳应答,平台会在超时后把设备标记为离线。

排查链路如下:

  1. 在MQTT X里连接后,观察连接状态是否持续保持。MQTT X会自动发送心跳,如果连接被踢,工具会显示断开。
  2. 检查设备端代码的MQTT心跳配置。阿里云平台要求keepalive间隔在30秒到1200秒之间,很多设备端默认是60秒,问题不大。关键是设备有没有在收到PINGRESP后维持会话。
  3. 检查设备是否启用了“离线缓存”机制。如果设备每次都使用相同的clientId重新连接,且cleanSession=false,平台会保持会话;如果cleanSession=true,每次重连都是全新会话。

我最后定位到的问题其实很蠢:设备端代码里把心跳线程写成了只发一次PINGREQ,没有循环发送。平台的保活超时一到,连接就被断开了。改掉之后,设备状态稳定保持在线。

5.2 场景二:OTA任务下发后设备毫无反应

这个现象最让人崩溃:控制台里升级批次显示“已下发”,设备串口日志里却没有任何新消息。

我用了一个二分法,很快圈定了问题范围。

第一步,用MQTT X模拟同一台设备接入,手动订阅/ota/device/upgrade这个Topic。然后回到控制台重新发起一次升级批次。如果MQTT X能收到升级指令,说明云端下发链路没问题,问题在设备端;如果MQTT X也收不到,说明云端批次配置有问题。

我当时是MQTT X收到了,设备端没反应。于是把注意力放到设备端代码上,检查三个地方:

  1. 设备是否成功订阅了/ota/device/upgrade Topic。我在日志里打印订阅返回码,发现订阅操作返回了失败码。原因是控制台里把设备对OTA Topic的订阅权限改了,设备端使用的SDK版本又恰好不支持低权限模式下的静默失败,订阅根本没生效。
  2. 设备端收到消息后是否进入了OTA处理分支。有些设备端的MQTT回调函数里,只处理了物模型Topic,遇到OTA Topic就直接丢弃了。
  3. 设备的版本上报是否正确。如果设备上报的版本号和云端认为的版本号不一致,批次匹配会失败。

这个排查过程的关键是:先把云端和设备端切成两个独立环节分别验证,不要混在一起猜。

5.3 场景三:升级到一半失败,甚至“变砖”

OTA升级到一半失败,是最能体现“设计决定成败”的场景。我调试期间遇到过几次下载到80%突然失败的情况,原因各不相同。

第一次是下载URL过期。我模拟了一个慢速网络,固件下载了十分钟还没完成,URL里的签名参数已经失效,后面的HTTP请求直接返回403。解决方法是缩短单次下载的耗时,或者实现失败后重新申请升级任务获取新URL。

第二次是Flash擦写异常。设备下载完固件,校验也通过了,但在擦除App区时因为剩余空间不足而失败。这个属于设备端Flash分区规划问题,在Bootloader设计阶段就要给App区预留足够的空间,不能猜一个大小就上线。

第三次是升级后设备起不来。这个最危险,设备重启后黑屏、串口无输出。原因是我的Bootloader设计有缺陷:它只检查App区有没有“有效标志位”,没有做完整的固件校验。如果App区数据损坏但标志位还留着,Bootloader会跳转到损坏的代码上。后来改成在Bootloader里重新计算App区固件的CRC,校验通过才跳转,问题才解决。

经过这一轮,我对OTA的理解彻底改变了。OTA不是一个简单的“文件下载+写入”功能,而是一套需要同时考虑网络、存储、异常恢复的系统设计。设备端在开发固件升级功能时,一定要把“升级失败后设备还能用”作为最高优先级。

5.4 场景四:连接老是被踢下线

还有一种情况:设备连接正常、消息收发正常,但每隔一段时间就会被断开,然后又自动重连。

这类问题的根源大多在MQTT心跳机制上。心跳间隔是设备在连接时通过CONNACK协商的。设备端如果设置的keepalive间隔过短,会导致频繁发心跳,网络负担加大但问题不大;如果设置得过长,超过平台允许的范围,平台会直接拒绝连接或者强制断开。

阿里云平台允许的keepalive范围一般建议30到1200秒。但如果设备所处的网络环境不稳定,比如4G模块频繁切换基站,TCP长连接很容易断。我在4G模组上调试时,发现运营商NAT超时时间比MQTT心跳间隔还短,导致连接被NAT切断,设备端却还不知道。解决方法是设备端加一个“掉线检测+自动重连”机制,不要依赖云端主动断开。

另外,如果设备侧的clientId每次重连时都带上不同的timestamp,会导致云端认为这是两个不同会话,旧的连接会被强制关闭。确保重连时clientId是稳定的,最好把timestamp固定到设备启动时生成一次,重连沿用。

6. 调试工具箱与个人经验

调试MQTT和OTA,工具选对了能省一半时间。以下是我实际用下来觉得价值最高的几个工具和习惯。

MQTT X,用于设备模拟和Topic验证,前面已经详细说过。

阿里云控制台的“设备模拟器”,可以模拟云端向设备下发指令,反向验证设备端处理逻辑。

串口调试助手,这个是基本配置,设备端的调试日志、MQTT消息打印、OTA进度信息全靠它。

Wireshark,当你怀疑网络层问题时用得上。MQTT基于TCP,过滤条件可以写成tcp.port==1883,能看到完整的MQTT报文交互过程,包括CONNACK返回码、心跳包、发布消息等。

除了工具,还有几个经验结论,是我这次调试后深刻体会到的。

第一,设备端日志一定要打完整。我做的第一件事就是在MQTT收发回调里,把Topic和payload都打印出来,而且打印的是原始hex格式。遇到中文和特殊字符时,hex比字符串更可靠。

第二,测试OTA时,永远先在测试环境用一台设备做完整验证,再推生产批次。开发阶段可以在控制台创建一个测试产品,上传测试固件,用真机跑一遍“下发-下载-校验-重启-上报新版本”的完整流程,确认没问题后,再切到正式产品。

第三,OTA的消息链路上,MQTT承担的是“通知”职责,真正的固件文件传输用的是HTTP。调试时把这两条链路拆开看:MQTT链路负责确认“要不要升级、升级什么版本”,HTTP链路负责解决“固件怎么可靠地下载下来”。分开排查,问题定位会快很多。

实际调试了这么一圈,我最大的体会是:物联网设备的OTA调试,本质上是在验证你对“设备身份认证、消息链路、升级异常恢复”这三件事的理解是否到位。每一件事单独看都不复杂,但它们组合在一起时,任何一个细节出错都会让整个系统看起来“神秘失控”。希望这份调试记录,能让你在面对类似问题时少走一些弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 1:14:37

ESP32-S3 Mini与C3 Mini怎么选?PSRAM和USB OTG避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:14:18

DRV8818PWPR+STM32L152RE双极步进电机工业控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:14:13

达梦DM8编程指南:DPI、JDBC与避坑实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:14:13

DRV8818+PIC18F4455双极步进电机驱动方案:从硬件到固件

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:14:08

基于Python与协同过滤的图书推荐系统设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:14:07

3.6KW储能双向逆变器完整拆解:从原理图到控制源码

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华