news 2026/9/13 20:59:58

OS开发实战:假持久化、USB枚举与内核自举的坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OS开发实战:假持久化、USB枚举与内核自举的坑

上篇结尾我信誓旦旦地说,下一步要给内核加上真正的持久化存储。结果这一个月下来,bug列表从45条一路滚到165条,期间亲手制造了“假持久化”,又一头扎进USB地狱,最后还差点被OS自举折腾到怀疑人生。这篇下篇就是来交作业的:把这三座大山连坑带路地讲一遍,顺便聊聊我是怎么从一堆乱七八糟的故障里把项目捞回来的。适合正在写OS、想摆脱grub打算自研引导、或者对USB驱动跃跃欲试的朋友,你看完至少能少走我这一半的弯路。

项目做到这里,其实已经不是“写个能跑的内核”了,而是“怎么让一个不断膨胀的内核在真实硬件逻辑面前不撒谎”。假持久化、USB枚举、运行时自举,这三块几乎是OS开发里最让人头秃的组合拳。我把踩坑过程、修复思路、排查工具全记录在下面,都是一手现场,照着试就行。

1. 项目进展与Bug爆炸:45到165到底意味着什么

很多人看到标题第一反应是“怎么越写越烂”。其实恰恰相反,这个数字翻倍不是代码质量崩了,而是工程化程度起来了。上篇结束的时候,系统只能做非常有限的事:切GDT、装IDT、开分页、跑一个简陋的调度器,能验证的路径就那么几条。但这一个月我加了块设备层、文件系统、USB主机控制器驱动、运行时内核自举,每一条新路径都在和真实硬件协议碰撞,bug数量不涨才奇怪。

1.1 为什么功能没多多少,Bug却翻了快4倍

先说结论:bug数量的增长曲线,本质上等于“可验证功能边界”的增长曲线。你什么都没有的时候,一个panic能让你排查半天,是因为你根本不知道是内存写坏了还是GDT表搞错了。但当你逐步有了串口日志、断言机制、页面分配器自检,bug就从“隐形的幽灵”变成了“有名字有编号的故障项”。

我做了个很土但有效的统计,把45到165这120个bug按模块拆开,分布大概是:

  • 块设备与文件系统相关:43个
  • USB协议栈相关:51个
  • 内核自举相关:18个
  • 内存管理回归问题:12个
  • 其他(编译、链接、启动参数):8个

从这个分布能看出来,新增bug主要集中在协议交互和启动切换这类“时序敏感”的地方。这类bug的共同特点是:单看某一行代码可能都没错,但放在整个系统的状态机里,就是会在某个边界条件下炸开。所以我对这120个bug的处理原则是:不急着修,先给它们建档案,搞清楚复现路径,再动手。

1.2 我是怎么维护这165个Bug的

我用一个Markdown表格当bug清单,字段是:编号、日期、模块、现象、复现条件、根因、修复、回归结果。这看起来笨,但真的好用。举三个有代表性的条目:

编号模块现象根因修复
B104块设备写入后重启数据全丢写操作只进了内存缓存,没有下发ATA命令增加真正的ATA PIO写流程
B131USB枚举U盘时端口复位风暴设备描述符只读了8字节,设备等待后续请求时我发错了SET_ADDRESS修正控制传输状态机
B158自举新内核跳转后立即三重故障页表切换后,下一条指令所在页没被映射切换前先建立恒等映射关键代码页

每次修完一个bug,我会在对应的模块函数里加一个注释,写清楚这个bug是从哪条路径暴露的、为什么这么修。这比单独写一份“修改记录”文档更有价值,因为代码就是活文档。另一个重要习惯是:一次只修一个根因。很多新人遇到连续崩,喜欢同时改三处看似可疑的地方,结果就是排查成本翻倍,还说不清到底是哪一步把它修好的。

还有一点值得说:不要完全迷信调试器。在内核环境里,硬件断点和单步是有用的,但很多OS级bug(比如中断风暴、DMA缓存不一致)在单步时根本复现不出来。我更依赖串口日志,在关键函数入口打印状态,然后跑一轮回归,看日志和预期是否吻合。

2. 假持久化:一个让我连续三天睡不着的“写盘”骗局

