一. 简介

1. 项目介绍

本项目fork自raspberry-pi-os

该库包含一个分步指南,教你如何从零开始创建一个简单的操作系统(OS)内核。源代码在很大程度上基于 Linux 内核,但该操作系统的功能非常有限,而且仅支持 Raspberry PI 3。

本篇博客会紧跟源项目作者的步伐,通过编写一个小型的裸机 “Hello, World”应用程序开始操作系统开发之旅。

最后还会记录一些扩展练习的解决思路。

2. 目的

以下内容摘自作者对创建这个项目动机的介绍:

After I realized this, an idea came to me: if the Linux kernel is too vast and too complicated to be used as a starting point for learning OS development, why don’t I implement my own OS that will be explicitly designed for learning purposes? In this way, I can make the OS simple enough to provide a good learning experience. Also, if this OS will be implemented mostly by copying and simplifying different parts of the Linux kernel source, it would be straightforward to use it as a starting point to learn the Linux kernel as well.

1
2
3
译文:

意识到这一点后,我萌生了一个想法:既然 Linux 内核过于庞大和复杂,不能作为学习操作系统开发的起点,那我为什么不自己开发一个明确为学习目的而设计的操作系统呢?这样,我就能让操作系统足够简单,从而提供良好的学习体验。而且,如果这个操作系统主要是通过复制和简化 Linux 内核源代码的不同部分来实现的,那么把它作为学习 Linux 内核的起点也会很简单。

二. 参考资料

raspberry-pi-os

三. kernel8.img

本节按照自顶向下的逻辑,从目的出发,通过问答方式来捋清除kernel8.img的生成与加载流程。

3.1 制作内核镜像

疑问1:我们暂时不谈内核代码要怎么完成,不妨先考虑一下,kernel8.img中都存着什么东西?

回答:个人认为,里面放着的是一条条的由0/1编码的指令,树莓派启动后,会把这个镜像加载到内存中,然后按照顺序执行指令。

证据:我们通过objdump查看一下kernel8.elf文件中_start标签的指令:

1
2
3
4
5
6
7
8
9
10
11
12
zrf@debian:build$ aarch64-linux-gnu-objdump -d kernel8.elf 

kernel8.elf: 文件格式 elf64-littleaarch64


Disassembly of section .text.boot:

0000000000000000 <_start>:
0: d53800a0 mrs x0, mpidr_el1
4: 92401c00 and x0, x0, #0xff
8: b4000060 cbz x0, 14 <master>
c: 14000001 b 10 <proc_hang>

可以发现字节序是小端字节序,指令为d53800a0,92401c00,b4000060,14000001

不妨在使用二进制文本器打开kernel8.img,看看开头都记录了什么:

kernel8.img

可以看出,d53800a0小端字节序在内存中就应该是ao0038d5,刚好是kernel8.img中的前四个字节。

结论:所以我们需要把最重要的指令放在镜像的开头!

疑问2:我们应该如何把kernel8.elf文件打包成kernel8.img文件呢?

回答:要编写裸机程序,通过objcopy在ELF文件中提取所有可执行程序部分和数据部分,并将它们放入kernel8.img映像中

1
2
3
zrf@debian:build$ ls
boot_s.d boot_s.o kernel8.elf kernel_c.d kernel_c.o mini_uart_c.d mini_uart_c.o mm_s.d mm_s.o utils_s.d utils_s.o
zrf@debian:build$ aarch64-linux-gnu-objcopy kernel8.elf -O binary kernel8.img

疑问3:kernel8.elf怎么生成呢?

回答:elf文件当然是从一堆.o文件中通过ld命令,链接为一个elf文件的。

1
$(ARMGNU)-ld -o kernel8.elf  $(OBJ_FILES)

疑问4: elf中的段布局有什么讲究吗?

回答:有的,根据疑问1得出的结论,我们要把重要的指令放在最前面。

结论:所以我们需要通过linker.ld文件来自定义镜像的段布局:

1
$(ARMGNU)-ld -T $(SRC_DIR)/linker.ld -o kernel8.elf  $(OBJ_FILES)

3.2 elf中的段布局

详细文档请参考这里

疑问1:EFL文件中都有哪些常用的段呢?

回答:.text存放代码指令。.data存放已初始化的全局变量和局部静态变量。.bss存放未初始化的全局变量和局部静态变量,默认值都为0。

