引言

我们之前介绍的处理器/模拟器都是计算模型

真实的计算机如何与物理世界交互?

 

本次课内容:

  • 计算机和设备的接口
  • 设备模型
  • AM的IOE抽象
  • 游戏是什么

设备和计算机

不停计算的机器

你的NEMU/NPC只能跑cpu-tests, 具体地, 只能

  1. M中将需要计算的数据读到R
  2. R之间计算
  3. R的计算结果写回M

连程序结束都要靠运行时环境

 

虽然理论上已经能解决所有可计算问题

  • hanoi(64, 'A', 'B', 'C')
    • 印度传说: 移完64个圆盘后, 世界将毁灭
    • 即使没毁灭, 也输出不了 😂

 

如果要输出 “Hello World”, 还需要什么?

设备 = 计算机与物理世界交互的桥梁

用户需要通过设备来使用计算机

  • 触摸屏
  • 显示屏
  • 扬声器
  • 麦克风

  • 操作面板: 开关, 按钮等
  • 读卡器: 输入指令卡片
  • 打印机: 输出数据卡片

设备的内部结构

设备 = 电气部分 + 数字部分(设备控制器)

设备控制器

控制设备工作的部件

  • 一侧连处理器, 接收来自处理器的命令
    • 接口 = 总线
  • 一侧连电气部分, 给电气部分发送命令
    • 接口 = 数/模转换芯片或电缆

可以看成一个将处理器的二进制命令翻译成电气信号的部件

 

主板和总线

这块主板很老了, 还没有PCI-e(2003年)

  • 总线的各种表现形式: 电缆, 插槽, 主板走线, 引脚打线, 芯片内部绕线

芯片封装 - 第三期 “一生一芯”

把芯片的端口信号引出来

版图 ===生产=> 裸片 ===封装=> 芯片

 

芯片引脚上的信号通过板卡走线连到设备

  • 如flash颗粒的引脚

市场主流主板

老主板通常配备芯片组: 北桥, 南桥

  • 北桥连接高速设备, 如DDR, 显卡, PCI-e等
  • 南桥连接低速设备, 如BIOS, 磁盘, USB, 网卡, 声卡等

 

现在芯片集成度大幅提升

  • DDR控制器, PCI-e控制器, 显卡控制器都可以集成到CPU芯片内部了
    • 不需要北桥芯片了
  • PCI-e/USB流行, 可接入大部分设备
    • 连南桥芯片的设备越来越少了
    lspci -vt
    lsusb -vt

设备模型

设备控制器里面有什么?

与CPU相关的3个重要功能:

  • 数据交换 - 数据缓冲寄存器
  • 命令控制 - 控制寄存器
  • 状态检测 - 状态寄存器

还可能有很多其他部件:

  • 用于控制设备电气部分的逻辑
  • 甚至还可能包含处理器, 例如SSD控制器需要进行复杂的存储管理
  • 甚至还可能就是处理器, 例如GPU包含多个处理单元以及GDDR

 

对CPU来说, 这些寄存器就是设备功能的抽象

  • CPU只要访问设备寄存器即可控制设备工作, 无需关心设备的内部实现
  • I/O控制逻辑会把CPU发送的命令翻译成设备的物理操作

设备功能 = RTFM

Xilinx UART 16550 v2.0 LogiCORE IP Product Guide

开发者(例如SoC工程师)只需RTFM即可开发底层软件, 无需了解设备的电气特性

设备寄存器的编址

为了让CPU指定访问哪个设备寄存器, 需要给它们编号

         +------+ 0xfffff
         |      |
         |      |
         |      |
         |      |
         |      |    +------+ 0xffff
         |      |    |@@@@@@|
         +------+ 0  +------+ 0
          memory      device

独立编址, 内存和设备的地址空间不同, 需要新I/O指令指定访问后者;
也称port-mapped I/O(PIO)

               +------+ 0xfffff
               |      |
               |      |
               +------+
               |@@@@@@| device
               +------+
               |      |
               +------+ 0
                memory

