news 2026/10/5 6:04:57

CANoe中ISO 15765多帧传输全解析:从首帧到连续帧,逐字节拆解UDS长响应

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CANoe中ISO 15765多帧传输全解析:从首帧到连续帧,逐字节拆解UDS长响应

如果你第一次在CANoe的Trace窗口里看到0x7E8这条ID连续蹦出好几帧,第一帧是10 14 62 F1 90 4C 53 56,第二帧是21 41 41 34 31 4A 30 41,第三帧是22 31 32 33 34 35 36 37,中间还夹了一条30 00 00 00 00 00 00 00,大概率会一头雾水:这到底是几条报文?为什么要分这么多帧?那串21、22开头的又是什么?

其实这几帧就是ISO 15765定义的传输层(Transport Protocol,TP)在干活。它解决的是一个非常朴素的矛盾:CAN单帧数据场最多8个字节,而UDS诊断服务一次要传的数据经常超过8个字节。比如读VIN码(0x22服务)的响应有20个字节,一条CAN帧根本装不下,那就得拆。怎么拆、怎么告诉对方“我拆了”、对方怎么回“我知道了,你继续发”,全是ISO 15765-2定的规矩。

这篇文章就是带你把这条规矩彻底看明白。我会用CANoe里实际抓到的报文,从帧结构、逐字节拆解、Trace配置到常见的坑,手把手捋一遍。适合刚接触UDS诊断、被多帧搞得晕头转向的测试工程师、嵌入式软件工程师,也适合那些已经会用CANoe的Diagnostic Console点按钮、但还想知道底层到底发生了什么的人。

1. 为什么多帧传输总让人“懵圈”

1.1 名字很直观,行为却不像想象中那样

ISO 15765-2里有四种帧:Single Frame(单帧)、First Frame(首帧)、Flow Control(流控帧)、Consecutive Frame(连续帧)。名字本身都不难懂,但一旦落到Trace里,很多人就乱了,原因在于它们出现的顺序不是按字母排的。

一次正常的“长响应”交互顺序是这样的:

  1. 接收方先收到一个首帧(FF),它相当于一个“开场白”:我要发的完整数据一共N个字节,这里先给你前6个字节。
  2. 发送方(确切说是请求方,也就是诊断仪侧)收到FF后,回一个流控帧(FC),相当于说:收到,你可以继续发,或者按我要求的节奏发。
  3. 接收方随后收到一串连续帧(CF),每一帧带7个字节数据,还有一个从1开始递增的序列号。

我在带新人时常用一个快递的类比:FF是快递员打电话说“这个包裹一共20公斤,我先搬了6公斤到门口”,FC是你说“行,你继续搬”,CF就是后面一趟一趟搬上来的包裹,而序列号就是每趟的编号,防止中途丢件。

1.2 CAN帧和UDS消息不是一回事,这是懵圈的根源

很多人犯迷糊,是因为脑子里把“一条CAN帧”和“一条诊断消息”画了等号。实际上两者之间隔着传输层:UDS服务(比如0x22读数据、0x2E写数据、0x19读故障码)是应用层的东西;ISO 15765-2是传输层;CAN控制器只负责把一条一条独立的数据帧发到总线上。

这三层在CANoe里对应的观察窗口完全不同:

层级协议在CANoe里怎么看
应用层UDS服务Diagnostic Console里看到的“Response”
传输层ISO 15765-2 PCITrace里的10/21/30开头字节
数据链路层CAN帧Trace里的ID、DLC、时间戳

如果你只看Diagnostic Console,CANoe会帮你在后台把多帧重组好,你看到的是一条完整的UDS响应,底层拆了几帧、中间有没有流控,全都看不见。而一旦你打开Trace看原始报文,传输层的“拆装现场”就暴露出来了,没有提前建立这套分层概念的人,自然就懵了。

所以我的建议是:学ISO 15765多帧,千万别只用Diagnostic Console,一定要开着Trace去对应着看。只有看到“高层的完整响应”和“底层的多帧拆解”是同一件事,你才算真正理解了它。

