C2 设备和输入输出

你已经在NEMU和NPC上都实现TRM了, 下一步当然是让它们支持输入输出了. 不过在这之前, 我们先来体验一下GUI程序的开发.

支持GUI输入输出的程序

你之前开发的C程序都只能通过终端进行输入输出. 那么, 我们平时常用的GUI程序是如何开发的呢? 为了给大家建立简单的认识, 我们先来让你体验红白机游戏的运行.

运行红白机游戏

阅读PA1的在开始愉快的PA之旅之前->NEMU是什么?开头部分的讲义内容, 按照讲义指示尝试运行红白机游戏, 并完成画面, 按键和声音的检查.

你一定会对红白机游戏具体如何运行感到好奇. 事实上, 游戏本身也是一个指令序列, 不过其中的指令属于一种叫6502的指令集. 这种指令集是上世纪80年代的时候问世的, 现在已经很少使用, 相应的处理器也不那么常见了. 但你刚才并没有在真正的6502处理器上运行游戏, 那游戏究竟是如何运行起来的呢? 刚才其实是先运行了一个6502指令集的模拟器程序fceux, 这个程序的行为十分特殊, 它可以通过软件的行为模拟6502指令执行的过程, 从而模拟出整个游戏执行的过程!

体验GUI输入输出

不过我们还是先来考虑GUI程序相关的内容, 后面再回过头来讨论模拟器. 我们引入一个叫abstract machine(简称AM)的运行时环境, 它以库函数的方式提供了一些API(Application Programming Interface), 用于实现一些在GUI中常用的输入输出功能. 刚才的fceux正是通过调用这些API, 来实现游戏的操作和显示. 但fceux的行为对大家来说还是太复杂, 我们还是先通过一些简单的示例程序来体会fceux用到的输入输出功能.

体验时钟功能

通过如下命令运行时钟测试程序:

cd am-kernels/tests/am-tests
make ARCH=native mainargs=t run

你会发现程序在终端上每隔1s输出一句话. 此外, 程序还会弹出一个画面全黑的新窗口, 但在当前程序中无任何功能, 目前你不必关心它.

体验上述功能后, 尝试阅读am-kernels/tests/am-tests/src/tests/rtc.c, 理解上述功能是如何实现的. 其中, 代码io_read(AM_TIMER_UPTIME).us将获得程序运行以来经过的时间, 单位是us.

体验按键功能

通过如下命令运行按键测试程序:

cd am-kernels/tests/am-tests
make ARCH=native mainargs=k run

你会发现程序弹出一个画面全黑的新窗口, 在新窗口中按下按键, 你将会看到程序在终端输出相应的按键信息, 包括按键名, 键盘码, 以及按键状态.

体验上述功能后, 尝试阅读am-kernels/tests/am-tests/src/tests/keyboard.c, 理解上述功能是如何实现的(目前你可以忽略代码中uart相关的功能). 其中, 代码io_read(AM_INPUT_KEYBRD)将获得一个按键事件ev, ev.keycode表示按键的编码, ev.keydown表示按键为按下还是释放. 按键的编码值可查阅abstract-machine/am/include/amdev.h, 它们均以AM_KEY_为前缀, 如A键的编码为AM_KEY_A. 特别地, AM_KEY_NONE表示无按键事件.

体验显示功能

通过如下命令运行显示测试程序:

cd am-kernels/tests/am-tests
make ARCH=native mainargs=v run

你会发现程序弹出一个新窗口并播放动画.

体验上述功能后, 尝试阅读am-kernels/tests/am-tests/src/tests/video.c, 理解上述功能是如何实现的. 其中, 代码io_write(AM_GPU_FBDRAW, x * w, y * h, color_buf, w, h, false) 表示向屏幕(x * w, y * h)坐标处绘制w*h的矩形图像. 图像像素按行优先方式存储在color_buf中, 每个像素用32位整数以00RRGGBB的方式描述颜色.

设计简单的屏幕保护程序

了解如何在代码中使用GUI输入输出后, 我们接下来将实现一个简单的屏幕保护程序, 来了解如何在AM上开发一个简单的GUI程序.

首先, 我们先尝试让弹出的窗口显示某种颜色. 我们给出如下的框架代码:

#include <am.h>
#include <klib-macros.h>

void draw(uint32_t color) {
  // change the code below
  io_write(AM_GPU_FBDRAW, 0, 0, NULL, 0, 0, false);
}

int main() {
  ioe_init(); // initialization for GUI
  while (1) {
    draw(0x000000ff);
  }
  return 0;
}