统一编址, 内存和设备的地址空间相同, 根据访存地址决定要访问什么; 也称memory-mapped I/O(MMIO)

若内存地址空间很小, PIO是不错的设计方案

RISC指令集一开始用于大型机/超算, 内存地址空间很大, 自然用MMIO

movl $0x41, %al
movl $0x3f8, %edx
outb %al, (%dx)
#################
li  t0, 65
lui a0, 0x40000
sb  t0, 0(a0)

MMIO编程

#include <stdint.h>
int main() {
  const int BUSY = 0x0;
  uint8_t *status = (uint8_t *)(uintptr_t)0x400;
  uint8_t *data = (uint8_t *)(uintptr_t)0x404;
  while (*status == BUSY); // wait until idle
  *data = 0;
  return 0;
}
riscv64-linux-gnu-gcc -march=rv64g -O2 -c a.c
riscv64-linux-gnu-objdump -d a.o

0000000000000000 <main>:
   0:   40004783   lbu  a5,1024(zero) # 400 <.L5+0x3f4>
   4:   00079463   bnez a5,c <.L5>

0000000000000008 <.L4>:
   8:   0000006f   j    8 <.L4>

000000000000000c <.L5>:
   c:   40000223   sb  zero,1028(zero) # 404 <.L5+0x3f8>
  10:   00000513   li  a0,0
  14:   00008067   ret

MMIO的新问题

回顾: 在满足程序的可观测行为(observable behavior of the program, C99 5.1.2.3节第6点)一致性的前提下, 编译器可以进行任意优化

 

新问题: 访问设备会改变其状态, 从而影响程序行为

  • 盲目优化将违反上述一致性, 但编译器不知道哪些操作会访问设备

只好把锅甩给程序员了:

  • 访问设备时通过volatile标识, 告诉编译器这个访问要严格执行
volatile uint8_t *status // ...
volatile uint8_t *data   // ...
0000000000000000 <main>:
  0:   40004783   lbu  a5,1024(zero) # 400 <main+0x400>
  4:   fe078ee3   beqz a5,0 <main>
  8:   40000223   sb   zero,1028(zero) # 404 <main+0x404>
  c:   00000513   li   a0,0
 10:   00008067   ret

回顾 - 计算机系统是个状态机

  • 状态集合S = {<R, M>}
    • R = {PC, x0, x1, x2, ...}
      • RISC-V手册 -> 2.1 Programmers’ Model for Base Integer ISA
      • PC = 程序计数器 = 当前执行的指令位置
    • M = 内存
      • RISC-V手册 -> 1.4 Memory
  • 激励事件: 执行PC指向的指令
  • 状态转移规则: 指令的语义(semantics)
  • 初始状态S0 = <R0, M0>

 

如何从状态机模型理解输入输出?

输入输出的状态机模型

挑战: 设备的状态受物理世界的影响, 难以建模

折中: 通过引入状态机的不确定性, 仅对I/O指令的行为进行建模:

 

  • 执行普通指令时, 按照TRM模型转移状态
  • 执行设备输出指令(如x86的out指令或RISC架构的MMIO写指令)时
    • 除了更新PC之外, 其他状态均保持不变
    • 但设备状态和物理世界会发生相应变化
  • 执行设备输入指令(如x86的in指令或RISC架构的MMIO读指令)时
    • 状态机的转移将会 “分叉”: 由执行这条指令时设备的状态决定次态
      • 如读入的按键取决于用户如何操作键盘: 按下/释放/无按键

AM中的IOE

  • 不同指令集访问设备的方式有所不同: PIO vs. MMIO
  • 设备型号也可能不同: UART 16550 vs. Xilinx UARTLite
  • 即使型号相同, 地址也可能不同: “一生一芯”SoC vs. 哪吒D1开发板

 

老规矩, 加个抽象层: IOE (I/O Extension)

  • 提供 “抽象设备寄存器”
  • 提供3个API
