C语言实现银行家算法:从死锁预防到安全状态检测的完整实践
1. 项目概述从“死锁”到“银行家”的思维跃迁在操作系统这门硬核课程里“死锁”绝对是一个让无数初学者头疼也让资深开发者不敢掉以轻心的概念。想象一下你的系统里有几个进程它们都像在玩一个“我等你你等我”的尴尬游戏每个进程都持有部分资源同时又在等待其他进程释放资源结果就是大家全都卡住系统陷入停滞。这可不是什么有趣的游戏而是可能导致服务崩溃的严重问题。而“银行家算法”就是这个困局中一盏智慧的明灯。它得名于银行家发放贷款时评估风险、避免资金链断裂的思维方式被Dijkstra大神巧妙地移植到了计算机资源管理领域。今天我们不谈枯燥的理论推演就聚焦于如何用最经典的C语言亲手把这个精妙的算法从课本搬到屏幕上实现一个能动态模拟进程申请、分配资源并能进行安全性检查的“银行家”。无论你是正在啃《操作系统》这门课的学生想找一份能写在简历上的课程设计还是对并发与资源调度底层逻辑感兴趣的开发者这篇详解都能带你绕过我当年踩过的坑直击核心。我们将从零开始构建数据结构、实现核心函数并最终完成一个可交互的演示程序让你不仅理解算法更能驾驭代码。2. 算法核心思想与数据结构设计2.1 银行家算法的精髓安全状态与避免死锁银行家算法的目标不是检测死锁已经发生而是预防死锁。它的核心思想是在分配资源之前先模拟这次分配然后判断系统是否会进入一个“安全状态”。什么是安全状态简单说就是存在一个“进程执行序列”使得系统能按这个顺序为每个进程分配其所需的全部资源并让它们运行完毕、释放资源而不会导致任何进程因资源不足而永久等待。如果存在这样的序列系统就是安全的这次资源请求就可以批准否则就拒绝请求让进程等待。这个算法基于几个关键假设理解它们对编码至关重要固定数量的资源类型比如系统有A、B、C三类资源每类的总数量是固定的。进程最大需求已知每个进程在运行前会声明自己可能需要的最大资源量。进程可动态申请但必须一次性释放运行中进程可以多次申请资源但结束时会释放所有占用的资源。系统采用“拒绝可能导致不安全状态的分配请求”的保守策略。注意银行家算法是一个保守的、悲观的算法。它为了保证绝对安全可能会拒绝一些实际上并不会导致死锁的请求从而可能降低资源利用率。但在对可靠性要求极高的场景如某些嵌入式系统、金融核心系统这种“宁可错杀不可错放”的策略是值得的。2.2 用C语言构建算法世界数据结构定义要模拟这个算法我们首先需要用C语言的结构体struct和数组来为算法世界“建模”。这是整个项目的基石设计得好后续代码会清晰很多。核心数据结构我们通常需要定义以下全局变量或结构体成员#define PROCESS_NUM 5 // 假设有5个进程 #define RESOURCE_NUM 3 // 假设有3类资源例如A、B、C int Available[RESOURCE_NUM]; // 系统当前可用资源向量 int Max[PROCESS_NUM][RESOURCE_NUM]; // 进程最大需求矩阵 int Allocation[PROCESS_NUM][RESOURCE_NUM]; // 已分配给进程的资源矩阵 int Need[PROCESS_NUM][RESOURCE_NUM]; // 进程还需要的资源矩阵Need Max - Allocation为什么是这四个矩阵/向量Available这是系统的“家底”时刻知道还有多少资源可分配。Max这是进程的“信用额度”它承诺不会超过这个额度来申请。Allocation这是已经“借出去”的资源是既成事实。Need这是进程“还想借”的资源是未来的潜在需求。它不是一个独立存储的数据而是一个衍生量关系式为Need[i][j] Max[i][j] - Allocation[i][j]。在代码中我们通常会在初始化或分配后动态计算它而不是单独维护一个可能不同步的矩阵。设计心得我强烈建议将这几个矩阵封装在一个结构体里比如struct Banker这样代码模块化更好多个函数传参方便也便于管理多个“银行家”实例虽然本项目通常一个就够了。同时可以增加一个int Finish[PROCESS_NUM]数组作为安全性检查时的临时标记记录进程是否已“完成”。3. 核心函数实现与分步拆解有了数据结构接下来就是实现算法的灵魂——两个核心函数安全性检查算法和资源请求算法。3.1 安全性检查算法寻找安全序列这是银行家算法最核心的部分。它的作用是给定当前系统的资源分配状态即AvailableAllocationNeed判断系统是否处于安全状态。算法步骤C语言实现思路初始化两个工作向量Work[RESOURCE_NUM]初始等于Available表示当前可用的资源。Finish[PROCESS_NUM]全部设为0false表示所有进程初始都未完成。循环寻找这样一个进程iFinish[i] 0该进程尚未完成。Need[i]的每一个分量都小于等于Work的对应分量即系统当前资源能满足进程i的全部剩余需求。如果找到这样的进程i假设它获得所需资源运行完毕然后释放资源。在模拟中我们执行Work Work Allocation[i]将进程i占有的资源回收。Finish[i] 1标记进程i完成。然后回到步骤2继续寻找下一个可完成的进程。如果最终所有进程的Finish[i]都变为1说明存在一个安全序列系统处于安全状态函数返回1或true。否则系统不安全返回0。C语言代码片段示例int isSafe(struct Banker *banker) { int work[RESOURCE_NUM]; int finish[PROCESS_NUM] {0}; int safeSeq[PROCESS_NUM]; int count 0; // 1. 初始化Work for (int j 0; j RESOURCE_NUM; j) { work[j] banker-Available[j]; } // 寻找安全序列 while (count PROCESS_NUM) { int found 0; for (int i 0; i PROCESS_NUM; i) { if (!finish[i]) { // 2. 检查Need[i] Work? int canBeSatisfied 1; for (int j 0; j RESOURCE_NUM; j) { if (banker-Need[i][j] work[j]) { canBeSatisfied 0; break; } } // 3. 如果满足模拟进程完成 if (canBeSatisfied) { for (int j 0; j RESOURCE_NUM; j) { work[j] banker-Allocation[i][j]; } safeSeq[count] i; // 记录安全序列 finish[i] 1; found 1; } } } // 4. 如果一轮找完都没找到可完成的进程说明不安全 if (!found) { printf(系统处于不安全状态\n); return 0; } } // 打印安全序列 printf(系统安全安全序列为); for (int i 0; i PROCESS_NUM; i) { printf(P%d , safeSeq[i]); } printf(\n); return 1; }实操要点Need矩阵需要在函数外部提前计算好并传入或者在函数内部根据Max和Allocation临时计算。前者效率高后者逻辑清晰但略有重复计算。安全序列可能不唯一上述算法找到的是其中一种通常依赖于遍历顺序。我们的目标是判断“是否存在”而非找出“所有”。3.2 资源请求算法处理进程的每一次伸手当某个进程P_i提出一个资源请求向量Request[i]时系统不能直接分配必须经过银行家算法的检验。处理流程初步检查如果Request[i]的任何一个分量大于Need[i]的对应分量则报错进程超出了其声明的最大需求。如果Request[i]的任何一个分量大于Available的对应分量则让进程P_i等待资源目前不足。尝试分配假设系统分配资源给P_i修改状态Available Available - Request[i]Allocation[i] Allocation[i] Request[i]Need[i] Need[i] - Request[i]注意这里是“假设”修改最好在临时副本上进行以免后续检查失败导致真实状态被污染。安全性检查调用上面的isSafe()函数检查这个“假设分配”后的新状态是否安全。决策如果安全则正式批准分配将第2步的“假设”修改应用到真实的数据结构上。如果不安全则拒绝本次请求P_i必须等待。并且必须将第2步中修改的临时状态回滚到分配前的状态。C语言代码逻辑框架int requestResources(struct Banker *banker, int process_id, int request[]) { // 1. 初步检查 for (int j 0; j RESOURCE_NUM; j) { if (request[j] banker-Need[process_id][j]) { printf(错误进程P%d申请的资源数超过其声明的最大需求。\n, process_id); return -1; // 错误码 } if (request[j] banker-Available[j]) { printf(提示资源不足进程P%d需等待。\n, process_id); return 0; // 等待 } } // 2. 尝试分配在临时副本上操作 struct Banker temp *banker; // 创建临时副本这是一个关键技巧 for (int j 0; j RESOURCE_NUM; j) { temp.Available[j] - request[j]; temp.Allocation[process_id][j] request[j]; temp.Need[process_id][j] - request[j]; } // 3. 安全性检查 if (isSafe(temp)) { // 4. 安全正式分配 *banker temp; // 将临时状态赋回真实状态 printf(请求被批准资源已分配。\n); return 1; // 成功 } else { // 不安全拒绝请求临时副本会被丢弃自动回滚 printf(请求被拒绝若分配将导致系统进入不安全状态。\n); return 0; // 拒绝 } }踩坑提醒这里最大的一个坑就是状态污染。绝对不能在原数据上直接修改然后发现不安全再试图改回来。因为isSafe()函数内部可能会修改传入的结构体比如我们上面实现的isSafe会修改Finish数组但不会修改核心矩阵。最稳妥的方法就是像上面一样为整个Banker结构体创建一个临时副本temp在副本上操作和检查。如果安全就用副本覆盖原版如果不安全直接丢弃副本即可。这是避免BUG的黄金法则。4. 从零搭建完整的C语言演示程序理解了核心函数我们来搭建一个完整的、可交互的程序。这个程序将包括初始化、状态显示、请求处理等模块。4.1 程序框架与模块划分一个良好的程序结构能让代码更易读、易维护。建议按以下模块组织banker.h头文件包含结构体定义、常量宏、函数声明。banker.c核心算法实现文件包含isSafe(),requestResources(),calculateNeed()等函数。main.c主程序文件负责初始化数据、提供用户界面、调用核心函数。banker.h示例#ifndef BANKER_ALGORITHM_H #define BANKER_ALGORITHM_H #define PROCESS_NUM 5 #define RESOURCE_NUM 3 typedef struct { int Max[PROCESS_NUM][RESOURCE_NUM]; int Allocation[PROCESS_NUM][RESOURCE_NUM]; int Need[PROCESS_NUM][RESOURCE_NUM]; int Available[RESOURCE_NUM]; } Banker; // 函数声明 void initBanker(Banker *b, int max[][RESOURCE_NUM], int allocation[][RESOURCE_NUM], int available[]); void calculateNeed(Banker *b); int isSafe(Banker *b); int requestResources(Banker *b, int pid, int request[]); void printState(const Banker *b); #endif4.2 数据初始化与状态展示在main.c中我们需要一个初始状态。这个状态必须是手工精心设计的最好能同时演示安全和潜在不安全的情况方便测试。初始化示例Banker banker; // 一个经典的安全状态初始数据示例 int max[PROCESS_NUM][RESOURCE_NUM] { {7, 5, 3}, {3, 2, 2}, {9, 0, 2}, {2, 2, 2}, {4, 3, 3} }; int allocation[PROCESS_NUM][RESOURCE_NUM] { {0, 1, 0}, {2, 0, 0}, {3, 0, 2}, {2, 1, 1}, {0, 0, 2} }; int available[RESOURCE_NUM] {3, 3, 2}; initBanker(banker, max, allocation, available); calculateNeed(banker); // 初始化后计算Need矩阵 printState(banker); // 打印当前状态 isSafe(banker); // 检查初始状态是否安全printState函数实现这个函数对于调试和演示至关重要。它应该清晰地以表格形式打印出MaxAllocationNeedAvailable让人一目了然。可以使用printf配合格式控制符%d和制表符\t来对齐。4.3 构建交互式测试循环为了让演示更生动我们可以做一个简单的命令行交互界面。int main() { // ... 初始化代码 ... int running 1; while (running) { printf(\n 银行家算法模拟器 \n); printState(banker); printf(\n请选择操作\n); printf(1. 进程请求资源\n); printf(2. 检查当前安全性\n); printf(3. 退出\n); printf(请输入选项: ); int choice; scanf(%d, choice); switch (choice) { case 1: { int pid; int request[RESOURCE_NUM]; printf(请输入进程号 (0-%d): , PROCESS_NUM - 1); scanf(%d, pid); printf(请输入对%d类资源的请求量 (用空格隔开): , RESOURCE_NUM); for (int j 0; j RESOURCE_NUM; j) { scanf(%d, request[j]); } int result requestResources(banker, pid, request); // 根据result输出不同信息 break; } case 2: isSafe(banker); break; case 3: running 0; break; default: printf(无效选项\n); } } return 0; }5. 深度调试、边界案例与性能思考5.1 常见问题与调试技巧在实现过程中你肯定会遇到各种问题。以下是我总结的几个常见坑点和调试方法数组越界这是C语言永恒的主题。确保所有循环的边界是PROCESS_NUM和RESOURCE_NUM而不是魔法数字。使用sizeof(array)/sizeof(array[0])来计算数组长度有时在函数内会失效因为数组退化为指针所以最好用明确的常量或变量传递大小。状态不一致Need矩阵必须等于Max - Allocation。在每次分配(Allocation增加)或释放项目结束时后必须同步更新Need和Available。一个最佳实践是永远不要直接修改Need而是通过一个calculateNeed()函数根据最新的Max和Allocation重新计算整个Need矩阵。这能保证数据一致性。安全性算法死循环确保你的isSafe()函数在找不到可完成进程时能正确跳出循环并返回“不安全”。检查found标志的逻辑是否正确。请求算法中的回滚问题如前所述使用临时副本是最安全的方式。如果为了效率想直接在原数据上操作务必在调用isSafe()前备份关键数据并在检查失败后精确还原。调试建议在关键函数入口和出口打印状态信息。例如在requestResources中打印传入的请求、尝试分配后的临时状态、安全性检查结果等。这比单纯用调试器单步跟踪更直观。5.2 设计边界测试案例一个好的演示程序需要能处理各种边缘情况。你应该设计以下几类测试数据测试案例目的预期结果正常安全请求进程请求资源后系统仍处于安全状态。请求被批准打印安全序列。导致不安全的请求进程请求资源后系统进入不安全状态。请求被拒绝状态回滚。请求超过最大需求进程申请的资源数超过其声明的Max。直接报错不进行安全性检查。请求超过当前可用进程申请的资源数超过Available。提示资源不足进程等待。多个进程并发请求模拟连续处理多个请求。系统应能正确处理序列保持状态一致。你可以通过修改main函数中的初始化数据来构造这些案例。例如构造一个Available资源极少的状态很容易触发不安全请求。5.3 算法局限性与扩展思考实现完基础版本后我们可以更进一步思考局限性需要预知最大需求在实际动态系统中进程很难预先准确知道自己的最大资源需求。进程数量与资源种类固定算法假设进程数和资源种类在运行期间不变这限制了其灵活性。开销较大每次资源请求都要进行O(n*m)时间复杂度的安全性检查n为进程数m为资源种类数在高并发场景可能成为性能瓶颈。资源利用率可能降低因为保守策略而拒绝的请求可能延缓了任务的完成。可能的扩展方向课程设计加分项动态进程管理实现进程的创建与终止。终止时需要将该进程持有的所有资源归还给Available并将其从各个矩阵中移除或标记为无效。文件I/O将系统的初始状态矩阵数据从配置文件或文本文件中读入将运行日志写入文件。图形化界面使用如GTK、Qt甚至简单的Web前端配合C后端来可视化展示矩阵的变化、安全序列的寻找过程这将大大提升演示效果。与真实调度器结合在一个简单的模拟操作系统中将银行家算法作为其资源管理模块与进程调度器如轮转调度联动。通过这个从理论到代码从核心到边界的完整实践你收获的不仅仅是一个“银行家算法”的C程序更是一种将复杂算法转化为可靠代码的系统性思维和能力。这种能力在你未来面对任何需要精密逻辑控制的系统编程任务时都将是一笔宝贵的财富。最后一个小建议试着把你的代码放到GitHub上写一个清晰的README这本身就是一次非常好的工程实践。

