Welcome to telecotele.com » Projects

DPDKでLチカ

DPDKでLチカし、ついでに演奏します。

tags: music dpdk

DPDKでLチカのカバー画像

この記事は下記で寄稿した記事を2024年に再構成したものです。

はじめに

DPDK (Data Plane Development Kit) をDPDKらしからぬ使い方をしてNICのLEDを制御します。 DPDKはパケット処理を高速化するライブラリで、副次的にユーザランドからNICへ直接アクセスが可能です。 通常は触りにくいNICのレジスタ、特にLEDを点灯させるレジスタへもアクセスでき、次の図のようなことが実現できます。

DPDKによるNIC LED点灯の様子 照リーヌの様子

さらに、光とくれば音でしょう。 そこで今回は、コンピュータに備わるブザーで音楽を奏でること、またそれに連動してNICのLEDを光らせることも試しています。

それでは、次章から本題に入ります。 まず第2章はDPDKの概要とNICのLEDを制御するAPIについての紹介です。 第3章はLEDを制御するための実装に関して、既存のETHTOOL_PHYS_IDおよびDPDKを利用した場合のそれぞれについて述べています。 第4章が発展課題として音楽演奏やいくつかの課題に取り組んだ結果で、最後の第5章がよく分からないあとがきです。

DPDKとは

DPDKとは何か、公式サイトによると「パケット処理を高速化できるライブラリ」で「LinuxのユーザランドとFreeBSDで動く」そうです。

え、ユーザランド? Linuxの場合のパケット処理はカーネルが行うはずで、一部の場合を除きユーザランドで行われません。 一部の場合とは、例えば独自のネットワークスタックを動かしたい、または特殊なルーティングを実現したいときです。 このとき、TAP/TUNデバイスやPacket socket、Raw socketといった機能を利用してユーザランドでパケットを処理しますが、カーネルとの頻繁なメモリコピーといった要因によってパフォーマンスの低下が予期されます。 もしかして、DPDKとは「TAP/TUNデバイスやPacket/Raw socketを用いたユーザランドでのパケット処理を、CPU拡張命令やアクセラレータやなんやかんやで頑張って高速化できるライブラリ」なのでしょうか?

それはそれで有用だと思いますが、DPDKは「パケット処理をカーネルよりも高速化できるライブラリ」です。 以降では、この高速化を実現するための特徴と、気になるAPIについて述べます。

DPDKの特徴

DPDKには、「割り込みの廃止」と「カーネルバイパス」という大きく分けて2つの特徴があります。

割り込みの廃止

LinuxカーネルのNAPI (New API)1において、受信したパケットを処理する際のおおまかな流れを次に示します。

  1. NICがパケットを受信し、割り込みを発生させる
  2. しばらくポーリングでパケットを処理していく
  3. 次の割り込みを待つ

割り込みを待っている間、CPUは他の作業を行えます。 その一方で、割り込みによって作業を切り替える必要があるため、コンテキストスイッチとそれによるオーバヘッドの発生は避けられません。

そこでDPDKではPMD (Poll Mode Driver)という仕組みによって割り込みを廃止し、すべての処理をポーリングで行っています。 この間CPUは他の作業が行えない、つまりCPU使用率100%となりますが仕様です。

UIOなどによるカーネルバイパス

UIO (Userspace I/O)は、ユーザランドからデバイスを操作できる機能です。 DPDKでは、このUIOなど2を用いてユーザランドからNICを操作する、つまりカーネルをバイパスします。 これによって、ユーザランドのアプリケーションであってもNICとのやりとりにDMAを利用でき、NICで送受信されるデータへのアクセスが高速になります。

ただ、カーネルをバイパスするということは、NICがカーネルの管理から外れることを意味します。 ifconfigethtoolといったツールからいないものとして扱われ、カーネルが備えているデバイスドライバやプロトコルスタックは利用できません。

とはいえ、デバイスドライバについてはDPDK側で用意されたものがあり、あまり気にしなくて大丈夫です。 プロトコルスタックについては、そもそもスイッチやルータを作るならほぼ必要ありません。 もし本格的なプロトコルスタックが必要でも、Seastarなどの独自のTCP/IPプロトコルスタックを備えたフレームワークを活用すれば楽をできるかも知れません。

