news 2026/9/16 2:02:05

松灵底盘ROS下CAN通讯调试实战:从接线到轮子转动的全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
松灵底盘ROS下CAN通讯调试实战:从接线到轮子转动的全流程

拿到松灵底盘的第一天,我对着那根CAN线愣了十分钟。网口、串口都好理解,CAN是什么?为什么不上网线?更麻烦的是,ROS下的CAN通讯调试跟普通Linux开发完全是两个路子,命令多、概念杂,网上资料还东一块西一块。这篇就是把我从“接上线”到“轮子转起来”的全过程捋一遍,重点是两条线:一是松灵机器人驱动所需的调用包去哪找、怎么编译,二是ROS下CAN通讯从物理链路到话题收发的操作步骤。适合手上有松灵底盘、或者刚入坑移动机器人底盘开发的兄弟,看完能少踩我踩过的那堆坑。

标题说“ROS下的CAN通讯调试”,很多人以为这俩是一回事,其实得拆成两个独立问题来解。ROS管的是上层节点和话题,CAN管的是底层数据搬运,中间靠松灵官方提供的驱动调用包搭桥。整个调试的本质就是:先确认CAN总线上能收到正确报文,再确认驱动包能把报文转成ROS话题,最后确认ROS话题能反过来驱动底盘运动。任何一层出问题,表象都是“车不动”或者“数据不对”,这时候如果不懂分层排查,就会陷入瞎改代码的泥潭。

这篇文章我会按调试顺序来写:先讲为什么松灵选CAN、整体调试怎么分层;再讲CAN通讯的核心原理和底盘的报文模型;接着是环境准备和调用包编译;然后是完整的实操步骤;最后把我遇到的典型问题整理成排查手册。整篇偏实战,命令我都会给全,但你拿到手上时务必对着自己那款底盘的官方手册核对波特率和帧ID,别盲抄。

1. 调试前的整体规划与方案选型:为什么先打通CAN?

1.1 松灵底盘为什么用CAN总线?

先解决一个最基础的疑问:为什么这些移动机器人底盘不用网线或者串口,非要用CAN?原因其实不复杂。底盘内部有电机驱动器、转向控制器、电池管理单元,有的还挂着IMU和急停模块,这些部件分布在车体各处,工作环境是强震动、强电磁干扰的。CAN总线是差分信号传输,抗干扰能力强,而且采用多主发送的仲裁机制,多个控制器之间可以直接通信,不需要主机轮询。对比一下就清楚了:

通讯方式抗干扰性实时性布线复杂度典型应用
串口UART较差一般简单传感器点对点
RS485工业总线
以太网复杂大数据量传输
CAN简单车载/机器人

松灵这种AGV底盘,轮子电机、转向电机、刹车模块都在底盘内部,用CAN把整车控制器和驱动板串成一个网络,一根双绞线就能搞定,比走网线简单可靠得多。所以说这不是“坚持用老技术”,而是CAN在车载这个场景下确实是最合适的方案。

1.2 调试全程分层拆解:从物理链路到上层节点

我在实际调试中把整个CAN通讯问题分成四层,这个分层帮我省了大量时间,也推荐给你:

第一层是物理链路,包括CAN线有没有接对、CAN_H和CAN_L有没有接反、波特率是否一致、有没有接终端电阻、是否共地。这一层的典型表现是“完全没有数据”或者偶发丢帧。第二层是数据链路,包括帧ID、DLC、数据字节顺序和校验方式,典型表现是“能收到数据但是解析出来是乱的”。第三层是应用层,也就是松灵官方驱动调用包这个黑盒,它负责把CAN原始报文转成ROS话题,或者把ROS话题转回CAN报文。第四层是整机验证,验证cmd_vel话题能不能真的让轮子按预期转。

分层有一个直接好处:每一层都有对应的独立工具去验证。物理链路用万用表和can-utils里的candump,数据链路用cansend发固定报文再检查回包,应用层用rostopic echo查看话题数据,整机验证就靠人眼盯着轮子了。把这四层搞清楚,遇到问题你不会慌,因为你至少知道问题不在自己的代码里。

1.3 驱动调用包选型:官方包还是通用包?

