新闻详情

新闻详情

首页 / 资讯中心 / 详情

飞控软件为什么严禁动态内存分配:从WCET到内存池的工程逻辑

发布时间:2026/10/2 12:08:43来源:尧图网络
飞控软件为什么严禁动态内存分配:从WCET到内存池的工程逻辑
我工作第二年的时候第一次坐在飞控软件代码评审的桌子前面。投上去一段自认为写得很漂亮的代码Y老师只看了一眼就指着其中一行问我“这个malloc你是要用在哪”我解释说是运行时要动态创建一个缓冲区。他沉默了几秒说了句让我记到现在的话——“你写的这个malloc在这个系统里是要拉着所有人陪你赌命的。”当时我以为他在吓唬新人。直到后来自己开始做系统级设计去算最坏情况执行时间、去做内存资源预算、去复查那些“偶发”故障的时候才把这句话真正理解了。给导弹写代码本质上就是给一套“没有重试机会”的实时系统写代码而动态内存分配恰恰是所有不确定性的集中营。这篇博文我想把“为什么严禁动态内存分配”这件事讲透既讲原理也讲标准还讲替代方案。适合正在做飞控、汽车电控、医疗器械、工业控制这些安全关键系统的朋友也适合刚从桌面开发转嵌入式、还没转过弯来的同学。1. 先别急着骂保守一次malloc背后到底发生了什么1.1 malloc不是“问系统要内存”这么简单很多从桌面开发转过来的同事第一反应是malloc不就是调个库函数从堆里拿块内存吗系统给就是给不给就返回NULL有什么不安全的实际上在主流操作系统的实现里一次malloc的旅程远比想象中复杂。以Linux下glibc的ptmalloc为例进程启动后维护一个大堆heap按不同大小分成fastbins、unsorted bin、small bins、large bins等一堆空闲链表malloc进来之后要先去查这些链表看有没有大小合适的块没有的话还要从top chunk上切再不够就要走系统调用去扩展堆。free的时候也不轻松要把块挂回对应链表还可能要跟前后的相邻空闲块做合并。这只是最朴素的描述再往下钻还有arena锁竞争、内存碎片整理、首次访问新内存时的缺页异常、系统真正内存不够时的OOM处理。也就是说一个看似简单的malloc背后是一整套基于“运行时状态”的决策过程。而你写的每一行控制代码都依赖这个过程的输出。1.2 一条指令的耗时从几十纳秒到几毫秒不等这里的关键还不是慢而是“不可控”。一次分配从代码发出到返回最快的情况是堆里刚好有大小匹配的空闲块也许几个纳秒就回来了。最坏的情况呢要加锁、要合并碎片、要调用brk/mmap触发内核态开销、要引发缺页中断去建立物理页映射甚至可能触发内存回收算法。桌面服务器上一两次这样的慢没问题可是当程序长时间运行堆的状态越来越散乱这种“慢”会以概率的方式散布在程序运行的任意角落。我见过一个做互联网后端的同事很不屑你们嵌入式就那么点内存至于吗我的回答通常是同一个malloc在你的服务器上慢100毫秒用户最多骂一句“网站卡了”在我的系统里慢1毫秒飞行器可能已经错过了当前控制周期该输出的舵面指令。1.3 桌面系统觉得“慢”只是卡飞控系统觉得“慢”就是事故好这就是理解整条规矩的钥匙安全关键系统和普通软件对“不确定性”的容忍度完全不同。普通应用软件的评价指标是平均响应时间、吞吐量、可用性偶尔抖一下没事。而导弹飞控这类安全关键软件要满足的是“任何情况下、任何输入序列、任何历史状态下行为都必须在规定时间内、按规定路径完成”。它不是统计学上“99.99%没问题”而是逻辑上“找不到一条让它出问题的路径”。动态内存分配恰恰把系统拖入了“路径多到无法穷举”的深渊。提示判断一段代码能不能用malloc先别问“运行时会出问题吗”先问“你能证明它在任何时刻都不会出问题吗”。后者才是安全关键系统的标准。这也是为什么很多飞控团队在VxWorks工程里直接把heap大小配成0或者从链接层面裁掉malloc符号。不是矫枉过正是他们在用工程手段确保“运行时根本没有动态内存分配这件事发生”。2. 飞控软件的第一性原理时序必须是确定的2.1 固定周期任务的死线是怎么定的导弹的制导控制软件几乎都是周期任务架构。飞控计算机按照一个固定的时间节拍运行比方说主控制周期10毫秒每个周期内依次完成传感器数据采集、信号滤波、惯导解算、控制律计算、舵机指令输出状态需要更新、导引律要迭代、遥测帧要组包。这一串动作必须在10毫秒的“死线”deadline之前干完。这个死线不是拍脑袋定的它跟飞行器的动态特性强相关。气动参数、舵机带宽、制导回路的时间常数决定了控制周期只能短不能长。周期一旦定死里面所有任务的最坏执行时间加起来必须小于这个周期还要留出调度余量。实时调度理论里有一个核心概念叫最坏情况执行时间WCET所有任务的可调度性验证都建立在这个指标之上。2.2 WCET分析为什么容不下mallocWCET分析的理想情况是把每段代码的执行路径、每条指令的访存行为都摸清楚得到一个可证明的执行时间上界。静态分析工具会做指令级建模缓存命不命中、流水线阻塞、分支预测错误都要算进去。即便这样已经很痛苦了一旦代码里出现malloc整个分析体系直接崩盘。为什么因为malloc的执行时间取决于“堆的当前状态”而堆的状态取决于程序从启动到现在经历过的所有分配和释放历史。这块内存是fastbin里有还是largebin里要切相邻块要不要合并堆区要不要扩展系统调用要不要发生——每一条都跟运行时状态绑定理论上可能出现的状态组合是天文数字。你没法给malloc一个可用的WCET上界自然也没法证明整个任务的执行时间总和小于控制周期。2.3 中断上下文里连一丝不确定性都不能有再往下钻一层导弹软件里大量代码运行在中断上下文或者实时操作系统的任务上下文里。中断服务程序的要求是“快到什么程度都能说清楚”你永远不知道下一个中断什么时候来所以程序响应中断时要做到不依赖任何可能阻塞的操作。而malloc轻则加锁重则系统调用在RTOS里甚至可能触发任务调度这些都是中断上下文的大忌。我见过最典型的转行错误有人把桌面程序的一整套“尽量动态分配、多用List”的习惯搬到VxWorks里结果中断里偶发放一次缓冲区分配系统就偶发地挂死。查起来非常绝望普通调试器都抓不到现场只能靠插桩、记录堆栈、反向对照时间点。折腾两周最后把这段分配去掉改成静态数组问题消失得无影无踪。所以飞控团队对时序的理解和对内存的理解从来是一体的内存的分配方式必须服从时序的可证明性而不是反过来让时序迁就内存管理。3. 内存碎片是比脏指针更隐蔽的杀手3.1 外部碎片、内部碎片和一段“连续恐怖”内存碎片分两类。第一类是内部碎片分配器给你一整块块大小你实际只用了一部分剩下没用上的部分浪费了。第二类是外部碎片这个更致命堆里到处散落着小块空闲内存每个单独拿出来都不够大加起来倒不少可你要一块连续的稍大内存时发现哪一块都不够。动态内存分配器按字节维护连续区域分配出去就占据一段释放之后还要尽量保持连续。工程上经典场景堆总共100KB程序按16B、32B的尺寸频繁分配释放一些小对象中间穿插一次性的大块请求。几百个周期之后堆里布满了大小不一的小洞这时候你请求一个20KB的连续缓冲malloc把分配链从一头翻到另一头最终返回NULL。注意此时整个堆的“剩余字节数”可能还有30KB可就是没有一段连续的空间能塞下20KB。3.2 一次典型的碎片化过程用具体数字算一遍比较直观。假设堆初始空闲区100KB我们按下面的顺序分配释放步骤操作效果初始空闲100KB连续1分配24KB24KB占用76KB连续2在剩余区分配128B小对象×20024KB占用中间散落大量128B小块3释放24KB24KB区域被若干128B小块从边缘咬碎4请求32KB总剩余量可能超过30KB但找不到32KB连续区实际堆里的布局比这个复杂得多但原理完全一致那些不起眼的小对象会在释放大块后“顶”在大块原来的位置上把一块可以连续使用的大区间分割成互不相连的碎片区。表面上看内存没有少实际能用的连续空间迅速缩水。3.3 更头疼的是它的“路径依赖”今天复现不了明天复现碎片问题最恶心的不是“最终失败”而是“很难复现”。分配器的行为不是从输入数据推导的而是从“整个历史路径”推导的。你今天跑同一组输入两次飞行堆的状态可能完全不同一次在循环里先分配A再分配B另一次因为某个分支早成立先分配了B再分配A堆的布局就南辕北辙。于是故障变成概率性的做可靠性审查的人最烦这个你连一个稳定复现的缺陷都拿不出来怎么证明代码是可靠的安全关键系统有个基本要求是确定性相同输入相同状态相同输出。动态内存分配从根上破坏了这个链条。这也是为什么哪怕项目里加了无数次“如果malloc返回NULL就报错”的防护审查方依然不会放行——防护只是把失败从“静默”变成“显性”并没有消除“可能失败”这件事本身。3.4 泄漏问题在安全关键领域同样不可接受碎片之外还有泄漏。长时间运行的系统中分配和释放一旦漏配堆会被慢慢蚕食。桌面程序漏内存往往要到几小时甚至几天后才有感知导弹软件一次飞行任务虽短但加上勤务状态、多次待机唤醒、地面测试反复运行泄漏一样会积累到致命。何况在嵌入式裸机环境里你很难把Valgrind这类工具接到实时线程上。内存泄漏常常变成“物理地址被反复使用、谁也不知道谁还在引用”的悬案这类问题在型号归零时最耗时。4. 标准与审查动态分配在认证层面就是死路4.1 MISRA C里的Rule 21.3和“标准库禁区”在C语言嵌入式开发领域MISRA C是绕不开的编码规范。MISRA C:2012里面有一条被无数评审工程师背得滚瓜烂熟的规则Rule 21.3“不得使用stdlib.h中的内存分配与释放函数”。malloc、calloc、realloc、free一个都逃不掉。MISRA C还配套规定了标准库函数的受限集内存分配类函数直接被拉黑。我在评审时经常见到用“#define malloc my_malloc”之类的手段绕规则的代码这种属于刑侦级操作现场就会被退回。规则的意义不在于“这个函数有毒”而在于要求整个团队在架构上就不依赖它而不是在代码缝缝补补。你用宏把malloc换名堆的状态还是不可控换个名字解决不了WCET和碎片问题。4.2 适航和军品审查逻辑你要证明它不掉链子航空领域有一个DO-178C标准把软件失效的影响等级分成A到EA级意味着软件失效可能导致灾难性事故。DO-178C对验证的要求是你要拿出证据证明软件在所有可能工况下行为和设计一致。这条逻辑下动态内存分配格外吃亏。你要想用malloc就必须证明分配不会失败吗不会碎片化吗耗时不会超上限吗内存不会泄漏吗每个证明的背后都是状态爆炸。如果你的产品还要去做适航审查或军品定型审查审查方问的第一个问题往往就是“堆在哪谁在用用了之后怎么保证可控”绝大多数团队到这里就放弃了所以业内几乎所有高安全等级的系统在需求设计阶段就直接把动态分配列为非门槛项。提示如果你负责的项目未来可能需要过DO-178C、IEC 61508、ISO 26262或者国内相应的GJB审查建议从第一天就用静态分配。事后整改的成本远远高于一开始的规划成本。4.3 国内型号软件的实际执行尺度国内航天、兵器、电科这些领域软件的可靠性设计准则里几乎都明确禁止在实时任务中使用动态内存分配。我记得GJB 5369《航天型号软件C语言使用规则》里就有一条硬性规定不允许在实时控制软件中使用动态内存分配相关操作GJB 5000B这类研制能力标准没有直接规定某个函数能不能用但它要求的工程化、可追溯性、可靠性分析在实际审查时也把动态分配推向了死角。实际执行尺度比条文更严。我参与过的一个型号连在任务线程里自己写的单向链表都要求改成定长数组。评审意见写得很现实“就算你的链表不用malloc只要它能在运行时改变长度你就要证明长度不会超限这个证明我不好审。”这种思路看多了就明白了不是排斥某种写法而是排斥“运行时数据结构规模不可静态验证”这件事。4.4 一句话总结这里面的工程哲学安全关键系统的软件工程哲学可以浓缩成两句话能把决策放到编译期就绝不放到运行期能让状态在程序启动前定型就绝不让它在飞行途中演化。动态内存分配恰好同时违反这两条。所谓“严禁”不是因为用了会立刻炸而是因为它在“证明安全”这件事上制造了一个无法翻越的坎。5. 不用malloc那大坨内存需求怎么办池化与静态规划5.1 把“运行时决策”提前到“编译时决策”禁用动态分配不等于“内存不够用硬扛”。它的本质是把内存的生命周期管理从运行时挪到设计时哪些任务要多少内存、这些内存在哪个阶段存在、谁负责使用谁负责归还全部在启动或者编译阶段就定死。怎么做资源统计最实用的办法是做一张“内存资源预算表”。把软件里的每类对象列出来估算规模上限按峰值分配。我在项目里的示例预算表大概是这样的用途实例数单例大小总占用传感器原始数据缓冲8256 B2 KB控制律计算中间数组41 KB4 KB遥测帧发送缓冲双缓冲2512 B1 KB任务栈4个实时任务44 KB16 KB航路点信息静态存储18 KB8 KB加起来31KB左右。设计评审时拿这张表说事比代码里写一百个malloc都有说服力。因为评审的人一眼就能看出每一块内存从哪里来、用到哪里去、上限是多少。5.2 定长内存池一个可以直接抄的C实现最常见的替代方案是内存池启动时把一大块静态数组切成固定大小的块用空闲链表串起来。之后分配和释放都是O(1)没有任何系统调用、没有锁、没有碎片。#include stdint.h #include string.h #define POOL_BLOCK_SIZE 64 #define POOL_BLOCK_NUM 32 typedef struct pool_block { struct pool_block *next; } pool_block_t; typedef struct { pool_block_t *free_head; /* 用 union 保证对齐每个块既可以被看作裸内存也可以被看作链表节点 */ union { uint64_t align; unsigned char raw[POOL_BLOCK_SIZE]; } mem[POOL_BLOCK_NUM]; } mem_pool_t; void pool_init(mem_pool_t *pool) { pool-free_head NULL; for (int i 0; i POOL_BLOCK_NUM; i) { pool_block_t *blk (pool_block_t *)pool-mem[i].raw; blk-next pool-free_head; pool-free_head blk; } } void *pool_alloc(mem_pool_t *pool) { if (pool-free_head NULL) { return NULL; } pool_block_t *blk pool-free_head; pool-free_head blk-next; memset(blk, 0, POOL_BLOCK_SIZE); return (void *)blk; } void pool_free(mem_pool_t *pool, void *ptr) { if (ptr NULL) { return; } pool_block_t *blk (pool_block_t *)ptr; blk-next pool-free_head; pool-free_head blk; }这段代码的核心思路就一个内存块在编译期就躺在静态数组里运行期只是把一个块从“空闲链表”挪到“使用中”再挪回来。没有系统调用没有缺页没有长度检查以外的任何分支。你把POOL_BLOCK_NUM和POOL_BLOCK_SIZE定成多少系统使用的内存上限就是多少一清二楚。5.3 需要变长缓冲区时多级固定池与分档固定块池的缺点是每一块大小固定。现实中不同场景需要不同大小的缓冲解决方法是“分档”建几档池比如16B、32B、64B、128B、256B。分配时按申请大小上浮到最近档位代价是每个块损失一点内部碎片换来的是全程可预测、无外部碎片。分档池特别好用的场景是消息队列和协议栈。比如遥测数据帧长度在80到220字节之间用一个256B档位的池子不到1KB的静态内存就能撑住十几帧在途数据换成malloc方案要为几百个不同长度做动态适配堆迟早千疮百孔。分档池使用时要记录一下“一个申请落到哪一档”这相当于给数据流打了一个资源标签日后做故障归零时有据可查。5.4 还想要动态行为环形缓冲和双缓冲顶上很多开发者其实不是真的需要“动态”只是需要“多个消费者轮流拿数据”“写入方和读取方节奏不同”。这类需求用环形缓冲ring buffer和双缓冲double buffer就能完美覆盖而且它们都是纯静态结构。环形缓冲适合流式数据比如串口收包固定数组加头尾指针就能实现无锁的单生产者单消费者模型。双缓冲适合帧式数据写线程写一块完成后切换指针读线程读另一块。这些都是飞控软件的常规操作。所谓“动态”只是表象“共享数据的高效传递”才是本质而后者根本不需要堆。5.5 什么场景是可以松动的最后说句公道话不是所有导弹相关软件都禁用动态内存分配。地面测试设备、上位机工具、仿真校核程序这些跑在完整OS上、不在飞行链路里的软件用malloc很正常。禁用范围严格限定在板卡上的实时安全关键路径——特别是中断处理和周期性控制任务里。我见过一些新人把“不用malloc”理解为“全项目都不许碰堆”反而把地面软件写得异常痛苦这是走向另一个极端。6. 实战中的坑与保命技巧那些我见过、踩过的案发现场6.1 一次realloc引发的“幽灵覆写”我参与过一个安全关键项目遥测数据偶发出现异常指针指向的内存内容被无声无息地改写。故障没有任何规律可能连续跑几天都不出也可能一上午就崩两次。排查的第一步怀疑数组越界。我们往可疑缓冲区前后加金丝雀值跑了一周没触发。第二步静态分析工具扫了一遍没有发现明显的越界路径。第三步我们开始对堆做插桩把所有malloc、realloc、free的地址和时间戳都打出来再和故障现场比对。最后锁定的元凶是realloc。事情是这样的线程A持有一个指向旧缓冲区的指针线程B在处理过程中调用realloc要求扩展这个缓冲区。realloc发现原地扩不了就把内存搬到了新地址旧地址回收进了分配器的空闲链表。线程A不知道这件事还在往旧地址写数据于是把堆元数据写坏了下一次malloc从损坏的链表中取地址返回了一个指向正在使用的内存的“新块”故障就此引爆。最麻烦的是这次运行里realloc恰好移动了内存下次运行可能没移动于是故障变成幽灵一样的存在。这个案例给我留下的教训很深凡是涉及共享缓冲区的地方要么用双缓冲要么用环形缓冲要么明确约定“缓冲区地址不允许改变”绝不允许一个线程能动态迁移一块别人还在引用的内存。安全关键系统里“悬空指针”经常不是delete出来的而是realloc搬家搬出来的。6.2 池水位爆了怎么办设计时的降级方案静态池方案也会有一个问题池用尽了怎么办。桌面代码malloc失败可以返回NULL然后错误处理池方案同样要有明确策略。我的做法是三层水位正常水位池使用量低于70%什么都不用做。预警水位70%到85%限制低优先级任务申请记录日志比如停止遥测明细记录只保证核心控制。应急水位85%以上保留给最高优先级任务一定数量的预留块其他申请直接拒绝并触发降级动作。预留块要单独计算不跟正常块混用。比如控制任务最多同时需要3个块那么池里最后5个块永远不参与普通申请。这样哪怕池的规模估算出错丢的也只是非关键功能控制回路永远有内存可用。这也是为什么静态规划不是“物理上禁用动态”而是把风险从运行时转移到了设计期——用尽所有计算把每一种水位下的行为提前定义好。6.3 从桌面开发转RTOS第一件事把堆禁掉给刚转到嵌入式的同学一个极端建议在飞控板卡的工程里用链接脚本把堆区清零或者在编译配置里把malloc/free符号直接裁掉。让程序在链接期就报错比你靠自觉去约束自己可靠一万倍。代码里一旦出现malloc编译直接失败比什么代码评审都硬核。具体做法在GNU链接脚本里把heap段声明为0或者链接时不链接libc的动态分配实现检查map文件确认相关符号不存在再在编译选项里开-Wall -Wextra -Werror把未初始化、类型转换问题全堵住。静态分析工具用PC-lint、QAC或者开源Cppcheck都能把标准库动态分配函数识别出来。我们团队现在把“代码里出现malloc”直接等同于“没通过静态检查”该PR连合入的资格都没有。6.4 给新人的资源预算表和审查小抄最后给做安全关键系统的新人一个“审查小抄”运行在中断上下文或周期任务里的代码内存要么是栈上局部变量要么是从启动期就建好的静态池任何“长度可能变化”的数据结构都要能说清楚上限并在设计评审时给出来源全局共享缓冲区优先双缓冲或环形缓冲避免动态锁每次设计都问一遍这行代码的最坏执行时间是多少它的内存来自哪里提示过完这四条你的代码大概率已经在很多人前面了。我评审过几百份代码出问题的大多栽在前两条上而不是什么深奥的算法。说实话我刚开始也觉得“严禁动态内存分配”这条规矩老土甚至觉得是老师傅们不懂新技术。直到亲眼见到那个realloc事故又算了算WCET和碎片账才明白为什么飞控部门几十年前就把它写进铁律。技术的迭代会让很多旧的限制失效但有一条不会安全关键系统里不可证明的东西就是不安全的东西。希望这篇东西能帮刚入行的朋友少走弯路别像我当年那样被一句“你这是让所有人陪你赌命”当场问住还一脸委屈。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

