Project RC

一种标准阿西的设计与实现。

移植树莓派驱动框架 Circle 到自制操作系统

创建于
分类:Dev
标签:树莓派驱动OS

0. 前言

什么是 Circle?

Circle 是一个叫 rsta2 的大佬用 C++ 写的 bare-metal 的树莓派驱动框架,同时支持现存的几乎所有版本树莓派,能够驱动树莓派上的大部分设备,包括 SD 卡控制器、有线和无线网卡、GPIO、USB 控制器及一些常用 USB 设备等。

这些设备驱动中有一些是 rsta2 参考 Linux 或其它 bare-metal 的实现自己写的,还有一些是他直接从其它系统移植过来的。各驱动对外封装成了 C++ 类的形式,可以按需对其实例化和使用。对于在树莓派上编写自制操作系统并且希望尽快驱动一些设备的场景,将 Circle 整个移植过来是非常值得考虑的选项。当然,需要注意的是,Circle 的开源协议是 GPLv3,具有传染性,应该把它作为系统的非必要组件来使用。

本文干了什么?

本文将简要记述将 Circle 驱动框架移植到一个自制微内核操作系统(实际上是实验室项目),作为用户态驱动进程的过程。

由于只是简要地记录移植过程和经验总结,本文不会深入到每一个具体的细节,如果你恰好有相似的需求,除了参考本文,还需要具体地去研究 Circle 的代码,并根据具体情况具体分析。

1. 确定需求

首先一开始不要盲目移植,先确定一下自己需要 Circle 中的哪些设备驱动,比如说,如果你只是需要 USB 相关驱动,那么在移植过程中可以不关心 WLAN 那块是否有不兼容。

然后跑一些 Circle 提供的 sample,确定 Circle 确实可以满足需求。

2. 移植基本部分

这部分包括一些非常必要的组件,比如 mailbox 访问,许多驱动都需要通过 mailbox 获取信息或申请资源等。这部分移植完成后,将可以在启动时获取机器信息,并点亮 LED 灯。

具体地,主要需要做下面这些事情:

  1. 修改 Rules.mkMakefile,去掉任何 boot 相关的代码
  2. 重新实现一些模块,去除需要特权指令的地方,并使用用户态 lib 提供的功能代替:
    • assert:assert 失败后不要真的关机或重启,可以 exit
    • interrupt:一开始可简单打印点内容,不用真的实现
    • logger:改为 printf 输出
    • new:使用 malloc 分配
    • sysinit:提供假实现
    • timer:使用 nanosleep 实现 SimpleusDelay
    • memory:CMemorySystem 类可以直接去掉,一开始会有地方用到 CMemorySystem::GetCoherentPage,改用系统提供的分配物理上连续的 non-cacheable 内存的接口
  3. Circle 进程启动时将物理地址的外设区域直接对等映射到当前进程的虚拟地址空间,这样将不需要改动 Circle 中通过 MMIO 访问外设时使用的地址

经过一些调试后,可以点亮 LED 灯,输出日志到 stdout,然后退出(需要编写适当的 kernel.cpp,可参考 sample),这意味着简单的 MMIO 已经可以了。

3. 驱动屏幕

这一步同样需要重新实现一些模块:

  • synchronize:主要是刷 cache 相关操作
  • bcmframebuffer:向 GPU 申请 frame buffer 后需要将其映射到当前进程的虚拟地址

经过一些调试后,可以通过 HDMI 输出内容到屏幕。

4. 模拟实现 Timer

对于一些稍复杂的驱动,例如 USB 和 WLAN,会依赖 timer 获取当前 tick,因此需要重新实现 timer 模块。

具体地,可以在 CTimer::Initialize 中创建一个新的线程,每隔 10 ms(利用 nanosleep 等函数)调用一次 CTimer::InterruptHandler,其它代码几乎不用改动。此外,还需要实现 CTimer::GetClockTicks 以获得当前 tick 数。

经过一些调试后,输出的日志中能够包含当前时间,此时说明 timer 基本实现对了。

5. 解决内存相关的一系列问题

许多驱动在运行的过程中需要分配内存以供外设进行 DMA,同时又需要在进程内访问这块内存以读写跟外设交互的数据。因此,这块内存既需要能通过虚拟地址访问,又需要能获取到物理地址,同时在物理地址上连续且 non-cacheable。malloc 是不能满足这个需求的,因为 malloc 只保证分配出的内存虚拟地址连续,不能保证物理地址连续,也无法配置成 non-cacheable。