松灵底盘在ROS下的驱动方案,主流有两条路。一条是直接用松灵官方维护的调用包,ROS1对应agilex_ros、ROS2对应agilex_ros2,里面有各个系列底盘的驱动节点。另一条是通用的ros_canopen/canopen_chain这类包,自己写协议映射文件。

两条路的取舍很简单。如果你是想快速跑通底盘、马上做上层导航或感知算法,那直接用官方包,这是最稳的方案。官方包已经把底盘协议封装好了,launch文件一拉起来就能用,还自带了URDF模型和遥控示例。但封装的代价是黑盒,一旦协议对不上,你很难从代码层面定位问题。我自己第一次调的时候,就是官方包跑起来但轮子不动,官方包里找不到原因。

用通用ros_canopen包的好处是整套协议映射都是你自己写的,每一帧数据你都门儿清,调试时能更快定位是协议解析还是硬件问题。但代价是你得把底盘协议表吃透,起步成本高不少。我的建议是两条腿走路:先用官方包把底盘跑起来,证明链路没问题;再去读官方包的源码,结合协议表理解它的报文处理逻辑,这样出现问题你能绕开黑盒,直接查根本原因。

2. CAN通讯原理与松灵底盘报文模型:先搞懂对讲机规则

2.1 CAN总线的工作机制与人话版类比

CAN总线是个什么玩意?我教你一个很好记的类比:它就像一个单位里大家共用的对讲机频道,谁都能说话,但同一时刻只允许一个人的声音发出去。如果两个人同时按住PTT按键,系统会让优先级高的人先说,另一个人自动退让,等频道空闲了再重发。这个“优先级”就是CAN报文头里的帧ID,ID数值越小优先级越高。

上报健康档位的节点(比如电池电压检测)优先级就低,而控制类和急停类报文优先级必须最高。否则一旦总线上流量大了,控制指令被挤到后面,机器人反应延迟,那可是会出事的。

从协议格式上,CAN报文分标准帧和扩展帧,标准帧是11位ID,扩展帧是29位ID。松灵底盘大多用的是标准帧。一帧标准CAN报文由ID、DLC(数据长度)、最多8字节数据和校验机制组成。

调试时用candump看到的每一行就是一个CAN帧,你看到的比如can0 141 [8] 00 00 00 00 00 00 00 00,意思是在can0总线上收到一个ID为0x141的帧,数据段长度是8字节,后面的十六进制数就是数据。理解了这一行,你就理解了CAN数据链路层的全部精髓。

2.2 控制帧与状态帧:一收一发两个方向

松灵底盘的CAN通讯模型其实特别简单,核心就两类报文:上层发下去的“控制指令帧”和底盘回上来的“状态反馈帧”。控制指令帧一般包含目标线速度、目标角速度(或转向角)、使能位和控制模式等字段;状态反馈帧一般包含当前轮速、转向角、电池电压、整车电流、运行模式和故障码等字段。底盘的中控板或电机驱动板按固定周期解析这些报文,同时按固定周期上报状态。

这里必须敲黑板:不同系列底盘的控制帧ID、数据字段排列、字节序、缩放比例很可能都不一样。比如我调试的这款底盘,控制指令帧ID是0x141,线速度占前两个字节,角速度占后两个字节,带符号位,小端排列,还有一个单独的使能控制帧。但你手头的Hunter或者Bunker可能就是完全另一套定义。

我见过不少兄弟拿到底盘不查协议表,直接拿网上别人的报文去试,结果底盘纹丝不动,转头骂厂家。这真不该。正确的做法是:把手头底盘的《CAN协议说明书》打开,对照里面的字段定义表,逐一确认ID、偏移、缩放因子,再上手写代码或拼报文。协议表就是你的地图,不看地图就开车,不迷路才怪。

2.3 波特率、终端电阻和总线质量:物理层决定成败

物理层的三个关键词:波特率、终端电阻、共地。

波特率是CAN通讯的“语速”,通信双方必须完全一致才能对话。松灵底盘默认波特率常见的是500kbps,也就是每秒500000位。波特率不一致的典型表现是candump监听半天什么都收不到,或者收到的全是一堆错帧。判断波特率的方法是用candump加上-t参数,如果完全没有数据,就用ip -details link show can0查看当前设置,逐一尝试常见波特率。

