Post

Orange OS: Cross-compiling Microkernel Operating System from aarch64 to x86.

0. 前言

这篇博客主要是写给那些像我一样喜欢折腾的朋友们的, 里面记录了一些在 m 芯片 (aarch64 架构) 的 mac 电脑上完成《Orange’s 一个操作系统的实现》实验时踩过的坑和解决方案. 如果你想用 mac 电脑或者只用 qemu 来完成实验, 亦或是单纯是想比课程内容更深入的了解 OS, 那么这篇博客将会对你有所帮助.

为什么不按照书上所说, 在 i386 的 Ubuntu 上做呢? 那样确实会简单许多, 也不会遇到这么多坑. 但是! i386 的系统太古老了! 2023 年还在用 2014 年的系统, 实在说不过去了, 当然也是因为 m 芯片的 mac 早就抛弃了对 x86 的支持, 想装个课程所用的 Ubuntu 14.04 就得先用 Parallels 虚拟一个 x86 Ubuntu 22.04 然后在里面用 qemu 再模拟一个 14.04, 三重套娃效率可想而知. 第一周的时候折腾了整整一周都没有配好这个环境, 索性直接就在 mac 本机上摸索去了. 记得上课的时候严老师提了一嘴之前有人跑通过 qemu, 说是到后期调试会非常舒服, 于是就确定下来了工具链: qemu vscode + 交叉编译工具链.

博客中只会写一些网上搜不到的关键步骤, 其他相关知识大家可以自己在网络上找到. 当然, 受限于课程时间, 最后还是遗留了几个小问题没有解决, 希望后来的朋友们能更加完善吧.

项目地址: github.com/aaroncomo/Orange-OS

1. 环境配置

安装 qemu 和 nasm

如果你有配置过 brew 包管理器, 那么直接一条命令即可安装. brew 可以理解为 macOS 上的 apt 指令, 强烈建议装好它, 这会让你的日常开发变得舒适很多.

1
2
brew install qemu
brew install nasm

使用下列命令来检查一下是不是安装成功.

1
2
qemu-system-i386 --version
nasm --version

配置 lldb

既然用了 mac, 那当然要用 lldb 来 debug 了, 为了让自己看着方便, 将它默认的反汇编风格从 AT&T 改成 intel.

1
echo 'setting set target.x86-disassembly-flavor intel' >> ~/.lldbinit

挂载 FAT12 文件系统

mac 已经抛弃了对 FAT 文件系统的支持, 这里需要用到一个工具 OrbStack, 你可以理解为类似 Windows 上 WSL 的东西.

1
2
orb sudo mount -o loop your_file.img /mnt/floppy
orb sudo umount /mnt/floppy

这里我没找到原生挂载FAT12的方法, 大家可以试着找一找其他方法.

创建空磁盘

前面章节用到的磁盘都是没有文件系统的, 这里有两种方法来创建.

  1. bximage

    1
    
     brew install bximage
    

    其余步骤同书上.

  2. dd

    空磁盘的本质其实就是一个全 0 的文件, 可以直接用 dd 命令写入相应字节数的 0 即可.

    1
    
     dd if=boot.bin of=a.img bs=512 count=1 conv=notrunc
    

2. 第一次启动

配好上述环境后就可以启动我们的 OS 了. qemu 不像 bochs 有自己的内置调试器, 但它开放了一个 1234 号端口, 让我们能够用任何你喜欢的调试器远程接入, 也就是说你可以完全抛弃 bochs 里那些调试指令了, 以后只需要用 gdb 或者 lldb 就能对 OS 进行调试.

运行下面命令启动 qemu.

1
qemu-system-i386 -drive format=raw,file=a.img,if=floppy -boot a -s -S

其中 -s -S 参数表示让启动器在代码入口处停止执行并等待调试器连接. 随后启动 lldb, 通过 1234 端口接入 qemu.

1
2
lldb
(lldb) gdb-remote localhost:1234

image-20240305144612898

代码停在 0xfff0, 也就是书上说的入口地址. 使用 br set -a 0x7c00 设置一个断点, 使用 c 指令通知程序继续运行, 打断后对指令进行反汇编一下. 这正是我们编写的代码.

