声明 该文章作为一生一芯官方学习记录的补充,部分代码实现思路以及思考仅代表个人观点。 本文不立足于撰写“详细教程”、“通关秘籍”等类型的文章,也不包含考核答辩面经,仅仅作为个人记录留存。 如果您正参加该项目,且尚未完成相应任务点,请自觉退出!该行为可能与“一生一芯”项目官方所推崇的独立解决能力培养理念相违背!
📖
本文内容包括但不限于:
  1. 部分代码设计实现思路
  1. 踩坑记录
  1. 部分必做题、思考题观点整理
……

📝 预学习阶段

NVBoard

  1. sim目标通过定义FST宏可自由生成不同格式的波形文件
一生一芯官方推荐版本为Verilator 5.008 2023-03-04 rev v5.008,该版本下—trace-vcd—trace-fst不可用 新版本中已经弃用—trace参数,作者此处使用版本为Verilator 5.040 2025-08-30 rev v5.040-50-g0a9d9db5a 具体请参考官方手册:
 
  1. 键盘检测实验中作者觉得讲义中所写状态机不好理解,遂自行实现,依照 HDLBits 网站中的状态机描述风格:

NEMU(PA1)

主要内容是实现一个简易调试器
notion image

sdb_mainloop()解析

rl_gets()函数将整行命令读取到str中,strtok函数按空格分割出目标命令,在查找表中逐个对比,一旦匹配则进入相应的cmd_*逻辑中继续执行。

优雅的退出

主函数退出前会执行一个判断状态的函数:
故只需要在cmd_q中设置 NEMU 状态为退出即可:

表达式求值

  1. 隐式类型转换
完成表达式求值的核心任务就是不断处理各种非法表达式,确保计算结果格式与预期相符。
讲义中统一规定使用无符号格式进行计算,而C语言中执行字面量除法默认是有符号数除法,为了与测试脚本运行结果一致,将val1val2强转为有符号类型
  1. 表达式生成器
讲义中建议为在主函数中加入测试,而作者在这里单独新增一个test指令,在 NEMU 中执行 test 命令将自动生成 500 条表达式(自动丢弃掉非法表达式,例如包含除以 0 的表达式)。
同时为了避免生成过长的表达式,对递归深度和生成表达式长度进行限制,否则有可能除法段错误:
测试结果:

断点Bug

完成监视点内容之后,作者使用w $pc==0x8000000c设置断点到最后一条 ebreak 指令,结果如下:
发现非法指令被执行。经过 gdb 调试发现,执行结束 ebreak 指令后,NEMU 状态会被设置为END后安全退出,而扫描监视点时,命中监视点会将 NEMU 状态设置为STOP,导致后续读取到非法指令触发错误,解决方案也很简单,加一层状态判断即可。
另外,此处是用监视点模拟断点,因此程序代码运行时产生了两次中断(旧值不等于新值)。

C阶段

理解一条指令在 NEMU 中的执行过程

  • exec_once(&s, cpu.pc); 包含指令的取指、译码、执行、pc 更新,cpu 结构体保存全局状态,Decode s;为运行时上下文结构体,保存静态 pc、动态 pc、指令等相关元素。
  • exec_once 前四行:
  • 取指过程:
vaddr_ifetch函数到对应地址下读取32位长度的指令,然后将snpc+4,返回读取的指令。
  • 译码与执行过程:
这一长串宏直接使用 VS Code 插件自带的智能感知(Intellisence)展开为:(下文只保留一个INSTPAT)
有关于do {} while(0)的用法参考
有关于符号标记用法参考。从功能上来说,这种方式给出了一种高效快捷的代码跳转方式,一旦匹配则提前结束该代码块。
  • pattern_decode函数详解
这里i表示处理指令字符串的下标。空格被自动忽略,除0、1、?之外的字符会抛出异常,下面以“??????? ????? ????? ??? ????? 00101 11”为例子:
__key用于识别指令操作码,只对0和1感兴趣,最终值为0010111;
__mask用于生成掩码,?对应位置设置为0,其他对应设置为1,表示?区域不应参与匹配。
__shift对于一些不关心低位匹配的操作码,如110 ????,最终它的值为4,右移四位匹配。
micro64(0); 展开为macro32(0);macro32(32);,继续展开为macro16(0);macro16(16);macro16(32);macro16(48); ,最终变成macro(0);macro(1);macro(2);……;marcro(64); ,这种宏展开可以在编译阶段直接优化,减少for循环方式造成的运行时开销。
💡
这种思路的可实现性在于指令是提前就加载到代码中的,即编译阶段指令代码的内容就是确定的。而对于等待用户输入是程序运行时才能解决的问题。
接下来这行代码(((uint64_t)((s)->isa.inst) >> shift) & mask) == key 对指令进行匹配,匹配成功则提取操作码和操作数。
  • decode_operand函数详解
这里BITS宏进行位切片,提取出对应的寄存器。
src1R()宏展开之后长这样,很显然是对相应寄存器的赋值操作:
immI()宏展开之后长这样,提取I型指令的立即数,并做符号扩展:
之后便可以完成代码的执行,结束后返回0。

实现更多的指令

运行测试用例 mul-longlong.c 时出现 HIT BAD TRAP 的提示:
notion image
使用 gdb 调试,调用栈如下:
notion image
该语句导致halt_ret被置1:
这说明,执行 ebreak 时,$a0 寄存器的值为 1。打印当前s→pc的值为 0x80000140
接下来利用 sdb 在 NEMU 中设置对 $a0 的监视点:
查看反汇编,发现 check 函数执行导致 a0 被设置为 1
结合C代码和反汇编分析容易知道,check函数负责检查乘法计算结果和预期结果是否一致,说明我们乘法指令的实现存在问题
C11 标准(N1570)中指出: “When a value with integer type is converted to another integer type other than _Bool, if the value can be represented by the new type, it is unchanged.” 当一个整数类型的值被转换为除 _Bool 以外的另一种整数类型时,如果该值能够被新类型表示,则该值保持不变。 32位无符号数任意取值都能够被 64 位有符号类型的整数所表示,所以转换时值不变,高位作零扩展。如果作符号扩展,它的数值就被改变了。
此处实现方法的确有问题,对于mulh这种指令,src1src2如果是负数,类型转换后变成了正数,计算结果自然与实际不相符。于是对src1src2先使用宏函数SEXT()进行符号扩展,最后进行运算。最终修改如下:
再次运行成功通过:
notion image
完整测试如下:
notion image