终端电阻同样关键。CAN总线规范要求在总线两端各接一个120欧姆的终端电阻,作用是在总线两端形成阻抗匹配,吸收信号反射。缺了终端电阻,总线上的信号会出现反射,短距离可能看不出问题,线一长就大量丢帧。很多USB转CAN卡内部自带120欧姆电阻,有的还带跳线开关,使用前务必确认。另外松灵底盘本身通常在内部已经处理好了电阻,你只需要确保调试工具那一端匹配就行。

共地问题是最隐蔽的。CAN物理层虽然是差分信号,但两个节点之间如果地电位差太大,依然会导致通讯异常。USB转CAN卡接电脑、底盘电池供电,两边电源系统完全独立时,地电位不定,就容易出现偶发性无响应。最简单的处理是确认转CAN卡与底盘之间在电气上是共地的,很多转接模块自带隔离,就不会有这个问题。

3. 调试环境准备:从Ubuntu到松灵调用包

3.1 系统环境与ROS版本匹配

ROS的版本跟Ubuntu版本严格绑定,这一步错了后面全白搭。先给你一张对应表做参考:

Ubuntu版本ROS1版本ROS2版本
Ubuntu 18.04MelodicDashing
Ubuntu 20.04NoeticFoxy / Galactic
Ubuntu 22.04官方无ROS1Humble

注意松灵官方调用包分ROS1和ROS2两套,仓库名也不同,动手前先确认你装的是哪套。如果你的工控机是Ubuntu 22.04,那ROS1只能在Docker或容器里跑,直接用ROS2的agilex_ros2更省心。如果还是18.04或20.04,用ROS1的Melodic/Noetic完全没问题,生态更成熟,网上案例也多。

3.2 鱼香ROS一键安装与基础工具

ROS本身的安装流程比较长,依赖多,国内网络环境下全量安装也很考验耐心。如果你不想被这些步骤折磨,可以直接用“鱼香ROS一键安装”这个社区方案,我个人在虚拟机、实体机和若干工控机上实测过很多次,稳定可靠。一条命令就能拉起来:

wget http://fishros.com/install -O fishros && . fishros

执行之后跟着交互菜单走,选择安装ROS版本和桌面版或基础版,它会自动配置软件源、装依赖、初始化rosdep。这里有个小提醒:安装过程中会提示是否需要修改源、是否安装rosdep,建议都选是。装完记得source一下环境变量:

echo "source /opt/ros/noetic/setup.bash" >> ~/.bashrc source ~/.bashrc

手上用的版本小尾巴不同,把noetic换成你实际的ROS版本名就行。另外还要用到can-utils这个工具集,它包含cansend、candump、cansniffer等一系列CAN调试命令,后面测试链路全靠它。安装命令一行搞定:

sudo apt install can-utils

还有一个特别容易踩的坑:权限。USB转CAN卡通常会被识别为串口设备或网络设备,操作它们一般需要root权限或者把自己加进dialout组。不加权限的话,后面一执行ip link set can0 up就会报“Operation not permitted”之类的错误。执行下面命令加组,然后注销重新登录一次:

sudo usermod -aG dialout $USER

3.3 获取并编译松灵底盘驱动调用包

环境基本就绪后,下一步就是拉取松灵官方调用包。ROS1一般用agilex_ros,ROS2用agilex_ros2。用git克隆到你的工作空间src目录下:

mkdir -p ~/agilex_ws/src cd ~/agilex_ws/src git clone https://github.com/agilexrobotics/agilex_ros.git

克隆完成后先别急着编译,先看一眼仓库结构和README。通常里面会按底盘系列分几个子包,比如Scout系列对应scout_base,Hunter系列对应hunter_base,还有公共的权限和消息定义。确认你手里底盘的型号在支持列表里,再开始编译。

编译前建议先安装必要的依赖包,否则catkin_make中途报找不到头文件会让人心态爆炸:

sudo apt install ros-noetic-teleop-twist-keyboard ros-noetic-ros-control ros-noetic-ros-controllers ros-noetic-gazebo-ros ros-noetic-rviz

这里的ros-noetic-前缀对应ROS1 Noetic版本,如果你用的是Melodic或者ROS2,就替换成对应的包名。依赖装好后编译:

cd ~/agilex_ws catkin_make source devel/setup.bash

ROS2环境下的编译则一般用colcon build,命令换成:

cd ~/agilex_ws colcon build source install/setup.bash

编译过程中如果出现找不到某个包的错误,八成就是依赖没装全,根据错误提示搜索包名装依赖就行。我当时第一次编译就卡在一个地盘消息依赖上,搜索之后一条apt install命令解决,整个过程并不复杂。

4. 实操全流程:把CAN卡变成can0,把can0变成能跑的车

4.1 挂载CAN卡:让系统识别总线的第一步

先把USB转CAN卡插到工控机的USB口,然后执行以下命令加载SocketCAN相关内核模块:

sudo modprobe can sudo modprobe can_raw sudo modprobe can_dev

加载完模块后,一般就能在系统里看到can0这个网络接口了。有时候CAN卡需要厂家提供的linux驱动和工具,确认一下产品说明书里是否需要额外安装。用以下命令检查接口是否存在:

ip link show can0

如果看到了can0,接下来配置波特率并启动接口。以500kbps为例:

sudo ip link set can0 up type can bitrate 500000

注意这里的500000是bitrate数值,单位bps。有些板卡的驱动支持canfd,指令略有不同。配置成功后再看一次状态:

ip -details link show can0

输出中会包含state UPbitrate 500000之类的信息,说明物理接口已经就绪了。此时最好再确认一下当前工作正常:

dmesg | grep -i can

如果输出中有can0相关的注册信息,说明内核和驱动层面都认了这块卡。我踩过的坑是USB转CAN卡插在前置USB口供电不稳,偶尔掉线,后来换到后置USB口并禁用USB自动休眠才彻底解决。

4.2 裸命令验证:cansend与candump打通链路

接口拉起来后,第一个要做的事不是启动ROS节点,而是用裸命令验证CAN链路通不通。打开两个终端,一个监控总线数据:

candump can0

另一个终端发送一帧测试报文:

cansend can0 001#00

如果链路是通的,并且底盘那边有节点在回复,监控窗口会立刻出现来自底盘状态反馈的报文。这类报文的ID通常是底盘协议表里定义好的状态帧ID。如果监控窗口什么都没打出来,先别怀疑代码,回到第2.3节的问题清单去排查物理层:波特率、接线、终端电阻、共地,逐一确认。

如果只想看看总线上有哪些ID在活动,用cansniffer更直观,它会自动按照ID分组显示报文:

cansniffer can0

这个命令在调试阶段特别好用。底盘上电后,你能在cansniffer里看到一组一组周期出现的ID和对应的数据,对照协议表就能快速确认哪条是状态帧、哪条是轮速反馈。我调试时习惯先整机断电,只开CAN卡自检,确认接收端没数据也没报错,然后上电底盘,这能帮我确认报文的来源到底是底盘发出的,还是总线上其它设备制造的噪音。

4.3 启动底盘驱动节点:话题在哪里,反馈在哪里

链路通了、报文能看到,接下来才轮到调用包登场。先看看包内提供的launch文件,以Scout底盘为例:

roslaunch scout_base scout_base.launch

启动后建议立刻用一组命令确认节点状态:

rosnode list rostopic list

正常情况下,你会看到驱动节点和一组以底盘型号名开头的话题,例如/scout_status/cmd_vel/scout_odom等。注意不同系列话题名会有差异。

验证状态反馈是否在更新,用rostopic hz看频率是最直接的:

rostopic hz /scout_status

如果能看到“average rate”在跳动,比如10Hz或者50Hz,说明驱动节点已经从CAN总线上正确读取了底盘状态并发布到ROS话题了。接着再用echo看一眼具体内容:

rostopic echo /scout_status

你至少应该能看到电池电压、轮速等数值在变化。如果一直是重复的一样的值,甚至没有话题输出,基本可以判断驱动节点没有从CAN收到有效报文,这时候不要疑神疑鬼,回到第4.2步重新检查CAN链路和协议ID映射。

4.4 端到端联调:从cmd_vel到轮子转动

