Azure Sphere Development Kitで C# を使ってコードを書いてみる

この記事は、.NET, .NET Core, monoのランタイム・フレームワーク・ライブラリ Advent Calendar 2018と、Seeed UG Advent Calendar 2018の23日目のダブルエントリーです(遅刻)。

(For English edition, click here)

Azure Sphere Development Kitとは、 Seeedさんが出しているAzure Sphere評価ボード と、開発用SDKのことです。リンクはこのSeeed公式のページからでも買えますが、国内にも代理店がいくつかあるので、今ならそちらから買うほうが入手性も良いでしょう。

MT3620とは、 MEDIATEK社が開発した、Azure Sphereの仕様に対応したMCU(CPUチップ) です。そして、上記のSeeedさんが販売しているボードはMT3620を載せた、評価用の開発ボードです。なので、ちょっと語弊あるのですが、この評価ボードの事を、以降MT3620と呼びます。

で、何でこれをAdvent Calendarのネタにしたかと言うと、現状のAzure Sphere SDKは、「C言語」でのみ開発が可能というもので… .NET界隈の読者はもう言わなくてもわかりますよね ;)

Seeed UGから来られた方は、タイトルにはC言語じゃなくてC#って書いてあるんだけど? と、若干意味不明に思うかも知れないので、以下に背景を書いておきます。

私のblogをちょっと遡ると 「IL2C」 に関する記事が沢山出てきますが、これは私が始めた「.NETのバイナリをC言語のソースコードに変換する」オープンソースのプロジェクトの事です。まだ完成していなくて、開発途上です。

開発を始めたのは、丁度1年半ぐらい前からです。1年前には、 .NET Conf 2017 とか、いくつかの勉強会やグロサミやらで細部とか進捗とかを発表していました。今年はつい先日開催した dotNET600 2018 でも発表しています。あと、YouTubeに大量にIL2Cを解説したビデオを公開しています。

資料などはそれぞれの勉強会サイトやGitHubをあたってもらえると良いと思いますが、ざっくり言うと、この図のとおりです。

C#でプログラムを書いて(もちろん、MT3620向けに)、それを普通にC#のプロジェクトとしてビルドします。そうすると、”SphereApp.dll”のような.NETのバイナリが出来ます。これをIL2Cに掛けると、C言語のソースコードに変換されたファイルが生成されます。これらのソースコードファイルを、ターゲットデバイスのC言語コンパイラ(この記事ではAzure Sphere SDKが提供するgcc)に掛けて、デバイス用のネイティブバイナリを生成します。

つまり簡単に言えば、この記事のタイトル通り、「Azure Sphere Development Kitで C# を使ってコードを書く」と言うことになるわけです。