void ioe_read(int reg, void *buf);
void ioe_write(int reg, void *buf);
bool ioe_init();
// abstract-machine/am/include/amdev.h
AM_DEVREG( 1, UART_CONFIG,  RD, bool present);
AM_DEVREG( 2, UART_TX,      WR, char data);
AM_DEVREG( 3, UART_RX,      RD, char data);
AM_DEVREG( 4, TIMER_CONFIG, RD, bool present, has_rtc);
AM_DEVREG( 5, TIMER_RTC,    RD, int year, month, day, hour, minute, second);
AM_DEVREG( 6, TIMER_UPTIME, RD, uint64_t us);
AM_DEVREG( 7, INPUT_CONFIG, RD, bool present);
AM_DEVREG( 8, INPUT_KEYBRD, RD, bool keydown; int keycode);
AM_DEVREG( 9, GPU_CONFIG,   RD, bool present, has_accel; int width, height, vmemsz);
AM_DEVREG(10, GPU_STATUS,   RD, bool ready);
AM_DEVREG(11, GPU_FBDRAW,   WR, int x, y; void *pixels; int w, h; bool sync);
AM_DEVREG(12, GPU_MEMCPY,   WR, uint32_t dest; void *src; int size);
AM_DEVREG(13, GPU_RENDER,   WR, uint32_t root);
AM_DEVREG(14, AUDIO_CONFIG, RD, bool present; int bufsize);
AM_DEVREG(15, AUDIO_CTRL,   WR, int freq, channels, samples);
AM_DEVREG(16, AUDIO_STATUS, RD, int count);
AM_DEVREG(17, AUDIO_PLAY,   WR, Area buf);
AM_DEVREG(18, DISK_CONFIG,  RD, bool present; int blksz, blkcnt);
AM_DEVREG(19, DISK_STATUS,  RD, bool ready);
AM_DEVREG(20, DISK_BLKIO,   WR, bool write; void *buf; int blkno, blkcnt);
AM_DEVREG(21, NET_CONFIG,   RD, bool present);
AM_DEVREG(22, NET_STATUS,   RD, int rx_len, tx_len);
AM_DEVREG(23, NET_TX,       WR, Area buf);
AM_DEVREG(24, NET_RX,       WR, Area buf);

AM上的程序可通过ioe_write(UART_TX, &data);往串口写入数据

  • 但具体如何写入, 由相应架构的AM实现, 程序无需关心

设备选讲

GPIO

最简单的设备, 可传输1bit数据, 如拨码开关/LED灯

  • 只需实现1bit数据寄存器, 无需额外的状态和控制
    • 若输出, 则将该寄存器连到芯片引脚
    • 若输入, 则将芯片引脚连到该寄存器

 

但由于在模拟/仿真环境中不便使用, AM的IOE未提供GPIO的抽象

串口

简单的设备, 可双向传输字符数据

  • 接收和发送各需要8bit数据寄存器
    • 但传输比CPU慢, 波特率115200几乎是最快
      • 再快容易干扰(电气特性)
    • 为了缓冲数据, 一般在控制器中实现fifo队列

RS232接口很少见了, 一般在板卡上放一个转换芯片出USB接口

  • 状态寄存器 - 目前是否可发送/接收? fifo是否满/空?
  • 控制寄存器 - 设置波特率等

 

AM的IOE屏蔽大部分细节, 只提供UART_TXUART_RX两个抽象寄存器

  • NEMU/QEMU中的串口无需考虑波特率等电气特性, 永远就绪

时钟

很简单的输入设备, 本质是一个靠时钟信号驱动的计数器

  • 只读数据寄存器, 可直接读出计数器的值
    • 软件可根据时钟频率, 将计数器值换算为时间单位
  • 一些复杂的时钟设备支持更多功能
    • 如计数的频率, 当前年月日时分秒, 产生中断等

 

 

AM的IOE只提供TIMER_RTCTIMER_UPTIME两个抽象寄存器

键盘

