ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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写入有严格的状态机任何一个环节漏掉都会造成数据丢失或者读到半截数据。核心流程是等待BSY位清零确认控制器空闲。写扇区数、LBA地址的低24位再写Drive/Head寄存器设置LBA模式。发送写扇区命令0x30。等待DRQ置位表示控制器准备好接收数据。连续向数据端口写入512字节按16位为单位写256次。等待BSY位再次清零并检查错误寄存器。发送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.1EHCI对应USB2.0xHCI对应USB3.x。它们的寄存器布局、传输描述符格式、内存数据结构完全不同。我平台默认用UHCI于是从UHCI开始撸。用一个生活化的类比USB端口就像酒店前台每个外设都得到前台登记。它先表明自己的“姓名”设备描述符再告诉你它有哪些“服务”接口描述符、端点描述符然后你根据服务类型给它安排房间设置地址、设置配置。问题是前台每天要为几百个客人重复这套流程任何一个环节超时、回错包、CRC错误整个登记就要从零开始。3.2 UHCI枚举U盘的完整流程经验版我在UHCI上枚举一个U盘走了一遍完整流程每一步都是坑。整理完大概是这样的等待端口状态寄存器里连接位变化确认设备已插入。对端口写复位位等待复位完成。用默认地址0和控制端点0发送GET_DESCRIPTOR请求。关键点第一次GET_DESCRIPTOR只请求8字节用来读取设备描述符的bMaxPacketSize0字段。如果一次性请求18字节很多设备会直接返回错误或者把控制传输搞挂。拿到最大包长度后发送SET_ADDRESS给设备分配一个新地址。用新的设备地址重新发送完整的GET_DESCRIPTOR拿到完整的18字节设备描述符。再读配置描述符解析接口和端点信息。发送SET_CONFIGURATION让设备进入工作状态。对海量存储设备再走BOT协议发送CBWCommand Block Wrapper等待CSWCommand 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字节读取bMaxPacketSize0TD没做内存对齐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 运行时自举的实现要点含伪代码我最终实现的切换流程大致是在内核里解析新镜像路径通过VFS读取文件。校验ELF头魔数和版本解析程序头表把各个段加载到指定物理地址。在页表里同时保留旧内核和新内核的映射确保拷贝过程中不会因为源或目的页不存在而触发page fault。屏蔽中断杜绝切换过程中被中断打断。建立新的GDT和IDT加载TSS设置新的内核栈。切换页表跳转到新内核入口。核心伪代码是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宿主机上调USBusbmon加Wireshark这个组合强烈推荐它能把真实设备的枚举过程原原本本地录下来对照调试非常直观。最后再分享一个小技巧在OS项目里日志系统越早做越好。不用太复杂一个串口输出一个环形缓冲区再加上模块前缀和级别就够用。很多“灵异bug”到最后其实都是“早期没日志、后期难复现”造成的。有了日志至少你能在系统死掉之前看到它是怎么一步步走向崩溃的。我现在回头再看那120个新bug印象最深的不是那些复杂的队列锁反而是假持久化那个看起来“一切正常”的骗局。它教会我的规则很简单如果一份数据对你的系统重要那就必须用“重启之后再读”去验证它如果一段代码声称自己已经跑通那就得在真实边界条件下反复敲打。搞OS本来就是一个不断怀疑自己、再不断验证自己的过程这个过程本身可能比那个“能跑起来的内核”更有意思。
返回列表