假持久化可能是这个项目里最丢人也最值得讲的一个坑。简单说,我的系统在“看起来能写盘”的状态下跑了将近两周,文件系统层的创建、写入、读取测试全部通过,我以为已经实现持久化存储了。直到某天我冷启动了一次QEMU,发现之前创建的所有文件都消失了,才意识到自己被骗了。

2.1 从Ramdisk开始是好的,但“假装是磁盘”就是灾难

为了加快开发速度,块设备层的第一版我用了Ramdisk,也就是在内存里开一个大数组,对外提供和磁盘一样的read/write接口。这本身是合理的,OS开发社区也经常用ramdisk做先期测试。问题出在后续设计:我为了先跑通文件系统,直接让VFS层挂载到了这个内存设备上,并把它的接口抽象成通用块设备。

于是出现了一个很尴尬的局面:所有上层模块,包括文件系统、目录项、页缓存,都以为自己在和一个持久化设备打交道。但最底层的数据其实只是躺在RAM里,一断电就灰飞烟灭。这个设计放在开发期是方便了,但我忘了给它定义清晰的边界,更致命的是没有在冷启动后做一次“数据是否还在”的验证。

这个教训我后来写进了自己的OS开发守则里:如果你声称一个设备是持久的,那么它必须通过冷启动测试。Ramdisk可以作为临时文件系统,可以当swap分区,但绝对不能作为“正式磁盘”挂载,否则你后面所有基于它的测试结果都会失真。

2.2 真·ATA PIO写入到底要经过多少道检查

等我决定切换到真实磁盘(QEMU里挂一个IDE盘),才发现事情远没有“把内存数组换成端口I/O”那么简单。ATA PIO写入有严格的状态机,任何一个环节漏掉,都会造成数据丢失或者读到半截数据。核心流程是:

  1. 等待BSY位清零,确认控制器空闲。
  2. 写扇区数、LBA地址的低24位,再写Drive/Head寄存器,设置LBA模式。
  3. 发送写扇区命令(0x30)。
  4. 等待DRQ置位,表示控制器准备好接收数据。
  5. 连续向数据端口写入512字节(按16位为单位,写256次)。
  6. 等待BSY位再次清零,并检查错误寄存器。
  7. 发送Cache Flush命令(0xE7),确保数据真正落到盘面。

下面是我后来整理出的最小可用的PIO写扇区代码:

