news 2026/10/1 14:45:15

Wireshark抓包实战:温湿度变送器SNMP与Modbus TCP故障诊断

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Wireshark抓包实战:温湿度变送器SNMP与Modbus TCP故障诊断

前几天在客户现场处理一个很典型的故障:动环监控大屏上一片飘红,三号机柜的温湿度数据停在“通信异常”已经超过两个小时。现场三方各执一词,设备厂家说变送器指示灯正常、本地液晶屏还在刷新,网络工程师说交换机没有任何告警,监控平台那边则一口咬定“就是设备不上报”。三个人的说法放在一起其实是死循环——谁都在说“我这边没问题”,可问题又确实存在。

后来我拿笔记本接到设备所在的接入交换机上,用Wireshark抓了三十秒的包,真相就清楚了:采集服务器的Modbus TCP请求根本没到达设备,连TCP 502端口的SYN都没出现过,而设备侧通过SNMP发给网管平台的应答却在正常响应。

故事看起来简单,但里面其实覆盖了工业以太网温湿度变送器调试中最常用的两条抓包分析主线:SNMP和Modbus TCP/IP。这篇文章不打算铺开讲Wireshark的界面功能,重点只放在三个问题上:为什么一台变送器里要同时跑两个协议栈、这两种报文在抓包里到底长什么样、出故障时报文怎么把真相说出来。适合做动环监控调试、工业物联网、SCADA系统集成,以及经常跟以太网传感设备打交道的朋友们参考。

1. 为什么一台温湿度变送器里要同时装SNMP和Modbus TCP两条协议栈

1.1 两个协议栈的分工完全不同

温湿度变送器现在的设计基本都是“一机两用”:SNMP和Modbus TCP同时开放,但服务对象和业务定位完全不同。

SNMP走的是UDP 161/162端口,主要服务对象是网络管理平台。设备在MIB树里暴露系统描述、固件版本、运行时间、设备状态这类管理信息,有些厂商还会把实时温湿度放到企业私有OID下。它的特点是无连接、一次UDP包完成一次交互、靠OID寻址、用community字符串做访问控制。打个比方,SNMP更像是设备的“体检入口”,网管系统定期来打个卡,确认设备活着、配置有没有变化、固件是不是最新版。

Modbus TCP则走TCP 502端口,服务对象是PLC、SCADA、动环采集器这类实时数据采集系统。它基于TCP长连接,通过功能码读写寄存器,寄存器里保存温湿度测量值、报警阈值、设备状态字。特点是开销小、周期快、结构简单,采集周期做到200毫秒甚至更快都没问题。它更像设备的“业务数据出口”,生产数据都从这条路出去。

为什么要同时开两个服务?因为监控中心和数据采集系统往往是两套相互独立的系统,协议栈不互通。设备厂商为了兼容两种上游,就在固件里同时跑两个服务任务,一个监听UDP 161,一个监听TCP 502。还有另一个现实原因:Modbus TCP虽然拿数据方便,但标准Modbus里没有规定设备型号、固件版本这类“自我描述”信息放哪个寄存器,而SNMP的MIB树正好补上这一块。抓包如果只盯着其中一种协议,就等于把现场线索丢掉了一半。

1.2 抓包能一次性回答的几类问题

把Wireshark接到设备侧之后,一口气能确认的事情包括:

  • 请求到底有没有到达设备门口:目标MAC、IP、端口对不对。
  • 设备到底有没有响应:应答包是否存在,事务ID和请求能不能对应上。
  • 返回的数据寄存器原值是多少:对照设备手册换算成物理量,验证对不对。
  • TCP链路质量如何:SYN重传、RTT、Dup ACK、零窗口有没有出现。
  • 协议实现偏差在哪里:OID不存在、功能码不支持、寄存器地址越界,这些在报文里都有明确的错误码。

常见的故障场景和对应要看的报文位置,我整理了一张表:

