软件与硬体的距离

本文目标

  • 理解作业系统与外部硬体的沟通方式
  • 学习 xv6 作业系统是如何顺利读取到硬碟的资讯

本篇文章将会带读者探讨 Virtio 以及作业系统如何处理各种中断,顺利的与外部设备进行沟通。

KVM / QEMU 的效能瓶颈

QEMU 与 KVM 属於完全虚拟化的解决方案,在没有硬体加速辅助的情况下,所有的工作都必须透过软件模拟,这样一来会造成模拟器的效能低落(尤其是 device I/O 的部分)。
一般来说,模拟器的 I/O request 的完整操作会经过流程:

  • I/O Trap

Trap 的中文有陷阱的意思,阅读过先前的异常与中断文章便会知道,不论是 Exceptions 或是 Interrupt ,其处理机制都是前去中断向量表找出对应的 ISR 。
这样的动作就好比跳入提前设下陷阱一样,所以称为 Trap

  • 将处理结果丢到 I/O sharing page

  • 通知 QEMU process 来取得 I/O 资讯,并交由 QEMU I/O Emulation Code 来模拟 I/O request

  • 完成後将结果放回 I/O sharing page

  • 通知 KVM module 中的 I/O trap 将处理结果取回并回传给 virtual machine

从上面复杂的步骤不难看出模拟器的 I/O 为何会效率不彰,除了每次 I/O request 处理的流程繁复之外,过多的 VMEntry, VMExit, context switch,也都是拖垮 QEMU 效能的原因。

Virtio

图片取自该连结

Virio 协定提供一个与虚拟装置沟通的渠道,像是: block device (HDD), input device (键盘滑鼠)等等。

Block device driver in Xv6

+-------------+
| File system |
+-------------+
|    VirtIO   |
+-------------+
|     HDD     |
+-------------+

Xv6 实作了 mkfs 这个系统呼叫,当虚拟机启动时便会自动读取/新增虚拟的 IDE 硬碟,顺序大概如下:

  1. 呼叫 mkfs 读取 fs.img 中的资讯,如果 fs.img 不存在就创建一个。
  2. 使用 Virtio 构建的 Block device driver 开始初始化并读取 Device 的相关资讯。
  3. 待作业系统初始化完成, Block device driver 会负责档案系统与硬碟之间的沟通。

Virtio 可视为硬体的抽象,提供了健全的应用程序介面。这样一来,作业系统就能透过 API ,支援 Network 、 Block 、 Balloon 等 I/O 装置。

Descriptor

Descriptor 包含这些讯息: 地址,地址长度,某些 flag 和其他信息。
使用 Descriptor ,我们可以将设备指向 RAM 中任何缓冲区的内存位址。

struct virtq_desc
{
  uint64 addr;
  uint32 len;
  uint16 flags;
  uint16 next;
};
  • addr: 我们可以在 64-bit 内存地址内的任何位置告诉设备存储位置。
  • len: 让 Device 知道有多少内存可用。
  • flags: 用於控制 descriptor 。
  • next: 告诉 Device 下一个描述符的 Index 。如果指定了 VIRTQ_DESC_F_NEXT, Device 仅读取该字段。否则无效。

AvailableRing

用来存放 Descriptor 的索引,当 Device 收到通知时,它会检查 AvailableRing 确认需要读取哪些 Descriptor 。

需要注意的是: Descriptor 和 AvailableRing 都存储在 RAM 中。

struct virtq_avail
{
  uint16 flags;     // always zero
  uint16 idx;       // driver will write ring[idx] next
  uint16 ring[NUM]; // descriptor numbers of chain heads
  uint16 unused;
};

UsedRing

UsedRing 让 Device 能够向 OS 发送讯息,因此, Device 通常使用它来告知 OS 它已完成先前通知的请求。
AvailableRing 与 UsedRing 非常相似,差别在於: OS 需要查看 UsedRing 得知哪个 Descriptor 已经被服务。

struct virtq_used_elem
{
  uint32 id; // index of start of completed descriptor chain
  uint32 len;
};

