输入和输出(I/O)
简单的输入和输出
键盘和显示器
输入/输出(Input/Output, I/O)
键盘和显示器
- 字符设备
- 面向流的设备
- 字符依次读写
- 按照先后顺序
最简单的 I/O 设备通常至少包括
- 保存在计算机和设备之间进行传输的数据寄存器
- 例如键盘数据寄存器为 KBDR (keyboard data register) 中存储的是用户输入的字符的 ASCII 码
- 保存设备的状态信息的控制寄存器
- 设备是处于可用的状态还是正忙于执行最近的 I/O 任务,例如键盘的 KBCR (keyboard control register)
键盘设备寄存器
- 每个设备寄存器 32 位
- KBDR:[7:0] 位用来存放数据,[31:8] 位包含 x000000
- KBCR:[0] 位,就绪位
显示器设备寄存器
- 每个设备寄存器 32 位
- DDR:[7:0] 位用来存放数据,[31:8] 位包含 x000000
- DCR:[0] 位,就绪位
内存映射 I/O
设置或加载 I/O 设备寄存器中的数据两种机制
- 专门 I/O 指令
- 如 x86 指令集 in/out
- 通用寄存器 I/O 设备寄存器
- 数据传送指令
- 通用寄存器 存储器
- 内存映射 I/O
表示 I/O 设备寄存器的方式:内存映射
- 每一个 I/O 设备寄存器都被分配一个存储器地址空间中的地址
- 这些地址被分配给 I/O 设备寄存器,而不再是存储单元
例如 xFFFF 0000
到 xFFFF 00FF
被分配给 I/O 设备寄存器,其它地址分配给存储单元:
地址 | I/O 寄存器 |
---|---|
xFFFF 0000 ~ xFFF 0003 |
KBCR |
xFFFF 0004 ~ xFFF 0007 |
KBDR |
xFFFF 0008 ~ xFFF 000B |
DCR |
xFFFF 000C ~ xFFF 000F |
DDR |
异步问题
假如 x6 中保存了 KBDR 地址,则可以用
lw x11, 0(x6) |
将 KBDR 中数据读入 x11。
但需考虑异步问题:例如执行指令时用户还尚未输入新字符,就会将 KBDR 之前存储的数据读入 x11 中。
异步问题
- I/O 的执行与处理器的执行不同步
- 无法保证在 I/O 设备寄存器存取时输入端的信号已就绪
微处理器指令和用户键盘输入的不同点:
- 微处理器:时钟控制下执行指令
- 用户键盘输入:不受时钟控制,随时可能发生
显示器同理,若使用下面的指令将 x11 的值存储到 DDR 中
sw x11, 0(x6) |
同样有异步问题:若执行指令时显示器尚未将上一个 DDR 中字符显示完成,则会将 DDR 之前存储的数据覆盖。
处理 I/O 异步问题
使用协议/握手机制。
设备各有 1 位标志就绪位,在设备状态寄存器的 [0] 位。
- 键盘 KBCR[0]:是否输入了一个字符
- 每当字符输入一个字符,键盘控制器就绪位设为 1(此时键盘不可用)
- 处理器读取字符时,键盘控制器将就绪位清 0(此时允许读取下一个字符)
- 显示器 DCR[0]:被送给显示器的字符是否已被显示
- 每当显示器完成了一个字符的显示,显示器控制器将 DSR[0] 设为 1(显示器就绪,可以写入新字符)
- 每当处理器向 DDR 写字符时,显示器控制器将 DSR[0] 清空(显示器正忙,当前字符未完成显示)
轮询模式
轮询模式
- 周期性的检查状态位,判断是否执行 I/O 操作
- 由处理器完全控制和执行与 I/O 的通信工作
轮询获取键盘输入的指令序列
1 | ...... |
轮询使用显示器输出的指令序列
1 | ...... |
若读取键盘数据时,键盘状态是未就绪,假设
- CPU 的时钟频率是 300MHz
- 那么,一个时钟周期是 3.3 纳秒
- 处理执行一条指令平均需要 10 个时钟周期
- 那么,执行一条指令的平均时间是 33 纳秒
用户在 1 秒后输入一个字符,若轮询指令序列含 10 条指令,则处理器执行指令序列约 300 万次,才能读到字符,浪费了大量的处理器时间。
自陷机制
完善 I/O 模式中,硬件寄存器中有特权。没有正确的特权级别的应用程序无法访问硬件寄存器。
RISC-V 特权:
Level | Encoding | Name | Abbr |
---|---|---|---|
0 | 00 | User/Application | U |
1 | 01 | Supervisor | S |
2 | 10 | Reserved | - |
3 | 11 | Machine | M |
操作系统自陷(TRAP)机制
- 用户的程序通过自陷指令,将控制权交给操作系统
- 由于操作系统拥有适当的特权级别,可以操作硬件寄存器,实现 I/O 行为
- 用户则不需要理解 I/O 行为的实现细节,由操作系统完成这层抽象
系统自陷(TRAP)
- 操作系统是拥有特权级别的系统程序,对计算机系统的资源(如内存和寄存器等)有高级别的访问权限
- 而用户程序只能访问有限的系统资源
- 从用户程序调用操作系统的服务例程,就是从用户模式到了拥有更高特权级别的特权模式下,这个过程被称为系统自陷
- 系统调用必须使用能够改变特权级别的指令,以及在特权模式下可以使用的特权指令来实现
RISC-V 指令集定义的工作模式
- 用户程序工作于用户模式下
- 操作系统可工作于机器模式下
改变特权级别的指令
ecall
指令(Environment Call,环境调用):从用户模式进入机器模式mret
指令(Machine Return,从机器模式返回):从操作系统返回到用户程序
特权指令
csrrw
指令(Control Status Register Read/Write,读/写控制状态寄存器):在操作系统中访问系统的控制状态寄存器
ecall
指令
- 环境调用(Environment Call)
- 向更高特权级发起请求
- 从用户模式进入机器模式
- 从用户程序自陷进入操作系统的服务例程
- 修改 PC 的值为操作系统处理自陷子例程的起始地址
- 提供一个用于返回调用者程序的地址
- 改变工作模式,从用户模式进入机器模式
指令格式
- opcode:1110011,是 I-型指令
- funct:[14:12] 位 000 代表改变特权级别,[31:20] 位全为零,代表环境调用指令
- 将 PC 的值,即返回链接,写入一个特殊寄存器 mepc(Machine Exception Program Counter,机器模式异常程序计数器)中
- 再将一个特殊寄存器 mtvec(Machine Trap Vector,机器模式自陷向量基址寄存器)中的值,加载到 PC 中
- mtvec,硬连线的值,即操作系统自陷处理子例程的起始地址
- 将一个特殊寄存器 mstatus(Machine Status,机器状态寄存器)中的相应位(如 [12:11] 位)设置为机器模式(如 11)
- 有权访问特殊寄存器和全部内存
当用户程序自陷进入操作系统后,系统应当执行哪一个服务例程?
系统预先为每个服务例程分配一个唯一的系统调用编号。用户程序在 ecall
指令之前,需要传递系统调用参数
- x10 寄存器(a0):系统调用编号
- x11 寄存器(a1):系统调用参数/返回值
csrrw
指令
自陷后系统如何将控制返回到用户程序?
一旦操作系统执行完服务子例程,将 PC 指向用户程序的 ecall
指令后面一条指令的地址,从而恢复用户程序的控制。
如何读/写特殊寄存器 mepc?使用 csrrw
特权指令。
csrrw
指令是用于读/写特殊寄存器的特权指令。
mepc, mtvec, mstatus 等都是特殊的控制状态寄存器(Control Status Register, CSR)
指令为每一个 CSR 分配一个 12 位的编码。
- mstatus 为 0x300
- mtvec 为 0x305
- mepc 为 0x341
指令格式
- opcode:1110011,I-型 指令
- funct:[14:12] 为 001,代表读/写特殊寄存器指令,[31:20] 为 CSR 编码
- 读 CSR 时,将 RS1 设置为 x0
csrrw RD, CSR, x0
- 写 CSR 时,将 RD 设置为 x0
csrrw x0, CSR, RS1
1 | csrrw x5, mepc, x0 # x5 <- mepc |
mret
指令
如何将控制从操作系统返回到用户程序?使用改变特权级别的 mret
指令。
指令格式
- opcode:1110011,I-型指令
- funct:[31:20] 函数码为 0011 000 0010,表示从机器模式返回
mret
指令用于从系统自陷中返回。
- 将 mepc 写入 PC
- 将 mstatus[12:11] 恢复为 00,即返回到产生自陷的用户模式
操作系统的服务例程
为用于处理自陷的子例程
1 | 01 TrapVec: .word ...... # 自陷向量表 |
自陷向量表:存储了系统调用的服务例程起始地址的空间,从而不需要将 x10 与每一个系统调用号进行比较判断。
- 标记 TrapVec 是自陷向量表的基址
- TrapVec + n 中保存的是响应自陷的服务例程的程序起始地址
在进入 puts 服务例程时,省略了寄存器保存的过程.自陷到服务例程和子例程调用相同,需要使用「栈机制」。
被调用程序,即服务例程,知道需要使用哪些寄存器来完成它的工作,而调用者,即用户程序,不知道哪些寄存器的值将被破坏。
- 03~06 行:采用 callee-save(被调用者保存)策略
- 07~0B 行:根据 x10 中的系统调用号,跳转到相应的服务例程去执行
- 0F~11 行:
mepc <- mepc + 4
,准备好返回地址 - 12~15 行:采用 callee-save(被调用者保存)策略
- 16 行:
PC <- mepc
,返回用户程序
C 语言的 I/O 实现
I/O 流
- 一种抽象:输入和输出以流的形式进入或离开缓冲区
- 这种抽象的流适用于字符的 I/O
- 常用的输入流
- 键盘
- 当一个字符被键入,添加到流的末尾处
- C 程序则从 I/O 流的开头处读取输入
- 常用的输出流
- 打印机
- C 程序将待打印的字符添到输出流的末尾处
- 打印机则从 I/O 流的开头处打印输出
C 语言标准输入输出流(stdin/stdout/stderr)
stdin
:标准输入流- 默认映射到键盘输入
- C 语言使用 getchar 函数返回 stdin 中的下一个输入字符的ASCII码
stdout
:标准输出流- 默认映射到屏幕输出
- C 语言使用 putchar 函数将下一个待输出字符的ASCII码添加到 stdout 中
stderr
:标准错误流- 用于输出错误信息
C 语言 I/O 流的实现
- 通过在 I/O 服务例程的基础上,增加了额外的软件层实现
- 例如,调用库函数
getchar()
- 先从 stdin 中读字符,如果有字符,就不需要进行系统调用
- 使用 I/O 流,可以有效减少系统调用次数,从而减少了由于系统运行状态的切换带来的开销
- 不必再从用户模式切换到机器模式,最后再从自陷返回
- 如果 stdin 中已经没有字符,则自陷进入操作系统
1 | 01 stdin: .byte 0, 0, ...... # 标准输入流,共 size 个字节,初值均为 0/null |
- 01 行:stdin 标记的空间,即输入流的缓冲区,大小以 size=1024 个字节为例
- 02 行:inPt 标记的存储单元,存储的是输入流中下一个字符的地址,即字符指针
- 03 行:num 标记的存储单元,存储的是输入流中还有多少个剩余的字符
- 06~09 行:读出字符指针和字符数
- 0A~0F 行:如果输入流中还有字符,就读出来,然后将指针指向下一个字符,剩余的字符数递减
- 如果输入流中没有字符可以读取,调用操作系统的输入字符串服务例程
- 11~14 行:设置系统调用号(x10)为 8,将 x11 设置为缓冲区首地址 stdin,将缓冲区大小(x12)设置为 1024,进 行系统调用
- 15~1C 行:从系统调用返回后,读出缓冲区中的第一个字符,将指针指向下一个字符,剩余的字符数递减
- 1D~1F 行:将读出的字符传到 x10 中,返回