DPDKのAPI

DPDKは「パケット処理を高速化できるライブラリ」であり、さまざまなAPIが存在します(APIドキュメント

たとえば、rte_eth_promiscuous_enable()ではNICのプロミスキャスモードを有効にできるほか、rte_eth_dev_get_eeprom()でEEPROMの内容を取得可能です。 プロトコルの処理に関してもrte_ipv4_udptcp_cksum()でチェックサム程度なら計算できるほか、各種プロトコルヘッダの構造体がすでに定義されています。

さらに、rte_eth_led_on()rte_eth_led_off()といった興味深いAPIもありました。 これらの詳細をドキュメントで確認し、要約すると次のようになります。

int rte_eth_led_on(uint16_t port_id);
int rte_eth_led_off(uint16_t port_id);

機能
  EthernetデバイスのLEDを点灯/消灯する

引数
  port_id: DPDKが管理するNICのID

返り値
  0: 成功
  -ENOTSUP: デバイスが対応していない
  -ENODEV: port_idが無効
  -EIO: デバイスが削除された

最高ですね。 「EthernetデバイスのLEDを点灯/消灯する」ってことは、つまりすなわち要するにNICのLEDを点灯/消灯できるということですよ!3 ぜひこのAPIを使ってみたいものです。

光れ!NICニウム

ということで、DPDKでNICのLEDを光らせましょう。

冴えないNICの照らしかた ~LinuxカーネルSide~

しかし、NICのLEDを光らせるというのは本当にDPDKを使わないと実現できないのでしょうか? そこで、まずはDPDKを使わずにNICの制御をLinuxカーネルに任せた場合でもLEDを光らせられるのか検討します。 ただ、先に結論を述べると、LEDを自由に制御するためにはカーネルの改変が必要で敷居は高いことが分かりました。

通常、NICのLEDが光って嬉しいときというのは、eth0enp1s0といったデバイス名に紐づくNICの物理的な位置を知りたい場合でしょう。 これを実現するのがethtool -p <デバイス名>というコマンドで、NICのLEDを一定間隔で点滅させます。 この機能は、ioctlシステムコールを呼び4実現しているということが、straceや次に示すソースコードから分かりました。

// ethtoolから抜粋したソースコード
static int do_phys_id(int fd, struct ifreq *ifr)
{
    int err;
    struct ethtool_value edata;

    edata.cmd = ETHTOOL_PHYS_ID;
    edata.data = phys_id_time;
    ifr->ifr_data = (caddr_t)&edata;
    err = ioctl(fd, SIOCETHTOOL, ifr);
    if (err < 0)
        perror("Cannot identify NIC");

    return err;
}

このioctl()を実行すると、カーネルのnet/core/ethtool.cに定義されるethtool_phys_id()関数が呼ばれます。 この関数のソースコードは次に示すとおりで、このコードからデバイスドライバのset_phys_id(dev, ETHTOOL_ID_ACTIVE)の返り値から決定した周期にしたがい、set_phys_id(dev, ETHTOOL_ID_OFF)またはset_phys_id(dev, ETHTOOL_ID_ON)によってLEDを点滅させていました。 (デバイスドライバ側で点滅させるNICもあるようですが、もう追いきれません。忘れたことにします。)

// ethtool_phys_id()のソースコード(抜粋)
static int ethtool_phys_id(struct net_device *dev, void __user *useraddr)
{
    int rc = ops->set_phys_id(dev, ETHTOOL_ID_ACTIVE);
    int n = rc * 2, i, interval = HZ / n;

    do {
        i = n;
        do {
            rtnl_lock();
            rc = ops->set_phys_id(dev, (i & 1) ? ETHTOOL_ID_OFF : ETHTOOL_ID_ON);
            rtnl_unlock();
            if (rc)
                break;
            schedule_timeout_interruptible(interval);
        } while (!signal_pending(current) && --i != 0);
    } while (!signal_pending(current) && (id.data == 0 || --id.data != 0));

    (void) ops->set_phys_id(dev, ETHTOOL_ID_INACTIVE);
    return rc;
}

set_phys_id()は関数ポインタであり、たとえばe1000ドライバの場合drivers/net/ethernet/intel/e1000/e1000_ethtool.cに定義されるe1000_set_phys_id()が呼び出されます。 ソースコードは次に示すとおりで、ETHTOOL_ID_ACTIVEを指定して呼び出されたときは定数2を返しており250 [ms]間隔でLEDの点滅が変化すること、またETHTOOL_ID_ONETHTOOL_ID_OFFが指定されるとe1000_led_on()関数やe1000_led_off()関数を呼び出していることが判明しました。 なお、e1000_led_on()関数やe1000_led_off()関数の中では、特定のレジスタを操作しています。 これによって、NICの口に内蔵されたLEDに向けて電圧が印加され、点灯状態が変化するわけです。

// e1000_set_phys_id()のソースコード(抜粋)
static int e1000_set_phys_id(struct net_device *netdev,
                 enum ethtool_phys_id_state state)
{
    switch (state) {
    case ETHTOOL_ID_ACTIVE:
        e1000_setup_led(hw);
        return 2;
    case ETHTOOL_ID_ON:
        e1000_led_on(hw);
        break;
    case ETHTOOL_ID_OFF:
        e1000_led_off(hw);
        break;
    case ETHTOOL_ID_INACTIVE:
        e1000_cleanup_led(hw);
    }
    return 0;
}

以上をまとめると、ethtool -pによる操作でのLEDは点滅以外できず、しかもその周期はデバイスドライバによって決められているということになります。 そして、実はe1000_led_on()関数やe1000_led_off()関数をたとえ間接的にでもユーザランドから呼ぶにはioctl()SIOCETHTOOLを操作する以外なく、ユーザランドからLEDを自由に制御することはできません。 どうしても自由なLED制御をユーザランドから実現したい場合、net/core/ethtool.cの改変が必要となり、気軽に試すわけにもいかなくなります。

照-Teru- DPDK編

やはりDPDKから照らすのが最高なんですね。 ということで、次からはその動作環境について説明し、続いて環境構築とDPDKアプリケーションの作成について述べます。

動作環境

まず、DPDKが動作する環境において最も重要なハードウェアはNICです。 DPDKは以前Intel DPDKという名前だったこともあり、2012年に初めて公開されたバージョン1.2.3r0ではigb (e1000)とixgbeにしか対応していませんでした。 現在ではIntel以外の物理NICのほか、QEMUのvirtio-netやEC2のENA (Elastic Network Adapter)といった仮想NICにも対応しています。 どうせなら物理NICで動かしたいところですが、Intel以外のNICは10GbEやFPGA付きばかりなので覚悟が必要です。

特に目的がなければ(目的がない人がDPDKをやるかどうかはさておき)、Amazonで新品か中古かそもそも本物なのか分からない謎のIntel NICを買う方針でも良いと思います。 このとき複数のポートがあると、スイッチやルータのサンプルアプリケーションが動かしやすくなるかも知れませんね。 そうして筆者は「Intel PRO/1000 PT Dual Port Server Adapter EXPI9402PT (Intel 82571EB Gigabit Ethernet Controller)」を4,930円で購入しました。(※2018年時点の情報。現在の2024年時点でも別に良いとは思いますが、自分なら検証後に使い回すためもっと新しいNICにすると思います。Mellanoxの何かとか…?)

次にCPUです。 DPDKではx86 (x86_64)やARMのほか、POWERまでサポートされています。 特にx86に注目すると、次の条件を満たす必要がありますが(※2018年時点)最近のCPUであれば問題ありません。

  • マルチコアCPU
    • DPDKに1コアを持っていかれるためです。もっとも、今となってはわざわざシングルコアCPUを用意するほうが難しいと思います。
  • Hugepages対応
    • DPDKではHugepagesを利用しています(と言いつつ--no-hugeオプションもあるようですが…使ったことが無いので割愛します)。Hugepagesとは、仮想記憶におけるページサイズを通常の4KBから2MBや1GBに拡張できる仕組みのことです。これによって任意アドレスの変換情報がTLBに収まるほど少数のページテーブルに集約されやすくなり、TLBミスの抑制が期待できます。この仕組みはCPU側の対応が必要で、/proc/cpuinfoflagspse (2MB)またはpdpe1gb (1GB)があれば大丈夫です。なお、DPDKのドキュメントによると「Hugepagesは全体で2GBほど予約すべし」といった記述があります。しかし、もっと小さくしても動くので、メモリが足りなくても心配する必要はありません。
  • SSE4.2対応
    • DPDK v17.08以降では、命令セットとしてSSE4.2を備えたCPUを要求するようになりました。とはいえ、Intel Core iシリーズの第一世代(Nehalem)から実装されており、よほど古いCPUでなければ気にする必要はないでしょう。

ところが今回用意できたCPUは「Intel Celeron E3400」というよほど古いCPUであり、SSE4.2対応の条件を満たせませんでした。 ただ、DPDK v17.05では、SSE4.2ではなくSSE3を備えたCPUであれば利用可能です。 そこで、そのバージョンを利用することにします。 DPDKが管理するポートIDがuint8_tからuint16_tに拡張されたなど多少の違いはありますが、やむを得ません。

以上を踏まえつつ動作環境を決めると、次に示す表のとおりとなりました。

項目内容
CPUIntel Celeron E3400 (Dual Core, pse, SSE3)
Memory2GB
NICIntel 82571EB (Dual 1GbE)
OSUbuntu 18.04 LTS Server
DPDKv17.05
Hugepages2MB×512Pages

環境構築

ソフトウェアのセットアップについて述べます。

まずやることはDPDKのビルドです。 次に示すようにいくつかアプリケーションをインストールして、ソースコードからビルドします。

$ sudo apt install build-essential libcap-dev python
$ wget http://fast.dpdk.org/rel/dpdk-17.05.2.tar.gz
$ tar zxvf dpdk-17.05.2.tar.gz
$ cd dpdk-stable-17.05.2/ && make install T=x86_64-native-linuxapp-gcc

その次はHugepagesの設定です。 次に示すコマンドを実行します。

$ sudo sed -ie 's/\(GRUB_CMDLINE_LINUX=\)/#\1/g' /etc/default/grub
$ echo 'GRUB_CMDLINE_LINUX="hugepages=512"' | sudo tee -a /etc/default/grub
$ sudo grub-mkconfig -o /boot/grub/grub.cfg
$ sudo mkdir -p /mnt/huge
$ echo 'nodev /mnt/huge hugetlbfs defaults 0 0' | sudo tee -a /etc/fstab
$ sudo reboot

さらにNICの設定です。 次に示すように、NICをカーネルからDPDKの管理下におきます。 この操作は再起動すると元に戻るので気をつけましょう。 なお、modprobeについては/etc/modulesにモジュール名を書いておけば起動時にロードしてくれます。 dpdk-devbind.pyについてはsystemdのサービスを書いて起動時に実行するという方法があるようです。

$ sudo modprobe uio_pci_generic
$ sudo ~/dpdk-stable-17.05.2/usertools/dpdk-devbind.py --status
$ sudo ~/dpdk-stable-17.05.2/usertools/dpdk-devbind.py --bind=uio_pci_generic 0000:01:00.0
$ sudo ~/dpdk-stable-17.05.2/usertools/dpdk-devbind.py --bind=uio_pci_generic 0000:01:00.1
$ sudo ~/dpdk-stable-17.05.2/usertools/dpdk-devbind.py --status

そして最後に環境変数の設定を行います。 内容は次のとおりです。 ~/.bash_profileかどこかに記述しても構いません。

$ export RTE_SDK=~/dpdk-stable-17.05.2/
$ export RTE_TARGET=x86_64-native-linuxapp-gcc

これまでの操作でDPDKアプリケーションのビルドと実行が可能になります。 実際、次のようなコマンドが実行できるはずです。

$ cd $RTE_SDK/examples/helloworld
$ make
$ sudo ./build/helloworld

DPDKアプリケーションの作成

やや大げさな見出しですが、NICのLEDを光らせるためのDPDKアプリケーションを作成します。 前もって作成しておいたものがこちらです。

lrks/hikare-nicnium
https://github.com/lrks/hikare-nicnium

ここでは、DPDKのexamples/ethtoolに含まれる初期化処理を流用しました。 主な処理は次に示すとおりとなり、非常に簡単になります。 rte_eth_led_on()rte_eth_led_off()を叩いているだけなので当然ですね。

// DPDKでLEDを点滅させるコード
static void control_led(uint8_t port_id, int flg)
{
    flg ? rte_eth_led_on(port_id) : rte_eth_led_off(port_id);
}

void nicapp_main(uint8_t cnt_ports)
{
    int i;
    uint8_t id;

    for (i=0; i<10; i++) {
        for (id=0; id<cnt_ports; id++)
            control_led(id, (id + i) % 2);
        sleep(1);
    }

    for (id=0; id<cnt_ports; id++)
        control_led(id, 0);
}

このアプリケーションを実行すると、次に示す図のようにNICのLEDが交互に点灯します。

あるNICのLEDが光る異なるNICのLEDが光る
あるNICのLEDが光る異なるNICのLEDが光る

発展課題

ただ単にLEDを光らせただけでは物足りません。 そこで、3つの発展課題をこなしていきます。 ただし、先に述べておくと最初のふたつは「要再提出」、最後がかろうじて「可」という感じです。

PWM制御

LチカといえばPWMでしょう。 LEDをPWMで制御すれば、点灯または消灯という状態に加えて「ほのかに光る」「そこそこ明るい」といった中間の状態を擬似的に作り出せます。 これにはある期間における「LEDが点灯している時間」と「消灯している時間」の比率を制御する必要があり、今回のDPDKでは次のようなコードで実現できると考えていました。

static void led_pwm(uint8_t port_id, int ratio)
{
    int i;
    int on = ratio % (10 + 1);
    int off = 10 - on;

    while (1) {
        for (i=0; i<on; i++) rte_eth_led_on(port_id);
        for (i=0; i<off; i++) rte_eth_led_off(port_id);
    }
}

ところが、このコードを実行すると常にLEDが点灯してしまいます。 RTOSではないため精度が悪くなり、実際にはPWMっぽいことは出来ているが人間には感知できないだけ? いえ、わざわざオシロスコープで観測しても常に点灯しています。

はっきりとした原因は不明ですが、NIC側の少なくともLED状態を設定するレジスタへのアクセスが頻繁に行えないような、または消灯よりも点灯が優先されるような印象を受けました。 一定時間待てばきちんと点灯と消灯が可能でしたが、この一定時間というのは200msなど点滅が目視できるほど長く、PWM制御は諦めざるを得ません。 DPDKで利用されるドライバを改変し、レジスタの書き込みに使われるE1000_WRITE_REG()マクロの代わりにE1000_PCI_REG_WRITE_RELAXED()+E1000_PCI_REG_ADDR()マクロやE1000_WRITE_FLUSH()というそれらしい名前のマクロも使ってみたものの、効果はありませんでした。 回路的に単安定マルチバイブレータのようなものが構成されている? いずれにしてもPWMは実現できませんでした。残念。

光れ!NICニウム~ethtoolこうこうこうこう部へようこそ~

少し前に「ethtoolではNICのLEDを自由に制御できない」と述べました。 しかし、本当にそうでしょうか? すでに紹介したethtool_phys_id()について、再度見てみます。

// ethtool_phys_id()のソースコード(抜粋2)
int rc = ops->set_phys_id(dev, ETHTOOL_ID_ACTIVE);

int n = rc * 2, i, interval = HZ / n;
do {
    i = n;
    do {
        rc = ops->set_phys_id(dev, (i & 1) ? ETHTOOL_ID_OFF : ETHTOOL_ID_ON);
        schedule_timeout_interruptible(interval);
    } while (!signal_pending(current) && --i != 0);
} while (!signal_pending(current));

ops->set_phys_id(dev, ETHTOOL_ID_INACTIVE);

おおまかな処理の流れは次のとおりです。

  1. set_phys_id(dev, ETHTOOL_ID_ACTIVE)でLEDの点灯状態を保存
  2. iに偶数をセットする
  3. set_phys_id(dev, ETHTOOL_ID_ON)でLEDを点灯
  4. schedule_timeout_interruptible(interval)interval [tick]間sleepしつつ、処理すべきシグナルが来たら起きる
  5. signal_pending(current)でシグナルが来ているか調べる
  6. 来ていたら処理を抜けて10.へ、何もなければiに奇数をセット
  7. set_phys_id(dev, ETHTOOL_ID_OFF)でLEDを消灯
  8. 4.から6.までとほぼ同じ処理を行う
  9. 2.に戻る
  10. set_phys_id(dev, ETHTOOL_ID_INACTIVE)で1.の状態を復元

よく見ると、4.のときにSignalを配送すればLEDの点灯時間を0からinterval [tick]まで任意に設定できます。 その後にすぐethtool_phys_id()を呼び直せば、より長い時間LEDが点灯しているように見えるかもしれません。 また、LEDの消灯時間は1.でLEDが消灯している、すなわちNICがLinkUpしていないならばもちろん任意で、これは10.によって直前の点灯状態に依存しないはずです。

これらを踏まえ、まずは点灯のみに注目した実験コードを次に示します。

// ethtool_phys_id()でLEDを光らせようとしたコード(抜粋)
#define ETHTOOL_LED_VALUE(v) ((struct ethtool_led_value *)(v))
struct ethtool_led_value {
    int fd;
    struct ifreq *ifr;
    int status;
};

void *ethtool_led_on(void *args)
{
    int oldtype;
    pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, &oldtype);

    struct ethtool_value edata;
    edata.cmd = ETHTOOL_PHYS_ID;
    edata.data = 0;
    ETHTOOL_LED_VALUE(args)->ifr->ifr_data = (caddr_t)&edata;

    int err = ioctl(ETHTOOL_LED_VALUE(args)->fd,
                    SIOCETHTOOL, ETHTOOL_LED_VALUE(args)->ifr);
    ETHTOOL_LED_VALUE(args)->status = err;
    return args;
}