使用 DPI-C 机制验证 RV32I 单周期 CPU

DPI-C 机制允许在 System Verilog 中直接调用 C 函数,或者导出 function 和 task 供 C 语言调用。
npc_trap();的实现:
使用 verilator 进行仿真
while内至少top→eval();两次,第一次计算由时钟沿触发产生的时序逻辑更新(clk 0→1,eval()触发 pc_o 的更新),第二次计算触发寄存器更新导致的组合逻辑更新(由新的pc_o取出下一条指令)。
这样波形上才能和事件驱动型仿真器一致。
top→eval();是根据输入激励计算输出
tfp->dump(contextp->time());是按当前时间轴记录一次波形 contextp->timeInc(1);是当前记录时间加一,三者必须共同配合才能仿真出完整波形
测试结果:
notion image

对 AM 的 Makefile 默认启动批处理模式NEMU

通过RTFSC得知,要在运行NEMU时传入参数-b
作者此处直接使用 remake 工具单步调试,发现最终ARGS变量的来源:
对应 Makefile 源代码位置修改如下:
考虑到对测试代码使用 sdb 的需求,作如下调整:

添加递归 remake 支持

起因是由于使用 remake 单步调试时遇到子项目的 make 构建就会直接跳过,如果想要调试子目标的 Makefile 就要中途退出一次,然后使用 remake 调试 Makefile.xxx,不太方便。后来发现了 nemu.mk 文件使用了 $(MAKE) 这个内置变量,查询了该变量相关用法,于是做如下修改:
修改前单步调试:
notion image
修改后单步调试:
notion image
内部的 Make 也会开启 Trace 模式,子目录的详细构建过程也被打印出来了。
参考文档内容

使用 C 语言解析 ELF 文件并实现 ftrace

主要完成:
  • 使用 elf.h 解析 ELF 文件的符号表,建立针对 FUNC 类型的查找表
  • 判断 call 指令和 ret 指令并打印跟踪日志
  • 修改 Makefile 使得运行 tests 时可以自动传入elf参数
ftrace 效果如下:
notion image

这里发现两个个问题:
  • C 代码最开始是 f0 调用了 f3,但是 ftrace 显示是 f0 调用了 f2
  • 讲义中提到的返回不对应问题
参考资料
The RISC-V Instruction Set Manual Volume I | © RISC-V
2.5.1. Unconditional Jumps
notion image
另外,根据手册和查阅反汇编代码,函数调用指令一般情况目标寄存器为 ra 寄存器,ret 指令在 RV32I 和 RV64I 中扩展为 jalr x0, 0(x1)

Difftest 实现

RISCV32 寄存器成员顺序定义:先是 32 位通用寄存器,然后是 PC 寄存器。
对比每个寄存器值和下一条指令地址是否一致即可,不过多赘述。
验证效果:尝试修改 inst.c 中几条指令的实现,比如先后修改 add、sub、sw、jal、bne,执行
add:
sub:
sw:
jal:
bne:

构建 NPC-Infra

总体思路

总代码量(整个 C.4 任务):
notion image
本文将介绍一种设计思路:将 NPC 编译为动态链接库 (.so),然后嵌入到 NEMU 中运行。让 NPC 负责执行指令,而让 NEMU 负责提供内存和外设服务。
核心思想是将 NPC 视为 NEMU 中的一个“执行引擎”。NEMU 原本使用的是解释器引擎,现在我们扩展它,使其能加载 NPC 硬件模型。同时为了最大程度实现解耦合,NEMU 文件夹下仅新增src/engine/npc文件夹,相关修改只涉及 Kconfig、Makefile 中将包含 NPC 目录下的构建脚本,其他修改均位于 NPC 目录下。NEMU 中可以通过 menuconfig 便捷切换后端执行引擎,以达到共用同一套前端设施(SDB, Trace, Device)的目的。

将 NPC 编译成动态链接库

最开始徒手链接源文件瞎折腾了几个小时,后来才发现 verilator 原生支持编译成一个动态链接库(—lib-create)(没好好 RTFM 的后果……),几行命令就搞定:

Difftest 支持

我们可以在 Kconfig 选项中增加以 NEMU 作为 REF 的选项,阅读源码发现此处有 CONFIG_DIFFTEST_REF_NEMU 的宏定义包裹,我们只需要在别的地方定义这个宏然后重新定义加载 .so 的加载路径即可。(更好的方式直接在 Kconfig 定义路径即可)

组合逻辑调用 DPI-C 接口的问题

运行示例程序时始终会在 Load 类指令发生内存访问越界错误:
notion image
使用 gdb 调试发现地址异常出现总是在上升沿时钟调用 eval() 之后出现(期间会出现多次调用的情况)。猜测有可能是组合逻辑毛刺问题。
bit.c
bit.c
recursion.c
recursion.c
另外由于代码是在 itrace 日志写入成功后加入环形缓冲区,此处出发错误的位置还在 isa_exec_once 函数内部,所以环形缓冲区指向的最后一条指令应该是问题现场指令的前一条。
最后拉起时钟后结算一次信号是为了实时更新指令结束后的寄存器状态,然而这也会导致 DPI-C 函数再次调用:
当前指令会将 a0 所指向的地址的值写入 a0,也就是说,eval() 执行之后 a0 这个寄存器的值会发生变化。
notion image
结果很显然,上升沿更新了寄存器状态,但是指令状态没有更新,这时继续调用 DPI-C 函数,从而出现内存异常访问(此处是将 NEMU 的 vaddr_read() 复用,这样就省去了后续再添加 mtrace 的任务)。解决这个问题需要想办法在上升沿之后停止对其继续调用。
那么第一次 eval() 时候也多次调用了该函数,它有无可能意外触发内存访问越界呢?多次试验貌似未发生这种情况,或许和 verilator 调用 C++ 的机制有关,具体原因暂时不得而知。
对于其他指令,add sub 等在第二个eval() 的执行下确实会进行组合路径传播,但是在下一次进入该函数又立即被新指令冲刷掉,而 npc_pmem_write() 本身被时序逻辑调用,显然不会受到影响。
⚠️
后续补充:
在对 NPC 做键盘设备支持时出现了新问题,vaddr_read函数会产生副作用(键盘数据队列移动),这时 DPI-C 函数必须只能调用一次,否则键盘永远读取不到有效值。为了暂时向单周期架构的 NPC 妥协,将 DPI-C 读取函数放在下降沿时钟触发。
参考资料