image-20240305145218677

前几次实验需要使用到 FreeDOS, 启动时只需要更改一下 qemu 的参数即可.

1
2
3
4
5
qemu-system-i386 \
    -drive format=raw,file=freedos.img,if=floppy,id=a \
    -drive format=raw,file=pm.img,if=floppy,id=b \
    -boot a \
    -s -S \

两个 -drive 指令分别指明用两张软盘启动系统.

OK, 这就是最基础的部分了, 后续所有实验都会在这个流程上进行扩充. 当你尝试 qemu 以后, 是不是感觉比 bochs 更加现代呢?

3. 断点

bochs 提供了一个非常方便的功能: magic_break. 它在代码中插入一个 xchg bx, bx 作为断点, 当程序运行到这里的时候就会被打断. qemu 没有内置调试器, 那就当然就没办法触发断点, 那如何调试呢? 当然是自己动手写一个啦. 其实非常简单, 就一条指令:

1
jmp $

死循环. 当程序运行到此就会卡住, 跟断点功能一致是不是. 我们知道 CPU 通过按字节解析二进制来对指令进行解码, jmp $ 代码的二进制长度是 4 Bytes, 如果想让代码继续运行, 只需要对 eip 寄存器 (即 PC) 加四即可跳过 jmp $ 指令.

4. 深入了解实模式和保护模式下的指令运行模式

这部分内容需要配合第二次实验进行.

使用 qemu 调试时我发现一个很奇怪的问题, 无论在实模式还是保护模式下, 当程序被设置的死循环打断时, 反汇编的结果经常是错误的, 无论汇编代码是什么, 总会解析出 lock 等指令, 有时甚至会出现 eip 相同的情况, 使得调试无法进行.

img

这让我很奇怪为什么 bochs 的反汇编没有问题但是 qemu 却不行. 经过几天的摸索, 终于在 bochs 的输出中找到了答案.

实模式下的寻址方式

img

img

在这个断点的位置在实模式中, lldb只反汇编了eip 的值, 虽然 bochs 也显示这个地址为 3224:03a0, 但是前面中括号中的地址看起来和这两个值都不相关, 简单算了一下, 这个地址正好是 cs<<4+eip 的值, 这就是不就是 i386 的寻址方式吗, 试着将这个地址反汇编, 发现结果真的正确.

img 正确反汇编出代码, 第一行的 mov 指令是我自己加的

每次都要这样手算一次地址再反汇编实属麻烦, 搜了一下发现 lldb 提供了 python 接口支持, 那不如直接写个脚本每次自动算出地址再反汇编.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import lldb


def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand("command script add -f disass.disassemble da")
    
    
def toInt(x):
    """x: hex string like 0x..."""
    return int(x, 16)


def disassemble(debugger, command, result, internal_dict):
    interpreter.HandleCommand("re r cs pc", returnObject)
    output = returnObject.GetOutput().split()
    cs, eip = output[2], output[5]
    cs = toInt(cs) << 4
    eip = toInt(eip)
    addr = hex(cs + eip)
    interpreter.HandleCommand(f"disass -b -s {addr} -c {lines}", returnObject)
    print(returnObject.GetOutput())

__lldb_init_module() 中注册指令, 使每次启动 lldb 时自动将脚本注册为指令da, da 命令将调用 disassemble() 实现实模式下正确地址的反汇编. 编写完成后将其配置为启动参数, 让每次lldb启动时自动引入此扩展脚本.

1
echo 'command script import ~/lldb/disass.py' >> ~/.lldbinit

这样就解决了实模式下无法正确调试问题.

img da 指令反汇编效果图

保护模式下的寻址模式

写了上面的脚本后本以为万事大吉, 结果第二个断点就出问题了, 依旧是 lock 指令.检查了一下, da 指令反汇编的地址确实 cs 和 eip 算出来的, 那这个地方为什么不是我是指令?

img 出错位置的反汇编指令

img 出错位置的寄存器

image-20240305154416915