int main(int argc, char *argv[])
{
    struct ifreq ifr;
    memset(&ifr, 0, sizeof(struct ifreq));
    strncpy(ifr.ifr_name, argv[1], IFNAMSIZ);

    int fd = socket(AF_INET, SOCK_DGRAM, 0);
    struct timespec req = { 0, 100 * (1000 * 1000) }; // 100ms
    struct ethtool_led_value args = { fd, &ifr, 0 };
    while (args.status >= 0) {
        pthread_t th;
        pthread_create(&th, NULL, ethtool_led_on, (void *)&args);
        nanosleep(&req, NULL);
        pthread_cancel(th);

        // sleepなし:常時点灯, あり:単発で100msだけ光らせる
        sleep(1);
    }

    close(fd);
    return 0;
}

ところが、このコードを実行しても期待通りの動作はしません。 まず、常時点灯を試すと頻繁にEPERM (Operation not permitted)が返され、反対に点灯時間を短くしようとすると無視されて引き伸ばされます。 もっとも、後者に関しては前項の「PWM制御」で述べたのと同じ挙動という印象で、ethtoolとDPDKのどちらを使っても回避できないのかも知れません。 残念!要再提出です。でもたぶん発展課題なので履修放棄します。

BUZとioctl

音楽を奏でつつ、NICのLEDが光ったら面白いと思いました。

