1. 症状

記事執筆時点ではkvm上でNetBSD/amd64はインストーラを起動することもできません。この組み合わせを例にとり、OSと仮想マシンの低レベルの処理の解説を兼ね、判明した問題点と修正方法を解説します。

目的

仮想マシンは実在の標準的なハードウェアをエミュレートしているはずであり、一般の(任意の?)OSの実行に支障があるのは正しい姿ではありません。
一方、標準的な部品とBIOSを組みあわせて作られているわけではなく、仮想マシンの開発者は必ずしも十分な検証の手間はかけられないので、使われる頻度の低いOSの動作に支障のある特殊な存在となってしまうのは仕方がない面もあると言えます。また、OS側も仮想マシン上での動作を検証する人的資源を欠くこともあります。
記事執筆時点ではkvm上でNetBSD/amd64はインストーラを起動することもできません。この組み合わせを例にとり、OSと仮想マシンの低レベルの処理の解説を兼ねて、判明した問題点と修正方法を解説します。

それぞれの問題点はOSと仮想マシンのいずれの側でも対策できますが、より受け入れられやすい修正とするためにACPIの仕様書などからどちらかのバグと判断できるものについては、それぞれのバグを修正する方向で行いました。
仮想マシンとしてはバグのあるOSも動作させられるほうが望ましいですし、特に古いOSを動作させたい場合などは原因がOS側にあっても仮想マシン側で回避する必要があります。
しかし、そのような回避策を実装すると他の今まで動いていたOSの動作に支障が出る可能性もあり、技術的にはともかく政治的にそのような修正は難しいのではないかと思われます。
一方、既にある仮想マシンを使うユーザーにとって便利なように、仮想マシンのバグに合わせてOS側が対策することもできます。

対象とするバージョン

  • Linux 2.6.26-2-amd64(Debian lenny)
  • kvm-84
  • NetBSD/amd64 5.0

症状

(1)特別なオプションを指定しなかった場合

$ qemu-img create -f qcow2 netbsd-dbg.img 1G
Formatting ‘netbsd-dbg.img’, fmt=qcow2, size=1048576 kB
$ qemu-system-x86_64 -m 512 netbsd-dbg.img -cdrom Desktop/amd64cd-5.0.iso

vga0が認識されていないのが原因です。そもそもvga0がattachするpciバスが認識されていません。
特に起動時にエラーメッセージは見られません。

【図 crash1】

 

(2)qemuに-no-acpiを指定した場合

$ qemu-system-x86_64 -no-acpi -m 512 netbsd-dbg.img -cdrom Desktop/amd64cd-5.0.iso

(1)と同様のpanicが発生します。pciバスが認識されないのも同様です。

(3)NetBSDのブートローダでACPIおよびSMPサポートを無効にした場合

panicにはなりませんが、ブート途中で固まって動かなくなります。

【図 hang1】

また、【図 debug】のようにpciバスおよびvga0は認識されています。

【図 debug】

(4)NetBSDのブートローダでACPIおよびSMPサポートを無効にし、qemuに-no-kvmを指定した場合

pciバスの認識を含めて正常に起動します。

(5)serial console

NetBSDのboot promptを選択し、【図 serial】のようにコンソールデバイスをcom0にします。

【図 serial】

Ctrl-Alt-3でqemuのserial console画面になるので、そこで
> boot
とコマンドを入力すると、インストーラが起動します。pciバスが認識されないのは変わりありません。

2. デバッグの手順

前章で述べた症状をまとめると以下のようになります。

 

  • ACPIまたはSMPサポートが有効であると、pciバスが認識されない
  • kvmが有効でACPIおよびSMPサポートを無効にすると、bootしない

まずbootしないという症状をより詳しく調査することを試みます。
qemu monitorからRIPレジスタをみてみます。

【図 hang2】

 

インストールメディアのnetbsdにはsymbol tableがついていませんが、netbsd-INSTALL.symbols.gzというnm(1)コマンドの出力があるので、これを用いてどのあたりでhangしているかを調べることにします。

 

0xffffffff801157a7 => spllower
0xffffffff804f9b91 => mutex_enter
0xffffffff80474880 => cache_lock_cpus
0xffffffff804290e8 => kpause
0xffffffff80424bd8 => sleepq_abort

