开发环境搭建

调试内核一节中我们使用 initramfs + bzImage 构建了一个基础的 linux kernel 调试环境, 但是目前的所有操作都只会保存在 initramfs 中, 目前可用的软件很少, 没有编译器, 没有联网, 没有 apt 包管理工具, 不能持久化存储

本文介绍如何使用 qemu 模拟更加复杂的环境, 以及如何利用现有的 Linux 发行版根文件系统(比如说 Ubuntu 的基础软件包)来搭建一个可用的 linux 开发环境, 并且使用自己编译的内核启动

Ubuntu 云镜像

手动构建软件包环境是十分繁琐的, 读者感兴趣的话可以了解一下 linux from scratch 项目,lfs 介绍的非常完善,您可以从软件代码开始一点一点编译得到一个真正的可以使用的 linux 发行版。这非常有趣!

本文介绍一种比较简单直接的方式,即利用现有的 Linux 发行版提供的系统映像安装, 这里以 Ubuntu 为例。Ubuntu 提供了已经构建好的云镜像 ubuntu cloud-images

这个网址有很多目录,按照不同的 ubuntu 版本名区分,这里读者可以自行选择希望使用的版本,笔者这里选用一个较为稳定且年轻的版本 ubuntu22.04(jammy)

20251031141024

点进去之后可以看到很多日期,ubuntu 云镜像的构建是比较频繁的,基本上隔几天就会自动构建一次。这里随便选一个新的即可。里面可以看到不同架构(amd64/arm64/riscv64)等,对应后面有介绍说明,我们下载 .img 镜像。它是一个 QCow2 UEFI/GPT Bootable disk image,也就是说我们可以直接通过 qemu 启动这个镜像。

20251031141335

同理我们可以在 minimal 目录下的 release/jammy 下载一个最小化的镜像(290M),本文后续会使用这个镜像作为演示完成基础配置,基础配置大同小异,并无很大差别,读者可以放心阅读。

wget https://cloud-images.ubuntu.com/minimal/releases/jammy/release-20251001/ubuntu-22.04-minimal-cloudimg-amd64.img
[!TIP] TIP

直接使用 wget 下载速度可能有点慢,国内下载可以使用清华源 tuna mirrors,mirror 中搜索 ubuntu 选择 ubuntu-cloud-images 即可,它的目录结构和 ubuntu 官方源是一样的

启动镜像

前文调试内核中介绍了一个使用 initramfs 启动调试内核的 Makefile,但是现在有了一个完整的 linux 发行版镜像我们就不再需要自己构建的 initramfs 了,我们将 Makefile 更新如下。其中 IMG 修改为你的镜像名即可

.PHONY: init qemu clean debug

KERNEL = bzImage
QEMU = qemu-system-x86_64
IMG = jammy-minimal-cloudimg-amd64.img

disk:
    $(QEMU) \
        -m 4G \
        -enable-kvm \
        -kernel $(KERNEL) \
        -drive format=qcow2,file=$(IMG) \
        -append "root=/dev/sda1 console=ttyS0" \
        -nographic -no-reboot -d guest_errors

debug:
    $(QEMU) \
        -m 4G \
        -enable-kvm \
        -kernel $(KERNEL) \
        -drive format=qcow2,file=$(IMG) \
        -append "root=/dev/sda1 console=ttyS0" \
        -nographic -no-reboot -d guest_errors \
        -S -s

接着可以启动 qemu 了,这里我们使用了 -enable-kvm 进行加速(如果kvm需要sudo那就加上)

[!TIP] TIP

启动镜像会发现内核日志正常启动,然后 systemd 启动各种服务 ok,然后在一个地方卡了许久 "A-start-job-is-running-for-wait-for-network-to-be-configured-ubuntu-server" 这是因为当前的宿主机没有联网

[!WARNING] WARNING

如果启动失败了,那么先看一下内核的报错日志能否解决,大概率是因为缺少了相关的内核模块,因为部分内核模块是在核外编译的,需要把它们安装到内核 /lib/modules/ 下面之后才可以完成装载。可以先跳过此步完成后面的磁盘扩容/挂载/安装内核模块之后再次尝试启动。或者删除掉 --kernel 和 --append 参数采用镜像默认的内核启动

之后进入了登录页面,要求我们输入用户名和密码