构建可解释的 AI Agent Harness Engineering 系统:从 401 报错到 TaoToken 统一通道的排障实录 2026/10/2 16:57:11

构建可解释的 AI Agent Harness Engineering 系统:从 401 报错到 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/2 16:57:10

多线程并发安全实战:从原子性、可见性到线程池配置与死锁排查

聊到线程中的并发安全,我脑子里首先冒出来的不是某个高深的API,而是这些年排查线上故障时那些“数据莫名其妙错了”“程序偶尔卡死”“同样的代码在我电脑上没问题”的场景。多线程不是新鲜词,但并发安全依然是大多数项目里最容易被低估的技术…

阅读更多 →
workerId 配重复导致 3000 条重复订单号:分布式 ID 的时钟回拨与号段双缓冲,我用过血换来的配置清单 2026/10/2 16:57:10

workerId 配重复导致 3000 条重复订单号:分布式 ID 的时钟回拨与号段双缓冲,我用过血换来的配置清单

sn: 19 batch: 4 round: 9 topic: 分布式 ID 生成方案对比:雪花、号段、Leaf订单号重复这事,多数人第一反应是"不可能,雪花算法很成熟"。但我们真出过:机房迁移后,两台机器的 workerId 被配成了同一个值&…

阅读更多 →
com.mongodb.MongoCursorNotFoundException 解决方案:TaoToken 统一 Key 通道下的游标超时排查与复现 2026/10/2 16:57:04

com.mongodb.MongoCursorNotFoundException 解决方案:TaoToken 统一 Key 通道下的游标超时排查与复现

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

阅读更多 →
NetCoreKevin-DDD-微服务-WebApi-AI智能体、AISemanticKernel集成、MCP协议服务、SignalR、Quartz 框架-04-安装:把 settings 改到 T 2026/10/2 16:57:04

NetCoreKevin-DDD-微服务-WebApi-AI智能体、AISemanticKernel集成、MCP协议服务、SignalR、Quartz 框架-04-安装:把 settings 改到 T

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

阅读更多 →
降重后格式乱成一团,TaoToken 统一 Key 通道下有哪些真正好用的降AIGC软件推荐? 2026/10/2 16:57:04

降重后格式乱成一团,TaoToken 统一 Key 通道下有哪些真正好用的降AIGC软件推荐?

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

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