コンピュータで音を鳴らすといえば、pcspkr(BEEP)をioctl()やbeepコマンドで操作して鳴らすのが一般的です。 modprobe pcspkrして次のようなコードを実行すると、freq [Hz]の音が1秒間鳴ります。

// pcspkrを操作するコード(抜粋)
#define DEVICE_CONSOLE "/dev/tty0"
#define CLOCK_TICK_RATE 1193180

void pcspkr(int freq)
{
    int fd = open(DEVICE_CONSOLE, O_WRONLY);
    ioctl(fd, KIOCSOUND, (CLOCK_TICK_RATE / (double)freq));
    sleep(1);
    ioctl(fd, KIOCSOUND, 0);
    close(fd);
}

和音は出せない、つまり同時発音数は1つだけですが意外と綺麗な音です。

なお、たまにコンピュータに備わるブザーではなく、ALSAから音が出る場合があります。 これは、サウンドカードを検出するとブザー音がエミュレートされてしまうためで、下記のページによると回避するにはカーネルのリビルドが必要のようです。

beep は PC スピーカーで鳴らしたい – 半月記

このコードに周波数と時間を書いていけば音楽を奏でられますが、手でひとつずつ書いていくのは非常に手間です。 そこで、SMF (Standard MIDI File)を基に奏でて楽をしましょう。 SMFとは、後述する「MIDIイベント」とそれを発行するタイミングが記録された、いわゆるMIDIファイルです。 イベントとタイミングはトラック(Track)という場所に格納されます。 SMF (Format 0)を除くSMF (Format 1)またはSMF (Format 2)では最大256本のトラックを保持でき、各トラックに格納されたデータは他トラックと独立です。 ここに含まれるMIDIイベントのうち、主なものは次のとおりとなっています。

  • ノートオン (0x9n kk vv)
    • ノートナンバーkk(key, 0~127)の音をチャンネルn (0~15)で鳴らす。ピアノの鍵盤を押し込む速度(音の大きさ)をvv (velocity, 0~127)で指定する。vv00とすると、次に述べる「ノートオフ」とほぼ同じとなる。
  • ノートオフ (0x8n kk vv)
    • チャンネルnで鳴っているノートナンバーkkの音を止める。鍵盤から手を離す速度vvを指定する。
  • ポリフォニックキープレッシャー (0xAn kk vv)
    • チャンネルnで鳴っているノートナンバーkkの音を速度vvで発音し直す。ノートオンと同様に、vv00ならばノートオフと同じになる。
  • チャンネルプレッシャー (0xDn vv)
    • チャンネルnで鳴っているすべての音を速度vvで発音し直す。vv00ならばチャンネルnの音をすべて消音する。
  • プログラムチェンジ (0xCn pp)
    • チャンネルnで鳴らす音のプログラム(音色)をpp (program, 0~127)に変える。音色にはピアノや打楽器などがあり、番号との対応は機器がサポートする規格によって決まる。
  • ピッチベンド (0xEn mm ll)
    • チャンネルnのピッチ(音高)を微調整する。mmllはともに7bit、mmをMSB、llをLSBとして0~16383までのデータを構成する。ここでは初期値を8192としますが、-8192~8191として初期値0とすることもあるようです。
  • オールサウンドオフ / オールノートオフ (0xBn 78 / 0xBn 7B)
    • チャンネルnで鳴っているすべての音を止める。
  • リセットオールコントローラ (0xBn 79)
    • チャンネルnについて設定値を初期化する。