20251030235213

这时你会发现怎么输入都不对,因为这个镜像并没有配置默认的密码,我们需要使用 virt-customize 来为镜像初始化密码,首先安装 virt-customize 所属的软件包

sudo apt install guestfs-tools

使用 virt-customize 为当前镜像重新设置 root 密码,然后就可以通过 root + 密码登陆了

virt-customize -a jammy-minimal-cloudimg-amd64.img --root-password password:<your-passwd>
[!NOTE] NOTE

virt-customize 是一个处理磁盘镜像很好用的工具,除了修改密码它还有很多有趣的功能(包括拷贝删除文件,安装软件包等等)。它本质是使用了 supermin + libguestfs,在一个微型虚拟机(appliance VM)中挂载磁盘并修改

[!WARNING] WARNING

virt-customize 在 WSL 中不生效,因为 WSL 不能支持 libguestfs,你可以把镜像拷贝到一个真实的 ubuntu 服务器上操作完成再回到 wsl

如果读者使用 df 查看当前存储系统可以发现这个系统竟然有 2GB 的磁盘空间?但是明明我下载的 img 没有这么大啊?

root@ubuntu:~# df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/root       2.0G  860M  1.2G  44% /
tmpfs           2.0G     0  2.0G   0% /dev/shm
tmpfs           787M  420K  786M   1% /run
tmpfs           5.0M     0  5.0M   0% /run/lock
/dev/sda15      105M  6.1M   99M   6% /boot/efi
tmpfs           394M  4.0K  394M   1% /run/user/0

这是因为 .img 是 QCOW2,这种格式是稀疏,按需分配的。宿主机上看到的 319M 是已经分配的数据量,虚拟机内部看到的 2.2G 是“虚拟磁盘的逻辑大小 (virtual size)”

$ qemu-img info jammy-minimal-cloudimg-amd64.img
image: jammy-minimal-cloudimg-amd64.img
file format: qcow2
virtual size: 2.2 GiB (2361393152 bytes)
disk size: 318 MiB
cluster_size: 65536
Format specific information:
    compat: 0.10
    compression type: zlib
    refcount bits: 16
Child node '/file':
    filename: jammy-minimal-cloudimg-amd64.img
    protocol type: file
    file length: 318 MiB (333774848 bytes)
    disk size: 318 MiB

基本环境配置

初始下载的镜像是一个 minimal 的镜像,还并不能像一个成熟的发行版一样使用,下面我们依次解决磁盘和网络的配置问题

磁盘扩容

初始磁盘容量一般不大,联网之后稍微下载几个文件就满了。为磁盘扩容非常简单,使用 qemu-img 为当前镜像 +8GB 磁盘容量

qemu-img resize <img> +8G
# 直接 resize 到 10G
qemu-img resize <img> 10G

但是进入虚拟机之后 df -h 虽然可以看到 /dev/sda 变大了,但是显示的磁盘容量还是 2.2GB?

上文 qemu-img info 的信息我们可以看到,一个虚拟磁盘(qcow2/raw)实际上是一个容器,里面包含:

  1. 分区表(MBR 或 GPT)
  1. 分区(比如 /dev/sda1)
  1. 分区内部的文件系统(ext4/xfs/btrfs 等)

而 qemu-img resize 只改了虚拟磁盘大小,没有改分区表、文件系统。所以当前的容器状态可以理解为 2.2GB 之后加了一段 free 的空闲磁盘,但是并没有划分分区也没有创建文件系统

[ qcow2 image ]
|-- sda1 (2.2G) ----|---- free 8G ----|

此时需要在虚拟机内部执行

growpart /dev/sda 1
resize2fs /dev/sda1

其中第一步 growpart 用于修改分区表,让 /dev/sda1 从 2.2GB 到占满整个磁盘

[ qcow2 image ]
|-- sda1 (2.2G) ----|---- free 8G ----|

$ growpart /dev/sda 1

|-- sda1 (10.2G) ----------------------|

growpart 扩大了 sda 分区,但是此时 free 磁盘部分还没有被文件系统使用,所以我们需要 resize2fs /dev/sda1 来扩展文件系统的元数据和数据区,让文件系统(一般来说是ext4)真正使用新的空间