看了一眼所有寄存器发现这时 CPU 已经从实模式跳到了保护模式中, 寻址方式已经变了所以肯定找不到对的地址. 想了一下这里的寻址方式应该已经变成了右图的模式. cs 中装的并不是代码段段地址, 而是代码段选择子; eip 中也不再是指令指针, 而是下一条指令的段偏移.

那代码究竟在哪? 正好有了 da 指令了, 那我干脆在实模式下多反汇编点东西出来, 看看都有什么. 于是在第一个断点的位置反汇编出来了一大堆指令, 往下翻果然找到了保护模式跳入的第一条代码 0x326f4 以及第二个断点真正的地址 0x326f8.

img

当时就很迷惑, 这地址是怎么来的? 我能用 reg read 读出来的所有寄存器值没有一个值跟这个地址沾边. 查了很多资料以后从这个帖子中找到了灵感: cs 中放的是 selector! 比如 cs 的值是 0x10, 段号就是 2. 那方向就清晰了: 找到 gdt 的基址. 虽说要得到 gdt 的基址直接看 gdtr 寄存器就行了, 但是为什么 reg read 的结果中没有这个寄存器? 又找了很久看到这样一句话:

gdb 运行在 user mode 上, 无法访问 gdtr.

img

User mode, 换言之就是用户态嘛, 既然用户态的程序访问不到 gdtr, 那我找个有 ring 0 权限的程序就行了, 那谁权限最高, 显而易见是 qemu 本身. 查了一下 qemu 的文档, 果然找到了在 qemu 中直接读寄存器的方法: 在 qemu monitor 中用 info registers 指令. qemu monitor 是一种交互式命令行界面, 提供了一系列命令, 可以用于管理和监视虚拟机的各个方面, 包括虚拟机的状态、设备的配置、内存和 CPU 的情况, 以及与虚拟机中的操作系统进行交互等. 从右图中能够看到 gdt, idt, ldt 等寄存器都被显示出来了, 并且给出了他们的基地址以及大小. 好了这次终于看到了 gdt 的基址 0x32344 以及大小 0x3f . 直接用 lldb 读一下这段内存.

img In Protect Mode now. \^-\^ ABCDEFGHIJKLMHOPQRSTUVWXYZ. 多么熟悉的字符串, 这不就是汇编代码中定义完 gdt 和 selector 以后在数据段定义的字符串吗. 那显然从 0x32344 开始的内存就是 gdt 了. 刚才看到 cs 里是 0x10, 段号是 2, 找一下就是这个 descriptor (注意高位在右侧):

img

段基址是 0x326f4 好像很耳熟, 再把刚才的两个图摆出来:

img

img

这就是跳入保护模式后第一条指令的地址, 而 eip 的值为 0x4 也就很好解释了, 因为当时处在断点 (死循环) 中, 离段基址偏移 4 的位置正好就是这条跳转指令 jmp 0x326f8.

好了既然手动能算出来, 那就写一个自动解析的工具吧. qemu 提供了一个很好用的接口来让外部调试器访问到 qemu monitor.

1
(lldb) process plugin packet monitor info registers

当然也可以简写为:

1
(lldb) pr p p m info registers

这条指令将会返回 monitor 中的信息.

image-20240305161545827

下面附上完整的 da 指令插件.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import lldb


def toInt(x):
    """x: hex string like 0x..."""
    return int(x, 16)