ここで、「チャンネル」とは機器アドレスのようなものです。 GM (General MIDI)という規格では10番目のチャンネルが打楽器専用など、チャンネル番号自体が意味を持つこともあります。

なお、pcspkrに渡す周波数はノートナンバーとピッチを基に次のように計算できます。

freq = 440 * 2^(((note - 69)/12) + ((pitch-8192)/(4096*12)))

music - Converting a pitch bend (MIDI) value to a “normal” pitch value - Signal Processing Stack Exchange

これらを踏まえてSMFをパースしていきます。 Cは辛いので、一度PythonからパースしてCで扱いやすいテキスト形式に変換しましょう。 midoというライブラリを用いて実装したものがこちらになります。

https://github.com/lrks/hikare-nicnium/blob/master/pcspkr/mid2txt.py
$ ./mid2txt.py --help
usage: mid2txt.py [-h] [-m METHOD] inputFile [outputFile]

そういえば、以前にpcspkrでは和音が出せないことを述べました。 これでは、Monophonic(単音) MIDIしか奏でられません。 そこで、Polyphonic(多音) MIDIもそれなりに奏でられるよう「先着順」で音を鳴らしています。 その様子は次の図のとおりで、発音中に来たイベントは無視されて流れていきます。

MIDIイベントを「先着順」で発音する様子(黒塗りのところが発音状態) MIDIイベントを「先着順」で発音する様子(黒塗りのところが発音状態)