残念なことにこれでは原因はよくわからないので、Ctrl-Alt-ESCでNetBSDカーネル内蔵のデバッガに落とし、スタックトレースを観察します。

【図 hang3】

0xffffffff80101bb3より上は、Ctrl-Alt-ESCで発生したキーボード割りこみによるものなので意味はありません。

0xffffffff80474fd0 => cache_thread
0xffffffff80474ae0 => cache_reclaim
0xffffffff80474fb5 => cache_thread

cache_thread()は以下のような関数で、何度デバッガに落としてもスタックトレースにcache_thread()が現れ、【図 hang2】のRIPレジスタの値もcache_thread()から呼ばれるものなので、何らかの理由でこの関数のループをずっと実行しているのではないかと想像できます。

/*
 * Cache maintainence thread, awakening once per second to:
 *
 * => keep number of entries below the high water mark
 * => sort pseudo-LRU list
 * => garbage collect dead entries
 */
static void
cache_thread(void *arg)
{
    mutex_enter(namecache_lock);
    for (;;) {
        cache_reclaim();
        kpause("cachegc", false, hz, namecache_lock);
    }
}

この方向からさらに追及することは困難と思われますので、別のアプローチとしてpciバスが認識されない問題のデバッグを試みることにします。pciバスの認識される場所は、sys/arch/amd64/mainbus.cのmainbus_attach()内の以下のコードです。

#if NPCI > 0
    if (pci_mode != 0) {
        mba.mba_pba.pba_iot = X86_BUS_SPACE_IO;
        mba.mba_pba.pba_memt = X86_BUS_SPACE_MEM;
        mba.mba_pba.pba_dmat = &pci_bus_dma_tag;
        mba.mba_pba.pba_dmat64 = &pci_bus_dma64_tag;
        mba.mba_pba.pba_pc = NULL;
        mba.mba_pba.pba_flags = pci_bus_flags();
        mba.mba_pba.pba_bus = 0;
        mba.mba_pba.pba_bridgetag = NULL;
#if NACPI > 0 && defined(ACPI_SCANPCI)
        if (mpacpi_active)
            mpacpi_scan_pci(self, &mba.mba_pba, pcibusprint);
        else
#endif
#if defined(MPBIOS) && defined(MPBIOS_SCANPCI)
        if (mpbios_scanned != 0)
            mpbios_scan_pci(self, &mba.mba_pba, pcibusprint);
        else
#endif
        config_found_ia(self, "pcibus", &mba.mba_pba, pcibusprint);
#if NACPI > 0
        if (mp_verbose)
            acpi_pci_link_state();
#endif
    }
#endif

qemuのオプションに-no-acpiを指定するとmpacpi_activeが0に、ブートローダでACPIおよびSMPサポートを無効にするとmpbios_scannedも0になります。
mpbios_scan_pci()は以下のような関数で、

int
mpbios_scan_pci(struct device *self, struct pcibus_attach_args *pba,
        cfprint_t print)
{
    int i;
    struct mp_bus *mpb;
    struct pci_attach_args;
    for (i = 0; i < mp_nbus; i++) {
        mpb = &mp_busses[i];
        if (mpb->mb_name == NULL)
            continue;
        if (!strcmp(mpb->mb_name, "pci") && mpb->mb_configured == 0) {
            pba->pba_bus = i;
            config_found_ia(self, "pcibus", pba, print);
        }
    }
    return 0;
}

ここのmp_busses[]はMP BIOSのconfiguration tableをもとにmpbios_scan()から初期化されるデータです。このtableが作られるのは、kvm-84/bios/rombios32.c内のmptable_init()です。(kvm-84/qemu/pc-biosというディレクトリにも似たものがありますが、こちらは使われていないので注意が必要です。)mptable_init()を見るとISA バスのエントリは作成されていますが、PCIバスのものは作成されていないことがわかり、これがNetBSDがpciバスを認識できない理由であると考えられます。解決方法はpciバスのエントリを作成することであり、その方法は次章で解説します。

修正箇所1

MP table

IntelのサイトからダウンロードできるMultiProcessor Specification(Version 1.4、 May 1997)によると、MP Configuration tableはAPIC、プロセッサ、バス、および割り込みの情報を記述するものです。以下に添付します【mptable.diff】で示したように修正すればよいでしょう。