文件系统需要初始化磁盘划分 inode/data block 之后才能正常存储数据,并不是直接拿来空磁盘就可以直接使用的,详见文件系统磁盘布局

[!NOTE] NOTE

简单总结一下,磁盘扩容需要三步

qemu-img resize <img> +8G
growpart /dev/sda 1
resize2fs /dev/sda1

多块磁盘或者多个分区只需要调整 /dev/xxx 即可

磁盘挂载

有时候我们希望添加一些在磁盘镜像中添加一些内容, 比如希望把本机上面的文件拷贝到虚拟机中,一种可用的方式当然是通过网络(ftp/scp),另一种更简单性能更好的方式就是挂载磁盘镜像然后直接复制过去。

要把 QCOW2 磁盘镜像挂载到本地(像挂载普通目录一样访问)很简单,最常用、最稳定的方法是 使用 qemu-nbd。这是官方推荐方式,不需要转换格式,也不需要复制磁盘。

另一种方式是使用 guestmount

# 安装
sudo apt install libguestfs-tools

# 挂载
guestmount -a your.qcow2 -i my_mnt

# 卸载
guestunmount /mnt
[!TIP] TIP

虽然看上去好像 guestmount 步骤少了很多,但是你用一下就会发现拷贝文件等操作非常慢,因为 guestmount 本质上是 libguestfs + FUSE 文件系统,libguestfs 内部会运行一个轻量化 QEMU,FUSE 有多次用户态/内核态切换,以及 guestmount 默认的安全模式(甚至root用户都不能cp,只能调用guestmount的普通用户才有权限)都会拉低性能

所以建议使用 qemu-nbd 的方式

如果磁盘并不是 qcow2 格式,例如读者使用了一个 raw 格式的磁盘安装 iso 得到的镜像,那么需要先查看一下该磁盘的分区,因为安装 ubuntu 的话默认第一个分区是 /boot 的分区不能直接挂载

sudo fdisk -l ubuntu.raw

此时发现该磁盘有两个分区, 第二个分区为根分区, 所以应该跳过第一个分区, 找到对应的偏移量开始挂载

20240613182359

偏移量计算为 (4095 + 1) * 512 = 2097152,每个扇区 512 字节,需要跳过前面 4095 个扇区,第 4096 个扇区是对应的根分区起始地址

sudo mount -o loop,offset=2097152 ubuntu.raw tmp

关于挂载的更多内容详见mount

磁盘安装内核模块

内核中有一些组件并不是直接编译进内核的, 绝大部分内核模块都是核外编译保存的,它们可以在合适的时机装载进内核,在内核启动之后也会加载一些内核模块。

我们在之前的 Makefile 中使用了 --kernel 参数传递了自己编译的内核,如果使用 uname -r 查看应该是 6.6.0+ 版本

root@ubuntu:~# uname -r
6.6.0+

默认所有内核模块都会被安装到 /lib/modules/$(uname -r) 下,这个目录下目前只有一个 5.15.0-1088-kvm,它是这个镜像默认使用的内核

首先挂载镜像,然后在内核的源码库下执行

make modules_install INSTALL_MOD_PATH=/path/to/mnt

将 modules 目录下的格式如下, kernel 目录下是所有的内核模块,build 目录是一个指向源码目录的软链接,如果要在虚拟机内编译对应的内核模块可以考虑将内核源码树拷贝进去。当然也可以选择在外侧编译然后把编译好的模块拷贝进去。本系列文章选择使用在外侧编译,内核模块编译方法详见内核模块编译

 lib
     modules
         6.6.0+
             build -> /home/kamilu/klinux
             kernel
                 drivers
                    net
                       ethernet
                           intel
                               e1000
                    thermal
                        intel
                 fs
                    efivarfs
                 net
                     ipv4
                        netfilter
                     netfilter

将该目录 copy 到磁盘镜像的 /lib/modules 下即可

进去之后发现终端没有彩色输出, 修改 .bashrc, 找一个 ubuntu 默认的 .bashrc 拷贝过来 github gist

export TERM=xterm-256color

这一步我们选择一个比较老的 Ubuntu 18.04 mini.iso, 只有 70MB 大小, 其余软件是从网络上下载的

这个版本的镜像没有安装 openssh, 需要先安装

sudo apt install net-tools
sudo apt-get install openssh-client
sudo apt-get install openssh-server
sudo /etc/init.d/ssh restart

