这次我们来看一个非常硬核的学习项目:从零开始造一个 Docker、Git 和操作系统。这听起来像是“重新发明轮子”,但它的核心价值不在于替代现有成熟工具,而在于通过动手实现来深度理解这些系统级软件的核心原理。对于想深入计算机科学底层、提升系统编程能力的开发者来说,这是一个极佳的实践路径。

本文不会教你如何使用 Docker 或 Git,而是带你拆解如何从零构建它们的简化版本。我们将重点关注每个项目的核心概念、实现的关键模块、所需的开发环境,以及如何一步步验证你的“轮子”是否转得起来。无论你是想巩固基础知识,还是为面试中的系统设计问题做准备,这篇文章都能提供一套清晰的实操路线图。

我们将依次探讨三个核心部分:一个极简的操作系统内核、一个玩具版的版本控制系统(类似 Git)、以及一个基础的容器运行时(类似 Docker)。每个部分都会给出明确的技术栈选择、关键代码结构、以及如何编译和运行。虽然最终产物无法投入生产,但完成这个过程,你对进程、内存、文件系统、网络命名空间、联合文件系统等概念的理解将完全不同。

1. 核心能力速览

在开始“造轮子”之前,我们先明确这三个项目的目标、技术栈和产出物。下表概括了每个部分的核心要点:

项目 核心目标 主要技术栈 关键产出物 学习价值
操作系统 (OS) 实现一个能启动、打印字符、处理中断的极简内核。 C、汇编 (x86或RISC-V)、链接器脚本、QEMU模拟器 一个可启动的磁盘镜像文件,能在QEMU中运行。 理解引导流程、保护模式、内存管理、中断处理。
版本控制 (类Git) 实现基础的快照存储、提交、分支查看功能。 Python / C / Go、文件系统操作、SHA-1哈希、Zlib压缩 一套命令行工具,能初始化仓库、添加文件、提交更改。 理解对象存储模型(Blob, Tree, Commit)、有向无环图(DAG)。
容器运行时 (类Docker) 实现进程隔离(命名空间)、资源限制(cgroups)、镜像分层(联合文件系统)。 Go / C、Linux系统调用( clone , unshare , mount 一个命令行工具,能拉取基础镜像、运行隔离的容器进程。 理解容器本质、命名空间、控制组、镜像分层原理。

硬件/环境门槛 :这三个项目对硬件要求不高,但强烈建议在 Linux 或 macOS 环境下进行。Windows 用户可以使用 WSL2。你需要一个能编译 C 代码的编译器(如 gcc)、Python/Go 解释器/编译器、以及 QEMU 等模拟器。不需要高性能 GPU,主要依赖 CPU 和内存。

本文实操内容 :我们将为每个项目规划一个最小可行的实现路径,提供关键代码片段和验证步骤。你不会得到一个功能完备的产品,但会获得一套可以运行、可以扩展的代码框架,并知道每一步背后的原理。

2. 适用场景与使用边界

谁适合这个“造轮子”之旅?

  1. 计算机科学学生 :课本上的操作系统、编译原理概念过于抽象,亲手实现是最好的消化方式。
  2. 后端/基础设施开发工程师 :天天用 Docker 和 Git,想彻底搞懂其底层机制,避免成为“调参侠”。
  3. 技术面试准备者 :系统设计面试中常涉及这些主题,有实现经验会让你回答得游刃有余。
  4. 编程爱好者 :享受从底层构建系统的乐趣和成就感。

能解决什么问题?

  • 知识断层 :将分散的计算机基础知识(数据结构、操作系统、网络)串联成一个可运行的实体。
  • 黑盒困惑 :明白 docker run 背后到底调用了哪些系统调用; git commit 时数据是如何被存储和关联的。
  • 调试能力 :当生产环境遇到深层次的容器或系统问题时,具备更底层的问题定位思路。

不适合什么场景?

  • 寻找生产级替代品 :这些玩具实现性能、安全性、稳定性远不及成熟产品, 绝对不可用于生产环境
  • 快速应用开发 :如果你的目标是快速搭建应用,请直接使用 Docker、Git 等成熟工具。
  • 无编程基础者 :需要具备至少一门编程语言(C/Go/Python)的基础和 Linux 命令行操作能力。

安全与合规边界

在实现类 Docker 的容器运行时部分,会涉及 Linux 内核的命名空间、cgroups 等特性。这些是 Linux 内核提供的合法能力。但在学习过程中:

  1. 仅在测试环境进行 :在你的个人开发机或虚拟机中操作。
  2. 不要突破隔离 :理解隔离的原理是为了更好地使用它,而非绕过它。切勿尝试用你的实验代码去攻击或破坏主机系统安全。
  3. 尊重版权 :所有代码应为原创或基于明确允许学习使用的开源代码(如 MIT、BSD 协议)。参考知名开源项目(如 Linux Kernel, Git, runc)时,注意其许可证。

3. 环境准备与前置条件

开始编码前,请确保你的开发环境满足以下要求。这是后续所有步骤的基础。

3.1 操作系统与基础工具

  • 主操作系统 :推荐 Ubuntu 22.04 LTS macOS 。Windows 用户请安装 WSL2 (Ubuntu发行版)
  • 包管理器 :确保 apt (Ubuntu) 或 brew (macOS) 可用。
  • 基础编译工具链
    # Ubuntu / WSL2
    sudo apt update
    sudo apt install -y build-essential git curl wget python3 python3-pip
    
    # macOS
    xcode-select --install # 安装命令行工具
    brew install git curl wget python3
    

3.2 各项目专项依赖

操作系统项目
  • 汇编器 & 链接器 nasm (用于汇编代码), ld (GNU 链接器,通常包含在 build-essential 中)。
  • 模拟器 QEMU ,用于运行你的内核而不需要重启真机。
    # Ubuntu / WSL2
    sudo apt install -y qemu-system-x86
    
    # macOS
    brew install qemu
    
  • 调试器 gdb ,用于调试运行在QEMU中的内核。
    sudo apt install -y gdb
    
类Git项目
  • 编程语言 :选择 Python 3 Go 。本文示例将使用 Python 3 以便于理解。
  • Python 额外库 :可能需要 zlib hashlib (通常Python已内置)。
    pip3 install --upgrade pip
    # 本项目通常无需额外安装大型库
    
类Docker项目
  • 编程语言 :推荐 Go 。因为 Docker 和 containerd 等现代容器运行时主要用 Go 编写,它能很好地调用 Linux 系统调用。
    # Ubuntu / WSL2
    sudo apt install -y golang-go
    
    # macOS
    brew install go
    
  • Linux 内核头文件 (仅Linux需要):用于编译时获取系统调用常数定义。
    sudo apt install -y linux-headers-$(uname -r)
    

3.3 磁盘空间与权限

  • 磁盘空间 :准备至少 2GB 的可用空间,用于存放代码、编译中间文件和镜像。
  • 权限 :部分操作(如挂载文件系统、配置cgroups)需要 root 权限。我们将在需要时使用 sudo 请谨慎执行任何需要 sudo 的命令

4. 操作系统:从引导到“Hello World”

我们的目标是制作一个能在 QEMU 中启动并打印 “Hello, My OS!” 的极简内核。

4.1 项目结构与核心文件

创建一个项目目录并初始化以下文件:

my_os/
├── boot/
│   ├── boot.asm      # 引导扇区汇编代码
│   └── linker.ld     # 内核链接脚本
├── kernel/
│   ├── kernel.c      # 内核主入口 (C语言)
│   ├── screen.c      # 屏幕输出函数
│   └── screen.h
└── Makefile          # 构建脚本

4.2 关键代码实现

1. 引导扇区 ( boot/boot.asm ) 引导扇区是计算机启动后加载的第一个512字节代码。它负责切换到保护模式,并加载我们的内核。

; boot.asm - 简化的引导加载程序
[org 0x7c00]        ; BIOS 将引导扇区加载到 0x7c00
    mov bp, 0x8000  ; 设置栈底
    mov sp, bp

    mov bx, MSG_REAL_MODE
    call print_string

    call switch_to_pm ; 切换到32位保护模式
    jmp $

%include "print_string.asm"
%include "gdt.asm"
%include "switch_to_pm.asm"

[bits 32]
BEGIN_PM:
    mov ebx, MSG_PROT_MODE
    call print_string_pm
    call KERNEL_OFFSET    ; 跳转到内核入口点,假设内核被加载到 0x1000
    jmp $

MSG_REAL_MODE db "Started in 16-bit Real Mode", 0
MSG_PROT_MODE db "Successfully landed in 32-bit Protected Mode", 0

times 510-($-$$) db 0
dw 0xaa55

注意 :这是一个极度简化的示例。完整的引导程序还包括加载内核到内存的磁盘操作。

2. 内核主入口 ( kernel/kernel.c ) 这是内核的 C 语言入口点。

// kernel.c
void kernel_main(void) {
    // 初始化屏幕驱动,清屏或设置显示模式
    clear_screen();
    // 在屏幕指定位置打印信息
    print_at("Hello, My OS! From C Kernel.", 0, 0);
    // 挂起,防止跑飞
    for(;;);
}

3. 屏幕输出 ( kernel/screen.c ) 实现直接写显存(VGA文本模式,地址通常为 0xb8000 )来打印字符。

// screen.c
#include “screen.h”

#define VIDEO_ADDRESS 0xb8000
#define MAX_ROWS 25
#define MAX_COLS 80

void print_at(char* message, int col, int row) {
    unsigned char* vidmem = (unsigned char*) VIDEO_ADDRESS;
    int offset;
    if (col >= 0 && row >= 0) {
        offset = (row * MAX_COLS + col) * 2;
    } else {
        // 默认光标位置逻辑(此处简化)
        offset = 0;
    }
    int i = 0;
    while (message[i] != 0) {
        vidmem[offset] = message[i];
        vidmem[offset + 1] = 0x0f; // 黑底白字
        i++;
        offset += 2;
    }
}

void clear_screen() {
    // 用空格字符和默认属性填充整个屏幕缓冲区
    for (int i = 0; i < MAX_COLS * MAX_ROWS; i++) {
        print_at(" ", i % MAX_COLS, i / MAX_COLS);
    }
}

4.3 编译、链接与运行

1. 编写 Makefile

# Makefile
ASM=nasm
CC=gcc
LD=ld
CFLAGS=-ffreestanding -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector
LDFLAGS=-m elf_i386 -T boot/linker.ld

.PHONY: all run clean

all: myos.bin

boot/boot.bin: boot/boot.asm
	$(ASM) -f bin $< -o $@

kernel/kernel.o: kernel/kernel.c kernel/screen.c
	$(CC) $(CFLAGS) -c kernel/kernel.c -o kernel/kernel.o
	$(CC) $(CFLAGS) -c kernel/screen.c -o kernel/screen.o

myos.bin: boot/boot.bin kernel/kernel.o
	# 这里需要将内核目标文件链接成二进制,并与引导扇区合并
	$(LD) $(LDFLAGS) -o kernel.elf kernel/kernel.o kernel/screen.o
	# 提取纯二进制代码段
	objcopy -O binary kernel.elf kernel.bin
	# 合并引导扇区和内核
	cat boot/boot.bin kernel.bin > myos.bin

run: myos.bin
	qemu-system-x86_64 -drive format=raw,file=myos.bin

clean:
	rm -f *.bin *.o *.elf boot/*.bin kernel/*.o

2. 构建并运行

cd my_os
make        # 编译
make run    # 在QEMU中启动

如果一切顺利,QEMU窗口将显示从“实模式”切换到“保护模式”,最后打印出“Hello, My OS! From C Kernel.”。

3. 验证成功

  • 成功标志 :QEMU窗口不崩溃,能看到预设的打印信息。
  • 常见失败原因
    • nasm gcc 未安装:根据错误信息安装对应工具。
    • 编译参数错误:确保 -m32 生成32位代码, -ffreestanding 表示独立环境。
    • 链接脚本错误: linker.ld 需要正确指定内核的入口点和各段布局。
    • QEMU启动失败:检查 myos.bin 文件是否存在,QEMU命令格式是否正确。

5. 类Git:实现对象存储与提交

我们将用 Python 实现一个名为 mygit 的玩具,支持 init , add , commit , log 基本命令。

5.1 核心概念与数据结构

Git 的核心是 内容寻址文件系统 。所有数据(文件内容、目录树、提交信息)都以“对象”形式存储,用 SHA-1 哈希值作为键。

  • Blob对象 :存储文件内容。
  • Tree对象 :存储目录结构,包含文件名、模式、以及对应 Blob 或 Tree 的哈希值。
  • Commit对象 :存储一次提交,包含根 Tree 的哈希、父提交哈希、作者、提交者、时间戳和提交信息。

5.2 项目结构

mygit/
├── mygit.py          # 主命令行入口
├── objects/          # 对象存储目录 (由 init 创建)
├── refs/
│   └── heads/        # 分支引用
└── HEAD              # 当前分支指针

5.3 关键代码实现

1. 对象存储基础 ( mygit.py 部分函数)

import os
import hashlib
import zlib
import json
from pathlib import Path

GIT_DIR = “.mygit”

def init():
    """初始化仓库,创建必要的目录结构"""
    dirs = [GIT_DIR, os.path.join(GIT_DIR, “objects”), os.path.join(GIT_DIR, “refs”, “heads”)]
    for d in dirs:
        os.makedirs(d, exist_ok=True)
    with open(os.path.join(GIT_DIR, “HEAD”), “w”) as f:
        f.write(“ref: refs/heads/master\n”)
    print(f“Initialized empty MyGit repository in {os.path.abspath(GIT_DIR)}”)

def hash_object(data, obj_type=“blob”):
    """计算数据的SHA-1哈希,并存储为对象。返回哈希值。"""
    header = f“{obj_type} {len(data)}\0”
    full_data = header.encode() + data
    sha1 = hashlib.sha1(full_data).hexdigest()
    path = os.path.join(GIT_DIR, “objects”, sha1[:2], sha1[2:])
    os.makedirs(os.path.dirname(path), exist_ok=True)
    # 存储压缩后的数据
    with open(path, “wb”) as f:
        f.write(zlib.compress(full_data))
    return sha1

def get_object(sha1):
    """根据哈希值读取并解压对象,返回 (obj_type, data)"""
    path = os.path.join(GIT_DIR, “objects”, sha1[:2], sha1[2:])
    with open(path, “rb”) as f:
        raw = zlib.decompress(f.read())
    # 查找空格分隔符和空字节
    space_idx = raw.find(b’ ‘)
    null_idx = raw.find(b’\0’, space_idx)
    obj_type = raw[:space_idx].decode()
    # 数据长度(暂时不用)
    # obj_size = int(raw[space_idx+1:null_idx])
    data = raw[null_idx+1:]
    return obj_type, data

2. 实现 add commit

def add(filepath):
    """将工作区文件添加到暂存区(这里简化,直接创建对象)"""
    with open(filepath, “rb”) as f:
        data = f.read()
    sha1 = hash_object(data, “blob”)
    # 实际Git会更新index文件,这里简化打印
    print(f“Added ‘{filepath}’ -> {sha1}”)
    return sha1

def commit(message, author=“Your Name <you@example.com>”):
    """创建一次提交。需要先创建Tree对象(这里极度简化)。"""
    # 1. 创建Tree对象 (简化:假设只提交当前目录的一个文件列表)
    # 真实情况需要遍历工作区,为每个文件创建blob,并构建tree对象。
    # 此处我们创建一个虚拟的tree对象,其内容是一个固定的字符串。
    tree_data = b“100644 dummy.txt\0” + bytes.fromhex(“<some-blob-sha>”) # 仅为示例结构
    tree_sha = hash_object(tree_data, “tree”)

    # 2. 创建Commit对象
    # 获取当前HEAD指向的提交(父提交)
    try:
        with open(os.path.join(GIT_DIR, “HEAD”), “r”) as f:
            head_ref = f.read().strip()
        if head_ref.startswith(“ref: “):
            ref_path = head_ref[5:]
            with open(os.path.join(GIT_DIR, ref_path), “r”) as f:
                parent = f.read().strip()
        else:
            parent = head_ref # detached HEAD
    except FileNotFoundError:
        parent = “” # 首次提交,无父提交

    import time
    timestamp = int(time.time())
    commit_content = f“tree {tree_sha}\n”
    if parent:
        commit_content += f“parent {parent}\n”
    commit_content += f“author {author} {timestamp} +0800\n”
    commit_content += f“committer {author} {timestamp} +0800\n\n”
    commit_content += f“{message}\n”

    commit_sha = hash_object(commit_content.encode(), “commit”)

    # 3. 更新当前分支引用(例如 master)指向新提交
    with open(os.path.join(GIT_DIR, “refs”, “heads”, “master”), “w”) as f:
        f.write(commit_sha + “\n”)
    print(f“[master {commit_sha[:7]}] {message}”)
    return commit_sha

3. 实现 log

def log():
    """显示提交历史"""
    try:
        with open(os.path.join(GIT_DIR, “refs”, “heads”, “master”), “r”) as f:
            current_sha = f.read().strip()
    except FileNotFoundError:
        print(“No commits yet.”)
        return

    while current_sha:
        obj_type, data = get_object(current_sha)
        if obj_type != “commit”:
            break
        commit_text = data.decode()
        lines = commit_text.split(‘\n’)
        # 简单解析并打印
        print(f“commit {current_sha[:7]}”)
        for line in lines:
            if line.startswith(‘author ‘) or line.startswith(‘committer ‘):
                print(f“    {line}”)
            elif line and not (line.startswith(‘tree ‘) or line.startswith(‘parent ‘)):
                print(f“    {line}”)
        print()
        # 查找父提交
        parent = None
        for line in lines:
            if line.startswith(‘parent ‘):
                parent = line.split(‘ ‘)[1]
                break
        current_sha = parent

5.4 功能测试

  1. 初始化仓库
    python3 mygit.py init
    
    检查是否生成 .mygit 目录及子目录。
  2. 添加文件
    echo “Hello, MyGit” > test.txt
    python3 mygit.py add test.txt
    
    观察输出,确认打印了生成的 blob 对象哈希。
  3. 提交更改
    python3 mygit.py commit “Initial commit”
    
    观察输出,确认打印了提交哈希和消息。
  4. 查看历史
    python3 mygit.py log
    
    应该能看到刚刚的提交记录。

验证成功 .mygit/objects 目录下应出现以哈希前两位命名的子目录,里面存储着对应的对象文件。 log 命令能正确显示提交链。

6. 类Docker:实现进程隔离与镜像运行

我们将用 Go 实现一个名为 mydocker 的极简容器运行时,核心是使用 Linux 命名空间进行隔离,并用联合文件系统(OverlayFS)提供镜像层。

6.1 核心概念

  • 命名空间 (Namespace) :隔离进程的视图,包括 PID(进程ID)、Mount(挂载点)、Network(网络)等。
  • 控制组 (cgroup) :限制进程的资源使用(CPU、内存等)。本文为简化,暂不实现。
  • 联合文件系统 (Union Filesystem) :将多个目录(层)合并成一个统一的视图。OverlayFS 是常用的一种。

6.2 项目结构

mydocker/
├── main.go          # 主程序入口
├── go.mod           # Go 模块定义
└── images/
    └── busybox/     # 存放基础镜像(例如 busybox 文件系统)

6.3 关键代码实现

1. 定义命令与主流程 ( main.go )

package main

import (
	"fmt”
	“log”
	“os”
	“os/exec”
	“path/filepath”
	“syscall”
)

func main() {
	switch os.Args[1] {
	case “run”:
		run()
	case “pull”:
		pull()
	default:
		log.Fatalf(“Unknown command %s”, os.Args[1])
	}
}

func run() {
	fmt.Printf(“Running command %v as PID %d\n”, os.Args[2:], os.Getpid())
	// 1. 设置主机名等命名空间(需要 root 权限)
	cmd := exec.Command(“/proc/self/exe”, append([]string{“child”}, os.Args[2:]…)...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
	}
	must(cmd.Run())
}

func child() {
	fmt.Printf(“Running command %v as PID %d\n”, os.Args[2:], os.Getpid())
	// 2. 在子进程中,挂载新的根文件系统(使用 busybox)
	cg()
	must(syscall.Chroot(“./images/busybox”))
	must(os.Chdir(“/”))
	// 3. 挂载 proc 文件系统
	must(syscall.Mount(“proc”, “proc”, “proc”, 0, “”))
	// 4. 执行用户命令
	cmd := exec.Command(os.Args[2], os.Args[3:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	must(cmd.Run())
	// 5. 卸载 proc
	must(syscall.Unmount(“proc”, 0))
}

func cg() {
	// 简化:这里可以创建并加入 cgroup,设置资源限制。此处省略。
}

func pull() {
	// 简化:从网络下载 busybox 根文件系统到 ./images/busybox
	fmt.Println(“Pulling busybox image (simulated)...“)
	// 实际应使用类似 docker pull 的机制,这里假设已存在。
}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

2. 准备基础镜像 我们需要一个最小的根文件系统。可以使用 busybox

# 在 mydocker 项目根目录执行
mkdir -p images/busybox
cd images/busybox
# 创建基本的根文件系统目录
mkdir -p bin etc dev lib lib64 proc sys tmp
# 复制 busybox 二进制文件 (需要先安装 busybox 或从其他容器提取)
# 这里假设你已通过其他方式获得了 busybox 静态链接版
cp $(which busybox) bin/  # 如果系统有安装
# 为 busybox 创建常用命令的符号链接
./bin/busybox --install ./bin
cd ../..

6.4 编译与运行测试

  1. 初始化 Go 模块并编译
    cd mydocker
    go mod init mydocker
    go build -o mydocker main.go
    
  2. 以 root 权限运行容器 (因为涉及命名空间和挂载操作):
    sudo ./mydocker run /bin/sh
    
    如果成功,你将进入一个隔离的 shell 环境, ps aux 可能只看到很少的进程,主机名也可能不同。
  3. 执行命令
    # 在容器 shell 中
    ls /
    hostname
    exit
    

验证成功

  • 命令 sudo ./mydocker run /bin/sh 能成功启动一个 shell。
  • 在启动的 shell 中, / 目录下的文件应与 images/busybox 目录内容一致。
  • 使用 ps top 看到的进程列表与主机不同(PID 命名空间隔离)。
  • 退出 shell 后,主机环境不受影响。

常见失败原因

  • 权限不足 :必须使用 sudo
  • busybox 镜像不完整 :确保 images/busybox/bin 下有可执行的 busybox 及其符号链接。
  • 系统不支持命名空间 :确保内核版本较新,且已启用相关配置(通常默认开启)。
  • Go 编译错误 :检查 main.go 语法,确保 go.mod 正确。

7. 资源占用与性能观察

这三个项目在资源消耗上各有特点,理解它们有助于优化和调试。

7.1 操作系统项目

  • CPU :在 QEMU 中运行,会占用一个 CPU 核心模拟整个 x86 环境。在宿主机的任务管理器中,QEMU 进程的 CPU 使用率可能较高,这是正常的。
  • 内存 :我们极简的内核可能只占用几 MB 内存。你可以在 QEMU 启动命令中指定内存大小,例如 -m 128M
  • 磁盘 :最终的 myos.bin 文件可能只有几十到几百 KB。
  • 观察方法 :使用 htop top 观察 qemu-system-x86_64 进程的资源使用情况。

7.2 类Git项目

  • CPU/内存 :Python 脚本执行单次命令消耗极低。主要开销在文件 I/O 和哈希计算(SHA-1)。处理大仓库时,内存占用会随着索引文件大小增加。
  • 磁盘 :对象存储位于 .mygit/objects 。每个对象都被压缩存储,但多次提交同一文件的不同版本会产生多个对象,可能造成冗余(真实 Git 有打包机制优化)。
  • 性能瓶颈 :遍历大型工作目录树、计算大量文件的哈希值会比较耗时。

7.3 类Docker项目

  • CPU/内存 :Go 编译的二进制文件本身很小。主要的资源消耗在于容器内运行的进程(例如你运行的 /bin/sh 及其子进程)。隔离本身(命名空间)带来的开销极小。
  • 磁盘 :镜像层(如 images/busybox )占用空间。联合文件系统(如果实现)会在运行时产生可写层,增加少量开销。
  • 观察容器资源 :在宿主机上,可以使用 ps auxf 查看容器进程的 PID,然后通过 cat /proc/<PID>/status 查看其资源限制情况(如果实现了 cgroup)。更简单的方法是直接用 top htop 观察进程。

通用性能建议

  • 操作系统 :在 QEMU 中调试时,可以启用 -s -S 参数配合 gdb 进行单步调试,这会减慢执行速度,但便于开发。
  • 类Git :对于 add 操作,可以设计一个“索引”文件来避免每次全量扫描工作区。
  • 类Docker :镜像拉取( pull )是主要的网络和磁盘 I/O 操作,可以设计分层下载和缓存机制。

8. 常见问题与排查方法

在实现这三个“轮子”的过程中,你几乎一定会遇到各种问题。下表列出了一些常见问题及解决思路:

问题现象 可能原因 排查方式 解决方案
操作系统:QEMU 启动后黑屏或无输出 1. 引导扇区未正确跳转到内核。
2. 内核代码未正确编译/链接到预期地址。
3. 屏幕输出函数(如 print_at )写错了显存地址或格式。
1. 使用 -d cpu 等 QEMU 调试参数输出 CPU 执行轨迹。
2. 使用 objdump 反汇编内核二进制,检查入口点是否正确。
3. 在 QEMU 中使用 -serial stdio 将串口输出重定向到终端,先不用 VGA 显示。
1. 检查引导扇区代码,确保 jmp 指令正确。
2. 检查链接脚本 linker.ld ,确保 .text 段地址正确。
3. 验证 VGA 文本模式内存地址是否为 0xb8000 ,字符和属性字节顺序是否正确。
类Git: add commit 后对象库为空或文件损坏 1. hash_object 函数中 header 格式错误(缺少空格或空字节)。
2. 文件路径错误,对象未写入正确位置。
3. zlib 压缩/解压出错。
1. 打印 header full_data 的字节序列进行比对。
2. 检查 objects/ 目录下是否生成了以哈希前两位命名的子目录及文件。
3. 尝试直接读取存储的文件并用 zlib.decompress 手动解压测试。
1. 严格按照 type size\0data 的格式构造 header。
2. 使用 os.path.join 确保路径正确,并检查目录权限。
3. 确保读写文件时使用二进制模式( ’wb’ ’rb’ )。
类Git: log 显示混乱或无法追溯父提交 1. Commit 对象格式错误,解析失败。
2. HEAD 或分支引用文件内容格式错误。
3. 父提交哈希值存储或读取错误。
1. 手动 cat 一个 Commit 对象文件(先解压),检查其格式是否符合规范。
2. 检查 .mygit/HEAD .mygit/refs/heads/master 文件内容。
3. 在 commit 函数中打印生成的 commit 内容字符串。
1. 参照 Git 对象格式规范,确保每行以换行符结束, tree parent author committer 字段顺序可自定义但需正确解析。
2. 确保引用文件只包含哈希值和一个换行符。
类Docker: sudo ./mydocker run 提示 exec format error no such file or directory 1. 容器内要执行的命令(如 /bin/sh )在根文件系统中不存在。
2. 根文件系统中的二进制文件动态链接库缺失。
3. 二进制文件架构不匹配(如宿主机 x86_64,容器内是 arm64)。
1. 检查 images/busybox/bin 下是否有 sh
2. 使用 ldd 命令检查宿主机的 /bin/sh 依赖哪些库,并确保它们在容器根文件系统的 lib lib64 目录下。
3. 使用 file 命令检查二进制文件架构。
1. 使用静态链接的 busybox ,它不依赖外部库。
2. 确保复制了所有必要的库文件到容器根文件系统。
3. 确保使用的 busybox 版本与宿主机架构一致。
类Docker:容器内无法访问网络 1. 未创建网络命名空间,或未配置虚拟网络设备(如 veth pair)。
2. 未设置路由和 DNS。
1. 检查 Cloneflags 是否包含 syscall.CLONE_NEWNET
2. 在宿主机使用 ip netns list 查看命名空间。
1. 添加 CLONE_NEWNET 标志创建网络命名空间。
2. 实现复杂的网络配置(创建 veth、分配 IP、设置 NAT),这超出了最小示例范围。可先跳过网络,或使用 –net=host 模式(不隔离网络)。
类Docker:运行后宿主机文件系统被意外修改 1. 挂载点隔离不彻底。在 child() 函数中,应在 chroot 后立即 chdir(“/”) ,并只挂载必要的文件系统(如 proc )。
2. 容器进程逃逸(极罕见,在极简实现中可能性低)。
1. 检查 child() 函数中的挂载操作序列。
2. 在容器内尝试 cd /.. ls ,看是否能访问宿主机文件。
1. 确保在 syscall.Chroot 后调用 os.Chdir(“/”) ,将当前目录锁定在新根内。
2. 除了 proc ,考虑也挂载 sys , dev 等目录,但注意权限。

9. 最佳实践与使用建议

  1. 版本控制 :立即为你的“轮子”项目使用 Git(真实的 Git)进行管理。这是最好的实践。
  2. 增量开发 :不要试图一次性实现所有功能。为操作系统先实现打印字符串,再加中断,再加内存管理。为类Git先实现存储一个文件对象,再实现Tree,再实现Commit。
  3. 充分利用调试工具
    • 操作系统 :QEMU 的 -gdb 参数、 objdump nm
    • 类Git :Python 的 pdb 调试器,或直接打印对象内容。
    • 类Docker :Go 的 dlv 调试器,以及 strace / dtruss 来跟踪系统调用。
  4. 阅读真正的源码 :当你的玩具实现遇到瓶颈或想深入时,去阅读 Linux Kernel、Git 或 runc(Docker 的底层运行时)的源码。你会豁然开朗。
  5. 安全边界 :类Docker项目需要 sudo 。永远不要在生产服务器上运行未经严格审计的自制容器运行时。学习目的也仅在隔离的虚拟机或个人开发机中进行。
  6. 文档与注释 :为你代码中的关键设计决策和复杂逻辑写下注释。这不仅能帮助未来的你,也是理解过程的一部分。
  7. 测试驱动 :为每个核心函数编写简单的单元测试。例如,测试 hash_object 函数对于相同输入是否总是产生相同哈希。

10. 总结与下一步

通过从零开始构建这三个项目的简化版本,我们走马观花般地体验了系统软件的核心领域:操作系统管理硬件并提供抽象,版本控制系统管理数据变更和历史,容器运行时则利用操作系统提供的隔离机制来打包和运行应用。每个项目都像打开了一扇门,门后是一个深邃而有趣的世界。

最值得尝试的点

  • 操作系统 :亲手写出引导代码和内核入口,那种“机器听我指挥”的感觉是无与伦比的。
  • 类Git :理解对象存储模型后,再看 Git 的命令会觉得异常清晰,不再是黑魔法。
  • 类Docker :用几十行 Go 代码就启动了一个隔离环境,揭示了容器技术并不神秘的本质。

最先应该验证的功能

  1. 让操作系统在 QEMU 里打印出你的学号或名字。
  2. 用你的 mygit 管理一个简单的文本文件,并做几次提交和查看历史。
  3. 用你的 mydocker 运行 busybox echo “Hello from container”

最容易踩的坑

  • 操作系统的链接地址和引导流程。
  • Git 对象存储的严格格式(特别是 header 中的空格和空字节)。
  • Docker 容器根文件系统的准备和挂载 proc 等文件系统的时机。

后续扩展方向

  • 操作系统 :添加键盘输入、简单的内存管理(分页)、用户态进程、文件系统。
  • 类Git :实现真正的暂存区(index)、分支切换(checkout)、合并(merge)、远程仓库 fetch/push。
  • 类Docker :实现完整的镜像拉取(从仓库下载层)、完整的 cgroups 资源限制、网络配置、更健壮的 OverlayFS 使用。

重新发明轮子不是为了替代汽车,而是为了理解发动机是如何工作的。当你下次再使用 docker run git commit 时,脑海中对底层发生的系统调用和数据流动有了清晰的图景,这就是这个学习过程带来的最大回报。建议将本文作为路线图收藏,在你遇到具体实现难题时,再深入查阅相关专题资料和源码。动手开始写第一行代码吧。

更多推荐