这篇文章是从笔者的笔记整理而来, 比起“讲义”更像“博客” ,目前有许多东西都还没有完善(特别是linux driver的部分)
在笔者尝试给NEMU
移植kernel
的时候, 感到完全无从下手(也没发现CommandBlock
老师的教程),过程中踩了非常多的坑,于是总结了这篇笔记
虽然大家更加建议的roadmap
是PA3,4->xv6->quardstar->linux-nommu->linux-mmu
,但笔者觉得,如果我们给NEMU
加上了完备的基础设施(difftest/trace/gdb
),NEMU
比QEMU
更能帮助我们理解kernel
启动的流程,完全可以PA3,4->(xv6->)linux-mmu
也可以参考
发现问题/有好的建议欢迎提PR!
PR is always welcome here.
建议先完成:
NEMU PA
全部内容Opensbi
和RISCV Spec Volume II, ch 1,2,3,10
由于笔者的NEMU的架构是riscv32
,这篇文章的很多Tips可能只适合与riscv32
架构,但是从生态的角度来讲,riscv64gc
的生态会非常好,建议大家优先考虑实现riscv64gc
如果NEMU
实现的足够优雅是可以通过menuconfig
来切换rv32
/rv64
,但显然我没有😭
linux kernel
和启动nanos-lite
的区别Nanos-lite
的实现不是很规范,比如在M-mod
下启用虚拟内存,也没有实现S-Mod
NEMU
)的角度来说,Linux的规模更大,如果实现有问题更能体现出来Nanos-lite
是我们自己写硬件+软件,如果硬件有BUG可以在软件上加一个workaround()linux kernel
的多种方式fsbl->opensbi->linux
fsbl->opensbi->u-boot->linux
uboot-spl->opensbi->u-boot->linux
在 nemu 上都不用实现 fsbl, 所以可以选择最简单的方法: opensbi->linux
可以参考
Opensbi repo
里面的fpga/arine
“硅基大陆的宪法仍在,城邦却铸造着各自的货币”
提供标准SBI接口、隔离硬件访问
1.虽然有统一的标准, 但是不同RISC-V
硬件实现的差异还是太多了, 比如用多少个 pmp
寄存器, 相关硬件的早期初始化都不一样, opensbi
就是负责启动早期的工作的
2.抽象和安全 当计算机世界一个东西变得足够复杂的时候, 就创建一个抽象层来简化它
所以启动带 mmu 的 kernel
一定要用 opensbi
我的理解:更高级的支持->支持命令行/从 tftp
服务器上下载文件, 更复杂的硬件和安全支持.
但显然目前在 nemu/npc
上不需要
FW_PAYLOAD
把下一个阶段的内容直接打包进 opensbi
生成的 binary
里面FW_JUMP
直接跳转到一个固定的地址FW_DYNAMIC
从上一个 booting-stage 获取信息 (比如上一个 stage 已经把 opensbi
和系统准备好了)在 nemu
上用 ` FW_PAYLOAD ` 是最省力的
为啥不去看看 rv 手册呢 (Volume II, ch 1,2,3,10)
(Reserved Writes Preserve Values, Reads Ignore Value)WPRI
(Write/Read Only Legal Values)WLRL
(Write Any Values, Reads Legal Values)WARL
Opensbi 在启动的过程中就会尝试给很多 csr 寄存器写数值, 然后再读取出来,
illegal instruction fault
, 这时候跳转到 Opensbi
自己的异常处理程序里面, 如果这个 csr 是必须的, 那么 opensbi 会抛一个异常停下来, 如果不是必须的, 那么接下来就不使用这个寄存器继续csr
寄存器可以通过索引的高四位判断权限/RW 权限等等->硬件实现就简单了
mstatus/sstatus
& sie/mie
的某些位应该是硬件上的相同 bit, 根据手册定义
A restricted view of mstatus appears as the sstatus register in the S-level ISA.
csr
?如果目标仅仅是<我要把 kernel
正常跑起来>的话
csr
opensbi-FW_JUMP
模式, 把 opensbi
的输出调好, 只要能正常跑到跳转的地方就说明 csr
已经实现的差不多了difftest
Spike
源码重要的文件
processor.h/state_t
:里面包含了 spike 的状态 (全部的寄存器)../difftest.cc/difftest_init
: 里面包含了 spike 的初始化参数->只实例化 16 个 pmpregions
csrs.cc
:里面有各种 csr 寄存器的行为csrrs.cc
中)riscv/insns/*.h
中回想起之前手册的内容, 访问没有实现的 CSR 寄存器的时候会抛出 Illegal Instruction Fault,
我们有很多方法来处理
illegal instruction fault
那么我们就需要修改Spike的代码了
在spike中,所有 csr 指令都会首先 get_csr
, 如果 csr 不存在就抛异常, 所以只要在不打算实现的 csr 上抛出一个异常就行了
bool difftest_dut_csr_notexist = false;
// Note that get_csr is sometimes called when read side-effects should not
// be actioned. In other words, Spike cannot currently support CSRs with
// side effects on reads.
reg_t processor_t::get_csr(int which, insn_t insn, bool write, bool peek)
{
if(difftest_dut_csr_notexist) {
difftest_dut_csr_notexist=false;
printf("spike:stepping DUT(nemu,npc)'s unimplemented csr\n");
throw trap_illegal_instruction(insn.bits());
}
auto search = state.csrmap.find(which);
if (search != state.csrmap.end()) {
if (!peek)
search->second->verify_permissions(insn, write);
return search->second->read();
}
printf("spike:stepping REF(spike)'s unimplemented csr\n");
// If we get here, the CSR doesn't exist. Unimplemented CSRs always throw
// illegal-instruction exceptions, not virtual-instruction exceptions.
throw trap_illegal_instruction(insn.bits());
}
ref_difftest_raise_intr
来实现上述功能ref_difftest_raise_intr
是用来实现中断的,只会设置异常号跳转到异常处理程序
但是illegal instruction fault
存在”副作用”,会对多个csr寄存器做修改,所以不要使用下面的方案
//不要使用下面的方案!
void difftest_step_raise(uint64_t NO) {
//step
ref_difftest_exec(1);
//rasie intr
ref_difftest_raise_intr(NO);
//set step
difftest_skip_ref();
ref_difftest_regcpy(&cpu, DIFFTEST_TO_REF);
}
difftest_csr
difftest_init
的 api, 传入需要 diff 的 csr 的索引数组使用mini-gdbstub
项目可以很轻松在nemu
里面接入gdb-server
如果仔细测试mini-gdbstub
的实现,你会发现虽然mini-gdbstub
虽然实现了stepi
函数,但gdb并不会调用它!gdb的每次step都是在下一条指令的位置打一个断点,然后continue
,这会导致对ecall
step
的话有问题,无法step进异常处理程序.
猜测是init的时候给gdb传的参数有问题.
gdb
传送target description
文件来实现对csr的读取具体参考往期分享会
tmux
实现自动分屏tmux split-window -h -p 65 "riscv64-unknown-linux-gnu-gdb -ex \"target remote localhost:1234\" $(ELF)"
ELFS :='-ex \"set confirm off\" -ex \"symbol-file ${PWD}/opensbi/build/platform/nemu/firmware/fw_payload.elf\" -ex \"add-symbol-file ${PWD}/linux/vmlinux\" -ex \"set confirm on\"'
socket
加速参考该项目github pr#5
为了更加深入理解linux kernel
的行为,可以考虑添加:
trace
PLIC
)的trace
MMU
的trace
ecall
的trace
非常不建议完全按照我的方法走!
一开始在感觉给 NEMU
“移植” ` linux ` 的过程中用NEMU
来模拟硬件的行为是不是怪怪的
所以我选择了不改动 nemu
的实现 (比如 ` uart ) ,而是给
opensbi /
linux` 写驱动 (但这样会花很多时间)
然后写 linux-uart
驱动的时候发现自己小看了 linux kernel
的复杂程度 (:-
其实 nemu 的 uart 可以轻松修改兼容标准的UART16550
,具体RTFSC
.
Opensbi
主要参考了 opensbi/docs/platform_guide.md
,但是,如果 nemu
模拟了 UART16550
的话, 其实更推荐使用 Opensbi 官方提供的 Generic Platform
,根据官网介绍可以直接按照设备树来自行加载驱动
从 platform/template
里面复制然后稍作修改
Makefile
的参数PLATFORM_RISCV_XLEN = 32
PLATFORM_RISCV_ABI = ilp32
PLATFORM_RISCV_ISA = rv32ima_zicsr_zifencei
PLATFORM_RISCV_CODE_MODEL = medany
FW_DYNAMIC=n
FW_JUMP=y
FW_TEXT_START=0x80000000
FW_JUMP_ADDR=0x0
这里可以先使用JUMP
模式,把 FW_JUMP_ADDR
设置成 0, 如果执行 mret
之后跳转到了 0 就说明 ` opensbi ` 执行完了,后续我们跑linux的时候再使用PAYLOAD
模式。
你接下来需要在开启difftest的情况下正常跑到mret
的地方.
编译:
make CROSS_COMPILE=riscv64-unknown-linux-gnu- PLATFORM=nemu
生成的二进制文件: ./build/platform/nemu/firmware/fw_jump.bin
后续开启PAYLOAD以后二进制文件就是
fw_payload.bin
了
opensbi
正常输出字符 (适配 nemu-uart
)主要参考 int uart8250_init(unsigned long base, u32 in_freq, u32 baudrate, u32 reg_shift,u32 reg_width, u32 reg_offset)
这个函数的代码, 主要要调用 sbi_console_set_device
sbi_domain_root_add_memrange
这两个函数, 然后自己实现一个 nemu-uart
的驱动, 这样就能看到字符的正常输出了
static int uart_getch(void)
{
return -1;
}
static void uart_putch(char ch)
{
char *serial_base = (char *)0xa0000000 + 0x00003f8;
*serial_base = ch;
}
static struct sbi_console_device my_uart = { .name = "nemu_uart",
.console_putc = uart_putch,
.console_getc = uart_getch };
/*
* Platform early initialization.
*/
static int platform_early_init(bool cold_boot)
{
if (!cold_boot)
return 0;
sbi_console_set_device(&my_uart);
return sbi_domain_root_add_memrange(0x10000000, PAGE_SIZE, PAGE_SIZE,
(SBI_DOMAIN_MEMREGION_MMIO |
SBI_DOMAIN_MEMREGION_SHARED_SURW_MRW));
return 0;
}
如果实现比较正常, 那么你应该能看见输出信息 (要么是 Opensbi
的欢迎界面, 要么是 Opensbi
报错某个寄存器没有实现)
system_opcode_insn: Failed to access CSR 0x104 from M-mode
sbi_trap_error: hart0: trap0: illegal instruction handler failed (error -1)
Opensbi
的源码如果提前看了 opensbi 的汇编代码, 会发现 csr_read_num
等函数里面有很多 csr
寄存器, 但其实不一定都要实现
这是因为:在启动过程中,Opensbi会先注册一个特殊的中断处理程序,然后对很多个寄存器尝试写入,如果这个寄存器硬件没有实现,那么就会跳转到它的中断处理程序里面,如果这个寄存器是必须的,那Opensbi就会抛出一个异常,如果是可选实现,那么就会继续执行,并在之后不使用这个寄存器
可以参考下面的代码:
sbi_csr_detect.h/csr_read_allowed//csr_write_allowed
检测寄存器是否支持读写!sbi_hart
里面 hart_detect_features
会检测平台支持的寄存器是否存在等, 它包括异常处理, 允许后续恢复现场nemu
添加更多的寄存器我不选择”一口气把所有手册中定义的 csr 全部实现”因为感觉会陷入名为<细节>的黑洞:要实现很多非必须的csr的功能细节>
听北京基地的某位大佬说香山的 nemu
的 csr
实现的非常巧妙, 感兴趣可以参考, 但我没看 (:-
在实现过程中可能要频繁修改 csr
寄存器的列表, 我希望通过宏定义实现相对统一的寄存器管理: 在头文件中添加了一个寄存器之后:
Difftest
的时候会自动比较这个寄存器gdb/sdb
能读取/显示/打断点这个寄存器
所以我使用了 define
和 undef
组合, 让一个宏有多种展开方式#define CSR_LIST \
GenCSR(MHARTID, 0xf14) \
GenCSR(MSTATUS, 0x300) \
...
#define GenCSR(name, paddr) \
static const uint32_t NEMU_CSR_V_##name = paddr; \
static const uint32_t NEMU_CSR_##name = paddr;
CSR_LIST
#undef GenCSR
...
static const char *difftest_csr_name[] = {
#define GenCSR(NAME,IDX) #NAME,
CSR_DIFF_LIST
#undef GenCSR
};
...
#define GenCSR(name, paddr) \
"<reg name=\"" #name "\" bitsize=\"32\" type=\"int\" regnum=\"" #paddr "\" />\n"
...
typedef union {
struct {
unsigned int : 1;
unsigned int SSIE : 1;
unsigned int : 1;
unsigned int MSIE : 1;
unsigned int : 1;
unsigned int STIE : 1;
unsigned int : 1;
unsigned int MTIE : 1;
unsigned int : 1;
unsigned int SEIE : 1;
unsigned int : 1;
unsigned int MEIE : 1;
unsigned int : 1;
unsigned int LCOFIE: 1;
unsigned int : 18;
} bits;
uint32_t value;
} mie_t;
#define NEMU_mie ((mie_t *)(&cpu.csr[NEMU_CSR_MIE]))
//使用
NEMU_mie->bits.STIE = xxx;
NEMU_mie->value = xxx;
当然这种写法有问题(Unspecified behavior(参考标准附录J)),根据标准定义,struct中的bit-filed必须被打包进同一可寻址单元的相邻域中(如果大小合适)但是,
(From
ISO/IEC 9899:2024 6.7.3.2.13
) An implementation may allocate any addressable storage unit large enough to hold a bit-field. If enough space remains, a bit-field that immediately follows another bit-field in a structure shall be packed into adjacent bits of the same unit. If insufficient space remains, whether a bit-field that does not fit is put into the next unit or overlaps adjacent units is implementation-defined. The order of allocation of bit-fields within a unit (high-order to low-order or low-order to high-order) is implementation-defined. The alignment of the addressable storage unit is unspecified.
指令运行执行过程中当前正在执行的指令直接触发的异常一般是同步异常(Synchronous Exception), 要立刻阻塞当前的指令执行流, 并且指令本身不应该产生其他的副作用。
所以word_t isa_raise_intr(word_t NO, vaddr_t epc)
didn’t work!
当然我们可以用一个参数来表示是否成功, 但是,考虑这一个指令
INSTPAT("??????? ????? ????? 001 ????? 11100 11", csrrw , I, R(rd)=CSRR(imm&0xfff,s);CSRW(imm&0xfff,s)=src1);
可能会发生什么呢?
Load page fault
/ Store/AMO page fault
这么多不同的地方会抛出这么多不同的错误, 这也太不”优雅”了! 所以 Spike 选择用 try-catch, 但是我们的 c 没有😭
回忆 15-213 ,老师似乎讲过一个 none-local-jump 的东西, 允许程序直接从一个很深的调用栈里面直接跳出跳转到某个位置, 查询资料, 找到了 set-jump
函数, 虽然有性能的损失,但也能满足我们的需求。
int isa_exec_once(Decode *s) {
int jump_value = setjmp(memerr_jump_buffer);
if(jump_value!=0){
return exception_exec(jump_value,s);
}
...
}
在kernel.org
下载linux内核源码
可以大致读一下Linux的启动流程
linux 内核
6.x
开始menuconfig
默认不显示riscv32
的编译选项了,需要勾选(Allow configurations that result in non-portable kernels),我拉取5.15的版本
建议先从 defconfig
改动, 而不是 tinyconfig
改动, 先把 kernel
跑起来再说
虽然提供了具体的配置方案,但还是建议大家自己好好看看kernel 有哪些配制
make ARCH=riscv CROSS_COMPILE=riscv32-unknown-linux-gnu- (defconfig/menuconfig/tinyconfig)
defconfig
的参考配置方案//设置initramfs的文件((可以先不填)如果不填kernel会默认拿一个空文件)
→ General setup->Initial RAM filesystem and RAM disk (initramfs/initrd) support(填自己的Initramfs source file(s))
→ Platform type ->Base ISA (RV32I)
//关闭了这个才能关闭compressed instructions
→ Boot options -> UEFI runtime support (n)
→ Platform type->Emit compressed instructions when building Linux (n)
→ Platform type->FPU support(n)
→ Device Drivers → Character devices → Serial drivers -> NEMU uartlite serial port support (y)(自己写的驱动)
//在vmlinux里面加入调试信息,提升调试体验
→ Kernel hacking → Compile-time checks and compiler options->Compile the kernel with debug info (y)
tinyconfig
开始修改?earlycon
,printk
uart
和plic
的驱动uart
输入)printk
,early-console
等)tinyconfig
实现最小化配制的参考方案//启用printk的支持(用于打印log)
→ General setup → Configure standard kernel features (expert users) -> Enable support for printk(y)
//启用并选择一个initramfs的内核文件
→ General setup->Initial RAM filesystem and RAM disk (initramfs/initrd) support(y)(填自己的Initramfs source file(s))
→ Platform type ->Base ISA (RV32I)
→ Boot options -> UEFI runtime support (n)
→ Platform type->Emit compressed instructions when building Linux (n)
→ Kernel hacking → printk and dmesg options->Show timing information on printks
→ Kernel hacking → Compile-time checks and compiler options -> Compile the kernel with debug info (y)
→ Device Drivers → Character devices ->Enable TTY (y)
→ Device Drivers → Character devices ->Enable TTY -> Early console using RISC-V SBI (y)
→ Device Drivers → Character devices ->Enable TTY -> NEMU uartlite serial port support (y)
→ Executable file formats->Kernel support for scripts starting with #! (y)
→ Device Drivers → IRQ chip support->SiFive Platform-Level Interrupt Controller (y)
linux kernel
的打开方式clangd
,支持代码跳转/宏展开等功能nemu/tools/spike-diff/Makefile
里面有一个替换指令sed -i -e 's/-g -O2/-O2/' $@
kernel
make ARCH=riscv CROSS_COMPILE=riscv32-unknown-linux-gnu- -j $(nproc)
会编译出:
./vmlinux
kernel
的elf文件./arch/riscv/boot/Image
二进制文件,作为Opensbi
的payload将软件TLB(Translation Lookaside Buffer)填充设置为可选项->在nemu中为了简化就可以不实现TLB了
The initial RISC-V paged virtual-memory architectures have been designed as straightforward implementations to support existing operating systems. We have architected page table layouts to support a hardware page-table walker. Software TLB refills are a performance bottleneck on high-performance systems, and are especially troublesome with decoupled specialized coprocessors. An implementation can choose to implement software TLB refills using a machine-mode trap handler as an extension to M-mode.
如果你在这时候使用objdump
尝试反编译vmlinux
的内容,你会发现kernel
被链接到了0xC0000000
的位置,这和我们将要把代码放置的位置不同!
先别急,这是正常现象,如果你的riscv
模拟器实现正确,kernel
完全可以正常运行
为什么?不妨加一个trace自行探索试试看?
hint:linux内核中异常!=错误,只有无法处理的异常==错误
为啥不先看看 kernel
访问了那些寄存器呢?
但注意:有一个 time (timeh) 寄存器反汇编出来的指令是 rdtime/rdtimeh
首先 Objdump 出 vmlinux
的内容, 然后可以写一个简单的 Python 脚本来统计总共访问了哪些 csr 寄存器
import re
import sys
def find_csr_registers(disassembly):
csr_pattern = re.compile(r'.*csr[a-z]{1,2}\t.*')
csrr_pattern = re.compile(r'.*csrr\t.*')
csr_registers = set()
for line in disassembly.split('\n'):
match = csr_pattern.search(line)
csrr_match = csrr_pattern.search(line)
if csrr_match:
result=re.split(r'[,\t]+',line)[-1]
csr_registers.add(result)
elif match:
result=re.split(r'[,\t]+',line)[-2]
csr_registers.add(result)
return sorted(csr_registers)
if __name__ == "__main__":
with open("./result.txt", 'r') as f:
disassembly = f.read()
csr_list = find_csr_registers(disassembly)
print("Used CSR registers:")
for csr in csr_list:
print(f"- {csr}")
在真实的系统中, 时钟一般不会设计成一个寄存器/csr 的形式, 因为会有多个 hart 同步/关机/动态调频的问题, 一般设计成 MMIO
来自 riscv-spec
Accurate real-time clocks (RTCs) are relatively expensive to provide (requiring a crystal or MEMS oscillator) and have to run even when the rest of system is powered down, and so there is usually only one in a system located in a different frequency/voltage domain from the processors. Hence, the RTC must be shared by all the harts in a system and accesses to the RTC will potentially incur the penalty of a voltage-level-shifter and clock-domain crossing. It is thus more natural to expose mtime as a memory-mapped register than as a CSR.
kernel
的报错输出依赖关键csr寄存器的实现正确,但是csr实现的细节很繁杂,没有difftest的话很可能会存在某些地方实现错误!
gdb可以极大地加强你的调试体验,你不会喜欢一直使用printk调试法/ebreak调试法的
ebrak调试大法(别学)
asm volatile (
"mv a0, %0\n\t" // 将 start 的值加载到 a0 寄存器
"mv a1, %1\n\t" // 将 end 的值加载到 a1 寄存器
"ebreak" // 执行 ebreak 指令
:
: "r"(start), "r"(end) // 输入操作数:将 start 和 end 传递给寄存器
: "a0", "a1" // 声明 a0 和 a1 寄存器会被修改
);
gdb好用的地方之一:可以读取函数调用的backtrace
和参数,如果你的earlycon输出不正常也可以使用gdb来调试
#2 0x8091d2d8 in panic (fmt=fmt@entry=0x81410b78 <payload_bin+12651384> "\0014RISC-V system with no 'timebase-frequency' in DTS\n")
at kernel/panic.c:443
→ Kernel hacking → Compile-time checks and compiler options -> Compile the kernel with debug info
打开这个选项以后gdb的调试体验会极大增强(可以读函数参数,可以对着源代码调试)
kernel
启动早期会开启MMU,MMU的实现会导致gdb远程调试出现bug(无法正确扫描内存导致info src
出现异常),所以需要特殊处理,有两个方法:
page table walk
kernel
的BUG_ON
宏或者导致设备树读取失败)修改linux kernel
的Makefile
,把PAGE_OFFSET
设置成和加载地址一样的数值,这样可以保证kernel的虚拟地址和物理地址相等用gdb远程调试给kernel
打一个断点,看看是否能够正常停下来,info src
能不能正常定位到源代码
回想我们笔记本的linux启动的时候会有很多调试信息,在linux出现问题的时候能给我们很大的提示,但是,serial驱动的初始化往往在kernel
初始化的很晚的阶段,那怎么看早期的log呢?
当我们想到这个问题的时候,大概率有人想过了,这就是OpenSBI提供的earlycon
功能,如果启用了这个功能以后,kernel
的输出会经过一次ecall
以后跳转到Opensbi后然后由Opensbi输出
kernel
的printk的支持建议检查一下printk的选项有没有开,如果printk没有开那么不会输出log!
Kernel hacking-> printk and dmesg options
→ General setup → Configure standard kernel features (expert users) -> Enable support for printk
kernel
的earlycon
确保在menuconfig里面勾选了earlycon功能,并且给kernel
传递了earlycon=sbi
作为启动参数(可以通过设备树传递,也可以临时在menuconfig里面指定( → Boot options->Built-in kernel command line
))
[ 0.000000] Linux version 5.15.178 (seeker@miLaptop) (riscv64-unknown-linux-gnu-gcc (GCC) 13.2.0, GNU ld (GNU Binutils) 2.41) #138 SMP Sat Feb 15 16:19:35 HKT 2025
遇到了问题正在阅读linux kernel mmu的源代码?
但是如果阅读kernel
源代码,会发现一个奇怪的逻辑
void __init create_pgd_mapping(pgd_t *pgdp,
uintptr_t va, phys_addr_t pa,
phys_addr_t sz, pgprot_t prot)
{
pgd_next_t *nextp;
phys_addr_t next_phys;
uintptr_t pgd_idx = pgd_index(va);
if (sz == PGDIR_SIZE) {
if (pgd_val(pgdp[pgd_idx]) == 0)
pgdp[pgd_idx] = pfn_pgd(PFN_DOWN(pa), prot);
return;
}
...
}
不理解?该去翻一下手册了(10.3.2. Virtual Address Translation Process)
如果查看kernel
的汇编发现指令中混入了一个 ebreak
!
为什么会 call ebreak
: 因为有 BUG_ON
等等宏触发了, 通常是 Kconfig/Makefile
有问题
BUG_ON((PAGE_OFFSET % PGDIR_SIZE) != 0);
BUG_ON()
#define BUG_ON(condition) do { if (unlikely(condition)) BUG(); } while (0)
#define __BUG_FLAGS(flags) do { \
__asm__ __volatile__ ("ebreak\n"); \
} while (0)
#define BUG() do { \
__BUG_FLAGS(0); \
unreachable(); \
} while (0)
asmlinkage void __init setup_vm(uintptr_t dtb_pa)
{
...
/* Sanity check alignment and size */
BUG_ON((PAGE_OFFSET % PGDIR_SIZE) != 0);
BUG_ON((kernel_map.phys_addr % PMD_SIZE) != 0);
...
}
设备树
主要是描述硬件平台设备组成和配置的数据结构,它的核心作用是将硬件信息从内核代码中分离,实现硬件描述与系统软件的解耦,从而提升系统的可移植性、可维护性和灵活性,一般由bootloader加载进内存并传递给kernel,也可以直接打包进kernel.
x86架构并没有设备树,x86架构通过ACPI等协议自动探测硬件连接(当然也有
riscv-ACPI
)
第一次学设备树会觉得很抽象, 其实可以直接额参考文档/其他设备的 example
设备”树”有很多种写法, 感觉 json
很像, 但也有区别
可以参考
大概需要有什么:
┌─────────────────────────────┐
│ Root Node │ / {
├─────────────────────────────┤
│ #address-cells = <1> │
│ #size-cells = <1> │
│ compatible = "seeker_nemu" │
└─────────────┬───────────────┘
│
┌────────────────┬─────┴───────────────────┬───────────────────┬──────────────────┐
│ │ │ │ │
┌───▼──────┐ ┌──────▼───────────┐ ┌────────▼──────────┐ ┌──────▼─────────┐ ┌─────▼───────────┐
│ choosen │ │ cpus │ │ plic0@0xC000000 │ │ uart@a00003f8 │ │ memory@80000000 │
├──────────┤ ├──────────────────┤ ├───────────────────┤ ├────────────────┤ ├─────────────────┤
│ bootargs │ │timebase-frequency│ │ compatible │ │ reg=0xA00003F8 │ │ reg=0x80000000 │
│ │ │ │ │ riscv,ndev │ │ status=okay │ │ -0x87FFFFFF │
└──────────┘ └──────┬───────────┘ │ reg │ └────────────────┘ └─────────────────┘
│ │interrupts-extended│
┌──────▼────────┐ └────────────┬──────┘
│ / cpu@0 │ │
├───────────────┤ │
│ compatible │ │
│ device_type │ │
│ status │ │
│ riscv,isa │ │
└─────────┬─────┘ │
│ │
┌──▼──────────────────┐ │
│ cpu0_intc │ │
├─────────────────────┤ │
│ #interrupt-cells │ │
│ compatible │ │
│interrupt-controller ◄───────┘
│ │
└─────────────────────┘
kernel
的如果你尝试调试kernel
中访问设备树的部分,你会发现:kernel
访问设备树时候访问的是0x3e200000
附近的地址
这个地址是怎么来的呢?
根据手册规定,设备树地址应该放在a1寄存器传递给kernel
如何确定这块地址是不是设备树->可以扫描内存看看魔数对不对
/* Save hart ID and DTB physical address */
mv s0, a0
mv s1, a1
...
#ifdef CONFIG_BUILTIN_DTB
la a0, __dtb_start
#else
mv a0, s1
#endif /* CONFIG_BUILTIN_DTB */
/* Set trap vector to spin forever to help debug */
la a3, .Lsecondary_park
csrw CSR_TVEC, a3
call setup_vm
之后我们追踪一下这个变量(head.s
), 发现传递给了 ` setup_vm ,然后会映射这片内存到
0x3e200000`附近
你需要给这里打一个断点,来检测设备树是否读取成功
status = early_init_dt_verify(params);
if (!status)
return false;
如果你有任何想法,pr/issue is always welcomed!
如果你的设备树传递的地址没有对齐,可能会在这里设置错误的dtb_early_va
,我没搞清楚为什么不需要显式对齐
这里建议按照Opensbi官方仓库里面的fpga/ariane
的makefile来配置FW_PAYLOAD_FDT_ADDR
,FW_PAYLOAD_OFFSET
,FW_PAYLOAD_ALIGN
等参数
kernel
的代码:
dtb_early_va = (void *)fix_fdt_va + (dtb_pa & (PMD_SIZE - 1));
我认为需要显式对齐的代码:
dtb_early_va = (void *)(fix_fdt_va & ~(PMD_SIZE-1) ) + (dtb_pa & (PMD_SIZE - 1));
可以参考 drivers/of/fdt.c
, 里面的 early_init_dt_scan_nodes
,在这里面初始化内存,把设备树解析到内存里面,之后的driver_init的时候再根据设备树里面的compatable
子段匹配驱动,然后调用对应的probe
函数
void __init early_init_dt_scan_nodes(void)
{
int rc = 0;
/* Initialize {size,address}-cells info */
of_scan_flat_dt(early_init_dt_scan_root, NULL);
/* Retrieve various information from the /chosen node */
rc = of_scan_flat_dt(early_init_dt_scan_chosen, boot_command_line);
if (!rc)
pr_warn("No chosen node found, continuing without\n");
/* Setup memory, calling early_init_dt_add_memory_arch */
of_scan_flat_dt(early_init_dt_scan_memory, NULL);
/* Handle linux,usable-memory-range property */
early_init_dt_check_for_usable_mem_range();
}
这里应该只解析了设备树,初始化设备还在后面,但是内存(页表是在这里初始化的)
虚拟内存的映射也是根据设备树来的,在设备树读取到内存节点的时候, 会调用 early_init_dt_add_memory_arch
之后调用 memblock_add
存储地址进 memblock.memory
以便之后读取
FIXME: 由于kernel的复杂性,这里有非常多的疏漏和没讲清楚的地方
主要参考 linux 内核 driver-api/serial/driver
同时可以参考 linux 内核的 uart-lite 的驱动
,因为从代码行树来看的话,uart-lite
是代码最少的uart驱动
启用uart
(nemu-uart/uartlite
)驱动的位置在 tinyconfig→ Device Drivers → Character devices->tty->xxx
nemu-uart.c
文件obj-$(CONFIG_NEMU_UART) += nemu-uart.o
menuconfig
里面勾选驱动kernel
驱动的组成Linux 驱动主要包含几个结构体:
console
控制台设备的接口uart_ops
定义uart
的函数集合uart_driver
表示一个uart
的驱动程序uart_port
表示一个具体的uart
端口platform_driver
实现平台总线上的一个设备驱动module_init
宏会注册模块的初始化函数,如果这个驱动被编译进kernel
(比如我们的nemu-uart
驱动),就会在kernel
启动的时候被do_initcalls()
调用
module_init(nemu_uart_init);
uart
驱动的基本流程do_initcalls()
do_one_initcall()
调用每个注册的init函数nemu_uart_init
(初始化函数)platform_driver_register
注册驱动(nemu_uart_platform_driver
结构体)driver_probe_device()
,尝试把driver
和某个device
绑定nemu_uart_platform_driver
注册的nemu_uart_probe()
函数probe
函数获取内存资源/获取中断资源/映射内存/注册驱动/初始化端口/添加自旋锁static struct uart_driver nemu_uart_driver = {
.owner = THIS_MODULE,
.driver_name = DRIVER_NAME,
.dev_name = "ttyNEMU",
.major = TTY_MAJOR,
.minor = 2472,
.nr = 1,
};
主要的参考资料
Uart-lite
更多资料可以参考
之前的内容跑到这里就说明成功了,接下来就需要一个文件系统了,
#2 0x8091d1f4 in panic (
fmt=fmt@entry=0x81410748 <payload_bin+12650312> "No working init found. Try passing init= option to kernel. See Linux Documentation/admin-guide/init.rst for guidance.") at kernel/panic.c:443
文件系统
是操作系统给我们提供的又一层抽象.由于NEMU中我们尚未实现磁盘,所以最好的方法是打包一个initramfs
参考linux文档
,如果不指定路径,内核会使用一个空的initramfs
真实系统的initramfs:只是启动过程中的一部分,bootloader负责把kernel和initfs加载进内存然后启动kernel,kernel会判断initfs的类型(initrd/initramfs), 一般initramfs只是作为在真正的根文件系统被挂载之前的一个临时文件系统,里面存放一些被编译成”可加载的内核模块”的驱动, 这样也可以简化kernel的实现,因为磁盘的实现有多种多样(可能是软件RAID, LVM, NFS…这些都需要特殊的步骤去挂载, ),但内存是简单统一的. 再之后会调用
pivot_root()
来卸载initramfs
并切换到真正的根文件系统
需要打开initramdisk的支持,并把我们之后打包的initramfs添加进来
-> General setup -> Initial RAM filesystem and RAM disk (initramfs/initrd) support
首先,我们需要创建linux的目录结构
mkdir --parents /usr/src/initramfs/{bin,dev,etc,lib,lib64,mnt/root,proc,root,sbin,sys,run}
创建控制台设备
sudo mknod rootfs/dev/console c 5 1
系统启动后由内核创建的第一个用户空间进程(PID 为 1)。它是所有其他进程的父进程或间接父进程,负责初始化系统环境、管理系统服务和守护进程的生命周期。
我们的目标是在nemu
上启动一个简单的kernel
就行了,所以init
进程主要的工作就是启动shell
由于init
进程是内核启动加载的第一个进程,我们只要测试一下init能不能正常加载执行就可以了
可以先用c写一个死循环程序,用工具链静态编译以后打包进initramfs
里面,之后给kernel传递init=xxx
参数,让kernel运行init
riscv32-unknown-linux-gnu-gcc -static -o init init.c
诶,page fault了?
hint:异常!=错误
该读文档了!
The Svade extension: when a virtual page is accessed and the A bit is clear, or is written and the D bit is clear, a page-fault exception is raised.
riscv页表的脏位检查是允许硬件维护,同时也使用一个M-mode
拓展来允许软件维护
在nemu中就直接抛异常让软件来实现就行了
参考Spike的代码:
reg_t ad = PTE_A | ((type == STORE) * PTE_D);
if ((pte & ad) != ad) {
if (hade) {
// set accessed and possibly dirty bits.
pte_store(pte_paddr, pte | ad, addr, virt, type, vm.ptesize);
} else {
// take exception if access or possibly dirty bit is not set.
break;
}
}
之前编译Opensbi和kernel的时候其实没有链接glibc
,现在在编译用户程序的时候就需要一个带glibc
的工具链支持了,可以自行编译riscv-gnu-toolchain
因为--enable-multilib
会默认用rv32gc来编译标准库,如果传递了 --enable-multilib
可能会导致编译出的标准库包含 c 拓展的指令,从而导致最后静态链接的elf文件包含压缩指令
可以先用静态链接的方式编译init/busybox
虽然在大多数情况下可以正常运行,但是静态编译链接glibc是非常不推荐的参考StackOverflow
推荐的编译选项:
./configure --prefix=/opt/riscv --with-arch=rv32ima --with-abi=ilp32
make linux
newlib
和musl
库不建议尝试使用musl
和newlib
截至目前,newlib
上游只适配了x86-linux
来自newlib官网
:
Now linux is a different animal. It is an OS that has an extensive set of syscalls. If you look in the newlib/libc/sys/linux directory, you will find a number of syscalls there (e.g. see io.c). There is a set of basic syscall macros that are defined for the particular platform. For the x86, you will find these macros defined in newlib/libc/sys/linux/machine/i386/syscall.h file. At the moment, linux support is only for x86. To add another platform, the syscall.h file would have to be supplied for the new platform plus some other platform-specific files would need to be ported as well.
截至目前,musl
上游没有支持riscv-linux
来自musl官网
Use it on Linux x86 (32/64), ARM (32/64), MIPS (32/64), PowerPC (32/64), S390X, SuperH, Microblaze, OpenRISC
虽然有riscv-newlib
和riscv-musl
的分支, 不过也是archieve的状态了,也没必要使用没有官方支持/停止维护的东西
编译生成的工具链的名称有riscv32-unknown-linux-gnu-xxx
,riscv32-unknown-elf-xxx
,musl-xxx
等这些其实包含了Target Triplet
的东西,可以通过gcc -dumpmachine
查看
Target Triplet
的格式是machine-vendor-operatingsystem
,详细信息可以自行STFW
其中riscv32-unknown-elf-xxx
使用Newlib的工具链,不包含riscv-linux
系统相关的代码(比如linux的ecall)
可以先写一个死循环来测试, 然后再 initscript
init要有执行权限!
(cd initramfs && find . | cpio -o --format=newc | gzip > ../initramfs.cpio.gz)
busybox
make CROSS_COMPILE=riscv32-unknown-linux-gnu- ARCH=riscv CONFIG_PREFIX=/root/initramfs meuconfig
make CROSS_COMPILE=riscv32-unknown-linux-gnu- ARCH=riscv CONFIG_PREFIX=/root/initramfs install
<byteswap.h>
未找到?busybox里面有platform-spec的适配代码,通过检查gcc 的 System-specific Predefined Macros
在toolchain的config 里面有参数 --enable-linux
,如果make linux
的话会默认选中这个参数,但如果强行传–enable-linux就链接到glibc库里面去了
/* ---- Endian Detection ------------------------------------ */
#include <limits.h>
#if defined(__digital__) && defined(__unix__)
# include <sex.h>
#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) \
|| defined(__APPLE__)
# include <sys/resource.h> /* rlimit */
# include <machine/endian.h>
# define bswap_64 __bswap64
# define bswap_32 __bswap32
# define bswap_16 __bswap16
#else
# include <byteswap.h>
# include <endian.h>
#endif
init
脚本需要先开启kernel的init脚本的支持
→ Executable file formats->Kernel support for scripts starting with #!
具体可以参考各路wiki,在这里我们可以简化,直接启动一个sh就行了
如果之前一切顺利,那应该能看见kernel
运行了init
脚本的内容,并且最终执行了/bin/sh
之后我们当然想要输入,支持输入的话就要中断的支持了,在riscv中,外部的中断需要一个统一的中断控制器来管理,这个中断控制器可以协调多个外部中断源, 实现分配优先级, 抢占, 屏蔽, 路由, 完成通知,…这就是PLIC(Platform-Level Interrupt Controller)
在nemu中,我们直接把输出打印到控制台,但是log也会打印到控制台,输入/输出/Log全部混在一起并不是一个明知的选择,所以我采用了伪终端(pseudoterminal)
,创建一个伪终端,通过screen链接这个伪终端来和nemu交互
写了一个最小化实现的例子
如果没有一个client
(比如screen
)连接上这个pyt消费掉master
(nemu)存进去的数据的话,输出的内容会在下一次读取的时候读取出来,我就让ptyinit
的时候等待client
链接(详细参考我的例子)
参考:
PLIC就不写驱动了,还是老老实实实现sive
的PLIC
吧
需要修改uart的设备树,声明中断源和连接plic
uart: uart@a00003f8 {
compatible = "seeker,nemu_uart";
reg = <0xa00003f8 0x1>;
interrupts = <1>; // 使用PLIC中断源1(可随便定义,但需<=riscv,ndev-1)
interrupt-parent = <&plic0>; // 关联到PLIC
status = "okay";
};
PLIC&CLINT是两个设备,所以需要另外一根线连到处理器核,所以核内也有一个中断控制器(相信在写设备树的时候也发现了)
这个核内的中断控制器是必须要实现的, 不然plic
驱动就加载不起来
hartid = riscv_of_parent_hartid(node);
if (hartid < 0) {
pr_warn("unable to find hart id for %pOF\n", node);
return 0;
}
根据riscv手册,优先中断的优先级如下
Multiple simultaneous interrupts destined for M-mode are handled in the following decreasing
priority order: MEI, MSI, MTI, SEI, SSI, STI, LCOFI.
这里的缩写分别代表:
MEI (Machine External Interrupt)
MSI (Machine Software Interrupt)
MTI (Machine Timer Interrupt)
SEI (Supervisor External Interrupt)
SSI (Supervisor Software Interrupt)
STI (Supervisor Timer Interrupt)
LCOFI (Local Custom Offload Interrupt)
这里的主要设计原则:
mip
的低四位,允许单条csr指令(csrrwi
等)直接修改要让kernel知道中断发起的时候应该调用哪个处理函数,就需要我们自己注册中断了
其实kernel文档提醒了: Probe 的时候获取中断号 (这里要判断一下是否正常, 否则等到
platform_get_irq
的时候会 fail)
nemu_uart_port.irq = platform_get_irq(pdev, 0);
然后 startup 的时候注册中断
int ret = request_irq(port->irq, nemu_uart_irq,
IRQF_TRIGGER_RISING, "nemu_uart",
port);
这样当中断到来的时候kernel
就会调用nemu_uart_irq
这个函数了
具体参考文档
给 plic 加一个 trace, 发现读写的地址有:
0xc002080->Hart 1 M-mode enables
0xc002084->same area
0xc201000->Hart 1 M-mode priority threshold
0xc000004-> source 1 pirority
阅读手册, 可以知道大概的流程是
claim/complete reg
(反回0或最高记别的中断)claim/complete reg
如果成功就清除pengding bit
实现PLIC的行为就很简单了
其实没有完全实现正确可能也能跑,因为目前 difftest 的框架没有办法diff
到中端是否应该被响应->我们difftest
的difftest_raise_intr(NO)
只会发起一个intr并立刻响应,并不会检查这个中断是否被屏蔽了;
所以实现的时候要仔细阅读手册
中断是交给M-Mode 处理还是S-Mod处理->应该仔细阅读手册有关medeleg
& mideleg
的部分
默认情况下会把所有异常/中断都交给 M-Mod 处理, 然后让 M-mod 的程序来选择是自己处理还是交给S-Mode的操作系统来处理, 但是为了提高性能, 可以把某一些中断/异常委托给 S-Mod (timer/pagefault/plic)
在mstatus中有全局中断使能,mie
& mip
有对细分的中断使能
看看远方的 Riscv64 吧!
如果发新版支持,可以直接chroot进不同架构的rootfs参考
,然后直接执行apt install
大致的步骤
当然rv32
也有社区支持,参考
启动linux的时候不需要实现PMP的功能
pmp<n>cfg
: L0A | XWR
L: locked->(addr&entry) O:reserved A: Access Type
Permissions-error:
AccessType:
pmpaddr
寄存器定义一个连续的地址范围
pmaddr(i-1)<y<pmaddr(i)
, 如果大于则无效