IL2Cの最大の特徴であり目標は、例えば.NET Coreのネイティブビルドやngen、monoの極小ビルドと比較しても、到底不可能なぐらいにフットプリントを小さく出来て、内部構造がシンプルで、ネイティブコードとの連携をコントローラブルにする、というものです。これまでにデモンストレーションで作ってみたものとしては:

  • 簡易電卓: Win32ネイティブコンソールアプリケーション(つまり.NET CLRを使わず、Win32ネイティブで動く)
  • 簡易電卓: UEFIアプリケーション(つまりOSなしで動く電卓をC#で書いたって事です)
  • 簡易電卓: M5Stack テンキーブロック(ESP32上で組み込みソフトウェアとして)
  • ディスクのセクタデータをリアルタイムに書き換える、カーネルモードWDM(つまりWindowsカーネルモードで動くコードをC#で書いたって事です)
  • micro:bitで、加速度センサーを使用してLEDに方向をフィードバックするプログラム(これはC#だけではなく、F#でもデモしました)

という感じで、極小リソースのデバイスでも.NET ILを変換したネイティブコードを、インタプリタではなくAOTコンパイルしたコードで動かせるという実証や、UEFIやWDMなどの極めて特殊な環境でも動作するという実証実験をしています。今回はここに、Azure Sphereを加えるという目標です。

ということで、

  • MT3620のセットアップの流れ
  • サンプルコードの構造の確認とC#での実現方法

の大きく二章立て構成で解説します。

(ところで、来年から仕事無いので、これをやらせてくれる仕事ありましたら、凄く嬉しいのでDMください)


MT3620のセットアップの流れ

MT3620は割と早い時期に入手していたのですが、(某人がこれの環境構築に四苦八苦してたのを横目で見てたこともあり…)色々忙しくて放置してました。なので、今の時期にデバイスクレーム(MT3620の登録作業)からやる話は、同様にこれから始める人向けにも良いんじゃないかと思ってます。

@linyixian さんの、Azure Sphere 開発ボードでLチカをやってみる が非常に参考になりました。ほぼこの通りで出来ましたが、私固有の問題が少しあったので、ログ含めてセットアップの記録を残しておきます。

なお、使用したAzure Sphere SDKのバージョンは “18.11.3.23845” でした。SDKもそこそこバージョンアップされているので、あまりバージョンがかけ離れると、ここに書いてあることも無意味になるかも知れません。

さて、Azure AD面倒くさいんですが、組織アカウントを作っておくところまでは、記事通りやっておきます。私の場合、個人的にいくつかのサブスクリプションと組織が紐付いている状態で実験したりしていた関係で、シンプルに無料チャージから始めた方とは事情が違ってしまっている可能性があります。Azure ADはいつ作ったか作ってないのかわからないものが一つだけあったので、この中に組織アカウントを作りました(管理的に問題のある選択だったかも知れない… 消さないようにしなければ)。

Azure ADに組織アカウントを作ったら、Azure Sphereテナントを作ります。テナントはAzure Sphere SDKのコマンドプロンプトで作業します。つまり、ここからの操作はAzureのダッシュボードは不要です。が…

C:\>azsphere tenant create -n ***********
error: An unexpected problem occurred. Please try again; if the issue persists, please refer to aka.ms/azurespheresupport for troubleshooting suggestions and support.
error: Command failed in 00:00:39.0889958.

何故か失敗(以下、ログはIDとか伏せ字です)…

しかも意味不明なエラーメッセージなので、こりゃ早速サポート案件か… と思ったのですが、@linyixian さんのLチカ記事読み直すと、この時点でMT3620をUSB接続していたようなので(テナント生成にデバイス関係あるの?という疑問は残るものの)MT3620を接続して試したら、うまく行きました:

C:\>azsphere tenant create -n ***********
warn: Your device's Azure Sphere OS version (TP4.2.1) is deprecated. Recover your device using 'azsphere device recover' and try again. See aka.ms/AzureSphereUpgradeGuidance for further advice and support.
Created a new Azure Sphere tenant:
 --> Tenant Name: ***********
 --> Tenant ID:   ***********
Selected Azure Sphere tenant '***********' as the default.
You may now wish to claim the attached device into this tenant using 'azsphere device claim'.
Command completed successfully in 00:00:23.2329116.

(そう言えばMatsuoka氏も最初の頃そんな事を言っていた事を思い出した)

ただ、上記のように、私のMT3620はファームウェアが古すぎだと言われた(まあ、放ったらかしにしてたしね)ので、早速suggestされたコマンドで更新をかけました。

C:\>azsphere device recover
Starting device recovery. Please note that this may take up to 10 minutes.
Board found. Sending recovery bootloader.
Erasing flash.
Sending images.
Sending image 1 of 16.
Sending image 2 of 16.
Sending image 3 of 16.
Sending image 4 of 16.
Sending image 5 of 16.
Sending image 6 of 16.
Sending image 7 of 16.
Sending image 8 of 16.
Sending image 9 of 16.
Sending image 10 of 16.
Sending image 11 of 16.
Sending image 12 of 16.
Sending image 13 of 16.
Sending image 14 of 16.
Sending image 15 of 16.
Sending image 16 of 16.
Finished writing images; rebooting board.
Device ID: ***********
Device recovered successfully.
Command completed successfully in 00:02:41.2721537.

難なく、更新されたようです。では、いよいよ後戻りできない、MT3620のデバイスクレームを実行します:

C:\>azsphere device claim
Claiming device.
Successfully claimed device ID '***********' into tenant '***********' with ID '***********'.
Command completed successfully in 00:00:04.6818769.

これで、このMT3620はこのAzure Sphereテナントに登録されてしまったので、もう誰にも譲れなくなりました… (Azure Sphere MCU対応デバイスは、デバイスクレームを一度しか実行できません。詳しくはググってみてください)

さて、気を取り直して、WiFi出来るようにしておきます。

C:\>azsphere device wifi add --ssid *********** --key ***********
Add network succeeded:
ID                  : 0
SSID                : ***********
Configuration state : enabled
Connection state    : unknown
Security state      : psk

Command completed successfully in 00:00:01.7714741.

WiFi接続ができているかどうかを確認

C:\>azsphere device wifi show-status
SSID                : ***********
Configuration state : enabled
Connection state    : connected
Security state      : psk
Frequency           : 5500
Mode                : station
Key management      : WPA2-PSK
WPA State           : COMPLETED
IP Address          : ***********
MAC Address         : ***********

Command completed successfully in 00:00:01.0424492.

いい感じです。一度動き出せば、管理は非常に簡単です。WiFi構成はいつでも上記コマンドで変更できます。

最後に、MT3620を「開発モード」にします。

MT3620自体はDevKitなので、文字通り「開発」に使わないのなら何に使うんだ? ということで、この操作は意味不明に思われるかも知れません(特にArduinoやESP32のようなプラットフォームを触っていると)。冒頭で触れたように、元々のAzure Sphere MCUは、様々な組み込み機器のCPUとして使われることを想定しており、真にIoTデバイスとして使う場合は、逆に開発モードと言う危なげな状態にならないほうが良いわけです。

この辺は、最近のスマートフォンでも同じように制限を掛けていると思うので、それと同じように考えればわかりやすいと思います。では、開発モードに変更します:

C:\>azsphere device prep-debug
Getting device capability configuration for application development.
Downloading device capability configuration for device ID '***********'.
Successfully downloaded device capability configuration.
Successfully wrote device capability configuration file 'C:\Users\k\AppData\Local\Temp\tmp6CD5.tmp'.
Setting device group ID '***********' for device with ID '***********'.
Successfully disabled over-the-air updates.
Enabling application development capability on attached device.
Applying device capability configuration to device.
Successfully applied device capability configuration to device.
The device is rebooting.
Installing debugging server to device.
Deploying 'C:\Program Files (x86)\Microsoft Azure Sphere SDK\DebugTools\gdbserver.imagepackage' to the attached device.
Image package 'C:\Program Files (x86)\Microsoft Azure Sphere SDK\DebugTools\gdbserver.imagepackage' has been deployed to the attached device.
Application development capability enabled.
Successfully set up device '***********' for application development, and disabled over-the-air updates.
Command completed successfully in 00:00:32.3129153.

これで開発モードに切り替えできました。

後は、@linyixian さんのLチカの記事通り、Blinkサンプルを実行してLチカ出来る、言い換えればDevKitの用を成しているかどうかをを確認しておきます。

これで、MT3620が使える状態になったかどうかの確認は完了です。

DevKitの詳細については、

を参照すると良いでしょう。


サンプルコードの構造の確認とC#での実現方法

で、ここからが本題なのですが、とりあえず先程のBlinkのサンプル(このサンプルはあくまでC言語)を眺めてみると、以下のような定義が見つかります:

/// <summary>
///     Main entry point for this application.
/// </summary>
int main(int argc, char *argv[])
{
    Log_Debug("Blink application starting.\n");
    if (InitPeripheralsAndHandlers() != 0) {
        terminationRequired = true;
    }

    // Use epoll to wait for events and trigger handlers, until an error or SIGTERM happens
    while (!terminationRequired) {
        if (WaitForEventAndCallHandler(epollFd) != 0) {
            terminationRequired = true;
        }
    }

    ClosePeripheralsAndHandlers();
    Log_Debug("Application exiting.\n");
    return 0;
}

なんだか、至って普通の、なんのヒネリもないmain関数です。あと、いくつか気がつくことがあります:

  • Log_Debug関数でログが出せそう。
  • InitPeripheralsAndHandlersとClosePeripheralsAndHandlersでデバイスの初期化処理と終了処理らしきことをしている。これはSDKの関数ではなく、このサンプル内にコードがあるので、すぐ後で見てみます。
  • ど真ん中にポーリングのようなコードがある。コメントに”SIGTERM”とかコメントがある。

ぐらいですかね。InitPeripheralsAndHandlersを見てみると:

static int InitPeripheralsAndHandlers(void)
{
    struct sigaction action;
    memset(&action, 0, sizeof(struct sigaction));
    action.sa_handler = TerminationHandler;
    sigaction(SIGTERM, &action, NULL);

    epollFd = CreateEpollFd();
    if (epollFd < 0) {
        return -1;
    }

    // ...

えぇ… POSIX signal使えるんだ… ちょっと予想外だった。いや、やや不透明ですが、内部ではLinuxカーネルが動いているので、当然といえば当然かも知れません。同様に、main関数のポーリングのようなコードは、epollと関係あるのかも知れません。

また、C言語なのである程度は仕方がないのですが、質素なLチカのサンプルコードなのにグローバル変数がてんこ盛りかつ複雑なので、これで何かしようというやる気が初っ端からへし折られていく感じがします… (一応擁護しておくなら、頑張ってきれいに書こうとしている匂いはします)

では、そろそろ今回やろうとしていることの本題に入りましょうか。

  1. 本格的に何か出来るようにしようとすると、Advent Calendarでは収集つかなくなりそうなので、Blink同様のものを目標にします。
  2. 真のLチカ(つまり、LEDの点滅のみ)をやってみます。
  3. ボタン入力に対応させてみます。
  4. C#らしいコードにします。
  5. 生成されたコードを比較します。

ビルド環境の準備

完成したサンプルコードはここにあります。

上記サンプルコードを動かす場合は、リポジトリ全体をcloneした後、以下のように準備しておきます。

  1. Visual Studio 2017にC#, C++(VC++), NUnit3 test adapter(拡張機能から)が入っている必要があります。普通はVC++入れないと思いますが、Azure Sphere SDKを入れてあるなら入っているでしょう。
  2. ルートにあるil2c.slnを開いてビルドします。どうしてもビルドが通らない場合は、3のテストは諦めることも出来ます(.NET Framework 4.0以降・.NET Core 1.0以降・.NET Standard 1.0以降の全てのバージョンに依存しているため、環境によってはビルドできないかも知れません。Visual Studio Installerを起動して、とにかく全てのコンポーネントをインストールしてみるのはありかも知れません。私は常に全部入りです)。
  3. Test Explorerからテストを実行して全部パスすることを確認しておきます(結構時間がかかります。CPUコア数多いと速いです。i7-4790Kで5分ぐらい。あと、1GBぐらいのディスクスペースが必要で、初回実行時に一度だけMinGWのgcc toolchainをダウンロードするため、遅い回線だと辛いかもしれません)。masterブランチには安定的に動作するコードが入っているはずですが、テストを実行しておけば、少なくとも私が見た結果と同じものが得られるはずです。
  4. samples/AzureSphere/AzureSphere.slnを開いてビルドしてください。このソリューリョンには3つのプロジェクトが含まれていますが、依存関係が正しく設定されているはずなので、ビルドすれば全てが正しく構築されるはずです。

AzureSphere.slnソリューションに含まれるプロジェクトは、以下のとおりです:

  • IL2C.Runtime: IL2Cのランタイムコードをライブラリ化するVC++プロジェクト
    (このプロジェクトはIL2Cリポジトリに含まれるランタイムのソースファイルを参照しているので、このプロジェクトをビルドするためにリポジトリ全体が必要です)
  • MT3620Blink: C#で書かれたBlinkのプロジェクト(以降で説明するC#で書くコード)
  • MT3620App: MT3620向けのデプロイ用VC++プロジェクト(SDKのサンプルコードから不要なコードを省いたものです)

以降の解説は、上記プロジェクトのうちのC# Blinkのプロジェクトを一から書く場合の解説です。

このC#プロジェクトは、いわゆるMSBuildの新形式(.NET Coreや.NET Standardで使われている、新しいシンプルなcsprojファイル)ライブラリプロジェクトを使って、net46以上かnetcoreapp2.0以降をターゲットにします。そして、NuGetパッケージのIL2C.Build 0.4.22以降(この記事を書いた時点の最新)を参照してください。

これでビルド時に裏でIL2Cが自動的に実行され、C言語のソースコードが出力されるようになります(IL2Cのバイナリを明示的にインストールしたりする必要はありません)。

(説明が前後しますが、C#プロジェクト自体は、IL2C.Buildパッケージを参照するだけで作ることが出来ます。今回、IL2Cのリポジトリ全体をcloneするのは、IL2C.Runtimeのビルドのためです。この辺りはスマートではない事は認知しているので、将来的にはもっと簡単に出来るようにしたいと思っています)

C言語のソースコードは、デフォルトでは$(OutDir)/IL2C/の下に出力されます。今回は、別のVC++プロジェクト配下のGeneratedフォルダに出力したいため、csprojのPropertyGroupに以下のように1行加えておきます:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net46</TargetFramework>
    <OutputType>Library</OutputType>
    <AssemblyName>MT3620Blink</AssemblyName>

    <!-- ... -->

    <!-- C言語ソースコードの出力先フォルダを指定 -->
    <IL2COutputPath>$(ProjectDir)../MT3620App/Generated</IL2COutputPath>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="IL2C.Build" Version="0.4.6" />
  </ItemGroup>
</Project>

なお、旧形式のMSBuild、つまり.NET Frameworkで使われているcsprojのライブラリプロジェクトでも出来ますが、IL2C.Interopパッケージが正しく参照されないため、手動でこのパッケージも追加する必要があります。

また、net46より古いか、netstandard辺りのプラットフォームをターゲットとすることも出来ますが、現状のIL2C.Buildではビルドできないので、IL2C.exeを自力で呼び出す必要があります(PostBuildEventに書きます。引数の種類は限られているのでそれほど難しくはありませんが、ここでは省略します)。

IL2CのInteroperability(相互運用機能)

.NETの世界で Interoperability(相互運用)と言うと、.NETのコードから、ネイティブのコード(例えばWin32やLinuxのAPI)を呼び出したりする事を指します。.NETのコードは、全てが.NETだけで実現されているわけではなく、例えばファイルの入出力やネットワークアクセスなどは、OSのAPIを呼び出して実現する必要があります。

普通のプログラムは、これらの「外部リソース」にアクセスします。したがって、如何に簡単にプログラムの処理系からAPIを呼び出せるかが鍵となります。.NETの場合、標準の相互運用機能に P/Invoke と呼ばれる機能があり、これを使って相互運用を実現します。

例えば、WindowsのWin32 APIにはデバッグAPIがあって、デバッグ用の文字列を出力することが出来ます(デバッガのログに表示されます)。これを.NET上から呼び出すには、以下のようなコードを書きます:

public static class InteroperabilityDemonstration
{
    // Win32 APIのデバッガAPIへの呼び出しを定義する
    [DllImport("kernel32.dll", EntryPoint = "OutputDebugStringW", CharSet = CharSet.Unicode)]
    private static extern void OutputDebugString(string message);

    public static void WriteMessageToDebugger(string message)
    {
        // デバッガに文字列を出力する
        OutputDebugString(message);
    }
}

詳細は省きますが、DllImportという属性を適用したメソッドは、対応するWin32 APIと紐付けられ、まるで.NETにそのメソッドが存在するかのように呼び出すことが出来るようになります。

例としてWin32 APIを挙げましたが、.NET Coreやmonoで想定されるマルチプラットフォーム(つまりLinuxやMac)でも、全く同じ方法でネイティブのAPIを呼び出すことが出来ます。

IL2Cでは、P/Invokeを踏襲した相互運用機能を実現するのと同時に、独自の相互運用機能も用意しています(注:現状、どちらの実装もまだ完全ではありません)。この、独自の相互運用機能(便宜上、IL2C/Invokeと呼びます)で同じ事を実現する場合は、以下のように書きます:

public static class InteroperabilityDemonstration
{
    // Win32 APIのデバッガAPIへの呼び出しを定義する
    [NativeMethod("windows.h", SymbolName = "OutputDebugStringW", CharSet = NativeCharSet.Unicode)]
    [MethodImpl(MethodImplOptions.InternalCall)]
    private static extern void OutputDebugString(string message);

    public static void WriteMessageToDebugger(string message)
    {
        // デバッガに文字列を出力する
        OutputDebugString(message);
    }
}

何故、IL2C/Invokeを用意したのかは、このコードを注意深く見ると気がつくかも知れません。P/InvokeのDllImport属性は、対象のAPIが「ダイナミックリンクライブラリ」に含まれている事を前提としています。kernel32.dllを参照することで、プログラムの実行時に指定されたライブラリを「動的」に読み込んで、APIの呼び出しを可能にします。

しかし、IL2CはC言語のソースを出力し、それらがすべて事前にコンパイルされる必要があります。参照すべきAPIもコンパイル時に解決される必要があるため、ライブラリ本体ではなく、C言語のヘッダファイルへの参照が必要なのです。当初はDllImport属性を流用していたのですが、このような理由から独自の属性を定義して区別するようにしました。また、区別したことで、後で述べるような利点も生まれています。

唯一、MethodImpl属性が邪魔ですね。これはC#コンパイラが要求していて省略できないため、どうしても一緒に書いておく必要があります。今のところ回避方法はありません。

Azure Sphere SDKのAPIを参照する

このIL2C/Invokeを使って、Azure Sphere SDKのAPIを.NETから使えるようにしてみます。まずはnanosleep APIをカバーしてみましょう。

namespace MT3620Blink
{
  [NativeType("time.h", SymbolName = "struct timespec")]
  internal struct timespec
  {
      public int tv_sec;
      public int tv_nsec;
  }

  public static class Program
  {
      [NativeMethod("time.h")]
      [MethodImpl(MethodImplOptions.InternalCall)]
      private static extern void nanosleep(ref timespec time, ref timespec remains);

      public static int Main()
      {
          var sleepTime = new timespec { tv_sec = 1 };
          var dummy = new timespec();
        
          while (true)
          {
              nanosleep(ref sleepTime, ref dummy);
          }
      }
  }
}

nanosleep APIは、Azure Sphere SDKのtime.hに定義されています。そして、引数にtimespec構造体へのポインタを取ります。P/Invokeで使われるのと同じ技法で、ポインタはrefで参照するようにします。

また、.NETから構造体を参照できるように、timespec構造体を宣言します。ここに、NativeType属性を適用して、この構造体がどのヘッダファイルに定義されているのかを宣言し、合わせて実際の構造体の宣言名(ここでは “struct timespec”)をSymbolNameプロパティで適用しておきます。

宣言名を省略すれば、.NET構造体の名前がそのまま使用されます。しかし、SDKのtimespec構造体はtypedefで別名宣言されていないため、”struct”の接頭語を付けておく必要があります(Azure Sphere SDKはC++をサポートしていないので、この場合は接頭語を省略できません)。

先程、IL2C/Invokeには利点があるという話をしましたが、このNativeType属性が正にそれです。P/Invokeの場合、定義する構造体の型は、バイナリレイアウトを含めて完全な状態で再定義しなければならず、比較的難易度の高い作業でした。

NativeType属性が適用された構造体は、基本的にシンボル名と型が合っていれば、問題なくネイティブコードと結合できます。何故なら、NET構造体の定義はIL2Cによって無視され、C言語の構造体を(定義が同一であるという前提で)そのまま使うからです。

以下に、変換結果を概念的にしたものを示します:

#include <time.h>

// struct timespec構造体の別名を定義
typedef struct timespec MT3620Blink_timespec;

void MT3620Blink_Program_nanosleep(MT3620Blink_timespec* time, MT3620Blink_timespec* remains)
{
    nanosleep(time, remains);
}

int32_t MT3620Blink_Program_Main(void)
{
    MT3620Blink_timespec sleepTime = { 1 };
    MT3620Blink_timespec dummy;
    // ...
    MT3620Blink_Program_nanosleep(&sleepTime, &dummy);
    // ...
}

C言語のソースコード中に、timespec構造体の詳細な定義が無く、typedefを使って本物のtimespec構造体にバイパスしているだけです。

なお、文字列(.NETのSystem.String型)は、NULLで終端されている wchar_t* が渡されるようになっています。NativeMethod属性のCharSetにUTF8を指定することで char* を渡すことも出来るようにしたり、P/Invokeと同じように、StringBuilderクラスを使ったやり取りも出来るようにする予定です。

GPIO APIをカバーする

GPIO APIをカバーして、Lチカを完成させます。以下に、GPIOにアクセスする部分だけを抜き出したC#のコードを載せます:

namespace MT3620Blink
{
  [NativeType("applibs/gpio.h")]
  internal enum GPIO_OutputMode_Type
  {
      GPIO_OutputMode_PushPull = 0,
      GPIO_OutputMode_OpenDrain = 1,
      GPIO_OutputMode_OpenSource = 2
  }

  [NativeType("applibs/gpio.h")]
  internal enum GPIO_Value_Type
  {
      GPIO_Value_Low = 0,
      GPIO_Value_High = 1
  }

  public static class Program
  {
      [NativeValue("mt3620_rdb.h")]
      private static readonly int MT3620_RDB_LED1_RED;

      [NativeMethod("applibs/gpio.h")]
      [MethodImpl(MethodImplOptions.InternalCall)]
      private static extern int GPIO_OpenAsOutput(
          int gpioId, GPIO_OutputMode_Type outputMode, GPIO_Value_Type initialValue);

      [NativeMethod("applibs/gpio.h")]
      [MethodImpl(MethodImplOptions.InternalCall)]
      private static extern int GPIO_SetValue(int gpioFd, GPIO_Value_Type value);

      public static int Main()
      {
          var fd = GPIO_OpenAsOutput(
              MT3620_RDB_LED1_RED,
              GPIO_OutputMode_Type.GPIO_OutputMode_PushPull,
              GPIO_Value_Type.GPIO_Value_High);
          var flag = false;

          while (true)
          {
              GPIO_SetValue(fd, flag ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low);
              flag = !flag;

              // nanosleep(...);
          }
      }
  }
}

MT3620のGPIOは、GPIO_OpenAsOutput APIで出力用にデバイスをオープンして、その時得られたディスクリプタをGPIO_SetValue APIに渡すことで操作します。入力の場合はまた別のAPIを使いますが、まずはLチカを実現しましょう。

ディスクリプタは32ビット整数(C言語でint)なので、.NETでもint(System.Int32)でOKですね。戻り値で得られるので、これをローカル変数に保存しておきます。

NativeValue属性もIL2C/Invokeで使用する属性で、定数シンボル(C言語マクロなど)を参照出来るようにするために使用します。但し、staticフィールドの初期値を割り当てないため、コンパイル時に警告が発生し、具合がよくありません。将来のバージョンではやり方を変えるかも知れません。

ループ内で、現在のフラグに応じて、GPIO_Value_HighとGPIO_Value_Lowを切り替え、LEDの点滅を実現します。

ここでもNativeType属性を便利に使うことが出来ます。列挙型GPIO_OutputMode_TypeとGPIO_Value_Typeを、C言語の列挙型に対応付けています。残念ながら、今はまだ、列挙型の値(数値)をC言語の定義と一致させる必要があります(将来的には省けるようにしようと考えています)。

このコードを俯瞰すると、C#でP/Invokeを使ったことがある人なら「この醜い相互運用の定義は、別のクラスに移動しておこう。そうすれば、Mainメソッドがスッキリする」と想像出来ると思います。

実際、別のクラスに定義を移動しても問題ありませんし、フットプリントが気になるならpartial classを使えば良いでしょう。IL2CはC#のソースファイルではなく、コンパイルされたアセンブリファイル(MT3620Blink.dll)に対して処理を行うからです。

(というように各自で工夫してくれると良いなーと思ってたのですが、Matsuoka氏に見せたら初見で同じことをつぶやいたので、まあ作戦は成功したと言えるでしょう :)

サンプルコードでは、Interopsというクラスを作り、そこに移動しました。

横道にそれましたが、ボタン入力をGPIO APIで取得するには、GPIO_OpenAsInput APIでオープンしてから、GPIO_GetValue APIを使います。これらは以下のように定義できます:

internal static class Interops
{
    // ...

    [NativeValue("mt3620_rdb.h")]
    public static readonly int MT3620_RDB_BUTTON_A;

    [NativeMethod("applibs/gpio.h")]
    [MethodImpl(MethodImplOptions.InternalCall)]
    public static extern int GPIO_OpenAsInput(int gpioId);

    [NativeMethod("applibs/gpio.h")]
    [MethodImpl(MethodImplOptions.InternalCall)]
    public static extern int GPIO_GetValue(int gpioFd, out GPIO_Value_Type value);
}

IL2Cでは、ref引数でもout引数でもポインタ渡しになります。しかし、GPIO_GetValueは(ポインタ経由で)値を読み取ることは無いのでoutにしておきます。すると、メソッドを使う側ではout varを使って簡単に書けるようになります。nanosleepも直しておきます。全体的なコードは以下の通りです:

// 指定されたnsecだけ待機する
private static void sleep(int nsec)
{
    var sleepTime = new timespec { tv_nsec = nsec };
    Interops.nanosleep(ref sleepTime, out var dummy);
}

public static int Main()
{
    var ledFd = Interops.GPIO_OpenAsOutput(
        Interops.MT3620_RDB_LED1_RED,
        GPIO_OutputMode_Type.GPIO_OutputMode_PushPull,
        GPIO_Value_Type.GPIO_Value_High);

    var buttonFd = Interops.GPIO_OpenAsInput(
        Interops.MT3620_RDB_BUTTON_A);

    var flag = false;

    // 待機時間(nsec)
    var blinkIntervals = new[] { 125_000_000, 250_000_000, 500_000_000 };
    var blinkIntervalIndex = 0;

    var lastButtonValue = GPIO_Value_Type.GPIO_Value_High;

    while (true)
    {
        Interops.GPIO_SetValue(
            ledFd,
            flag ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low);
        flag = !flag;

        // ボタンの状態を読み取る: out varで簡単に書ける
        Interops.GPIO_GetValue(buttonFd, out var buttonValue);
        // 直前のボタンの状態から変化していれば
        if (buttonValue != lastButtonValue)
        {
            // ボタンが押されていれば(ボタンの信号は論理が逆なので注意)
            if (buttonValue == GPIO_Value_Type.GPIO_Value_Low)
            {
                // 待機時間を変更
                blinkIntervalIndex = (blinkIntervalIndex + 1) % blinkIntervals.Length;
            }
        }
        lastButtonValue = buttonValue;

        sleep(blinkIntervals[blinkIntervalIndex]);
    }
}

ボタンの状態は瞬時に読み取られるので、連続的に待機時間が変わってしまわないように、ボタン状態の変化を見るようにしています。

Azure Sphere SDKのBlinkerサンプルは、ディスクリプタの取得に失敗した場合(-1が返されるなど)の処理もきちんと実装していますが、このコードでは省略しています。もしやるならC#らしく、例外を定義してスローするという方法が考えられます。

なお、ここまでのコードは ac2018-step1のタグ で保存してあります。

応用: epollを使って疑似イベント駆動にする

最初に取り組むLチカ+αの課題はこれで十分だと思いますが、C#を使っているのにC言語でプログラムを書いているのとあまり変わりがなく、IL2C/Invokeのための記述が増えるだけで面白みがありませんね。

また、Azure Sphere SDKのBlinkerサンプルは、実装がもう一捻りしてあります(それであんなに複雑なことになっているのですが…)。それは、ボタンクリックのトリガーと点滅を、それぞれタイマーを割り当てて、タイマー経過をepollを使ってマルチプレクスして、擬似的なイベント駆動として処理しているのです。実際、前述のコードには些末ですが問題があります。それは、sleepを呼び出している間は、ボタンの入力が受け付けられないことです。

最後にこの処理を、.NETとC#のパワーでキレイに対処して見ましょう。

この図は、これからC#で作る型の関係を示したものです。いくつかのクラスとインターフェイスを定義して、このような継承関係を構築します。

Descriptorクラスは、このすぐ後で説明する、APIのディスクリプタを管理する基底クラスです。Applicationクラスが、IEPollListenerインターフェイスを実装するインスタンスを受け取ってepollに登録し、発生するイベントの集約と配信を行います。

GpioBlinkerとGpioPollerクラスが、LEDの点滅とボタンのトリガーを監視します。両方共にTimerクラスを継承して、インターバルタイマーの経過を使って処理を行います。

それでは、順を追って説明します。

まず、すべてのAPIへのアクセスは、共通の「ディスクリプタ」で行われている、という事実に着目し、ディスクリプタを管理するクラスを用意します:

public abstract class Descriptor : IDisposable
{
    public Descriptor(int fd)
    {
        if (fd < 0)
        {
            throw new Exception("Invalid descriptor: " + fd);
        }
        this.Identity = fd;
    }

    public virtual void Dispose()
    {
        if (this.Identity >= 0)
        {
            Interops.close(this.Identity);
            this.Identity = -1;
        }
    }

    protected int Identity { get; private set; }
}

このクラスのポイントは、基底クラスとして継承可能にしておき、IDisposableインターフェイスを実装してディスクリプタを破棄可能にします。コンストラクタで渡されたディスクリプタが負数の場合は、例外をスローしておきます。

このクラスを継承して、GPIOのIn,Outを実装してみます:

internal sealed class GpioOutput : Descriptor
{
    public GpioOutput(int gpioId, GPIO_OutputMode_Type type, bool initialValue)
        : base(Interops.GPIO_OpenAsOutput(
            gpioId,
            type,
            initialValue ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low))
    {
    }

    public void SetValue(bool value) =>
        Interops.GPIO_SetValue(
            this.Identity,
            value ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low);
}

internal sealed class GpioInput : Descriptor
{
    public GpioInput(int gpioId)
        : base(Interops.GPIO_OpenAsInput(gpioId))
    {
    }

    public bool Value
    {
        get
        {
            Interops.GPIO_GetValue(this.Identity, out var value);
            return value == GPIO_Value_Type.GPIO_Value_High;
        }
    }
}

各APIへのアクセスは、前章で作ったInteropsクラスの定義をそのまま流用しています。また、ディスクリプタはDisposeメソッドで解放されるため、破棄についてはここでは何もしていません。特に説明不要で理解できると思います。

次に、タイマーの処理もクラスにカプセル化してしまいましょう。その前に、以下のようなインターフェイスを用意しておきます。これは、後でepollがタイマーの経過を通知するための窓口となるものです:

// epollを使ってイベントを受信するクラスが実装するインターフェイス
public interface IEPollListener
{
    // epollで管理に使用するディスクリプタ
    int Identity { get; }
    // epollがイベントを受信した際に呼び出すコールバックのメソッド
    void OnRaised();
}

タイマークラスはこのインターフェイスを実装して、epollからのイベントを受信できるようにします:

internal abstract class Timer : Descriptor, IEPollListener
{
    [NativeValue("time.h")]
    private static readonly int CLOCK_MONOTONIC;
    [NativeValue("time.h")]
    private static readonly int TFD_NONBLOCK;

    protected Timer()
        : base(Interops.timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK))
    {
    }

    public void SetInterval(long nsec)
    {
        var tm = new timespec
        {
            tv_sec = (int)(nsec / 1_000_000_000L),
            tv_nsec = (int)(nsec % 1_000_000_000L)
        };
        var newValue = new itimerspec
        {
            it_value = tm,
            it_interval = tm
        };

        Interops.timerfd_settime(this.Identity, 0, ref newValue, out var dummy);
    }

    // epollで管理に使用するディスクリプタ
    int IEPollListener.Identity => this.Identity;

    // epollがイベントを受信した際に呼び出すコールバックのメソッド
    void IEPollListener.OnRaised()
    {
        // タイマーイベントを消化する
        Interops.timerfd_read(this.Identity, out var timerData,(UIntPtr)(sizeof(ulong)));
        // 派生クラスの実装を呼び出す
        Raised();
    }

    // タイマー経過を示す純粋仮想メソッド(継承して処理を実装)
    protected abstract void Raised();
}

タイマークラスは更に継承して、Raisedメソッドを実装することで、タイマー経過時の処理を簡単に記述できるようにしておきます。

C#で書き始めた途端に、ものすごく設計が捗るようになった気がしますね。最後にepollを処理するクラスを用意します。epoll自体もディスクリプタで管理されるので、Descriptorクラスを継承します:

public sealed class Application : Descriptor
{
    public Application()
        : base(Interops.epoll_create1(0))
    {
    }

    // 指定されたインスタンスをepollに登録する
    public void RegisterDescriptor(IEPollListener target)
    {
        // インスタンスの参照を固定する
        GCHandle handle = GCHandle.Alloc(target, GCHandleType.Pinned);

        // epollにインスタンスハンドルを結びつけるための情報
        var ev = new epoll_event {
            events = Interops.EPOLLIN,
            data = new epoll_data_t { ptr = GCHandle.ToIntPtr(handle) }
        };

        // 登録する
        Interops.epoll_ctl(
            this.Identity,
            Interops.EPOLL_CTL_ADD,
            target.Identity,
            ref ev);
    }

    // 指定されたインスタンスをepollから解除する
    public void UnregisterDescriptor(IEPollListener target)
    {
        // ...
    }

    public void Run()
    {
        while (true)
        {
            var ev = new epoll_event();
            var numEventsOccurred = Interops.epoll_wait(this.Identity, ref ev, 1, -1);

            if (numEventsOccurred == -1)
            {
                break;
            }
            if (numEventsOccurred == 1)
            {
                // GCHandle.ToIntPtrメソッドで得たポインタを使ってGCHandleを復元する
                GCHandle handle = GCHandle.FromIntPtr(ev.data.ptr);
                // GCHandleからインスタンス参照を入手する
                var target = (IEPollListener)handle.Target;
                target.OnRaised();
            }
        }
    }
}

この後で紹介しますが、このクラスはWinFormsやWPFのApplicationクラスに似た位置づけなので、同じようにApplicationクラスと名付けました。IEPollListenerインターフェイスを実装したクラスをepollに連携させるように登録・解除出来るように、RegisterDescriptorメソッドとUnregisterDescriptorメソッドを公開しています。そして、Runメソッドでepollイベントの監視を行います。

このコードには少し解説が必要でしょう。ポイントはGCHandle構造体です。この構造体はインスタンスへの参照をガベージコレクタの操作から保護します。

この記事ではあえてIL2Cの内部構造に触れてきませんでしたが、IL2Cはマークアンドスイープ方式のガベージコレクタを持っています。クラスのインスタンスをnewすると、そのインスタンスはヒープメモリ(最終的にはmalloc関数)に格納されます。マークアンドスイープ方式では、メモリコンパクションと呼ばれる操作は行われませんが、使われていないインスタンスについては自動的に解放(free関数)されます。

ここで問題が2つあります:

  • ネイティブAPIに、インスタンス参照のポインタを渡す方法
  • ネイティブAPIがそのポインタを操作する可能性がある時に、誤ってインスタンスを解放してしまわないようにする方法

本物の.NET CLRや.NET Core、monoではこれに加えて、インスタンスが別の場所に移動してしまうかもしれない問題もある(メモリコンパクションが発生)のですが、IL2Cではその問題は発生しません。これらの問題は、GCHandleを使えば解決できます。

まず、インスタンスが誤って解放されないようにするには、GCHandle.AllocメソッドをGCHandleType.Pinnedで呼び出します。これで指定されたインスタンスは、IL2C上で使用されていなくても破棄されなくなります。

次に、GCHandle.ToIntPtrメソッドを呼び出すと、Allocで指定したインスタンスへのポインタが得られます。ポインタの型はSystem.IntPtr型(C言語ではintptr_t型)です。このポインタは、基本的に直接操作するためのものではありません。間接的にインスタンスを示す値と考えておけばよいでしょう。

これで上記2点についてはクリアできました。但し、注意すべきことがあります。一度GCHandle.Allocでインスタンスを固定すると、解除するまでは永久にヒープメモリに保持され続けます。不要になったタイミングでGCHandle.Freeメソッドを呼び出して固定を解除する事を忘れないようにしましょう。メモリリークに繋がります(具体的な方法についてはUnregisterDescriptorメソッドを参照して下さい)。

上記で得たポインタは、epoll_event構造体の中のepoll_data_t構造体に保存します。この構造体はNativeType属性をつけてあります(実際は共用体です。IL2C/Invokeなら簡単に共用体を扱えます):

[NativeType("sys/epoll.h")]
internal struct epoll_data_t
{
    // C言語のepoll_data_t.ptrは、void*型
    public NativePointer ptr;
}

[NativeType("sys/epoll.h", SymbolName = "struct epoll_event")]
internal struct epoll_event
{
    public uint events;
    public epoll_data_t data;
}

ptrフィールドはNativePointer型で宣言してあります。実際の型がvoid*で宣言されていますが、C#ではvoid*は限られた使用法でしか使えません。NativePointer型としておくと、System.IntPtr型と自由に変換することが出来るようになります。

さて、epoll_wait関数がイベントを受信すると、そのイベントに対応する情報がepoll_event構造体に格納されます。これは、epoll_ctl関数で登録した情報と同じものです。つまり、イベントが発生すると、GCHandle.ToIntPtrメソッドで得たポインタを取得できることになります。このポインタはGCHandle.FromIntPtrメソッドを使ってGCHandleに復元できます。

GCHandleさえ手に入ってしまえば、Targetプロパティから(C#で認識可能な)インスタンス参照を得ることが出来ます。プロパティの型はSystem.Object型なので、明示的にIEpollListenerインターフェイスにキャストしておきましょう。

最後にIEPollListener.OnRaisedメソッドを呼び出せば、イベントを登録したときのインスタンスのOnRaisedが呼び出されます(ここではTimer.OnRaisedメソッド)。

これでインフラは全部揃いました。Blinkのサンプルとしての実装を含めた、Mainメソッドの実装です:

public static class Program
{
    private sealed class GpioBlinker : Timer
    {
        private readonly long[] blinkIntervals = new[] { 125_000_000L, 250_000_000L, 500_000_000L };
        private readonly GpioOutput output;
        private bool flag;
        private int blinkIntervalIndex;

        public GpioBlinker(int gpioId)
        {
            output = new GpioOutput(
                gpioId,
                GPIO_OutputMode_Type.GPIO_OutputMode_PushPull,
                true);
            this.NextInterval();
        }

        public override void Dispose()
        {
            base.Dispose();
            output.Dispose();
        }

        protected override void Raised()
        {
            output.SetValue(flag);
            flag = !flag;
        }

        public void NextInterval()
        {
            this.SetInterval(blinkIntervals[blinkIntervalIndex]);

            blinkIntervalIndex++;
            blinkIntervalIndex %= 3;
        }
    }

    private sealed class GpioPoller : Timer
    {
        private readonly GpioInput input;
        private readonly GpioBlinker blinker;
        private bool last;

        public GpioPoller(int gpioId, GpioBlinker blinker)
        {
            input = new GpioInput(gpioId);
            last = input.Value;
            this.blinker = blinker;
            this.SetInterval(100_000_000L);
        }

        public override void Dispose()
        {
            base.Dispose();
            input.Dispose();
        }

        protected override void Raised()
        {
            var current = input.Value;
            if (current != last)
            {
                if (!current)
                {
                    blinker.NextInterval();
                }
            }
            last = current;
        }
    }

    public static int Main()
    {
        using (var epoll = new Application())
        {
            using (var ledBlinker = new GpioBlinker(Interops.MT3620_RDB_LED1_RED))
            {
                using (var buttonPoller = new GpioPoller(Interops.MT3620_RDB_BUTTON_A, ledBlinker))
                {
                    epoll.RegisterDescriptor(ledBlinker);
                    epoll.RegisterDescriptor(buttonPoller);

                    epoll.Run();
                }
            }
        }

        return 0;
    }
}

GpioBlinkerクラスはLEDを点滅させるクラス、GpioPollerはボタン入力を検知してLEDの点滅速度を変化させるクラス、そしてMainメソッドでApplicationクラスと2つのクラスを生成して登録し、イベント処理のループに入ります。

今までインフラを作っていたので全容が見えにくかったのですが、このMainメソッドと2つのクラスだけを見れば、かなりシンプルでわかりやすいと思います。この図のように、Lチカ固有のコードにだけ関心を向けることが出来ます。

インフラの部分は、MT3620(又はAzure Sphere)のライブラリとして固めておけば、更に捗りそうです。NuGetパッケージ化すればなお良いでしょう。

これで完成です。このコードは ac2018-step2のタグ で保存してあります。

生成されたコードを比較する

最後に、IL2Cが生成するコードが、手でC言語コードを書いたものと比較してどの程度のフットプリントなのかを見てみます。

比較対象は、ビルドするとgccによって生成されるネイティブバイナリです(デプロイ用パッケージではありません)。例えば、$(OutDir)/Mt3620App.outです。なお、以下の全ての計測は、デバッグ情報を含んでいない(-g0)サイズです:

Debug Release
SDK Blinker (C sample code) 16KB 16KB
IL2C Step1 (Non OOP) 111KB 42KB
IL2C Step2 (OOP) 142KB 54KB

SDK BlinkerのサイズがDebugとReleaseで同一なのは、間違いではありません。それだけ、コードの複雑性がなく、最適化の余地は無かったということでしょう。IL2CのStep1・Step2共に、最適化がかかった場合のフットプリント縮小が劇的です。これはIL2Cの設計上狙ったとおりの結果です。

IL2Cは、比較的単調なコード(言い換えればC言語コンパイラが最適化を行いやすいコード)を出力し、かつ、gccのオプション(-fdata-sections -ffunction-sections -Wl,–gc-sections)によって、使用されていないシンボルをリンク時に削除する事を前提としています。

IL2Cのランタイムはまだ小規模ですが、やがてcorefxのマージを行うようになると、非常に大きくなることが予想されます。その場合でも、最低限必要なコードだけがバイナリに含まれて欲しいので、この前提が重要になります。

他にも、C言語標準ライブラリをできるだけ使用する、という方針もあります。独自のコードを含めば含んだだけ、フットプリントが増えてしまうからです。System.Stringの文字列操作も、内部ではC言語のwcslenやwcscatなどの関数を使ったり、マークアンドスイープガベージコレクタが、mallocやfree関数をバックエンドとして使っている(そしてコンパクションを行わない)のも、このような背景によるものです。

しかし、それでもSDK Blinkerのサイズと比較すると、やや大きいですね。不本意です。まだ努力が足りない感じです。フットプリントを小さくするアイデアは、まだ色々あるので、今後のバージョンでトライして行こうと思います。

まとめ

IL2Cを使って、Azure Sphere MCUを搭載したMT3620で、C#を使ってモダンなスタイルのコードを書くという試みを行いました。今回の試行でうまく動くようにするための技術的なフィードバックは、結構沢山出てきました(詳しくはコミットログを参照して下さい。かなり難易度高くて、初見では意味不明かもしれませんが…)。ドッグフーディングはやはりやっておくべきですね。

IL2Cのような試みは他でもいくつか行われていて、LLVM向けのものとか、Roslynの構文木からC言語のコードを生成するとか、はてはC#でブート可能なOSを作る、なんてものもあります(ググってみて下さい)。それらのコミュニティプロジェクトや、本家.NET Core・monoとの違いとしては、ポータビリティとフットプリントかなと思っています。まだまだ完成には遠い感じですが、これからも、ただの概念実験にならないようにしていきたいと思います。