BitVisor
BitVisorDay 14

BitVisorのlwIPを使ってシリアルポートに書き込まれたデータをTCP/IPで送信する

More than 1 year has passed since last update.


まえがき

この前、BitVisor Summit 5へ遊びに行ったらアドベントカレンダーの募集をやってたので乗っかることにした。

肝心のアドベントカレンダーは、すげぇ人が書きまくってて中々のハードルの高さ。

初参戦・初心者が書いた記事なので、お手柔らかにお願いしますね!

で、サクッと簡単なところでレガシーなデバイスとlwIPで遊んでみたという内容で書くことにする。


これは何?

ゲストOSがシリアルポートに書いたデータをBitVisorのTCP/IPで流すというもの。

I/O mapped I/Oを使ったオンボードのシリアルポート(8250 UART)のアクセスを横取りして作ってみた。

TCP/IPは、最近機能追加されたlwIPを使って実装。通信は、BitVisor側がサーバになるようにして、クライアントとの接続を確立する。確立された後にシリアルポートへ書き込まれたデータをそのまま転送するという仕組み。

なお、シリアルポートのエミュレーション機能は無いので、BitVisorを動作させるPCにシリアルポート(COM1)が実装されていなければ使用できないので注意。

この点はBitVisorっぽい気がする、横取りして後は実ハード任せという感じが。


書いたプログラムの説明

全体の流れとしては、IOフックして捉えたデータをTCPに流す。ただそれだけ。


IOのフック

BitVisorには元々 core/serial.cserial_init_iohook にデバッグログをシリアルポートに出力するためのコードが書いてある。その中に TTY_SERIAL が有効になっている時に、OSからのシリアルポートアクセスを捨てるような処理がある。これを参考にして次に示すように改造する。

diff -r 76db0ae4260b core/serial.c

--- a/core/serial.c Mon Sep 12 20:01:54 2016 +0900
+++ b/core/serial.c Sun Dec 11 18:25:20 2016 +0900
@@ -165,13 +165,27 @@
serial_send (PORT, c);
}

+void hook_uart_putchar (int);
+static enum ioact
+hook_uart (enum iotype type, u32 port, void *data) // THR or RBR or DLL
+{
+ switch (type) {
+ case IOTYPE_OUTB:
+ if (!(port & 0x07))
+ hook_uart_putchar (*(u8 *)data);
+ break;
+ default:
+ break;
+ }
+ return do_iopass_default (type, port, data);
+}
+
void
serial_init_iohook (void)
{
unsigned int i;

for (i = PORT; i < PORT + NUM_OF_PORT; i++)
- set_iofunc (i, do_io_nothing);
+ set_iofunc (i, hook_uart);
}
diff -r 76db0ae4260b core/io_iopass.c
--- a/core/io_iopass.c Mon Sep 12 20:01:54 2016 +0900
+++ b/core/io_iopass.c Sun Dec 11 18:25:20 2016 +0900
@@ -38,6 +38,7 @@
#include "printf.h"
#include "tty.h"
#include "types.h"
+#include "serial.h"

enum ioact
do_iopass_default (enum iotype type, u32 port, void *data)
@@ -76,8 +77,12 @@
return;
for (i = 0; i < NUM_OF_IOPORT; i++)
set_iofunc (i, do_iopass_default);
+#ifndef TTY_SERIAL
+ serial_init_iohook ();
+#else
tty_init_iohook ();
debug_iohook ();
+#endif
acpi_iohook ();
}

元は、do_io_nothingを登録して、ゲストOSがIOを発行した時に何もしないという動作をする。この部分を必要な部分だけフック、あとはパススルーという動作に書き換えた。hook_uart関数がそれ。

hook_uart関数は、ベースのIOポート+0 (0x3F8) への書き込みのみをフックしてそれ以外はパススルーするように仕向ける。パススルー処理は自力で書いても良いが、do_iopass_defaultがあるので流用する(この辺りの書き方はなんとなくWINAPIのWndProcっぽい)。