相关新闻

35岁转行学什么技术?别盲目跟风,选对赛道少走几年弯路

35岁转行学什么技术?别盲目跟风,选对赛道少走几年弯路

今日头条首发不少人在步入35岁之际, 会身处于职场那种进退维谷的困局之中。从事原本的工作, 未来的职业发展一目了然, 升职空间愈发限缩不爽;想着转换门庭, 却忌惮岁数渐长, 学习能力跟不上趟, 家中又有房贷需偿还, 孩子要抚育, 老人得照料, 根本输不起, 没胆量随意…

2026/10/18 12:13:51 阅读更多 →
数据结构-双向循环链表

数据结构-双向循环链表

双向循环链表(哨兵位)踩坑全复盘:从运行崩溃到接口统一 写在前面 在学习完单链表之后,我继续实现了带哨兵位的双向循环链表。相比单链表,双向链表的每个节点多了一个 prev 指针,可以同时向前、向后访问&a…

2026/10/17 4:26:41 阅读更多 →
Nemotron 3.5 Lightning + NeMo Switchyard深度解析:30B MoE仅3B活跃参数,从蒸馏到路由的AI Agent执行层新范式

Nemotron 3.5 Lightning + NeMo Switchyard深度解析:30B MoE仅3B活跃参数,从蒸馏到路由的AI Agent执行层新范式

