500行C语言实现解释器:词法语法求值全流程拆解
简介这是一份面向编译器与解释器入门学习者的微型C语言解释器实现资源适合具备C语言基础、希望理解词法分析、语法分析与执行流程的开发者练手。资源围绕「用C语言在500多行内实现解释器」展开涉及编译器设计基础、标记识别、抽象语法树构建与运行时执行等核心概念帮助读者在精简代码中体会解释器的工作原理。压缩包共10个文件约25KB包含1个核心C源码文件、1个示例脚本、1个许可证文件以及7个Markdown说明文档文档用于分章节讲解实现思路与关键步骤源码则给出可直接阅读的完整实现。目前已有230人学习下载。通过阅读源码与配套笔记读者可掌握词法分析、语法分析、语义检查与执行模块的协作方式并借鉴在有限行数内兼顾可读性与效率的编码技巧为后续深入学习编译原理打下实践基础。1. 从一份 500 行的 C 源码说起为什么值得花一晚上拆它很多人第一次接触「解释器」这个词脑子里浮现的是几百兆的 LLVM 或者动辄上万行的 CPython 源码觉得那是编译器方向研究生才碰的东西。但 tryC 这个项目反着来它用 C 语言在 500 多行里塞进了一个能跑的解释器词法分析、语法分析、AST 求值一条龙没有依赖任何第三方库一个gcc就能编出来。我第一次看到这个体量是有点怀疑的——500 行能干什么跑个11就完事实际拆下来发现它支持变量声明、算术表达式、比较运算、if/else、while循环甚至还有函数调用。对于想搞明白「一门语言从字符串到执行结果中间到底发生了什么」的人来说这份源码的性价比极高它把编译器前端最核心的那条链路完整暴露出来没有宏展开地狱没有模板元编程全是直白的 C 结构体和指针操作。适合谁写过 C、知道指针和结构体怎么回事、但从来没自己动手实现过解释器的开发者。如果你正好在搜 c interpreter、compiler design 相关的练手项目这个规模刚刚好——大到能跑真逻辑小到一个周末能逐行读完。2. 解释器的三段式骨架词法、语法、求值怎么串起来2.1 从字符流到 Token 流词法分析器在做什么解释器接收的输入是一串字符比如let x 10 20;。计算机不认识「let」是什么意思第一步就是把这串字符切成有意义的单元也就是 Token。tryC 的词法分析器逻辑很朴素一个指针从头扫到尾遇到空格和换行跳过遇到数字就连续读取直到非数字字符遇到字母就连续读取直到非字母数字字符然后拿这个单词去查关键字表——是let、if、while就标记为对应关键字类型否则就是标识符。运算符和括号直接单字符映射。这个过程听起来简单但有几个设计决策值得注意。第一Token 用什么数据结构存tryC 用的是固定大小的结构体数组每个 Token 包含类型枚举和一个字符串值。固定大小意味着有上限但对于教学项目来说够用而且避免了动态内存管理的复杂度。第二数字的解析怎么处理它用strtol把连续数字字符转成long类型这意味着它只支持整数运算没有浮点。第三关键字识别用的是线性扫描一个静态数组不是哈希表——在关键字只有七八个的场景下线性扫描反而更快因为没有哈希计算开销。我一般会建议在读这段代码时重点关注 Token 类型枚举的设计。tryC 的枚举大概长这样typedef enum { TOKEN_LET, // let 关键字 TOKEN_IF, // if 关键字 TOKEN_WHILE, // while 关键字 TOKEN_IDENT, // 标识符变量名、函数名 TOKEN_NUMBER, // 整数字面量 TOKEN_PLUS, // TOKEN_MINUS, // - TOKEN_STAR, // * TOKEN_SLASH, // / TOKEN_EQ, // TOKEN_ASSIGN, // TOKEN_LPAREN, // ( TOKEN_RPAREN, // ) TOKEN_LBRACE, // { TOKEN_RBRACE, // } TOKEN_SEMI, // ; TOKEN_EOF // 输入结束 } TokenType;这个枚举就是整个解释器的「字母表」。后面语法分析器的每一个分支判断都是基于这些类型。如果你要扩展这门语言比如加一个for循环第一步就是在这里加TOKEN_FOR然后在词法分析器的关键字表里注册for最后在语法分析器里加对应的解析分支。这条扩展路径非常清晰也是 tryC 适合练手的原因之一。2.2 递归下降解析把 Token 流变成 AST拿到 Token 流之后下一步是理解这些 Token 之间的结构关系。10 20 * 30和(10 20) * 30的 Token 序列不同但更重要的是运算符优先级——乘法必须比加法先算。tryC 用的是递归下降解析Recursive Descent Parsing这是手写解析器最常用的方法核心思想是每个语法规则对应一个函数函数之间互相调用调用层次自然体现了优先级。具体来说tryC 的表达式解析分了三层。最底层是primary()负责解析最基本的单元数字字面量、变量名、括号表达式。中间层是term()负责处理乘除法——它先调primary()拿到左操作数然后看下一个 Token 是不是*或/如果是就继续调primary()拿右操作数构建一个二元运算节点。最上层是expr()负责处理加减法逻辑和term()一样只不过操作符是和-。这个层次结构就是优先级的具体实现expr()调term()调primary()越深的函数绑定越紧。当你解析10 20 * 30时expr()先调term()term()解析出10之后发现下一个是不是*就返回10然后expr()看到再调term()这次term()解析出20之后看到*继续调primary()拿到30构建20 * 30的节点返回最后expr()构建10 (20 * 30)的节点。整个优先级关系不需要任何优先级表完全由函数调用栈体现。AST 节点的数据结构通常是这样的typedef struct ASTNode { NodeType type; // 节点类型数字、变量、二元运算、赋值等 long value; // 如果是数字节点存字面量值 char name[64]; // 如果是变量节点存变量名 TokenType op; // 如果是运算节点存运算符类型 struct ASTNode *left; // 左子节点 struct ASTNode *right; // 右子节点 struct ASTNode *body; // 语句块或循环体 struct ASTNode *next; // 语句序列中的下一条 } ASTNode;这个结构体是「万能节点」的设计——所有类型的节点共用同一个结构用type字段区分用不到的字段就空着。这种设计在小型解释器里很常见优点是内存管理简单统一malloc和free缺点是有点浪费空间。但对于 500 行的项目来说可读性比内存效率重要得多。2.3 树遍历求值环境表与变量作用域AST 构建完成之后最后一步是遍历这棵树并计算结果。tryC 的求值器是一个递归函数eval()接收一个 AST 节点和一个环境表Environment返回计算结果。环境表本质上就是一个从变量名到值的映射tryC 用的是最简单的实现一个固定大小的数组每个元素存一个名字和一个long值查找就是线性扫描。typedef struct { char names[256][64]; // 变量名数组 long values[256]; // 对应的值 int count; // 当前变量数量 } Env; long eval(ASTNode *node, Env *env) { if (node-type NODE_NUMBER) { return node-value; // 数字节点直接返回值 } if (node-type NODE_VAR) { // 在环境表中查找变量名 for (int i 0; i env-count; i) { if (strcmp(env-names[i], node-name) 0) return env-values[i]; } fprintf(stderr, undefined variable: %s\n, node-name); exit(1); } if (node-type NODE_BINOP) { long left eval(node-left, env); // 递归求值左子树 long right eval(node-right, env); // 递归求值右子树 switch (node-op) { case TOKEN_PLUS: return left right; case TOKEN_MINUS: return left - right; case TOKEN_STAR: return left * right; case TOKEN_SLASH: if (right 0) { fprintf(stderr, division by zero\n); exit(1); } return left / right; case TOKEN_EQ: return left right; default: return 0; } } // ... 赋值、if、while 等节点的处理 return 0; }这段代码是整个解释器的心脏。递归求值的逻辑非常直观要算一个二元运算先算左边再算右边然后根据运算符做对应计算。变量查找就是遍历环境表找到就返回找不到就报错退出。if和while节点的处理稍微复杂一点——if会先求值条件表达式根据结果决定是否执行body子树while会循环求值条件只要为真就反复执行body。这里有一个值得注意的边界tryC 的环境表是全局的没有块级作用域。也就是说在if块里声明的变量出了块之后依然存在。这和 C 语言本身的行为不同但对于一个教学解释器来说简化作用域管理是合理的取舍。如果你要加块级作用域常见做法是在进入块时创建一个新的 Env退出时销毁变量查找沿着 Env 链向上找——这就是「环境链」的思路实现起来也不复杂大概多三四十行代码。3. 动手编译与跑通从源码到可执行文件3.1 编译命令与依赖检查tryC 的编译过程没有任何花哨的地方。它就是一个.c文件或者几个.c文件依赖只有标准库的stdio.h、stdlib.h、string.h、ctype.h。不需要 Makefile不需要 CMake一条命令就能出可执行文件# 假设源码文件为 tryc.c输出可执行文件名为 tryc gcc -O2 -Wall -Wextra -o tryc tryc.c-O2开优化-Wall -Wextra开所有常见警告。为什么要开警告因为这种手写解释器的代码里指针操作和类型转换很多编译器警告能帮你发现一些潜在问题比如strtol的返回值没检查、malloc的返回值没判空、有符号和无符号整型混用等。我见过不少人编译这种小项目时直接gcc -o tryc tryc.c结果跑起来遇到玄学崩溃——十有八九是某个地方的类型转换出了问题而编译器其实早就警告过了。编译过程中可能遇到的第一个坑是strtol的声明。在某些严格模式下如果没包含stdlib.h编译器会隐式声明strtol返回int导致大数解析出错。确保源码头部有#include stdio.h #include stdlib.h #include string.h #include ctype.h第二个坑是strcmp的返回值用法。有些人写if (strcmp(a, b))以为是在判断相等实际上strcmp返回 0 才表示相等非 0 表示不等。tryC 的变量查找里如果犯了这个错会导致「找到变量时反而报未定义」的诡异现象。3.2 写一个测试脚本验证功能边界编译通过之后别急着跑复杂程序。先写一个最小测试用例确认基本链路是通的# 测试 1最简单的算术 echo let x 1 2; print x; | ./tryc # 测试 2运算符优先级 echo let x 2 3 * 4; print x; | ./tryc # 预期输出 14如果输出 20 说明优先级处理有问题 # 测试 3括号改变优先级 echo let x (2 3) * 4; print x; | ./tryc # 预期输出 20 # 测试 4变量赋值与读取 echo let a 10; let b a 5; print b; | ./tryc # 预期输出 15 # 测试 5if 分支 echo let x 5; if (x 5) { print 100; } | ./tryc # 预期输出 100 # 测试 6while 循环 echo let i 0; while (i 3) { print i; let i i 1; } | ./tryc # 预期输出 0 1 2这六个测试覆盖了词法分析数字、标识符、关键字、运算符、语法分析表达式优先级、括号、语句块、求值变量读写、条件判断、循环。如果全部通过说明解释器的核心链路是完整的。如果有失败根据失败的那一条就能定位到具体模块——比如测试 2 失败就是term()和expr()的调用关系写反了测试 6 失败通常是while节点的body执行完之后没有回到条件判断。提示tryC 的print语句在有些版本里可能叫别的名字比如echo或者直接就是一个表达式语句。拿到源码后先搜一下输出相关的关键字确认语法。3.3 扩展一个运算符以取模为例跑通基本功能之后最有价值的练习是扩展一个运算符。以取模%为例需要改四个地方。第一步在 Token 类型枚举里加TOKEN_PERCENT。第二步在词法分析器的字符判断里加一个分支遇到%就返回TOKEN_PERCENT。第三步在语法分析器的term()函数里把%和*、/放在同一优先级处理// 在 term() 函数的循环条件里加入 TOKEN_PERCENT while (current_token.type TOKEN_STAR || current_token.type TOKEN_SLASH || current_token.type TOKEN_PERCENT) { TokenType op current_token.type; next_token(); // 消费运算符 ASTNode *right primary(); // 解析右操作数 ASTNode *new_node make_binop(op, left, right); left new_node; }第四步在eval()的switch里加case TOKEN_PERCENT: return left % right;同时记得加除零检查。case TOKEN_PERCENT: if (right 0) { fprintf(stderr, modulo by zero\n); exit(1); } return left % right;改完之后重新编译跑echo print 10 % 3; | ./tryc预期输出 1。这个练习虽然小但完整走了一遍「加 Token → 加词法规则 → 加语法规则 → 加求值逻辑」的流程和真实编译器前端扩展新语法特性的路径是一致的。4. 避坑与排查500 行里最容易翻车的五个地方4.1 段错误AST 节点没初始化就用了现象程序跑简单表达式正常一遇到if或while就 segfault。原因make_node之类的构造函数只malloc了内存但没有把所有字段初始化为 NULL 或 0。当eval()递归到一个if节点时去访问node-body或node-next拿到的是随机垃圾值解引用就崩。解决在节点构造函数里用memset或者逐个字段赋初值确保所有指针字段初始为 NULL所有整型字段初始为 0。我一般会在make_node里直接写ASTNode *n calloc(1, sizeof(ASTNode));calloc自动清零省心。4.2 死循环while 的条件表达式没有被重新求值现象while (i 3) { ... }跑起来卡死i永远不增加。原因eval()处理while节点时条件表达式的 AST 子树只求值了一次然后拿这个结果反复判断。正确做法是每次循环迭代都重新调eval(node-cond, env)。这个坑的本质是混淆了「AST 节点」和「AST 节点的求值结果」——节点是静态的但每次求值可能因为变量变化而得到不同结果。解决把条件求值放在循环体内部确保每轮都重新计算。4.3 变量名截断固定长度数组的边界问题现象定义了一个长变量名比如let counter 0;后面用counter读取时报未定义。原因环境表里变量名数组的每个槽位是char[64]但词法分析器在拷贝标识符时没有检查长度或者strncpy没有补\0。如果变量名超过 63 个字符就会被截断导致查找时strcmp不匹配。解决在词法分析器拷贝标识符时加长度检查超过上限就报错退出同时确保strncpy之后手动补\0。更稳妥的做法是把变量名长度上限调大比如 256同时用snprintf代替strcpy。4.4 运算符优先级写反2 3 * 4算出 20现象2 3 * 4的结果是 20 而不是 14。原因expr()和term()的调用关系搞反了——expr()直接调了primary()或者term()里处理了加减法。递归下降解析的优先级完全由调用层次决定expr()必须调term()term()必须调primary()不能跳级。解决画一棵调用树确认expr → term → primary的层次关系。如果发现expr()里出现了TOKEN_STAR的判断那就是写错了。4.5 内存泄漏每次循环都 malloc 但不 free现象跑一个循环次数较多的程序内存占用持续上升。原因eval()在处理表达式时如果每次求值都创建临时 AST 节点但不释放循环一万次就泄漏一万个节点。tryC 这种教学项目通常不处理内存回收因为程序跑完就退出操作系统会回收。但如果你要把它改成一个长期运行的服务就必须加引用计数或者 arena 分配器。常见做法是为每个语句的执行创建一个 arena语句执行完后一次性释放整个 arena避免逐个节点free的复杂度和性能开销。5. 从能跑到好用三个让 tryC 更实用的改造方向把 tryC 跑通、扩展完运算符之后如果你觉得这个项目值得继续折腾有三个改造方向性价比最高。第一个方向是加错误恢复。目前的 tryC 遇到语法错误直接exit(1)连行号都不报。改造方法是在词法分析阶段记录每个 Token 的行号语法分析器发现意外 Token 时打印行号和列号然后跳过当前语句继续解析下一条。这样调试脚本时能一次看到所有错误而不是改一个跑一次。实现上大概需要给 Token 结构体加一个line字段然后在next_token()里维护行号计数。第二个方向是把环境表从数组换成哈希表。目前的线性扫描在变量少于 50 个时没问题但变量一多查找就变慢。一个简单的开放寻址哈希表大概 80 行代码能把变量查找从 O(n) 降到 O(1)。不过要注意哈希表引入之后变量的遍历输出比如调试时打印所有变量会变复杂需要额外维护一个链表把所有变量串起来。第三个方向是加一个简单的 REPL交互式解释器。目前 tryC 是从标准输入读完整程序再执行改成 REPL 之后可以一行一行输入、一行一行执行调试体验会好很多。核心改动是把main()里的「读全部输入 → 词法分析 → 语法分析 → 求值」改成循环「读一行 → 词法分析 → 语法分析 → 求值 → 打印结果 → 读下一行」。环境表在循环之间保持这样变量可以跨行保持。改造方向预估代码量难度收益错误恢复与行号40-60 行低调试效率大幅提升哈希表环境80-100 行中变量查找性能提升REPL 交互模式30-50 行低使用体验接近真实语言我自己的习惯是拿到任何一个教学解释器先跑通基本功能然后加错误行号再加 REPL。这两个改完之后这个解释器就从「能跑」变成了「能用」后续再扩展语法特性时也有了一个舒服的调试环境。从那以后我每次拆这类小语言实现都强制先走一遍「编译 → 六个基础测试 → 加一个运算符 → 加行号报错」的流程确认整条链路没有暗坑再往下读代码。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

JSP+MSSQL进销存系统实战:从建库到过账的完整实现

JSP+MSSQL进销存系统实战:从建库到过账的完整实现

简介:这是一套面向高校计算机专业学生与Java Web初学者的进销存管理系统毕业设计资料,基于JSP表现层与MSSQL数据库实现,采用表现层、业务逻辑层、数据访问层的三层架构,通过JDBC完成数据交互。系统覆盖商品管理、供应商管理、进货…

2026/10/13 19:09:05 阅读更多 →
C++享元模式实战:从字符渲染内存危机到高效共享对象

C++享元模式实战:从字符渲染内存危机到高效共享对象

1. 场景先行:一次字符渲染引发的内存危机前阵子接手了一个文本编辑器项目,功能不复杂:支持超长文档的渲染、滚动、搜索高亮。第一版实现很快,无非就是每个字符保存字体、颜色、大小、位置信息,然后在界面上逐个绘制。结…

2026/10/12 16:12:42 阅读更多 →
Unity Mesh顶点修改实现自由捏脸系统全解析

Unity Mesh顶点修改实现自由捏脸系统全解析

简介:这是一套基于Unity引擎的Mesh顶点修改捏脸系统,面向游戏开发者与角色定制爱好者,主要解决RPG或角色创建场景中的面部个性化定制需求。压缩包共含68个文件,以asset、meta等Unity工程文件为主,搭配cs脚本、shader、…

2026/10/13 20:02:36 阅读更多 →

最新新闻

微信小程序奶茶店毕设从0到1全流程指南

微信小程序奶茶店毕设从0到1全流程指南

十个计算机毕设选题里,大概有五个是“基于微信的奶茶店小程序”。这个题目乍一看很重复,但它能常年被列入选题清单是有原因的:业务模型清晰,用户能一眼看懂价值;涉及点单、支付、购物车、后台管理、数据统计这些经典模…

2026/10/13 23:59:41 阅读更多 →
微生物群落结构差异分析:从统计显著到生物学可解释

微生物群落结构差异分析:从统计显著到生物学可解释

1. 这不是“画个热图就完事”的统计游戏很多人第一次接触“微生物群落结构差异分析”&#xff0c;脑子里浮现的是一张带颜色的热图、几个堆叠柱状图&#xff0c;外加一句“两组间存在显著差异&#xff08;p<0.05&#xff09;”。我见过太多项目——某高校实验室的土壤样本测…

2026/10/13 23:59:41 阅读更多 →
智慧用能研究院Java一面:高频考点与模拟面试实战拆解

智慧用能研究院Java一面:高频考点与模拟面试实战拆解

一个模拟出来的“研究院一面”&#xff0c;为什么值得认真拆&#xff1f;很多同学一听说“模拟面试”就觉得是走过场——找个学长学姐或者培训机构老师&#xff0c;照着简历问一圈&#xff0c;然后给个“不错”“基础还行”的评价&#xff0c;就结束了。但我可以负责任地讲&…

2026/10/13 23:59:41 阅读更多 →
AnyPS5:基于语义桥接的PS5游戏跨平台运行技术解析

AnyPS5:基于语义桥接的PS5游戏跨平台运行技术解析

1. 项目概述&#xff1a;一个被误读的命名现象&#xff0c;背后是跨平台兼容性设计的现实困境“AnyPS5”这个词最近在多个技术社区和硬件讨论区频繁出现&#xff0c;但几乎没人能说清它到底指什么。它既不是索尼官方发布的型号&#xff0c;也不是某款已上市设备的代号&#xff…

2026/10/13 23:59:41 阅读更多 →
Manjaro GNOME配置包冲突:manjaro-gnome-settings与-18.0互斥解析

Manjaro GNOME配置包冲突:manjaro-gnome-settings与-18.0互斥解析

1. 问题本质&#xff1a;这不是系统坏了&#xff0c;是包管理器在“认真履职”刚看到这个报错——manjaro-gnome-settings 与 manjaro-gnome-settings-18.0 有冲突——很多刚从Ubuntu或Windows转来的用户第一反应是“系统崩了”“是不是升级把GNOME搞挂了”。其实恰恰相反&…

2026/10/13 23:59:41 阅读更多 →
MyBatis插件机制深度剖析:从动态代理到SQL改写与加解密实战

MyBatis插件机制深度剖析:从动态代理到SQL改写与加解密实战

1. 插件机制的本质&#xff1a;不是魔法&#xff0c;是接口的排列组合我最早接触Mybatis插件的时候&#xff0c;把文档翻来覆去看了好几遍&#xff0c;脑子里一直是糊的。原因很简单&#xff1a;网上大部分教程上来就丢一个拦截器的代码&#xff0c;然后告诉你“这样就能自动分…

2026/10/13 23:58:40 阅读更多 →

日新闻

周新闻

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

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

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

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

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

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

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

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

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

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 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/13 22:01:27 阅读更多 →
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/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练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 阅读更多 →