为了使用AM提供的功能, 我们需要编写一个符合AM规范的Makefile文件, 例如:

NAME = screensaver
SRCS = screensaver.c
include $(AM_HOME)/Makefile

如果你保存的C代码的文件名不是screensaver.c, 你需要按照实际的文件名修改上述Makefile文件. 然后通过以下命令编译运行:

make ARCH=native run

你应该能看到程序弹出一个画面全黑的新窗口, 这是因为你还没有正确实现void draw(uint32_t color)函数.

实现单种颜色的显示

实现上述的draw()函数, 它将弹出的窗口填充为参数color所指示的颜色, 窗口的分辨率为400x300.

实现后, 重新编译运行. 如果你的实现正确, 你应该看到弹出的窗口填充了蓝色.

我们需要实现的屏幕保护程序的效果是让屏幕在多种颜色之间发生渐变. 渐变算法需要在当前颜色和目标颜色之间线性插入种颜色, 并依次显示它们, 从而实现渐变效果. 假设当前颜色是, 渐变过程经过步后变化为目标颜色, 每个颜色分量的取值都是, 则第步(应该显示的颜色

当一轮渐变结束后, 就选择下一种目标颜色进行下一轮的渐变. 目标颜色可以在以下颜色中随机选择:

0x000000, 0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff, 0x00ffff, 0xffffff

我们只需要通过时钟相关的代码控制屏幕显示颜色的间隔, 即可实现渐变效果.

实现颜色渐变效果

按照上述介绍, 实现颜色渐变效果. 你可以自行决定每轮渐变持续多长时间, 以及一轮渐变中的步数.

最后, 让我们把按键的功能用起来. 具体地, 我们希望实现如下的按键功能:

  • 如果按下ESC键(按键编码为AM_KEY_ESCAPE), 则退出程序
  • 如果按下其他任意键, 则加快一轮渐变的时间; 释放按键后, 渐变时间恢复

添加按键效果

按照上述介绍, 为屏幕保护程序添加按键效果.

屏幕保护程序如何保护屏幕

过去, 人们通常采用CRT显示器, 其工作原理是通过电子枪发射的高速电子束轰击屏幕的荧光层, 从而使其发光来显示图像. 但如果屏幕显示的画面不变, 电子束将长时间轰击荧光层的相同位置, 会使得该位置的显像管加速老化, 甚至击穿显像管, 损坏屏幕. 屏幕保护程序通过不断变化屏幕上显示的图像, 从而不断改变电子束轰击屏幕的位置和强度, 避免损坏屏幕.

不过, 如今人们用的更多是LCD显示器(即液晶显示器), 其工作原理与CRT显示器不同, 过去的屏幕保护程序如今已经不能起到保护作用了. 上面的必做题也仅仅是作为一个简单的练习, 并不考虑其保护功能. 事实上, 屏幕显示的画面不变通常意味着用户长时间未对计算机进行操作, 此时最好的做法是关闭显示器.

实现屏幕保护程序之后, 你应该对GUI程序的开发过程有了基本的了解. 但是, 我们并没有进一步解释io_read()io_write()究竟是如何实现输入输出的: 为什么调用io_read(AM_INPUT_KEYBRD)就可以获得按键的信息? 为什么调用io_write(AM_GPU_FBDRAW, x, y, pixels, w, h, true)就可以往屏幕绘制像素? 在真实的计算机系统中, 这是需要多个抽象层次的模块之间相互协助才能实现的, 包括运行时环境, 指令集, 处理器, 总线, 设备控制器, I/O设备. 随着学习进度的深入, 你将会在B阶段了解到这个过程中的每一处细节.

开发自己的小游戏

如果你感兴趣, 你还可以利用上述API来开发一款属于自己的小游戏! 所谓小游戏, 其本质不过是一个死循环:

while (1) {
  等待新的一帧();
  处理用户按键();
  更新游戏逻辑();
  绘制新的屏幕();
}

你刚才开发的屏幕保护程序, 本质上也是这样一个死循环, 只不过其游戏逻辑非常简单, 只包含一个渐变算法的处理. 更多地, am-kernels还提供了一些小游戏的示例:

  • am-kernels/kernels/typing-game - 打字游戏
  • am-kernels/kernels/snake - 贪吃蛇

开发自己的小游戏

尝试构思并设计一个小游戏. 一方面, 将来这个小游戏可以运行在你自己设计的处理器上; 另一方面, 我们也长期征集好玩有趣的小游戏, 如果你设计的游戏足够优秀, 将有机会收录到我们维护的游戏集合中, 供其他同学进行娱乐, 甚至在真实的芯片上展示.

一些有用的资源:

  • 显示字符: am-kernels/kernels/typing-game/font.c提供了字符的位图, 请查阅相关代码了解如何使用
  • 显示图片: 可以通过命令convert abc.jpg abc.txt将一张图片转化成一个枚举像素的文本文件, 文件中按顺序列举了每一个像素的颜色编码, 你可以通过脚本进一步处理这个文本文件, 将其转化成一个C语言的数组

如果你计划向我们提交你设计的游戏, 可联系助教. 但为了保证你的游戏可以在其他同学的环境中运行, 请额外保证如下内容:

  • 可移植性:
    • 代码中不使用浮点数
      • 需要进行小数相关的计算时, 可放大到整数后进行操作, 例如, 要处理一个3位小数, 可将其放大1000倍后再处理
      • 需要计算sin等初等函数时, 可通过幂级数展开来近似计算
    • 需要64位整数类型时, 使用uint64_tint64_t
    • 需要使用整数类型存放指针时, 使用uintptr_tintptr_t
  • 可玩性: 进入游戏主体后, 如果玩家不操作, 一段时间后游戏将失败
  • 可退出: 按下ESC键后, 代码调用halt(0)退出

在NEMU中实现输入输出

根据PA讲义完成PA2阶段3, 直到你看到如下提示框:

温馨提示

PA2到此结束...

NPC中的输入输出

对于RISC-V架构, 输入输出是通过MMIO来实现的. 有了基于DPI-C的内存读写函数, 目前我们不必修改RTL代码就可以为NPC实现输入输出了! 我们只需要在这两个函数中对地址的范围进行简单的判断, 就可以将来自NPC的访存请求重定向到正确的设备了. 硬件上的MMIO是基于总线来实现的, 我们将来再实现真正的MMIO.

关于设备, 我们在这里不直接采用NEMU的设备模型, 而是为NPC在仿真环境中实现一套与将来流片SoC相近的设备模型. 实现总线后, 我们再来基于总线实现RTL版本的设备. 这时候, AM中IOE抽象的作用就体现出来了: NEMU和NPC的设备地址和设备模型都有所不同, 但经过抽象之后, 它们都可以运行同一份红白机模拟器的源代码, 更多地, AM上的所有程序都不必为不同的运行环境编写不同的代码.

在NPC中运行红白机游戏

大家已经完成了支持RV32IM和外设的NEMU, 并在NEMU上成功运行了红白机游戏. 同样, 我们也可以在RV32E的NPC上运行红白机游戏, 不过这首先需要实现串口和时钟.

我们知道RISC-V处理器通过MMIO访问外设, 例如在NEMU中串口会映射到0xa00003f8. 类似地, 我们也可以在NPC的仿真环境中实现简单的串口和时钟. 在上一节中提到, 我们通过DPI-C方式让NPC调用读写函数pmem_read()pmem_write()来访问内存. 和NEMU一样, 我们可以在这两个函数中添加若干判断来实现MMIO的功能, 伪代码如下所示:

extern "C" int pmem_read(int raddr) {
  // 总是读取地址为`raddr & ~0x3u`的4字节并返回
  if (raddr == 时钟地址) { 返回当前时间 }
  ...
}
extern "C" void pmem_write(int waddr, int wdata, char wmask) {
  // 总是往地址为`waddr & ~0x3u`的4字节按写掩码`wmask`写入`wdata`
  // `wmask`中每比特表示`wdata`中1个字节的掩码,
  // 如`wmask = 0x3`代表只写入最低2个字节, 内存中的其它字节保持不变
  if (waddr == 串口地址) { putchar(...) }
  ...
}

为NPC添加串口和时钟

在NPC仿真环境中实现串口的输出功能, 并运行hello程序. 为了和后面的SoC串口地址保持一致, 此处可将NPC的串口地址设置为0x10000000.

为NPC添加时钟

在NPC仿真环境中实现时钟, 并运行am-testsreal-time clock test测试. 可以基于系统时间来实现时钟的功能, 在C语言中与系统时间相关的库是什么, 以及如何获取系统时间, 就交给你来STFW了.

运行字符版本的红白机游戏

尝试在NPC中运行字符版本的红白机游戏. 但此时NPC仿真环境还不支持键盘输入.

最近更新时间:
贡献者: Zihao Yu