设备相关

设备的输入与输出

首先主循环中 execute() 函数负责 VGA 显示和键盘状态的更新:
对于其他设备,NEMU 在初始化时会为所有设备注册一个地址空间,设备相关信息(名称、地址范围、回调函数)会占有一个IOMap结构体:
后续凡是对设备地址空间的读写,都会对执行相应设备的回调函数以 “唤醒” 设备运行。 以定时器为例子,若指令出现 Load 类,则会调用paddr_read函数,在判断地址属于设备空间后,依次进入mmio_readmap_readinvoke_callbackrtc_io_handler负责向设备私有寄存器写入当前时间值,实际写入和读取的地址则是通过IOMap中的space成员变量进行管理
以上是从模拟器层面理解,接下来从 AM 层面: io_read 完成了几件事:定义设备结构体 → 对应地址读取 → 更新该结构体的值。IO 地址访问产生了 “副作用” (NEMU 中体现为设备回调函数的执行)。
💡
这里是 gcc 的 Statement expression 扩展特性,允许在表达式中使用循环、switch、和局部变量。上述代码最后的结果就相当于__io_param.us

读取定时器寄存器

notion image
官方手册明确指定了读取定时器寄存器的顺序,先读高位 → 读低位 → 循环校验,防止读取过程中发生了进位。
如果先读低位(有可能是 0x00000000_FFFFFFFF),读高位就容易读到0x00000001
⚠️
勘误:这里的伪指令是 RISC-V 中实际用于读取一个周期计数 CSR 寄存器的,常用于统计 IPC,和这里的 timer.c 的读取语境上完全不同,只不过本质功能都是计数。
device/timer.c中,只有读取高位时才触发一次更新,这意味着如果先读取低位,再读取高位,结果是前一时刻的低位 + 当前时刻的高位。
notion image
猜测讲义所说的坑有可能就是上述内容。
而讲义中的设备是模拟出来的,只有读取寄存器高位才会触发一次更新,因此不必读二遍校验。
microbench 跑分结果:
notion image

异常处理机制

实现流程

NEMU 运行至ecall 指令时,进入isa_raise_intr 函数调用,传入的参数依据手册内容,同时需要对 mstatus 寄存器状态更新以通过 difftest。而后该函数将返回cte_init中传给 mtvec 寄存器的异常处理代码的地址,使得 NEMU 跳转到异常处理区域。
🤖
以下内容由 NotebookLM 根据特权级指令手册内容总结:
触发 ecall(环境调用)指令时,mcause 的值不从任何通用寄存器(GPR)中取得
根据来源文档的详细解释:
  • 硬件自动设置:当发生陷阱(Trap)进入机器模式时,mcause 寄存器是由处理器硬件自动写入一个代码,该代码用于标识导致陷阱的具体事件。
  • 异常代码的来源:硬件根据 ecall 指令执行时处理器所处的特权模式,在 mcause 的 Exception Code 字段中填入由标准预设的固定数值:
◦ 如果 ecall 来自 U 模式(用户模式),mcause 被设置为 8
◦ 如果 ecall 来自 S 模式(监督员模式),mcause 被设置为 9
◦ 如果 ecall 来自 M 模式(机器模式),mcause 被设置为 11
  • 通用寄存器的角色:虽然硬件不从通用寄存器读取 mcause 的值,但在软件层面,执行环境接口(EEI)通常会规定使用通用寄存器(如 ABI 约定的 a0-a7)来传递具体的服务请求参数或系统调用号。这属于软件处理程序的逻辑,而非硬件填充 mcause 的机制。
总结mcause 的值反映的是“发生了环境调用异常”这一事件类型,其数值由硬件根据当前的特权级状态直接生成。
异常处理代码对应 trap.S 中的 __am_asm_trap,它主要执行下面几件事:
  • 入栈顺序:gpr, mcause, mstatus, mepc。
  • 其中传入给__am_irq_handle 的参数是通过将栈指针送入 a0 寄存器实现的,栈指针指向整个上下文结构体的首地址。
  • 在回调函数内部,根据 mcause 来确定事件类型(11 属于机器模式下的系统调用),进而根据 a7 寄存器来确定所属事件类型(NEMU 中根据 a7 == -1 确定属于 EVENT_YIELD)。接着事件结构体和上下文结构体被传入用户(AM层)设置的句柄中。
  • 另外需要注意,mepc 位置如果是 ecall 需要由软件进行手动更新 mepc 的操作。
  • mret之后,如果是 ecall 引起的异常则运行后续指令。
  • 注意到,恢复上下文时并没有加载 sp 寄存器,原因是 sp 寄存器已经通过mv sp, a0完成了上下文切换,它的值正是当前进程的私有栈指针。

让 Difftest 支持异常相应机制

首先需要在 difftest.cc 中对寄存器复制添加相应的支持,在cte_init中实现对 mstatus 的初始化,同时需要对 NEMU 中 dut.c 的寄存器比较函数做出额外的针对 CSR 寄存器的检查。

内核线程的切换(yield-os)

  • 创建上下文给进程管理单元,需要指定 mepc, mstatus, a0 (ABI 规范) 寄存器。
  • 内核进程执行前会从栈中弹出上下文,执行结束(触发自陷)后会将上下文压栈。
  • 对于一个还未执行的进程,gpr 中的值不重要,不需要一开始初始化,进程不会尝试调用一个未经初始化的通用寄存器。内核进程自陷后会保存当前上下文。
  • 切换的上下文是否和原来相同(也即是否要进行切换)由user_handle决定,他接受一个Event和一个Context*参数并返回一个Context* 。在 yield-os 中,user_handle始终将进程切换到另一个。
  • 关于为什么kcontext()要求不能从入口函数返回:普通函数的返回依靠 ra 寄存器,而入口函数依靠 mret 跳转,mret 会根据创建上下文时给出的 mepc 找到入口的地址。如果此时调用 ret 会返回到一个非法的 ra 寄存器指向的地址。

支线

给 NEMU/NPC 添加串口输入支持

核心代码框架基本参考 native 的串口读取、键盘设备的队列,以下分为几个部分介绍:
  1. NEMU 端