2. 四类帧的字节结构:把N_PCI背下来,后面就顺了

2.1 单帧SF和首帧FF:长度字段最容易搞混

ISO 15765-2里,每个CAN数据场的第一个字节(或多个字节)叫N_PCI(Protocol Control Information),专门用来标记帧类型和数据长度。经典CAN(8字节数据场、普通寻址)下,四种帧的布局如下:

帧类型第一个字节第二字节数据载荷
SF单帧0x0n(n=数据长度,最大7)无最多7字节UDS数据
FF首帧0x1m + 0xll(m和ll组成12位长度)见左最多6字节UDS数据
CF连续帧0x2s(s=序列号)见左最多7字节UDS数据
FC流控帧0x3f(f=流控状态)BSSTmin

注意看SF和FF的用法差异,这是初学者最容易栽跟头的地方。

SF的第一个字节,高四位固定是0,低四位是这帧里携带的UDS数据长度。比如诊断请求22 F1 90是3个字节,那么CAN帧数据就是03 22 F1 90,03就是SF的N_PCI,表示后面3个字节是有效数据。

FF则复杂一些,因为长度可能超过15,4位装不下了。FF用两个字节表示长度:第一字节的高四位固定是1,低四位是12位数据长度的最高4位;第二字节是整个长度的低8位。所以10 14表示的是:这是一个FF,数据总长度是0x014,也就是20个字节。

这里有个极其容易搞混的点:FF_DL = 0x14 = 20,指的是这条UDS完整消息一共20个字节,而首帧本身只携带了6个字节。20和8、20和6,这些数字在脑子里对不上,人就乱了。记住一句话:FF_DL是“总账”,不是“当前帧账”。

2.2 连续帧CF和流控帧FC:序列号与红绿灯

CF的N_PCI是一个字节,高四位固定是2,低四位是序列号SN。协议规定:首帧之后的第一条CF,SN必须是1,之后每帧加1,到15之后回绕到0。为什么不是0开头?因为FF在逻辑上已经隐含了一个“第0帧”的位置,CF从1开始接续。

FC的N_PCI也是关键。第一个字节高四位固定是3,低四位是流控状态FS:

FS值含义说明
0 (0x30)CTS——继续发送接收方准备好了,发吧
1 (0x31)WAIT——等待接收方忙于其他事,稍后再给我FC
2 (0x32)OVFLW——溢出/中止接收方缓冲不够,这次传输作废

FC的第二个字节是BS(Block Size),表示允许连续发多少条CF后才需要再等下一个FC。BS=0是一个特殊值,表示“不限制block大小,剩下的CF你一口气发完”。第三个字节是STmin(Separation Time minimum),表示两条CF之间最小间隔时间,0x00-0x7F表示0到127毫秒,0xF1-0xF9表示100到900微秒。

把这三字节连起来看,30 00 00的意思就是:继续发,不限制块大小,也不要求间隔。

2.3 寻址方式不同,能装的数据量也不同

上面说的7字节、6字节、7字节,都有一个前提:用的是普通寻址(Normal Addressing),也就是CAN ID本身已经区分了请求和响应,不需要在数据场里再加目标地址字节。

但还有一种常见方式叫扩展寻址(Extended Addressing),数据场第一个字节固定放目标/源地址字节(比如物理寻址时放ECU地址),这个字节不算在UDS消息里,所以留给PCI和数据的位置就少了一个:

帧类型普通寻址可用数据扩展寻址可用数据
SF7字节6字节
FF6字节5字节
CF7字节6字节

很多OEM的诊断规范会明确规定用哪种寻址方式。如果配置错了,比如接收方按扩展寻址解析,发送方却按普通寻址发,那么所有字段都会错位一位,看起来就是“每个字节都对不上号”。一旦在工程里遇到这种情况,先别急着怀疑协议栈,把CANoe Trace里的第一字节拆出来看,确认是不是地址字节把PCI顶到后面去了。

