前言

这是一篇关于ICS-PA的二周目学习记录. 作者在完成YSYX项目后编写此文, 因此内容可能存在遗漏或跳阶段的情况. 如有好的想法或疑问, 欢迎通过邮件与我交流.

目前YSYX项目使用的是riscv32e架构. 经过考虑, 本文及后续分享将基于riscv32e进行. 但在与riscv64riscv32有特殊区别时, 会特别说明.

任务

PA1的任务并不复杂, 主要以思考题为主.

思考题: 计算机可以没有寄存器吗?

我认为是可行的, 但实现起来可能会有一定难度. 假设使用内存替代寄存器, 性能会显著下降, 且编程时需要频繁操作内存地址, 这无疑增加了编程的复杂性.

思考题: 尝试理解计算机如何计算.

基本流程如下:

  • PC初值为0, 取出地址0内的指令mov r1, 0, 执行后r1寄存器储存数值为0, 然后PC更新为1.
  • PC值为1, 取出地址1内的指令mov r2, 0, 执行后r2寄存器储存数值为0, 然后PC更新为2.
  • PC值为2, 取出地址2内的指令addi r2, r2, 1, 执行后r2寄存器储存数值为当前r2值加1, 然后PC更新为3.
  • PC值为3, 取出地址3内的指令add r1, r1, r2, 执行后r1寄存器储存数值为当前r1值加r2值, 然后PC更新为4.
  • PC值为4, 取出地址4内的指令blt r2, 100, 2, 执行后若r2小于100, PC更新为2.
  • PC值为5, 取出地址5内的指令jmp 5, 执行后PC更新为5.

编程题: 从状态机视角理解程序运行.

(  0,    x,  x) -> (  1,    0,   x) -> (  2,   0,    0) ->
(  3,    0,  1) -> (  4,    1,   1) -> (  5,   1,    2) ->
(  6,    3,  2) -> (..., ...., ...) -> (199, 4851,  99) ->
(200, 4950, 99) -> (201, 4950, 100) -> (202, 5050, 100)

思考题: kconfig生成的宏与条件编译.

MUXDEF主要通过拼接__P_DEF_macro后是否为__P_DEF_0__P_DEF_1来实现. 若成立, 则展开为X,, 在CHOOSE2nd中起到占位作用, 从而保留前者;否则, 展开后保留后者. 需要注意的是, macro必须是10, 其他内容无效.

思考题: 为什么全部都是函数?

将复杂或重复的功能进行分段封装, 有利于理清思路与后期维护.

思考题: 参数的处理过程.

操作系统提供的功能, 用于命令行参数的获取.

解决运行NEMU报错

