从零构建现代搜索引擎(00):最终要做出怎样的搜索引擎
在技术博客里搜"Java NullPointerException at line 42",搜索框返回了一堆标题带"Java"的文章,没有一篇提到这个异常。换成 Stack Overflow,第一条就是对应的解答。差距在哪里?不在界面,在从文档到查询结果之间的每一层处理。 这个系列要做的事情是:从一个空目录开始,逐步构建一套能抓取站点、索引中英文文档、接受关键词和自然语言查询、返回带摘要和排序的搜索结果的检索系统。不调用 Elasticsearch API,不安装现成搜索服务——先亲手写倒排索引和 BM25,再接入 Lucene,加上向量召回和模型重排序。 最终演示:搜索引擎能做什么 先把终点亮出来。系列完成时,系统应该支撑以下操作: 操作 可观察结果 验收证据 导入本博客已发布文章 + 抓取获准文档站 标题、正文、URL、语言、抓取时间进入统一文档模型 数据清单、抓取日志、去重统计 搜索错误码、中文术语和自然语言问题 精确标识符保留词法优势,语义检索补充同义表达 同一查询下 BM25、向量、混合、重排序四组结果 修...
从零编写操作系统 12 - 内核堆:在物理页内分配、分裂与合并
物理页分配器每次交出 4096 字节,队列节点、字符串和小结构体却通常用不满一页。 本篇在已经建立恒等映射的物理页内实现 kmalloc 和 kfree,按请求长度分配空间,并在释放时合并相邻空闲块。 堆最多持有四个独立页面,返回地址按 16 字节对齐,单次请求上限为 4076 字节。 实验继续运行在单 CPU、64 MiB RAM 的 qemu32 环境中,所有代码位于 ring 0。 正常镜像验证不同大小的读写、真实耗尽和回收;独立故障镜像破坏对象尾部标记,记录拒绝结果后主动进入诊断停机。 尾部标记由软件检查,越界写入发生时不会因此自动产生硬件异常。 页分配和字节分配各自管理什么 page_alloc() 依据 E820 和保留区规则选择可用物理页,返回物理地址。 它只记录页面归属,不知道页内有几个对象,也不知道其中某个对象申请了多少字节。 如果一个 17 字节对象独占一页,剩余空间仍然被这次页分配占用。 堆从页分配器取得整页,再用块头描述页内区间。 同一页可以同时容纳多个对象,释放一个对象后,其他对象继续有效。 分页还提供了另一项必要条件:这些物理字节必须能被当前虚拟地址...
从零编写操作系统 11 - 分页与页故障:修改映射后重试同一条指令
物理页分配器已经能交出一页空闲 RAM,但它不决定一条访存指令最终访问哪一页。 本篇建立 32 位两级页表,开启分页,再让同一个虚拟地址先后访问两个物理页。 随后触发两次真正的页故障:一次读取未映射页,一次向只读页写入;处理函数修复映射,返回后重试原指令。 实验沿用单 CPU、64 MiB RAM 的 qemu32 模拟机,所有代码运行在 ring 0。 低 64 MiB 保持恒等映射,分页开启前后继续使用原 C 栈。 页目录和页表共占用 18 个物理页,演示结束后仍然保留;用于读写的两个数据页则归还分配器。 一次访存经过哪几种地址 在此前的平坦段模型中,代码段和数据段基址都是 0。 指令使用的偏移经过分段后得到同值的线性地址,分页关闭时,再按此前建立的地址环境访问物理内存。 开启分页后,线性地址需要经过页表翻译;本文把这一层输入称为虚拟地址。 物理页分配器返回的 uint32_t 仍然表示物理地址。 把它转换为 C 指针,得到的却是一次线性地址访问。 只有相关地址已经建立适当映射,这种直接转换才有效;分页不会自动给所有分配结果补上映射。 本篇刻意保留低地址恒等映射,使物理页地...
从零编写操作系统 10 - 物理页分配:哪些内存真正可以交出去
内核已经能处理时钟中断和键盘事件,但新增数据仍依赖静态数组。 页表、任务栈或文件缓存需要动态取得内存时,必须知道哪些物理地址可以使用、哪些已经分配,以及释放是否有效。 本篇把第 04 篇取得的 E820 内存图转换成 4 KiB 物理页分配器。 实验仍使用单 CPU、64 MiB RAM 的 qemu32 模拟机,分页保持关闭。 分配器管理固定上限内的页框,返回物理地址;它不建立虚拟地址映射,也不提供任意字节大小的 malloc。 实测初始空闲页数为 16087,客体耗尽全部页面、验证每页数据并释放后,空闲数恢复为 16087。 E820 可用内存还需要扣除内核占用 E820 描述固件报告的系统地址范围。 内核已经装入内存,却不意味着固件会把这段地址从 type 1 区域里扣掉。 若直接把所有 type 1 页面交给调用者,分配结果可能覆盖正在运行的代码或栈。 ACPI 6.5 第 15 章定义了 BIOS 内存图接口和范围类型。 其中 type 1 表示可用 RAM;其他类型不能在本实验中直接当作普通空闲页。 低地址区域也可能有固件或引导程序用途,规范 §15.2要求调用方考...
从零编写操作系统 09 - 键盘与事件队列:把中断接收和字符处理分开
键盘接入后,内核需要同时处理两种节奏:设备产生扫描码时必须及时接收,字符转换、退格和整行输出则可以留在前台执行。把打印放进 IRQ1 会延长关中断时间,也会让输入设备直接依赖控制台。 本篇在已有异常入口和 PIC 重映射上接入 PS/2 键盘。IRQ1 只接收原始字节并放进队列;主循环负责 US set 1 解析与简单行编辑。输入回车后显示 LINE: ...,尚未执行命令,也没有 shell。 IRQ1 接到已有中断入口 第 08 篇把主 PIC 的向量起点设为 0x20。键盘接主片 IRQ1,因此向量为 0x21,即十进制 33。 123456789PS/2 键盘扫描码 ↓i8042 输出缓冲区 → IRQ1 ↓主 PIC → IDT[33] → 公共汇编入口 ↓trap_dispatch → keyboard_irq ↓ 原始字节入队,主 PIC EOIiret 恢复前台 → 出队 → 解析 → 行编辑 kernel/exceptions.c 现在初始化前 34 个 IDT 表项,0—31 为异常,32 为时钟,33 为键盘。其余表项仍不在场,不能...
从零编写操作系统 08 - 时钟中断:让外部事件打断当前执行
上一篇的异常由正在执行的指令触发。时钟中断来自处理器外部:即使内核没有执行 int,定时器也能发出请求,经中断控制器送到 CPU,再通过 IDT 进入处理函数。 本篇接入传统 PC 的 8259A PIC 与 8254 PIT,只开放 IRQ0。正常内核收到 20 次时钟中断后关闭外部 IRQ,继续原有控制台自检;两个独立诊断镜像验证屏蔽、恢复和漏发 EOI 的后果。入口仍沿用上一篇的保存与恢复路径,尚未加入调度或任务切换。 从 PIT 输出到 IDT 第 32 项 PIT 的 channel 0 产生周期信号,连接主 PIC 的 IRQ0 输入。PIC 根据屏蔽位和优先级判断是否请求 CPU 响应;CPU 接受请求后获得向量,再查 IDT。 12345678910111213PIT channel 0 周期输出 ↓主 PIC 的 IRQ0 输入 ↓ IMR、优先级及 CPU 当前 IF 允许CPU 接受可屏蔽外部中断 ↓IDT[0x20] → isr32 → exception_common ↓trap_dispatch(frame) → timer...
从零编写操作系统 05 - 跳进 C:入口、链接脚本与内存布局
第 04 篇的保护模式入口停在一段 32 位汇编里。本篇从这里继续,把磁盘中的真实内核复制到物理地址 0x100000,准备 BSS 和栈,再调用 kernel_main。 屏幕上的 KERNEL_MAIN OK 有几个前提:已初始化全局变量保持原值,未初始化全局变量为零,普通函数调用能返回,BootInfo 指针和调用栈符合约定。这些条件都在本篇镜像里检查;另一个故障镜像故意跳过 BSS 清零,输出 KERNEL BSS FAIL。 编译器不负责启动内核 GCC 生成指令,链接器安排地址,磁盘读取和运行环境由启动代码提供。-ffreestanding 选择独立环境,不能代替加载器,也不会附带完整标准库。普通应用依赖的启动对象、进程栈和零初始化内存,在这里都需要有明确来源。 第 03、04 篇的 payload 是 20000 字节测试数据。本篇将它替换为 C 和汇编编译得到的 kernel.elf,再经 objcopy -O binary 得到平坦文件。第二阶段仍读取固定 LBA 的镜像头与 payload,不解析 ELF 的 program headers。 1234567...
从零编写操作系统 04 - 进入保护模式:建立第一份运行环境
第 03 篇已经让第一阶段启动扇区加载第二阶段,也让第二阶段读取载荷并检查边界。它仍然停留在实模式。实模式下可以方便调用 BIOS,也能通过操作数前缀使用 32 位寄存器;后续内核需要的平坦地址空间和权限隔离仍要建立在保护模式上。 本篇完成第一次处理器模式切换:在进入保护模式前收集 E820 内存图,打开 A20,建立 GDT,设置 CR0.PE,再用远跳转刷新代码段。切换完成后,32 位代码直接写 VGA 文本显存,GDB 能观察到保护模式状态。BIOS 调用到这里为止,后面的内核不能再按实模式方式直接调用 int 10h 或 int 13h。 为什么先把 BIOS 调用做完 BIOS 服务是实模式时代的接口。第 03 篇依赖它读盘;本篇还要依赖它查询内存图。进入保护模式后,CPU 的段解释、地址宽度和中断入口都变了。没有虚拟 8086 模式、实模式回跳或专门 BIOS 调用桥时,内核不能继续把 int 15h 当作普通函数使用。 切换前后的准备顺序如下: 1234567891011real mode ├─ read disk through BIOS EDD ├─ pr...
从零编写操作系统 03 - 两阶段引导:加载比一个扇区更大的程序
第 02 篇的启动扇区已经能被 BIOS 执行,并在屏幕上显示启动盘编号。继续增加读盘、内存图和模式切换代码时,第一扇区的空间很快就会用完。本篇让第一阶段读取一个更大的第二阶段,再由第二阶段读取并校验载荷。 这一版读取 20000 字节的确定性测试数据,分两次放到低端内存,验证首尾标识与校验和后停机。载荷仍是数据,没有执行 C 内核,也没有进入保护模式。 本系列当前阶段在 QEMU pc-i440fx + SeaBIOS 环境开发与验证;对物理硬件或其他虚拟机的适配将在后续篇章讨论。 两次加载分别解决什么 BIOS 先读取 LBA 0。第一阶段只有 512 字节,末尾保留字节 55 aa,偏移 446–509 仍留给未来分区表。第二阶段占用 8 个扇区,第一阶段将其装入物理地址 0x8000 后远跳转。 12345678910BIOS -> MBR at 0000:7c00 | | INT 13h AH=42h: LBA 1..8 v stage2 at 0000:8000 | ...
从零编写操作系统 07 - 异常入口:CPU 出错后把哪些信息交给内核
串口和 panic 可以报告代码主动检测到的错误,但前提是程序执行到了检查位置。除数为零、指令无效、段选择子越界,会让处理器直接转入异常处理。没有异常入口,上一篇建立的输出能力也无从调用。 本篇安装 IDT,把异常现场整理成 C 结构,打印向量、错误码和寄存器。一个受控 INT3 探针验证返回路径,除零、非法指令和一般保护异常分别验证故障路径。范围限定在 32 位保护模式、同一特权级、固定 QEMU 教学配置;设备 IRQ 尚未启用。 IDT 决定异常从哪里进入 处理器通过异常向量查找中断描述符表 IDT。向量 0 对应除法错误 #DE,3 对应断点 #BP,6 对应无效操作码 #UD,13 对应一般保护异常 #GP。IDT 中的门描述符给出目标代码段和入口地址。 1234567891011指令触发异常 ↓CPU 按向量查 IDT,检查门与目标代码段 ↓CPU 保存返回现场,转入对应汇编 stub ↓stub 补齐向量与错误码,公共入口保存寄存器 ↓trap_dispatch(frame) ├─ 受控 INT3:返回公共入口,恢复现场,IRETD ...