串口的输入/输出缓冲区寄存器已经实现,接下来只需要实现 LSR 寄存器的第一位,即该位指示队列中是否有数据。需要在回调函数添加对应的逻辑:
  • 根据头尾指针是否相等设置 LSR 寄存器值。
  • 实现类似 keyboard.c 中读取队列的逻辑。
  • 在初始化串口设备的位置修改终端标准输入流的文件描述符,需要设置为O_NONBLOCK(非阻塞式读取,这部分参考的 native 实现),然后在device_update函数中调用fgetc()函数即可实现无阻塞地读取终端输入字符。
notion image
  1. AM 端
根据 amdev.h 中定义的三个串口寄存器,大致需要实现以下逻辑:
  • 新增__am_uart_rx__am_uart_tx,并在 ioe.c 中补充声明与查找表内容。
  • 修改对应的 Makefile 脚本添加源文件。
  • 为了保持一致性,在 trm.c 中新增getch()函数。
  1. RTT BSP 端
_uart_getc 函数直接依赖 AM 层的 io_read 即可,一行代码。
io_read会实例化一个串口接收寄存器结构体然后通过回调函数从 NEMU 中的串口缓冲区获取一个字符,并填充该结构体。
NPC 侧逻辑大致差不多,不过多赘述。
🛠️
NEMU/NPC 下运行 RT-Thread 输入命令回车后终端会回显一次该命令本身,该现象对于功能暂无影响,后期再进行进一步处理。
notion image

简单优化 NEMU 性能(使用 perf 工具)

perf 是一个系统级性能分析工具,可用于评估代码性能,执行下列命令记录红白机模拟器运行时性能数据,退出后会在当前目录生成 perf.data 文件:
在此之前需要运行以下命令临时开放内核权限:
然后会进入一个图形化交互界面,选择第二项 CPU 性能核(采样数最多)
第二个界面可以看到性能热点列表,排名第一的占用性能 31.07%。
第三个界面有一些常用选项:
  • Annotate :如果你选中具体的函数按这个,perf 会尝试反汇编该函数,展示哪一条具体的汇编指令最耗时。
  • Zoom into ... thread:只看当前线程的性能开销,屏蔽其他线程的干扰。
  • Zoom into [vdso] DSO:放大看共享库。选择这个后,列表里就只剩下属于 [vdso] 的事件了。
notion image
notion image
notion image
反汇编可以发现耗时严重部分位于0000000000000869 <__vdso_gettimeofday@@LINUX_2.6-0x597> ,这部分正是 NEMU 中的获取系统时间的底层函数,位于device_update函数内部。
CPU 每执行一次指令都会调用一次 device_update
notion image
于是我们可以降低device_update 函数的调用频率,最简化的实现如下:
实际效果有较大提升,优化前操作跟手性差,声音输出较为杂乱。优化后更加流畅,声音更加连贯:(实际测试发现至少每隔 3 条指令执行一次get_time()可以使得该部分性能消耗排名降至第二)
优化前
间隔 4 条指令

NPC 性能优化( Verilator )

  • Kconfig 中配置波形生成开关,并在软件驱动侧使用宏包裹波形相关逻辑。关闭波形生成提速大约 3 倍左右。使用 —trace-threads 1开启独立线程波形追踪,速度提升大约 80%。
  • 开启链接时优化 -flto
  • 关闭断言 —no-assert
  • 开启 -O3 优化,速度提升较明显。参考手册部分:
notion image
  • 多线程:开启多线程速度反而下降。
🤖
Gemini-3.1-pro总结内容:
1. 如何开启
  • 编译参数:在调用 Verilator 时添加 -threads <N> (N 为期望的物理线程数,通常不超过 CPU 核心数)。
  • 代码修改零修改。Verilator 会自动在底层的 C++ 代码中插入多线程调度和同步锁,无需修改 Testbench(main.cpp)。
2. 底层原理
  • 依赖图分析:Verilator 会在编译阶段分析 Verilog 代码的信号依赖。
  • 任务拆分 (mtasks):将互不依赖的逻辑块拆分给不同的线程并行计算。
  • 同步屏障 (Barrier):在每个时钟边沿,跑得快的线程必须停下来等待跑得慢的线程,全员算完再进入下一周期。
3. ✅ 适合开多线程的场景(正收益)
  • 大规模设计:复杂的 SoC(包含 CPU、GPU、DDR 控制器等独立大模块)。
  • 多核处理器:各个核心之间大部分时间独立运行,交互极少。
  • 高度并行的算法:如多路独立运行的图像/信号处理流水线。
4. ❌ 绝对不要开多线程的场景(负收益/反向优化)
  • 单核小规模 CPU(如一生一芯 NPC):内部 ALU、译码、取指之间组合逻辑强耦合,无法拆分出独立的大块逻辑。
  • 长依赖链:信号 A->B->C 串行依赖的纯组合逻辑。
  • 重度使用 DPI-C:默认情况下,遇到 DPI-C 调用时多线程会退化为单线程排队执行,且跨语言同步开销极大。
  • 后果:线程花在“互相唤醒和等待锁”上的时间,远超并行计算省下的时间,导致比单线程还慢

B 阶段

评估单周期NPC的主频和程序性能

运行 microbench 的 train 规模测试,获得关键信息total guest instruction 为 195 142 896,对于单周期 CPU 来说这就是运行 microbench 的时钟周期。
notion image
在进一步评估之前,先使用 sv2v 工具把 sv 全部转化到一个大的 verilog 文件中,因为 yosys 对 sv 一些高级语法特性支持不太好。
yosys 综合后估算最大主频 2.36 GHz,如果固定这个主频下运行 microbench 大约只需要 80 ms,实际肯定不会是这么多,讲义中也提到了综合器优化等等相关的一系列问题。

重构 NPC

整体结构

按照经典五级单元重构基于分布式消息控制的处理器,并注入 SVA 语法,bind 分离可综合代码与验证代码,实现功能解耦。

问题记录

  1. 5.038 之后的版本断言是默认开启的,要禁用需要显式的参数 —no-assert
notion image
  1. 报错 %Error: unknown:0: Testbench C called 'dpi_get_pc' but scope wasn't set, perhaps due to dpi import call without 'context', or missing svSetScope. See IEEE 1800-2023 35.5.3. 查询 Verilator 手册得知需要在模块实例化之后,函数调用之前设置作用域:
notion image
  1. 指令译码在 default 分支使用的立即断言会在复位时误触发,于是添加断言门控并定义成宏函数,顶层引出 inst_valid 信号由 C++ 控制:

多周期 NPC

核心要点

从单周期迈向多周期处理器之后,C++ 侧的 npc_exec_once 的任务由翻转一次时钟变成了正确执行并提交一条指令,原本单周期做出的一些约定与妥协不能再使用。为最大程度解耦合,需要对于 NEMU 和 NPC 的交互做出一些约定:
  1. NEMU 的 isa_exec_once 外部理应认为 NPC 的指令是顺序提交的,这一点也符合 gdb 视角看真实运行的 CPU。NEMU 需要拿到正确的 dnpc 以及执行完一条指令后的所有新的架构状态。
  1. npc_exec_once 依然是提交一条指令,get_regs_from_npc 负责更新 NEMU 中架构状态(gpr 和 csr)。即退出该函数后,NEMU 需要看到 NPC 执行完成指令后所有的提交的新状态。
因此只要严格遵守运行完 isa_exec_once 之后,等效顺序执行一条指令并完成所有状态更新,即可适配 NEMU 的所有基础设施。

问题记录

  1. 波形记录:
ebreak 指令之前一刻记录的波形是 clk = 1,时刻是 4879 ps,但是实际打开波形图发现 4878 ps 低电平之后就没有记录了。查询得知原因不是仿真没执行到,而是最后一个采样点正好落在 trace文件结尾,例如最后一条记录在 4879 ps。波形查看器展示的是状态在一个时间区间内的持续情况,也就是 [当前采样时刻, 下一采样时刻)。如果 4879 ps 已经是最后一个采样点,那么这个最终状态虽然被记录了,但它后面没有“下一时刻”来形成可见宽度,所以在 Surfer 里通常看起来像“没有画出来”。
notion image
  1. 指令提交与 Difftest:
之前由于是手动送指令,可在指令周期结束后手动拉高一次时钟更新架构状态。多周期则将更多主动权交还给 NPC。最开始是在第一条红线处作为指令提交点(状态采样点均在右侧),该处对于非 load 类指令属于已经拿到指令译码,准备写回但还没写回成功;对于 load 类指令属于已经拿到了 load 数据,准备写回。因此如果在该处提交,对于 snpc 和 dnpc 的值是有效的,但是架构状态仍然没有更新,仍是上一条指令执行完毕的状态,不利于 SDB 还有 Difftest。故考虑将提交处放在第三根红线处,此时架构状态已经更新,但是 pc 也随之更新,因此在 clk=0 还有 clk =1 之间提交指令(第二根红线处)。
notion image
notion image
  1. 内存读写副作用
由于 load 类型指令需要一个额外周期,此期间如果继续保持读使能拉高,就会重复触发 DPI-C 函数,会触发一些对于设备访问的副作用(例如重复触发出队列缓冲区,这个问题此前已经提到过)。在数据已经读出后(state == LS_GET),需要拉低读使能。
notion image

AXI 总线接入

避免握手的活锁和死锁

相关内容主要在 Chapter A2 AXI transport 章节,重点关注 A2.3 Valid-Ready transport,包含传输规范以及不同通道的握手依赖规范。以下内容由 NotebookLM 根据手册内容总结:
单通道握手信号的基本约束
  1. VALID 不得依赖 READY:发送端在产生 VALID 信号时,绝对不允许等待接收端的 READY 信号响应后再断定是否有效。如果双方都在等待对方的信号,就会发生死锁。
  1. READY 可以依赖 VALID:接收端可以等待检测到 VALID 信号后再断定是否产生 READY 信号。
  1. 推荐 READY 默认置高:协议建议 READY 信号的默认状态为 HIGH,这样传输可以在单周期内完成,提高效率。
  1. 信号稳定性要求:一旦 VALID 信号被置为 HIGH,它必须保持稳定,直到握手成功(即 VALID 和 READY 同时为 HIGH 的时钟上升沿)。在此期间,地址、数据或控制信息也必须保持不变。
  1. READY 信号的灵活性:即使 READY 已置高,如果在 VALID 变为高电平之前,接收方有理由取消准备就绪状态,则允许将 READY 拉低。
通道间依赖关系约束
1. 读事务依赖关系
  • 必须遵守:从机必须等到 ARVALIDARREADY 同时置高(即地址握手完成)后,才能置高 RVALID 开始返回数据。
  • 严禁依赖
    • 主机不得等待 ARREADY 置高后再置高 ARVALID
    • 从机不得等待主机置高 RREADY 后再置高 RVALID
2. 写事务依赖关系
  • 主机端独立性:主机置高 AWVALIDWVALID 时,不得依赖于从机端的 AWREADYWREADY
  • 从机端响应约束
    • 从机可以等待 AWVALIDWVALID(或两者)之后再置高 AWREADYWREADY
    • 从机必须等到写地址和所有写数据都握手完成(且 WLAST 为高)后,才能置高 BVALID 发送写响应。这是为了确保写响应仅在事务彻底完成后给出。
  • 严禁依赖:从机不得等待主机置高 BREADY 后再置高 BVALID
 
  • Single-headed arrows point to signals that can be asserted before or after the signal at the start of the arrow.
  • Double-headed arrows point to signals that must be asserted only after assertion of the signal at the start of the arrow.
notion image
notion image
参考手册
notion image
值得注意的是,某一个手册版本之后移除了 AXI4-Lite 相关内容,只能去更旧版手册找。

单次阻塞式事务实现

这次主要做的事情,是把 IFU 和 LSU 都接到 AXI4-Lite 总线上,并分别连到指令侧和数据侧的 DPI 从机模型。设计选择了最简单的单次事务阻塞,目的是快速跑通整个流程,学习意义大于实用意义。
  1. 总体结构
  1. 接口解耦合 & 协议转换
起初是觉得将巨大的 AXI 状态机硬塞进 CPU 的模块中显得非常臃肿,遂想实现一个接口转换,面向内部实现一个精简化接口,并在外部实现一个 AXI 协议转换的模块,例如 Tilelink
至于从机,当前就直接和 DPI 函数调用地点放在同一个位置。
它的限制也很明确:
  • 每个桥只支持 one outstanding
  • IFU 是 blocking fetch
  • LSU 是 blocking LSU
  • 没有 request queue / store buffer / ID / out-of-order completion
📝
自己写的随机延迟测试用模板

仲裁器实现

这里直接对 AXI4-Lite 主机接口进行仲裁,由于目前 IFU 和 LSU 目前不会同时请求访问,所以按照固定优先级模式分配总线,谁的请求来了就把总线仲裁给谁。

