AbydOS开发日记 (-1) - 第一次上板运行(C906)

4/5/2024 OSC++

# 来上板运行吧!

写了那么久,也快一个月了,都是在 QEMU 调试,多没意思。电子人,就是干,火速弄了块 Sipeed 的 LicheeRV Nano,基于 C906 和 A53的 SG2002,不到一够水就有百兆网口和 WiFi。(深圳到广州居然发了两天,真是慢啊,还有这排线还要我自己焊,好傻)

拿到板子,插锭,开机,屏幕没有当然点不亮。电脑上不停的叮咚响,看了下是模拟出了一个串口。那就很简单,直接找到镜像,烧录!再把卡插进去,启动!好了,过了一会,多了一个网卡,ssh一下成功,测试功能正常。那么就开始我们的上板之旅吧!

# 启动

我们知道,嵌入式 Linux 的启动流程通常是,FSBL -> 固件 -> U-Boot -> 内核,这里的固件就是 OpenSBI。根据 Sipeed 官网的描述 (opens new window),这个片子的启动流程如下:

  1. bootrom(bl1) 判断sd卡第一个FAT分区内是否拥有 fip.bin,如果有,则执行2,如果没有,则进入usb烧录模式(提供一个ACM串口从机设备),bl1会初始化uart0,波特率128000
  2. 加载fip.bin(bl2)里面的代码到0x0C000000(TPU SRAM),跳转到bl2,初始化clock,DRAM,执行3
  3. 加载opensbi到DRAM,执行,然后加载uboot到DRAM,执行,执行4
  4. uboot加载第一个分区内的boot.sd文件到DRAM,如果文件没有问题,跳转到5
  5. 跳转到boot.sd内提供的代码(通常是Linux内核)

再看 boot 分区的内容,果然如此:

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        2024/04/01     15:48         503808 fip.bin
-a----        2024/04/01     15:48        9308664 boot.sd
-a----        2024/04/01     15:48              0 usb.dev
-a----        2024/04/01     15:48              0 usb.rndis0
-a----        2024/04/01     15:48              0 wifi.sta
-a----        2024/04/01     15:48             28 ver
1
2
3
4
5
6
7
8

这样我们就很容易知道如何加载我们的内核,即利用 UBoot 加载,boot.sd 实际上是 uImage。那么先拿串口线接上去看看:

U-Boot 2021.10 (Apr 01 2024 - 15:40:59 +0800) soph

DRAM:  254 MiB
gd->relocaddr=0x8aaa8000. offset=0xa8a8000
MMC:   cv-sd@4310000: 0, wifi-sd@4320000: 1
Loading Environment from nowhere... OK
In:    serial
Out:   serial
Err:   serial
Net:   
Warning: ethernet@4070000 (eth0) using random MAC address - 72:50:86:01:a9:35
eth0: ethernet@4070000
Hit any key to stop autoboot:  0 
soph# 
soph# 
soph# env print
arch=riscv
baudrate=115200
board=mars
board_name=mars
bootcmd=cvi_update; run loadenvcmd ; run showlogo;run sdboot || run sdbootauto
bootdelay=0
consoledev=ttyS0
cpu=generic
fdtcontroladdr=8a161610
gatewayip=192.168.0.11
ipaddr=192.168.0.3
loadenvcmd=mmc info;load mmc 0:1 ${uImage_addr} uEnv.txt; if test $? -eq 0; then env import ${uImage_addr} - ; fi;
netdev=eth0
netmask=255.255.255.0
othbootargs=earlycon=sbi riscv.fwsz=0x80000   loglevel=9
root=root=/dev/mmcblk0p2 rootwait rw
sdboot=setenv bootargs ${reserved_mem} ${root} ${mtdparts} console=$consoledev,$baudrate $othbootargs;echo Boot from SD dev ${sddev} ...;mmc dev ${sddev} && fatload mmc ${sddev} ${uImage_addr} boot.sd;if test $? -eq 0; then bootm ${uImage_addr}#config-sg2002_licheervnano_sd;fi;
sdbootauto=cvi_sd_boot;setenv bootargs ${reserved_mem} ${root} ${mtdparts} console=$consoledev,$baudrate $othbootargs;echo Boot from SD dev ${sddev} auto ...;mmc dev ${sddev} && fatload mmc ${sddev} ${uImage_addr} boot.sd;if test $? -eq 0; then bootm ${uImage_addr}#config-sg2002_licheervnano_sd;fi;
sddev=0
serverip=192.168.56.101
showlogo=mmc dev 0;mmc read 0x84080000 ${MISC_PART_OFFSET} ${MISC_PART_SIZE};cvi_jpeg_dec 0x84080000 0x8ab30000 0x80000;startvo 0 8192 0; setvobg 0 0xffffffff;
uImage_addr=0x81800000
update_addr=0x8b300000
vendor=cvitek