struct virtq_used
{
  uint16 flags; // always zero
  uint16 idx;   // device increments when it adds a ring[] entry
  struct virtq_used_elem ring[NUM];
};

系统如何处理中断

在更先前的章节中,笔者已经介绍过 RISC-V 的异常与中断处理,当时有提到 CSR 寄存器与 CSR 指令,本篇文章会接续着介绍作业系统是如何针对中断进行处理以做到抢占式多工、读入键盘输入的字元等等。

中断向量表

中断向量表是由作业系统程序所维护,维基百科上是这麽描述的:

每一个表项纪录一个中断处理程序 (ISR,Interrupt Service Routine) 的位址。

在 RISC-V 架构上的作业系统,我们会将中断向量表的位置写进入 CSR 暂存器: mtvec

// 在作业系统初始化的时候,需要先将中断向量表建立完成:
w_mtvec((reg_t)trap_vector);

当中断或是异常发生时, Program counter 就会跳入 mtvec 所指向的地方开始执行:

.globl trap_vector
# the trap vector base address must always be aligned on a 4-byte boundary
.align 4
trap_vector:
	# save context(registers).
	csrrw	t6, mscratch, t6	# swap t6 and mscratch
	reg_save t6
	csrw	mscratch, t6

	# call the C trap handler in trap.c
	csrr	a0, mepc
	csrr	a1, mcause
	call	trap_handler

	# trap_handler will return the return address via a0.
	csrw	mepc, a0

	# restore context(registers).
	csrr	t6, mscratch
	reg_restore t6

	# return to whatever we were doing before trap.
	mret

上面的范例中, trap_vector 先将 mscratch 的内容进行保存,在保存之後进行了一个很关键的操作:

csrr	a0, mepc
csrr	a1, mcause
call	trap_handler

补充:
mscratch 是一个 MXLEN 位宽可读写的暂存器,一般来说,它用来保存 hart-local 上下文空间的 pointer ,并在进入 machine 模式 trap 处理程序时与通用暂存器交换。

当我们在 call function 时, RISC-V 会将 a0 与 a1 暂存器做为 function 的参数,所以我们把 mepc 与 mcause 作为参数塞给 trap_handler() 并呼叫它:

reg_t trap_handler(reg_t epc, reg_t cause)
{
	reg_t return_pc = epc;
	reg_t cause_code = cause & 0xfff;
	
	if (cause & 0x80000000) {
		/* Asynchronous trap - interrupt */
		switch (cause_code) {
		case 3:
			uart_puts("software interruption!\n");
			break;
		case 7:
			uart_puts("timer interruption!\n");
			break;
		case 11:
			uart_puts("external interruption!\n");
			external_interrupt_handler();
			break;
		default:
			uart_puts("unknown async exception!\n");
			break;
		}
	} else {
		/* Synchronous trap - exception */
		printf("Sync exceptions!, code = %d\n", cause_code);
		panic("OOPS! What can I do!");
		//return_pc += 4;
	}

	return return_pc;
}

跳到 trap_handler() 之後,它会针对不同类型的中断呼叫不同的 handler ,所以我们可以将它视为一个中断的派发任务中继站:

                         +----------------+
                         | soft_handler() |
                 +-------+----------------+
                 |
+----------------+-------+-----------------+
| trap_handler() |       | timer_handler() |
+----------------+       +-----------------+
                 |
                 +-------+-----------------+
                         | exter_handler() |
                         +-----------------+

由於 UART 中断也是属於外部中断的,所以在 external_handler() 我们可以看到它又做了更细一步的判断:

void external_interrupt_handler()
{
	int irq = plic_claim();

	if (irq == UART0_IRQ){
      		uart_isr();
	} else if (irq) {
		printf("unexpected interrupt irq = %d\n", irq);
	}
	
	if (irq) {
		plic_complete(irq);
	}
}

如果 IRQ 是属於 UART 的 IRQ , Handler 就可以呼叫 uart_getc() 等函式取得键盘输入的字元。
并且,等到 Handler 执行完成後, trap_vector 会恢复 mscratch 的数值并把 Program counter 修正到原来的位置继续执行系统程序。