疑问2:我们要针对那些段进行段落布局呢?

回答:不知道,从结果来说,我们似乎只需要把.text.boot,.text,.rodata,.bss这四个段进行布局就行。如果你有更好的答案,请分享出来。

疑问3:解释一下布局脚本linker.ld内容的意思吧

1
2
3
4
5
6
7
8
9
10
11
SECTIONS
{
.text.boot : { *(.text.boot) }
.text : { *(.text) }
.rodata : { *(.rodata) }
.data : { *(.data) }
. = ALIGN(0x8);
bss_begin = .;
.bss : { *(.bss*) }
bss_end = .;
}

首先把最终的与boot启动相关的代码放在最前面。接着存放程序的指令,最后是一些静态变量或者全局变量。

疑问4:因为bss_begin和bss_end是为了做什么?

回答:我们需要手动把.bss段内的数据置0,所以使用这两个符号来标记一下起始与终止位置。

3.3 选择CPU0

好了,到目前为止,如果我们把kernel8.img加载到树莓派内存中,他将会从.text.boot中执行指令了!

树莓派3B有四个核心,我们打算让主核心打印”hello world!”,然后处理串口发来的消息。其他三个核心死循环即可。

疑问1:我应该怎么确定当前是CPU0在执行当前指令呢?

回答:我们需要从mpidr_el1系统寄存器的低8位中获取处理器ID。

1
2
mrs    x0, mpidr_el1        
and x0, x0,#0xFF // Check processor id

疑问2:CPU0接下来要作什么呢?是不是可以直接接收串口信息了呢?

回答:CPU0还需要负责对.bss段进行初始化,还需要设置栈指针呢!

3.4 清空.bss段

疑问1:如何清空.bss段呢?memset可以吗?

回答:自然不行,我们无法调用libc库呢!需要使用arm指令来编写一个类似memset的函数,这里称之为memzero

疑问2:既然类似memset,那么总需要知道.bss的起始段大小吧?

回答:是的,在通过linker.ld链接elf文件时,我们就把起始地址和终止地址保存在bss_beginbss_end中了,相减就得到了段大小!

疑问3:memzero怎么实现呢?

1
2
3
4
5
6
.globl memzero
memzero:
str xzr, [x0], #8
subs x1, x1, #8
b.gt memzero
ret

回答(以下解释由chatgpt生成):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这段代码是用ARM汇编语言实现的一个简单的内存清零函数`memzero`。这个函数接收两个参数:一个指向内存区域的指针(`x0`)和内存区域的大小(以字节为单位,存储在`x1`中)。下面是每条指令的详细解释:

1. `.globl memzero`:声明`memzero`函数为全局函数,使其可以被其他文件调用。

2. `memzero:`:函数的开始标签。

3. `str xzr, [x0], #8`:将零寄存器(`xzr`,始终为零)存储到`x0`指向的内存位置,并将指针`x0`加8个字节。

4. `subs x1, x1, #8`:将`x1`中的值减去8。

5. `b.gt memzero`:如果`x1`的值大于零,则跳转回`memzero`标签,继续清零内存。

6. `ret`:返回,从函数中退出。

整个过程会循环执行,将指定大小的内存区域(`x1`字节)全部设置为零。

3.5 设置栈帧并执行kernel_main

树莓派会在地址0处加载内核,而栈帧会向下增长,为了防止栈使用的内存增长到一定程度时覆盖内核代码,栈帧要设置的足够高。

1
2
mov		sp, #LOW_MEMORY
bl kernel_main

疑问1:这个LOW_MEMORY是多大呢?

回答:ARM 架构通常使用 4KB 页和 2MB 段,x86 架构可能有不同的配置。我们这里使用页(4KB)和段(2MB)来管理内存,LOW_MEMORY为2 * SECTION_SIZE = 4MB。足够保证堆栈的增长不会覆盖内核指令。

四. ARM Peripherals

到目前为止,理论上,我们已经可以把kernel8.img在树莓派中运行起来了。但是要想要使用串口通信,就必须访问芯片为我们提供的一些外设了!

所以本节将介绍一下如何配置mini UART串口。

4.1 访问GPIO

疑问1:树莓派3B使用了什么芯片呢?怎么查找相关资料呢?

回答:树莓派3B使用了博通制造的BCM2837,这里是《BCM2837 ARM 外围设备手册》下载链接

疑问2:我们应该怎么访问这些硬件资源呢?