Environment size: 1315/131068 bytes
soph# 
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
35
36
37
38
39
40
41
42
43

从上面日志可以看出,bootcmd 是先加载 uEnv.txt,然后执行 sdboot。我们便可以利用 uEnv.txt 改写 sdboot,达到启动我们内核的目的。如下:

sdboot_backup=${sdboot}
sdboot=gpio input 30; if test $? -eq 0; then run ${sdboot_backup}; else fatload mmc 0 0x80400000 abydos.ub; bootm 0x80400000 - 0x80080000; fi;
1
2

这里gpio 30 是板载用户按键的 IO 编号,如果启动到 uboot 的时候按了键就启动 Linux,否则启动我们的 AbydOS。而 0x80080000 是 OpenSBI 传过来的 FDT 的地址,同时注意这里 OpenSBI 的版本是 0.9(很坑!太旧了!):

OpenSBI v0.9
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name             : LicheeRv Nano
Platform Features         : mfdeleg
Platform HART Count       : 1
Platform IPI Device       : clint
Platform Timer Device     : clint
Platform Console Device   : uart8250
Platform HSM Device       : ---
Platform SysReset Device  : ---
Firmware Base             : 0x80000000
Firmware Size             : 136 KB
Runtime SBI Version       : 0.3

Domain0 Name              : root
Domain0 Boot HART         : 0
Domain0 HARTs             : 0*
Domain0 Region00          : 0x0000000074000000-0x000000007400ffff (I)
Domain0 Region01          : 0x0000000080000000-0x000000008003ffff ()
Domain0 Region02          : 0x0000000000000000-0xffffffffffffffff (R,W,X)
Domain0 Next Address      : 0x0000000080200020  
Domain0 Next Arg1         : 0x0000000080080000 (这里)
Domain0 Next Mode         : S-mode
Domain0 SysReset          : yes

Boot HART ID              : 0
Boot HART Domain          : root
Boot HART ISA             : rv64imafdcvsux
Boot HART Features        : scounteren,mcounteren,time
Boot HART PMP Count       : 16
Boot HART PMP Granularity : 4096
Boot HART PMP Address Bits: 38
Boot HART MHPM Count      : 8
Boot HART MHPM Count      : 8
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000b109
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
35
36
37
38
39
40
41
42
43
44

接下来是生成 abydos.ub。这是一个 uImage,可以包含 Kernel,FDT 和 RAMDISK 等,目前我们只包含 Kernel:

add_custom_target(abydos.ub ALL
    COMMAND mkimage -A riscv -O linux -T kernel -C none -e 0x80100000 -a 0x80100000 -d AbydOS.bin abydos.ub
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
    DEPENDS AbydOS_KNL_BIN
)
1
2
3
4
5

命令中,我们指定类型为 kernel,不压缩,OS 为 linux(骗过 U-Boot,它并不会校验一个裸 bin),然后加载地址和入口点都是 0x80100000。

为什么不能用 go 命令直接启动呢?

很简单,我们的内核期望入口点 a0 是 核心ID,而 a1 是 fdt 地址,但是 go 命令不能实现,只有 boot 系列命令才会正确设置寄存器。

此外,bootelf 也是不行的,它会认为不是 Linux 内核(重定位不会成功),于是拒绝启动。

生成完了以后,拷贝 uEnv.txt 和 abydos.ub 到卡里,启动,发现加载完了没有动静,啥也没输出。

# SBI 的兼容性

还记得上面特别提到的 V0.9 吗?这个版本对应的 SBI Spec 是 v0.3,还没有加入 DBCN 扩展,我们的输出函数调用了这个扩展,于是就没有任何输出了。这个版本下的 SBI,只有 Legacy 的几个输出函数:

4.2. Extension: Console Putchar (EID #0x01)

void sbi_console_putchar(int ch)
1

Write data present in ch to debug console.

Unlike sbi_console_getchar(), this SBI call will block if there remain any pending characters to be transmitted or if the receiving terminal is not yet ready to receive the byte. However, if the console doesn’t exist at all, then the character is thrown away.

4.3. Extension: Console Getchar (EID #0x02)

int sbi_console_getchar(void)
1

Read a byte from debug console; returns the byte on success, or -1 for failure. Note. This is the only SBI call in the legacy extension that has a non-void return type.

那直接很简单,改写 _write() 函数,添加一个 Extension probe 然后决定调用的输出函数:

    int _write(int fd, char *buf, int size)
    {
        static int support_dbcn = -1;
        if (support_dbcn < 0)
        {
            support_dbcn = sbi_ecall(SBI_EXT_BASE, SBI_EXT_BASE_PROBE_EXT, SBI_EXT_DBCN, 0, 0, 0, 0, 0).value;
        }
        if (k_stdout_switched)
            return k_stdout_func(buf, size);
        if (support_dbcn > 0)
            sbi_ecall(SBI_EXT_DBCN, SBI_EXT_DBCN_CONSOLE_WRITE, size, (unsigned long)buf, 0, 0, 0, 0);
        else
        {
            for (int i = 0; i < size; i++)
            {
                sbi_ecall(SBI_EXT_0_1_CONSOLE_PUTCHAR, 0, buf[i], 0, 0, 0, 0, 0);
            }
        }
        return size;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

搞完这些,已经过去了一天。

# MMU 再出锅

改完了再运行,输出有了,可是又卡在 MMU 的启用上,映射完启用 MMU, 直接卡死,执行不下去。这里想起来写 MMU 配置的时候忽略了 Dirty 和 Access 位的处理,Spec 表明可以有两种行为,一种是不设置 A/D 访问的时候就直接触发页错误,另一种是由硬件在访问的时候写页表 A/D 位。很显然,第一种是把工程量留给了软件,而根据 C906 手册,采用的就是第一种。很简单,直接暴力把所有可读页标上 A,把可写页标上 D 。

这样改完之后能触发异常了,爆访存错误。百思不得其解,改了很久启用 MMU 的逻辑,终于发现了一点蛛丝马迹:

alt text

可以看到,写入 SATP 之后读出来是好的,但是期望值居然变成了0,妥妥的访存错误。遇事不决,手册先行。查了 C906 用户手册,注意到地址转换过程有这样的一句话:

若得到叶子页表,但:访问类型违反 A/D/X/W/R/U-bit 的设置,产生对应的 page fault 异常;若三 次访问结束仍未得到叶子页表,则产生对应的 page fault 异常;若访问 Dcache/内存过程中得到 access error 响应,产生 page fault 异常。

这里提到 D-cache。很显然,我们开启 MMU 之前,TLB 是全无效的,一开 MMU,TLB 缺了就找内存要,但是这时候 D-cache 没有和内存同步,导致映射飞了。继续看手册,有给出 TLB 与 D-cache 同步的示例:

sd x4,0(x3) // update a new translation table entry
sync.is/fence.i // ensure completion of update operation.
sfence.vma x5,x0 // invalid the TLB by va
sync.is/fence.i // ensure completion of TLB invalidation and
// synchronises context
1
2
3
4
5

尝试把这几句加上,如下:

    bool enable(bool enable) override
    {
        if (enable)
        {
            csr_write(CSR_SATP, *(uint64_t *)(&_satp));
            asm volatile("fence.i \n"
                         "sfence.vma \n"
                         "fence.i \n");
            if (csr_read(CSR_SATP) != *(uint64_t *)(&_satp))
            {
                printf("Expected %lx, got %lx\n", *(uint64_t *)(&_satp), csr_read(CSR_SATP));
                csr_write(CSR_SATP, 0);
                asm volatile("fence.i \n"
                             "sfence.vma \n"
                             "fence.i \n");
                return false;
            }
            return true;
        }
        else
        {
            csr_write(CSR_SATP, 0);
            asm volatile("fence.i \n"
                         "sfence.vma \n"
                         "fence.i \n");
            return true;
        }
    }
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

果然过了。

# 隐藏的原子操作 BUG

MMU 启动好了以后,外设的探测正常了,不过又卡在进入多核模式之前的一条 amoswap.w.aq 指令上。这是一个原子扩展指令集 (A) 的指令,由 C++ 的 std::atomic 生成。看了异常,是 Store/AMO access fault,不是页错误。这时候就想,入口处也有一个原子加指令,没事啊,为啥到这里出问题了?

查完手册,也没找到原子操作的前置条件。 百思不得其解,我像个无头苍蝇一样撞,到处改到处试,终于怀疑到MMU,弄出了一个最接近的简单例子:

    asm volatile("lla t0,_mmutest \n"
                 "li t1,1 \n"
                 "amoadd.w t2,t1,(t0) \n"
                 "sw t2, (t0) \n" ::
                     : "memory", "t0", "t1", "t2");
    std::cout << "Enabling MMU..." << std::flush;
    if (!sysmmu->enable(true))
    {
        // std::cout << "Failed!" << std::endl;
        printf("Failed!\n");
        return K_EFAIL;
    }
    // std::cout << "OK!" << std::endl;
    printf("OK!");

    asm volatile("lla t0,_mmutest \n"
                 "li t1,1 \n"
                 "amoadd.w t2,t1,(t0) \n"
                 "sw t2, (t0) \n" ::
                     : "memory", "t0", "t1", "t2");
    printf("Tested.\n");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

这样测试,开 MMU 之前是好的,开完 MMU 就不行了。于是去原厂发了工单询问,考虑到清明放假,又自己搜了一把,发现了一篇博客 (opens new window),讲全志 D1 上边移植 Linux 遇到的问题:

考虑到昨天的测试表明产生异常的指令是原子操作指令, 导致异常的原因应当是mmu_ca条件不满足。此处需要说明的是, C906处理器核对RISC-V指令集进行了扩展, 在每个页表项的高4位中加入了地址属性的设置位, 包括该页的地址是否是外设地址(SO位)、地址是否开启缓存(cache) (C位)等。因此, !mmu_ca && ag_pipe_amo的含义是原子操作指令(AMO指令)仅能对开启了缓存的地址进行, 对别的地址进行会产生异常。进一步, mmu_ca条件不满足就说明对应页表项中的C位没有被置1。然而, 对于普通内存, 它应当是置1的, 昨天对于pthread的各种测试也验证了这一点。

这里就可以知道,手册上没有提及的是,原子操作指令(AMO指令)仅能对开启了缓存的地址进行,而这几个位在 RISC-V 特权级手册里有别的用途,大概率是 C906 依照的是旧版手册导致的。至于为什么必须开缓存才能使用原子指令,个人猜想是时序问题的限制。于是修改 MMU 相关代码,基于 Vendor ID 判定 CPU 型号,随后针对 C906 这样的变体,设置好 C 位。要注意的是,低 2G 空间通常是外设地址空间,一般不能缓存。

再次运行,成功通过!典型 Log:

See More
====================
Model: LicheeRv Nano
Compatible: cvitek,cv181x
Stdout: serial0
Bootargs: 
====================
CPU Timebase frequency: 25000000
CPU # 0
+ State : okay
+ XLEN  : 64
+ MMU   : SV39
+ Exts  : imafdvcsu
====================
Reserved memory: (0x80000000 - 0x80040000) (0x80000000 - 0x80100000) 
Available memory: (0x80000000 - 0x8fe00000) 
====================
Using SV39 MMU
Mapped 0 to 0 with prot 55
Mapped 80000000 to 80000000 with prot 23

SP: 805b7ea0
GP: 801ae888
Enabling MMU...OK!Tested.
Mapped 3ffff80000 to 801c1000 with prot 23
> Preparing to set SP: 0x4000000000
> Probing peripheral devices
...
Switching stdout to serial0
Hello from local driver!
> Booting harts... (Boot hart = 0)
> Switching to multicore mode
Timer interrupt for hart 0
Current Time: 106767962
Hello from hart 0!
Mapped ffffffc000000000 to 80247000 with prot 15
ECall from U-mode at 0xffffffc000000012
[UMODE] Hello, World! 114514
Timer interrupt for hart 0
Current Time: 131768029
Timer interrupt for hart 0
...
Software interrupt for hart 0
Resume from U-mode!
> Waiting for other harts to return...

Reached k_clearup, clearing up...
* Kernel heap usage: 614400
===== Test Kernel exited with 0 =====

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

# 后记

真不容易,整个折磨了三天,问了相关的老师(还挺感谢他的,虽然没完全猜对,但是方向还是好的),甚至问到原厂去了 qwq。(为啥每次都是发完 issue 啥的就找到解决方案啊啊啊啊)。不过说回来,还是挺好玩的,尤其是看到正常运行的那一刻,真的有很大的成就感。