为 mini-riscv-os 添增 Block device driver

在实现外部中断的机制以後,我们已经在先前的 Lab 中加入了 UART 的 ISR,为了让作业系统能够读取磁碟资料,我们必须加入 VirtIO 的 ISR :

void external_handler()
{
  int irq = plic_claim();
  if (irq == UART0_IRQ)
  {
    lib_isr();
  }
  else if (irq == VIRTIO_IRQ)
  {
    virtio_disk_isr();
  }
  else if (irq)
  {
    lib_printf("unexpected interrupt irq = %d\n", irq);
  }

  if (irq)
  {
    plic_complete(irq);
  }
}

external_handler() 会透过 IRQ 识别中断的外部来源,再交给其他 ISR 做处理。
在看到 VirtIO 的 ISR 实作前,我们先来看一下读写请求是如何产生的吧!

发送 Block request

宣告 req 的结构:

struct virtio_blk_req *buf0 = &disk.ops[idx[0]];

因为磁碟有读写操作之分,为了让 qemu 知道要读还是要写,我们要在请求中的 type 成员中写入 flag :

if(write)
  buf0->type = VIRTIO_BLK_T_OUT; // write the disk
else
  buf0->type = VIRTIO_BLK_T_IN; // read the disk
buf0->reserved = 0; // The reserved portion is used to pad the header to 16 bytes and move the 32-bit sector field to the correct place.
buf0->sector = sector; // specify the sector that we wanna modified.

填充 Descriptor

到了这一步,我们已经分配好 Descriptor 与 req 的基本资料了,接着我们可以对这三个 Descriptor 做资料填充:

disk.desc[idx[0]].addr = buf0;
  disk.desc[idx[0]].len = sizeof(struct virtio_blk_req);
  disk.desc[idx[0]].flags = VRING_DESC_F_NEXT;
  disk.desc[idx[0]].next = idx[1];

  disk.desc[idx[1]].addr = ((uint32)b->data) & 0xffffffff;
  disk.desc[idx[1]].len = BSIZE;
  if (write)
    disk.desc[idx[1]].flags = 0; // device reads b->data
  else
    disk.desc[idx[1]].flags = VRING_DESC_F_WRITE; // device writes b->data
  disk.desc[idx[1]].flags |= VRING_DESC_F_NEXT;
  disk.desc[idx[1]].next = idx[2];

  disk.info[idx[0]].status = 0xff; // device writes 0 on success
  disk.desc[idx[2]].addr = (uint32)&disk.info[idx[0]].status;
  disk.desc[idx[2]].len = 1;
  disk.desc[idx[2]].flags = VRING_DESC_F_WRITE; // device writes the status
  disk.desc[idx[2]].next = 0;

  // record struct buf for virtio_disk_intr().
  b->disk = 1;
  disk.info[idx[0]].b = b;

  // tell the device the first index in our chain of descriptors.
  disk.avail->ring[disk.avail->idx % NUM] = idx[0];

  __sync_synchronize();

  // tell the device another avail ring entry is available.
  disk.avail->idx += 1; // not % NUM ...

  __sync_synchronize();

  *R(VIRTIO_MMIO_QUEUE_NOTIFY) = 0; // value is queue number

  // Wait for virtio_disk_intr() to say request has finished.
  while (b->disk == 1)
  {
  }

  disk.info[idx[0]].b = 0;
  free_chain(idx[0]);

当 Descriptor 被填充完毕,*R(VIRTIO_MMIO_QUEUE_NOTIFY) = 0; 会提醒 VIRTIO 接收我们的 Block request。

此外,while (b->disk == 1) 可以确保作业系统收到 Virtio 发出的外部中断後再继续执行下面的程序码。

实作 VirtIO 的 ISR

当系统程序接收到外部中断,会根据 IRQ Number 判断中断是由哪一个外部设备发起的 (VirtIO, UART...)。