现场现象优先检查的报文位置判断思路
平台显示设备超时上行是否有Modbus响应帧有响应说明设备正常,问题在采集端处理逻辑
设备指示灯正常但数据不刷新下行是否有Modbus请求到达设备请求没到设备,查网络路径和采集配置
读到的温度是-40℃或65535功能码、起始地址、寄存器数量大概率寄存器区或数据解析方式不匹配
SNMP能通但Modbus不通对比两条协议栈的TCP/UDP流量先确认设备服务是否同一IP,再补抓502口
数据偶尔跳变、断断续续TCP重传、RTO、连接断开重连链路质量差或设备处理不过来,结合时间列判断

2. 动手抓包前的现场装备和参数准备

2.1 三种物理接入方式怎么选

抓包设备接错了,后面分析全白费。现场常用接入方式有三种:

第一种是笔记本网卡直连设备网口。最直接,但有几个前提:设备必须已经独立供电,或者你的笔记本网口支持PoE供电(这种情况极少)。很多温湿度变送器是用PoE从交换机取电的,你把网线从PoE交换机口拔下来插到笔记本上,设备瞬间断电,抓到的包当然是空的。接线前必须先确认设备供电方式。另外,PC的IP必须和设备IP在同一网段。这里有个小技巧:如果不知道设备当前IP,可以先在网卡上配置一个169.254.x.x或192.168.1.x的静态地址,然后开Wireshark抓全量包看ARP广播,设备一旦通信就会暴露自己的IP和MAC。

第二种是交换机镜像端口。把设备上联的交换机端口配置成镜像口,把抓包笔记本接到镜像口上,不影响业务链路。这是最稳妥的接线方式,但需要交换机的配置权限。命令每家厂商不同,常见的是port mirror或monitor session,抓包前确认镜像方向是both(既要RX也要TX)。

第三种是用集线器Hub把PC和设备串在同一个冲突域。Hub会把所有流量广播到所有端口,不需要交换机配置,但现在的千兆Hub很少见,百兆Hub抓高速流量可能会丢包。小数据量调试可以,正经排查不推荐。

2.2 Wireshark抓包设置与基础配置

打开Wireshark第一件事是选对网卡。Windows下会看到一大堆网卡名:以太网、WLAN、VMware Virtual Ethernet Adapter、WAN Miniport等。物理网卡优先,不要选虚拟网卡。选网卡时注意看“Packets”列有没有在跑的数,那些一直跳的通常才是真正有流量的物理口。

进入Capture Options后两个关键设置:

  • 勾选“Use promiscuous mode on all interfaces”——开启混杂模式。看非本机MAC目标流量时才能抓到包,比如通过交换机镜像抓设备与采集服务器之间的通信。
  • Capture Filter和Display Filter是两个完全不同的语法,千万别混用。Capture Filter是BPF格式,在抓包前过滤,语法类似port 502 or port 161;Display Filter是Wireshark表达式,在抓包后过滤,语法类似tcp.port == 502 || udp.port == 161。我见过不少人把icmp写进Capture Filter,以为是在显示过滤,结果抓了半天一个包都没有。

2.3 预置设备信息清单

抓包不是打开就抓,抓完再看。在动手之前,我建议先把一组信息列在纸上或表格里:

参数项示例说明
设备IP192.168.1.100变送器当前地址
设备MAC00:01:02:03:04:05用于区分同类设备
Modbus TCP端口502有的厂商会改成非标端口
单元ID1网关场景下用来区分从站
功能码0x03 / 0x04保持寄存器或输入寄存器
寄存器起始地址0x0000注意组态软件显示可能从1开始
寄存器数量2或4取决于数据格式
数据格式uint16 / int16 / float关键,决定换算公式
SNMP端口161community通常public
温湿度OID1.3.6.1.4.1.xxxxx从MIB文件导入

这表格里的东西不知道也不用慌,可以从设备铭牌、说明书、官网驱动包里的MIB文件、或者是项目组态软件里反查。但不知道这些就抓包,后面很难判断报文内容对不对。

3. SNMP报文拆解:从GetRequest到Response,看管理层如何读变送器

3.1 SNMP报文的四层封装

SNMP抓包看起来比Modbus抽象,因为它的内容不是固定字段的“表格式”结构,而是TLV嵌套。一次典型的SNMPv2c GetRequest请求,在Wireshark里展开是这样的:

UDP header Source Port: 48623 Destination Port: 161 SNMP version: v2c (1) community: public data: get-request (0) request id: 0x1a2b3c4d error status: noError (0) error index: 0 varbinds: 1 varbind 1: 1.3.6.1.4.1.23456.3.1.1.0 (humidityCurrent) value: NULL

对应的Response:

SNMP version: v2c (1) community: public data: get-response (2) request id: 0x1a2b3c4d error status: noError (0) error index: 0 varbinds: 1 varbind 1: 1.3.6.1.4.1.23456.3.1.1.0 value (INTEGER): 25

关键点在于:SNMP本身是UDP载荷,走的是无连接路径,不需要三次握手。所以当你看到“请求发出去了但设备没响”,问题可能出在UDP的包被丢弃、设备上SNMP服务没起来、community不匹配这几个方向。它没有TCP那套重传机制来兜底,排查时要从应用层面去找原因。

3.2 OID寻址:1.3.6.1.4.1背后的企业私有树

OID的语义很多人一看就头大,其实拆开并不复杂:

  • 1.3.6.1.2.1是标准mib-2树,系统组、接口组、IP组都在这里。sysDescr是1.3.6.1.2.1.1.1.0,sysName是1.3.6.1.2.1.1.5.0。
  • 1.3.6.1.4.1是enterprises企业私有子树,下面每个厂商有一个由IANA分配的企业号,再往后全是厂商自己定义的内容。

温度、湿度、报警状态这类业务数据,很少有厂商放到标准mib-2里,绝大多数都挂在企业私有子树下。实操中我建议抓包前先用snmpwalk把设备整棵MIB树走一遍,找到温湿度节点:

snmpwalk -v2c -c public 192.168.1.100 .1

拿到OID后,再在Wireshark里针对性抓包。注意:设备随附的MIB文件最好导入到Wireshark里,这样varbind会直接显示OID的节点头名称,不用去背那一串数字。导入路径是Preferences > Name Resolution > 勾选SNMP OID resolution,然后加载MIB文件。

3.3 三类常见SNMP异常在抓包里的长相

第一类是设备完全不回包。抓包只能看到请求帧每隔几秒被重复发出——SNMP客户端通常有超时重试机制。这种情况优先排查设备上SNMP服务是否启用、community字符串是否匹配、UDP 161端口是否在监听。

第二类是回包但带错误状态。error-status字段不是noError,常见值有:

error-status含义排查方向
tooBig (1)响应超过UDP载荷限制MIB节点返回的数据量过大
noSuchName (2)请求的OID不存在OID写错,对照MIB重新确认
badValue (3)变量值非法多见于Set操作
noCreation (5)对象不可创建设备不支持该写操作

第三类是收到badCommunityName这类共同体不匹配的响应。抓包里能同时看到设备和网管站的IP,直接把community改成设备配置的一致即可。

补充一个容易误判的情况:如果抓到的SNMP报文TCP/UDP层一切正常、payload内容却是一堆不可读的二进制乱码,不要急着怀疑抓包有问题。SNMPv3是带认证和加密的,报文内容本身就不透明。在这种情况下要继续分析,只能配置SNMPv3的用户名、算法和密钥,Wireshark里的Protocols > SNMP菜单可以设置。

4. Modbus TCP报文拆解:MBAP头、功能码、寄存器逐字节分析

4.1 MBAP头7字节不是白给的

Modbus TCP和RTU最大的区别就是多了7字节的MBAP头(Modbus Application Protocol Header)。以一次读取温湿度寄存器的请求为例,抓包展开如下:

Modbus/TCP Transaction Identifier: 0x0001 Protocol Identifier: 0x0000 Length: 6 Unit Identifier: 1 Modbus Function Code: 3 (Read Holding Registers) Starting Address: 0x0000 Quantity of Registers: 0x0002

