snap7与MFC工业上位机开发:S7通信集成、线程模型与稳定性实战
1. 为什么还要折腾 snap7 与 MFC 这套组合工业上位机这个圈子有个很拧巴的现实新项目都在喊 OPC UA、MQTT、RESTful但真到了车间现场你面对的大概率还是一台跑了十来年的西门子 S7-1200 或者 S7-300网口就在那协议就是 S7 通信。这时候你想用 C 写个带界面的采集工具绕不开的一个库就是snap7。它是个开源的 S7 协议通信库跨平台Windows 上给的是静态库加动态库接口是纯 C 风格稳定、轻量、不依赖一堆运行时。而 MFC 呢很多人一听就皱眉觉得是上个时代的产物。但你在工业现场待久了会发现MFC 做上位机反而有它的道理编译出来是原生 exe不依赖 .NET 运行时拷到工控机上双击就能跑对话框资源编辑器拖控件快跟 Win32 API 贴得近串口、网口、多线程这些底层操作顺手。所以“snap7 MFC”这个组合在中小型数据采集、设备监控类项目里至今仍然是一套非常务实的方案。这篇笔记就是把我自己在实际项目里踩过的坑、验证过的写法整理出来。核心解决三件事怎么把 snap7 正确集成进 MFC 工程、怎么用后台线程稳定读写 PLC 而不卡界面、怎么处理连接断开、数据错位这些现场高频故障。适合已经会一点 C、能看懂 MFC 对话框工程、但第一次接触 S7 通信的开发者如果你已经用过 snap7 但总在稳定性上翻车里面的线程模型和排查表也值得对照看看。需要先说明一点下面涉及的具体 IP、机架号、数据块编号都是示例实际项目里要按你的设备组态来填。所有代码基于 snap7 官方库的通用接口不涉及任何特定厂商的私有封装。2. 整体方案设计与选型思路拆解2.1 为什么是 snap7 而不是别的通信方式先把这个前提讲清楚不然容易走弯路。跟 S7 系列 PLC 通信常见路子有这么几条走 OPC 服务器中转、用西门子官方提供的通信库、或者直接基于 S7 协议自己实现。OPC 方案要多装一个服务器软件工控机上多一个常驻进程出问题时排查链路变长官方库往往绑定特定开发环境授权和部署都麻烦。snap7 的好处是它把 S7 协议封装成了几十个 C 函数你链接一个库文件就能直接跟 PLC 的存储区对话中间没有额外进程部署就是拷一个 dll 过去。从架构上看snap7 支持两种角色客户端Client主动去读写 PLC服务端Server模拟一个 PLC 让别人来读。上位机采集场景里我们用的基本都是Client 模式。它内部维护一条 TCP 连接你调用Cli_Connect建立会话之后每次Cli_ReadArea/Cli_WriteArea就是在这条连接上收发数据包。理解这一点很关键连接是有状态的断了之后所有读写都会失败必须重连这也是后面稳定性处理的核心。2.2 MFC 工程里怎么摆放 snap7集成方式我试过两种最后固定用第二种。第一种是把 snap7 的源码直接拖进工程编译好处是调试能跟进库内部坏处是它有一堆平台相关的宏混在 MFC 的预编译头里容易打架编译告警一堆。第二种是用官方预编译好的 lib dll工程里只包含头文件、链接 lib、运行时带上 dll。这种方式干净升级库也方便我推荐这个。具体到文件层面你需要三样东西snap7.h头文件声明所有接口、snap7.lib导入库链接用、snap7.dll运行时动态库。注意 32 位和 64 位要跟你的 MFC 工程配置一致混用会直接链接报错。MFC 工程默认可能是 32 位而现在的工控机基本都是 64 位系统这里要提前想好如果你的工程还要调用别的 32 位老库那就统一 32 位如果没这个包袱建议直接上 64 位。2.3 线程模型为什么绝不能在 UI 线程里读写这是新手最容易犯的错也是界面卡死的元凶。S7 通信是网络操作一次读写从几十毫秒到几秒不等如果 PLC 响应慢或者网络抖动你在按钮响应函数里直接调Cli_ReadArea整个界面就冻住了用户点什么都没反应Windows 还会给你标个“未响应”。所以正确的做法是UI 线程只管界面通信放在独立的工作线程里跑。我的做法是开一个专门的采集线程用AfxBeginThread或者std::thread都行线程里循环执行“读数据 → 通过自定义消息 PostMessage 给主窗口 → 主窗口更新控件”。这里有个细节工作线程绝对不能直接操作 MFC 控件MFC 的控件对象不是线程安全的跨线程直接调用轻则显示错乱重则崩溃。必须用PostMessage把数据指针传回 UI 线程让 UI 线程自己去更新。这个规矩记住能省掉你后面一大半的诡异 bug。3. 核心细节解析与实操要点3.1 连接参数的几个关键字段Cli_Connect之前要填一个TS7Client结构里面几个字段必须跟 PLC 组态对上错一个就连不上。我列个表把每个字段的含义和常见取值说清楚。字段含义常见取值填错的表现AddressPLC 的 IP 地址按现场实际连接超时Rack机架号S7-1200/1500 通常为 0连接被拒绝Slot槽号S7-1200 通常为 1S7-300 常为 2连接被拒绝LocalTSAP本地端点一般留空自动分配少见问题RemoteTSAP远端端点一般留空由 Rack/Slot 推导连接被拒绝Rack 和 Slot 这两个是最容易搞混的。S7-1200 和 S7-1500 的 CPU 是集成式的槽号一般是 1而 S7-300 这种模块化的CPU 通常插在 2 号槽。如果你连不上又确认 IP 没问题八成是这两个值填错了。实测下来先试 Rack0、Slot1不行再试 Rack0、Slot2基本能覆盖大部分机型。3.2 读写区域的地址换算snap7 读写用的是“区域 起始字节 字节数”的方式区域常量有S7AreaPE输入、S7AreaPA输出、S7AreaMK位存储区、S7AreaDB数据块。这里有个坑DB 块的编号是单独传的不是拼在地址里的。比如你要读 DB100 从第 0 字节开始的 10 个字节调用是这样的byte buffer[10]; int result Cli_ReadArea(client, S7AreaDB, 100, 0, 10, S7WLByte, buffer);参数依次是客户端句柄、区域类型、DB 编号、起始字节、读取长度、数据单位、接收缓冲区。S7WLByte表示按字节读如果要读位或者字还有S7WLBit、S7WLWord等。新手常犯的错是把 DB 编号当成起始地址的一部分写成Cli_ReadArea(client, S7AreaDB, 0, 100, 10, ...)结果读出来全是零或者直接报错。3.3 返回值必须逐个判断snap7 的函数几乎都返回一个int0 表示成功非 0 是错误码。我见过太多人写完Cli_ReadArea(...)就不管返回值了然后数据不对就到处找原因。每一个通信调用后面都要判断返回值失败时用Cli_ErrorText把错误码转成文字打到日志里。这一步在调试期能帮你省下大量时间。int res Cli_ReadArea(client, S7AreaDB, 100, 0, 10, S7WLByte, buffer); if (res ! 0) { char errText[256]; Cli_ErrorText(res, errText, sizeof(errText)); // 把 errText 写进日志 }提示错误码里最常见的是连接类错误比如 0x00010000 系列一旦出现这类错误说明连接已经不可用了不要继续重试读写应该走重连流程。3.4 数据字节序的处理S7 的数据是大端序高字节在前而 x86 的 PC 是小端序。如果你读的是一个 16 位整数直接memcpy到short里数值是反的。处理方式有两种一是读回来之后手动交换高低字节二是用 snap7 提供的S7_GetWord、S7_GetDWord这类辅助函数它们内部帮你做了字节序转换。我一般用后者省心。但要注意如果你读的是一整块结构体里面字段类型混杂那就得自己按偏移量逐个字段用辅助函数取值不能整块强转。4. 实操过程与核心环节实现4.1 工程配置从零把 snap7 接进来假设你已经有一个 MFC 对话框工程下面按顺序操作。第一步把snap7.h放到工程目录或者一个 include 目录下在stdafx.h或者主对话框的 cpp 里#include snap7.h。第二步把snap7.lib放到工程目录在项目属性 → 链接器 → 输入 → 附加依赖项里加上snap7.lib同时在链接器 → 常规 → 附加库目录里加上 lib 所在路径。第三步把snap7.dll拷到你的输出目录Debug/Release 下或者系统目录保证运行时能找到。这里有个细节Debug 和 Release 用的 lib 可能不一样有些库会区分调试版和发布版。如果链接时报“无法解析的外部符号”先检查是不是把 Release 的 lib 用在了 Debug 配置里。另外如果你的工程开了“使用 Unicode 字符集”而 snap7 的接口是 ANSI 的传字符串参数时要注意转换不过 snap7 的接口里字符串参数很少主要是错误文本用char数组接收就行。4.2 建立连接与断线重连的完整逻辑连接不是一次性的要封装成一个可以反复调用的函数。我的做法是维护一个连接状态标志采集线程每次循环先检查状态没连上就尝试连连上了才读写。bool ConnectPLC(TS7Client* client, const char* ip, int rack, int slot) { if (Cli_GetConnected(client)) return true; // 已经连着就别重复连 int res Cli_ConnectTo(client, ip, rack, slot); if (res 0) { // 连接成功可以设置一些超时参数 Cli_SetConnectionParams(client, 0, 0); // 用默认本地端点 return true; } return false; }重连的节奏要控制好不能失败了一次就疯狂重试那样会把 CPU 占满。我的经验是失败后 sleep 一到两秒再试连续失败多次可以适当拉长间隔。另外Cli_GetConnected这个函数判断的是库内部记录的连接状态有时候网络断了但库还不知道所以更可靠的做法是以读写操作的返回值为准一旦读写返回连接类错误就主动Cli_Disconnect再重连。4.3 采集线程的骨架写法线程函数是整个采集逻辑的心脏我把它拆成“连接 → 读 → 投递 → 睡一会”四步循环。下面是一个简化但可直接参考的骨架。UINT CollectThread(LPVOID pParam) { CMainDlg* pDlg (CMainDlg*)pParam; TS7Client* client new TS7Client(); byte buffer[64]; while (pDlg-m_bRunning) { if (!ConnectPLC(client, 192.168.0.1, 0, 1)) { Sleep(2000); continue; } int res Cli_ReadArea(client, S7AreaDB, 100, 0, 64, S7WLByte, buffer); if (res 0) { // 把数据拷到堆上PostMessage 传指针给 UI 线程 byte* pData new byte[64]; memcpy(pData, buffer, 64); pDlg-PostMessage(WM_DATA_READY, (WPARAM)pData, 0); } else { // 读失败断开重连 Cli_Disconnect(client); } Sleep(200); // 采集周期按需调整 } delete client; return 0; }这里有几个要点。第一buffer是栈上的PostMessage 之后函数就返回了所以必须new一块堆内存把数据拷过去UI 线程处理完再delete否则就是悬空指针。第二Sleep(200)是采集周期200 毫秒就是每秒 5 次你要根据实际需求调别设太小把 PLC 通信资源占满。第三循环条件m_bRunning是个成员变量窗口关闭时置 false线程就能自然退出避免程序关了线程还在跑。4.4 UI 线程接收数据并刷新界面主对话框里要加一个消息处理函数响应自定义消息WM_DATA_READY。在这个函数里把传过来的字节数组解析成实际数值更新到编辑框或者列表控件上。LRESULT CMainDlg::OnDataReady(WPARAM wParam, LPARAM lParam) { byte* pData (byte*)wParam; // 假设前两个字节是一个 16 位整数 short value S7_GetWord(pData, 0); CString str; str.Format(_T(%d), value); SetDlgItemText(IDC_EDIT_VALUE, str); delete[] pData; // 记得释放 return 0; }消息映射别忘了在BEGIN_MESSAGE_MAP里加ON_MESSAGE(WM_DATA_READY, OnDataReady)。这个模式跑通之后你要加多少个采集点无非就是在缓冲区里多解析几个偏移量的事框架不用动。5. 常见问题与排查技巧实录5.1 连接类问题速查连接不上是最高频的问题我整理了一张排查表按顺序过一遍基本能定位。现象可能原因排查方法连接超时IP 不通先用 ping 确认网络可达连接被拒绝Rack/Slot 填错换 Slot1 或 2 再试连接被拒绝PLC 未允许 PUT/GET检查 PLC 组态里的通信访问设置时连时断网线或交换机问题换线、换端口测试连上后立刻断连接数超限确认没有别的程序占着连接其中“PLC 未允许 PUT/GET”这一条特别隐蔽。有些 PLC 出厂默认关闭了通过通信访问数据的功能你在上位机这边怎么调都连不上必须去 PLC 的硬件组态里把对应选项打开。这个坑我在项目里遇到过两次第一次查了大半天。5.2 数据读出来不对怎么办数据不对分几种情况。如果读出来全是零先确认 DB 块编号和起始地址对不对再确认 PLC 里那个地址是不是真的有数据。如果读出来数值明显偏大或偏小多半是字节序问题用S7_GetWord这类函数重新取值。如果读出来偶尔对偶尔错那可能是采集周期太快上一次读还没返回你就发了下一次或者缓冲区被覆盖了检查线程里有没有对同一块 buffer 并发操作。还有一种情况是读的字节数超过了 DB 块实际大小snap7 会返回错误但错误码不一定直观。这时候把读取长度调小一点试试能读通就说明是越界了。5.3 界面卡顿与内存泄漏界面卡顿基本就是通信跑在了 UI 线程或者 PostMessage 发得太频繁。前者把通信挪到工作线程后者把采集周期调大或者做批量更新别每读一个点就刷一次界面。内存泄漏则多半是new了数据没delete尤其是 PostMessage 传指针这种模式UI 线程处理完一定要释放。我建议在调试期用任务管理器盯着进程的内存占用如果持续上涨基本就是泄漏。注意工作线程退出前要确保所有 PostMessage 出去的内存都被处理了否则窗口销毁后消息还在队列里处理函数访问已销毁的控件会崩溃。稳妥做法是窗口关闭时先置停止标志等线程真正退出后再销毁窗口。5.4 几个我踩过的坑第一个坑是在对话框构造函数里就启动线程这时候窗口还没创建完PostMessage 过去控件还不存在直接崩。正确时机是在OnInitDialog里启动。第二个坑是忘记处理线程退出程序关了但线程还在跑任务管理器里进程残留。第三个坑是把 snap7 的客户端对象定义成局部变量函数一返回对象就析构了连接也跟着断必须定义成成员变量或者堆对象生命周期覆盖整个采集过程。6. 关于稳定性的一点个人经验这套 snap7 加 MFC 的方案我在几个中小型采集项目里都用过跑几个月不重启是能做到的但前提是把重连逻辑和线程模型写扎实。我的体会是工业上位机这东西功能实现只占三成剩下七成都在处理异常网络会抖、PLC 会重启、工控机会休眠你的程序得能扛住这些。所以别嫌重连代码啰嗦别嫌返回值判断烦这些恰恰是现场稳定运行的保障。另外提一句snap7 的官方文档虽然不算厚但每个函数的参数说明都值得仔细看一遍尤其是错误码那部分。很多问题其实文档里写了只是我们习惯性地跳过。真遇到卡壳的时候把错误码转成文字打出来往往一眼就能看出方向。

相关新闻

Claude Projects与Artifacts高阶玩法:搭建专属大脑工作流

Claude Projects与Artifacts高阶玩法:搭建专属大脑工作流

1. 从“聊天窗口”到“专属大脑”的认知转变大多数人第一次接触 Claude 这类对话式 AI,习惯把它当成一个更聪明的搜索引擎:问一句,答一句,关掉页面,下次重新开始。这种用法当然没错,但它只发挥了这套工具不…

2026/10/12 14:32:57 阅读更多 →
SpringBoot+Vue大学生迎新系统毕设项目全解析:从架构到部署

SpringBoot+Vue大学生迎新系统毕设项目全解析:从架构到部署

每年到了毕业季,总有一批同学为Java Web毕设发愁。很多人的需求其实很一致:要一套技术栈够主流、功能看得过去、能跑通前后端联调、最好带数据库脚本和接口文档的完整项目。我这次整理的SpringBootVue大学生迎新系统平台,就是把"毕设完整…

2026/10/12 14:31:55 阅读更多 →
obsidian-yolo 接入 OpenAI / Claude / Gemini 全指南:多模型提供商与 OAuth 登录怎么配

obsidian-yolo 接入 OpenAI / Claude / Gemini 全指南:多模型提供商与 OAuth 登录怎么配

【免费下载链接】obsidian-yolo Agent-native AI assistant — chat, write, whiteboard, learning, all in one. 项目地址: https://gitcode.com/gh_mirrors/ob/obsidian-yolo 点击查看 免费下载 obsidian-yolo(简称 YOLO)是一个 Agent-nat…

2026/10/12 14:31:55 阅读更多 →

最新新闻

2026年亲测AI论文平台榜单(合规高效版):把Base URL改到TaoToken的实测记录

2026年亲测AI论文平台榜单(合规高效版):把Base URL改到TaoToken的实测记录

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

2026/10/12 19:48:04 阅读更多 →
AI原生全栈开发训练营:从写代码到编排AI的实战指南

AI原生全栈开发训练营:从写代码到编排AI的实战指南

最近总有人问我:AI时代还要不要学全栈开发?我的回答很直接——要学,但不能再用原来那套学法。我自己做了好几年传统前后端,后来把AI能力嵌进产品,也在带一个叫“AI原生超级全栈开发工程师训练营”的项目。这个训练营的…

2026/10/12 19:48:04 阅读更多 →
企业级AI编程软件必看:2026最新8款权威工具深度推荐与TaoToken统一接入实践

企业级AI编程软件必看:2026最新8款权威工具深度推荐与TaoToken统一接入实践

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

2026/10/12 19:48:04 阅读更多 →
小白也能轻松玩转龙虾:虾壳云一键部署 OpenClaw 桌面快速安装与 TaoToken 通道配置

小白也能轻松玩转龙虾:虾壳云一键部署 OpenClaw 桌面快速安装与 TaoToken 通道配置

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

2026/10/12 19:48:04 阅读更多 →
【异常】解决 Windows PowerShell 无法识别 ‘claude‘ 命令的实战指南:把 PATH 改到 TaoToken 的排查路径

【异常】解决 Windows PowerShell 无法识别 ‘claude‘ 命令的实战指南:把 PATH 改到 TaoToken 的排查路径

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

2026/10/12 19:48:03 阅读更多 →
电商商品分类库实战:从类目映射到数据清洗的完整指南

电商商品分类库实战:从类目映射到数据清洗的完整指南

简介:这份商品分类库面向电商平台搭建者、零售运营人员及ERP/CRM系统开发者,提供一套可直接落地的货品分类数据方案,解决新建商城或系统升级时分类体系从零构建耗时费力的问题。资源包共1个文件,为SQL脚本类型,压缩包约…

2026/10/12 19:47:03 阅读更多 →

日新闻

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

在数码相机、高清显示屏与现代矢量图形技术高度发达的今天,画面可以做到绝对的锐利、平滑与无瑕。然而,当一张秋日手账插画或拍立得照片过于“平整无瑕”时,往往会散发出一种冰冷生硬的“数码塑料感(Digital Plasticity&#xff0…

2026/10/12 0:00:59 阅读更多 →
活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

在现代网页与移动端设计中,横排(Horizontal Layout)早已经成为了绝对的主流。然而,当我们翻开泛黄的线装古籍、宋版木刻诗集,或是欣赏一张茶道雅集的手写便签时,那种**自上而下纵向书写、自右向左逐列铺展&…

2026/10/12 0:00:59 阅读更多 →
周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

每到周日的晚上八点到十点,很多人心里都会悄悄亮起一盏警示灯。 在心理学上,这种现象有一个专门的称谓——“周日夜晚焦虑症(Sunday Scaries)”。明天又是周一,闹钟又要重新在七点响彻卧房;脑海里仿佛有一个…

2026/10/12 0:00:59 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/12 0:16:30 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/12 0:16:43 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/11 10:45:37 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/12 18:11:17 阅读更多 →