def disassemble(debugger, command, result, internal_dict):
    # Parse command
    cmds = command.split()
    lines = 8 if not len(cmds) else cmds[0]

    interpreter = lldb.debugger.GetCommandInterpreter()
    returnObject = lldb.SBCommandReturnObject()

    # test wether in protected mode
    interpreter.HandleCommand("re r cr0", returnObject)
    cr0 = returnObject.GetOutput().split()[-1]

    if cr0[-1] == "0":  # In real mode
        interpreter.HandleCommand("re r cs pc", returnObject)
        output = returnObject.GetOutput().split()
        cs, eip = output[2], output[5]
        cs = toInt(cs) << 4
        eip = toInt(eip)
        addr = hex(cs + eip)
        interpreter.HandleCommand(f"disass -b -s {addr} -c {lines}", returnObject)
        print(returnObject.GetOutput())
    else:  # In protected mode
        interpreter.HandleCommand("pr p p m info registers", returnObject)
        output = returnObject.GetOutput().split()
        gdtr = f"0x{output[79]}"
        gdt_len = f"0x{output[80]}"

        # # Read GDT from memory
        # interpreter.HandleCommand(f"m read {gdtr} -c {gdt_len}", returnObject)
        # gdt = returnObject.GetOutput()

        # Read cs
        interpreter.HandleCommand("re r cs eip", returnObject)
        output = returnObject.GetOutput().split()
        cs, eip = output[2], output[5]
        gdt_idx = int(bin(toInt(cs))[2:-3], 2)

        # Get descripter
        start = hex(toInt(gdtr) + gdt_idx * 8)
        interpreter.HandleCommand(f"m read {start} -c 0x8", returnObject)
        desc = returnObject.GetOutput().split()

        # Get physical address
        addr = hex(toInt(desc[5] + desc[4] + desc[3]) + toInt(eip))
        interpreter.HandleCommand(f"disass -b -s {addr} -c {lines}", returnObject)
        print(returnObject.GetOutput())
        pass


def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand("command script add -f disass.disassemble da")

5. 交叉编译

安装交叉编译工具链

1
2
brew install x86_64-elf-gcc
brew install x86_64-elf-binutils

上述工具可以将代码从 aarch64 编译到 x86_64.

配置 Makefile

修改 program 和 flag.

1
2
3
4
5
6
7
8
9
ASM		= nasm
DASM		= ndisasm
CC		= x86_64-elf-gcc
LD		= x86_64-elf-ld
ASMBFLAGS	= -I boot/include/ -g
ASMKFLAGS	= -I include/ -f elf -g1
CFLAGS		= -I include/ -c -fno-builtin -m32 -fno-stack-protector -g1
LDFLAGS		= -Ttext $(ENTRYPOINT) -m elf_i386 -z noexecstack -g
DASMFLAGS	= -u -o $(ENTRYPOINT) -e $(ENTRYOFFSET)

至于 gcc 的参数为什么使用 -g1 而不是 -g 有兴趣的朋友可以自己 man 一下看看文档就知道了. 当然我后续也会写到这个问题.

修改 buildimg

1
2
3
4
5
6
buildimg:
	dd if=boot/boot.bin of=a.img bs=512 count=1 conv=notrunc
	orb sudo mount -o loop a.img /mnt/test
	orb sudo cp -fv boot/loader.bin /mnt/test
	orb sudo cp -fv kernel.bin /mnt/test
	orb sudo umount /mnt/test

6. 接入 VS Code !

激动人心的时刻到了, 这是用 bochs 的朋友无法拥有的快乐! 这意味着从此以后整个 OS 中的每一个变量和每一个运行过程都能被你看到!

这里选择用 lldb 作为调试器, 当内核过大时完整调试请使用 gdb.

推荐首先在 VS Code 中安装一个 CodeLLDB 插件, 这个插件能够支持自动反汇编, 方便后续对汇编代码的调试. 关于 VS Code 的配置文件具体含义是什么我就不写了, 比较复杂一两句话说不清楚, 有兴趣的朋友可以自己上网查一下相关资料. 我给出的配置文件后续还会遇到问题, 到时候需要自己根据出现的问题来进行调整.

配置 tasks.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "launch qemu",
            "type": "shell",
            "isBackground": true,
            "command": "make image && echo Starting QEMU && qemu-system-i386 -drive format=raw,file=a.img,if=floppy,id=b1 -boot a -s -S",
            "problemMatcher": {
                "pattern": {
                    "regexp": "^(Starting QEMU)",
                    "line": 1
                },
                "background": {
                    "activeOnStart": true,
                    "beginsPattern": "^(Starting QEMU)",
                    "endsPattern": "^(Starting QEMU)"
                }
            }
        },
    ]
}

配置 launch.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "qemu kenel debug",
            "type": "lldb",
            "request": "custom",
            "breakpointMode": "file",
            "initCommands": [
                "setting set target.x86-disassembly-flavor intel"
            ],
            "targetCreateCommands": [
                "target create kernel.bin"
            ],
            "processCreateCommands": [
                "gdb-remote localhost:1234"
            ],
            "preLaunchTask": "launch qemu",
        },  
    ],
}