这7字节拆开看:

  • 事务标识符Transaction ID(2字节):每次请求自增,用于匹配请求和响应。Modbus TCP允许在同一个TCP连接上并发多个请求,事务ID就是对它们做一一对应的“编号”。如果抓包看到请求的事务ID没有递增,或者响应的事务ID跟请求对不上,极有可能是中间有协议网关在转发篡改,或客户端库实现有bug。
  • 协议标识符Protocol ID(2字节):固定为0x0000,表示Modbus协议。你看到其他值就要警惕是不是抓错了端口。
  • 长度Length(2字节):是指“Unit Identifier + PDU”的字节数。上面例子里Unit ID占1字节、功能码占1字节、起始地址占2字节、寄存器数量占2字节,总共6字节。它不算MBAP头自身,这个容易记错。
  • 单元标识符Unit ID(1字节):相当于RTU模式里的从站地址。直连变送器时通常是1;如果经过网关挂多台设备,就会用这个字段区分不同从站。

这里有基数问题:协议里寄存器地址是0x0000起始,但很多组态软件界面显示的“40001”是1起始。Wireshark抓包只认协议字段,你填软件地址时按组态软件的习惯,看抓包时记得减1对齐。

4.2 请求与响应的逐字段对照

接着看响应帧:

Modbus/TCP Transaction Identifier: 0x0001 Protocol Identifier: 0x0000 Length: 7 Unit Identifier: 1 Modbus Function Code: 3 (Read Holding Registers) Byte Count: 4 Register 0: 0x1448 Register 1: 0x0C3C

响应里的Transaction ID、Unit ID必须和请求一致,Length是7(1字节Unit ID+1字节功能码+1字节字节计数+4字节数据)。Byte Count表明后面跟了多少字节的寄存器数据,应该是寄存器数量乘以2。在这个例子里请求读了2个寄存器,响应返回4字节,完全匹配。

功能码的选择要特别注意。0x03是读保持寄存器,0x04是读输入寄存器。很多变送器的温度、湿度放在输入寄存器区,你用0x03去读虽然也能建立连接、也能收到响应,但数据可能全是0或固定值。判断依据只有设备手册,抓包本身不会告诉你“该读哪个区”,它只会忠实地把响应内容呈现在你面前。

4.3 寄存器原值如何换算成温度和湿度

这是最容易翻车的一步。两种主流数据格式:

第一种:整数定点数。寄存器0x1448十进制是5192,0x0C3C十进制是3132。假如设备手册写着“温度分辨率0.01℃、湿度分辨率0.01%RH”,那么温度就是5192 ÷ 100 = 51.92℃,湿度是3132 ÷ 100 = 31.32%RH。换算公式就一个除法,但分辨率系数必须看手册。

第二种:IEEE 754单精度浮点数。温湿度各占4字节、跨2个寄存器。这时候字节序特别关键,不同厂商实现不同。常见的有:

  • 大端顺序ABCD:寄存器1的高低位 + 寄存器2的高低位,依次拼成4字节
  • 字序交换CDAB:寄存器2的高低位 + 寄存器1的高低位

用Python解析时可以这样区分:

import struct data = bytes([0x42, 0xC8, 0x00, 0x00]) # 标准大端 val_be = struct.unpack('>f', data)[0] # 小端 val_le = struct.unpack('<f', data)[0] # 字序交换后大端(CDAB) val_word_swap = struct.unpack('>f', b''.join([data[2:], data[:2]]))[0] print(f"大端: {val_be:.2f}, 小端: {val_le:.2f}, 字交换: {val_word_swap:.2f}")

同一个字节序列,三种解析出来的数值天差地别。如果你把设备读回来的数据解析成-42.5℃、113.2℃这种明显不合理的值,不要先怀疑设备坏了,先换一种字节序试试。

4.4 异常响应的报文特征

Modbus协议里,异常响应会在功能码的最高位置1。比如请求功能码0x03失败,响应功能码是0x83,同时伴随一个异常码字段:

异常码名称含义与排查方向
0x01Illegal Function功能码不被设备支持,确认读保持还是输入寄存器
0x02Illegal Data Address起始地址+寄存器数量超出设备寄存器范围
0x03Illegal Data Value请求中的值非法,多见于写操作
0x04Slave Device Failure设备内部异常,可能是固件状态不对
0x06Slave Device Busy设备忙,稍后重试