[src/monitor/monitor.c:20 welcome] Exercise: Please remove me in the source code and compile NEMU again.
riscv32-nemu-interpreter: src/monitor/monitor.c:21: welcome: Assertion `0' failed.

根据报错信息, 可直接定位到src/monitor/monitor.c:20处的assert断言满足了条件. 找到对应代码后, 即可正常运行.

思考题: 究竟要执行多久?

cpu_exec本身接受一个参数, 表示运行的步数, 类型为uint64_t. 若传入-1, 会自动转换为64位整数的最大值(2^64 - 1). 一般程序并不会运行这么多指令, 因此可视为一直运行直到结束.

思考题: 潜在的威胁(无符号溢出是否未定义).

根据RTFM的结果, 有符号溢出是未定义的, 但无符号溢出不是.

思考题: 谁来指示程序的结束?

实际上, 在main函数前后都存在一些行为, 这些行为大多是操作系统为程序提供的支持, 例如参数的传入. 甚至可以通过一些系统调用, 在main函数结束后继续运行某些函数.

思考题: 有始有终(程序的开始与结束).

GNU/Linux上, 程序在execve系统调用开始时算作启动. 操作系统完成各项初始化后, 会进入main函数运行程序内的内容. 而在main函数结束并返回0后, 会调用exit系统调用, 操作系统开始回收资源, 结束整个进程.

编程题: 优美地退出.

判断nemu_state.halt_ret的值后, 再将nemu_state.state赋值为NEMU_QUIT.

编程题: 简易调试器.

  1. 单步执行
    • 函数void cpu_exec(uint64_t n)可以执行n步.
  2. 打印寄存器
    • 宏定义#define gpr(idx) (cpu.gpr[check_reg_idx(idx)])可返回对应寄存器的值.
  3. 扫描内存
    • 函数word_t vaddr_read(vaddr_t addr, int len)有相关实现.
  4. 表达式求值
    • 实现稍微复杂一点, 但很适合锻炼编程. 下面给出大致思路:
      • 完成各类符号的识别, 包括数字、运算符、括号、寄存器等. 若想功能更强, 可加入pccsr寄存器等. 特别注意类似* 乘号* 地址取值, - 减号- 负号同字符不同含义的情况.
      • 确定每一种运算的优先级, 大致排序为(具体情况请以手册为准): (括号)->(单目运算: 取反、取否、地址取值)->(乘除取余)->(加减)->(位移)->(比较)->(相等、不等)->(与)->(或)->(数字、寄存器、pccsr等)
      • 遍历整个表达式, 找到优先级最低的运算符处进行切分, 递归计算子表达式, 在递归最深处即为最高优先级子项, 递归返回结果即可. 单目三目运算与双目略有不同, 对应修改即可.
    • 下面为一个简单伪代码例子, 仅计算加减乘除:
/*
1*2+3*8/2切分顺序
递归层数| 分割点
1      |    +
2      |  *   *
3      | 1 2 3 /
4      |      8 2
*/
int expression_value(int start,int end) {
  if (start > end) {
    assert(0);
  }
  int cut_addr = cut_expression(start, end); // 找到最低优先级**运算符**
  if (is_pare_parenthesis(start, end)) { // 括号配对, 拆括号
    return expression_value(start + 1, end - 1);
  }
  else if (start == end) {
    switch (tokens[start].type) {
      case TK_NUM:
        answer = atoi(tokens[start].str);
        return answer;
      default: assert(0);
    }
  }
  else {
    switch (tokens[cut_add].type) {
      case TK_PLUS: return expression_value(start, cut_add - 1) + expression_value(cut_add + 1, end); break;
      case TK_TIMES: return expression_value(start, cut_add - 1) * expression_value(cut_add + 1, end); break;
      case ...;
    }
  }
}
  1. 监视点
    • 监视点实现比较简单, 主要是操作链表, 这里就不展示了.
    • 监视点自动停止运行只需要在static void trace_and_difftest(Decode *_this, vaddr_t dnpc)函数中添加判断函数并设置nemu状态即可:
if (is_change()) {
  nemu_state.state = NEMU_STOP;
}

编程题: 表达式生成器.

人生苦短, 我用Python.

必答题

必答题就不全都写了, 只完成部分:

  1. riscv32有哪几种指令格式?
  • 单讨论基础指令集的话, 有RISUBJ6种.
  1. riscv32的LUI指令的行为是什么?
  • 将立即数左移20位存入寄存器.
  1. riscv32的mstatus寄存器的结构是怎么样的?
  • 除去保留位, 每一位(两位)都表示某一个状态, 比较重要的有MIE[3], MPP[12:11], MPIE[7]等.
  1. 解释gcc中的-Wall和-Werror有什么作用?为什么要使用-Wall和-Werror?
  • -Wall: 启用警告, -Werror: 警告视为错误. 主要是为了提高代码质量, 减少潜在的错误.

小建议

后续任务100%会出现各类奇怪的bug, debug是必然的, 而且强度很高, 所以最好在PA1时就将sdb的功能做全一些.

除非你想当printf调试仙人.

下面给出作者为sdb写过的额外命令与扩展的功能:

  • 功能
    1. 直接回车执行缓存指令, 默认为上一条.
    2. 设置变量.
    3. 记录最近的n条命令.
    4. 参数解析
      • 输入字符串, 输出二维字符串数组指针.
      • 某一项参数若被单引号包裹, 意为为表达式, 计算结果数值存入字符串的前4字节(类型强转, 64位的话就是8字节).
  • 命令
    1. si指令变种
      • sr: 执行一步并打印寄存器.
      • sm: 执行一步, 若为访存, 则打印访存信息.
    2. 格式化输出, 增加颜色输出, 分割线等. 一行打印一个寄存器看起来不累吗
    3. history: 无参数时打印最近10条命令, 有参数则将缓存的指令替换为该参数指向的指令, 回车就能使用.
// 一个栗子
void isa_reg_display() {
  for (int i = 0; i < MUXDEF(CONFIG_RVE, 16, 32) / 4; i++) {
    for (int j = 0; j < 4; j++) {
      printf(ANSI_FG_GREEN "%-3s" ANSI_NONE ": " FMT_WORD "\t", reg_name(i*4+j), gpr(i*4+j));
    }
    printf("\n");
  }
  printf(ANSI_FG_RED "%-7s" ANSI_NONE ": " FMT_WORD "\n", "pc", cpu.pc);
  printf(ANSI_FG_RED "%-7s" ANSI_NONE ": "   "%d"   "\n", "mod", cpu.mode);
  printf(ANSI_FG_RED "%-7s" ANSI_NONE ": " FMT_WORD "\n", "mepc", cpu.csr.mepc);
  printf(ANSI_FG_RED "%-7s" ANSI_NONE ": " FMT_WORD "\n", "mcause", cpu.csr.mcause);
  printf(ANSI_FG_RED "%-7s" ANSI_NONE ": " FMT_WORD "\n", "mstatus", cpu.csr.mstatus);
}