还有一种29位CAN ID的情况,ID里包含了源地址和目标地址,数据场里通常不加地址字节,装载能力和普通寻址一样。具体用哪种,以协议规范为准。

3. CANoe实战拆解:一次完整的读VIN多帧交互

3.1 动手前的环境和配置

为了把原理落到实际,我在CANoe里搭了一个最简单的诊断测试环境:一个Vector硬件接口接驳到ECU所在的总线上(或者直接用CANoe的虚拟CAN口做纯软件仿真),总线波特率500 kbit/s,物理寻址请求ID是0x7E0,响应ID是0x7E8。

操作上分几步:

  1. 打开CANoe,配置一个CAN通道,波特率选500 kbit/s,采样点按OEM规范设置。
  2. 在“Simulation Setup”里添加一个网络节点,有现成的诊断描述(CDD/ODX)就直接加载,没有的话用IG(Interactive Generator)或CAPL节点手动发请求。
  3. 打开Trace窗口,先添加“Protocol”列和“DLC”列,这样后面解码TP帧时能直接看到帧类型。
  4. 如果你只是想看数据内容,不想让CANoe自动做TP高亮,直接用Raw模式看也可以,因为这篇文章就是要逐字节自己拆。

我用一个CAPL节点发了一条22 F1 90的UDS请求。22是ReadDataByIdentifier服务ID,F1 90是DID,这里对应的是VIN码。

3.2 请求阶段:为什么3个字节就敢用单帧

请求只有3个字节,加上1个字节的SF N_PCI,一个CAN帧完全装得下,所以Trace里看到的就是一条单帧:

序号时间方向IDDLCData
100:01.234Tx0x7E0803 22 F1 90 00 00 00 00

这里03是SF的PCI,表示这条帧携带的UDS数据是3个字节:22 F1 90。后面的00都是填充字节,不是有效数据。有些ECU喜欢填AA或CC,填充值不是协议规定死的,所以解析时一定要以PCI里的长度为准,不要看到DLC=8就全读进去。

3.3 响应阶段:FF/FC/CF逐字节分析

ECU收到请求后,返回的VIN有20个字节,这是一个典型的多帧响应。CANoe Trace里抓到的完整过程如下:

序号时间方向IDDLCData帧类型
200:01.256Rx0x7E8810 14 62 F1 90 4C 53 56FF
300:01.258Tx0x7E0830 00 00 00 00 00 00 00FC
400:01.260Rx0x7E8821 41 41 34 31 4A 30 41CF 1
500:01.262Rx0x7E8822 31 32 33 34 35 36 37CF 2

第二帧10 14:

  • 高四位1,低四位0,第二字节14,拼起来是12位长度0x014 = 20。
  • 后面6个字节是UDS响应数据的前6字节:62 F1 90 4C 53 56。

注意,这帧的DLC是8,但有效数据只有6个字节,因为它要留2字节给PCI。有些ECU会在这里做填充,有的不填,DLC照常拉满8,解析时必须跳过前2字节。

第三帧30 00 00:

  • 30表示CTS,继续发送。
  • 00表示BS=0,不限制连续帧数量。
  • 00表示STmin=0,不强制帧间隔。

这帧本身只有3个字节是协议有效的,后面的00全是填充。

第四帧21开头是第一条CF,SN=1,后面7字节是第7到第13个UDS数据字节:41 41 34 31 4A 30 41。

第五帧22开头是第二条CF,SN=2,后面7字节是最后7个数据字节:31 32 33 34 35 36 37。

把三部分拼起来:首帧的6字节62 F1 90 4C 53 56,加上CF1的7字节41 41 34 31 4A 30 41,再加上CF2的7字节31 32 33 34 35 36 37,得到完整的响应:

62 F1 90 4C 53 56 41 41 34 31 4A 30 41 31 32 33 34 35 36 37