抓包中看到0x83 0x02,基本可以直接判定是寄存器地址或者数量设置得不合理。看到0x83 0x01,说明功能码和设备的寄存器区不匹配。这些信息比设备“报不报故障”要精确得多。

5. 从抓包结果反推设备与网络:三起真实故障复盘

5.1 案例一:Modbus请求没到设备,SNMP却正常——配置漂移

平台上报三号变送器超时,设备现场指示灯正常。抓包30秒,得到两个重要事实:采集服务器定期向192.168.1.101的502端口发Modbus请求,但设备当前实际IP是192.168.1.110;同时192.168.1.50每秒向192.168.1.110的161端口发SNMP请求,设备正常回了GetResponse。

这说明设备、网络、SNMP服务全部正常,唯一不对劲的是采集平台的IP地址表。后来查明是现场有人调整过DHCP地址池,设备重开机后拿到的IP变了,采集端没同步更新。处置方法是把设备IP固定掉,同时把采集端的目标地址改过来。如果不抓包,三方可能还要扯半天。

5.2 案例二:SNMP读到状态正常,Modbus数据却是-40℃——功能码和寄存器区选错

设备SNMP里能读到温度值,网管平台显示设备健康。但SCADA通过Modbus读回的温度一直是-40.0℃。

抓包看到请求是FC03,读保持寄存器地址0x0000,响应帧也正常返回,寄存器原值是0xD8F0。对照手册:温度数据实际在输入寄存器区,应该用FC04读写地址0x0000;保持寄存器区0x0000存的是设备状态字,0xD8F0转成十进制就是某个状态码,跟温度没有任何关系。

把采集配置改成FC04、起始地址0x0000后数据立刻正常。这个案例想说明的是:SNMP回包正常只能证明设备“活着”,Modbus数据不对要回去核对功能码和寄存器地址,不要急着怀疑硬件。

5.3 案例三:报文被设备静默丢弃——轮询周期太快

整柜48路温湿度变送器,采集周期200ms。前期运行正常,半小时后其中几个设备数据开始断断续续,TCP连接反复断开重连。

抓包发现一个很有意思的细节:请求帧的事务ID在正常递增,但每隔几个请求,设备的响应帧就消失一次。这不是TCP层丢包,因为抓包机和设备在同一个二层环境里;更像是设备应用层根本没处理这个请求。配合SNMP侧观察,设备SNMP响应非常积极,说明TCP/IP协议栈本身是通的,问题出在Modbus服务任务的处理能力上。

这类低端变送器的固件对Modbus业务很可能就是单线程串行处理。轮询周期太短,处理队列溢出时,来不及处理的请求直接被丢弃。解决办法是:把轮询周期放宽到1秒,同时把请求合并成批读——比如用FC03一次读温度、湿度、状态字4个寄存器,而不是拆成4次单寄存器请求。调整后请求量直接减少75%,问题消失,抓包里也能看到请求间隔变得均匀。

6. 高频踩坑和效率技巧

6.1 抓包网卡这关就拦住了一半人

用笔记本自带的有线网卡是首选。不要用Wi-Fi来抓工业现场设备的包,无线网卡要开监听模式、还要面对802.11管理帧,复杂度比有线抓包高一个量级。Windows下有USB转网卡的,抓百兆流量可能丢包,尤其是镜像口流量本来就大的时候。

还有一个被忽略的细节:抓本机是采集服务器、设备是对端的时候,有些网卡驱动对“发往本机的包”和“本机发出的包”都会采集,这没问题;但如果采集服务器和抓包机不是同一台机器,你就必须开混杂模式并且保证网卡在同一个交换域里。

6.2 显示过滤器的语法细节与VLAN处理

常见显示过滤器:

  • tcp.port == 502:只看Modbus TCP两条方向
  • udp.port == 161:只看SNMP两条方向
  • modbus:已经被解析为Modbus协议的帧,注意与端口过滤的差别
  • snmp:显示SNMP协议帧
  • ip.addr == 192.168.1.100:只看某台设备相关流量