要解决这个问题,需要内核提供分配物理上连续且 non-cacheable 的内存并映射到虚拟地址空间的相关系统调用,然后再在 Circle 中利用这些系统调用重新实现内存相关模块。其实在前面已经粗略地实现了,但在这一步需要确保实现的正确性。对 Circle 的修改主要涉及 CMemorySystem::GetCoherentPageDMA_BUFFERBUS_ADDRESSnew (HEAP_DMA) 的定义及使用它们的地方。

6. 用户态处理中断

对于像 USB 和 WLAN 这些需要利用中断通知操作系统发生了特定事件的设备,还需要把之前虚假实现的 interrupt 模块实现对。

首先要求内核提供让特定用户态进程处理特定 IRQ 的能力,具体来说就是驱动进程要能够通过系统调用注册一个函数作为特定编号的 IRQ 的用户态处理函数,然后内核在收到 IRQ 后调用此函数来处理。

接着重新实现 interrupt 模块,把 CInterruptSystem::ConnectIRQ 改为使用上述注册 IRQ 处理函数的系统调用,暂时用不到的函数可以不实现,比如与 FIQ 相关的。

7. 驱动 USB

Timer、DMA buffer、中断这几个重要的部分移植完成后,比较容易就可以驱动 USB 控制器,进而可以检测并驱动 USB 键盘、鼠标、存储、串口转换器等设备,对于树莓派 3,还可以驱动有线网卡(LAN7800)。

8. 其它驱动

到目前为止已经移植了大部分驱动所需的运行环境,之后的移植工作主要看具体的需求了,比如如果需要网络协议栈,还要重新实现 sched 模块,里面包括线程抽象、调度、线程同步机制等。

移植树莓派驱动框架 Circle 到自制操作系统

ARM GIC 虚拟化学习笔记

创建于
分类:Note
标签:ARMGIC虚拟化LinuxKVMOS

这是一篇学习过程中的笔记,因为时间原因不再组织成流畅的语言,而是直接分享了~

References

GICv2

Non-virtualization

  • 中断进入 distributor,然后分发到 CPU interface
  • 某个 CPU 触发中断后,读 GICC_IAR 拿到中断信息,处理完后写 GICC_EOIR 和 GICC_DIR(如果 GICC_CTLR.EOImodeNS 是 0,则 EOI 的同时也会 DI)
  • GICD、GICC 寄存器都是 MMIO 的,device tree 中会给出物理地址

Virtualization

  • HCR_EL2.IMO 设置为 1 后,所有 IRQ 都会 trap 到 HYP
  • HYP 判断该 IRQ 是否需要插入到 vCPU
  • 插入 vIRQ 之后,在切换到 VM 之前需要 EOI 物理 IRQ,即 priority drop,降低运行优先级,使之后 VM 运行时能够再次触发该中断
  • 回到 VM 后,GIC 在 EL1 触发 vIRQ,这时候 EOI 和 DI 会把 vIRQ 和物理 IRQ 都 deactivate,因此不需要再 trap 到 HYP,不过如果是 SGI 的话并不会 deactivate,需要 HYP 自己处理(通过 maintenance 中断?)

HYP interface (GICH)

  • GICH base 物理地址在 device tree 中给出
  • 控制寄存器:GICH_HCR、GICH_VMCR 等
  • List 寄存器:GICH_LRn
  • KVM 中,这些寄存器保存在 struct vgic_cpuvgic_v2 字段,struct vgic_cpu 本身放在 struct kvm_vcpu_arch,每个 vCPU 一份
  • vCPU switch 的时候,需要切换这些寄存器(KVM 在 vgic-v2-switch.S 中定义相关切换函数)
  • VM 无法访问 GICH 寄存器,因为根本没有映射

List register (LR)