回答:BCM2837芯片包含多个内存映射设备(memory-mapped devices)。在计算机系统中,内存映射设备是指设备的寄存器通过内存地址进行访问,而不是通过传统的I/O端口。这种方式允许CPU通过普通的内存读写指令来访问设备寄存器。

疑问3:那么这些外设的基址什么呢?

回答:BCM2837手册中提到:

1
2
3
4
Physical addresses range from 0x3F000000 to 0x3FFFFFFF for peripherals. The
bus addresses for peripherals are set up to map onto the peripheral bus address range
starting at 0x7E000000. Thus a peripheral advertised here at bus address 0x7Ennnnnn is
available at physical address 0x3Fnnnnnn

结论:我们可以把0x3F000000作为外设的基地址。

疑问4:还是有点抽象,能否据一个详细一点的例子?

回答:我们可以举一个访问GPIO中的GPFSEL1寄存器的例子。

通过芯片外设手册,我们可以了解到GPFSEL1的bus address为0x7E20 0004

GPFSEL1

由于有内存映射的存在,我们可以通过访问0X3F20 0004来访问这个寄存器。

在疑问3中,我们已经把0x3F000000作为外设的基地址了:

1
2
#define PBASE 0x3F000000
#define GPFSEL1 (PBASE+0x00200004)

我们可以使用宏定义GPFSEL1来访问这个寄存器了!

1
2
unsigned int selector;
selector = get32(GPFSEL1);

疑问5:我们是不是可以直接访问串口外设了呢?

回答:并不是,串口通信需要使用第14和第15引脚,所以我们在配置串口之前,得激活这两个引脚。

GPIO PINS

4.2 激活GPIO引脚

通过BCM2837手册102页,可以看到,一个引脚是可以被很多外设使用的,每个引脚都可以通过配置alternative function来决定当前环境下,这个引脚最终被用来做什么事情。

alternative function

这里可以看到,引脚 14 和 15 具有 TXD1 和 RXD1 替代功能。这意味着,如果我们为 14 号和 15 号引脚选择 5 号替代功能,它们将分别用作 Mini UART 发送数据引脚和 Mini UART 接收数据引脚。

疑问1:明白了,我得配置这两个引脚的alternative function,配置为5,可是去哪里配置呢?

回答:GPFSEL1寄存器用来配置10-19号引脚的alternative function。

GPFSEL1

结论:我们只需要配置这个寄存器的12-17位(分别是14引脚和15引脚),每个引脚有三位可以配置。

疑问2:现在总可以去配置串口了吧?

回答:no no no。GPIO存在pull up/down状态,但我们即不需要UP,也不需要down,我们要让他运作在输入模式下。引脚状态之间的切换并不是一个非常简单的过程,因为它需要在电路上实际拨动一个开关。这一过程涉及 GPPUD 和 GPPUDCLK 寄存器,BCM2837 ARM 外围设备手册第 101 页对此进行了描述。我将描述复制到这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
The GPIO Pull-up/down Clock Registers control the actuation of internal pull-downs on
the respective GPIO pins. These registers must be used in conjunction with the GPPUD
register to effect GPIO Pull-up/down changes. The following sequence of events is
required:
1. Write to GPPUD to set the required control signal (i.e. Pull-up or Pull-Down or neither
to remove the current Pull-up/down)
2. Wait 150 cycles – this provides the required set-up time for the control signal
3. Write to GPPUDCLK0/1 to clock the control signal into the GPIO pads you wish to
modify – NOTE only the pads which receive a clock will be modified, all others will
retain their previous state.
4. Wait 150 cycles – this provides the required hold time for the control signal
5. Write to GPPUD to remove the control signal
6. Write to GPPUDCLK0/1 to remove the clock

译文:

1
2
3
4
5
6
7
8
GPIO上拉/下拉时钟寄存器控制相应GPIO引脚上内部下拉电阻的激活。这些寄存器必须与GPPUD寄存器配合使用,以实现GPIO上拉/下拉的更改。需要遵循以下步骤:

1. 写入GPPUD寄存器以设置所需的控制信号(即上拉或下拉或两者都不使用以移除当前的上拉/下拉)。
2. 等待150个周期——这提供了控制信号的必要设置时间。
3. 写入GPPUDCLK0/1寄存器以将控制信号时钟进入你希望修改的GPIO引脚——注意,只有接收到时钟的引脚才会被修改,其他所有引脚将保持其之前的状态。
4. 等待150个周期——这提供了控制信号的必要保持时间。
5. 写入GPPUD寄存器以移除控制信号。
6. 写入GPPUDCLK0/1寄存器以移除时钟。