最后一步,也是最激动人心的一步:让车动起来。松灵底盘驱动包一般会订阅标准的/cmd_vel话题,并把它转换成CAN控制帧发到底盘。为了保证安全,第一次测试请把底盘架起来让轮子悬空,或者用箱子垫起来。

先发一个很小的速度指令:

rostopic pub -r 10 /cmd_vel geometry_msgs/Twist '{linear: {x: 0.1, y: 0.0, z: 0.0}, angular: {z: 0.0}}'

这里-r 10表示10Hz周期发送,持续发送而不是只发一次,因为许多底盘的控制实现是“超过一定时间没有收到新指令就自动停车”,这是非常安全的设计。如果轮子开始缓慢转动,恭喜你,整条链路已经打通了。如果不转,先检查两件事:急停开关有没有拉起来,以及底盘是否处于使能状态。

底盘使能这块不同系列差别很大。有些系列上电默认就能通过/cmd_vel控制,有些需要通过一个独立的服务或话题发送使能指令,还有些则是把使能位放在CAN控制指令帧的某个字节里。你一定要去官方协议表里找到“使能”这个字段的定义,单独验证它。我当时的坑就在这儿:官方包跑起来了,/cmd_vel也在发,轮子就是不动,查了半天才想起来急停按钮没复位,拉起急停之后底盘立刻响应。

整车动起来之后,最后再用rviz验证一下底盘的里程计数据:

rosrun rviz rviz -d $(rospack find scout_base)/rviz/scout_base.rviz

如果驱动包自带了URDF模型,你会看到底盘模型在地图上移动,里程计话题在更新。底盘“会跑了”,CAN通讯这个环节才算真正过关。

5. 常见问题与排障实录:我踩过的那些坑

5.1 插上CAN卡但没有can0设备

现象:CAN卡插上后,ip link看不到can0。排查思路先看硬件枚举:dmesg | grep -i usblsusb,确认电脑是否识别到了CAN卡设备。如果dmesg里一点USB插入事件都没有,检查USB线、换一个USB口、看设备指示灯。如果识别到了但没生成can0,大概率是内核模块没加载,回到第4.1节执行modprobe三条命令,或者手动加载厂家提供的驱动模块。还有一种可能是设备被识别成了别的类型,比如ttyUSB的串口设备,那就需要安装厂家原厂驱动并按其说明创建CAN接口。

5.2 can0起来了但candump什么都收不到

这是物理层问题的大本营。优先级最高的是波特率是不是跟底盘一致,用ip -details link show can0看当前bitrate,然后跟协议手册核对。其次是CAN_H和CAN_L有没有接反,我见过不止一次用颜色区分结果厂家线序跟你想的不一样的情况,用万用表蜂鸣档对照针脚定义测一下最稳妥。再就是终端电阻,有些USB转CAN卡侧面有个小开关,出厂默认断开,拨到ON位置才能启用内部电阻。最后是共地问题,在电脑和底盘之间接一根共地线试试。

5.3 驱动节点启动后底盘没有反应

启动驱动节点后rosnode和rostopic都正常,但发cmd_vel时轮子不动。我的排查顺序固定是这样的:先看急停开关状态,很多底盘急停拉下后一切指令都会被忽略,而且急停开关本身在外观上不一定特别显眼;再看使能状态,在协议表找到使能位,用cansend手动把使能位置位;然后用cansend直接发一条控制指令帧,看轮子动不动,这样能区分问题是CAN层还是驱动包层;最后看驱动节点的日志输出里有没有异常报错,比如收到的帧ID不匹配之类。

5.4 状态反馈数据乱跳或长时间不更新

反馈数据乱跳,最常见的原因是字节序搞反了。CAN协议里多字节整数的大小端定义各不相同,同样两个字节01 02,小端解析是0x0201,大端解析是0x0102,数值完全不一样。看协议表时务必注意“字节序”这一栏,很多官方驱动包源码里的解析也最容易在这种地方出错。另一个常见原因是缩放因子没算对,比如协议表说电压原始值要乘以0.1才是实际电压,你直接拿原始值显示,数据当然跟万用表对不上。反馈数据长时间不更新,则多半是底盘的周期上报机制没触发,或者上了急停、进入异常保护模式后底盘暂停了状态上报。

5.5 排障速查表:一套命令定位问题

