裸机程序hello world
一. 简介
1. 项目介绍
本项目fork自raspberry-pi-os
该库包含一个分步指南,教你如何从零开始创建一个简单的操作系统(OS)内核。源代码在很大程度上基于 Linux 内核,但该操作系统的功能非常有限,而且仅支持 Raspberry PI 3。
本篇博客会紧跟源项目作者的步伐,通过编写一个小型的裸机 “Hello, World”应用程序开始操作系统开发之旅。
最后还会记录一些扩展练习的解决思路。
- 引入波特率常数,确保程序可以使用 115200 以外的波特率。
- 更改操作系统代码,使用 UART 设备而不是 Mini UART。
- 让每个处理器都打印”hello from <CPU ID>“
- 在qemu上运行kernel8.img
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 | 译文: |
二. 参考资料
三. kernel8.img
本节按照自顶向下的逻辑,从目的出发,通过问答方式来捋清除kernel8.img的生成与加载流程。
3.1 制作内核镜像
疑问1:我们暂时不谈内核代码要怎么完成,不妨先考虑一下,kernel8.img中都存着什么东西?
回答:个人认为,里面放着的是一条条的由0/1编码的指令,树莓派启动后,会把这个镜像加载到内存中,然后按照顺序执行指令。
证据:我们通过objdump查看一下kernel8.elf文件中_start标签的指令:
1 | zrf@debian:build$ aarch64-linux-gnu-objdump -d kernel8.elf |
可以发现字节序是小端字节序,指令为d53800a0
,92401c00
,b4000060
,14000001
。
不妨在使用二进制文本器打开kernel8.img,看看开头都记录了什么:
可以看出,d53800a0
小端字节序在内存中就应该是ao0038d5
,刚好是kernel8.img中的前四个字节。
结论:所以我们需要把最重要的指令放在镜像的开头!
疑问2:我们应该如何把kernel8.elf文件打包成kernel8.img文件呢?
回答:要编写裸机程序,通过objcopy在ELF文件中提取所有可执行程序部分和数据部分,并将它们放入kernel8.img映像中
1 | zrf@debian:build$ ls |
疑问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 | SECTIONS |
首先把最终的与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 | mrs x0, mpidr_el1 |
疑问2:CPU0接下来要作什么呢?是不是可以直接接收串口信息了呢?
回答:CPU0还需要负责对.bss段进行初始化,还需要设置栈指针呢!
3.4 清空.bss段
疑问1:如何清空.bss段呢?memset可以吗?
回答:自然不行,我们无法调用libc库呢!需要使用arm指令来编写一个类似memset
的函数,这里称之为memzero
疑问2:既然类似memset,那么总需要知道.bss的起始段大小吧?
回答:是的,在通过linker.ld链接elf文件时,我们就把起始地址和终止地址保存在bss_begin
和bss_end
中了,相减就得到了段大小!
疑问3:memzero怎么实现呢?
1 | .globl memzero |
回答(以下解释由chatgpt生成):
1 | 这段代码是用ARM汇编语言实现的一个简单的内存清零函数`memzero`。这个函数接收两个参数:一个指向内存区域的指针(`x0`)和内存区域的大小(以字节为单位,存储在`x1`中)。下面是每条指令的详细解释: |
3.5 设置栈帧并执行kernel_main
树莓派会在地址0处加载内核,而栈帧会向下增长,为了防止栈使用的内存增长到一定程度时覆盖内核代码,栈帧要设置的足够高。
1 | mov sp, #LOW_MEMORY |
疑问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 | Physical addresses range from 0x3F000000 to 0x3FFFFFFF for peripherals. The |
结论:我们可以把0x3F000000
作为外设的基地址。
疑问4:还是有点抽象,能否据一个详细一点的例子?
回答:我们可以举一个访问GPIO中的GPFSEL1
寄存器的例子。
通过芯片外设手册,我们可以了解到GPFSEL1
的bus address为0x7E20 0004
由于有内存映射的存在,我们可以通过访问0X3F20 0004
来访问这个寄存器。
在疑问3中,我们已经把0x3F000000
作为外设的基地址了:
1 |
我们可以使用宏定义GPFSEL1来访问这个寄存器了!
1 | unsigned int selector; |
疑问5:我们是不是可以直接访问串口外设了呢?
回答:并不是,串口通信需要使用第14和第15引脚,所以我们在配置串口之前,得激活这两个引脚。
4.2 激活GPIO引脚
通过BCM2837手册102页,可以看到,一个引脚是可以被很多外设使用的,每个引脚都可以通过配置alternative function
来决定当前环境下,这个引脚最终被用来做什么事情。
这里可以看到,引脚 14 和 15 具有 TXD1 和 RXD1 替代功能。这意味着,如果我们为 14 号和 15 号引脚选择 5 号替代功能,它们将分别用作 Mini UART 发送数据引脚和 Mini UART 接收数据引脚。
疑问1:明白了,我得配置这两个引脚的alternative function,配置为5,可是去哪里配置呢?
回答:GPFSEL1
寄存器用来配置10-19号引脚的alternative function。
结论:我们只需要配置这个寄存器的12-17位(分别是14引脚和15引脚),每个引脚有三位可以配置。
疑问2:现在总可以去配置串口了吧?
回答:no no no。GPIO存在pull up/down状态,但我们即不需要UP,也不需要down,我们要让他运作在输入模式下。引脚状态之间的切换并不是一个非常简单的过程,因为它需要在电路上实际拨动一个开关。这一过程涉及 GPPUD 和 GPPUDCLK 寄存器,BCM2837 ARM 外围设备手册第 101 页对此进行了描述。我将描述复制到这里:
1 | The GPIO Pull-up/down Clock Registers control the actuation of internal pull-downs on |
译文:
1 | GPIO上拉/下拉时钟寄存器控制相应GPIO引脚上内部下拉电阻的激活。这些寄存器必须与GPPUD寄存器配合使用,以实现GPIO上拉/下拉的更改。需要遵循以下步骤: |
疑问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 | ARMGNU ?= aarch64-linux-gnu |
-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 测试结果
六. 扩展练习
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 | 地址 | 使用情况 |
这里我们使用delay函数来等待CPU0把UART作初始化后,再去向串口进行输出。
看看最终的效果吧!
6.4 qemu
参考链接:
https://github.com/zzzzrf/raspberry-pi-os/tree/master/exercises/lesson01/4/zrf
qemu命令行为:
1 | zrf@debian:zrf$ qemu-system-aarch64 -M raspi3b -cpu cortex-a53 -kernel kernel8.img -nographic -serial null -serial stdio -monitor none |
参数解释:
1 | qemu-system-aarch64: |