ip.addr和ip.src/ip.dst不一样——ip.addr是“源或目标”任一匹配,新手容易误以为只是目标匹配。如果想精确过滤某个方向的流量,写成ip.dst == 192.168.1.100会更严谨。

如果交换机镜像口出来的报文带802.1Q VLAN Tag,显示过滤器可以用vlan.id == 10辅助过滤。不带tag的普通抓包则完全不需要加这个条件。

6.3 校准时间线和导出关键报文

Wireshark默认显示相对时间(从抓包开始经过的秒数)。现场排查时我习惯先把它切换成UTC绝对时间:View > Time Display Format > Date and Time of Day。这样才能把抓包和后台日志、操作记录精确对上。比如平台显示“10:31:02 数据超时”,你可以在抓包里直接定位到10:31:02前后发生了什么。

导出关键报文用File > Export Packet Dissections > As Plain Text,只勾选当前选中的帧,这样贴到故障工单里的是干净可读的协议展开文本,而不是整个抓包文件。

6.4 抓包完毕别忘清理现场

最后一件事很多人忽略:抓完包后,把笔记本从镜像口拔掉、恢复交换机端口配置、把临时设置的静态IP改回去。尤其是通过PoE交换机的端口抓包时,你拔插网线动作不对,可能会让正在供电的设备掉电重启。抓包记录几个MB的文件建议留着归档,后面如果设备出现周期性故障,翻旧抓包复盘比重新跑现场省力得多。

最后说点个人体会。我做过不少温湿度变送器的调试和验收,现在养成一个习惯:不管项目里有没有问题,第一次接设备都会先抓5分钟的包留底。抓包文件不占多少空间,但后面出疑问的时候,它比任何截图都有说服力。设备厂家、平台厂商、现场运维三方扯皮时,把抓包里“请求到了没、回没回、回得对不对”三个截图贴出来,责任边界立刻清楚。这不是工具多高明,而是报文不会说谎。以后遇到类似问题,不妨也先把Wireshark开起来再说。

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

剪贴板增强工具设计解析:历史记录、格式清洗与效率提升

1. 项目概述&#xff1a;一个看起来不起眼的 paperclip先说结论&#xff1a;这个项目叫 paperclip&#xff0c;但做的不是回形针&#xff0c;而是一套把“剪贴板”这个日常到容易被忽略的系统能力&#xff0c;重新做了一层封装和增强的工具。用我们这行的话说&#xff0c;它解决…

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

最强开源9B级VLM模型来了!GLM-4.1V-Thinking让视觉Agent有救了~

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

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

手写Java词法分析器:可调试的Lexer实战工程

简介&#xff1a;本资源是一份面向计算机专业本科生及编译原理初学者的Java词法分析器实践代码包&#xff0c;聚焦编译前端核心环节——源码字符流到Token序列的转换过程。压缩包为2KB ZIP格式&#xff0c;含2个Java源文件&#xff1a;ScanWords.java实现扫描逻辑&#xff0c;支…

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

C语言语法分析器实战:从LL(1)到错误恢复与AST遍历

简介&#xff1a;本资源为C语言LR(0)语法分析器实现项目&#xff0c;面向正在学习编译原理、希望动手实践自底向上语法分析的高校学生与开发者。项目围绕上下文无关文法展开&#xff0c;完整呈现从构建项集、构造状态机到生成状态转移表、执行语法分析的全过程&#xff0c;代码…

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

QuickBlue:基于Spring Cloud与JDK 21的企业级AI应用底座

1. 从一堆“微服务”热词里&#xff0c;拆出 QuickBlue 的真实定位1.1 为什么“AI 应用底座”这个词突然被频繁提起最近半年&#xff0c;我身边做企业级项目的朋友几乎都在聊同一个话题&#xff1a;AI 功能怎么往现有系统里塞。不是那种做个聊天窗口就完事的 Demo&#xff0c;而…

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

给 Codex 装上生图 SKILL:TaoToken 统一 Key 接入图像生成能力

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

作者头像 李华