疑问3:看起来好复杂,能再讲的简单点吗?

回答:其实就是向GPPUD写入一个状态,再等150周期,再向GPPUDCLK0/1写入要配置的引脚,再等150周期,最后再向GPPUD和GPPUDCLK0/1写入。总之就是读写寄存器和时延的结合。具体的代码可以去mini_uart.c中看看。

4.3 配置mini UART

终于,我们可以配置UART了。

通用异步收发传输器(Universal Asynchronous Receiver/Transmitter,通常称为UART)是一种异步收发传输器,是电脑硬件的一部分,将数据通过串列通信进行传输。UART通常用在与其他通信接口(如EIA RS-232)的连接上。

首先,我们得向AUX_ENABLES寄存器中写入1,用来启动mini UART以及相关的寄存器。
接下来通过向AUX_MU_CNTL_REG写入0,来禁用一些我们目前没办法实现的特性。
由于还没学到中断,我们向AUX_MU_IER_REG写入0来禁用中断。
AUX_MU_LCR_REG写入3,用来表示我们使用8位模式。
AUX_MU_MCR_REG写入0,RTS 线路用于流量控制,我们不需要它。将其一直设置为高电平。
AUX_MU_BAUD_REG写入270,配置波特率为115200。
AUX_MU_CNTL_REG写入3,开启接受和传输。

至此,我们就可以在电脑上使用串口软件,来接受和发送消息了!

疑问:mini UART只支持115200的波特率吗?

回答:不是的,波特率有一个计算公式

1
baudrate = system_clock_freq / (8 * ( baudrate_reg + 1 )) 

系统时钟频率是250 Mhz,你可以更具想要配置的波特率,算出baudrate_reg后配置到AUX_MU_BAUD_REG寄存器就可以了。

五. 编译并测试

5.1 裸机编译参数

这里有必要介绍一下编译参数

我们分析一下这个Makefile中编译参数COPS:

1
2
3
4
5
6
7
8
9
10
11
ARMGNU ?= aarch64-linux-gnu

COPS = -Wall -nostdlib -nostartfiles -ffreestanding -Iinclude -mgeneral-regs-only

DEP_FILES = $(OBJ_FILES:%.o=%.d)
-include $(DEP_FILES)

kernel8.img: $(SRC_DIR)/linker.ld $(OBJ_FILES)
$(ARMGNU)-ld -T $(SRC_DIR)/linker.ld -o $(BUILD_DIR)/kernel8.elf $(OBJ_FILES)
$(ARMGNU)-objcopy $(BUILD_DIR)/kernel8.elf -O binary kernel8.img

  • -Wall
    含义: 启用所有常见的警告(Wall是”Warn all”的缩写)。
    作用: 这有助于捕捉可能的错误和潜在的问题,提高代码的质量和可靠性。编译器将生成有关代码中潜在问题的警告消息。

  • -nostdlib
    含义: 不链接标准库(standard library)。
    作用: 在裸机(bare-metal)编程或操作系统开发中,经常需要自己编写启动代码和库函数,因此不会使用标准C库。此选项告诉编译器不要自动链接标准库。

  • -nostartfiles
    含义: 不使用标准启动文件(standard startup files)。
    作用: 通常情况下,编译器会自动包含一些启动文件,如C库的初始化代码。在操作系统开发或裸机编程中,开发者通常会编写自己的启动代码,所以此选项会禁用默认的启动文件。

  • -ffreestanding
    含义: 指示编译器生成独立的代码(freestanding code)。
    作用: 通常用于裸机编程环境,告诉编译器目标环境没有标准库的支持。编译器不会假设存在标准库或操作系统,并且不会使用任何库函数。

  • -Iinclude
    含义: 指定头文件搜索路径(include path)。
    作用: include 目录是头文件所在的目录。此选项告诉编译器在该目录中查找头文件。对于例如 #include “myheader.h” 的头文件包含指令,编译器将首先在 include 目录中查找。

  • -mgeneral-regs-only
    含义: 限制编译器只使用通用寄存器(general-purpose registers)。
    作用: 这个选项通常用于嵌入式编程,尤其是ARM架构,以确保编译器只使用通用寄存器,避免使用特殊寄存器或浮点寄存器,这可能在某些裸机环境中不可用。