最后检查一下防火墙是不是关闭了

sudo ufw status
# inactive 表示关闭

接下来就可以使用 ssh 从外部连接了

网络配置

qemu 的网络配置比较复杂, 可以做很多模式的连接, 这里简单介绍一下最基础的联网配置, 至少可以用 apt 下载软件包了

网卡驱动

大部分情况我们需要使用自己编译的内核, 在编译内核的时候选择 E1000 网卡驱动

-> Device Drivers
   -> Network device support (NETDEVICES [=y])
      -> Ethernet driver support (ETHERNET [=y])
         -> Intel devices (NET_VENDOR_INTEL [=y])
            -> Intel(R) PRO/1000 Gigabit Ethernet support (E1000 [=y])

也可以作为模块编译, 不过建议编译进内核比较省事

User 模式

启动 qemu 时添加如下参数

qemu-system-x86_64 \
        -m 4G \
        -kernel /home/kamilu/klinux/arch/x86/boot/bzImage \
        -drive format=raw,file=disk/ubuntu.raw \
        -append "root=/dev/sda2 console=ttyS0" \
        -nographic -no-reboot -d guest_errors -serial mon:stdio \
        -netdev user,id=net0,hostfwd=tcp:127.0.0.1:2222-:22 -device e1000,netdev=net0

查看网络配置, 不出意外的话网卡名应该是 enp0s3, 如果不是的话下面对应的名字也替换一下

kamilu@ubuntu2404:~$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    ...
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
   ...
3: sit0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
    link/sit 0.0.0.0 brd 0.0.0.0

其中 enp0s3 网卡还并未配置, 创建一个网卡配置文件

sudo vim /etc/netplan/01-netcfg.yaml

写入如下内容, 需要注意的是 YAML 对缩进非常敏感, 直接复制进去可能会有缩进问题, 建议手打

network:
  version: 2
  ethernets:
    enp0s3:
      addresses:
        - 10.0.2.15/24
      routes:
        - to: default
          via: 10.0.2.2
      nameservers:
        addresses:
          - 8.8.8.8
          - 8.8.4.4

修改文件权限

sudo chmod 600 /etc/netplan/01-netcfg.yaml

应用 Netplan 配置

sudo netplan apply

此时网络就已经完成了配置, 再次查看网络配置

kamilu@ubuntu2404:~$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host noprefixroute
       valid_lft forever preferred_lft forever
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 52:54:00:12:34:56 brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.15/24 brd 10.0.2.255 scope global enp0s3
       valid_lft forever preferred_lft forever
    inet6 fec0::5054:ff:fe12:3456/64 scope site dynamic mngtmpaddr noprefixroute
       valid_lft 86274sec preferred_lft 14274sec
    inet6 fe80::5054:ff:fe12:3456/64 scope link
       valid_lft forever preferred_lft forever
3: sit0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
    link/sit 0.0.0.0 brd 0.0.0.0

然后就可以正常联网使用了, 再次启动也会自动联网

tap 模式

[!WARNING] WARNING

存疑, 还有问题! 不要用!

一般的网络配置建议直接使用 user

-netdev user 模式适用于简单的网络需求,但如果你需要更复杂的网络配置或完整的桥接网络连接,可以考虑使用桥接模式。

首先在你的主机上创建 tap0 接口,并将其添加到一个网桥中

sudo ip tuntap add tap0 mode tap user $(whoami)
sudo ip link set tap0 up
sudo brctl addbr br0
sudo brctl addif br0 tap0
sudo dhclient br0

其中最后一步可能会卡住, 通常是因为网桥 br0 没有正确配置或没有有效的网络接口连接, 可以尝试为网桥 br0 手动分配静态 IP 地址,避免依赖 DHCP:

sudo ip addr add 192.168.1.100/24 dev br0  # 替换为你网络的 IP 地址段
sudo ip link set br0 up

要配置桥接网络,修改启动参数

qemu-system-x86_64 \
        -m 4G \
        -kernel /home/kamilu/klinux/arch/x86/boot/bzImage \
        -drive format=raw,file=disk/ubuntu.raw \
        -append "root=/dev/sda2 console=ttyS0" \
        -nographic -no-reboot -d guest_errors -serial mon:stdio \
        -netdev tap,id=net0,ifname=tap0,script=no,downscript=no -device e1000,netdev=net0