常见的输入设备, 通过电容变化识别按键情况

  • 只读数据寄存器, 可读出用户按下/释放按键的键盘码
    • 持续按键时会一直发送通码, 释放按键时会发送断码
    • 后者与串口不同, 因此串口无法识别是否有多个键同时按下
      • 所以用串口当输入设备玩游戏体验会下降 😂
    • 软件可通过查一张固定的映射表将键盘码翻译成按键信息
  • 状态寄存器 - fifo中是否有按键信息
  • 一些高级的键盘支持更多功能
    • 彩虹灯, 呼吸灯, 甚至可编程

 

AM的IOE只提供KEYBRD抽象寄存器

  • 若无按键, 则编码为KEY_NONE, 省掉状态寄存器

显卡

常见的输出设备, 将像素编码转换为RGB模拟信号在屏幕上显示

  • 数据寄存器 - 显存(其实是一段存储器)
    • 存放需要显示的像素信息
  • 控制寄存器 - 屏幕大小, 渲染命令
  • 状态寄存器 - 是否渲染结束
  • 真实的VGA控制器支持更丰富的功能(FreeVGA project)
    • 颜色控制, 属性设置, 同步等

 

AM的IOE提供5个抽象寄存器, 目前只使用其中两个

  • GPU_CONFIG - 可读出屏幕大小
  • GPU_FBDRAW - 往显存写入矩形区域的像素信息

酷炫的游戏

实现刚才的IOE API就可以运行游戏!

在x86-nemu上运行超级玛丽

第三期 “一生一芯”低配版超级玛丽: 将不同颜色的像素转换成字符

第一期 “一生一芯”演示

在FPGA上烧录 “果壳”玩超级玛丽

  • 通过串口操作, 对玩家操作水平要求较高 😂

在NPC中支持IOE

用RTL实现设备的工作量并不小: 总线协议 + 设备控制器

不过如果只是想让CPU跑游戏, 让仿真环境支持一下就可以了

  • 好处 - 通过迭代式开发方便地定位bug
    • 先让CPU跑足够复杂的程序, 对CPU进行充分测试
    • 然后用这个CPU去充分测总线
    • 再用这个CPU和总线去充分测试cache
    • 最后用总线和cache去充分测试流水线

有同学一上来写流水线, 但只能跑几条指令或几个小程序

  • 然后就开始添加cache和总线, 最后一把跑仙剑, 全是bug
  • 还没有很好的基础设施辅助 😂
  • 这是写处理器的地狱模式

更科学的方式是写单元测试, 但大部分同学都不愿意去写的 😂

游戏

游戏 = 死循环

while (1) {
  wait_for_next_frame(); // TIMER_UPTIME
  process_key();         // KEYBRD
  process_game_logic();  // TRM
  update_screen();       // GPU_FBDRAW
}
  1. 根据FPS决定每帧的时长
  2. 读入玩家的按键信息
  3. 结合按键情况和游戏逻辑, 更新物体状态
  4. 将相应画面显示到屏幕上

 

这些真的可以通过TRM + IOE来实现!

  • 所有的游戏都是这样的死循环, 包括仙剑
    • 强烈建议RTFSC打字小游戏, 不到200行代码

酷炫的游戏效果是如何做到的?

全靠计算!

跳帧和优化

GPU - 专业渲染, 让CPU专注计算

  • 中端版 - 支持2D渲染, 贴方块(tiling)
    • CPU把方块准备好, 告诉GPU怎么贴(把哪块贴到哪个坐标)
  • 高档版 - 支持3D渲染, 有一条渲染流水线
    • 顶点, 几何转换, 光栅化, 纹理, 光照…

总结

Device Stack(设备栈)

  • 物理世界
    • 用户操作设备
  • 设备的电气部分
    • 将用户的操作转换成电气信号
  • 设备的数字部分
    • 将模拟信号转换成数字信号, 通过设备寄存器提供设备功能的抽象
  • CPU I/O指令
    • 访问这些设备寄存器, 让数字信号变成指令的操作数
  • AM的IOE抽象
    • 进一步提供I/O指令和常用设备的抽象
  • 程序
    • 用IOE的API编程, 实现游戏