现象可能原因排查命令/方法
没有can0接口内核模块未加载/驱动未装/USB未识别lsusbdmesgsudo modprobe can can_raw can_dev
接口有但candump静默波特率不一致/接线反/缺终端电阻/未共地ip -details link show can0、万用表测通断
有报文但全是不认识的ID协议不匹配/收到噪声cansniffer can0对照协议表
驱动节点起不来依赖缺失/串口权限不足catkin_make报错信息、sudo usermod -aG dialout $USER
底盘不动急停未解除/未使能/控制ID不对检查急停、协议表使能位、cansend手动控制帧
反馈数据乱字节序错/缩放因子错对照协议表逐字段核对
反馈频率为0驱动卡死/底层报文无回复rostopic hz /scout_statuscandump看CAN层

排查过程中最忌讳同时改多个参数。一次只改一个变量,改完再测,这是我调试所有硬件现场的基本纪律。比如我发现candump收不到数据,就先从波特率开始试,试完波特率再动接线,接线确认没问题再查电阻,一层一层来,不会越调越乱。

写在最后

整个松灵底盘CAN通讯调试走下来,我的最大感受是:80%的时间花在了物理层和协议核对上,真正写代码的时间反而很少。很多人一上来就钻进驱动源码里去查为什么底盘不动,结果发现只是急停没拉起来,或者波特率差了那么一个数字。CAN调试的本质是在跟一条总线对话,工具就那些——candump、cansend、万用表、协议说明书,而耐心和分层排查的思路才是最重要的。

最后分享一个小技巧:调试前在纸上画一张链路图,把CAN卡型号、波特率、底盘型号、控制帧ID、状态帧ID、使能位的字节位置全标出来,贴在工位上。我几次凌晨调底盘卡住,都是靠着这张纸冷静下来,从物理层开始逐项复核,最后找到原因。这比任何花哨的调试工具都管用。

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

ST-GCN骨骼动作识别实战:PyTorch从图构建到训练

简介:基于时空图卷积网络(ST-GCN)的骨骼动作识别毕业设计源码,面向计算机、人工智能相关专业的学生,适用于毕业设计、期末大作业或课程设计。项目从头搭建了完整的动作识别流程,包括常见数据集的预处理、模…

作者头像 李华
网站建设 2026/9/16 2:01:27

结构型设计模式全解析:适配器、装饰器、代理等七大模式实战指南

"结构型设计模式"这六个字,凡是学编程的应该都不陌生。它和创建型、行为型并称设计模式三大类,而结构型这七个模式——适配器、桥接、组合、装饰器、外观、享元、代理——恰恰是日常开发里出场率最高、也最容易让人犯迷糊的一组。我见过太多人…

作者头像 李华
网站建设 2026/9/16 2:00:07

IEEE 754浮点数标准:从0.1精度陷阱到NaN实战避坑指南

1. 这个“浮点数标准”不是教科书里的装饰品,而是你调试崩溃程序时真正能救命的底层逻辑IEEE 754-2008 这串字符,对很多刚接触计算机组成原理或操作系统课程的同学来说,大概率是教材里一个加粗黑体、旁边配着几行晦涩公式和“单精度/双精度”…

作者头像 李华
网站建设 2026/9/16 1:59:56

Android commandlinetools 在 Linux 下的完整使用指南

简介:Android 命令行工具(commandlinetools-linux-13114758-latest.zip)面向 Linux 开发者,适合不想安装完整 Android Studio、又希望通过脚本自动化管理 SDK 组件的场景。压缩包共 108 个文件,大小约 157.13MB&#x…

作者头像 李华
网站建设 2026/9/16 1:59:10

APM32F407 RTC独立应用详解:备份域、时钟源与低功耗唤醒实践

简介:APM32F407实现RTC定时器的完整工程,基于Cortex-M4内核的APM32F4系列单片机,适合需要实时时钟与低功耗唤醒功能的嵌入式开发者直接参考。压缩包共97个文件,主体为46个C源文件与46个头文件,覆盖驱动、BSP、CMSIS与标…

作者头像 李华
网站建设 2026/9/16 1:56:59

Moshi怪兽风Windows光标主题:从设计到开源发布完整教程

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

作者头像 李华