Serial Console implementation
About Serial UART and ttyS0
UART(Universal Asynchronous Receiver/Transmitter)はコンピュータやマイコンと周辺機器を繋ぐ非同期式シリアル通信規格である。 UARTによってシリアル・パラレル信号の相互変換を行えるため、入力されるパラレルデータをシリアルデータへ変換し通信回線越しに相手に送信することができる。 これを実装するために設計された集積回路として8250 UARTと呼ばれるデバイスが製造され、その後さまざまなファミリが登場してきた。
さて、今回Guest OS(Linux)を起動させようとしているわけであるが、デバッグ等の用途としてシリアルコンソールが存在すると便利なケースが多い。
シリアルコンソールはGuestの全てのコンソール出力をシリアルポートに送付するため、シリアルターミナルが適切に設定されていればリモートターミナルとしてシステムの起動状況を確認したり、シリアルポート経由でシステムにログオンしたりすることができる。
今回は、ToyVMM上で起動したGuest VMの状態確認やGuestの操作をするためにこの方式を利用することにする。
コンソールメッセージをシリアルポートに出力させるために、カーネルの起動時パラメータとしてconsole=ttyS0を設定する必要がある。
現在のToyVMMの実装ではこの値をデフォルト値として与えている。
問題はこれを受け取るシリアルターミナル側である。
シリアルポートに該当するIO Portのアドレスは決まっているため、ToyVMMのレイヤからは当該アドレス付近に対してKVM_EXIT_IOの命令を受けることになる。
つまりGuest OS側から発行されるシリアルコンソールへの出力情報、またそれ以外にも必要なセットアップ要求などを適切に処理する必要があり、これはUARTデバイスをエミュレートすることで成立させる必要がある。
その上で、デバイスをエミュレートした結果として、標準出力に対してコンソール出力を出力したり、逆に我々の標準入力をGuest VM側に反映させることができれば、ToyVMMからVMを起動した際に、その起動情報の確認やGuestの操作を手元のターミナルから実施することが可能になる。
以上をまとめると、大まかに下記のような概念図のものを作成することになる。
以降では順を追って説明していく。
Serial UART
Serial UARTについては、以下のLammet Biesの資料とWikibooksに大変詳細な情報が記載されているため基本的にはこれを確認すれば良い。
以下の図は、Lammetの資料に記載のある図を引用しつつそれぞれのRegisterの各bitに関して簡単に説明を加えた図である。本資料の執筆において個人的に作成した図であるが、読者の理解の手助けになることを期待して添付しておく。 ただし、それぞれのregisterやbitの意味については本資料では説明はしないため、上記の資料を参考に確認してもらいたい。
基本的には上記のregisters/bitsを操作することで必要な処理を行うのがUARTの仕組みになっている。
今回はこれをSoftwareでエミュレートする必要があるが、この実装に関しては rust-vmm/vm-superioを利用することで代替とする。
以降でこの rust-vmm/vm-superio の実装と上記のSpecificationとを比較しながら簡単にではあるが確認していこうと思う。
rust-vmm/vm-superioによるSerial DeviceのSoftware実装
初期値設定/RWの実装
ここからはrust-vmm/vm-superioを利用したserial deviceの実装を、上記のSpecificationと比較しながら確認していく。
是非、上記からコードを取得して自分で確認してみてほしい。
なお、以下の内容は vm-superio-0.6.0 に準拠しているので、最新のコードでは変更されているかもしれないため留意されたい。
まず、いくつかの値の初期値について以下の通りに整理する。
rust-vmm/vm-superioはもともとVMMでの利用を前提にしているため、いくつかのレジスタの値を初期設定していたり、書き換え想定をしていなかったりする。
| Variable | DEFAULT VALUE | Meaning | REGISTER |
|---|---|---|---|
| baud_divisor_low | 0x0c | baud rate 9600 bps | |
| baud_divisor_high | 0x00 | baud rate 9600 bps | |
| interrupt_enable | 0x00 | No interrupts enabled | IER |
| interrupt_identification | 0b0000_0001 | No pending interrupt | IIR |
| line_control | 0b0000_0011 | 8 bit word length | LCR |
| line_status | 0b0110_0000 | (1) | LSR |
| modem_control | 0b0000_1000 | (2) | MCR |
| modem_status | 0b1011_0000 | (3) | MSR |
| scratch | 0b0000_0000 | SCR | |
| in_buffer | Vec::new() | vector values (buffer) | - |
- THR empty関係のbitを立てている。このbitを立てているといつでもデータを受信可能であることを表現していることになるが、これは仮想デバイスとしての利用が前提の設定になっている。
- 多くのUARTはAuxiliary Output 2の値を1に設定し、interruptを有効にするたデフォルトで有効にしている
- Connectedの状態、かつハードウェアデータフローの初期化
さて、次にwriteのリクエストが来た場合の処理内容について簡単に記載する。
KVM_EXIT_IOの結果として、IOが発生したaddressと、書き込むべきデータが渡される。
ToyVMM側ではこれらの値から適切なデバイス(今回はSerial UART device)とそのベースアドレスからのOffsetを計算し、vm-superioで定義されているwrite関数を呼び出す。
以下の内容は、Serial::writeの処理を簡単に表に起こしたものである。基本的には素直にレジスタ値の書き換えになるが一部ちょっとロジックが入る。
| Variable | OFFSET(u8) | Additional Conditions | write |
|---|---|---|---|
| DLAB_LOW_OFFSET | 0 | is_dlab_set = true | self.baud_divisor_lowを書き換え |
| DLAB_HIGH_OFFSET | 1 | is_dlab_set = true | self.baud_divisor_highを書き換え |
| DATA_OFFSET | 0 | - (is_dlab_set = false) | (1) |
| IER_OFFSET | 1 | - (is_dlab_set = false) | (2) |
| LCR_OFFSET | 3 | - | self.line_controlを書き換え |
| MCR_OFFSET | 4 | - | self.modem_controlを書き換え |
| SCR_OFFSET | 7 | - | self.scratchを書き換え |
- 現在のSerialの状態として、LOOP_BACK_MODE(MCR bit 4)が有効になっている場合とそうでない場合で場合分け
- 有効の場合、送信レジスタに書かれたものをそのまま受信レジスタに書き込まれる(loopbackする)ようにシミュレート(今回は重要ではない)
- 有効でない場合、書き込むべきデータをそのまま出力に書き出し、既存の設定状態に依存して割り込みを入れる。
- 上記の表をみてわかる通り、外部からのwriteによるIIRの変更はサポートしておらず、デフォルト値が
0b0000_0001で設定されている。 - もし、IER_OFFSETに対してIERのTHR empty bitのフラグが立っている場合は、IIRのTHR emptyに対応するフラグを立てて割り込みをトリガする。
- 上記の表をみてわかる通り、外部からのwriteによるIIRの変更はサポートしておらず、デフォルト値が
- IERのbitのうち0-3bit以外はMaskした結果(Interruptに関連するbitのみそのままにして)で
self.interrupt_enableを書き換え
次に、readのリクエストが来た場合の処理内容について簡単に記載する
同様に Serial::readの処理を表に起こしたものが下記である。readの場合はwriteと異なり基本的には返り値としてデータを返すロジックになっている
| Variable | OFFSET(u8) | Additional Conditions | write |
|---|---|---|---|
| DLAB_LOW_OFFSET | 0 | is_dlab_set = true | self.baud_divisor_lowを読み込み |
| DLAB_HIGH_OFFSET | 1 | is_dlab_set = true | self.baud_divisor_highを読み込み |
| DATA_OFFSET | 0 | - (is_dlab_set = false) | (1) |
| IER_OFFSET | 1 | - (is_dlab_set = false) | self.inerrupt_enableを読み込み |
| IIR_OFFSET | 2 | - | (2) |
| LCR_OFFSET | 3 | - | self.line_controlを読み込み |
| MCR_OFFSET | 4 | - | self.modem_controlを読み込み |
| LSR_OFFSET | 5 | - | self.line_statusを読み込み |
| MSR_OFFSET | 6 | - | (3) |
| SCR_OFFSET | 7 | - | self.scratchを読み込み |
- Serial構造体が持つbufferのデータを読み出したりするが、現実装ではこのbufferはloopback modeでのwriteでしかデータが積まれない実装になっているため、今回の内容では省略。OSの起動シーケンスでもこの領域に関する
readは発行されていなかった。 self.interrupt_identification|0b1100_0000(FIFO enabled)の結果を返却しデフォルト値に戻す- 現在の状態がloopback modeかどうかで場合分けを行う
- loopbackの場合は適切に調整する(今回は重要ではないので省略)
- loopbackで無い場合は素直に
self.modem_statusの値を返却する
ToyVMMでのrust-vmm/vm-superioの利用
ToyVMMでは上記のrust-vmm/vm-superioを利用しKVM_EXIT_IOの内容をハンドルする。
加えて考えなければならないのは以下の2点である。
- Guestからシリアルポートに当てたコンソール出力を標準出力に書き出すことで、起動シーケンスやGuest内部の状態を確認できるようにする。
- 標準入力の内容をGuest VMに引き渡す
以降、それぞれ順番に確認していく。
シリアルポート当てのコンソール出力を標準出力に書き出す
起動シーケンスやGuest VMの内部状態を確認するために、シリアルポート宛のコンソール出力を標準出力に書き出すようにしてみよう。
「シリアルポート当てのコンソール出力」はまさに、KVM_EXIT_IOでSerial向けのIO Portアドレスに対してのKVM_EXIT_IO_OUTのケースに該当する。
以下のコード部が該当処理になる。
#![allow(unused)] fn main() { ... loop { match vcpu.run() { Ok(run) => match run { ... VcpuExit::IoOut(addr, data) => { io_bus.write(addr as u64, data); } ... } } } ... }
さて、ここではKVM_EXIT_IO_OUTで受け取ったアドレスと書き込むべきデータを伴って、io_bus.writeを呼び出すのみになっている。
このio_busは以下のような形で設定を行ったものである。
#![allow(unused)] fn main() { let mut io_bus = IoBus::new(); let com_evt_1_3 = EventFdTrigger::new(EventFd::new(libc::EFD_NONBLOCK).unwrap()); let stdio_serial = Arc::new(Mutex::new(SerialDevice { serial: serial::Serial::with_events( com_evt_1_3.try_clone().unwrap(), SerialEventsWrapper { buffer_read_event_fd: None, }, Box::new(std::io::stdout()), ), })); io_bus.insert(stdio_serial.clone(), 0x3f8, 0x8).unwrap(); vm.fd().register_irqfd(&com_evt_1_3, 4).unwrap(); }
上記のセットアップについては少し説明が必要なため以降に順を追って話していくが、大まかに押さえておくと以下のようなことをおこなっている。
- I/O Busを表現している
IoBus構造体、割り込みを表現するeventfdを初期化する。 - Serial Deviceの初期化をおこなう。その際にGuestへ割り込みを発生させるための
eventfdと標準出力のためのFD(std::io::stdout)を渡している。 - 初期化した
IoBusに上記のSerial Deviceを登録している。この時、0x3f8をベースアドレス、0x8をレンジとして登録している。- これにより、
0x3f8を基底として0x8のレンジはこのSerial Deviceが利用するアドレス領域ということを表現している。
- これにより、
I/O Busの取り回し
KVM_EXIT_IOで渡されるアドレス値は、アドレス空間全体におけるアドレスの値になる。
一方で、rust-vmm/vm-superioのread/writeの実装は、Serial Deviceのベースアドレスからのオフセット値をとって処理を実施するような実装になっているため、このギャップを埋めるための処理が必要になる。
単純にオフセットを計算するだけでも良いが、Firecrackerではその後の拡張性(Serialデバイス以外のIO Portを利用するデバイス)も考慮してか、I/O Busを表現する構造体であるBusという構造体が存在する。
これはBusRange(バスにおけるデバイスのベースアドレスと利用するアドレスレンジを表現している構造体)とともにデバイスを登録できるようなものになっている。
さらに、あるアドレスへのIOが発生しときに、そのアドレスを確認し、対応するアドレスレンジに登録されているデバイスを取り出して、そのデバイスに対して、ベースアドレスからのオフセット値でIOを実行するような仕組みが提供されている。
例えばwrite関数は以下のような実装になっており、get_deviceでアドレス情報から対応する登録済みデバイスと、そのデバイスのベースアドレスからのオフセットを取得し、それを利用してデバイスに実装されているwrite関数を呼び出している。
#![allow(unused)] fn main() { pub fn write(&self, addr: u64, data: &[u8]) -> bool { if let Some((offset, dev)) = self.get_device(addr) { : u64 : &Mutex<dyn BusDevice> // OK to unwrap as lock() failing is a serious error condition and should panic. dev.lock() Result<MutexGuard<dyn BusDevice>, …> .expect("Failed to acquire device lock") MutexGuard<dyn BusDevice> msg: .write(offset, data); true } else { false } } }
具体的にSerialデバイスの話を例に挙げて考えてみる。
前述した通りGuest VMからのserialに対するKVM_EXIT_IO_OUTは0x3f8をベースとして8 byteのアドレスレンジで発生する。
ToyVMMのIoBusでも同様のアドレスベース、レンジ情報でSerial Deviceの登録をしているため、例えばKVM_EXIT_IO_OUTとして0x3fbへ0b1001_0011を書き込むという命令をトラップした場合、登録したSerial Deviceに対して、ベースアドレス(0x3f8)からのオフセット(0x3)の位置、つまりLCRに0b1001_0011を書く、という命令に解釈される。
eventfd/irqfdによるGuest VMへの割り込み通知
ここからは少しKVMと割り込みに関する話をしたい。
いくつかLinuxのソースコードを引用することになるが以降ではv4.18のコードから引用する。
:warning: 以降の話は基本的にソースコードを元に記載しているが、詳細な状態遷移を逐一確認して記載したものではないため多少間違っている可能性があります。もし間違いを発見した場合はコメントをいただけると幸いです。
rust-vmm/vm-superioでは、Serialの初期化時に第一引数にEventFdを要求する。
これはLinuxにおけるeventfdのWrapperになっているものである。
eventfdの詳細はmanを確認してほしいが、簡単にいうとプロセス間やプロセスとカーネルの間などでのイベントの通知を実現することができる仕組みである。
次にirqfdである。irqfdはeventfdをベースとしたVMに対して割り込みを入れることのできる仕組みである。
イメージとしてはeventfdの一端をKVMが保持し、もう片方からの通知をGuest VMへの割り込みとして解釈するというものである。
このirqfdによる割り込みは、Guest VMの外の世界からGuest VMへの割り込み、つまり通常のシステムで言うところの周辺デバイスからの割り込みをエミュレートするものである。逆方向の割り込みはioeventfdの仕組みを利用するがここでは一旦省略する。
実際にソースコードを見ながら、このirqfdがどのようにGuestへの割り込みにつながっていくかを確認してみよう。
KVMに対してKVM_IRQFDを伴ってioctlを実施すると、渡されたデータを元にkvm_irqfd、kvm_irqfd_assignの流れでKVMの処理が実行され、このkvm_irqfd_assign関数でkvm_kernel_irqfd構造体のインスタンスが作成される。
この時、ioctl時に渡した追加情報を元に設定を行うが、特にkvm_kernel_irqfd構造体はgsiというフィールドを持っており、これがioctlで渡した引数の値によって設定される。
このgsiは、irqfdに対応するGuestの割り込みテーブルのインデックスに該当するものになるため、ioctlを呼ぶ際にはeventfdに加えて、Guestのどの割り込みテーブルのエントリに対して割り込みを入れるかということも指定する。
ToyVMMではこの設定を行なっているのが以下の一行である。
#![allow(unused)] fn main() { vm.fd().register_irqfd(&com_evt_1_3, 4).unwrap(); }
これはkvm_ioctl::VmFd構造体のメソッドとして定義されている
#![allow(unused)] fn main() { pub fn register_irqfd(&self, fd: &EventFd, gsi: u32) -> Result<()> { let irqfd = kvm_irqfd { fd: fd.as_raw_fd() as u32, gsi, ..Default::default() }; // Safe because we know that our file is a VM fd, we know the kernel will only read the // correct amount of memory from our pointer, and we verify the return result. let ret = unsafe { ioctl_with_ref(self, KVM_IRQFD(), &irqfd) }; : i32 fd: req: arg: if ret == 0 { Ok(()) } else { Err(errno::Error::last()) } } }
つまり上記では、これまで話してきたSerial deviceに利用しているeventfd(com_evt_1_3)をGSI=4(Guest VM上のCOM1ポートへの割り込みテーブルインデックス)を伴って設定しているため、このcom_evt_1_3に対して実行したwriteは、COM1からの割り込みとしてGuest VMに渡り(つまり、Guestから見るとCOM1の先のserial deviceから割り込みが発生したことになり)、Guest VMのCOM1の割り込みハンドラを起動することになる。
さて、今Guest側の割り込みテーブル(GSI: Global System Interrupt)の話が出たが、これらはいつ、どのようにセットアップされるかいついて以降で説明していくこととする。
端的に言えばこれらはKVM_CREATE_IRQCHIPを伴ってKVMにioctlを発行することで設定される。これを実施すると割り込みコントローラであるPICとIOAPICの2種類が作成される(内部的にはkvm_pic_initでPICの初期化とread/write opsの登録などを行い、kvm->arch.vpicに設定。kvm_ioapic_initでIOAPICの初期化とread/write opsの登録などを行い、kvm->arch.vioapicに設定している)
このPICやIOAPICなどのハードウェアは高速化の目的でKVMに実装があるため、独自にエミュレートする必要がない。もちろんqemuなどに任せることができるが、ここでは利用しないため省略する。
さらにその後、kvm_setup_default_irq_routing関数の中でデフォルトのIRQ Routingの設定がなされている。
この処理によって、どのGSIに対する割り込みによってどのハンドラが起動するか、という部分がセットアップされる。
さて、もう少しkvm_setup_default_irq_routingの中身を見てみよう。この関数の中ではさらにkvm_set_irq_routing関数を呼びだしており、本質的な処理はそこに記載がある。
ここではkvm_irq_routing_tableを作成し、これに対してGSIからIRQへの対応を表現しているkvm_kernel_irq_routing_entryを設定していく形になる。
このkvm_kernel_irq_routing_entryはデフォルトのエントリ(default_routing)が存在しており、これをループしながら登録していくような形の実装が存在する。
このdefault_routingは以下のような定義になっている。関係するマクロの実装も記しておく。
#define SELECT_PIC(irq) \
((irq) < 8 ? KVM_IRQCHIP_PIC_MASTER : KVM_IRQCHIP_PIC_SLAVE)
#define IOAPIC_ROUTING_ENTRY(irq) \
{ .gsi = irq, .type = KVM_IRQ_ROUTING_IRQCHIP, \
.u.irqchip = { .irqchip = KVM_IRQCHIP_IOAPIC, .pin = (irq) } }
#define ROUTING_ENTRY1(irq) IOAPIC_ROUTING_ENTRY(irq)
#define PIC_ROUTING_ENTRY(irq) \
{ .gsi = irq, .type = KVM_IRQ_ROUTING_IRQCHIP, \
.u.irqchip = { .irqchip = SELECT_PIC(irq), .pin = (irq) % 8 } }
#define ROUTING_ENTRY2(irq) \
IOAPIC_ROUTING_ENTRY(irq), PIC_ROUTING_ENTRY(irq)
static const struct kvm_irq_routing_entry default_routing[] = {
ROUTING_ENTRY2(0), ROUTING_ENTRY2(1),
ROUTING_ENTRY2(2), ROUTING_ENTRY2(3),
ROUTING_ENTRY2(4), ROUTING_ENTRY2(5),
ROUTING_ENTRY2(6), ROUTING_ENTRY2(7),
ROUTING_ENTRY2(8), ROUTING_ENTRY2(9),
ROUTING_ENTRY2(10), ROUTING_ENTRY2(11),
ROUTING_ENTRY2(12), ROUTING_ENTRY2(13),
ROUTING_ENTRY2(14), ROUTING_ENTRY2(15),
ROUTING_ENTRY1(16), ROUTING_ENTRY1(17),
ROUTING_ENTRY1(18), ROUTING_ENTRY1(19),
ROUTING_ENTRY1(20), ROUTING_ENTRY1(21),
ROUTING_ENTRY1(22), ROUTING_ENTRY1(23),
};
見ての通り、0-15までのIRQ番号はROUTING_ENTRY2に、16-23までのをIRQ番号はROUTING_ENTRY1に引き渡しており、ROUTING_ENTRY2はIOAPIC_ROUTING_ENTRYとPIC_ROUTING_ENTRYを、ROUTING_ENTRY1はIOAPIC_ROUTING_ENTRYのみを呼び出して、必要な情報を埋めた構造体を作っている。
この構造体の情報を使いながら後続するkvm_set_routing_entry関数で以下の通りそれぞれの.u.irqchip.irqchipの値(KVM_IRQCHIP_PIC_SLAVE、KVM_IRQCHIP_PIC_MASTER、KVM_IRQCHIP_IOAPIC)ごとにコールバック(kvm_set_pic_irq、kvm_set_ioapic_irq)や必要な設定をおこなっている。このコールバックは割り込み発生時に呼ばれる関数に該当し、後ほど触れるため少し覚えておいてほしい。
int kvm_set_routing_entry(struct kvm *kvm,
struct kvm_kernel_irq_routing_entry *e,
const struct kvm_irq_routing_entry *ue)
{
/* We can't check irqchip_in_kernel() here as some callers are
* currently inititalizing the irqchip. Other callers should therefore
* check kvm_arch_can_set_irq_routing() before calling this function.
*/
switch (ue->type) {
case KVM_IRQ_ROUTING_IRQCHIP:
if (irqchip_split(kvm))
return -EINVAL;
e->irqchip.pin = ue->u.irqchip.pin;
switch (ue->u.irqchip.irqchip) {
case KVM_IRQCHIP_PIC_SLAVE:
e->irqchip.pin += PIC_NUM_PINS / 2;
/* fall through */
case KVM_IRQCHIP_PIC_MASTER:
if (ue->u.irqchip.pin >= PIC_NUM_PINS / 2)
return -EINVAL;
e->set = kvm_set_pic_irq;
break;
case KVM_IRQCHIP_IOAPIC:
if (ue->u.irqchip.pin >= KVM_IOAPIC_NUM_PINS)
return -EINVAL;
e->set = kvm_set_ioapic_irq;
break;
default:
return -EINVAL;
}
e->irqchip.irqchip = ue->u.irqchip.irqchip;
break;
...
さて、ここまで見てきたところで再度irqfdの話に立ち戻ろう。
先ほどは説明していなかったが、実はkvm_irqfd_assign関数の中では、init_waitqueue_func_entry(&irqfd->wait, irqfd_wakeup)という処理が呼び出され、&irqfd->wait->funcにirqfd_wakeupを登録している。
割り込みが発生した際にこの関数が呼び出され、この中でschedule_work(&irqfd->inject)が呼ばれる。
このinjectフィールドもkvm_irqfd_assign関数の中で初期化されており、結果としてirqfd_inject関数が呼び出されることになり、さらにこの関数の中でkvm_set_irq関数が呼び出される。
kvm_set_irqの処理の中では、割り込みがきたIRQ番号を持つエントリをリストアップし、そのsetコールバックを呼び出していく。これはつまり上記で説明したkvm_set_pic_irqやkvm_set_ioapic_irqなどの関数が呼ばれることを意味する。
以上の流れが割り込みとGSI、IRQ間Routingの実際の処理の部分である。
ここからの話は割り込み処理に対してもう少し深掘りした内容になるが、ToyVMMを理解する上で必ずしも必要ではない内容なので、ToyVMM serial consoleまで読み飛ばしてもらっても構わない。
折角なので、割り込みハンドラであるkvm_set_pic_irqの処理についてもう少し見てみることにしよう。やや話が脱線してきたが、せっかくなのでキリのいいところまで深掘りして確認してみる。
kvm_set_pic_irqは、KVM_CREATE_IRQCHIPで初期化したkvm->arch.vpicを利用して、kvm_pic_set_irqを呼び出しているのみである。
static int kvm_set_pic_irq(struct kvm_kernel_irq_routing_entry *e,
struct kvm *kvm, int irq_source_id, int level,
bool line_status)
{
struct kvm_pic *pic = kvm->arch.vpic;
return kvm_pic_set_irq(pic, e->irqchip.pin, irq_source_id, level);
}
さて、kvm_pic_set_irqの実装を見にいくと以下のようになっている。
int kvm_pic_set_irq(struct kvm_pic *s, int irq, int irq_source_id, int level)
{
int ret, irq_level;
BUG_ON(irq < 0 || irq >= PIC_NUM_PINS);
pic_lock(s);
irq_level = __kvm_irq_line_state(&s->irq_states[irq],
irq_source_id, level);
ret = pic_set_irq1(&s->pics[irq >> 3], irq & 7, irq_level);
pic_update_irq(s);
trace_kvm_pic_set_irq(irq >> 3, irq & 7, s->pics[irq >> 3].elcr,
s->pics[irq >> 3].imr, ret == 0);
pic_unlock(s);
return ret;
}
pic_set_irq1関数でIRQ Levelの設定を行い、pic_update_irq関数でpic_irq_request関数を呼び出し、kvm->arch.vpicに格納されているkvm_pic構造体の中を以下のように書き換えている。
/*
* raise irq to CPU if necessary. must be called every time the active
* irq may change
*/
static void pic_update_irq(struct kvm_pic *s)
{
int irq2, irq;
irq2 = pic_get_irq(&s->pics[1]);
if (irq2 >= 0) {
/*
* if irq request by slave pic, signal master PIC
*/
pic_set_irq1(&s->pics[0], 2, 1);
pic_set_irq1(&s->pics[0], 2, 0);
}
irq = pic_get_irq(&s->pics[0]);
pic_irq_request(s->kvm, irq >= 0);
}
/*
* callback when PIC0 irq status changed
*/
static void pic_irq_request(struct kvm *kvm, int level)
{
struct kvm_pic *s = kvm->arch.vpic;
if (!s->output)
s->wakeup_needed = true;
s->output = level;
}
さらにこの上でpic_unlock関数を呼び出しているが、この関数の中身が地味に重要である。
wakeup_neededがtrueの場合、vCPUに対してkvm_vcpu_kickを実行している。
static void pic_unlock(struct kvm_pic *s)
__releases(&s->lock)
{
bool wakeup = s->wakeup_needed;
struct kvm_vcpu *vcpu;
int i;
s->wakeup_needed = false;
spin_unlock(&s->lock);
if (wakeup) {
kvm_for_each_vcpu(i, vcpu, s->kvm) {
if (kvm_apic_accept_pic_intr(vcpu)) {
kvm_make_request(KVM_REQ_EVENT, vcpu);
kvm_vcpu_kick(vcpu);
return;
}
}
}
}
/*
* Kick a sleeping VCPU, or a guest VCPU in guest mode, into host kernel mode.
*/
void kvm_vcpu_kick(struct kvm_vcpu *vcpu)
{
int me;
int cpu = vcpu->cpu;
if (kvm_vcpu_wake_up(vcpu))
return;
me = get_cpu();
if (cpu != me && (unsigned)cpu < nr_cpu_ids && cpu_online(cpu))
if (kvm_arch_vcpu_should_kick(vcpu))
smp_send_reschedule(cpu);
put_cpu();
}
smp_send_rescheduleを呼び出した結果として、native_smp_send_reschedule関数が呼ばれる。
/*
* this function sends a 'reschedule' IPI to another CPU.
* it goes straight through and wastes no time serializing
* anything. Worst case is that we lose a reschedule ...
*/
static void native_smp_send_reschedule(int cpu)
{
if (unlikely(cpu_is_offline(cpu))) {
WARN_ON(1);
return;
}
apic->send_IPI(cpu, RESCHEDULE_VECTOR);
}
コメントにもある通り、この関数を呼び出すと別のCPUに対してIPI(Inter Process Interrupt)を発行し再スケジュールを促す。
vCPUに対して割り込みを送り、仮想マシンから強制的にVMExitする。そしてvCPUにスケジュールする際に割り込みが挿入されることになる。
さて、では実際に割り込みが挿入される処理も簡単に確認して割り込みの話を一旦終えることにしよう。
KVM_RUNが実行されると、以下のような処理が実行されていくようである(あくまで割り込みの挿入のみに着目しているため、そのほか膨大な処理は省略している)
kvm_arch_vcpu_ioctl_run
-> vcpu_run
-> vcpu_enter_guest
-> inject_pending_event
-> kvm_cpu_has_injectable_intr
kvm_cpu_has_injectable_intrの中でkvm_cpu_has_extint関数が呼ばれる。この処理は今回の場合はおそらくpic_irq_requestで設定したs->outputの値でreturnするため、この関数は全体としてreturn 1を返す。
そのため、inject_pending_event関数の以下の処理に差し掛かる。
} else if (kvm_cpu_has_injectable_intr(vcpu)) {
/*
* Because interrupts can be injected asynchronously, we are
* calling check_nested_events again here to avoid a race condition.
* See https://lkml.org/lkml/2014/7/2/60 for discussion about this
* proposal and current concerns. Perhaps we should be setting
* KVM_REQ_EVENT only on certain events and not unconditionally?
*/
if (is_guest_mode(vcpu) && kvm_x86_ops->check_nested_events) {
r = kvm_x86_ops->check_nested_events(vcpu, req_int_win);
if (r != 0)
return r;
}
if (kvm_x86_ops->interrupt_allowed(vcpu)) {
kvm_queue_interrupt(vcpu, kvm_cpu_get_interrupt(vcpu),
false);
kvm_x86_ops->set_irq(vcpu);
}
}
結果的にkvm_x86_ops->set_irq(vcpu)が呼ばれる。これはコールバック関数としてvmx_inject_irqが実行されることになる。
この処理で、VMCS(Virtual Machine Control Structure)にVMX_ENTRY_INTR_INFO_FIELDを設定することで割り込みを挿入している。
VMCSについての説明を行なっていないが、この話を始めるとハイパーバイザーの実装についての話が必要になってくるのでここでは省略する。
将来的に、補足情報としてドキュメントに追記するかもしれない。
長くなったが以上がPICを例にとった割り込み処理の流れである。
ToyVMM serial console
さて、この辺りで割り込みに関する探索を一旦切り上げて、ToyVMMの実装の話に戻ろう。
これまでの話を踏まえながら、ToyVMM側ではどのような処理を実行しており、それが裏ではどのような処理として実行されているかを整理する。
ToyVMMの中で、先に紹介したregister_irqfdを実施する前に実は以下のような処理を行なっていた。
#![allow(unused)] fn main() { vm.setup_irqchip().unwrap(); }
この関数は薄いwrapperになっており、内部的にはcreate_irq_chipの呼び出しとcreate_pit2の呼び出しを行う処理になっている。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] pub fn setup_irqchip(&self) -> Result<()> { self.fd.create_irq_chip().map_err(Error::VmSetup)?; let pit_config = kvm_pit_config { flags: KVM_PIT_SPEAKER_DUMMY, ..Default::default() }; self.fd.create_pit2(pit_config).map_err(Error::VmSetup) } }
ここで重要なのは、create_irq_chip関数である。これは内部的にはKVM_CREATE_IRQCHIPのAPIを叩いており、これによって先に話した通り、割り込みコントローラの初期化、IRQ Routingの初期化などを実施する。
上記セットアップ済みのGuest VMに対して、先に説明したregister_irqfd(&com_evt_1_3, 4)を実行することでKVM_IRQFD APIを叩き、先に説明したkvm_irqfd_assignなどの処理が実施されるため、割り込みハンドラの設定などが行われる。
これで、ToyVMMからKVM APIを利用した割り込み関係のセットアップは完了である。
さて、改めてcom_evt_1_3のからの割り込みについて確認してみよう。割り込みの一端は上記で見た通りregister_irqfdによってGSI=4とともにKVM側に受け渡しているため、もう一端から発行されたwriteをGuest VMへのCOM1ポートへのinterruptとして取り扱うことになる。一方、問題のcom_evt_1_3のもう一端はSerial Deviceに渡してあるためSerial Device側で実行されるeventfdへのwrite(Serial::writeによる書き込み処理後や、後述するSerial::enqueue_raw_byteによる呼び出しで発生する)が具体的な割り込みのトリガである。
つまり、通常のサーバとSerial Deviceが行うやりとりと同じような形で、Guest VMとSoftware実装のSerial Deviceがやりとりをするような構成を構築できる。
また、Serial Consoleを表現するために、今回はSerial Deviceのoutputに該当する書き込み先はstdoutに設定している。そのため、KVM_EXIT_IO_OUTをハンドルした際のTHRへの書き込みがstdoutへの書き込みへと渡っていき、結果として標準出力にコンソールメッセージが出力され、目的のSerial Consoleとしての機能が実現される。
標準入力でGuest VMを操作する。
最後に標準入力の内容をGuest VMに反映させてGuest VMを操作できるようにしたい。
rust-vmm/vm-superioが提供するSerial構造体には、enqueue_raw_bytesというヘルパー関数が存在し、これを利用するとあまり細かいレジスタ操作や割り込みを考えずにGuest VMに対してデータを送信することができる(関数の実装がこれらの処理をうまく実施してくれる)
あとはこれを標準入力から受け取った入力をプログラムで読み取って、このメソッドにそのまま引き渡してあげるようにすると目的の操作が達成できる。
標準入力をraw modeへと切り替えて、メインスレッドでpollingしながら標準入力を受け取ったらenqueue_raw_bytesでGuest VMに対してその入力内容を送信する。
Guest VMはvCPUごとに別スレッドを起動して処理させているため、メインスレッドで標準入力をpollingすることによるGuest VMの処理への影響はない。
#![allow(unused)] fn main() { let stdin_handle = io::stdin(); let stdin_lock = stdin_handle.lock(); stdin_lock .set_raw_mode() .expect("failed to set terminal raw mode"); let ctx: PollContext<Token> = PollContext::new().unwrap(); ctx.add(&exit_evt, Token::Exit).unwrap(); ctx.add(&stdin_lock, Token::Stdin).unwrap(); 'poll: loop { let pollevents: PollEvents<Token> = ctx.wait().unwrap(); let tokens: Vec<Token> = pollevents.iter_readable().map(|e| e.token()).collect(); for &token in tokens.iter() { match token { Token::Exit => { println!("vcpu requested shutdown"); break 'poll; } Token::Stdin => { let mut out = [0u8; 64]; tx.send(true).unwrap(); match stdin_lock.read_raw(&mut out[..]) { Ok(0) => { println!("eof!"); } Ok(count) => { stdio_serial .lock() .unwrap() .serial .enqueue_raw_bytes(&out[..count]) .expect("failed to enqueue bytes"); } Err(e) => { println!("error while reading stdin: {:?}", e); } } } _ => {} } } } }
ナイーブな実装でありあまり特別なことは行っていないので特に説明すべきことはない。
単純だが以上で目的の動作が達成できる。
Check UART request in booting linux kernel.
これまでSerial UARTのソフトウェア実装の内容とToyVMM内部での利用方法について記載した。
これは実際にうまく動作するが、やはりLinux Kernel起動時のUARTのやりとりを確認してみたいところである。
幸い、VMMの仕組み上、KVM_EXIT_IOをハンドルする必要があるため、このハンドリング処理にデバッグコードを仕込むことでシリアルポートに送付された全てのリクエストを盗み見ることができる。
ただし全ての内容を確認するのは現実的ではないので、一部だけ簡単に確認してみようと思う。
デバッグするために差し込んだコードの説明はここでは行わない。適切な処理箇所にデバッグコードを入れるだけのため非常に単純である。
以降では、シリアルポートへのリクエストに対して、分かりやすいように以下のような3種類の書式に従って注釈をつけている。
[書式1 - Read]
r($register) = $data
- Description
・ r = Read operation
・ $register = デバイスのアドレス(0x3f8)を利用して計算したoffsetに対応するレジスタ
・ $data = $registerから読み出したデータ
・ Description = 説明文
[書式2 - Write]
w($register = $data)
- Description
・ w = Write operation
・ $register = デバイスのアドレス(0x3f8)を利用して計算したoffsetに対応するレジスタ
・ $data = $registerの値に書き込むデータ
・ Description = 説明文
[書式3 - Write (character)]
w(THR = $data = 0xYY) -> 'CHAR'
・ w(THR ...) = Write operation to THR
・ $data = $registerの値に書き込むbinaryデータ
・ 0xYY = $dataをhexに変換したもの
・ 'CHAR' = 0xYYをASCIIコード表に基づきcharacterに変換したもの
さて、以下は少し長いがOS起動時の0x3f8 (COM1)のレジスタに対してのリクエストを上記の書式に従ってわかりやすく書き表したものである。
# 最初はbaud lateなどの初期設定を行なっている
w(IER = 0)
w(LCR = 10010011)
- DLAB = 1 (DLAB: DLL and DLM accessible)
- Break signal = 0 (Break signal disabled)
- Parity = 010 (No parity)
- Stop bits = 0 (1 stop bit)
- Data bits = 11 (8 data bit)
w(DLL = 00001100)
w(DLM = 0)
- DLL = 0x0C, DLM = 0x00 (Speed = 9600 bps)
w(LCR = 00010011)
- DLAB = 0 (DLAB : RBR, THR, and IER accessible)
- Break signal = 0 (Break signal disabled)
- Parity = 010 (No parity)
- Stop bits = 0 (1 stop bit)
- Data bits = 11 (8 data bit)
w(FCR = 0)
w(MCR = 00000001)
- Reserved = 00
- Autoflow control = 0
- Loopback mode = 0
- Auxiliary output 2 = 0
- Auxiliary output 1 = 0
- Request to send = 0
- Data terminal ready = 1
r(IER) = 0
w(IER = 0)
# この辺りから実際にコンソール出力をシリアルで受け取り、write(今回の場合はstdoutへのwrite)をしている様子である
# r(LSR)の内容をみて、次の文字を書いていいか判別していると思われる
r(LSR) = 01100000
- Errornous data in FIFO = 0
- THR is empty, and line is idle = 1
- THR is empty = 1
- Break signal received = 0
- Framing error = 0
- Parity error = 0
- Overrun error = 0
- Data available = 0
- 5, 6 bitはcharacter transmitterに関わるもので、UARTが次のcharacterを受付可能かの識別に利用する
- 5, 6 bitが立っていれば、新たなcharacterを受け付けることができる
- Bit 6 = '1' means that all characters have been transmitted
- Bit 5 = '1' means that UARTs is capable of receiving more characters
# 上記で、次の文字のwriteを受け付けているので、outputしたい文字を書く。
w(THR = 01011011 = 0x5b) -> '['
# 以降、これの繰り返しを行っている
r(LSR) = 01100000
w(THR = 00100000 = 0x20) -> ' '
# 上記の処理があと3回続く
r(LSR) = 01100000
w(THR = 00110000 = 0x30) -> '0'
r(LSR) = 01100000
w(THR = 00101110 = 0x2e) -> '.'
r(LSR) = 01100000
w(THR = 00110000 = 0x30) -> '0'
# 上記の処理があと5回続く
r(LSR) = 01100000
w(THR = 01011101 = 0x5d) -> ']'
r(LSR) = 01100000
w(THR = 00100000 = 0x20) -> ' '
r(LSR) = 01100000
w(THR = 01001100 = 0x4c) -> 'L'
r(LSR) = 01100000
w(THR = 01101001 = 0x69) -> 'i'
r(LSR) = 01100000
w(THR = 01101110 = 0x6e) -> 'n'
r(LSR) = 01100000
w(THR = 01110101 = 0x75) -> 'u'
r(LSR) = 01100000
w(THR = 01111000 = 0x78) -> 'x'
r(LSR) = 01100000
w(THR = 00100000 = 0x20) -> ' '
r(LSR) = 01100000
w(THR = 01110110 = 0x76) -> 'v'
r(LSR) = 01100000
w(THR = 01100101 = 0x65) -> 'e'
r(LSR) = 01100000
w(THR = 01110010 = 0x72) -> 'r'
r(LSR) = 01100000
w(THR = 01110011 = 0x73) -> 's'
r(LSR) = 01100000
w(THR = 01101001 = 0x69) -> 'i'
r(LSR) = 01100000
w(THR = 01101111 = 0x6f) -> 'o'
r(LSR) = 01100000
w(THR = 01101110 = 0x6e) -> 'n'
r(LSR) = 01100000
w(THR = 00100000 = 0x20) -> ' '
r(LSR) = 01100000
w(THR = 00110100 = 0x34) -> '4'
r(LSR) = 01100000
w(THR = 00101110 = 0x2e)-> '.'
r(LSR) = 01100000
w(THR = 00110001 = 0x31) -> '1'
r(LSR) = 01100000
w(THR = 00110100 = 0x34) -> '4'
r(LSR) = 01100000
w(THR = 00101110 = 0x2e) -> '.'
r(LSR) = 01100000
w(THR = 00110001 = 0x31) -> '1'
r(LSR) = 01100000
w(THR = 00110111 = 0x37) -> '7'
r(LSR) = 01100000
w(THR = 00110100 = 0x34) -> '4'
r(LSR) = 01100000
w(THR = 00100000 = 0x20) -> ' '
r(LSR) = 01100000
w(THR = 00101000 = 0x28) -> '('
r(LSR) = 01100000
w(THR = 01000000 = 0x40) -> '@'
w(LSR) = 01100000
r(THR = 00110101 = 0x35) -> '5'
r(LSR) = 01100000
w(THR = 00110111 = 0x37) -> '7'
r(LSR) = 01100000
w(THR = 01100101 = 0x65) -> 'e'
r(LSR) = 01100000
w(THR = 01100100 = 0x64) -> 'd'
r(LSR) = 01100000
w(THR = 01100101 = 0x65) -> 'e'
r(LSR) = 01100000
w(THR = 01100010 = 0x62) -> 'b'
r(LSR) = 01100000
w(THR = 01100010 = 0x62) -> 'b'
r(LSR) = 01100000
w(THR = 00111001 = 0x39) -> '9'
r(LSR) = 01100000
w(THR = 00111001 = 0x39) -> '9'
r(LSR) = 01100000
w(THR = 01100100 = 0x64) -> 'd'
r(LSR) = 01100000
w(THR = 01100010 = 0x62) -> 'b'
r(LSR) = 01100000
w(THR = 00110111 = 0x37) -> '7'
r(LSR) = 01100000
w(THR = 00101001 = 0x29) -> ')'
# 上記の出力を並べると以下のようになる。
[ 0.000000] Linux version 4.14.174 (@57edebb99db7)
# これはOSブート時に出力される一行目の内容と一致していることがわかる
当然ながらLinux起動時のUART requestはまだまだ続き、かつ上記に示すような単純な出力以外の処理も行われるが、ここではこれ以上の確認は行わないので気になる方は各々確認してほしい。
Reference
- Serial UART information
- Wikibooks : Serial Programming / 8250 UART Programming
- rust-vmm/vm-superio
- Interrupt request(PC architecture)
- Linux Serial Console
- KVM IRQFD Implementation
- KVMのなかみ(KVM internals)
- ハイパーバイザーの作り方~ちゃんと理解する仮想化技術~ 第2回 intel VT-xの概要とメモリ仮想化
- External Interrupts in the x86 system. Part1. Interrupt controller evolution