mptable.diff

--- kvm-84.orig/bios/rombios32.c 2009-02-13 00:19:26.000000000 +0900
+++ kvm-84/bios/rombios32.c 2009-04-17 16:52:56.000000000 +0900
@@ -1105,7 +1105,12 @@ static void mptable_init(void)
     putstr(&q, "0.1         "); /* vendor id */
     putle32(&q, 0); /* OEM table ptr */
     putle16(&q, 0); /* OEM table size */
+#define MPTABLE_PCI
+#ifdef MPTABLE_PCI
+    putle16(&q, MAX_CPUS + 19); /* entry count */
+#else
     putle16(&q, MAX_CPUS + 18); /* entry count */
+#endif
     putle32(&q, 0xfee00000); /* local APIC addr */
     putle16(&q, 0); /* ext table length */
     putb(&q, 0); /* ext table checksum */
@@ -1135,8 +1140,18 @@ static void mptable_init(void)
     }
     /* isa bus */
+#ifdef MPTABLE_PCI
+    putb(&q, 1); /* entry type = bus */
+    putb(&q, 0); /* bus ID */
+    putstr(&q, "PCI   ");
+#endif
+
     putb(&q, 1); /* entry type = bus */
+#ifdef MPTABLE_PCI
+    putb(&q, 1); /* bus ID */
+#else
     putb(&q, 0); /* bus ID */
+#endif
     putstr(&q, "ISA   ");
     /* ioapic */
