This page looks best with JavaScript enabled

树莓派OS-#0x00-自制系统镜像并控制处理器的运行

 ·  ☕ 9 min read

raspberry-pi-os 项目记录了从头实现一个适用于树莓派3B(处理器 ARMv8 架构)的操作系统的过程。这篇文章记录了我按照项目的 lesson01 学习的过程,另外再加上自己的修改。

本文概览

内容参考自 https://github.com/s-matyukevich/raspberry-pi-os

这个简易系统运行起来将只会做一件事情: 支持通过串口通信。项目结构基本和 raspberry-pi-os lesson01相同,我额外增加了继电器控制部分。

继电器连接 UART连接
接线 usb to ttl

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 汇编代码文件包含了内核的启动代码:

#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 开始。物理内存地址从0x3F0000000x3FFFFFFF为外设保留。外设的总线地址设置为映射到从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位的不同组合表示的含义:

GPFSEL0的不同位

继电器 GPIO 配置

那么现在我要使用3号引脚作为一个输出引脚,该做那些操作?

  1. 获取 pin3 所属的 GPFSEL 寄存器
  2. 设置 pin3 为 output
  3. 间隔输出高低电压

gnu(接地) 和 vcc(供电)引脚主板启动之后自己设置好的,所以不用额外设置。

GPIO寄存器

根据文档可知 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. 要使用这些外设辅助功能,也是通过修改寄存器的值,寄存器在总线上的位置和寄存器对应的功能如下:

Auxiliary

比如要让主板支持 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查看。

启动树莓派

树莓派的启动流程:

  1. 设备通电
  2. GPU 启动并读取 config.txt 配置文件
  3. kernel8.img 被加载到内存并执行

为了能够运行我这个简易的系统,config.txt 文件应该变成下面这样:

kernel_old=1
disable_commandline_tags=1
  • kernel_old=1 指定 kernel 镜像应该加载到内存地址0
  • disable_commandline_tags 告诉 GPU 不传递任何参数

测试 kernel

  1. 打包系统镜像

    使用 smatyukevich/raspberry-pi-os-builder 镜像进行编译行为, 该镜像已经配置了 GNU 的交叉编译环境

    docker run --rm -v $(pwd):/app -w /app smatyukevich/raspberry-pi-os-builder make $1
    
  2. 将镜像拷到 SD 卡(Mac os 下)

    将编译出的系统镜像写入 sd 卡,然后弹出

    cp kernel8.img /Volumes/boot
    hdiutil eject /Volumes/boot
    
  3. 将 SD 卡装到树莓派上

  4. 将继电器连接到树莓派

  5. 启动树莓派

使用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
    

多核心运行

 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.


Yang
WRITTEN BY
Yang
Developer