构建最简 Linux 文件系统
上回说到了我们如何用 QEMU 搭建一个包含最新内核的开发环境。但试过一段时间后,我却感觉这样并不方便。 更多时候,我只需要运行单个程序(比如,单元测试)即可,而上文中所构建的完整环境却让这个过程便复杂了。本文介绍了一个更简单的方法。只需要一个简单的脚本即可构建完整运行环境。 initrd 上一篇文章中已经提到了使用 initrd 启动操作系统的方法。initrd 是一个只读文件系统镜像,内核启动时,会将镜像内容加载到内存中。程序可以像普通文件系统一样访问。 加载文件系统之后,内核会从其中寻找可用的 init 程序。因此我们只要编写合理的 init 程序,将其打包到一个 initrd 中即可。 busybox 在这种从零开始构建文件系统的场景下,使用动态库太过繁琐。因此我们直接用宿主机上的 busybox 来提供基本的程序。 准备阶段 首先创建一个 WORK_DIR 目录。此路径下的文件会被打包成文件系统镜像。 WORK_DIR=$(mktemp -d) trap "rm -rf $WORK_DIR" EXIT 之后寻找宿主机上的 busybox,将其复制到 $WORK_DIR/bin/busybox。 mkdir -p "$WORK_DIR/bin" # Copy busybox into the work directory BUSYBOX_PATH=$(which busybox) if [ -z "$BUSYBOX_PATH" ]; then echo "Error: busybox not found in PATH." exit 1 fi cp "$BUSYBOX_PATH" "$WORK_DIR/bin/busybox" 创建 $WORK_DIR/init 脚本,赋予其可执行权限。内核在启动中会识别并执行该脚本。 # Create init script cat << 'EOF' > "$WORK_DIR/init" #!...
在 QEMU 上运行最新内核
最近,我的业余时间都花在了一个和系统编程有关的项目上。不久之后我遇到了一个问题:想要使用最新版本内核的特性并不是一件十分容易的事。发行版大多滞后于最新的内核版本,而我又不是那样激进的人,想要冒险在自己唯一的这台 Linux 电脑上升级内核(使用 installkernel 命令)。所以我需要一个办法,在保证我的系统安全的情况下,搭建一个具有新版本内核的开发环境。 这种情况容器帮不了我,虚拟机则是一个好选择。 我曾经写过一个简单的 QEMU 介绍。但实际上我对 QEMU 的了解也仅限于那篇文章的内容了。探索本文的内容花了我一些时间。在这个过程中,我也发现网上的相关内容要么包含了未充分说明的脚本,要么存在许多冗余选项。我觉得在此处以一个简单、充分解释的方式记录这一过程似乎是有意义的。 当然,从编译内核开始 如果你只需要一个能在虚拟机里运行的内核,不需要深度定制的话,那么编译内核其实很简单。这里有一个简单的教程,不过我们接下来要做的还要更简单。 首先,你要下载内核源码。如果不需要修改源码的话,从 kernel.org 下载是最方便的。这里下载了编写本文时的 stable 版本(6.17.9)。 wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.17.9.tar.xz tar -xf linux-6.17.9.tar.xz cd ./linux-6.17.9 之后,配置编译选项。这将创建编译配置文件 .config make defconfig make kvm_guest.config 前一条命令选择使用默认编译配置。第二条命令在默认配置基础上设置编译选项,使编译后的镜像可以作为 kvm 上的虚拟机运行。如果有其他需求,可以在此基础上继续配置。 之后直接编译即可。由于只包含了作为虚拟机运行的最小配置,不需要编译驱动程序,这一过程应该很快。在我的笔记本上只用了一分钟。 make -j $(nproc) 编译后的内核镜像位于 arch/x86/boot/bzImage。当然,是 x86 架构。 file arch/x86/boot/bzImage # arch/x86/boot/bzImage: Linux kernel x86 boot executable bzImage, version 6.17.9 (wokron@wokron-navi) #2 SMP PREEMPT_DYNAMIC Sun Nov 30 12:34:56 CST 2025, RO-rootFS, swap_dev 0XD, Normal VGA 准备根文件系统 简易根文件系统 这一点和我之前有关容器的文章中的内容类似。如果不考虑实际可用性,我们现在就可以运行一个内核了。...
用 Namespace 手搓一个容器
容器技术由 Linux 下三项技术构成。这三项技术分别是 Namespace、Cgroups 和 Unionfs。他们分别实现了系统逻辑资源的隔离、物理资源的限制以及容器的文件系统。 在这之中最为关键的是 Namespace。因为 Namespace 实现了虚拟化中最重要的隔离的功能。在 Namespace 之外即使不使用 Cgroups,用其他文件系统替代 Unionfs,依然能够实现一个容器的许多功能。 所以本文我们尝试用 Namespace 构建一个简单的容器。让我们首先想想,一个容器中的环境究竟需要与 host 隔离哪些资源。(请把容器想象成 host 之外的另一台机器。) 文件系统:容器中的进程不能访问 host 的文件系统。这意味着挂载点的隔离 – Mount Namespace 进程空间:容器中的进程无法查看容器外的进程信息。这意味着进程号的隔离 – PID Namespace 网络接口:容器中的进程拥有自己的网络接口,不使用 host 上的网络接口。这意味着网络的隔离 – Network Namespace 用户:容器中的用户和容器外的用户无关,例如容器内的 root 和容器外的 root 并不相同。这意味着用户的隔离 – User Namespace 物理资源:容器能够看到和管理的物理资源和容器外的资源不同。这意味着 Cgroups 视图的隔离 – Cgroups Namespace 时间:容器中的时间系统和容器外的时间不一定相同 – Time Namespace 主机名:容器中的主机名和容器外的主机名不一定相同 – UTS Namespace IPC:IPC,例如 Posix 消息队列,使用类似文件名的标识符,但是又并不真正存在于文件系统中。容器中的这些标识符和容器外的相同标识无关 – IPC Namespace 虽然种类很多,但是想要形成虚拟化的错觉只需要用到其中的一部分即可。为了构建我们的容器,我们选择只使用 Mount、PID 和 User。 隔离文件系统 容器化最重要的是隔离文件系统。所谓的程序运行环境,本质上就是文件系统中的各类库和应用程序。同一主机上的不同发行版的容器都运行在相同的内核上,他们只是在库和应用程序上存在不同。...
Linux 下的用户和容器中的用户
多用户操作系统的时代早就结束了。现在的人们一般不会通过多个用户登陆到同一个操作系统的方式共享计算资源,我们有更好的虚拟化技术。 不过用户依然在发挥作用,其中最主要的作用就是权限隔离。这一点似乎有许多内容可以讲,不过这里我们只会列出最关键的内容。容器中的用户会出现更多特殊情况,我们也会进行讨论。 用户与权限 用户和用户组 操作系统通过用户控制权限,特定的用户才能执行特定的操作。 操作系统中有一组配置好的用户。其信息被保存在 /etc/passwd 文件中。每个用户都属于一个或多个用户组。用户和用户组分别有 uid 和 gid。使用 id 命令可以查看当前用户的对应 ID。 $ id uid=1000(wokron) gid=1000(wokron) groups=1000(wokron) 1001(xxxxx) 1002(yyyyy) 其中 gid 指向的表示该用户的主组。可以通过 newgrp 命令切换主组。这会启动一个新的 shell。 $ newgrp xxxxx $ id uid=1000(wokron) gid=1001(xxxxx) groups=1000(wokron) 1001(xxxxx) 1002(yyyyy) 下面的内容忽略了许多细节。不过忽略的内容相对很少遇到。 当选择一个用户登陆系统时,这次登陆所创建的 shell 会将用户和其主组的 ID 附加到进程中。这两个 ID 称为该进程的进程凭证。 子进程创建时会继承父进程的的进程凭证。进程凭证表明了该进程代表哪一用户进行操作。 超级用户 进程的用户和用户组决定了其是否有权限执行某一操作。经典的 Unix 权限系统只区分了两种用户:超级用户和普通用户。超级用户能够执行操作系统所提供的所有操作,而普通用户则受到了限制。普通用户的操作不能够影响操作系统的状态。 超级用户的 uid 为 0,通常名为 root。其用户组通常只有 root(gid=0)。除此以外的用户都是普通用户,他们之间没有权限上的差异。 利用 sudo 命令可以临时提升当前用户的权限,使其以 root 用户的身份运行进程。(即进程和其子进程的进程凭证为 root)。 $ sleep 1 & ps -o user,group,uid,gid -p $!...
C++ 踩坑记录(持续更新!)
有人说你永远不能自称精通 C++,本文试图为这个观点提供一个例证。下面列出了一些从去年(2024)开始我编写 C++ 代码时犯过的错误。当然,有些可能看上去很蠢,不过谁又能在未知全貌的时候保证自己不会犯错呢?我认为这些错误至少初看上去是反直觉的。 std 集合操作只能用于有序容器 你要表示两个整数集合,所以你用了 std::unordered_set<int>。之后你想要求两个集合的交集,你搜了一下 STL,发现 std::set_intersection 似乎正合适。于是你写了一个简单的程序 // test_set.cpp std::unordered_set<int> set1 = {1, 2, 3, 4, 5}; std::unordered_set<int> set2 = {4, 5, 6, 7, 8}; std::unordered_set<int> result; std::set_intersection(set1.begin(), set1.end(), set2.begin(), set2.end(), std::inserter(result, result.begin())); for (const auto &elem : result) { std::cout << elem << " "; } std::cout << std::endl; 然后运行,你期望输出的结果是 4 5 或是 5 4(毕竟你很严谨)。可是实际结果呢 $ clang++ test_set.cpp -o test_set $ ./test_set 什么都没有输出。 正如 std::set 实际上表示的是有序集合一样,std::set_intersection 实际上也是 ordered_set_intersection,只不过函数签名并不告诉你。...
我该用什么参数类型?
本文我们讨论一个很小的问题:不同的函数参数类型都在什么情况下使用? 参数分类 比如我们有一个类型 T(让问题先简单点,此处 T 并非范型),那么要在函数中传入一个该类型的参数,共有几种可能?我们可以分一下类: 修饰符:有 const 修饰、无 const 修饰 是否引用:值、左值引用、右值引用 按照这个分类,我们总共能得出 5 种参数类型 const修饰 是否引用 参数类型 有 值 const T 有 左值引用 const T& 无 值 T 无 左值引用 T& 无 右值引用 T&& const T&& 是没有意义的 接下来介绍这些参数类型的应用场景 转移所有权的情况 假设需要将参数的生命周期转移到函数内,那么大多数情况下,直接使用 T 作为参数类型即可。 例如我们有类型 A,其构造时会接受并持有一个 std::vector<int> 参数。那么参数类型应当为 std::vector<int>。 class A { public: A(std::vector<int> v) : v_(std::move(v)) {} private: std::vector_<int> v_; }; 我们考虑两种构造类型 A 对象的情况。其一是我们在传入 std::vector<int> 对象后不会再使用该对象。这时我们可以采取移动语义。 std::vector<int> v; // ... A a(std::move(v)); 其二是我们在传入 std::vector<int> 对象后还会使用该对象。这是我们需要采取复制语义。...