しかし、この方法では必ずしも主旋律が鳴るとも限らず曲がよく分からなくなってしまいそうです。 Polyphonic MIDIから主旋律を抽出してMonophonic MIDIにするのは音楽情報検索(MIR)において必要なタスク(特徴的な情報を抽出し検索しやすい形にするため)のようで、そのためのアルゴリズムがいくつか提案されています。

そのうちの一つに、Skylineと呼ばれる方法があるようです。 Melodic matching techniques for large music databases | Proceedings of the seventh ACM international conference on Multimediaの「All-Mono」が基になっているようで、この手法では「先着順」かつ「なるべく高い音」を鳴らそうとします。

様子としては次の図のとおりで、実際に試してみると主旋律の音高が高い曲5においては、ほぼ主旋律だけを鳴らせました。

Skylineによる発音の様子(黒塗りのところが発音状態) Skylineによる発音の様子(黒塗りのところが発音状態)

どのような感じか、またDPDKによるNIC LED制御の様子を収めたのが次の動画です。

NICのLEDもちゃんと光っていますね。 ちなみにDPDKで制御しているのは右側カードのNICです。 左側のケーブルが刺さっているほうも何となく音楽に連動しているような…? これは「別のPCからSSHで繋いでいる」「ブザーを鳴らすたびにprintfしていた」結果、それっぽく連動したようです。 DPDKでNICのLEDを操作する必要なんて無かったんや!(企画倒れ)