0x3F8への書込みはシリアルポートへ出力となっているので、これを捉えれば書き込まれるデータがわかる(実際はちょっと違うが面倒なのでこれでやる)。

書き込まれたデータは、次で説明するTCP/IPのサーバプログラムに投げるようにしておく。

serial_init_iohookを書き換えただけでは、TTY_SERIALが宣言されていない時に、この処理を通ならくなるので、iopass.cに呼び出すコードを書き加えている。

注意点は、シリアルデバッグ時にBitVisorとゲストOSのデータが混るという問題が発生する(たぶん)。

今回は遊んでみただけなので、この問題はあまり気にしないことにしておく(初期化処理を書き換えずに専用の初期化関数に作っておけば良かった気がするが、やってしまったものは仕方ない)。

ここまででフック処理はできた。あとはTCPで送るだけ。


TCP/IP

lwIPというネットワークスタックが実装されているが、Linuxでよくあるソケットプログラミングと少しだけ違う。

lwIPは、組込システム向けに作られている。コールバックを使った非同期動作と思っておけば問題無いと思う。

実際に作る時は、ipディレトリの下に echo-server.c とか色々サンプルが転がっているのでそれを参考に作ればよさげ。

BitVisorでの作り方の注意としては、coreとかに置いてしまうとヘッダーのパスがかなり違うので、コンパイルがうまくいかない(というかMakefileの書換が面倒)。TCP/IPのプログラムを書くときは、ipディレクトリ配下に作る方がトラブルが少ない。

で、書いたのが以下。

diff -r 76db0ae4260b ip/Makefile

--- a/ip/Makefile Mon Sep 12 20:01:54 2016 +0900
+++ b/ip/Makefile Sun Dec 11 18:25:20 2016 +0900
@@ -41,3 +41,4 @@
objs-1 += ip_sys.o arch/sys_arch.o
objs-1 += ip_main.o net_main.o
objs-1 += echo-server.o echo-client.o echoctl.o
+objs-1 += serial-tcp.o
diff -r 76db0ae4260b ip/ip_main.c
--- a/ip/ip_main.c Mon Sep 12 20:01:54 2016 +0900
+++ b/ip/ip_main.c Sun Dec 11 18:25:20 2016 +0900
@@ -266,6 +266,8 @@

/* Configure and add network interface. */
tcpip_netif_conf (netif_arg, netif_num);
+
+ init_uart_tcp ();
}

