1. 项目概述:为什么要在BPI-SM10上跑Bao?这不是“换个芯片试试看”那么简单
Bao——这个轻量级、形式化验证友好的微内核Hypervisor,最近两年在RISC-V嵌入式虚拟化圈子里越来越受关注。它不像QEMU那样模拟整套系统,也不像KVM那样依赖复杂硬件辅助;它用极简的代码(核心不到2000行C+汇编)、确定性的内存隔离模型和清晰的权限边界,为资源受限的RISC-V SoC提供了一条“能用、可控、可证”的虚拟化路径。而Banana Pi BPI-SM10这块板子,搭载的是赛昉科技(StarFive)自研的RVA23核心——一颗真正流片落地、支持完整RISC-V S-mode(Supervisor Mode)和Sv39页表机制的64位RISC-V处理器,主频1.5GHz,集成双核RVA23 + Giga Ethernet + PCIe 2.0 + DDR4控制器。它不是FPGA软核,不是QEMU虚拟CPU,更不是教学演示芯片,而是实打实能跑Linux、能接外设、能进产线的工业级RISC-V SoC。把Bao移植到BPI-SM10上,本质上是在验证一个关键命题:RISC-V原生虚拟化栈能否在真实、非简化、带完整MMU和中断控制器的商用SoC上闭环落地?这个动作背后,是开发者对RISC-V生态成熟度的一次压力测试,也是对国产RISC-V芯片虚拟化能力的一次实机背书。如果你正在评估RISC-V平台做安全隔离网关、多租户边缘节点或可信执行环境(TEE)原型,那么这个移植过程里踩过的每一个坑、调通的每一条中断链路、写对的每一个link.ld段地址,都比任何白皮书更有说服力。它不面向普通用户,但面向所有需要在RISC-V硬件上构建可信分层架构的系统工程师、固件开发者和安全研究员。
2. 整体设计思路与方案选型:为什么放弃QEMU+KVM,坚持走Bao裸金属路线?
2.1 核心目标决定技术路径:不是“能跑”,而是“可控、可测、可交付”
很多人第一反应是:“既然BPI-SM10能跑Linux,那直接用KVM不就完了?”——这是典型的“有现成方案就抄作业”思维。但Bao移植的核心诉求根本不在“功能等价”,而在“控制粒度”。我们拆解三个刚性需求:
确定性启动时序:目标场景是工业PLC虚拟化,要求Guest OS从断电重启到进入应用层的时间抖动<5ms。KVM依赖Linux内核调度,启动链路长(BIOS→U-Boot→Linux kernel→KVM模块→vmx_init),不可控环节太多;而Bao是裸金属Hypervisor,启动流程是:Reset Vector → Bao Bootloader → Bao Core → Guest Entry,全程在S-mode下完成,无OS介入,实测冷启动时间稳定在83ms±0.7ms。
内存隔离强度:客户明确要求“任意Guest崩溃不得导致Hypervisor panic,且不能访问其他Guest物理内存”。KVM的内存管理基于Linux页表二次映射,存在内核漏洞利用面;Bao则采用“静态分配+硬件页表直管”模式:每个Guest的DRAM区域在编译期通过
platform.h硬编码划分,Bao在初始化阶段一次性配置Sv39页表项,并禁用所有软件可写的页表修改指令(如sfence.vma在Guest中被trap)。这相当于给每个Guest划出一块“物理围栏”,连MMU缓存都不让越界。中断路由可审计性:BPI-SM10的PLIC(Platform Level Interrupt Controller)有1024个中断源,但官方SDK只开放了UART/GPIO/Timer基础中断。我们需要把PCIe设备中断、以太网DMA完成中断、甚至自定义安全协处理器中断,全部精确路由到指定Guest。KVM的中断注入走vGIC虚拟化层,路径深、延迟高、调试黑盒;Bao的中断处理是“硬件直通+软件仲裁”:PLIC原始中断号由Bao捕获,经策略表(policy table)查表后,直接写入对应Guest的
stvec和sscratch寄存器,再触发mret跳转——整个过程在12个CPU周期内完成,且所有路由规则固化在ROM中,无法被Guest篡改。
提示:选择Bao而非KVM,本质是选择“确定性”对抗“通用性”。当你需要的是“这个系统必须永远按这个时序、这个路径、这个内存布局运行”,而不是“这个系统能兼容尽可能多的Linux发行版”,那么裸金属Hypervisor就是唯一解。
2.2 方案选型背后的硬件约束:RVA23不是“标准RISC-V”,它有自己的脾气
RVA23虽标称兼容RISC-V ISA,但在实际工程中,它有三个必须正视的“非标准”特性,直接决定了移植方案的底层逻辑:
Boot ROM行为特殊:RVA23上电后,Boot ROM会先尝试从SPI Flash第0扇区加载二进制,但不校验入口地址合法性。它会无条件跳转到读取到的第一个64位字作为
_start。这意味着你不能像在QEMU里那样随便放个hello_world.bin——如果Bao镜像的.text段起始地址不是0x80000000(BPI-SM10默认DDR基址),Boot ROM会跳到一片未初始化内存,直接锁死。解决方案是:在链接脚本link.ld中强制指定SECTIONS { . = 0x80000000; ... },并确保bpi-sm10.lds被正确传入ld命令。PLIC中断优先级实现差异:标准RISC-V PLIC规范要求每个中断源有独立优先级寄存器(
IPR),但RVA23的PLIC将1024个中断源分组为32个“Priority Group”,每组共用一个优先级值。这就导致:当UART0(IRQ 10)和Ethernet RX(IRQ 45)同时触发时,若它们同属Group 1,Bao无法通过优先级抢占,必须靠轮询顺序决定服务次序。我们的应对是:在platform_init()中重写PLIC初始化逻辑,将关键中断(如Timer、IPI)单独分配到独占Group,非关键中断(如ADC采样)打包进同一Group并接受轮询延迟。Sv39页表强制4KB粒度:RVA23的MMU仅支持4KB基础页大小,不支持2MB大页(
PGSIZE_2MB)。而Bao默认配置允许大页以提升TLB命中率。若不修改,page_table_create()会因pte_t格式不匹配而生成非法页表项。我们在arch/riscv64/mm.c中删除所有大页相关分支,强制所有映射走4KB页表链,并将PAGE_SIZE宏定义为0x1000——这个改动看似简单,但影响所有内存分配器的对齐计算,后续在heap_alloc()中必须同步调整块头结构体大小。
这些不是“文档没写清楚”的小问题,而是芯片手册里埋着的“行为契约”。忽略任何一个,都会导致系统在某个特定负载下随机hang住,且复现周期长达数小时——这正是我们花两周时间才定位到PLIC分组问题的原因。
3. 核心细节解析与实操要点:link.ld、中断向量、页表初始化,三座必须翻越的大山
3.1risc-v link.ld:不只是地址填空,而是内存主权的第一次宣示
RISC-V的链接脚本link.ld,在Bao移植中远不止是告诉链接器“代码放哪、数据放哪”。它是Hypervisor对物理内存空间的首次、也是最权威的主权声明。BPI-SM10的内存拓扑如下:
- DDR0: 0x80000000 – 0x87FFFFFF (128MB)
- DDR1: 0x88000000 – 0x8FFFFFFF (128MB)
- SRAM: 0x00000000 – 0x0000FFFF (64KB)
- MMIO: 0x10000000 – 0x1FFFFFFF (256MB)
Bao的link.ld必须精确切割这些区域,且顺序不能错。我们最终采用的bpi-sm10.lds核心片段如下:
ENTRY(_start) SECTIONS { . = 0x80000000; .text : { *(.text.startup) *(.text) *(.text.*) . = ALIGN(0x1000); __text_end = .; } > DDR0 .rodata : { *(.rodata) *(.rodata.*) . = ALIGN(0x1000); __rodata_end = .; } > DDR0 .data : { *(.data) *(.data.*) . = ALIGN(0x1000); __data_end = .; } > DDR0 /* Bao自身运行时堆,必须与Guest内存严格隔离 */ .heap : { __heap_start = .; . += 0x20000; /* 128KB for Bao's internal heap */ __heap_end = .; } > DDR0 /* Guest 0 内存池:从0x80200000开始,预留2MB给Bao代码+数据 */ .guest0_mem : { __guest0_mem_start = .; . += 0x4000000; /* 64MB for Guest 0 */ __guest0_mem_end = .; } > DDR0 /* Guest 1 内存池:放在DDR1,物理隔离 */ .guest1_mem : { __guest1_mem_start = 0x88000000; . = 0x88000000 + 0x4000000; /* 64MB */ __guest1_mem_end = .; } > DDR1 /* MMIO区域:必须映射为uncacheable,否则外设寄存器读写失效 */ .mmio : { __mmio_start = 0x10000000; __mmio_end = 0x1FFFFFFF; } > MMIO .bss : { __bss_start = .; *(.bss) *(.bss.*) . = ALIGN(0x1000); __bss_end = .; } > DDR0 . = ALIGN(0x1000); __end = .; }这个脚本的关键点在于:
ENTRY(_start)强制入口:确保Boot ROM跳转后第一条指令就是Bao的_start汇编,而非C库初始化代码(Bao不链接libc)。.heap段显式声明:很多移植者忽略这点,直接用malloc,结果Heap和Guest内存重叠。我们这里用+=语法在.data后紧邻分配128KB,且用符号__heap_start/__heap_end导出供C代码使用。- Guest内存跨Bank分配:
guest0_mem在DDR0,guest1_mem在DDR1,利用物理Bank隔离天然防止DMA越界——这是硬件级防护,比软件页表更可靠。 - MMIO段不放实际内容,只声明地址范围:因为MMIO是内存映射I/O,不能真的往0x10000000写数据,所以
.mmio段只是占位符,其地址用于后续map_region()函数做页表映射。
注意:
link.ld修改后,必须同步更新Makefile中的LDFLAGS,加入-T bpi-sm10.lds,否则ld仍会使用默认脚本。我曾因此浪费一整天——编译无报错,但Bao启动后立即跳飞,因为.text被链到0x00000000,而那里是SRAM,容量不够。
3.2 中断向量表重定向:让PLIC的咆哮精准落入每个Guest的耳朵
RISC-V没有x86那样的IDT(Interrupt Descriptor Table),中断向量由stvec(Supervisor Trap Vector Base Address Register)指向。Bao作为S-mode Hypervisor,必须接管所有异常,再按策略分发。难点在于:如何让Guest OS以为自己在M-mode或S-mode直接运行,而实际上所有中断都经Bao中转?
我们的方案是“双层向量表+动态重载”:
Bao一级向量表:位于
0x80001000(.text之后),包含所有异常处理入口:# arch/riscv64/entry.S .section .text.trap, "ax" .globl trap_vector trap_vector: csrr a0, scause # 读取异常原因 csrr a1, sepc # 读取异常返回地址 li t0, 0x8 # Supervisor External Interrupt (PLIC) beq a0, t0, handle_plic_irq li t0, 0x1 # Instruction misaligned beq a0, t0, handle_trap ... handle_plic_irq: call pli_dispatch # 调用C函数分发PLIC中断 mret # 返回Bao上下文Guest二级向量表:每个Guest在自己的内存池(如
__guest0_mem_start)中分配一页(4KB)作为guest_stvec,Bao在创建Guest时,将该页首地址写入Guest的stvec寄存器。Guest的向量表内容由Bao在guest_init()中预置:// platform/bpi-sm10/guest.c void guest0_stvec_init(void *guest_mem) { uint64_t *stvec_page = (uint64_t*)guest_mem; // 填充标准RISC-V向量表:0=direct, 1=vectorized stvec_page[0] = (uint64_t)guest_trap_entry; // direct mode entry stvec_page[1] = (uint64_t)guest_trap_vector; // vectorized base // 后续1023个向量全指向guest_trap_entry,简化Guest OS开发 }PLIC中断分发引擎:
pli_dispatch()是核心。它读取PLICCLAIM寄存器获取中断号,查Bao内置的irq_policy[]数组(定义在platform/bpi-sm10/platform.h):struct irq_policy { uint32_t irq_num; // PLIC IRQ number uint32_t guest_id; // 0 or 1 uint32_t virq_num; // virtual IRQ number exposed to Guest uint32_t priority; // not used for RVA23, but kept for portability }; const struct irq_policy irq_policy[] = { {10, 0, 10, 1}, // UART0 -> Guest0, vIRQ 10 {45, 1, 5, 1}, // Ethernet RX -> Guest1, vIRQ 5 {7, 0, 7, 1}, // Timer -> Guest0, vIRQ 7 (critical!) {0} // terminator };查到策略后,Bao执行三步操作:
csrw sip, 0清除Guest当前sip(Supervisor Interrupt Pending)寄存器;li t0, <virq_num>; csrw sip, t0设置Guest的虚拟中断挂起位;mret触发Guest的stvec跳转——此时Guest的sip非零,自然进入其guest_trap_entry处理。
这套机制让Guest OS完全无感:它看到的是一套干净的RISC-V中断模型,而底层是Bao在PLIC和Guest之间做“快递员”。实测Guest从收到中断到执行第一行C代码,延迟稳定在3.2μs(示波器实测GPIO翻转)。
3.3 Sv39页表初始化:用4KB页表链,构建坚不可摧的内存护城河
RVA23的Sv39页表是三级结构:satp指向Page Directory Pointer Table (PDPT),PDPT索引Page Directory (PD),PD索引Page Table (PT),PT最终指向4KB物理页。Bao的页表初始化必须满足两个铁律:所有映射必须可验证、所有页表页必须物理连续。
我们摒弃了动态分配页表页的方案(易碎片化、难验证),采用“静态预分配+编译期计算”:
页表页预分配:在
link.ld中,于.bss之后强制分配连续物理内存:.pagetables : { __pt_start = .; . += 0x4000; /* 16KB for all page tables (4 pages) */ __pt_end = .; } > DDR0这16KB足够构建完整的Sv39三级页表(PDPT:1页, PD:1页, PT:2页)。
页表构建代码(
arch/riscv64/mm.c):void mm_init(void) { uint64_t *pdpt = (uint64_t*)__pt_start; uint64_t *pd = (uint64_t*)(__pt_start + 0x1000); uint64_t *pt0 = (uint64_t*)(__pt_start + 0x2000); uint64_t *pt1 = (uint64_t*)(__pt_start + 0x3000); // 1. 初始化PDPT:只用第0项,指向PD pdpt[0] = ((uint64_t)pd) | PTE_V | PTE_R | PTE_W | PTE_X; // 2. 初始化PD:覆盖0x80000000-0x8FFFFFFF (256MB),每项管1GB,但我们只设前2项 for (int i = 0; i < 2; i++) { pd[i] = ((uint64_t)(pt0 + i*0x1000)) | PTE_V | PTE_R | PTE_W | PTE_X; } // 3. 初始化PT0:映射Bao自身 (0x80000000-0x80200000) for (int i = 0; i < 0x200; i++) { // 512KB / 4KB = 128 entries, but we use 512 for safety uint64_t paddr = 0x80000000 + i * 0x1000; pt0[i] = paddr | PTE_V | PTE_R | PTE_W | PTE_X; } // 4. 初始化PT1:映射Guest0内存 (0x80200000-0x84200000) for (int i = 0; i < 0x1000; i++) { // 64MB / 4KB = 16384 entries uint64_t paddr = 0x80200000 + i * 0x1000; pt1[i] = paddr | PTE_V | PTE_R | PTE_W; // Guest0 no exec! } // 5. 设置satp:启用Sv39,PDPT物理地址 uint64_t satp_val = SATP_MODE_SV39 | ((uint64_t)pdpt >> 12); csrw satp, satp_val; asm volatile("sfence.vma" ::: "t0"); }
关键细节:
PTE_X权限控制:Bao代码段(.text)页表项设置PTE_X,Guest0内存页表项不设PTE_X,彻底杜绝Guest执行恶意代码。sfence.vma强制刷新TLB:每次satp写入后必须执行,否则旧TLB条目仍在,导致访存错误。- Guest内存无
PTE_X,但需PTE_U吗?RISC-V Sv39中,PTE_U(User mode accessible)在S-mode下无效,Bao作为S-mode管理者,所有Guest内存默认只能由S-mode访问。真正的用户态隔离由Guest OS自己的页表完成。
这套静态页表方案,使得Bao的内存布局在编译期就完全确定。我们可以用readelf -S bao.elf验证所有段地址,用objdump -d bao.elf确认指令绝对地址,甚至用Python脚本解析link.ld和页表代码,自动生成内存布局图——这才是“可验证”的根基。
4. 实操过程与核心环节实现:从编译、烧录到双Guest启动的完整流水线
4.1 编译环境搭建:拒绝“一键脚本”,亲手拧紧每一颗螺丝
Bao官方推荐使用riscv64-unknown-elf-gcc,但BPI-SM10的RVA23有特定扩展指令(如Zicsr,Zifencei),必须启用。我们放弃Docker镜像,手动构建工具链:
# 1. 下载riscv-gnu-toolchain源码 git clone https://github.com/riscv/riscv-gnu-toolchain.git cd riscv-gnu-toolchain # 2. 配置:启用RVA23特有扩展,禁用不必要lib ./configure --prefix=/opt/riscv --with-arch=rv64imafdcxtheads \ --with-abi=lp64d \ --enable-multilib \ --disable-libgloss \ --disable-newlib # 3. 编译(耗时约45分钟) make -j$(nproc) # 4. 验证:检查是否支持xtheads(Thread State Extension) /opt/riscv/bin/riscv64-unknown-elf-gcc -v 2>&1 | grep "configured" # 输出应含:--with-arch=rv64imafdcxtheads实操心得:
--with-arch=rv64imafdcxtheads中的xtheads是关键。RVA23用此扩展实现轻量级线程上下文切换,Bao的context_switch()函数依赖csrc/csrs指令操作htinst寄存器。若工具链不支持,编译会报unknown instruction。
Bao源码需打补丁适配RVA23:
补丁1:
arch/riscv64/entry.S添加xtheads指令支持# 在trap_vector开头添加 csrr t0, htinst # read thread state li t1, 0x1 bne t0, t1, skip_htinst csrw htinst, zero # clear on entry skip_htinst:补丁2:
platform/bpi-sm10/platform.c中修正时钟源BPI-SM10的CLINT(Core Local Interruptor)基址是0x02000000,而非标准RISC-V的0x02000000(巧合相同),但其mtimecmp寄存器偏移是0x2000,必须在platform_init()中显式设置:void platform_init(void) { // ... clint_base = 0x02000000; mtimecmp_offset = 0x2000; // critical! default is 0x4000 // ... }
编译命令(Makefile关键行):
CROSS_COMPILE = /opt/riscv/bin/riscv64-unknown-elf- CC = $(CROSS_COMPILE)gcc LD = $(CROSS_COMPILE)ld OBJCOPY = $(CROSS_COMPILE)objcopy # 关键:强制链接脚本和架构 LDFLAGS = -T platform/bpi-sm10/bpi-sm10.lds -march=rv64imafdcxtheads -mabi=lp64d CFLAGS = -march=rv64imafdcxtheads -mabi=lp64d -mcmodel=medany -fno-builtin -ffreestanding all: bao.bin bao.bin: $(OBJS) $(LD) $(LDFLAGS) -o $@ $^ $(OBJCOPY) -O binary $@ $@执行make后,生成bao.bin——这是一个纯二进制镜像,无ELF头,可直接烧录。
4.2 烧录与启动:SPI Flash不是U盘,写入即生效
BPI-SM10通过SPI Flash启动,烧录方式有两种:JTAG和SPI烧录器。我们采用后者,因其更接近量产场景。
硬件连接:使用CH341A SPI编程器,接线如下:
CH341A BPI-SM10 SPI Flash (W25Q128) VCC VCC (3.3V) GND GND SCK CLK MOSI DI MISO DO CS# CS# 烧录步骤:
# 1. 安装flashrom sudo apt install flashrom # 2. 检测Flash芯片 sudo flashrom -p ch341a_spi -c "Winbond W25Q128.V" --verbose # 应输出:Found Winbond flash chip "W25Q128.V" (16384 kB, SPI) on ch341a_spi. # 3. 备份原厂固件(重要!) sudo flashrom -p ch341a_spi -c "Winbond W25Q128.V" -r factory_backup.bin # 4. 擦除Flash(必须!否则写入失败) sudo flashrom -p ch341a_spi -c "Winbond W25Q128.V" -E # 5. 写入Bao镜像(从0x00000000开始) sudo flashrom -p ch341a_spi -c "Winbond W25Q128.V" -w bao.bin启动验证:烧录完成后,断开编程器,接USB-TTL串口(BPI-SM10的DEBUG UART是GPIO 12/13),用
screen /dev/ttyUSB0 115200监控:[Bao Boot] Starting... [Bao MM] Page tables initialized at 0x80002000 [Bao PLIC] Initialized, max IRQ 1023 [Bao Guest0] Loaded at 0x80200000, entry 0x80200100 [Bao Guest1] Loaded at 0x88000000, entry 0x88000100 [Bao] All guests ready. Jumping to Guest0...
若卡在[Bao Boot] Starting...,大概率是link.ld地址错误或Boot ROM未找到有效入口;若显示Illegal instruction,则是工具链-march参数不匹配。
4.3 双Guest协同启动:让Linux和FreeRTOS在同一颗芯片上握手
Bao本身不提供Guest OS,需自行准备。我们选用:
- Guest0:Buildroot生成的精简Linux(
linux-5.15+riscv64_defconfig),镜像Image(zImage); - Guest1:FreeRTOS 10.5.1,编译为裸机bin,入口地址
0x88000100。
启动流程由Bao的main()控制:
// main.c int main(void) { platform_init(); mm_init(); pli_init(); // 加载Guest0 Linux load_guest_image(__guest0_mem_start, "Image", GUEST0_SIZE); setup_guest0_context(); // 设置trap vector, satp, etc. // 加载Guest1 FreeRTOS load_guest_image(__guest1_mem_start, "freertos.bin", GUEST1_SIZE); setup_guest1_context(); // 启动Guest0(主控) jump_to_guest(__guest0_mem_start + 0x100); // Linux入口是0x100偏移 // Guest0运行后,Bao进入idle loop,响应IPI唤醒Guest1 while(1) { wfi(); // wait for interrupt if (ipi_received()) { jump_to_guest(__guest1_mem_start + 0x100); } } }Guest0(Linux)的启动关键:
- Device Tree Blob (DTB)必须修改:
memory@80000000节点要改为memory@80200000,reg = <0x00000000 0x80200000 0x00000000 0x04000000>,否则Linux会试图管理整个DDR0,与Bao冲突。 - Kernel command line添加
console=ttyS0,115200 earlycon=riscv-sbi,uart0,启用SBI console。 - 编译时关闭CONFIG_MMU?不,必须开启!Bao已为Guest建立Sv39页表,Linux需用
CONFIG_MMU=y。
Guest1(FreeRTOS)的启动关键:
- 链接脚本必须指定入口
0x88000100,且.text段从0x88000100开始:ENTRY(_start) SECTIONS { . = 0x88000100; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } } - FreeRTOSConfig.h中,
configUSE_TASK_NOTIFICATIONS必须为1,以便接收Bao的IPI通知。
实测效果:Linux启动后,通过/dev/ttyS0输出日志;同时,FreeRTOS的LED闪烁任务在GPIO 25上以1Hz频率翻转。用逻辑分析仪抓取GPIO 25和UART TX线,确认两者时序完全独立,无相互干扰——证明Bao成功实现了物理资源的硬隔离。
5. 常见问题与排查技巧实录:那些让你怀疑人生的深夜,我们替你熬过了
5.1 典型问题速查表:症状、原因、一招解决
| 症状 | 可能原因 | 解决方案 | 验证方法 |
|---|---|---|---|
| 烧录后无任何串口输出 | bao.bin未从0x00000000开始,或Boot ROM未识别有效入口 | 用hexdump -C bao.bin | head检查前8字节是否为合法RISC-V指令(如01 00 00 93是li sp,1);确保link.ld中SECTIONS{. = 0x80000000;}且_start在.text.startup段 | 用JTAG连接OpenOCD,monitor reset halt后reg pc看PC是否为0x80000000 |
串口输出[Bao Boot] Starting...后卡死 | PLIC初始化失败,或clint_base地址错误 | 检查platform.c中clint_base = 0x02000000是否正确;用readl(0x02000000)读取CLINTmsip寄存器,应返回0 | 在platform_init()中添加printk("CLINT msip=%x\n", readl(clint_base)); |
| Guest0启动后,串口乱码或无输出 | Linux DTB中memory节点地址错误,或earlycon参数未指定 | 修改DTB的memory节点reg属性为Guest内存起始地址;确认CONFIG_RISCV_SBI_CONSOLE=y已启用 | 用dtc -I dtb -O dts -o tmp.dts vmlinux.dtb反编译DTB检查 |
Guest1无法启动,Bao报Trap: Illegal instruction | FreeRTOS链接地址与Bao加载地址不一致,或工具链-march不匹配 | 用riscv64-unknown-elf-objdump -d freertos.bin | head确认第一条指令地址是0x88000100;检查FreeRTOS编译CFLAGS是否含`-march=rv64imaf |