Raspberry Pi Bare Bones
难度等级 |
---|
高级 |
内核设计 |
---|
模型 |
其它概念 |
这是关于 Raspberry Pi 上的操作系统开发的教程。 本文章将作为如何创建最小系统的示例,而不是如何正确构建项目的示例。
有一个类似的教程 Raspberry Pi Bare Bones Rust 用Rust代替C。
准备
你即将开始开发新的操作系统。 也许有一天,你的新操作系统可以在其自身下开发。 这是一个被称为引导或自我托管的过程。 然而,这是未来的道路。 今天,我们只需要设置一个系统,该系统可以从现有的操作系统中编译你的操作系统。 这是一个被称为交叉编译的过程,这是操作系统开发的第一步。
本文假设你正在使用类似Unix的操作系统,例如Linux,它很好地支持操作系统开发。 Windows用户应该能够在MinGW或Cygwin环境中完成它。
构建交叉编译器
你应该做的第一件事是为 arm-none-eabi 设置一个 GCC Cross-Compiler。 你还没有修改你的编译器来了解你的操作系统的存在,所以我们使用一个名为arm-none-eabi的通用目标,它为你提供了一个针对 system V ABI 的工具链。 你将 “无法” 在没有交叉编译器的情况下正确编译你的操作系统。
如果你想要一个64位内核,你应该设置目标为 aarch64-elf 。 这提供了相同的系统V ABI接口,但仅适用于64位。 使用elf不是强制性的,但是可以简化一些工作。
概述
到现在为止,你应该已经为适当的ABI设置了 cross-compiler (如上所述)。本教程提供了创建操作系统的最小解决方案。 它不作为项目结构的推荐框架,而是作为一个最小内核的例子。 在这种简单的情况下,我们只需要三个输入文件:
- boot.S - 设置处理器环境的内核入口点
- kernel.c - 你实际的内核例程
- linker.ld - 用于链接上述文件
启动操作系统
现在,我们将创建一个名为boot.S的文件,并讨论其内容。 在这个例子中,我们使用的是GNU汇编程序,它是你之前构建的交叉编译器工具链的一部分。 这个汇编器与GNU工具链的其余部分集成得非常好。
每个Pi型号都需要不同的设置。 通常,你必须区分AArch32和AArch64模式,因为它们的启动方式不同。 后者只能从Pi 3及以上访问。 在一种模式下,你可以在运行时 detect the board,并相应地设置mmio基址。
Pi型号 A,B,A+,B和Zero
环境的设置和执行从 armstub.s 转移到 _start。
// AArch32 mode
// 将其保留在二进制文件的第一部分中。
.section ".text.boot"
// Make _start global.
.globl _start
.org 0x8000
// 内核的入口点。
// r15 -> 应该从0x8000开始执行。
// r0 -> 0x00000000
// r1 -> 0x00000C42 - machine id
// r2 -> 0x00000100 - start of ATAGS
// 保留这些寄存器作为kernel_main的参数
_start:
// 设置堆栈。
mov sp, #0x8000
// 清除bss。
ldr r4, =__bss_start
ldr r9, =__bss_end
mov r5, #0
mov r6, #0
mov r7, #0
mov r8, #0
b 2f
1:
// 在r4存储多个
stmia r4!, {r5-r8}
// 如果我们仍然小于bss_end,则循环。
2:
cmp r4, r9
blo 1b
// 调用kernel_main
ldr r3, =kernel_main
blx r3
// 暂停
halt:
wfe
b halt
“.text.boot” 部分将在链接器脚本中使用,以将boot.S作为我们内核映像中的第一件事。 代码在调用kernel_main函数之前初始化一个最小C环境,这意味着有一个堆栈并将BSS段归零。 请注意,该代码避免使用r0-r2,因此对于kernel_main调用保持有效。
然后,你可以使用以下方法组装boot.S:
arm-none-eabi-gcc -mcpu=arm1176jzf-s -fpic -ffreestanding -c boot.S -o boot.o
Pi 2
有了新版本的Pi,还有更多的事情要做。 Raspberry Pis 2和3 (第一个支持64位的型号) 有4个核心。 在引导时,所有内核都在运行并执行相同的引导代码。 因此,你必须区分核心,并且只允许其中一个运行,将其他核心置于无限循环中。
环境的设置和执行从 armstub7.S # L167 armstub7.s 转移到 _start。
// AArch32 mode
// 将其保留在二进制文件的第一部分中。
.section ".text.boot"
// Make _start global.
.globl _start
.org 0x8000
// 内核的入口点。
// r15 -> 应该从0x8000开始执行。
// r0 -> 0x00000000
// r1 -> 0x00000C42 - 机器标识
// r2 -> 0x00000100-ATAGS的启动
// 保留这些寄存器作为kernel_main的参数
_start:
// 关闭额外的内核
mrc p15, 0, r5, c0, c0, 5
and r5, r5, #3
cmp r5, #0
bne halt
// 设置堆栈。
ldr r5, =_start
mov sp, r5
// Clear out bss.
ldr r4, =__bss_start
ldr r9, =__bss_end
mov r5, #0
mov r6, #0
mov r7, #0
mov r8, #0
b 2f
1:
// 在r4存储多个
stmia r4!, {r5-r8}
// 如果我们仍然低于bss_end,则循环。
2:
cmp r4, r9
blo 1b
// 调用kernel_main
ldr r3, =kernel_main
blx r3
// 暂停
halt:
wfe
b halt
你可以使用以下方法组装boot.S:
arm-none-eabi-gcc -mcpu=cortex-a7 -fpic -ffreestanding -c boot.S -o boot.o
Pi 3, 4
值得一提的是,Pi 3和4通常将kernel8.img引导到64位模式,但是你仍然可以将AArch32与kernel7.img一起使用,以实现向后兼容。 请注意,在64位模式下,引导代码以0x80000而不是0x8000加载。 对于Pi 3和4,AArch64中的引导代码完全相同,但是Pi 4具有不同的外围基址 (请参见下面的C示例代码)。
使用最新的固件,只有主核心运行 (核心0),辅助核心正在循环中等待。 要唤醒它们,请在0xE0 (核心1),0xE8 (核心2) 或0xF0 (核心3) 上写一个函数的地址,然后它们将开始执行该函数。
环境的设置和执行从 armstub8.S # L154 armstub8.s 转移到 _start。
// AArch64模式
// 将其保留在二进制文件的第一部分中。
.section ".text.boot"
// 使 _start全局。
.globl _start
.org 0x80000
// Entry point for the kernel. Registers:
// x0 -> 内存中DTB的32位指针 (仅主核)/0 (辅助核)
// x1 -> 0
// x2 -> 0
// x3 -> 0
// x4 -> 32位内核入口点,_start位置
_start:
// 在我们的代码之前设置堆栈
ldr x5, =_start
mov sp, x5
// 清除bss
ldr x5, =__bss_start
ldr w6, =__bss_size
3: cbz w6, 4f
str xzr, [x5], #8
sub w6, w6, #1
cbnz w6, 3b
// 跳转到C代码,不应该返回
4: bl kernel_main
// 对于故障保护,也请停止此核心
b 1b
使用以下方法编译代码:
aarch64-elf-as -c boot.S -o boot.o
实现内核
到目前为止,我们已经编写了bootstrap程序集存根,用于设置处理器,以便可以使用高级语言 (例如C)。 也可以使用C等其他语言。
独立和托管环境
如果你在用户空间中进行了C或C编程,则使用了所谓的托管环境。 托管意味着有一个C标准库和其他有用的运行时功能。另外,还有独立式版本,这就是我们在这里使用的版本。 独立式意味着没有C标准库,只有我们自己提供的。 但是,有些头文件实际上不是C标准库的一部分,而是编译器。 即使在独立的C源代码中,这些仍然可用。 在这种情况下,我们使用 <stddef.h> 来获取size_t & NULL,并使用 <stdint.h> 来获取对于操作系统开发非常宝贵的intx_t和uintx_t数据类型,你需要确保变量是一个精确的大小 (如果我们使用一个短而不是uint16_t和短的大小改变,我们的代码将被破坏!)。 此外,你还可以访问 <float.h>,<iso646.h>,<limits.h> 和 <stdarg.h> 标头,因为它们也是独立的。 GCC实际上还提供了一些头,但这些都是特殊用途。
用C编写内核
下面演示如何在C ++ 中创建一个简单的内核。 请花点时间理解代码。 要在运行时设置 “int raspi” 的值,请参见 检测板类型。
#include <stddef.h>
#include <stdint.h>
static uint32_t MMIO_BASE;
// MMIO区域基地址,取决于主板类型
static inline void mmio_init(int raspi)
{
switch (raspi) {
case 2:
case 3: MMIO_BASE = 0x3F000000; break; // for raspi2 & 3
case 4: MMIO_BASE = 0xFE000000; break; // for raspi4
default: MMIO_BASE = 0x20000000; break; // for raspi1, raspi zero etc.
}
}
// 内存映射的I/O输出
static inline void mmio_write(uint32_t reg, uint32_t data)
{
*(volatile uint32_t*)(MMIO_BASE + reg) = data;
}
// 内存映射I/O输入
static inline uint32_t mmio_read(uint32_t reg)
{
return *(volatile uint32_t*)(MMIO_BASE + reg);
}
// 以编译器不会优化的方式循环 <延迟> 时间
static inline void delay(int32_t count)
{
asm volatile("__delay_%=: subs %[count], %[count], #1; bne __delay_%=\n"
: "=r"(count): [count]"0"(count) : "cc");
}
enum
{
// reach寄存器的偏移量。
GPIO_BASE = 0x200000,
// 控制所有GPIO引脚上/下拉的致动。
GPPUD = (GPIO_BASE + 0x94),
// Controls actuation of pull up/down for specific GPIO pin.
GPPUDCLK0 = (gpio _ 基数0x98),
// UART的基址。
UART0_BASE = (GPIO_BASE + 0x1000), // for raspi4 0xFE201000, raspi2 & 3 0x3F201000, and 0x20201000 for raspi1
// UART的reach register的偏移量。
UART0_DR = (UART0_BASE + 0x00),
UART0_RSRECR = (UART0_BASE + 0x04),
UART0_FR = (UART0_BASE + 0x18),
UART0_ILPR = (UART0_BASE + 0x20),
UART0_IBRD = (UART0_BASE + 0x24),
UART0_FBRD = (UART0_BASE + 0x28),
UART0_LCRH = (UART0_BASE + 0x2C),
UART0_CR = (UART0_BASE + 0x30),
UART0_IFLS = (UART0_BASE + 0x34),
UART0_IMSC = (UART0_BASE + 0x38),
UART0_RIS = (UART0_BASE + 0x3C),
UART0_MIS = (UART0_BASE + 0x40),
UART0_ICR = (UART0_BASE + 0x44),
UART0_DMACR = (UART0_BASE + 0x48),
UART0_ITCR = (UART0_BASE + 0x80),
UART0_ITIP = (UART0_BASE + 0x84),
UART0_ITOP = (UART0_BASE + 0x88),
UART0_TDR = (UART0_BASE + 0x8C),
// Mailbox寄存器的偏移量
MBOX_BASE = 0xB880,
MBOX_READ = (MBOX_BASE + 0x00),
MBOX_STATUS = (MBOX_BASE + 0x18),
MBOX_WRITE = (MBOX_BASE + 0x20)
};
// 设置时钟速率为PL011至3mhz标签的Mailbox message
volatile unsigned int __attribute__((aligned(16))) mbox[9] = {
9*4, 0, 0x38002, 12, 8, 2, 3000000, 0 ,0
};
void uart_init(int raspi)
{
mmio_init(raspi);
// 禁用UART0。
mmio_write(UART0_CR, 0x00000000);
// 设置GPIO引脚14 & & 15。
// 禁用所有GPIO引脚的上拉/下拉,并延迟150周期。
mmio_write(GPPUD, 0x00000000);
delay(150);
// 禁用引脚14、15的上拉/下拉,并延迟150个周期。
mmio_write(GPPUDCLK0, (1 << 14) | (1 << 15));
delay(150);
// 将0写入GPPUDCLK0以使其生效。
mmio_write(GPPUDCLK0, 0x00000000);
// 清除挂起的中断。
mmio_write(UART0_ICR, 0x7FF);
// Set integer & fractional part of baud rate.
// Divider = UART_CLOCK/(16 * Baud)
// Fraction part register = (Fractional part * 64) + 0.5
// Baud = 115200.
// 对于Raspi3和4,默认情况下,UART_CLOCK依赖于系统时钟。
// 将其设置为3Mhz,以便我们可以一致地设置波特率
if (raspi >= 3) {
// UART_CLOCK = 30000000;
unsigned int r = (((unsigned int)(&mbox) & ~0xF) | 8);
// wait until we can talk to the VC
while ( mmio_read(MBOX_STATUS) & 0x80000000 ) { }
// 将我们的消息发送到属性通道并等待响应
mmio_write(MBOX_WRITE, r);
while ( (mmio_read(MBOX_STATUS) & 0x40000000) || mmio_read(MBOX_READ) != r ) { }
}
// 分频器 Divider = 3000000/(16*115200) = 1.627 = ~ 1。
mmio_write(UART0_IBRD, 1);
// 小数部分寄存器 = (.627*64) 0.5 = 40.6 = ~ 40。
mmio_write(UART0_FBRD, 40);
// 启用FIFO & 8位数据传输 (1个停止位,无奇偶校验)。
mmio_write(UART0_LCRH, (1 << 4) | (1 << 5) | (1 << 6));
// 屏蔽所有中断。
mmio_write(UART0_IMSC, (1 << 1) | (1 << 4) | (1 << 5) | (1 << 6) |
(1 << 7) | (1 << 8) | (1 << 9) | (1 << 10));
// 启用UART0,接收和传输UART的一部分。
mmio_write(UART0_CR, (1 << 0) | (1 << 8) | (1 << 9));
}
void uart_putc(unsigned char c)
{
// 等待UART准备好传输。
while ( mmio_read(UART0_FR) & (1 << 5) ) { }
mmio_write(UART0_DR, c);
}
unsigned char uart_getc()
{
// 等待UART收到东西。
while ( mmio_read(UART0_FR) & (1 << 4) ) { }
return mmio_read(UART0_DR);
}
void uart_puts(const char* str)
{
for (size_t i = 0; str[i] != '\0'; i ++)
uart_putc((unsigned char)str[i]);
}
#if defined(__cplusplus)
extern "C" /* Use C linkage for kernel_main. */
#endif
# ifdef AARCH64
// AArch64的参数
void kernel_main(uint64_t dtb_ptr32, uint64_t x1, uint64_t x2, uint64_t x3)
#else
// arguments for AArch32
void kernel_main(uint32_t r0, uint32_t r1, uint32_t atags)
#endif
{
// 为Raspi2初始化UART
uart_init(2);
uart_puts("Hello, kernel World!\r\n");
while (1)
uart_putc(uart_getc());
}
GPU引导加载程序通过r0-r2将参数传递给AArch32内核,引导确保保留这3个寄存器。 它们是C函数调用中的前3个参数。参数基本传染数包含RPi从其启动的设备的代码。 通常为0,但其实际值取决于主板的固件。 r1包含 “arm Linux机器类型”,其对于RPi是3138 (0xc42) 标识BCM2708 CPU。 可以从 这里 获得ARM机器类型的完整列表。 r2包含ATAGs的地址。
对于AArch64,寄存器有点不同,但也作为参数传递给C函数。 第一,x0是DTB的32位地址 (即内存中的 [1])。 小心,它是32位地址,高位可能无法清除。 其他参数,x1-x3现在被清除为零,但保留供将来使用。 你的boot.S应该保存它们。
请注意,我们希望如何使用通用的C函数strlen,但这个函数是C标准库的一部分,我们没有。 相反,我们依靠独立的标头 <stddef.h> 来提供size_t,我们只是声明自己的strlen实现。 你将必须为你希望使用的每个函数执行此操作 (因为独立标头仅提供宏和数据类型)。
GPIO和UART的地址是与外围基地址的偏移量,对于Raspberry Pi 1为0x20000000,对于Raspberry Pi 2和Raspberry Pi 3为0x3F000000。 对于 Raspberry Pi 4,基址是0xFE000000。 你可以在BCM2835手册中找到寄存器的地址以及如何使用它们。 可以通过 读取板id 在运行时检测基址。
编译使用:
arm-none-eabi-gcc -mcpu=arm1176jzf-s -fpic -ffreestanding -std=gnu99 -c kernel.c -o kernel.o -O2 -Wall -Wextra
或64位:
aarch64-elf-gcc -ffreestanding -c kernel.c -o kernel.o -O2 -Wall -Wextra
请注意,上面的代码使用了一些扩展,因此我们将其构建为c99的GNU版本。
链接内核
要创建完整的和最终的内核,我们必须将这些目标文件链接到最终的内核程序中。 在开发用户空间程序时,你的工具链附带了用于链接此类程序的默认脚本。 但是,这些不适合内核开发,我们需要提供自己的自定义链接器脚本。
64位模式的链接器脚本看起来完全一样,除了起始地址。
ENTRY(_start)
SECTIONS
{
/* 从LOADER_ADDR开始。*/
. = 0x8000;
/* 对于AArch64,使用。= 0x80000; */
__start = .;
__text_start = .;
.text :
{
KEEP(*(.text.boot))
*(.text)
}
. = ALIGN(4096); /* align to page size */
__text_end = .;
__rodata_start = .;
.rodata :
{
*(.rodata)
}
. = ALIGN(4096); /* align to page size */
__rodata_end = .;
__data_start = .;
.data :
{
*(.data)
}
. = ALIGN(4096); /* align to page size */
__data_end = .;
__bss_start = .;
.bss :
{
bss = .;
*(.bss)
}
. = ALIGN(4096); /* align to page size */
__bss_end = .;
__bss_size = __bss_end - __bss_start;
__end = .;
}
这里有很多文字,但不要绝望。 如果你一点一点地看,这个脚本是相当简单的。
ENTRY(_start) 声明内核映像的入口点。 该符号已在boot.S文件中声明。由于我们实际上是在启动二进制映像,因此该条目完全无关紧要,但是它必须存在于我们作为中间文件构建的elf文件中。
SECTIONS声明节。 它决定了我们的代码和数据的点点滴滴,并设置了一些符号来帮助我们跟踪每个部分的大小。
. = 0x8000;
__start = .;
“.” 表示当前地址,因此第一行告诉链接器将当前地址设置为0x8000 (或0x80000),即内核开始的位置。 当链接器添加数据时,当前地址会自动递增。 然后,第二行创建一个符号 “__start”,并将其设置为当前地址。
之后,为文本 (代码),只读数据,读写数据和BSS (0初始化内存) 定义了部分。 除了名称之外,这些部分是相同的,所以让我们看看其中一个:
__text_start = .;
.text : {
KEEP(*(.text.boot))
*(.text)
}
. = ALIGN(4096); /* align to page size */
__text_end = .;
第一行为该部分创建一个 __text_start符号。 第二行打开一个。输出文件的文本部分,在第五行中关闭。 第3行和第4行声明输入文件中的哪些部分将放置在输出中。文本部分。 在我们的案例中,“.text.boot” 将首先放置在更一般的 “.text” 之后。“.text.boot” 仅在boot.S中使用,并确保它最终位于内核映像的开头。“.text” 然后包含所有剩余的代码。 链接器添加的任何数据都会自动增加当前地址 (“.”)。 在第6行中,我们显式地增加它,使其与4096字节边界对齐 (这是RPi的页面大小)。 最后一行7创建一个 __text_end符号,以便我们知道该部分的结尾。
__ text_start和 __ text_end是什么?为什么使用页面对齐? 可以在内核源中使用2个符号,然后链接器将正确的地址放入二进制文件中。 例如,在boot.S中使用 __ bss_start和 __ bss_end。 但是你也可以通过先声明它们extern来使用来自C的符号。 虽然不是必需的,但我使所有部分都与页面大小对齐。 稍后,这允许将它们映射到具有可执行、只读和读写权限的页表中,而不必处理重叠 (一页中有2个部分)。
__end = .;
声明所有部分后,将创建 __end符号。 如果你想知道你的内核在运行时有多大,你可以使用 __start和 __end来找出答案。
有了这些组件,你现在可以实际构建最终内核。 我们使用编译器作为链接器,因为它允许它更好地控制链接过程。 请注意,如果你的内核是用C编写的,则应改用C编译器。
然后,你可以使用以下方式链接你的内核:
arm-none-eabi-gcc -T linker.ld -o myos.elf -ffreestanding -O2 -nostdlib boot.o kernel.o -lgcc
arm-none-eabi-objcopy myos.elf -O binary kernel7.img
或64位:
aarch64-elf-gcc -T linker.ld -o myos.elf -ffreestanding -O2 -nostdlib boot.o kernel.o -lgcc
aarch64-elf-objcopy myos.elf -O binary kernel8.img
启动内核
过一会儿,你会看到你的内核在运行。
测试你的操作系统 (真实硬件)
当你在上面测试硬件时,你是否仍然拥有带有原始Raspbian映像的sd卡? 太好了。 所以你已经有一个带有引导分区和所需文件的sd卡。 如果没有,请下载原始Raspberry引导映像之一并将其复制到sd卡。
现在从sd卡挂载第一个分区,看看它:
bootcode.bin fixup.dat kernel.img start.elf
cmdline.txt fixup_cd.dat kernel_cutdown.img start_cd.elf
config.txt issue.txt kernel_emergency.img
如果没有raspbian映像,则可以从官方存储库中创建一个FAT32分区,然后 下载固件文件。 你只需要三个文件:
- bootcode.bin: 这是首先加载的,在GPU上执行的 (RPi4不需要,因为该模型在ROM中具有bootcode.bin)
- fixup.dat: 此数据文件包含与硬件相关的重要信息,必须具有
- start.elf: 这是RPi固件 (与IBM PC上的BIOS相同)。这也在GPU上运行。
简化当RPi上机时,ARM CPU停止并且GPU运行。 GPU从ROM加载引导加载程序并执行 然后找到sd卡并加载bootcode.bin (RPi4除外,它有一个足够大的ROM来包含bootcode.bin)。 bootcode加载固件start.elf,它处理config.txt和cmdline.txt。 start.elf加载内核 *.img,最后ARM CPU开始运行该内核映像。
要在ARM模式之间切换,你必须重命名内核.img文件。 如果将其重命名为 kernel7.img,则将在AArch32模式 (ARMv7) 下执行。 对于AArch64模式 (ARMv8),你必须将其重命名为 kernel8.img 。
因此,现在我们用自己的umount,sync替换原始的kernel.img,将sd卡插入RPi并打开电源。 然后,你的微型计算机应该显示以下内容:
Hello, kernel World!
测试你的操作系统 (QEMU)
QEMU支持用机器类型 “raspi2” 模拟Raspberry Pi 2。 在撰写本文时,此功能在大多数软件包管理器中不可用,但可以在此处找到的最新QEMU来源中找到: https://github.com/qemu/qemu
检查你的QEMU安装是否有qemu-system-arm,并且它支持选项 “-M raspi2”。 在QEMU中进行测试时,请务必使用源代码中注明的raspi2基址。
使用QEMU,你无需将内核objcopy到纯二进制文件中; QEMU还支持ELF内核:
$YOURINSTALLLOCATION/bin/qemu-system-arm -m 256 -M raspi2 -serial stdio -kernel kernel.elf
更新了对AArch64 (raspi2,raspi3) 的支持
从QEMU 2.12 (2018年4月) 开始,针对64位ARM “qemu-system-aarch64” 的仿真现在支持使用机器类型 “raspi2” 和 “raspi3” 直接仿真Raspberry Pi 2和3。分别。 这应该允许测试64位系统代码。
qemu-system-aarch64 -M raspi3 -serial stdio -kernel kernel8.img
请注意,在大多数情况下,32位ARM代码和64位ARM代码之间几乎没有差异,但是代码的行为方式可能会有所不同,尤其是在内核内存管理方面。 此外,一些AArch64实现可以支持在它们的32位对应物中的任何一个上找不到的特征 (例如,加密扩展、增强的霓虹灯SIMD支持)。
另一个非常重要的注意事项: 从Raspberry Pi 3开始,SoC更改为 1888662/ BCM2837,并且PL011时钟 (UART0) 不再固定,而是从系统时钟派生。 因此,要正确设置波特率,首先必须设置时钟频率。 这可以通过 [2] 来完成。 或者你可以使用AUX miniUART (UART1) 芯片,这更容易编程。 下面的链接包括有关如何同时进行的教程。
= 另见 =
文章
外部链接
- BCM2835 ARM Peripherals (original Raspberry Pi)
- BCM2837 ARM Peripherals (latest Raspberry Pi 3)
- PrimeCell UART (PL011) Technical Reference Manual
- Raspberry-Pi-OS a hobby OS tutorial for the Raspberry Pi (details Linux drivers too, great source)
- Bare metal tutorial for AArch64