这是20个字节,和FF_DL里声明的20完全一致。拆开看:62是0x22服务的正响应SID,F1 90是DID,后面17个字节就是ASCII码的VIN字符串“LSVAA41J0A1234567”。校验一下长度:17个字符,正好。

3.4 时间戳里藏着的协议参数

把上面几张表的时间列连起来看,你会发现FF和CF之间的时间差只有2毫秒上下,这是因为FC里STmin=0。如果ECU在FC里写了10(16ms),那么你会在Trace里看到CF帧之间至少间隔16ms;如果写了32(50ms),那间隔会更明显。

这种时间上的观察,对排查“为什么我的分帧传输老是超时”很有帮助。我曾经遇到过一个项目,ECU在FC里要求STmin=0xF1(100微秒),但是诊断仪端的协议栈因为内部任务调度,实际发CF间隔只有50微秒,结果ECU那边缓冲区溢出,直接回了一条32把传输掐断。这种问题光看数据内容根本找不到,必须盯时间戳。

另外,协议里还有几个时间参数也值得了解:N_Bs是发完FF后等待FC的超时时间,N_Cr是两条CF之间允许的最大间隔。CANoe的TP统计信息里能看到这些超时是否发生过,后面排查超时NRC时会很有用。

4. 让CANoe帮你把TP层“翻译”成人话

4.1 Trace窗口的协议解码配置

在CANoe默认情况下,如果你在Trace里看到的是原始数据,很可能是因为工程没有加载诊断描述,或者通道没有启用ISO TP解码。多数场景下只需要做两件事:

  1. 确认通道的CAN设置里加载了包含ISO 15765传输层属性的数据库(.dbc或CDD/ODX),或者添加了诊断通道。
  2. Trace窗口的列头右键,把“Protocol”列显示出来。如果配置正确,这一列会显示ISO-TP或者Diag相关的协议名,FF、CF、FC也会被识别。

实际测试中,我会同时打开三个窗口:Trace看原始帧、Diagnostic Console看UDS层、CAN Statistics看总线整体负载。尤其是在做压力测试时,如果总线上有大量其他报文,CF帧可能会被高优先级报文延迟,导致N_Cr超时,这时CAN Statistics窗口里的总线负载率就是第一手线索。

4.2 用Diagnostic Console和HexView配合验证

Diagnostic Console的价值在于,它把TP层的拆装完全封装了,你选中“ReadDataByIdentifier”,填上DID,它会自动帮你发请求、收响应,然后把重组的完整响应直接显示出来。这对日常测试非常高效,但也很容易让人忽略底层逻辑。

所以我建议的做法是:先用Diagnostic Console确认“功能上是通的”,再用Trace去验证“底层是怎么通的”。比如Console里看到响应是62 F1 90加VIN,你再去Trace里数一数,应该是1个FF加2个CF,CF序列号是1和2。两边对得上,说明协议栈工作正常;对不上,才是你需要深入源码或者CAPL脚本的时候。

HexView这个工具主要是用来查看和编辑二进制文件的,在诊断领域经常用来做Flash数据、标定数据的比对。它不直接参与TP解析,但如果你在做Bootloader刷写测试,常常需要把待刷写的.s19或.hex文件用HexView打开,对照诊断仪实际发送的分帧内容,确认数据分块、校验和逻辑是否符合预期。

4.3 用CAPL写一个多帧自动拼装小工具

有的测试场景需要在CANoe里自动验证TP组包是否正确,比如做自动化回归测试。这时候写一个几十行的CAPL脚本比人眼盯着Trace靠谱得多。下面是一个简化版的示例,监听0x7E8上的响应,把FF和CF自动拼成完整数据,并在完成后输出:

variables { byte rxData[5000]; int rxLen = 0; int totalLen = 0; } on message 0x7E8 { int i; int pciType; pciType = this.byte(0) >> 4; if (pciType == 1) // First Frame { totalLen = ((this.byte(0) & 0x0F) << 8) | this.byte(1); rxLen = 0; for (i = 2; i < 8; i++) rxData[rxLen++] = this.byte(i); } else if (pciType == 2) // Consecutive Frame { for (i = 1; i < 8; i++) rxData[rxLen++] = this.byte(i); if (rxLen >= totalLen) { write("完整响应, 长度=%d", totalLen); for (i = 0; i < totalLen; i++) write(" %02X", rxData[i]); rxLen = 0; totalLen = 0; } } }

这段脚本只处理了普通寻址、经典CAN的情况,没有判断FC,也没有处理扩展寻址和地址字节,但在多数读VIN、读数据块的测试场景里足够验证组包逻辑了。实际工程中建议把输出拼成一个字符串写到Write窗口或者面板文本控件里,这样清洗得多。

5. 多帧传输的翻车现场:这些坑我基本都踩过

5.1 CF序号走到F之后绕回0,代码里最容易漏

前面说过,CF的SN是4位,从1走到15之后,下一帧会变0。很多人写接收组包逻辑时,会潜意识认为SN应该一直是1、2、3……顺序递增,一旦看到2F后面跟着20,第一反应是“协议错了吧”。

举个例子,一条UDS响应如果总长度是130字节,FF先带6字节,剩下124字节,每帧CF带7字节,一共需要18条CF。SN会是这样一段序列:1、2、3……14、15、0、1、2、3。也就是说,第16帧的SN是0,而不是16。如果你的判断代码写的是“收到SN=0就认为非法”,那这条长响应就永远组不完整。

正确的做法是按模16来比较,哪怕SN回绕了,也能判断连续性。CANoe的TP层已经处理了这个问题,但如果是自写协议栈或者写CAPL辅助脚本,十有八九会在这里翻车。

5.2 FC里的BS含义,很多人一开始理解反了

我第一次带项目时,看到FC的BS=1以为是“完整传输完成”,后来才发现完全不是这回事。BS=1的意思是:发送方最多只能发1条CF,然后必须停下等接收方再发一个FC,才能继续发下一条。这相当于每传一帧都要握一次手,效率很低,但接收方缓冲区很小或处理速度慢时,这是必要的保护。

理解BS后,再看刷写场景就清楚为什么有些Bootloader的刷写速度上不去了——要么BS=1导致频繁握手,要么FC里STmin设得很大。通常刷写阶段ECU会要求一个较小的BS和STmin,比如BS=1、STmin=1ms,以保证Flash写入不溢出;而普通的诊断读数据,ECU一般给BS=0、STmin=0,尽量跑满带宽。

如果发送方无视BS约束,一口气把几十条CF全发出去,接收方的接收缓冲很快就会爆,随后一条32(OVFLW)直接让这次的传输中止。在Trace里看到这种情况,先检查FC的第二个字节,再检查发送方有没有按约定的块大小执行。

5.3 STmin被忽略,低速端根本来不及处理

STmin看似只是一个毫秒数,但它直接决定接收端“能不能喘过气”。高速诊断场景下,STmin=0很正常,总线速率500k时,理论上两条背靠背的CF之间只有几十微秒间隔,接收端的CPU如果只是简单搬运,可能处理不过来。

在测试中我发现,很多ECU在FC里写的STmin并不是0,比如0x10(16ms)、0x32(50ms),这些数值通常和ECU的接收任务周期有关。如果诊断仪侧为了追求速度强行忽略STmin,后续大概率会出现CF丢失或接收超时,最终表现为诊断无响应或响应异常。

排查思路也简单:在Trace里量两条CF的时间差,如果ECU要求STmin=50ms,但实际发CF的间隔只有1ms,那问题一定在发送方。反之,如果发送方已经按50ms发了,ECU还是超时,那就要怀疑ECU内部的软件问题了。

5.4 环境相关的坑:虚拟CAN口、采样点、软件版本

关注CANoe的人应该都搜过“虚拟CAN口”“采样点”“运行后自动退出”这类问题。这几个确实都和实际测试环境强相关。

虚拟CAN口(CANoe Virtual Bus)非常适合做纯软件的协议学习:没有真实电路,不会有错误帧,采样点设置也不会影响数据接收,你可以放心的在里面模拟ECU和诊断仪交互。但虚拟CAN口有个副作用——过于理想。真实总线上有报文仲裁、位定时偏差、线束干扰,有些TP问题在虚拟环境下根本复现不出来。所以我一般建议:学协议用虚拟CAN,验证可靠性一定要上真实硬件和真实总线。

采样点的问题,常见于高波特率总线。比如500 kbit/s下,如果采样点设置得太后(比如90%),线缆稍长一点就可能采样到边沿附近,导致数据错误进而产生错误帧。很多人在CANoe里看到Error Frame就怀疑ECU坏了,其实先看一下通道配置里的采样点是否在80%左右,往往能省下半天排查时间。

至于CANoe 17 SP3运行后自动退出的情况,多数和授权服务、驱动版本或者.NET组件有关。真遇到的话,别急着怀疑工程文件,先试管理员权限运行、检查Vector工具链是否完整,再去事件查看器里看崩溃模块,基本都能定位。

6. 一点自己的排查心法:先看PCI,再谈诊断

6.1 拿到一条长报文,我从来不会先读数据

不管是自己做测试,还是帮同事看问题,我拿到一帧带10或21开头的报文,第一件事永远是看PCI,不是看后面的业务数据。因为业务数据再花哨,如果PCI不对,后面全是废的。

具体我会按这个顺序撸一遍:

  1. 看第一字节高四位:是1(FF)、2(CF)还是3(FC)。
  2. FF的话,立刻算一下12位总长度,心里大概有数后面会来几条CF。
  3. CF的话,看低四位SN,确认序列号连续,特别是要留意F之后有没有正确回到0。
  4. FC的话,重点看BS和STmin,判断发送方后面应该用什么节奏发。
  5. 最后才拼数据,再去UDS层做语义解析。

这个顺序看起来机械,但效率极高。因为PCI就是传输层的“骨架”,只要骨架是对的,数据就算解析错了,也只是业务层的问题;骨架错了,一切免谈。

6.2 测试报告里真正有价值的,永远是原始Trace

最后分享一个习惯:每次出一个诊断相关的测试结论,我都会把完整的原始Trace一起附上,而不是只贴Diagnostic Console的截图。因为Console截图是“加工过的结果”,丢了传输层的所有过程信息;而原始Trace里包含了时间戳、ID、DLC、每个字节,甚至总线错误帧,这些才是跨部门沟通时最有说服力的东西。

对正在学CANoe和ISO 15765的朋友,我的建议很朴素:不要满足于会点几个按钮、会看Console显示“Response”。花一个下午,把读VIN、读DID这些场景在Trace里逐帧扒一遍,自己动手算一次FF_DL、CF数量、序列号回绕,这个协议你就真的掌握了。以后再看到10 14开头的帧,不会再懵,而是会心一笑:哦,这是个20字节的长响应啊。

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

低功耗测量中的探头优化:捕捉微弱信号的关键技巧

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

作者头像 李华
网站建设 2026/10/5 6:04:32

Cesium克里金插值实战:从离散点到三维热力图的完整流程

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

作者头像 李华
网站建设 2026/10/5 6:04:12

MRAM与PIC18F86K22工业数据记录方案:SPI通信与掉电保护实战

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

作者头像 李华
网站建设 2026/10/5 6:03:58

clusterProfiler安装避坑指南:环境配置与报错全解决

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

作者头像 李华
网站建设 2026/10/5 6:02:27

AUTOSAR NvM状态机与读写时序:NvM_WriteBlock落盘解析

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

作者头像 李华
网站建设 2026/10/5 6:02:26

FPGA双通道中频信号数字下变频与相位差估计实现详解

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

作者头像 李华