MIDIの主旋律のほうですが、うまく抽出できて鳴らせているようです。 また、主旋律が途切れたときに伴奏が聞こえてくるのが良いと思いませんか? たとえば0:38ごろ「真っ先に思い浮かぶ 君のこと」のあとに伴奏が流れてくるのが良いです。

ちなみに、他の主旋律抽出の手法も調べてみました。 具体的には、特徴的なチャンネルを選択・結合してSkylineを適用する手法(ozcan2005)や、ノートの数や種類から主旋律のトラックやチャンネルを抽出する手法(velusamy2007)です。 しかし、あまり良い結果は得られませんでした。 期待していたよりも伴奏が抽出されてしまい、奏でる曲がよく分からなくなってしまいます。 とはいえ、前処理として述べられていた「打楽器は伴奏なので除く」「発音時間が0.05秒など短すぎるノートはノイズの可能性が高い」というコツは有効でした。 これをSkylineと合わせるといい感じになります。

おわりに

NICのLEDを光らせることができて良かったです。 本当はPWMがうまくいかなかった原因を探ったり、リンクLEDのみならずステータスLEDも光らせたりしたかったですね。 でも、データシートを読む気力がなかったので終わりです。

後半はpcspkrとMIDIの話が中心でした。 音を鳴らすことができて良かったです。 できれば、擬似和音にも挑戦したかったですね。 と述べつつ実は少しだけ挑戦していて、1音を1 [ms]間隔で区切って鳴らすとそれらしい感じになっていました。 ただ、この間隔はなんとなく決めるものではなさそうなので、もっといい感じでやりたいです。

Footnotes

  1. パケット受信ごとに割り込み処理を行っていた時代に登場した「新しいAPI」です。NAPIの「N」は「Network」ではありません。2005年に実装されており、そろそろ「New NAPI」が出るかも知れませんね。

  2. DPDKでは「UIOよりも安全で高機能」と謳われる (Linux/Documentation/vfio.txtより) VFIO (Virtual Function I/O)もサポートされています。

  3. この原稿は2018年7月~8月ごろに書いたものですが、図らずも進次郎構文になっていた。

  4. 厳密には、ソースコードだけではioctlシステムコールが呼ばれているとは断定できず、「ioctl()関数が呼ばれている」「(絶対ではないがおそらく)glibcまたは同等のインターフェースを持ったCライブラリのioctl()関数を呼ぼうとしており、これはioctlシステムコールをラップしているはず」「実行時にも(LD_PRELOADなどで曲げない限り)glibcなどのioctl()関数が呼ばれ、最終的にioctlシステムコールが呼ばれるであろう」です、と予防線を張ります。

  5. 特にアニソンとかにはだいたいいけそう…と思いましたが「カメリア!~Dangerous Camellia Vacation~」「みんなのきもち」あたりだと、伴奏も高めの音でありうまく抽出できないかも知れません。