磁盘扩容

如果初始创建的磁盘镜像随着使用发现空间不足, 可以比较方便的扩容

第一步将磁盘镜像文件的大小从 20GB 扩展到 50GB

qemu-img resize ubuntu.raw 50G

扩展磁盘后,还需要在虚拟机内部调整分区和文件系统以使用新增的空间, 使用 cfdisk 调整分区 /dev/sda

如果是用的 if=virtio 那么是 /dev/vda

sudo cfdisk /dev/sda

选择 /dev/sda2 分区将其扩容至 50GB

使用 df -h 查看磁盘剩余容量, 发现还是 20G

kamilu@ubuntu2404-VM:~$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/root        20G  8.2G   11G  45% /
tmpfs            95G     0   95G   0% /dev/shm
tmpfs            38G  892K   38G   1% /run
tmpfs           5.0M     0  5.0M   0% /run/lock
tmpfs            19G   12K   19G   1% /run/user/1000

但是 lsblk 可以看到分区信息已经更新

kamilu@ubuntu2404-VM:~$ lsblk
NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
sda      8:0    0   50G  0 disk
sda1   8:1    0    1M  0 part
sda2   8:2    0   50G  0 part /
sr0     11:0    1 1024M  0 rom

这是因为文件系统的信息还没有更新, 查看根目录的文件系统类型(默认是ext4)

kamilu@ubuntu2404-VM:~$ df -Th
Filesystem     Type   Size  Used Avail Use% Mounted on
/dev/root      ext4    20G  8.2G   11G  45% /
tmpfs          tmpfs   95G     0   95G   0% /dev/shm
tmpfs          tmpfs   38G  892K   38G   1% /run
tmpfs          tmpfs  5.0M     0  5.0M   0% /run/lock
tmpfs          tmpfs   19G   12K   19G   1% /run/user/1000

重新更新文件系统信息

sudo resize2fs /dev/sda2

ext4 更新块信息后再次查看发现磁盘大小正常

kamilu@ubuntu2404-VM:~$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/root        50G  8.2G   39G  18% /
tmpfs            95G     0   95G   0% /dev/shm
tmpfs            38G  892K   38G   1% /run
tmpfs           5.0M     0  5.0M   0% /run/lock
tmpfs            19G   12K   19G   1% /run/user/1000

Ubuntu iso 安装

也有一些读者可能就像手动安装一下,当然也可以,在 ubuntu server 查找到 Ubuntu 的 server 镜像 iso 文件,也有一些其他存档镜像:

下载的 iso 是光盘 CD/DVD 映像, 需要先创建一个存储介质用于安装系统

qemu-img create ubuntu.raw -f raw 20G

安装系统, 其中 -drive -cdrom 的路径自行调整

qemu-system-x86_64 \
            -m 4G \
            -drive format=raw,file=disk/ubuntu.raw,if=virtio \
            -cdrom iso/ubuntu-24.04-live-server-amd64.iso

具体的安装过程略过, 注意不要开启 LVM, 启用 OpenSSH

安装完成之后就不再需要 -cdrom 了, 可以直接通过磁盘启动

qemu-system-x86_64 \
            -m 4G \
            -drive format=raw,file=disk/ubuntu.raw,if=virtio

启动会有点慢, 耐心一些

启动之后可以使用 lsblk 看一下当前的所有磁盘, 启动 sr0 代表第一个光盘驱动器(CD-ROM Drive), vda 代表第一个 Virtio 磁盘设备

kamilu@kamilu:~$ lsblk
NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
sr0     11:0    1 1024M  0 rom
vda    253:0    0   20G  0 disk
vda1 253:1    0    1M  0 part
vda2 253:2    0   20G  0 part /

由于命令行中设置参数中 if(interface) 指定为 if=virtio 所以这里显示的是 vda, 如果设置为 if=sd 则这里应该是 sda, 这里设置为 virtio 可以让设备通过允许客体系统直接与主机通信而绕过仿真层, 以实现高效的磁盘I

可以看到 /dev/vda1 是用于启动的 /boot 分区, /dev/vda2 是挂载的根分区, 也是后面启动时需要手动指定的分区

参考

zood