配置 settings.json

1
2
3
4
5
6
{
    "lldb.displayFormat": "auto",
    "lldb.showDisassembly": "auto",
    "lldb.dereferencePointers": true,
    "lldb.consoleMode": "commands"
}

一些效果图

image-20240305175450780

image-20240305175154714

7. 附加调试信息

书上给的 loader.asm 是有问题的, 一旦内核超过 64KB 保存内核大小的寄存器将会发生溢出, 导致虚拟机崩溃. 之前在这里我使用 -g1 参数, 就是为了只附加最少调试信息保证内核大小不超过 64KB. 但是这终究是个临时的方法. 如果你当时动手尝试过使用 -g 或者 -g3 参数就会发现调试信息占的空间远超内核代码本身. 如果能把调试信息从内核中剥离出来就好了. 在计算机中你只要敢想, 就一定能找到办法. 怎么办呢? 第一步就是重新投入 gdb 的怀抱.

这里回到 gdb 是因为 lldb 好像加载不了 GNU 工具链的符号文件, 大家可以找找用 lldb 来加载的方式.

剥离符号文件

1
2
3
make everything CFLAGS="-I include/ -I include/sys/ -c -fno-builtin -Wall -m32 -fno-stack-protector -g"
x86_64-elf-objcopy --only-keep-debug kernel.bin kernel.debug
x86_64-elf-objcopy --strip-debug kernel.bin

动态修改 CFLAGS 参数, 在其中加入 -g 参数来为内核附加完整调试信息. 然后将符号文件单独存为 kernel.debug, 并将其从内核中剥离. 这样以来我们就得到了一个干净的内核以及 g2 级别的所有变量的符号文件.

安装 gdb

mac 其实是能够支持 gdb 的, 只需要安装下面这个交叉编译过的版本即可.

1
brew install x86_64-elf-gdb

重新配置 VS Code

换到 gdb 后, VS Code 的配置文件需要进行更新.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "gdb kernel debug",
            "type": "cppdbg",
            "request": "launch",
            "miDebuggerServerAddress": "127.0.0.1:1234",
            "program": "kernel.bin",
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": true,
            "logging": {
                "engineLogging": false
            },
            "stopAtConnect": true,
            "MIMode": "gdb",
            "miDebuggerPath": "/opt/homebrew/bin/x86_64-elf-gdb",
        },
        {
            "name": "lldb kenel debug",
            "type": "lldb",
            "request": "custom",
            "breakpointMode": "file",
            "initCommands": [
                "command script import ~/coding/lldb/disass.py",
                "setting set target.x86-disassembly-flavor intel",
            ],
            "targetCreateCommands": [
                "target create --arch i386 kernel.bin",
            ],
            "processCreateCommands": [
                "gdb-remote localhost:1234",
            ],
        },
    ],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "launch qemu",
            "type": "shell",
            "isBackground": true,
            "command": "make image && echo Starting QEMU && qemu-system-i386 -drive format=raw,file=a.img,if=floppy -drive format=raw,file=80m.img,if=ide -boot a -s -S",
            "problemMatcher": {
                "pattern": {
                    "regexp": "^(Starting QEMU)",
                    "line": 1
                },
                "background": {
                    "activeOnStart": true,
                    "beginsPattern": "^(Starting QEMU)",
                    "endsPattern": "^(Starting QEMU)"
                }
            }
        },
        {
            "label": "build image",
            "type": "shell",
            "command": "orb build image && x86_64-elf-objcopy --only-keep-debug kernel.bin kernel.debug && x86_64-elf-objcopy --strip-debug kernel.bin"
        }
    ]
}

加载符号文件

后续调试我们使用 gdb kernel debug 配置文件来进行. 启动调试后在命令行拉起 qemu.

1
qemu-system-i386 -drive format=raw,file=a.img,if=floppy -drive format=raw,file=80m.img,if=ide -boot a

在 VS Code 中的调试窗口中输入如下指令加载符号文件:

1
-exec add-symbol-file kernel.debug
This post is licensed under CC BY 4.0 by the author.