引言:当AI Agent的"执行层"成为瓶颈 2026年8月11日,NVIDIA发布了Nemotron 3.5 Lightning开源模型和NeMo Switchyard模型路由库。这不是一次普通的模型发布——它直指当前AI Agent系统中最痛的一个问题:执行层成本。 任何一个长期运行的AI Agent,其生命周期中的…

2026/10/17 1:14:05 阅读更多 →

最新新闻

Python目录扫描工具dirsearch:从socket原理到自动化巡检实践

Python目录扫描工具dirsearch:从socket原理到自动化巡检实践

简介:Dirsearch-master 是基于Python开发的目录扫描工具,面向渗透测试人员、安全初学者及Web开发者,用于快速发现网站潜在的敏感目录和隐藏文件。工具支持HTTP、HTTPS与SOCKET等多协议扫描,可自定义字典、多线程并发、递归探测子目…

2026/10/18 12:13:19 阅读更多 →
STM32+LwIP+CLion实现DHCP热插拔稳定联网

STM32+LwIP+CLion实现DHCP热插拔稳定联网

简介:本资源是一套基于STM32F407ZGTx的嵌入式以太网开发完整工程,面向具备HAL库和CMake基础的中级嵌入式开发者,解决STM32在Clion环境下集成LWIP协议栈并实现DHCP动态获取IP及网线热插拔检测的实际工程难题。项目涵盖STM32CubeMX图形化配置&a…

