从零写OS(四十四):UDP Socket + DNS 解析

上一章(四十三)把 TCP 重传做好了。这一章加两个新功能:UDP socket 用户空间接口 和 DNS 解析 syscall,让用户程序可以用域名来连接服务器。 目标 内核里其实早就有 udp_send 和 dns_resolve 这两个函数了,但用户程序用不到,因为没有对应的 syscall。这章要做的就是把这两个内核能力"打通"到用户空间: socket(AF_INET, SOCK_DGRAM, 0) → 分配一个 UDP socket,返回 fd 200+ sendto / recvfrom → 通过 UDP socket 收发数据 SYS_DNS_RESOLVE(自定义 syscall 500)→ 通过域名查 IP UDP Socket 设计 UDP 比 TCP 简单很多:无连接、无状态机、无重传。核心是一个接收队列:内核收到 UDP 包时,把它放进对应 socket 的队列,用户程序再来取。 typedef struct { uint32_t src_ip; uint16_t src_port; uint16_t len; uint8_t data[512]; // 每个包最多 512 字节 } udp_pkt_entry_t; typedef struct { int used; uint16_t local_port; udp_pkt_entry_t queue[4]; // 最多暂存 4 个包 uint32_t qhead, qtail; } udp_sock_t; 队列用无限增长的 qhead/qtail 计数(不是环形下标),访问时用 % UDP_PKT_MAX,满了就丢包: ...

June 2, 2026 · 3 min · 大飞

从零写OS(三十二):启动 busybox sh —— 用户指针与内核页表的陷阱

ch31 已经能跑 musl libc 的 hello world 了,这一章目标更高:启动 busybox sh,看到 / # 提示符。busybox 是个真正的程序,碰到的问题也更真实。 准备工作 获取静态编译的 busybox wget https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox chmod +x busybox file busybox # busybox: ELF 64-bit LSB executable, x86-64, statically linked 更新 ext2 镜像 busybox 需要 /bin/sh、/etc/passwd、/etc/group: sudo mkdir -p /tmp/ext2mnt/bin sudo mkdir -p /tmp/ext2mnt/etc sudo cp $(BUSYBOX) /tmp/ext2mnt/bin/busybox sudo cp $(BUSYBOX) /tmp/ext2mnt/bin/sh printf 'root:x:0:0:root:/root:/bin/sh\n' | sudo tee /tmp/ext2mnt/etc/passwd > /dev/null printf 'root:x:0:\n' | sudo tee /tmp/ext2mnt/etc/group > /dev/null Bug 1:GDT TSS 描述符溢出 x86_64 下 TSS 描述符是 16 字节(两个 qword),GDT 必须为它预留两个连续槽位。之前只预留了一个,导致 TSS 描述符的高 8 字节覆盖了相邻的全局变量(恰好是 mmap_next),进程一分配匿名内存就跳到奇怪的地址。 ...

May 22, 2026 · 2 min · 大飞

从零写OS(三十一):运行 musl libc 程序 —— 两个隐藏的寄存器 Bug

上一章对齐了 Linux syscall ABI,现在试着跑一个真正用 musl libc 静态编译的 C 程序。目标很简单: #include <stdio.h> int main(int argc, char **argv) { printf("hello from musl libc!\n"); printf("argc=%d\n", argc); return 0; } 编译:musl-gcc -static -O2 -o hello.elf hello.c 结果踩了两个隐藏很深的寄存器 bug,花了不少时间才找出来。 新增的 syscall musl libc 启动时会调用一批 syscall,其中有三个是绕不过的: syscall 编号 说明 arch_prctl(ARCH_SET_FS, addr) 158 设置 TLS base(FS 寄存器) set_tid_address(tidptr) 218 设置线程 ID 地址,返回 pid writev(fd, iov, iovcnt) 20 向量写,musl 的 stdio 用它输出 arch_prctl 需要写 MSR 0xC0000100(FS.base),这是 TLS 的基地址,musl 用来存放线程局部变量: case SYS_ARCH_PRCTL: if (a1 == ARCH_SET_FS) { wrmsr(0xC0000100, a2); // FS.base MSR return 0; } return (uint64_t)(-EINVAL); writev 需要遍历 iov 数组,把多段数据拼起来写到 tty: ...

May 22, 2026 · 3 min · 大飞

从零写OS(三十):对齐 Linux syscall ABI,让 musl libc 能跑起来

musl libc 编译出来的程序会直接用 syscall 指令,传的是 Linux x86-64 标准调用号。我们之前的调用号是自己编的,如果不对齐,musl 程序一个 syscall 都跑不通。这一章把内核的 syscall 接口全面对齐 Linux ABI。 Linux x86-64 syscall 约定 Linux 的 x86-64 syscall 约定: 调用:rax=syscall号,rdi=a1,rsi=a2,rdx=a3,r10=a4,r8=a5,r9=a6 返回:rax(负数表示错误,如 -ENOENT = -2) CPU 自动保存:rcx=用户 rip,r11=用户 rflags 注意第 4 个参数是 r10,不是 rcx。原因是 syscall 指令会把用户 rip 存进 rcx,所以 r10 顶替了 rcx 的位置。这一点非常容易踩坑。 关键改动 1. syscall 号全部换成 Linux 标准 之前的调用号是自己定义的,现在全部换成 Linux 标准值: 功能 之前 现在 read 6 0 write 1 1 ✓ open 5 2 close 7 3 fork 3 57 exit 2 60 kill 12 62 write 碰巧没变,其他大部分都不一样。 ...

May 22, 2026 · 3 min · 大飞

从零写OS(二十四):signal 信号机制

到目前为止,进程只能顺序跑完,没有"被打断"的能力。按 Ctrl+C 没有反应,子进程崩溃父进程不知道,kill 命令更无从谈起。 这一章实现 signal——内核向进程发送异步通知的机制。 信号是什么 信号是一个整数编号,内核用它告诉进程"发生了某件事": 信号 编号 默认行为 SIGUSR1 10 终止 SIGKILL 9 终止(不可捕获) SIGTERM 15 终止 SIGCHLD 17 忽略 进程可以用 signal(sig, handler) 注册自定义处理函数,也可以接受默认行为。 关键:信号不立刻打断进程 信号发送时只是设置一个 pending 位,不立刻跳转。等进程下次从内核态返回用户态时,才检查并派发: SYS_KILL → pending_signals |= (1 << sig) syscall 处理完 → 准备 sysret ↓ 检查 pending_signals ↓ 有信号 → 操纵 user_rip/user_rsp → sysret 跳到 handler handler 执行完 ret → 回到原来被中断的位置 这和硬件中断不同——硬件中断可以在任意时刻打断 CPU,信号只在内核→用户的切换点才生效。 派发机制(trampoline) signal_dispatch 收到两个指针:内核栈上保存的 user_rip(原来准备 sysret 到的地址)和 user_rsp。它做的事: 1. user_rsp -= 8 2. *(user_rsp) = user_rip // 原返回地址压到用户栈 3. user_rip = handler_addr // sysret 改跳到 handler sysret 跳到 handler,handler 执行完 ret,弹出用户栈上的原 rip,回到 kill syscall 之后继续。 ...

May 14, 2026 · 2 min · 大飞

从零写OS(二十二):管道 pipe 与 dup2

上一章实现了文件描述符,进程可以用 open/read/close 访问 ext2 文件。但两个进程之间还没有办法通信。shell pipeline cmd1 | cmd2 的本质是:cmd1 的输出直接流进 cmd2 的输入,中间不落磁盘。 这一章实现 pipe 和 dup2,打通进程间通信的最小路径。 pipe 是什么 pipe 是内核里的一块环形字节缓冲区。系统调用 pipe(fds) 返回两个文件描述符: fds[0] = 读端 fds[1] = 写端 写进程 → fds[1] → [内核 ring buffer 256字节] → fds[0] → 读进程 和普通文件一样,管道也走 fd → VFS → 底层数据 这三层,只不过底层数据不是磁盘,而是内核里的 pipe_t。 dup2 是什么 dup2(oldfd, newfd) 让 newfd 指向和 oldfd 同一个内核文件描述符,如果 newfd 已经打开则先关掉它。 shell pipeline 的核心操作就是 dup2 + exec: // 子进程(cmd1),stdout → pipe 写端 dup2(fds[1], 1); close(fds[0]); close(fds[1]); exec("cmd1"); // cmd1 以为自己在写 stdout,实际写进了管道 // 父进程(cmd2),stdin → pipe 读端 dup2(fds[0], 0); close(fds[0]); close(fds[1]); // 关掉写端,否则 cmd2 永远等不到 EOF exec("cmd2"); pipe 的 EOF 语义 read 管道什么时候返回 0(EOF)? ...

May 14, 2026 · 2 min · 大飞

从零写OS(二十一):文件描述符 fd

前几章的用户程序如果想读文件,得直接传 inode 号给内核——read(ino, offset, buf, len)。这意味着用户程序要自己记当前读到哪了,而且根本不知道文件名,只有一个数字。 这一章引入 文件描述符(fd),让用户程序用熟悉的方式操作文件: int fd = open("hello.txt"); int n = read(fd, buf, 64); close(fd); fd 是什么 fd 是一个进程内的整数,通常从 0 开始分配(0=stdin,1=stdout,2=stderr,之后是普通文件)。 它背后有三层: 进程 fd_table[] 内核 open_file 磁盘 fd=3 ──────────→ { ino=42, offset=0 } ──→ hello.txt fd=4 ──────────→ { ino=43, offset=0 } ──→ readme.txt fd 本身只是下标,真正存 offset 的是内核里的"打开文件"结构。这样的好处: offset 由内核自动推进,用户不用管 fork 后父子可以共享同一个打开文件(共享 offset) 文件、管道、设备用同一套接口,背后实现不同 实现:每个进程一张 fd 表 在 process_t 里加一个数组: typedef struct { // ... int32_t fd_table[PROC_MAX_FD]; // fd → vfs_fd,-1 表示空槽 } process_t; fd_table[fd] 存的是 VFS 层的内部 fd(VFile 数组下标)。 ...

May 14, 2026 · 2 min · 大飞

从零写OS(二十):wait / exit 完整语义

上一章实现了 exec(),用户程序能跑起来了。但进程退出时只是把进程表项清掉,父进程拿不到退出码,也没有办法等待。这一章实现完整的 exit / wait 语义。 为什么进程退出后不能直接消失 直觉上,进程退出就应该立刻清掉进程表项。但这里有个问题:父进程怎么拿到子进程的退出码? 如果进程表项直接清掉,退出码就丢了。父进程调用 wait() 的时机可能比子进程 exit() 晚,也可能早——无论哪种情况,父进程都需要能读到那个退出码。 Linux 的解法是引入**僵尸(zombie)**状态:进程 exit() 后不立即消失,保留退出码,等父进程 wait() 读取后才彻底释放。 这就是为什么 ps 输出里偶尔会出现状态为 Z 的进程——它已经退出了,只是在等父进程来"收尸"。 进程状态机的变化 原来进程只有三个状态:UNUSED、READY、RUNNING。这一章加两个: UNUSED → READY → RUNNING ↓ exit() ZOMBIE ← 已退出,等父进程 wait ↑ 唤醒 BLOCKED ← 父进程 wait 时挂起 调度器只挑 READY 的进程运行,ZOMBIE 和 BLOCKED 自然就不会被调度到,不需要改调度器的逻辑。 exit 和 wait 的实现思路 exit 做三件事: 把退出码存到进程表项,状态改为 ZOMBIE 如果父进程正在 BLOCKED 等待,把它改回 READY(唤醒) 切回内核栈和内核页表,hlt 循环等待被回收 wait 做的事: 扫描进程表,找有没有自己的子进程且状态是 ZOMBIE 找到了:读退出码,把它设为 UNUSED,返回退出码 没找到但有活着的子进程:挂起等待(ring3)或返回 -2 让调用方重试(ring0) 没有子进程:返回 -1 parent_pid 需要在 exec / fork 时记下来,这样 exit 才知道该唤醒谁,wait 才知道哪些进程是自己的子进程。 ...

May 13, 2026 · 2 min · 大飞

从零写OS(十九):exec 与用户程序

之前所有代码都跑在内核态(ring0)。这一章第一次让用户程序在 ring3 里运行——加载 ELF 文件,跳到用户态执行,用户程序通过 syscall 和内核通信。 这是操作系统最核心的一个边界:内核和用户程序之间的隔离。 exec 做什么 exec 的语义是"用一个新程序替换当前进程": 从文件系统读 ELF 文件 解析 ELF header,找到各个段的加载地址 创建新的用户页表,把各段加载进去 分配用户栈 设置进程的 rip = 入口地址,rsp = 栈顶,cs/ss = 用户段,放入就绪队列 调度器下次选中这个进程,就会跳到用户态开始执行。 进入用户态:iretq 第一次进入用户态不能用 sysret——没有对应的 syscall。用 iretq: static void enter_usermode(uint64_t rip, uint64_t cs, uint64_t rflags, uint64_t rsp, uint64_t ss) { __asm__ volatile( "push %4\n" // ss "push %3\n" // rsp "push %2\n" // rflags(IF=1,开中断) "push %1\n" // cs(0x23,ring3 代码段) "push %0\n" // rip(entry point) "iretq" :: "r"(rip), "r"(cs), "r"(rflags), "r"(rsp), "r"(ss) ); } iretq 弹出这 5 个字段,CPU 检查 cs 的 RPL 发现是 ring3,自动完成特权级切换。 ...

May 9, 2026 · 2 min · 大飞

从零写OS(十):系统调用,用户和内核的边界

前几章的"进程"其实是假的——它们直接跑在内核态,和内核同等权限,可以随意读写任何内存、操作任何硬件。 真实的操作系统里,用户程序跑在用户态(Ring 3),权限受限,不能直接操作硬件。需要内核帮忙时,必须通过系统调用这扇受控的门进入内核,做完事再回去。 这一章实现 syscall / sysret:用户态和内核态之间最快速的切换机制。 两种特权级 x86-64 有 4 个特权级(Ring 0~3),操作系统只用两个: Ring 0(内核态):可以执行任何指令,访问任何地址,操作 CR3、MSR 等特权寄存器 Ring 3(用户态):不能执行特权指令,访问不属于自己的内存会触发 #GP 或 Page Fault CS 段寄存器的低 2 位(CPL,Current Privilege Level)表示当前特权级。syscall 指令把 CPL 从 3 切到 0,sysret 把 CPL 从 0 切回 3。 为什么用 syscall 而不是中断 早期 Linux 用 int 0x80 触发系统调用——软件中断,要保存完整的中断栈帧,走 IDT 查表,开销大。 syscall / sysret 是专门为系统调用设计的快速路径:不走 IDT,入口地址直接写在 MSR 里,省去了大量压栈操作。现代 x86-64 系统全部用这对指令。 配置 MSR MSR(Model Specific Register,型号特定寄存器,CPU 内部的一组控制寄存器,用 rdmsr / wrmsr 访问)控制 syscall 的行为。需要配置 4 个: ...

May 6, 2026 · 3 min · 大飞
京ICP备14031575号-3