void external_handler()
{
  int irq = plic_claim();
  if (irq == UART0_IRQ)
  {
    lib_isr();
  }
  else if (irq == VIRTIO_IRQ)
  {
    lib_puts("Virtio IRQ\n");
    virtio_disk_isr();
  }
  else if (irq)
  {
    lib_printf("unexpected interrupt irq = %d\n", irq);
  }

  if (irq)
  {
    plic_complete(irq);
  }
}

如果是 VirtIO 发起的中断,便会转派给 virtio_disk_isr() 进行处理。

void virtio_disk_isr()
{

  // the device won't raise another interrupt until we tell it
  // we've seen this interrupt, which the following line does.
  // this may race with the device writing new entries to
  // the "used" ring, in which case we may process the new
  // completion entries in this interrupt, and have nothing to do
  // in the next interrupt, which is harmless.
  *R(VIRTIO_MMIO_INTERRUPT_ACK) = *R(VIRTIO_MMIO_INTERRUPT_STATUS) & 0x3;

  __sync_synchronize();

  // the device increments disk.used->idx when it
  // adds an entry to the used ring.

  while (disk.used_idx != disk.used->idx)
  {
    __sync_synchronize();
    int id = disk.used->ring[disk.used_idx % NUM].id;

    if (disk.info[id].status != 0)
      panic("virtio_disk_intr status");

    struct blk *b = disk.info[id].b;
    b->disk = 0; // disk is done with buf
    disk.used_idx += 1;
  }

}

virtio_disk_isr() 主要工作会将 disk 的状态改下,告诉系统先前发出的读写操作已经被顺利执行了。
其中 b->disk = 0;,可以让先前提到的 while (b->disk == 1) 顺利跳出,释放 disk 中的自旋锁。

int os_main(void)
{
	os_start();
	disk_read();
	int current_task = 0;
	while (1)
	{
		lib_puts("OS: Activate next task\n");
		task_go(current_task);
		lib_puts("OS: Back to OS\n");
		current_task = (current_task + 1) % taskTop; // Round Robin Scheduling
		lib_puts("\n");
	}
	return 0;
}

由於 mini-riscv-os 并没有实作能够休眠的锁,所以笔者将 disk_read() 这个测试函式在开机时执行一次,若要向上实现更高层的档案系统,就会需要使用 sleep lock,以避免当有多个任务尝试取用硬碟资源时造成 deadlock 的情况发生。

总结

本篇提到了 VirtIO 与中断向量表,有些读者可能会不明白两者个关联性,其实我们透过 virtio 操作 block device 时,也会发送各种中断(软件中断或是外部中断),
了解系统程序如何处理中断後,我们就可以依样画葫芦的为作业系统添加不同的功能。
最後,本篇的外部中断程序范例参考了 《从头写一个RISC-V OS》课程 ,在笔者阅读多个不同的 RISC-V 作业系统後,发现这些作业系统都是这样处理中断的,如果读者想看简单的 Timer Interrupt ,可以参考 mini-riscv-os 的说明文件,本文便不对此多做介绍。

Reference


<<:  javascript(DOM调整属性与样式&计时器)(DAY23)

>>:  爬虫怎麽爬 从零开始的爬虫自学 DAY22 python网路爬虫开爬-4翻页继续爬

暗通款曲的闭包

在「闭包」这一关,我一直有一种似懂非懂,玄之又玄的感觉。 MDN上对「闭包」的定义: 「闭包为函式...

Day 5:Hello....android world! 建立第一个KMM专案(Android)

Keyword: Android Studio,AVD Manager 到Day6完成第一个KMM专...

day28: Pointfree

今天要来补充 Pointfree 的概念, 这也同样和 pipe 有关。 function 运算的过...

Day 19 - 研习计画之各种挖坑和进度追杀

进入九月後的开发过程中越来越常看到研习生回报开发上遇到的问题,而今天的文章主要聚焦在网站上线遇到的一...

Day2 Let's ODOO: 开发者模式

当需要以管理者身份去修改ODOO的时候,我们就要打开开发者模式,流程如下: 1.点击Setting图...