vCPU interface (GICV, GICC in VM's view)

  • GICV 也是物理 GIC 上存在的,base 物理地址同样在 device tree 中给出
  • KVM 在系统全局的一个结构体(struct vgic_params vgic_v2_params)保存了这个物理地址
  • 创建 VM 时 HYP 把一个特定的 GPA(KVM 中通过 ioctl 设置该地址)映射到 GICV base 物理地址,然后把这个 GPA 作为 GICC base 在 device tree 中传给 VM
  • VM 以为自己在访问 GICC,实际上它在访问 GICV
  • 目前理解这些 GICV 寄存器在 vCPU switch 的时候是不需要保存的(KVM 里没有保存 GICV 相关的代码),因为它其实在硬件里访问的是 GICH 配置的那些寄存器,比如 LR

Virtual distributor (GICD in VM's view)

  • 实际是内核里的一个结构体(struct vgic_dist
  • 在 device tree 中给 VM 一个 GICD base,但实际上没有映射
  • VM 访问 GICD 时,trap & emulate,直接返回或设置 struct vgic_dist 里的字段(在 vgic-v2-emul.c 文件中)
  • 每个 VM 一个,而不是每个 vCPU 一个,所以 struct vgic_dist 放在 struct kvm_arch

VM's view

  • 从 device tree 获得 GICD、GICC base 物理地址(实际是 HYP 伪造的地址)
  • 配置 GICD 寄存器(实际上 trap 到 HYP,模拟地读写了内核某 struct 里的数据)
  • 执行直到发生中断(中断先到 HYP,HYP 在 LR 中配置了一个物理 IRQ 到 vIRQ 的映射,并且设置为 pending,回到 VM 之后 GIC 在 VM 的 EL1 触发中断)
  • 读 GICC_IAR(经过 stage 2 页表翻译,实际上读了 GICV_IAR,GIC 根据 LR 返回 vIRQ 的信息,vIRQ 状态从 pending 转为 active)
  • 写 GICC_EOIR、GICC_DIR(经过 stage 2 页表翻译,实际上写了 GICV_EOIR、GICV_DIR,GIC EOI 并 deactivate 对应的 vIRQ,并 deactivate vIRQ 对应的物理 IRQ)

GICv3

新特性:

  • CPU interface(GICC、GICH、GICV)通过 system register 访问(ICC_*_ELnICH_*_EL2ICV_*_ELn,ICC 和 ICV 在指令中的编码相同,硬件根据当前 EL 和 HCR_EL2 来路由),不再用 MMIO
  • 使用 affinity routing,支持最多 2^32 个 CPU 核心
  • 引入 redistributor,每个 CPU 一个,和各 CPU interface 连接,使 PPI 不再需要进入 distributor
  • 引入一种新的中断类型 LPI 和一个新的组件 ITS(还没太看懂是干啥用的)

Non-virtualization

Virtualization

ARM GIC 虚拟化学习笔记

理解 std::declval

创建于
分类:Dev
标签:C++类型模板标准库

这是一篇攒了很久的文章……相关参考链接一直放在收藏夹,今天终于决定写一下……

使用场景

在写 C++ 的时候,往往需要使用 decltype 获得一个类的成员函数的返回类型,像下面这样:

struct A {
    int foo();
};

int main() {
    decltype(A().foo()) foo = 1; // OK
}

由于 decltype 是不会真正执行它括号里的表达式的,所以 A 类的默认构造函数不会被执行,A() 这个临时对象不会被创建。

但有时候,一个类可能没有默认构造函数,这时就无法使用上面的方法,例如:

struct A {
    A() = delete;
    int foo();
};

int main() {
    decltype(A().foo()) foo = 1; // 无法通过编译
}

于是 std::declval 就派上了用场:

#include <utility>

struct A {
    A() = delete;
    int foo();
};

int main() {
    decltype(std::declval<A>().foo()) foo = 1; // OK
}

原理

于是自然想看它是如何实现的,通过阅读 cppreference 和搜索,发现它其实就只是一个模板函数的声明:

template<class T>
typename std::add_rvalue_reference<T>::type declval() noexcept;

因为前面说的 decltype 不会真正执行括号里的表达式,所以 std::declval 函数实际上不会执行,而只是用来推导类型,于是这个函数不需要定义,只需要声明。

为什么返回引用?

接着我有了一个疑惑,为什么 std::declval 要返回 std::add_rvalue_reference<T>::type 而不是直接返回 T 呢?在上面的例子中,如果将 declval 实现成下面这样也是能够工作的:

template<class T>
T declval() noexcept; // 某些场景下可以工作,但其实是有问题的

然后去搜了下,Stack Overflow 上有人跟我有同样的疑惑,看了答案恍然大悟,像 int[10] 这种数组类型是不能直接按值返回的,直接宣判了返回 T 方案的死刑,在更复杂的情况下只会更糟糕,因此还是需要返回一个引用。

为什么使用 std::add_rvalue_reference 而不是 std::add_lvalue_reference

网上还有人问了为什么要用 std::add_rvalue_reference 而不是 std::add_lvalue_reference。这个问题是比较显然的,添加右值引用可以进行引用折叠,最终 TT &&T &&T & 还是 T &,不会改变类型的性质,但如果添加左值引用,T 就会变 T &,性质直接变了,比如声明为 int foo() & 的成员函数,本来不能访问现在可以访问,显然是错误的。

参考资料

理解 std::declval

编译一个 AArch64 平台的最小 Linux 内核

创建于
分类:Dev
标签:LinuxOS内核BusyBoxQEMUARMAArch64

总结一下最近折腾的事情,方便以后查阅。

所有内容都假设已经安装了必须的构建工具链,如果没有装,可以在报错的时候再根据提示安装。

编译 BusyBox

需要先编译一个 BusyBox 作准备,之后作为 rootfs 加载。

这里 下载适当版本的 BusyBox 源码并解压,然后运行:

cd busybox-1.32.0
mkdir build

make O=build ARCH=arm64 defconfig
make O=build ARCH=arm64 menuconfig

这会首先生成默认配置,然后开启一个配置菜单。在「Settings」里面修改下面几项配置:

[*] Don't use /usr
[*] Build static binary (no shared libs)
(aarch64-linux-gnu-) Cross compiler prefix

然后保存并退出。运行:

make O=build # -j8
make O=build install
cd build/_install

这会使用刚刚保存的配置进行编译,然后安装到 build/_install 目录,此时该目录如下:

$ tree -L 1 .
.
├── bin
├── linuxrc -> bin/busybox
└── sbin

2 directories, 1 file

接着创建一些空目录:

mkdir -pv {etc,proc,sys,usr/{bin,sbin}}

然后创建一个 init 文件,内容如下:

#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys

echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"

exec /bin/sh

修改 init 文件为可执行:

chmod +x init

此时当前目录(build/_install)内容如下:

$ tree -L 1 .
.
├── bin
├── etc
├── init
├── linuxrc -> bin/busybox
├── proc
├── sbin
├── sys
└── usr

6 directories, 2 files

把这些目录和文件打包:

find . -print0 | cpio --null -ov --format=newc | gzip > ../initramfs.cpio.gz

生成的 gzip 压缩后的 cpio 映像放在了 build/initramfs.cpio.gz,此时 BusyBox ramdisk 就做好了,保存备用。

编译最小配置的 Linux 内核

这里 下载适当版本的内核源码并解压,然后运行:

cd linux-5.8.8
mkdir build

make O=build ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- allnoconfig
make O=build ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- menuconfig

这会首先初始化一个最小的配置(allnoconfig),然后打开配置菜单。在配置菜单中做以下修改:

-> General setup
[*] Initial RAM filesystem and RAM disk (initramfs/initrd) support

-> General setup
  -> Configure standard kernel features
[*] Enable support for printk

-> Executable file formats / Emulations
[*] Kernel support for ELF binaries
[*] Kernel support for scripts starting with #!

-> Device Drivers
  -> Generic Driver Options
[*] Maintain a devtmpfs filesystem to mount at /dev
[*]   Automount devtmpfs at /dev, after the kernel mounted the rootfs

-> Device Drivers
  -> Character devices
[*] Enable TTY

-> Device Drivers
  -> Character devices
    -> Serial drivers
[*] ARM AMBA PL010 serial port support
[*]   Support for console on AMBA serial port
[*] ARM AMBA PL011 serial port support
[*]   Support for console on AMBA serial port

-> File systems
  -> Pseudo filesystems
[*] /proc file system support
[*] sysfs file system support

完成后保存并退出,再运行:

make O=build ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- # -j8

即可编译 Linux 内核,编译出来的两个东西比较有用,一个是 build/vmlinux,另一个是 build/arch/arm64/boot/Image,前者是 ELF 格式的内核,可以用来在 GDB 中加载调试信息,后者是可启动的内核映像文件。

编译 qemu-system-aarch64

这一步是可选的,直接使用包管理器安装 QEMU 也可以。

这里 下载适当版本的 QEMU 源码并解压,然后运行:

cd qemu-5.0.0

mkdir build
cd build

../configure --target-list=aarch64-softmmu
make # -j8

即可编译 AArch64 目标架构的 QEMU。

启动 Linux

为了清晰起见,回到上面三个源码目录的外层,即当前目录中内容如下:

$ tree -L 1 .
.
├── busybox-1.32.0
├── linux-5.8.8
└── qemu-5.0.0

3 directories, 0 files

然后使用 QEMU 启动刚刚编译的 Linux:

./qemu-5.0.0/build/aarch64-softmmu/qemu-system-aarch64 \
    -machine virt -cpu cortex-a53 -smp 1 -m 2G \
    -kernel ./linux-5.8.8/build/arch/arm64/boot/Image \
    -append "console=ttyAMA0" \
    -initrd ./busybox-1.32.0/build/initramfs.cpio.gz \
    -nographic

这里使用了 QEMU 的 virt 平台。

参考资料

编译一个 AArch64 平台的最小 Linux 内核

在 main 函数运行之前修改全局变量

创建于
分类:Dev
标签:C++奇技淫巧

动机

写 C++ 时往往有这样一种需求,即在 main 函数运行之前就对全局变量进行修改,比如向全局的容器对象中填充内容。考虑下面这个需求:作为一个第三方库,我希望向用户提供一种事件机制,在发生某些事件时,触发用户编写的某些代码,但我不希望用户自己编写 main 函数并在里面设置事件回调,而是提供一个更强制的接口。在这个需求下,设计接口如下:

// 用户代码 app.cpp

#include <somelib.hpp>

ON_EVENT(handler1) {
    // 事件处理程序 1
}

ON_EVENT(handler2) {
    // 事件处理程序 2
}

此时,用户的两个处理程序,就需要在静态区初始化时被注册到我所提供的第三方库的某个全局容器对象中。

有问题的实现

最开始通过下面的代码来实现上述机制:

// somelib.hpp

extern std::vector<std::function<void()>> event_callbacks;

#define ON_EVENT_A(Name)                                \
    static void __handler_##Name();                     \
    static bool __dummy_val_##Name = [] {               \
        event_callbacks.emplace_back(__handler_##Name); \
        return true;                                    \
    }();                                                \
    static void __handler_##Name()
// somelib.cpp

std::vector<std::function<void()>> event_callbacks;

这种实现乍看起来没有什么问题,并且在很多情况下真的可以正常工作。然而,这种表面上的正常是建立在 app.cpp 中的全局变量 __dummy_val_##Name 迟于 somelib.cpp 中的 event_callbacks 初始化的情况下的,也就是说,一旦 __dummy_val_##Nameevent_callbacks 之前初始化,那么调用 event_callbacks.emplace_back 的那个 lambda 表达式将会访问一个无意义的 event_callbacks

那么这种错误情况到底有没有可能出现呢,花费好几个小时调试程序的事实已经告诉我有可能。后来查阅资料发现,在同一个翻译单元,全局变量的初始化顺序是按定义顺序来的,但在不同的翻译单元之间,初始化顺序是未定义的。上面的实现中,两个变量正是处于不同的翻译单元。

正确的实现

同一篇资料里也提出了解决这个问题的方法,那就是利用函数内静态变量在第一次使用时初始化的特性。只需要一点点修改即可得到正确的实现:

// somelib.hpp

inline auto &event_callbacks() {
    static std::vector<std::function<void()>> _event_callbacks;
    return _event_callbacks;
}

#define ON_EVENT_A(Name)                                  \
    static void __handler_##Name();                       \
    static bool __dummy_val_##Name = [] {                 \
        event_callbacks().emplace_back(__handler_##Name); \
        return true;                                      \
    }();                                                  \
    static void __handler_##Name()

同时不再需要 somelib.cpp 了。

这里 event_callbacks() 函数调用保证了当它返回引用时,_event_callbacks 静态变量必然已经被初始化,因此 emplace_back 必然有效。

为什么可以是 inline

这是另一个话题了。上面的 event_callbacks 被标记为 inline 函数,这意味着它将在可能的情况下被原地展开。那么这会不会导致不同的用户 cpp 文件中拿到的 _event_callbacks 引用不一样呢?答案是不会,因为 inline 有一个特性就是,编译器会自动将多次导入的 inline 函数合并为一个,因此其中的静态变量也只有一个。

参考资料

在 main 函数运行之前修改全局变量