Yosys 评估时序

评估最大主频为 397.867MHz,之前做第一次评估到 GHz 多半有点问题,可能是 sv2v 没转换完全。。。

总线互联矩阵

  1. 总体架构
xbar 的实现逻辑比较简单,直接根据上游的请求信号地址决定路由到对应的从机。如果地址译码不匹配任何一个从机,将会路由到 xbar 内部一个虚拟从机,直接返回 DECERR 的错误信号。目前 AXI 主机的实现是接受到错误信号直接触发立即断言终止仿真进程。
  • UART 和 CLINT 作为独立从设备挂在 xbar 下
  • 其他暂时没有单独建模的 MMIO 地址,先路由到 dpi_sim_sram,由内部的 DPI 函数处理其他设备的访问
  • 对于 MTIME,讲义提到需要根据仿真速率修改 AM 侧的代码,使得 io_read 读出的 us 数更接近真实的时间流逝。实际运行发现 mtime 读秒没有远远大于物理时间流逝,且随着各种调试开关仿真速率会时刻变化,故不对 AM 端侧代码进行更改。
  1. Difftest bug
在开启 difftest 的情况,由于之前访问设备的时候 通过 dpi 接口 paddr_write,而内部如果是设备访问会设置 is_skip_ref,从而跳过指令对 比。但是现在 sim_uart.sv 将 npc 的串口访问路由到 rtl 内部,副作用由$write()完成,因 此 difftest 会对设备访问指令进行检查,又由于参考模型 ref 不支持设备,导致 out_of_bound 触发物理地址越界。解决方案通过 dpi 接口设置一个跳过标志位即可。

ysyxSoC

输出自动刷新

notion image
UART 设备通过 $write() 系统任务输出单个字符,这时需要启用 --autoflush 选项,但官方手册仍然建议在 C++ 主循环中偶尔调用 fflush(stdout),启用该参数可能降低运行性能。

添加 AM 运行时环境

重新梳理 abstract-machine 文件夹下的 script 调用
终端输入 make ARCH=riscv32e-npc run 之后:
  1. 生成一个 Makefile.xxx$(ALL)用于选择参与编译的源文件对象
核心入口是 abstract-machine/Makefile
  1. ARCH 必须等于 abstract-machine/scripts/ 下某个 .mk 文件的文件名,否则会引发报错。之后内部会对 ARCH 的名称进行拆解,例如:
      • ARCH=riscv32e-npc -> ISA=riscv32e, PLATFORM=npc
      • ARCH=riscv32-nemu -> ISA=riscv32, PLATFORM=nemu
      依据解析出的 ARCH,包含对应的 .mk 脚本:
      • ARCH=riscv32e-npc 时,自动包含 scripts/riscv32e-npc.mk
      • ARCH=riscv32-nemu 时,自动包含 scripts/riscv32-nemu.mk