使用场景

这些编译参数常用于操作系统开发、裸机编程或其他低级别编程场景。在这些场景中,开发者通常需要完全控制生成的代码,并且不能依赖标准库或默认的启动文件。通过这些参数,开发者可以确保编译器生成的代码是完全自主的,可以在没有操作系统支持的情况下运行。

5.2 测试结果

reault

六. 扩展练习

6.1 配置波特率

参考链接:

https://github.com/zzzzrf/raspberry-pi-os/tree/master/exercises/lesson01/1/zrf

mini UART波特率计算公式如下:

1
baudrate = system_clock_freq / (8 * ( baudrate_reg + 1 )) 

时钟频率为250Mhz,我们可以依据想要配置的波特率,计算baudrate_reg,并写入AUX_MU_BAUD_REG寄存器即可。

6.2 UART

这得好好看看芯片手册吧,这个练习不太感兴趣,以后有空再说吧。

6.3 CPU

参考链接
https://github.com/zzzzrf/raspberry-pi-os/tree/master/exercises/lesson01/3/zrf

尝试使用全部 4 个处理器内核。操作系统应为所有内核打印 “Hello, from processor <processor index>!”。不要忘记为每个内核设置单独的堆栈,并确保 Mini UART 只初始化一次。您可以结合使用全局变量和延迟函数来实现同步。

在为每个CPU配置栈帧的时候,我有些疑惑,或许学了后面的内存管理相关的章节就会明白了。

参考了一下其他答案,我们的内存可能看起来是这个样子

已知条件:
LOW_MEMORY = 4MB
SECTION_SIZE = 2MB

对于每个CPU,栈指针的计算公式如下:
sp=SECTION_SIZE×CPU_ID+LOW_MEMORYsp=SECTION_SIZE×CPU_ID+LOW_MEMORY

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
地址       | 使用情况
-----------|--------------------------------------
10MB |-------------------------------------| <-- CPU 3 栈顶 (sp 初始值)
| Stack 3 |
| ... |
| ... |
8MB |-------------------------------------| <-- CPU 2 栈顶 (sp 初始值)
| Stack 2 |
| ... |
| ... |
6MB |-------------------------------------| <-- CPU 1 栈顶 (sp 初始值)
| Stack 1 |
| ... |
| ... |
4MB |-------------------------------------| <-- CPU 0 栈顶 (sp 初始值)
| Stack 0 |
| ... |
| ... |
3MB |-------------------------------------|
| .bss 段 |
2MB |-------------------------------------|
| .text 段 |
|-------------------------------------|
| .text.boot 段 |
0MB |-------------------------------------| <-- Kernel 起始地址

这里我们使用delay函数来等待CPU0把UART作初始化后,再去向串口进行输出。

看看最终的效果吧!

reault

6.4 qemu

参考链接:

https://github.com/zzzzrf/raspberry-pi-os/tree/master/exercises/lesson01/4/zrf

qemu命令行为:

1
2
zrf@debian:zrf$ qemu-system-aarch64 -M raspi3b -cpu cortex-a53 -kernel kernel8.img -nographic -serial null -serial stdio -monitor none
Hello, world!

参数解释:

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
qemu-system-aarch64:

启动QEMU模拟器,目标架构是AArch64(ARM64)。

-M raspi3b:

指定要模拟的机器类型为 Raspberry Pi 3B。

-cpu cortex-a53:

指定虚拟机使用 Cortex-A53 CPU。

-kernel kernel8.img:

指定要启动的内核映像文件为 kernel8.img。

-nographic:

禁用图形输出,将所有I/O重定向到当前的shell。这意味着你不会看到QEMU的图形窗口,而是直接在终端中与模拟器交互。

-serial null:

将第一个串口(UART0)输出重定向到null,即丢弃所有输出。这是为了避免第一个串口的输出干扰其他输出。

-serial stdio:

将第二个串口(UART1)输入输出重定向到标准输入输出(当前的shell)。这意味着你可以在当前的shell中看到串口输出,并且可以通过键盘输入与其交互。

-monitor none:

禁用QEMU监视器。QEMU监视器是一个命令行界面,用于控制QEMU虚拟机的各种操作。禁用它可以防止与标准输入输出的冲突。