static void ata_write_sector(uint32_t lba, const void *buf) { // 1. 等待 BSY 清 0 while (inb(ATA_PORT + 7) & (1 << 7)) ; // 2. 写入扇区数、LBA、驱动器号 outb(ATA_PORT + 2, 1); outb(ATA_PORT + 3, lba & 0xff); outb(ATA_PORT + 4, (lba >> 8) & 0xff); outb(ATA_PORT + 5, (lba >> 16) & 0xff); outb(ATA_PORT + 6, 0xe0 | ((lba >> 24) & 0x0f)); // 3. 发送写扇区命令 outb(ATA_PORT + 7, 0x30); // 4. 等待 DRQ 置位 while (!(inb(ATA_PORT + 7) & (1 << 6))) ; // 5. 输出 256 个 16 位数据 const uint16_t *p = (const uint16_t *)buf; for (int i = 0; i < 256; i++) outw(ATA_PORT, p[i]); // 6. 等待 BSY 清 0 while (inb(ATA_PORT + 7) & (1 << 7)) ; // 7. 刷写缓存 outb(ATA_PORT + 7, 0xe7); }

第7步的Cache Flush是最容易被忽略的。我最初以为发完数据命令就算完事,结果在模拟器里看不出问题,后来在真机上重启后偶尔丢最后一个扇区的数据,才意识到是写缓存没刷。你甚至可以理解为:命令发出去了,不代表数据已经落盘,控制器只是把数据放进了自己的缓存里。

2.3 假持久化问题速查表

为了不让后来人重蹈覆辙,我整理了一张很直接的排查表:

现象可能原因验证方式修复方向
写入后冷启动数据消失底层是Ramdisk,没有真实写盘写入后立即重启,再读切换真实块设备,或确认当前设备非持久
写盘后偶发丢最后一个扇区没有发Cache Flush命令连续写100个扇区后断电重启校验增加0xE7命令
读回的数据和写入不一致等待DRQ时序不对,读到半截写入已知模式,逐个字节比对严格按状态机轮询
在QEMU正常,真机丢数据PIO访问次数太多,超时处理缺失真机长跑写读测试增加超时和错误恢复逻辑

我在这个阶段总结出一条经验:持久化不是“写了一次”就成立,而是“写、刷、读、重启后再读”全都成立才算数。用这个标准去测,假持久化第二天就现原形了。

3. USB地狱:一个枚举就把我按在地上摩擦了两周

如果说假持久化是“自我欺骗”,那USB纯属是“现实毒打”。我原以为U盘驱动比键盘复杂不了太多,结果一进去就被协议分层、端点、描述符、传输类型轮番教育。到后期我甚至分不清自己是在写OS还是在写协议解析器。

3.1 USB协议栈到底难在哪

USB难在它是一个分层协议,每一层都有自己的一套状态机:

  • 物理层负责编解码、同步、电气特性,会直接表现为设备无法识别。
  • 链路层负责包结构、CRC校验、握手信号。
  • 事务层处理令牌包、数据包、握手包的交互。
  • 再往上才是设备架构层,包括端点、管道、接口描述符。
  • 最后一层是各种类驱动,比如人类接口设备(HID)、海量存储(BOT/UFI)。

更麻烦的是,USB主机控制器也有四大家族:UHCI、OHCI、EHCI、xHCI。UHCI和OHCI对应USB1.1,EHCI对应USB2.0,xHCI对应USB3.x。它们的寄存器布局、传输描述符格式、内存数据结构完全不同。我平台默认用UHCI,于是从UHCI开始撸。

用一个生活化的类比:USB端口就像酒店前台,每个外设都得到前台登记。它先表明自己的“姓名”(设备描述符),再告诉你它有哪些“服务”(接口描述符、端点描述符),然后你根据服务类型给它安排房间(设置地址、设置配置)。问题是前台每天要为几百个客人重复这套流程,任何一个环节超时、回错包、CRC错误,整个登记就要从零开始。

3.2 UHCI枚举U盘的完整流程(经验版)

我在UHCI上枚举一个U盘,走了一遍完整流程,每一步都是坑。整理完大概是这样的:

  1. 等待端口状态寄存器里连接位变化,确认设备已插入。
  2. 对端口写复位位,等待复位完成。
  3. 用默认地址0和控制端点0,发送GET_DESCRIPTOR请求。
  4. 关键点:第一次GET_DESCRIPTOR只请求8字节,用来读取设备描述符的bMaxPacketSize0字段。如果一次性请求18字节,很多设备会直接返回错误,或者把控制传输搞挂。
  5. 拿到最大包长度后,发送SET_ADDRESS,给设备分配一个新地址。
  6. 用新的设备地址重新发送完整的GET_DESCRIPTOR,拿到完整的18字节设备描述符。
  7. 再读配置描述符,解析接口和端点信息。
  8. 发送SET_CONFIGURATION,让设备进入工作状态。
  9. 对海量存储设备,再走BOT协议:发送CBW(Command Block Wrapper),等待CSW(Command Status Wrapper),和U盘进行真正的读扇区交互。

这套流程里,第4步是最典型的OS开发坑。我一开始图省事,直接请求18字节,结果U盘返回的数据长度不对,导致缓冲区里塞进了莫名其妙的数据,后续描述符解析全乱。

在UHCI里,所有传输描述符(TD)和队列头(QH)都要放在物理内存中,而且要求4KB边界对齐。我为此专门写了一个对齐分配函数。代码层面大概长这样:

typedef struct { uint32_t next_td; // 下一个TD的物理地址 uint32_t status; // 状态和控制 uint32_t token; // 令牌,包含端点号、PID、数据方向 uint32_t buffer; // 数据缓冲区物理地址 } usb_td_t;

如果你在数据结构上不加__attribute__((aligned(16)))或者类似对齐属性,UHCI的DMA就会读到错位的数据,然后表现得玄之又玄。

3.3 调试USB用到的三板斧

这块是纯干货,按推荐程度排序:

  • 串口日志:在枚举的每个阶段打印状态,包括发送了什么请求、收到了什么响应、当前状态机的停滞点在哪里。这是最高效的方式,没有之一。
  • 用Linux主机的usbmon抓包做比对:在宿主机上抓真实U盘枚举的数据流,再用Wireshark打开看,然后回到我的内核里,看哪一步和正确的抓包结果不一致。这个办法帮我定位了一个从XP时代流传下来的“先复位再读取”时序问题。
  • QEMU的参数配合:用-device usb-storage,drive=...挂U盘镜像,再配合-d int查看中断日志。如果你对中断处理不熟,这一步能省很多事。

还有一个硬件层面的插曲:我一度在实机上遇到U盘反复枚举,像是端口复位风暴,软件逻辑完全没毛病。后来查了一圈,发现是USB PHY的供电滤波没做好,板上给PHY供电的自举电容和去耦电容布局不佳,导致电压纹波偏大,设备在握手阶段频繁掉线。这个坑和软件无关,但它让我学会了在遇到“逻辑正确但不工作”的情况时,先检查电源和硬件稳定性,别只盯着协议栈猛调。

我整理了USB调试中最常见的几个坑:

表现对策
第一次GET_DESCRIPTOR请求全量长度设备不响应或返回乱码只请求8字节,读取bMaxPacketSize0
TD没做内存对齐DMA读到错位数据使用4KB对齐分配器,或确保结构体对齐
忘记清中断状态系统陷入中断风暴卡死处理完中断后立即写清除位
缓存一致性问题外设写的缓冲区CPU看不到对DMA缓冲区执行合适的缓存失效指令
端口复位后没等待设备地址分配失败在复位后增加延时或轮询稳定状态

USB协议栈是那种“你以为自己懂了,跑一遍就老实了”的东西。我的建议是:新手先用UHCI和USB键盘起步,搞清楚端点0、控制传输和中断传输之后,再上BOT和U盘。直接上U盘等于把枚举、BOT、批量传输三大难点一次性砸到你脸上,心态容易崩。

4. OS自举:旧内核怎么把新内核“举”起来

“OS自举”这个词,我把它拆成两层来理解。第一层是最常见的bootstrapping,也就是机器加电后从Bootloader开始,一级一级把操作系统加载起来。第二层是我这个月做的更有意思的事:让已经运行起来的内核,在运行期间从磁盘读取一个新内核镜像,然后像凤凰涅槃一样把自己更新掉,整个过程不重启硬件。第二层才是我标题里“OS自举”真正的重头戏。

4.1 两层自举:Bootloader的“被自举”和内核的“自己举自己”

先说第一层,传统引导链:BIOS读取512字节的主引导扇区,我把这段代码叫boot0,它只有一项职责——从磁盘读取boot1。boot1大概占3个扇区,用16位实模式代码写成,负责找到FAT32分区下的KERNEL.BIN,把它搬进内存,然后切换到保护模式,跳进内核入口。

这个做法是为了绕开512字节的物理限制。MBR里塞不下FAT32解析器和ELF加载器,所以必须拆成两级。boot0只做最小的事:读固定扇区到0x7E00,跳过去。boot1才开始干真正的活。如果你在做一个自己的OS,建议尽早确定这种多级引导结构,别试图挑战512字节的天花板。

第二层,运行时自举,源于一个很实际的需求:每次改完内核都要重启QEMU、重新加载镜像,开发效率太低。我希望能像Unix的init那样,直接在一个正在运行的OS里执行一条boot /kernel2.bin,让系统切换到新内核去运行。

这个需求听起来很酷,做起来全是雷。核心挑战是:正在运行的代码要加载并跳转到另一份代码,而且两份代码的地址空间、文件系统、设备状态可能完全不同。你不能随便跳,否则新内核的第一条指令就可能老崩。

4.2 运行时自举的实现要点(含伪代码)

我最终实现的切换流程大致是:

  1. 在内核里解析新镜像路径,通过VFS读取文件。
  2. 校验ELF头魔数和版本,解析程序头表,把各个段加载到指定物理地址。
  3. 在页表里同时保留旧内核和新内核的映射,确保拷贝过程中不会因为源或目的页不存在而触发page fault。
  4. 屏蔽中断,杜绝切换过程中被中断打断。
  5. 建立新的GDT和IDT,加载TSS,设置新的内核栈。
  6. 切换页表,跳转到新内核入口。

核心伪代码是:

void do_boot(const char *path) { // 1. 读取新内核镜像到临时地址 load_elf(path, (void *)NEW_KERNEL_ADDR); // 2. 建好新页表,并映射当前代码段 pgd_t *new_pgd = build_new_pgd(); map_page(new_pgd, (uintptr_t)&do_boot_switch, (uintptr_t)&do_boot_switch); // 3. 屏蔽中断后开始切换 cli(); load_gdt(new_gdt); load_idt(new_idt); write_cr3((uintptr_t)new_pgd); set_kernel_stack(new_stack); // 4. 跳转到新内核入口 asm volatile("mov %0, %%rsp; jmp *%%rax" : : "r"(new_stack), "a"(entry_addr)); }

这里最反直觉的一点是:切换页表时,CPU可能正好执行到一处只有旧内核映射、新内核没映射的代码地址。如果不把切换代码所在的物理页同时映射到新旧两个虚拟地址,或者是使用恒等映射,跳转后直接触发page fault。我在第B158号bug上就栽在这里,切换瞬间三重故障,QEMU直接重启,查了一整个下午才定位到问题。

另一个陷阱是中断。切换GDT/IDT之前必须把中断关掉,否则一个时钟中断打进来,CPU会拿着新的IDT去找旧的中断处理函数,而那个函数可能已经被新内核镜像覆盖了,结果就是ASTRAY、reset、心态崩。

4.3 自举引发的Bug和回滚策略

自举功能上线后,我新添了18个bug,其中我觉得最有价值的是“回滚策略缺失”的问题。最初自举失败时,系统只能三重故障重启,旧内核和新内核都没法用,只能靠QEMU重新加载镜像。后来我在内核镜像头部加了一个版本号和校验和字段,Bootloader在加载时会校验,如果校验失败,就自动加载上一个preferred镜像。这套方案很朴素,但救了我好几次。

常见的自举相关坑,我整理成了一张表:

表现原因与解法
新内核没有初始化串口跳转后什么日志都没有在启动早期初始化串口,或保留公共日志缓冲区
页表切换后代码区不可见三重故障对切换代码段做恒等映射或双映射
新内核依赖旧内核的中断表时钟中断后崩溃在跳转前重建IDT并加载
新内核入口模式不对莫名其妙地跑飞检查链接脚本,入口函数和CPU模式要匹配
拷贝镜像覆盖当前执行代码执行到一半代码变成数据使用memmove语义,或把源目标放到不重叠的地址
TLB/cache未刷新跳转后读到旧指令执行invlpg和sfence,必要时刷Cache

运行时自举这种东西,如果没有特别刚需,建议放在内核功能的中后期再做。它的难度并不是单点技术,而是把内存管理、中断异常、设备重初始化全部串在一起,任何一个环节没有想清楚,都会在“跳转那一瞬间”一次性爆发。但一旦把它跑通,你对“操作系统是怎么启动的、又是怎么被加载的”这件事的理解,会比看一百遍文档都深。

5. 可复用的Bug方法论:大厂那套规范在OS项目里怎么落地

165个bug修完,我最大的收获反而不是哪段代码,而是一整套适配OS开发的bug管理方式。大厂里常见的规范是代码评审、单测、持续集成,这些在应用层项目里很好用,但搞OS时要裁剪,不能照搬。

5.1 从“45到165”学到的三件事

第一,测试覆盖必须跟功能边界同步。很多人是先写功能,等模块写完再补测试,但OS开发里功能之间耦合度极高,等你写完才发现底层接口设计失误,返工成本很大。我的做法是每个模块至少提供一个自检函数,在内核启动时选择执行,让它可以脱离完整系统单独验证。

第二,一个bug一个提交。这个是最朴素的版本管理纪律,它让我的git bisect真正可用。如果一次提交里塞了三四个改动,出了问题你只能瞪眼猜。拆开提交之后,二分定位一个bug通常只需要几分钟。

第三,用回归脚本挡住门级错误。我写了一个scripts/boot_test.sh,每次提交前都自动启动QEMU,跑一遍基础用例:内存分配、文件写入读取、USB设备枚举、内核自举。只要有一项失败就不允许提交。这套脚本很简陋,但它在两周内帮我拦截了至少20个低级回归问题,COST比写脚本要低得多。

5.2 “我踩过最值的坑”清单

如果只让我选几个印象最深的,给后来人讲,我会毫不犹豫地列这些:

模块最值的一个坑你能带走的建议
块设备Ramdisk挂成了正式磁盘持久化必须用冷启动测试验证
ATA漏了Cache Flush真机数据丢失先用排除法确认有没有刷盘
USB第一次GET_DESCRIPTOR请求了全量枚举时严格按规范分批读取
USBTD和缓冲区未做内存对齐DMA结构体必须物理对齐
自举页表切换后下一条指令无映射切换代码要做恒等映射或双映射
自举新内核初始化串口太晚启动早期就初始化调试输出

这些坑之所以“值”,是因为它们每一个都让我重新审视了系统底层的某种假设。比如“写入成功”不等于“真正落盘”,“枚举成功”不等于“设备可用”,“跳转到新内核”不等于“新内核能活下来”。搞OS最大的乐趣,就是不断打破这些想当然。

5.3 给想从零写OS的朋友的路线建议

如果你也想写一个自己的OS,我的建议是别一上来就奔着“图形界面+网络”去,先老老实实做基础四件套:引导、内存、中断、定时器。这四个搞定之后,再按顺序挑战块设备、文件系统、USB键盘、USB存储、内核自举。每跨过一个阶段,你对计算机系统的理解都会上一个台阶。

调试工具方面,QEMU-s -S配合gdb看实模式还是很好用的,但进入保护模式和长模式之后,我就更依赖串口日志和断言了。Bochs对8086实模式支持得很细,适合看Bootloader问题。如果你在Linux宿主机上调USB,usbmon加Wireshark这个组合强烈推荐,它能把真实设备的枚举过程原原本本地录下来,对照调试非常直观。

最后再分享一个小技巧:在OS项目里,日志系统越早做越好。不用太复杂,一个串口输出,一个环形缓冲区,再加上模块前缀和级别就够用。很多“灵异bug”到最后其实都是“早期没日志、后期难复现”造成的。有了日志,至少你能在系统死掉之前看到它是怎么一步步走向崩溃的。

我现在回头再看那120个新bug,印象最深的不是那些复杂的队列锁,反而是假持久化那个看起来“一切正常”的骗局。它教会我的规则很简单:如果一份数据对你的系统重要,那就必须用“重启之后再读”去验证它;如果一段代码声称自己已经跑通,那就得在真实边界条件下反复敲打。搞OS本来就是一个不断怀疑自己、再不断验证自己的过程,这个过程本身,可能比那个“能跑起来的内核”更有意思。

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

MATLAB反演正则化实战:用IRtools解决不适定问题

简介&#xff1a;本资源是面向科研人员与高年级本科生的MATLAB不适定问题求解工具包&#xff0c;聚焦逆问题建模中的正则化核心难点&#xff0c;适用于地球物理反演、医学成像、信号去噪等强噪声、小样本场景。IRtools-master提供完整可运行的正则化算法实现&#xff0c;涵盖Ti…

作者头像 李华
网站建设 2026/9/13 20:58:14

房地产宣传片制作全攻略(类型_流程_价格_案例)

房地产宣传片是楼盘营销的重要武器&#xff0c;一部好的宣传片能在几分钟内传递项目的核心价值、激发客户的购买欲望。但很多开发商对宣传片的类型选择、制作流程、价格区间缺乏了解。本文全面解析房地产宣传片制作的方方面面。 一、房地产宣传片的 5 种类型 类型 内容重点 时…

作者头像 李华
网站建设 2026/9/13 20:57:52

Python语音处理:用librosa提取MFCC特征完整指南

简介&#xff1a;面向音频处理与机器学习入门者的MFCC特征提取示例代码包&#xff0c;使用Python语言和librosa库实现&#xff0c;可直接运行并生成直观的梅尔频率倒谱图。程序能够读取wav格式音频&#xff0c;计算梅尔频率倒谱系数&#xff0c;并将结果以谱图形式呈现&#xf…

作者头像 李华
网站建设 2026/9/13 20:54:01

AI对话生成零代码应用:从数据表到自动化工作流的完整实践

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

作者头像 李华