void
diff -r 76db0ae4260b ip/serial-tcp.c
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/ip/serial-tcp.c Sun Dec 11 18:25:20 2016 +0900
@@ -0,0 +1,95 @@
+#include <core/spinlock.h>
+#include <core/list.h>
+#include "lwip/tcp.h"
+
+struct tcp_sock {
+ LIST1_DEFINE (struct tcp_sock);
+ struct tcp_pcb *pcb;
+};
+static LIST1_DEFINE_HEAD (struct tcp_sock, tcp_sock_list);
+static spinlock_t tcp_sock_lock;
+
+static err_t uart_tcp_recv (void *arg, struct tcp_pcb *pcb, struct pbuf *pbuf, err_t err)
+{
+ struct tcp_sock *p = (struct tcp_sock *)arg;
+ if (!pbuf) {
+ tcp_close (pcb);
+ printf ("Disconnected\n");
+
+ spinlock_lock (&tcp_sock_lock);
+ LIST1_DEL (tcp_sock_list, p);
+ spinlock_unlock (&tcp_sock_lock);
+ mem_free (p);
+ }
+
+ return err;
+}
+
+static err_t uart_tcp_accept (void *arg, struct tcp_pcb *newpcb, err_t err)
+{
+ struct tcp_sock *p;
+
+ LWIP_UNUSED_ARG(arg);
+ LWIP_UNUSED_ARG(err);
+
+ spinlock_lock (&tcp_sock_lock);
+ p = (struct tcp_sock *)mem_malloc (sizeof (struct tcp_sock));
+ if (!p)
+ panic ("mem_malloc");
+ p->pcb = newpcb;
+ LIST1_ADD (tcp_sock_list, p);
+ spinlock_unlock (&tcp_sock_lock);
+
+ tcp_arg (newpcb, p);
+ tcp_recv (newpcb, uart_tcp_recv);
+
+ printf ("Connected!\n");
+
+ return ERR_OK;
+}
+
+static bool initialized;
+
+void hook_uart_putchar (int ch)
+{
+ char str[1] = { ch };
+ struct tcp_sock *p;
+
+ //printf ("uart: putchar %02x\n", ch);
+
+ if (!initialized)
+ return;
+
+ spinlock_lock (&tcp_sock_lock);
+ LIST1_FOREACH (tcp_sock_list, p) {
+ tcp_write (p->pcb, str, 1, 1);
+ }
+ spinlock_unlock (&tcp_sock_lock);
+}
+
+void init_uart_tcp (void)
+{
+ struct tcp_pcb *pcb;
+
+ if (initialized)
+ panic ("already initialized!");
+
+ spinlock_init (&tcp_sock_lock);
+ LIST1_HEAD_INIT (tcp_sock_list);
+
+ pcb = tcp_new ();
+ if (pcb) {
+ err_t err;
+ err = tcp_bind (pcb, IP_ADDR_ANY, 1234);
+ if (err == ERR_OK) {
+ pcb = tcp_listen (pcb);
+ tcp_accept (pcb, uart_tcp_accept);
+ } else {
+ panic ("tcp_bind");
+ }
+ } else {
+ panic ("tcp_new");
+ }
+
+ initialized = true;
+}

init_uart_tcpがサーバの初期化処理。ソケットプログラミングで言うところのsocket -> bind -> listen -> acceptの順で初期化する処理を書いている。違うところを指摘するなら、acceptがコールバックになっているところ。これで非同期動作になり、init_uart_tcpはクライアントの接続を待たずに終了する。

初期化完了後は、1234/tcpで待機するのでtelnetなりncなりで接続できるようになる。クライアント接続時は、uart_tcp_acceptが呼び出されて、クライアント用のソケットが作成される。作成されたソケットは、リストに追加してシリアルポート出力時(hook_uart_putcharから呼出し)に書き込まれるようにしておく。

受信用の動作をuart_tcp_recvで定義しているが、今回は受信機能ではなく終了タイミングを取るために使用している。

tcp_recv関数で登録された関数は、クライアントからデータを受信した時以外に、クライアントからセッションクローズした時にも呼び出される。クライアントからFINが送信されてくるとpbuf==NULLで呼び出されるようになっているので、それを条件としてクリーンナップする。

hook_uart_putcharは、リストを辿り全クライアントにtcp_write関数でデータを1バイトずつ送り付けている。プログラム上は1バイトずつ送信するようになっているが、実際はlwIPでバッファリングされ15-20バイトくらいで送られていた。

initialized変数は、初期化を管理するフラグだが、恐らく無くても動く。作り始めは初期化処理をserial_init_iohookに書いていたが、これではlwIPが初期化される前にinit_uart_tcpが呼び出されてしまうので、panic("tcp_new");で死ぬと言う問題が発生していた。仕方ないのでlwIP_initの後に初期化処理を埋め込み、何とか動くようにした。その過程のどこかでinitialized変数を付けたのだが、その名残。なぜ付けたかは良く覚えていない。。。

これで完成。後はビルドして動けばOK。動けばOK・・・


使い方

make configdefconfigをカスタマイズしまくるだけ。

make config 有効にする必要があるのは以下。

- NET_DRIVER

- NET_*(device)

- NET_PRO1000など環境に応じて

- IP

※TTY_SERIALは無効にしておく