2026/10/18 12:13:19 阅读更多 →
AI知识库+RAG:让技术写作告别断更的实战方法

AI知识库+RAG:让技术写作告别断更的实战方法

你有没有过这种体验:代码写得很顺,但一到写技术文章、做分享总结的时候就反复卡壳,标题改了七八遍,正文只写了一小段,最后只能憋出一句“最近太忙了,下周补上”。我身边写技术博客的开发者,十有…

2026/10/18 12:13:19 阅读更多 →
用AI知识库解决写作断更:RAG语义检索实战指南

用AI知识库解决写作断更:RAG语义检索实战指南

先问一个扎心的问题:你是不是也经历过这种“素材明明攒了一大堆,真到写的时候还是卡住”的瞬间?写技术博客想引用那篇文档,明明看过就是翻不到;想复盘某次线上故障排查,转头忘了当年是怎么定位的&#xff1…

2026/10/18 12:13:19 阅读更多 →
BiLSTM-CRF序列标注原理与调参实战:工单实体抽取全记录

BiLSTM-CRF序列标注原理与调参实战:工单实体抽取全记录

开头先交代背景。几个月前我接了一个文本抽取类的需求,需要从工单数据里把设备名称、故障类型、处理措施这三类实体抽出来。看过一圈方案,最后选了BiLSTM-CRF做序列标注。选它的理由很直接:效果在评测集上够用,部署成本比预训练大…

2026/10/18 12:13:19 阅读更多 →
Agent Infra全解析:从智能体基础设施到工程实践

Agent Infra全解析:从智能体基础设施到工程实践

前几年聊AI,所有人都在盯着模型的参数规模和榜单分数,一个模型发布,刷屏的是几个基准测试的涨点。但从去年下半年开始,我明显感觉到风向变了。身边做产品的朋友,不再张口闭口“换个大模型”,而是开始问一些…

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

日新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

月新闻

我发现了一个新思路:用 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/18 0:55:02 阅读更多 →
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/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练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/16 14:23:36 阅读更多 →