raspberry-pi-os 项目记录了从头实现一个适用于树莓派3B(处理器 ARMv8 架构)的操作系统的过程。这篇文章记录了我按照项目的 lesson01 学习的过程,另外再加上自己的修改。
内容参考自 https://github.com/s-matyukevich/raspberry-pi-os
这个简易系统运行起来将只会做一件事情: 支持通过串口通信。项目结构基本和 raspberry-pi-os lesson01相同,我额外增加了继电器控制部分。
继电器连接 |
UART连接 |
|
|
make
make 工具依据 Makefile 定义的规则执行编译工作,Makefile 的格式如下:
targets : prerequisites
recipe
…
- targets: 编译的产出文件名,使用空格分隔。target 文件会在 make 执行下面的 recipes 之后生成。
- prerequisites: 依赖的文件, 使用空格分隔, 当 make 检测到某个 target 声明的 prerequisties 文件有改动时, 就将会忽略之前的缓存, 重新编译该目标
- recipe: 执行的shell命令或脚本, 每一行在单独的 shell 进程中执行. 比如: 如果你在上句命令设置了临时的环境变量, 执行下一句命令时上一句的临时环境变量就不存在了
- targets 和 prerequisties 支持使用通配符(
%
)。当使用通配符的时候,对于每个匹配了的 prerequisties,都会单独执行 receipes。在 receipe 中也可以使用 $<
和 $@
去引用 prerequiste 和 target。
该项目的 Makefile:
1
2
|
ARMGNU ?= aarch64-linux-gnu
# ARMGNU 交叉编译的前缀, 这里编译的目标平台是 arm64 架构的 x86 机器, 所以使用 aarch64-linux-gnu-gcc 作为编译器
|
1
2
3
4
5
6
7
|
COPS = -Wall -nostdlib -nostartfiles -ffreestanding -Iinclude -mgeneral-regs-only # 传递给C语言编译器的选项
# -Wall 显示所有警告
# -nostdlib 不使用C标准库,因为许多C标准库的调用实际都会与操作系统做交互。我们这里自己实现一个简易的操作系统,因此没有任何已有的操作系统调用供标准库使用。
# -nostartfiles 不使用标准的 startup 文件,Startup 文件的作用是设置一个栈指针,初始化静态数据,跳到主要的入口。这里我们将自己实现这些工作。
# -ffreestanding 告诉编译器不要去假设标准函数有通常的实现
# -Iinclude 在 include 目录里搜索头文件
# -mgeneral-regs-only 只使用通用寄存器
|
1
2
3
|
ASMOPS = -Iinclude # 传递给汇编编译器的选项
BUILD_DIR = build # 编译之后文件的存储位置
SRC_DIR = src # 源代码所在的目录
|
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
|
all : kernel8.img # 默认的构建目标,依赖 kernel8.img 构建目标
clean : # clean 目标的动作是删除所有编译产物
rm -rf $(BUILD_DIR) *.img
# 编译 SRC_DIR 目录下所有 .c 文件到 BUILD_DIR
# $< 和 $@ 是占位符,$< 指依赖的文件, $@ 指输出的文件
# -MMD 参数让编译器为每一个 object 文件创建一个依赖文件, 依赖文件包含所有编译目标源码时的依赖文件
$(BUILD_DIR)/%_c.o: $(SRC_DIR)/%.c
mkdir -p $(@D)
$(ARMGNU)-gcc $(COPS) -MMD -c $< -o $@
# 编译 SRC_DIR 目录下所有 .S 文件到 BUILD_DIR
$(BUILD_DIR)/%_s.o: $(SRC_DIR)/%.S
$(ARMGNU)-gcc $(ASMOPS) -MMD -c $< -o $@
# OBJ_FILES 数组将包含所有 c 源码和汇编源码编译之后的 object 文件
C_FILES = $(wildcard $(SRC_DIR)/*.c)
ASM_FILES = $(wildcard $(SRC_DIR)/*.S)
OBJ_FILES = $(C_FILES:$(SRC_DIR)/%.c=$(BUILD_DIR)/%_c.o)
OBJ_FILES += $(ASM_FILES:$(SRC_DIR)/%.S=$(BUILD_DIR)/%_s.o)
# 因为 make 因为依赖的文件的修改触发重新编译, 如果只是依赖关系改变了则不能触发重新编译
# 因此这里将 -MMD 生成的依赖文件也 include 进编译链,间接的让 make 能跟踪到依赖间的改变
DEP_FILES = $(OBJ_FILES:%.o=%.d)
-include $(DEP_FILES)
# 编译目标 kernel8.img 依赖 linker.ld 和 OBJ_FILES 文件
kernel8.img: $(SRC_DIR)/linker.ld $(OBJ_FILES)
# 将 OBJ_FILES 数组链接为 kernel8.elf 文件, 使用 linker.ld 作为链接器的链接规则
$(ARMGNU)-ld -T $(SRC_DIR)/linker.ld -o $(BUILD_DIR)/kernel8.elf $(OBJ_FILES)
# elf 文件面向的是操作系统去执行,因此这里需要将其转换为系统镜像文件,才能作为系统镜像去加载
# 文件名末尾的8,是树莓派硬件的约定,8表示该镜像文件用于 64 位架构的 ARMv8 处理器,kernel8.img 告诉硬件启动处理器到64位模式
$(ARMGNU)-objcopy $(BUILD_DIR)/kernel8.elf -O binary kernel8.img
|
linker 脚本
linker 脚本的目的是:定义如何将目标文件存放到 .elf 文件中的规则。linker script的详解.
SECTIONS
{
.text.boot : { *(.text.boot) }
.text : { *(.text) }
.rodata : { *(.rodata) }
.data : { *(.data) }
. = ALIGN(0x8);
bss_begin = .;
.bss : { *(.bss*) }
bss_end = .;
}
启动后,Raspberry Pi 将 kernel8.img 加载到内存中,并从文件开头开始执行。这就是必须首先使用.text.boot部分的原因。操作系统启动代码将放入这个 section 中。 .text, .rodata, .data 分别包含: 内核代码编译之后的指令, 只读数据, 普通数据。 .bss section 包含应初始化为0的数据,也就是内存中剩余的空间。将镜像加载到内存后,必须将 .bss 部分的内存空间初始化为0, 所以使用bss_begin和bss_end符号来记录开始和结束地址,并保证以 8 的倍数对齐起始地址(ALIGN(0x8))。
启动 kernel
src/lesson01/src/boot.S 汇编代码文件包含了内核的启动代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
#include "mm.h"
.section ".text.boot" // 表示该汇编代码中定义的内容都应该存放到 .text.boot section 中
// 设备启动之后, 每个处理器核心都会从 _start label 开始执行
.globl _start
_start:
mrs x0, mpidr_el1 // 从 mpidr_el1 寄存器获取当前运行的处理器ID,然后存到 x0 寄存器
and x0, x0,#0xFF // 将获得的处理器ID 与 0xFF 做与运算,从而得到低8位的值,然后存到 x0 寄存器
cbz x0, master // 因为树莓派有4个处理器核心, 但是现在的这个系统只希望在单处理器核心下运行, 所以将只让0号处理器执行master
b proc_hang // 其他处理器执行简单的无限循环
# proc_hang 将调用自己, 也就意味着无限循环
proc_hang:
b proc_hang
master:
adr x0, bss_begin
adr x1, bss_end
sub x1, x1, x0 // x1 减 x0 的结果存到 x1, 即得到需要初始化的内存空间大小
bl memzero // 调用 memzero 将 x0 到 x0+x1 的内存赋值0
mov sp, #LOW_MEMORY // LOW_MEMORY的值为 4MB, 意思是将内存中4MB的地址拷贝到表示运行栈的 sp 寄存器中.
bl kernel_main // 调用 kernel_main 方法
|
汇编命令
- mrs: 移动 PSR 寄存器的值到通用寄存器
- and: 与操作
- cbz: 如果是0,则跳到后面的 label 执行
- b: 跳到 label 执行
- adr: 在目标寄存器中为存储映射中定义的标签生成相对于寄存器的地址
- sub: 做减法
- bl: 执行跳转到 label 对应的链接
- move: 拷贝值到寄存器
kernel_main
方法
我改变了项目里的 kernel_main 实现,增加了控制继电器开关的通断来体现系统的运行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
#include "switch.h"
#include "utils.h"
#include "mini_uart.h"
void kernel_main(void)
{
switch_init();
uart_init();
uart_send_string("Hello, world!\r\n");
while (1) {
uart_send(uart_recv());
switch_on();
delay(99999);
switch_off();
delay(99999);
}
}
|
以上就是这个系统内核所做的所有工作,系统启动之后开始向串口发送一个字符串数据,然后一直循环接收串口的输入并将输入返回给串口,同时控制一个继电器的通断。
树莓派硬件
为了控制外部设备,还需要了解下树莓派的外设在底层是如何让工作的。
树莓派 3B、B+ 使用的主板是 BCM2837 ARM 主板
。
BCM2837 是一种简单的 SOC (System on a chip) 主板。在这种主板上访问外部设备都是通过内存映射寄存器实现。
ARM 上内存的物理地址从 0x00000000 开始。物理内存地址从0x3F000000
到0x3FFFFFFF
为外设保留。外设的总线地址设置为映射到从0x7E000000
开始的外设总线地址范围。因此,假设一个外设在总线上的地址是0x7Ennnnnn
,那么外设在物理内存上的地址将是0x3Fnnnnnn
。
一个设备寄存器就是一个32位的内存区域。每个设备寄存器中每一位的含义都在 BCM2837 ARM 主板外设文档中有描述。
为了向一个 GPIO 针脚外设写入高低电压,会涉及 BCM 主板外设部分的两个概念。
Alternate function
Alternate function 可以翻译为备用功能。每个GPIO pin(引脚)都可以承载多个功能。一共有6种备用功能可用,但并非每个引脚都具有那么多备用功能。如果只是将 pin 作为输入输出引脚,则用不到这些备用功能。
外设寄存器
GPIO pin 和其他主板上的外设一样,也被设备寄存器来表示。GPIO pin 涉及的寄存器有多种。比如 GPFSELn 寄存器用来配置一个 pin 的功能。
通俗点说就是,要使用一个 pin,需要先拿到它被映射到了内存的哪里,然后在向它对应的内存区域写入不同的位来使用不同的功能。
BCM2837 主板一共有6个 GPFSLEn (GPFSEL0~GPFSEL5) 寄存器。每个寄存器占用 32 位内存空间,这 32 位内存空间每 3 位用来表示一个pin, 32 位就能够表示 10 个 pin。
比如 GPFSEL0 寄存器能用来表示 0~9 号 GPIO pin。
比如设置 GPFSEL0 的第 29-27 位(表示pin 19), 3位的不同组合表示的含义:
继电器 GPIO 配置
那么现在我要使用3号引脚作为一个输出引脚,该做那些操作?
- 获取 pin3 所属的 GPFSEL 寄存器
- 设置 pin3 为 output
- 间隔输出高低电压
gnu(接地) 和 vcc(供电)引脚主板启动之后自己设置好的,所以不用额外设置。
根据文档可知 GPFSELn 寄存器在总线上的地址从 0x7E200000
开始, 那么对应到物理内存上就是 0x3F200000
, 所以定义 GPFSELn
宏为:
1
2
3
4
5
6
7
8
|
#define PBASE 0x3F00000
#define GPFSEL0 (PBASE+0x00200000)
#define GPFSEL1 (PBASE+0x00200004)
#define GPFSEL2 (PBASE+0x00200008)
#define GPFSEL3 (PBASE+0x0020000C)
#define GPFSEL4 (PBASE+0x00200010)
#define GPFSEL5 (PBASE+0x00200014)
|
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
|
#include "utils.h"
#include "peripherals/gpio.h"
void switch_init(){
unsigned int selector;
// 按文档可知 pin3 属于 GPFSEL0 寄存器
selector = get32(GPFSEL0);
// pin3 的控制位是11~9位
selector &= ~(7<<9); // xxx -> 000 清空为0
selector |= (1<<9); // xxx -> 001 作为 output
put32(GPFSEL0, selector);
}
void switch_on(){
// pin3 clear
unsigned int output = get32(GPCLR0);
output |= (1<<3);
put32(GPCLR0, output);
}
void switch_off() {
// pin3 set
unsigned int output = get32(GPSET0);
output |= (1<<3);
put32(GPSET0, output);
}
|
UART 配置
UART串口通信
UART 串口通信属于外设辅助,BCM 主板支持三种 Aux 通信, mini UART 和 2个 SPI master. 要使用这些外设辅助功能,也是通过修改寄存器的值,寄存器在总线上的位置和寄存器对应的功能如下:
比如要让主板支持 Aux 通信。需要先修改 AUX_ENABLES 寄存器的值为 1.
根据文档可知 AUX_ENABLES 寄存器在总线上的地址为 0x7E215004
, 那么对应到物理内存上就是 0x3F2154004
, 所以定义 AUX_ENABLES
宏为:
1
2
|
#define PBASE 0x3F00000
#define AUX_ENABLES (PBASE+0x00215004)
|
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
|
void uart_init ()
{
unsigned int selector;
selector = get32(GPFSEL1);
selector &= ~(7<<12); // 复位 gpio14
selector |= 2<<12; // gpio14 设置 alt5
selector &= ~(7<<15); // 复位 gpio15
selector |= 2<<15; // gpio 15 设置 alt5
put32(GPFSEL1,selector);
put32(GPPUD,0); // 向总线发出一个 GPIO PULL DOWN 控制信号
delay(150); // 等待 150 个 CPU 周期
put32(GPPUDCLK0,(1<<14)|(1<<15)); // 让PULL DOWN 信号写入 14和15号 pin
delay(150); // 等待 150 个 CPU 周期
put32(GPPUDCLK0,0); // 移除拦截
put32(AUX_ENABLES,1); //打开 mini uart (this also enables access to it registers)
put32(AUX_MU_CNTL_REG,0); //关闭自动控制和接收和转发
put32(AUX_MU_IER_REG,0); //关闭关闭和转发拦截
put32(AUX_MU_LCR_REG,3); //设置数据格式为8位模式 <- 3 表示二进制的 11: the UART works in 8-bit mode
put32(AUX_MU_MCR_REG,0); //设置电路的状态总是为高电位
put32(AUX_MU_BAUD_REG,270); //设置调制速率为 115200
put32(AUX_MU_CNTL_REG,3); //最后, 开启发送和接收
}
|
上面每行代码的详细解释以及数据发送和接收的实现可以到initializing-the-mini-uart查看。
启动树莓派
树莓派的启动流程:
- 设备通电
- GPU 启动并读取
config.txt
配置文件
kernel8.img
被加载到内存并执行
为了能够运行我这个简易的系统,config.txt
文件应该变成下面这样:
kernel_old=1
disable_commandline_tags=1
kernel_old=1
指定 kernel 镜像应该加载到内存地址0
disable_commandline_tags
告诉 GPU 不传递任何参数
测试 kernel
-
打包系统镜像
使用 smatyukevich/raspberry-pi-os-builder 镜像进行编译行为, 该镜像已经配置了 GNU 的交叉编译环境
docker run --rm -v $(pwd):/app -w /app smatyukevich/raspberry-pi-os-builder make $1
-
将镜像拷到 SD 卡(Mac os 下)
将编译出的系统镜像写入 sd 卡,然后弹出
cp kernel8.img /Volumes/boot
hdiutil eject /Volumes/boot
-
将 SD 卡装到树莓派上
-
将继电器连接到树莓派
-
启动树莓派
使用4个处理器核心
在上文,我们只使用了1个处理器核心,另外3个都在执行无意义的死循环。
要让程序支持多个处理器核心运行,需要注意以下几个方面:
- 每个处理器核心的寄存器们是相互独立的
- 需要为每个处理器核心分配他们各自的内存区域,否则处理器之间如果交叉读写了彼此的内存,会导致意外的问题
- 某些只能执行一次的操作,需要做额外处理。防止多个处理器核心都执行了。
1
2
3
4
5
6
7
8
9
10
11
|
.globl _start
_start:
b master //每个核心通电之后都会执行 master
master:
bl get_core_id //获取核心编号
cbz x0, init_memory //编号为0的核心执行内存初始化工作
bl get_core_id //再次获取核心编号
bl init_stack //设置栈空间
bl get_core_id
bl kernel_main //执行kernal_mail
|
获取处理器核心
从 mpidr_el1
寄存器中可以获取当前正在运行的处理器核心的编号
1
2
3
4
5
|
.global get_core_id
get_core_id:
mrs x0, mpidr_el1
and x0, x0, #0xFF
ret
|
内存初始化
多核心运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
void kernel_main(unsigned int core_id) {
if(core_id == 0) {
// uart 外设只需要初始化一次, 所以只让核心0执行
uart_init();
} else {
// 其他核心等待一段时间
delay(300000 * core_id);
}
uart_send_string("Hello Word From #");
uart_send(core_id + '0');
uart_send_string(" Processor Core.\r\n");
if(core_id == 0) while (1) {
// 只让核心0执行读取行为
uart_send(uart_recv());
} else while(1) {};
}
|
结果
Hello From RPI #0 Processor Core.
Hello From RPI #1 Processor Core.
Hello From RPI #2 Processor Core.
Hello From RPI #3 Processor Core.