defconfigはよしなに。pci,ipを設定すればOK。

config.vmm.pci = "slot=00:07.0,driver=conceal,and,device=pro1000,number=0,driver=pro1000,net=ip,tty=1",

config.ip = {
.ipaddr = { 192, 168, 0, 2 },
.netmask = { 255, 255, 255, 0 },
.gateway = { 192, 168, 0, 1 },
}

使った環境は、PRO1000。PCIeの拡張ボードを使ったので、ルートブリッジを隠さないとpanicを起こす。ここは環境依存なので実機を見て調整する。

詳しくはここ→ http://www.bitvisor.org/archives/bitvisor-devel/2015-December/000071.html

あとはBitVisorをビルドしてテスト機で起動させ、クライアントかconfig.ip.ipaddrで指定したアドレスに接続するだけ。


Client

nc 192.168.0.2 1234


OSで何かを書き込めばクライアント側に出てくるはず。


GuestOS

echo Hello > /etc/ttyS0



参考: テスト環境

近年はシリアルポート付きのPCが少なくなってきたので、環境を用意するのが難しい。しかも個人・趣味だと資金もそんなに無い。

結局色々試した結果、PCサーバを使うの方法が割と楽だとわかった。特にIPMI、KVM over LANがあるのでリモートでできるところがGOOD。

起動は、PXE -> iPXE -> BitVisor -> CentOS (Local Disk) とした。


  • 開発PC


    • 自作PC

    • Intel Core-i7 3770K

    • DDR3 Non-ECC 8GB

    • CentOS 7.2 x86_64

    • gcc 4.8.5



  • テストPC


    • DELL PowerEdge R610

    • Intel Xeon X5650 (2 CPUs)

    • DDR3 Registered-ECC 64GB

    • Broadcom Corporation NetXtreme II BCM5709 Gigabit Ethernet(ゲストのSSHとPXEブート用。これはbnxで動かなかった)

    • Intel Corporation 82576 Gigabit Network Connection(PRO1000で動いた)

    • GuestOS: CentOS 7.2 x86_64



この環境でテストする時の残念なところは、POSTが遅いこと。1試行に5分くらいかかる。SupermicroだとPOSTが早く、体感で30秒未満なので試すときはこっちが良いかもしれない。

あと、Hyper ThreadingをONにしているとmm.cでpanicして起動しない。コア多すぎ?HTT未対応?よくわからん。


あとがき

BitVisorでTCP/IPはなんとなくわかった気になった。今回作ったのは、送信のみだけだが受信も少し工夫すればできる気がする。

あと、TCP/IP用にスレッドを立てる必要があるかと思ったが、net_main.cにスレッドがあるようで作らなくても良いのは非常に楽。

シリアルポートのフックは、かなり乱暴に実装したので環境によっては動作しないかもしれない。FIFOバッファが詰まったらハングする気がするが、自分の環境では発生しなかった。

最後に「これは何に使えるのか」という声が聞こえそうだが、今の実装では使える場面が思いつかない。もし、入力があれば仮想コンソール (Serial-over-LANみたいなもの) として使えるかもしれないが、それは内部的にIRQを発生させる必要があって、PICやIO APICの処理に手を加える必要があると思う←面倒くさい

また暇とやる気があれば遊ぼう。


12/14 19:00追記

入力を作ろうとすると割込みを制御しなければいけないと書いたが、もしかするとシリアルポートを物理的にループバック接続してやると問題が解決するかもしれない。

考えたやり方は、BitVisorでゲストの出力をマスクし、実デバイスへの処理を妨害させ、ゲストへの入力として与えるデータを実デバイスに投げつける。そうすると、ローカルエコーとなるのでゲストへの入力として認識されるはず。

ただし、出力時に何もしない挙動となるので動くかは未知。

今すぐやってみたいところだが、テストPCはかなり離れた場所に設置されていてすぐには検証不可能。また今度、覚えていたらやってみる。