而这些 script/$(ARCH).mk 包含了 $(ISA).mk$(PLATFORM).mk 以及私有的源码和编译参数。
文件结构组织
有了上面的分析,如何添加 AM 环境相关的文件就比较清晰了,这里先按照最小化可以运行的配置添加,后续再逐步新增设备/功能的支持。
  1. am/src/platform/am/src/riscv/ 下添加两个 ysyxsoc 的文件夹:
    1. 新增 am/scripts/platform/ysyxsoc.mkam/scripts/riscv32e-ysyxsoc.mk 两个 Makefile 脚本,内容大致参考 NPC 和 NEMU 的,只修改源文件列表和链接脚本路径。
    链接脚本和启动代码详解
    启动代码:
    • 第一行定义 section name 为 entry,”ax” = alloc + executable 运行时需要分配/加载到内存 + 可执行。
    • 定义 _start 为全局符号,这样链接脚本中的 ENTRY(_start) 还有 LDFLAGS 中的 -e _start 才能找到该符号。
    • 第三行,指定 _start 的类型是 FUNC
      • notion image
    • _start:主要就是分配栈空间,然后进入 trm_init 函数,执行 main 后根据 ret 触发 trap
    链接脚本
    • 定义入口为 _start
    • MEMORY 字段划分物理内存空间,后面 > mrom 代表将该 section 划分到对应的物理空间
    • 相比于原本的 linker.ld 主要修改了栈指针和堆地址,_stack_top 从 sram 基地址开始连续划分 4 KB 的空间,剩余的空间作为 heap
    • 内存布局如下:
      值得注意的是,因为此时 .data 和 .bss 段被分配到 mrom,所以 全局变量不支持写入
      Program header 定义了程序可加载段,loader 解析 elf 文件的时候会用到(操作系统执行 elf 可执行文件、OpenOCD 烧录程序),对于当前阶段来说也并不是必须的,详见:
      支持全局变量的写入操作
      fib 测试中使用并写入了 全局变量,而在上一个 linker.ld 中,.data 被分配到了 mrom,因此触发断言 mrom 拒绝写入。为了支持全局变量的写入,很显然应该将 .data 放入 sram,但是修改完之后又遇到了一个问题:
      .bin 镜像达到了 200 多 MB,最终应该导致加载时 pmem 溢出了。
      关于这个问题,下面的网页都有提到:
      大概意思就是,.text 段被分配在 mrom 中,.data 被分配在了 sram 中,表面上看没有什么问题,实际上:
      “GNU Objcopy 工具生成 BIN 文件的过程,实际上就是把 ELF 文件中各个定义了 LOAD 属性的 SECTION 按照 LMA 的先后顺序提取出来,写入 BIN 文件中。SECTION 的地址如果不是连续的,间隔部分则会填充 DUMMY 或指定的数据。所以 BIN 文件的大小为最大 LMA 加上对应的 SECTION 的 Size。”
      查看 elf 文件的 section 地址,由于 .rodata.data 之间的间隔都被 DUMMY 数据填充了,所以会生成巨大的 BIN 文件。BIN 文件不自带地址属性,这样才能保证实际的数据加载到对应的物理地址中去。
      调整内存布局如下:
      .data 段的初始化数据保存至 mrom 区域(LMA),运行时进入主函数前需要由 bootloader 加载进 sram 区域(VMA)。.bss 段不需要加载进入 .bin 文件中,直接由 bootloader 在其对应的 VMA 位置清零即可。堆和栈的大致布局还是和之前一样。这样 .text 段和 .data 的数据存放是连续的,不会造成 .bin 大小异常的问题。
      bootloader 的实现很简单,在 trm.c 创建一个 C 函数分别调用 klib 的 memcpymemset 加载 .data.bss 段即可,在 _start 中添加 call __boot_loader 即可。
      大部分 cpu-test 测试都通过了,但是有极个别出现报错(wanshu and string)。打开 difftest 发现,报错指令均为 lbu 。后来意外发现一个现象,每次触发 difftest 断言的时候,日志中的 Expect 值是随机值:
      NEMU 上电时会随机初始化物理内存区域的数据,由此可以推断,这一块内存区域自运行后一直没有被写入过。简单测试中不涉及内存的分配与释放,所以不可能存在访问非法内存的问题,大概率是 bootloader 没有正确从 LMA 加载到 VMA。
      查看两个报错 elf 的节头表:
      断言指令 lbu 访存地址恰好分别是 0x0f0000000x0f000018 ,访问 section 为 .sdata.*,而 linker.ld 中 .sdata.* 并未作为 Input Section,因此最终加载内存时没有将这部分数据内容正确搬运,最终的 Output Section 中不包含该数据段。
      .sdata section 核心目的:把较小的全局/静态对象集中放在可由一个专用基址寄存器加短位移访问的区域,从而减少取地址指令数量,缩短代码,有时也提高访问速度。
      在 ysyxsoc.mk 中 LDFLAGS 追加 --print-map 参数查看链接器的链接过程:
      notion image
      主要关注舍弃的输入节,在 ysyxsoc.mk 中有以下参数:
      两个编译参数为每个全局/静态数据对象/函数分配独立的 section,这样才能配合后面的链接参数精准做 section 级别的垃圾回收,剔除未使用的函数/变量代码。

      symbol lookup error 问题解决记录

      在一次关闭设备选项运行 NPC 时,遇到报错:
      使用 LD_DEBUG=symbols 来查看 compressBound 的查找过程:
      可知,关闭 Device 选项导致 libz.so.1 库依赖缺失,更具体的,相关符号声明位于一个叫 zlib.h 的头文件中。
      后面查询得知,verilator 在执行 --lib-create 参数时不会自动添加 $(LDLIBS) 的链接参数,其中包含 -lz 以及多线程和原子操作的支持库(也就是说多线程在此前可能也没有真正起作用?)。只有在添加 --exe 参数生成最终可执行文件的时候,verilator 才会链接所有可用的第三方库。
      临时的解决方案是在 V{top_module_name}.mk文件构建 .so 目标中末尾追加 $(LDLIBS)
      使用 nm 命令查看 libnpc.so 中的符号表:
      U compressBound@ZLIB_1.2.0 U 表示这个库调用了该函数/变量,但是 在这个库自己的代码里没有它的实现。它期望在运行时,由加载它的主程序或者其他的动态库提供。

      Flash XIP

      状态机设计:is_xip 标志拉高表明 CPU 通过 APB 总线访问 Flash 的地址区域,状态机启动,在 SETUP ↔ ACCESS 状态之间流转依次向 5 个 SPI 内部寄存器发出写入请求。等待 SPI 中断信号拉高之后发起对 SPI Master RX0 寄存器的读请求,取得应答信号后拉高 APB 的 pready 信号,此时 APB 总线上的读数据有效。
      notion image
      📝
      这里其实明显还可以优化一下,第一次 state == SETUP && reg_cnt == 5 之后设置一个 cfg_done 的标志位,reg_cnt 复位值将被设置为 3,后续有 flash 请求时直接写两个寄存器 TX1 和 CTRL.GO 即可。
      附上非 XIP 的软件流程:
      在完成 XIP 内容的时候有两点容易忽视:
      1. flash.v 中会将读出数据的字节序翻转一次,符合小端内存按照地址递增读出字节的效果。例如仿真环境中内存一个字的数据是 0x11223344 ,SPI Master 的 RX0 中最后将会是 0x44332211XIP 还需要做一次字节序调整
      1. 当 SPI Master 的 ASS 控制位为 1 时,控制器会在传输期间自动驱动已选中的 SS 信号:传输开始后拉低对应片选,传输结束后释放片选。但 ASS 只负责自动控制片选时序,并不负责选择哪个从设备。因此软件仍需要先写 SS 寄存器,指定本次传输要选中的 SPI 设备。

      PSRAM

      状态机设计如下:
      状态转移主要来源于一个全局 sck 周期计数器 cyc_cnt
      notion image

      从 Flash 加载程序到 RAM

      按照加载数据段的方法,对 linker.ld 作如下修改:
      很显然这有一个致命的问题,一开始从 Flash 取指令加载代码段到 SRAM,然后跳到 SRAM 取指令,又来一次 bootloader …… 循环往复。期望的效果应该是,bootloader 之前的程序仍然存放在 Flash,用户代码将会被 bootloader 搬运到 SRAM 中执行,因此需要新增一个数据段,更正 linker.ld 和 start.S 启动代码。
      入口放在 .boot 段,分配临时启动栈,调用 _boot_loader 将用户代码段和数据都放入 SRAM 中,然后直接跳转到 _main(原来的 _start),后丢弃初始栈。
      在 linker.ld 的 Output Section 添加 .boot 段:
      运行发现取到了非法指令:
      查看反汇编发现 _boot_loader 还有memcpymemset 这类 klib 函数也是被分配到了 .text 段,于是使用 __attribute__ 显式声明,并为 boot 程序准备专用的两个内存操作函数。
      但事实上仍然有部分程序运行不成功。查看反汇编文件发现一些程序的入口函数 _start 并没有被排在最前面,SDB 调试也可以发现第一条指令是从 boot_memcpy 开始执行的:
      搜索相关资料后发现,入口函数虽然已经明确了为 _start ,分别在两处(linker.ld: ENTRY(_start) 以及 链接参数 -e),但并不保证生成的二进制文件一定被排在前面,只是指定了 elf header 的一个值。
      objcopy can be used to generate a raw binary file by using an output target of ‘binary’ (e.g., use -O binary). When objcopy generates a raw binary file, it will essentially produce a memory dump of the contents of the input object file. All symbols and relocation information will be discarded. The memory dump will start at the load address of the lowest section copied into the output file. objcopy 可通过使用输出目标“ binary ”来生成原始二进制文件(例如,使用 -O binary )。当 objcopy 生成原始二进制文件时,它本质上会生成输入对象文件内容的内存转储。所有符号和重定位信息都会被丢弃。该内存转储将从复制到输出文件中的最低段的加载地址开始。 —— 源于 objcopy 官方手册
      在一些嵌入式设备中,硬件复位时会读取中断向量表,正确找到程序的入口地址,因此入口地址不必是指令存储器的最开头。这里要注意区分 “硬件复位地址” 与 “程序入口地址”
      而对于目前的裸机程序就要求硬件复位地址等于程序入口地址。
      具体的解决方法是:给 .boot 段的入口单独分配一个 section,然后在 linker.ld 的 .boot output section 里把 .boot.entry 明确排在其他 .boot 段之前。
      二级加载思路比较简单,在 _start 调用 _fsbl_ssbl 相关的逻辑全部搬运到 SRAM,再直接跳转到 _ssbl_entry ,最后跳转到 _main。每次跳转都可以丢弃当前栈,因为加载阶段完成后不再需要返回了。
      linker.ld 将 fsbl 和 ssbl 单独划分 section:
      运行 RTT 加载时间很长,查看符号表:
      假设按照每秒 10000 条指令估算,大概需要 20 分钟。于是想到之前在 RTT 中通过命令运行了一些 AM 程序,这部分程序很可能占用了较大空间。
      🚨
      此外,上面表明 .srodata.* 没有被放入统一的 output section 中,这会导致 bootloader 不加载这些数据。

      SDRAM

      SDRAM 的逻辑相较于之前其他的存储器要复杂一些,所以单开一小节对控制器代码和仿真模型实现做一些记录。
      控制器部分
      模式寄存器值
      • A12 ~ A10:保留段,设置为 0
      • A9:可编程突发长度模式,只决定写入是否突发,读出仍然为突发
      • A8 ~ A7:Standard Operation
      • A6 ~ A4:列选通延迟,注册一个 READ 命令到输出数据可用之间的时钟周期延迟为 2
      • A3:顺序突发模式
      • A2 ~ A0:一次突发两个 16 bits,即 4 bytes
      notion image
      APB 地址解码
      上面三行等效为:
      • 列地址取总线上地址的九位,寻址范围 512
      • 行地址取总线上 [24:12],寻址范围 8192
      • 块地址取总线上 [11:10],寻址范围 4
      ⚠️
      此处需要明确 CPU 总线地址到 SDRAM 内部地址的转换。通常内存按照字节寻址,这款型号的 SDRAM 内部则是按照半字寻址
      如果把地址进行线性映射(DPI-C 访问数组),则是 {row, bank, col} ,索引走完一行之后跳到下一个 bank,走完所有 bank 后回到第一个 bank 的下一行,周而复始。
      对于位扩展,控制器的地址切分要改成这样:
      因为位扩展后 SDRAM 相当于按字寻址,所以总线地址清零低两位后再除以4。相应的,DPI 函数也要乘以 4 映射至按字节寻址的地址。NEMU 中仍然把扩展后 32MB * 2 的空间线性映射成数组,便于 SDB 访问。
      字扩展就更容易了,直接取总线地址上的第 26 位作为片选信号。此外还需要修改控制器行缓存的数量。但是考虑到做字扩展直接修改 verilog 代码比较复杂,必然还会在 Makefile 中添加大量的 sed 命令,所以还是直接选择修改 chisel 里面的端口定义。之前有点被讲义误导了,其实就算只写 verilog 也是可以修改端口定义部分的。
      仿真模型
      跨行访问测试中出现错误,波形中发现问题。
      首先对 bank3 row0 写入一个数据:
      notion image
      然后对 bank0 row1 写入一个数据:
      notion image
      由于是跨行访问,两次均发送 ACTIVE 命令。
      然后紧接着读取第一个地址,控制器并未发送 ACTIVE 命令,而是直接发送了 READ。而仿真模型中 addr_q 仍然是前一个行地址,读出无效数据。
      notion image
      RTFSC 控制器,想查看一下发出 ACTIVE 命令的条件:
      从代码可以看到,当前行地址如果已经在 active_row_q 被记录视为行命中,不会再次发送 ACTIVE 命令。控制器 每个 bank 都有独立的 row buffer,而自己的仿真模型仅仅实现了一个 row buffer。
      bug 原因找到。
      notion image

      接入 NVBoard

      首先需要添加 NVBoard 的构建规则,主要参考 nvboard/example 项目。
      基本构建流程是将 NVBoard 源文件编译成静态库 nvboard.a ,作为 verilator 编译的依赖之一。由于我这里是需要将 NPC 编译成动态链接库,所以需要在 nvboard 的 CXXFLAGS 加一条 -fPIC 。最后在 npc_api.cpp 中添加 nvboard 初始化、引脚绑定、状态更新、退出。
      python 脚本会读取约束文件,生成一个封装了所有引脚绑定的 cpp 文件,这个文件也需要添加到 verilator 的依赖项。
      测试过程中遇到的一些符号/文件未找到、未定义等问题基本都是源文件或者编译参数、链接参数不全导致的。
      Uart 手册中提到:
      The value set should be equal to (system clock speed) / (16 x desired baud rate)
      实际波特率等于时钟频率除以 16 再除以用户设置的 divisor,而 nvboard 中实际波特率就是时钟频率除以 divisor。因此 divisor (nv) = 16 * divisor (rtl)。此外还需要注意,nvboard_update() 应该时钟每翻转一次就调用,而不是提交一条指令才调用。
      串口输入大部分内容其实已经在 C 阶段最后的支线任务完成了,当时是通过 NEMU 底层设置终端文件描述符,非阻塞从终端获取字符输入的。这里则是通过 nvboard SDL 监测键盘输入事件,获取到字符按照 uart 标准协议发送到 ysyxSoC 顶层的 uart_rx 中。软件再读取 uart 的寄存器来获取到键盘输入到内存中。
      最后终于完成 SoC 计算机系统:完结撒花!
      性能:大约 48000 insts/s,microbecn test 规模
      notion image
      notion image

       
      使用 Remake 工具分析复杂 Makefile仿真过程的竟态条件
      Loading...
      Hyacimond
      Hyacimond
      Hyacimond
      公告
      🎉 本站已运行 20642 天 🎉
      🎉这是一个基于NotionNext搭建的个人博客🎉
      搭建于2025年10月3日
      -- 感谢您的支持 ---