硬件初始化
两种启动方式:
嵌入式设备启动
- 需要初始化具体主板相关硬件如 GPIO 和内存等
- 从 devicetree 获知有哪些设备
- 操作系统中需要很多设备相关的驱动
- 通常与设备强相关
- ROM 通常是闭源的
- 早期启动流程通常是厂商提供的代码
- 缺点:对可插拔外设的兼容性
- 如 RISC-V 嵌入式设备支持 PCIe 外设,需要操作系统内核包含具体硬件上的 PCIe 控制器驱动
RISC-V嵌入式平台常见组合:
- ROM code(在ROM):主板厂商私有
- Bootloader(在SD卡或 SPI flash):如u-boot(非必须)
- Linux kernel(在SD卡)
以常用的基于 U-Boot 和 OpenSBI 的启动流程为例:
- 从 ROM 中开始执行代码
- SoC 芯片内部包含一块 ROM 保存了一些简单的驱动
- 从 SD 卡或 flash 加载 U-Boot SPL 到一块小内存中(单独的 SRAM 或 L2 cache-as-RAM)
- U-Boot SPL 进行最早的初始化
- DDR 内存、时钟等最重要的设备
- 从 SD 卡加载 OpenSBI 和 U-Boot 本体到 DDR 内存,然后跳转到 OpenSBI 运行
- OpenSBI
- 初始化 IPI 和时钟设备
- 初始化自身准备好提供 SBI 服务
- 切换到 Supervisor mode,跳转到 U-Boot
- U-Boot
- 从预先配置的启动设备加载内核到内存(来源可以是 SD 卡、NVMe、网络……)
- 跳转到内核入口点
设备树 Devicetree
U-Boot 和 OpenSBI 为了方便及复用 devicetree 已经完成的工作,也用 FDT 获得硬件配置:
- 文本格式 (Devicetree source, DTS) 和二进制格式 (Flattened devicetree, FDT 或 DTB)
- 包含设备信息、MMIO 地址、中断连接方式、时钟复位电源连接方式等信息
- 与内核同时加载到内存中,跳转到各软件入口点时传入地址
- 惯例:a0 是当前核心的 hartid,a1 是内存中 FDT 的物理地址
通用主板启动
- 系统配置情况在开机时候是不知道的
- 需要探测(Probe)、Training(内存和PCIe)和枚举(PCIe 等等即插即用设备)
- UEFI/ACPI 提供了整个主板、包括主板上外插的设备的软件抽象
通过定义的接口把这些信息传递给 OS,使 OS 不需要改动而能够适配到所有机型和硬件 - 或者由操作系统通过 ACPI 获取硬件信息
- 比 devicetree 提供更多的信息,还提供更多接口,如通用的 PCIe 设备访问,热插拔通知,电源管理接口
- 比 devicetree 复杂
- 在比较简单的设备上 UEFI 也可以不提供 ACPI 而提供 devicetree
常见组合:
- BIOS(在ROM):传统BIOS、EFI/UEFI、coreboot
- Bootloader(在磁盘的MBR):NTLDR、Grub
- Linux kernel(在磁盘其他位置)
RISC-V UEFI
- 提供 PCIe 控制器的驱动,可以以通用方式访问 PCIe 设备
- 提供 PCIe Option ROM 支持,在早期启动时就可以用上外部设备的功能(显卡的显示功能、网卡的网络启动功能……)
内核代码
以 linux 6.10.3 为例
链接脚本: arch/riscv/kernel/vmlinux.lds.S:
- 启动入口:
_start即LOAD_OFFSET即KERNEL_LINK_ADDRKERNEL_LINK_ADDR是0xFFFF_FFFF_8000_0000- 内核启动时的 CPU 复位值为
0x4020_0000
HEAD_TEXT_SECTION定义在include/asm-generic/vmlinux.lds.hHEAD_TEXT中的代码从.head.text节开始
boot into kernel: arch/riscv/kernel/head.S
_start_HEAD: 定义于include/linux/init.h, 表示.section ".head.text","ax"- 跳转到
_start_kernel
_start_kernel- 禁用中断: 清空相应特权级下的 IE 和 IP CSR (mie/sie, mip/sip)
- 对于特权级 M : …
- 将
_global_pointer加载到 gp 寄存器 - 禁用浮点和向量单元: 相应特权级下的 status CSR 的 FS 和 VS 域置 0
- 如果配置了
CONFIG_RISCV_BOOT_SPINWAIT: … - 如果配置了
CONFIG_XIP_KERNEL: …
否则:将 .bss 段清零 - 保存启动时的 cpu hartid
- 初始化寄存器
- tp: init_task
- sp: init_thread_union + THREAD_SIZE
- 如果配置了
CONFIG_BUILTIN_DTB: … - 页表和 MMU 初始化
- 页表初始化 :
setup_vminarch/riscv/mm/init.c - 如果配置了
CONFIG_MMU: 启动 MMU 跳转到高地址执行:relocate_enable_mmu - 重新设置高地址的 tp, sp 寄存器 (gp 寄存器在
relocate_enable_mmu中设置)
- 页表初始化 :
- 设置异常向量:
.Lsetup_trap_vector - 其他组件初始化
- KASAN:
kasan_early_init() - SOC:
soc_early_init()
- KASAN:
- 进入内核:尾调用跳转到
start_kernel
页表和 MMU 初始化
页表初始化
setup_vm 用到两个页表:
trampoline_pg_dir启用 MMU 前后所用,映射启用 MMU 的代码到高地址- 使用 2M 的大页映射内核的起始部分, 作为启动 MMU 的跳板
- pgd 为第一级页表项, pmd 为 2MiB 大页页表项
1 | /* Setup trampoline PGD and PMD */ |
early_pg_dir内核最初启动的时候所用,映射整个内核到高地址
1 | /* |
MMU 初始化
relocate_enable_mmu 启用 MMU,并跳转到高地址继续执行
- 用 trampoline_pg_dir 衔接
- 启用 MMU
- 返回后 pc 在高地址
- 计算返回地址:原有 ra 加上偏移量变为高地址
- 查询 kernel_map 和 _start 之间的地址差作为偏移量
- 设置异常处理地址 stvec 为 标签 1 的高地址
- 之后将通过触发 page fault 的方式切换到高地址运行
- 计算 satp
- 计算 early_pg_dir 对应的 satp 到 a2 寄存器
- 计算 trampoline_pg_dir 对应的 satp 到 a0 寄存器
- 加载 trampoline_pg_dir 页表:sfence.vma 之后将 trampoline_pg_dir(a0) 的 satp 写入 stap CSR
- 随后将触发 page fault: 因为下一条指令依旧是低地址,没有映射
- page fault 后跳转到 stvec 即标签 1 的高地址
- 开始以高地址执行标签 1 及之后的指令
- 标签 1 处代码: 将 stvec 设置到一个死循环的入口(
Lsecondary_park),用于调试
- 标签 1 处代码: 将 stvec 设置到一个死循环的入口(
- 重新加载 gp 寄存器(高地址)
- 加载 early_pg_dir 页表:将 early_pg_dir(a2) 的 satp 写入 stap CSR 后 sfence.vma
- ret 返回原有调用地址的高地址
异常向量初始化(异常/中断/系统调用)
设置异常向量入口: setup_trap_vector
.Lsetup_trap_vector
in arch/riscv/kernel/head.S
- 将
handle_exception设置到 stvec- RISC-V Linux 唯一的异常入口点: handle_exception
- 将 sccratch 设置为 0
- sscratch = 0 说明异常从内核发生
- sscratch $\neq$ 0 指向一个 task_struct 表示内核从用户态发生
- ret 返回
异常处理: handle_exception
in arch/riscv/kernel/entry.S
- 检查异常来自内核态还是用户态 (sccratch 是否为 0)
- 如果来自内核,则恢复内核的 tp 和 sp 寄存器:
- tp 恢复为 sccratch 中表示的 task_struct 结构体指针
- sp 恢复为 tp 的 task_struct 对应的内核栈
如果配置了CONFIG_VMAP_STACK: …
- 如果来自内核,则恢复内核的 tp 和 sp 寄存器:
- 保存上下文:
.Lsave_context- 栈切换:
- 将 sp 写入 tp 指向的 task_struct 结构体中的用户栈指针
- 将 tp 指向的 task_struct 结构体中的内核栈指针写入 sp
- 将除 x0, x2(sp), x4(tp) 寄存器之外的所有 GPR 压栈
- 将 task_struct 的用户栈指针压栈
- 将 status, epc, tval, cause, scratch CSR 寄存器压栈
并清空当前 status 中的 SUM, FS, VS 域 (禁用 U mode 访存和 FPU/Vector Unit)
- 栈切换:
- 将 scratch 寄存器设置为 0 (内核)
- 防止发生递归异常触发
- 加载 gp 寄存器
scs_load_current_if_task_changed s5: ?- 如果配置了
CONFIG_RISCV_ISA_V_PREEMPTIVE: … - 将
ret_from_exception地址作为返回地址(ra)- 异常处理结束后将进入
ret_from_exception恢复上下文
- 异常处理结束后将进入
- 判断是异常还是中断: cause CSR 的 MSb 是否为 1 (1 为中断)
- 如果是中断:尾调用进入
do_irq处理中断(do_irqret 后直接进入ret_from_exception) - 对于异常,则计算
excp_vect_table(异常向量表) 中的第 cause 个 entry 的地址
检查地址是否超出excp_vect_table_end
超出则跳转到do_trap_unknown
未超出则跳转到相应的异常处理函数地址
- 如果是中断:尾调用进入
中断处理: do_irq
todo…
异常向量表:excp_vect_table
in arch/riscv/kernel/entry.S
1 | .section ".rodata" |
向量表中的异常处理函数均定义于 arch/riscv/kernel/traps.c.
系统调用: do_trap_ecall_u
- 通过上下文检查 ecall 是否来自于 U mode ,否则触发
do_trap_error - 通过上下文获取系统调用参数
- a7: 系统调用号
- a0: 系统调用参数
- 对系统调用号进行检查和调整:
syscall_enter_from_user_modeinentry-common.h - 调用系统调用函数进行处理:
syscall_handlerinarch/riscv/include/asm/syscall.h- 从
sys_call_table中找到第 syscall 个函数,然后调用 sys_call_table定义于arch/riscv/kernel/syscall_table.c- 其中 0 号系统调用为
__riscv_sys_ni_syscall,其余系统调用通过_SYSCALL定义于arch/riscv/include/asm/unistd.h - riscv 特有的系统调用声明于
arch/riscv/include/uapi/asm/unistd.h - linux 通用系统调用声明于
include/uapi/asm-generic/unistd.h
- 从
异常返回处理: ret_from_exception
in arch/riscv/kernel/entry.S
- 将 status CSR 恢复到 s0 寄存器,并设置 PP 特权级位 (MPP/SPP)
- 如果 s0 为 0 则:…
- 如果配置了
CONFIG_RISCV_ISA_V_PREEMPTIVE: … - 恢复上下文
- 将 status , epc CSR 恢复
- 将 除 x0, x2(sp) 外的 GPR 恢复
- 最后恢复 sp 寄存器(因为恢复的过程本身需要 sp )
- trap 返回: mret/sret