raspberry-pi-os 项目记录了从头实现一个适用于树莓派3B(处理器 ARMv8 架构)的操作系统的过程。这篇文章记录了我按照项目的 lesson01 学习的过程,另外再加上自己的修改。
这个简易系统运行起来将只会做一件事情: 支持通过串口通信。项目结构基本和 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:
- 一些参数的定义
|
|
|
|
|
|
- 构建目标的定义
|
|
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 汇编代码文件包含了内核的启动代码:
#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 实现,增加了控制继电器开关的通断来体现系统的运行。
|
|
以上就是这个系统内核所做的所有工作,系统启动之后开始向串口发送一个字符串数据,然后一直循环接收串口的输入并将输入返回给串口,同时控制一个继电器的通断。
树莓派硬件
为了控制外部设备,还需要了解下树莓派的外设在底层是如何让工作的。
树莓派 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
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
宏为:
|
|
|
|
UART 配置
UART 串口通信属于外设辅助,BCM 主板支持三种 Aux 通信, mini UART 和 2个 SPI master. 要使用这些外设辅助功能,也是通过修改寄存器的值,寄存器在总线上的位置和寄存器对应的功能如下:
比如要让主板支持 Aux 通信。需要先修改 AUX_ENABLES 寄存器的值为 1.
根据文档可知 AUX_ENABLES 寄存器在总线上的地址为 0x7E215004
, 那么对应到物理内存上就是 0x3F2154004
, 所以定义 AUX_ENABLES
宏为:
|
|
|
|
上面每行代码的详细解释以及数据发送和接收的实现可以到initializing-the-mini-uart查看。
启动树莓派
树莓派的启动流程:
- 设备通电
- GPU 启动并读取
config.txt
配置文件 kernel8.img
被加载到内存并执行
为了能够运行我这个简易的系统,config.txt
文件应该变成下面这样:
kernel_old=1
disable_commandline_tags=1
kernel_old=1
指定 kernel 镜像应该加载到内存地址0disable_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个都在执行无意义的死循环。
要让程序支持多个处理器核心运行,需要注意以下几个方面:
- 每个处理器核心的寄存器们是相互独立的
- 需要为每个处理器核心分配他们各自的内存区域,否则处理器之间如果交叉读写了彼此的内存,会导致意外的问题
- 某些只能执行一次的操作,需要做额外处理。防止多个处理器核心都执行了。
.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
寄存器中可以获取当前正在运行的处理器核心的编号
.global get_core_id
get_core_id:
mrs x0, mpidr_el1
and x0, x0, #0xFF
ret
内存初始化
-
将 bss_begin 和 bss_end 范围的内存赋值0
.global init_memory init_memory: adr x0, bss_begin adr x1, bss_end sub x1, x1, x0 b memzero ret
-
为每个处理器核心分配 1KB 的栈空间
.global init_stack init_stack: mov x1, #STACK_OFFSET //x1=STACK_OFFSET, STACK_OFFSET值为1KB mul x1, x1,x0 //x1=x1*x0, x0是调用方传递过来的处理器核心编号(0~3) add x1, x1,#LOW_MEMORY //x1=x1+LOW_MEMORY, LOW_MEMORY值为4MB mov sp, x1 //将当前处理器核心的sp(栈指针)寄存器移动到x1位置 ret
多核心运行
|
|
结果
Hello From RPI #0 Processor Core.
Hello From RPI #1 Processor Core.
Hello From RPI #2 Processor Core.
Hello From RPI #3 Processor Core.