@@ -1151,9 +1166,21 @@ static void mptable_init(void)
     for(i = 0; i < 16; i++) {
         putb(&q, 3); /* entry type = I/O interrupt */
         putb(&q, 0); /* interrupt type = vectored interrupt */
-        putb(&q, 0); /* flags: po=0, el=0 */
+#if 0
+   if (0)
+#else
+        if ( PCI_ISA_IRQ_MASK & (1U << i) )
+#endif
+   {
+            putb(&q, 0xd); /* flags: po=1, el=3 */
+   } else
+            putb(&q, 0); /* flags: po=0, el=0 */
         putb(&q, 0);
+#ifdef MPTABLE_PCI
+        putb(&q, 1); /* source bus ID = ISA */
+#else
         putb(&q, 0); /* source bus ID = ISA */
+#endif
         putb(&q, i); /* source bus IRQ */
         putb(&q, ioapic_id); /* dest I/O APIC ID */
         putb(&q, i); /* dest I/O APIC interrupt in */

これは、以下3点の変更となります。

 

  1. ISA bus entryの前にPCI bus entryを追加
  2. 上記の変更によりISA busのIDが変わるのでI/O Interrupt entryのbus IDを合わせて変更
  3. I/O Interrupt entryのPOとELフィールドをACPIのテーブルのものと同じになるように修正

(1)の変更点の補足として、PCIバスを先にID 0で記述しないとうまくいきません。複数のPCIバスがある時の注意として、AppendixD.2にPCIバスの列挙はID 0から始めろというものはありましたが、それ以外にバスIDをどの順番で付けるかについての記述は見つけることができませんでした。また、(3)の変更がないと、PCIバスは認識されるものの、割りこみがおかしくなってうまく動作しません。

NetBSDのACPI実装

前述の修正により、qemuに-no-acpiオプションを指定した場合は動作するようになりましたが、ACPIが有効の場合はmpacpi_scan_pci()でPCIバスの認識が行われるのでまた別の問題となります。(上記で解決したのはmpbios_scan_pci()の問題です。)
NetBSDのACPIのコードを見るのでなければ、ACPI_DEBUGなどを有効にしたカーネルを作ってどこで失敗しているか見るのが通常のデバッグ方法になりますが、この時点ではまだinstallできていないので、インストールメディアのカーネルを入れかえる必要があり若干手間が掛かります。そこで、netbsd-INSTALL.symbols.gzをverboseでgrepして、とりあえず有効にできるものがないかを調べます。mp_verboseがあるので、これを有効にしてなにか手掛りとなるメッセージが表示されないか試すことにします。

ブートプロンプトに-dオプションを指定してデバッガからmp_verboseに1を書きこみます。pciバスが見つからずbootに失敗した後、デバッガのdmesgコマンドでdmesgを確認すると、以下のような出力が得られます。

【図 mp_verbose】

mpacpi: switch to APIC mode failedというメッセージが確認できます。この文字列でソースコードをgrepすると、sys/arch/x86/x86/mpacpi.c内に以下の関数が見つかります。

int
mpacpi_find_interrupts(void *self)
{
(略)
#if NIOAPIC > 0
    if (mpacpi_nioapic != 0) {
        /*
         * Switch us into APIC mode by evaluating _PIC(1).
         * Needs to be done now, since it has an effect on
         * the interrupt information we're about to retrieve.
         */
        arglist.Count = 1;
        arglist.Pointer = &arg;
        arg.Type = ACPI_TYPE_INTEGER;
        arg.Integer.Value = 1; /* I/O APIC (0 = PIC, 2 = IOSAPIC) */
        rv = AcpiEvaluateObject(NULL, "_PIC", &arglist, NULL);
        if (ACPI_FAILURE(rv)) {
            if (mp_verbose)
                printf("mpacpi: switch to APIC mode failedn");
            return 0;
        }
    }
#endif
(以下略)

ACPIの仕様をみると、_PICはOSが使う割り込みモード(PIC、APICなど)をBIOSに通知するものであり、optionalであると記述されています。
kvm-84/biosの下を_PICでgrepしてもなにも見つからず、_PICメソッドは実装されていないと思われます。Linuxでは、linux-2.6/drivers/acpi/bus.cで、

static int __init acpi_bus_init_irq(void)
{
(略)
    status = acpi_evaluate_object(NULL, "_PIC", &arg_list, NULL);
    if (ACPI_FAILURE(status) && (status != AE_NOT_FOUND)) {
        ACPI_EXCEPTION((AE_INFO, status, "Evaluating _PIC"));
        return -ENODEV;
    }
    return 0;
}

となっており、_PICメソッドがない場合はエラーにしないようになっています。NetBSDの実装も同様に変更することで、pciバスが検出されbootに成功するようになりました。

修正箇所2

qemu/hw/rtl8139.c

ネットワークデバイスにも問題があります。インストールCDからshellを実行し、

# dhclient re0
# ping 10.0.2.2

を実行すると【図 re-timeout】のとおりとなります。

【図 re-timeout】

watchdog timeoutというのは、大抵の場合は送信側でうまく割り込みが発生していないことが原因ですので、その推測のもとに調査を進めます。
まず、kvm-84/qemu/hw/rtl8139.c内でコメントアウトされているDEBUG_RTL8139の#defineを有効にして、デバッグメッセージを表示させます。
大量のデバッグメッセージが表示されますが、送信と関係がありそうな部分の抜粋を以下に示します。

デバッグメッセージ抜粋

RTL8139C+ TxPoll write(b) val=0x40
RTL8139C+ TxPoll normal priority transmission
RTL8139: +++ C+ mode reading TX descriptor 19 from host memory at 000000000x013df000 = 0x 13df130
RTL8139: +++ C+ mode TX descriptor 19 b0000036 00000000 06d85a5a 00000000
RTL8139: +++ C+ Tx mode : transmitting from descriptor 19
RTL8139: +++ C+ Tx mode : descriptor 19 is first segment descriptor
RTL8139: +++ C+ mode transmit reading 54 bytes from host memory at 0000000006d85a5a to offset 0
RTL8139: +++ C+ Tx mode : descriptor 19 is last segment descriptor
RTL8139: +++ C+ mode transmitting 54 bytes packet
RTL8139: +++ C+ mode reading TX descriptor 20 from host memory at 000000000x013df000 = 0x 13df140
RTL8139: +++ C+ mode TX descriptor 20 00000000 00000000 00000000 00000000
RTL8139: C+ Tx mode : descriptor 20 is owned by host
RTL8139: Set IRQ to 0 (0004 c07b)

最後の行のSet IRQ to 0というのが気になりますが、これは「TxOKビットは立ったが、その割り込みはマスクされていて割り込みは発生しない」という意味です。rtl8139.c内を IntrStatusおよびrtl8139_update_irqで検索してみると、送信に関する割り込みはTxOKしか発生しないようになっています。
NetBSDのドライバにおいて、送信の割り込みを処理するのはre_txeof()であり、以下の条件のときに呼ばれます。

if (status & (RTK_ISR_TIMEOUT_EXPIRED | RTK_ISR_TX_ERR |
RTK_ISR_TX_DESC_UNAVAIL))
re_txeof(sc);

RTK_ISR_TX_DESC_UNAVAILという名前からすると、送信デスクリプタが空になった場合に割り込みを発生させてまとめて処理するようになっているかとも思われますが、チップのマニュアルを取得するにはNDAが必要となり、またqemuはこの割り込みを発生させないので、これ以上の追求は差し控えます。

気をとりなおして、RTK_ISR_TIMEOUT_EXPIREDを調べることにします。これはqemuではPCSTimeoutという名前であり、実装されてはいるものの、#ifdef RTL8139_ONBOARD_TIMERで無効になっていて、rtl8139.cの冒頭のコメント内のchangelogにもdisableされていると記載されています。
また、このタイマはNetBSDのドライバでは以下の箇所で初期化されており、コメントからこれがTxOKの代わりに用いられていることがわかります。

/*
* Main transmit routine for C+ and gigE NICs.
*/
static void
re_start(struct ifnet *ifp)
{
(略)
        /*
         * Use the countdown timer for interrupt moderation.
         * 'TX done' interrupts are disabled. Instead, we reset the
         * countdown timer, which will begin counting until it hits
         * the value in the TIMERINT register, and then trigger an
         * interrupt. Each time we write to the TIMERCNT register,
         * the timer count is reset to 0.
         */
        CSR_WRITE_4(sc, RTK_TIMERCNT, 1);
        /*
         * Set a timeout in case the chip goes out to lunch.
         */
        ifp->if_timer = 5;
    }
}

対処としては、RTL8139_ONBOARD_TIMERを#defineしてコンパイルすることで、watchdog timeoutは起きなくなり正常に動作するようになります。このタイマはPCIの33MHzのクロックで動くカウンタを使っているようですが、qemuの実装では、このカウンタを律儀に1ずつインクリメントしようとしており、これがCPU負荷を発生させるためdefaultではdisableされているのではないかと思われます。

カウンタは1ずつインクリメントしなくても、必要になったときに計算すればいいように思われます。Linuxではタイマを使ってないので実装を手抜きしたのでしょうか?
念のためにCPU負荷をtopで観察すると1%程度なのか4割程度なのか判断しかねるデータが得られました。

CPU負荷の観察

top - 18:20:18 up 78 days,  5:57, 12 users, load average: 0.72, 0.30, 0.10
Tasks: 142 total,   2 running, 122 sleeping, 17 stopped,   1 zombie
Cpu(s):  0.8%us,  1.1%sy,  0.0%ni, 98.0%id, 0.0%wa,  0.0%hi,  0.0%si,  0.0%st
Mem:   2018344k total,  1982548k used,   35796k free,    78924k buffers
Swap:  3903752k total,     6564k used, 3897188k free,   270208k cached
PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM   TIME+  COMMAND
1870 toshii   20   0  710m 526m 4212 R   41 26.7  2:00.84 qemu-system-x86

topコマンドは、CPU統計は/proc/stat、プロセスのCPU%は/proc/n/statのutime+stimeの差分から取得しています。また、shellのtimeコマンドを使用してCPUの消費時間を測定すると、それも異なる数値が得られました。
この件もデバッグの題材としてはおもしろそうですが、本題からそれてしまいますので、 kernel.orgから執筆時点での最新版であるlinux-2.6.29.6を取得し、カーネルのみ入れかえて再測定を行いました。
ゲストOSのidle時にはCPU負荷は1%未満である測定結果が得られ、RTL8139_ONBOARD_TIMERを有効にしてもCPU負荷的には問題はないということが確認できました。

ちなみにLinuxのドライバではTxOKは最初は有効になっているのですが、TxOK割り込みが発生すると割り込みハンドラでTxOKがマスクされて無効になり、__napi_schedule()が呼ばれ、rtl8169_poll()から(タイミングによっては)まとめて処理されるようになっています。

まとめ

今回は以下の箇所に修正を加えました。

 

  • 仮想マシンBIOSのMP table
  • NetBSDのACPI実装
  • 仮想マシンのネットワークデバイス

これらの修正により、起動すらできなかったNetBSD/amd64が、特にエラーメッセージを発することもなく正常に動作するようになりました。