組み込みの埋まってるとこ

システム寄りの組み込みCプログラミングBLOG

組み込みでも使えるオープンソース ~アプリケーション編~

オープンソースと言えばLinuxWindows用が多いのですが、 本日は非力なマイコンでも実用的に使えるお気に入りのオープンソースをピックアップしてみたいと思います。 あなたはいくつご存じでしょうか?

libpng ~画像コーデックライブラリ~

libpngはお馴染みのPNG画像を圧縮伸長するライブラリです。 libpngではファイルからだけではなくメモリ上の画像データをデコードすることも可能になっていて 組み込みでも簡単に利用することができます。 GUIにたくさん画像を使いたいようなときはとても助かります。

libpngから派生したソフトウェアにはヘッダをインクルードするだけで PNG画像がデコードできるようになっているライブラリもあり、こういったものを利用すると導入の敷居も低くなります。 まだなんとなくBMPのライブラリを使っている人は是非トライしてみて下さい。

www.libpng.org

zlib ~データ圧縮ライブラリ~

zlibはZIP等でお馴染みの圧縮ライブラリです。 実はPNGはzlibに画像用のヘッダを付け加えたようなフォーマットになっていて、 zlib単体も組み込みで簡単に利用することができます。

ROMがカツカツになっているようなときにこのzlibが使えるかもしれません。.dataセクションを圧縮しておいて起動時に展開するブートローダなんてのも面白そうです。 ただし組み込みなのでzlibのデコーダのサイズの方が圧縮前のデータより大きかった、という事態には気を付けなければなりませんが。

zlib.net

FreeType ~フォントレンダラ~

FreeTypeはフォントデータを画像としてレンダリングするライブラリです。 対応しているフォントの種類も多くTrueTypeのようなアウトラインフォントをラスタライズできるところは強いです。 このFreeTypeは実際のところ単独で直接使うよりも GUIのようなフォントを扱うのライブラリの一部として間接的に使っていることの方が多いかもしれません。

FreeTypeを直接使うときはレンダリング後のフォント画像をフレームバッファに描きこむ部分を作る必要があります。 ここはグラフィックスのスキルが必要なところで、CPUでベタ転送すると動作がモッサリと遅くなってしまったり、 黒背景の上にしか描画できないチープな制限がついてしまったりします。

またフォント形式によっては組版をするときに使う専門的なパラメータを数多く持っていることがあり、 位置や大きさを思いどおりに指定するだけでもちょっとした調べ物が必要になることがあります。

freetype.org

LVGL ~GUIライブラリ~

LVGLはOS無しのベアメタルでも動作するGUIライブラリです。 ちゃんと自前のウインドウシステムを持っていて、OSの助けがなくても綺麗にウインドウの重ね合わせやアニメーションができてイベントも飛んできます。 ウィジェットもボタンだけしかないということはなく、あるべきものはひととおり揃っています。

特に良いと思うところは対象としているマイコンのスペックが決して高くないところで、 グラフィックスであればフルカラーのダブルバッファはもちろんのこと、8ビットカラーのラインバッファでもポーティング可能になっています。これならSPI接続で外付けするグラフィックチップもターゲットに入ります。

アプリケーションの開発に目を向けると、GUIをプログラムするときのAPIはLVGLとターゲットが近いFLTKに似た体系になっていると思います。 まだ試したことはありませんがGUIのデザインをつくるツールとしてSquareLine Studioというものもあるらしく、 開発環境としても有名どころのGUIに肩を並べた感じです。 関連プロジェクトも充実していて、試しに動かしてみるみるところも、Windowsやお手軽評価ボード用にポーティングされた環境を使えば直ぐに始められます。

lvgl.io

Luaスクリプト言語

Luaプログラミング言語です。 組み込みにプログラミング言語なんか必要なと思われるかもしれませんが、 テキスト形式でデータを読み込ませたいときにパーサーの変わりとして使うと 制約だらけのCSVのサブセットよりもユーザーによい経験をしてもらうことができます。

そういう用途であればJSONXMLも候補に上がるかもしれませんが、 LuaANSI-C準拠のコンパイラですんなりとコンパイルできるようにコーディングされいて、 ポーティングもmallocシリーズに相当する関数を追加するだけで済むのでとても簡単です。

ただしLuaとCのインターフェースがスタックマシンのようになっていて少し独特です。 ここは分かるまでに少し時間がかかるところかもしれません。

www.lua.org

まとめ

今回ピックアップしたオープンソースはライセンスがBSDかそれに類する穏やかなもので 利用や配布に対して過度に神経を尖らせる必要はありません。 RAMが数十kBのようなマイコンではオープンソースを活用する余地は小さいのですが、 マイコン以上Linux未満のところではこれらのソフトが役に立つ機会も十分あり覚えておいて損はないと思います。

ベクタライズされる基本的なプログラム例

Cコンパイラの最適化でベクタライズされるプログラムの基本的な条件を実験しながら確認してみました。

ベクタライズされるプログラムとされないプログラム

演算に使用される変数をそれぞれ配列、構造体、スカラーとしたとき、 ベクタライズされるかどうかを確認してみます。

今回の確認にはARM Linux用のGCC 12を使用しています。 特に断りがなければコンパイルオプションは次のとおりです。

-O3 -ftree-vectorize -march=armv7-a+simd

変数が配列になっているときはベクタライズ可能

変数を配列にすることはベクタライズを意識したプログラムの基本ですので まずそのとおりやってみます。

void add_array(
  const int32_t *restrict a,
  const int32_t *restrict b,
  int32_t *restrict c)
{
    c[0] = a[0] + b[0];
    c[1] = a[1] + b[1];
}

結果をアセンブラで確認すると期待どおり綺麗にベクタライズされています。 aとbそれぞれで32ビットの値が2つ同時に64ビットのdレジスタにロードされ、 その後の加算と結果のストアもずっと2つ同時になっています。

add_array:
        vld1.32 {d16}, [r0]
        vld1.32 {d17}, [r1]
        vadd.i32        d16, d16, d17
        vst1.32 {d16}, [r2]
        bx      lr

変数が構造体になっているときベクタライズされる条件

構造体の中の変数どうしの演算がベクタライズできるかどうかはやや不確かになります。 理論的には変数が配列にしたときのように連続したアドレスに配置されればできるはずです。 例えば次のようなシンプルな構造体であればまず問題ないでしょう。

struct vec2 {
 int32_t x;
 int32_t y;
 };

void add_struct(
  const struct vec2 *restrict a,
  const struct vec2 *restrict b,
  struct vec2 *restrict c)
{
    c->x = a->x + b->x;
    c->y = a->y + b->y;
}

結果は先ほどの変数が配列のときと同じになりました。

add_struct:
        vld1.32 {d16}, [r0]
        vld1.32 {d17}, [r1]
        vadd.i32        d16, d16, d17
        vst1.32 {d16}, [r2]
        bx      lr

変数がスカラーのときはベクタライズされない

変数がスカラーのときにベクタライズされることはおそらくないでしょう。 ベクタライズされない原因として単純にコンパイラがベクタライズ可能であると認識するのが難しいということもあるのでしょうが、 もし仮に認識できたとしても スカラーとしてプログラムされている変数が連続したアドレスに割り当てられることは保障できず、 個別にロードして1つのレジスタに詰め込んでから演算したのではコストが高くなり過ぎます。

void add_scalar(
  int32_t a_x,
  int32_t a_y,
  int32_t b_x,
  int32_t b_y,
  int32_t *c_x,
  int32_t *c_y)
{
    *c_x = a_x + b_x;
    *c_y = a_y + b_y;
}

ベクタライズされていないので加算命令addも2回でてきています。

add_scalar:
        add     r1, r1, r3
        ldr     r3, [sp]
        add     r0, r0, r2
        str     r0, [r3]
        ldr     r3, [sp, #4]
        str     r1, [r3]
        bx      lr

ポインタはrestrictで型修飾されていないとベクタライズされないことがある

ベクタライズのプログラム例には restrictというあまり見かけない型修飾がついていることがよくあるのですが これは必須なのでしょうか?

restrictはポインタが指しているデータ領域が他のポインタから参照されているデータ領域と重複していないという意味になります。 ではベクタライズするときデータ領域が重複していると何が問題かというと、 出力と入力のデータ領域が一部重複しているときは出力したデータで入力のデータ領域を破壊することが理論的に起こり得るため、 コンパイラはベクタライズを躊躇するようになってしまいます。

下記の関数を例にすると、入力のaと入力のbはrestrictである必要はなく同じデータ領域であったとしても問題ないのですが、 出力のcと入力のa、または出力のcと入力のbが重複したデータ領域である場合には問題になります。 そのためcがrestrictであるか、またはaとbが共にrestrictである(aとbはどちらもcと重複していない)ことがベクタライズされるための条件になります。

void add_array_non_restrict(
  const int32_t *a,
  const int32_t *b,
  int32_t *c)
{
    c[0] = a[0] + b[0];
    c[1] = a[1] + b[1];
}
add_array_non_restrict:
        ldr     r3, [r0]
        push    {r4}
        ldr     r4, [r1]
        add     r3, r3, r4
        str     r3, [r2]
        ldr     r4, [sp], #4
        ldr     r3, [r0, #4]
        ldr     r1, [r1, #4]
        add     r3, r3, r1
        str     r3, [r2, #4]
        bx      lr

上記のプログラムを少し手直して、ポインタとしてではなく配列の実体を参照するようにするとrestrictが無くてもデータ領域が重複しないことが明らかなのでベクタライズされます。

void add_array_ref()
{
    extern int32_t a[2];
    extern int32_t b[2];
    extern int32_t c[2];

    c[0] = a[0] + b[0];
    c[1] = a[1] + b[1];
}
add_array_ref:
        movw    r1, #:lower16:a
        movt    r1, #:upper16:a
        movw    r2, #:lower16:b
        movt    r2, #:upper16:b
        movw    r3, #:lower16:c
        movt    r3, #:upper16:c
        vld1.32 {d16}, [r1]
        vld1.32 {d17}, [r2]
        vadd.i32        d16, d16, d17
        vst1.32 {d16}, [r3]
        bx      lr

ループ回数が変数になっているときのベクタライズ

ループ回数が変数になっているプログラムをベクタライズするのは技術的に難しいと思うのですが、 これは圧巻の力技で解決されます。

例えば次のプログラムは不定回の代入を行うプログラムですが、 GCC 12ではループの残り回数が4以上のときは4並列で代入し、それ以外の残り回数のときは1つずつ代入するという それなりにボリュームのあるコードにベクタライズされました。 ここのところがどのようにベクタライズされるかはGCCのバージョンによりかなり異なるようです。

void variable_loop(
  int32_t n,
  int32_t *restrict a,
  int num)
{
  for (int i = 0; i < num; i++) {
    a[i] = n;
  }
}
variable_loop:
        cmp     r2, #0
        ble     .L9
        subs    r3, r2, #1
        cmp     r3, #2
        bls     .L6
        lsr     ip, r2, #2
        vdup.32 q8, r0
        mov     r3, r1
        add     ip, r1, ip, lsl #4
.L4:
        vst1.32 {q8}, [r3]!
        cmp     ip, r3
        bne     .L4
        bic     r3, r2, #3
        cmp     r2, r3
        beq     .L12
.L3:
        add     ip, r3, #1
        push    {lr}
        cmp     r2, ip
        lsl     lr, r3, #2
        str     r0, [r1, r3, lsl #2]
        ble     .L1
        add     r1, r1, lr
        adds    r3, r3, #2
        cmp     r2, r3
        str     r0, [r1, #4]
        it      gt
        strgt   r0, [r1, #8]
.L1:
        ldr     pc, [sp], #4
.L9:
        bx      lr
.L12:
        bx      lr
.L6:
        movs    r3, #0
        b       .L3

undefined reference to になるGCC特有の原因

undefined reference to エラーはリンク時に定義されていないシンボルがあるときに発生しますが、 GCCではリンクに関する意外なルールが原因で発生することもあります。

GCCでのシンボル検索とundefined referenceになる原因

GCCではリンクするとき起動時にコマンドラインで渡されたリストの順序に従って関数名や変数名等で未解決になっているシンボルを検索していきます。 そして最後まで辿り着いた時点でまだ未解決のシンボルが残っていればもう一度最初から検索することを繰り返します。

しかしライブラリファイル(.a)に対してはこの検索が1回しか行われないという少し意外なルールが存在します。 これにより、あるライブラリで未解決のシンボルがリストの手前に位置しているライブラリの中で定義されていた場合にそれを発見することができず undefined referenceエラーが発生する原因になります。

下記の図はエラーが発生する具体例です。 libB.aの中に未解決のシンボルを発見したとき、 それを解決するためのライブラリに対して行われる検索はリストの最後に位置しているlibB.aで終了してしまい、libA.aがもう一度検索されることはありません。 そのためリストの前方に位置するlibA.aにシンボルが定義されているにも係わらずundefined referenceエラーが発生します。

GCCでライブラリの中で定義されているシンボルが未定義となる例

もしもこのときlibB.aで未解決のシンボルがオブジェクトファイル(.o)の中で定義されているのであれば、 オブジェクトファイルがリストのどこに位置していたとしても再度最初から検索されるときに発見され、エラーになることはありません。

undefined referenceの解決方法

基本的にはコマンドラインで渡すリストの順序を決めるとき、 シンボルを定義しているライブラリをシンボルを外部参照しているライブラリよりも後方に位置するように並べます。

一般的にはよく参照される汎用的なライブラリである程リストの後方に位置するように並べるのがコツと言えます。 またライブラリは1回しか検索されないルールがあるためここで取りこぼしが起こらないように、 ライブラリファイルはすべてのオブジェクトファイルよりも後方に並べるようにします。

gcc ... objA.o objB.o -lA -lB

稀なケースでAライブラリでBライブラリのシンボルを参照していて、かつBライブラリでAライブラリのシンボルを参照する相互参照になっているケースがあります。この場合は同じライブラリを複数回ライブラリリストに並べることで解決できます。なおライブラリは複数回同じファイルをリンクしてもシンボル重複のエラーにはなりません。

gcc ... objA.o objB.o -lA -lB -lA -lB

相互参照を解決するもう一つ方法としてライブラリのグループ化があります。 グループ化されたライブラリは相互参照になっているシンボルがすべて解決するまで繰り返し検索されるようになります。

ライブラリをグループ化するためには--start-groupと--end-groupでライブラリリストを囲みます。 このオプションはリンカーのオプションであるため、GCCから渡すときは -Wl, による前置きが必要です。

gcc ... objA.o objB.o -Wl,--start-group -lA -lB -Wl,--end-group

ただしグループ化はやり過ぎるとリンクが遅くなることがあるため、 本当に必要なものだけをグループ化するように心がけます。

まとめ

GCCでリンクするときライブラリがリストの順序で1回しか検索されることと、 そのために定義されているシンボルがundefined referenceになるケースを示しました。

またその解決方法として、ライブラリのリストの並べ方による方法とライブラリをグループ化する方法を示しました。

arm-none-eabi-gccとは何か?

arm-none-eabi-gccとは何か?

arm-none-eabi-gccとはGCCのクロスコンパイラです。 クロスコンパイラとはプログラムを実行するターゲットとは異なるホストコンピュータで コンパイルすることができるコンパイラのことです。

arm-none-eabiの意味

gccの手前にハイフンで区切られた長い前置きが付いていますが、この部分はトリプルと呼ばるコンパイラ命名するときの慣習で、クラスコンパイラがターゲットとしているmachine-vendor-osを意味しています。

しかし必ずしもこの慣習どおりに命名されていないこともよくあり、 arm-none-eabiの場合は次のように考えばよいでしょう。

  • arm ... ARM用の
  • none ... 特定のベンダーが提供するものではない
  • eabi ... EABIと呼ばれるABIに準拠したバイナリコードを生成するコンパイラ

arm-none-eabi-gccの使い方

arm-none-eabi-gccはこれだけを単体で入手しても専門的な技術がなければ完全に動作するプログラムをコンパイルすることができません。 通常はターゲットにしている環境のSDKを入手しその指示に従って使用します。

SDLとMinGWによるOpenGL入門環境

SDLによるOpenGLの実行例

Windowsとグラフィックスボード無しのPCでOpenGL入門用の環境を構築してみました。 ANGLEによりDirectX上でOpenGL ESをエミュレートしましたので実行速度も高速です。

動作環境

今回はできるだけ多くのPCで動作することを目標としているのでハードウェアに注目すべき点は特になく、事務処理に使用されるようなPCと同レベルです。

グラフィックスもインテルCPU内蔵のものを使用していますし、 ドライバはWindows 10と一緒にインストールされていたDirectXそのままです。

ツールチェーン

  • MinGW64
  • SDL
  • ANGLE (libGLESv2.dll libEGL.a d3dcompiler_47.dll)
  • GCC
  • make

ソフト開発のツールはmsys2のMinGW64をベースにしています。 Visual Studioは便利なのですが完成した開発環境を配布するとき敷居が高いので候補から外しました。GCCとmakeを使っているのもそのような理由からきています。

OpenGLフレームワークとしてはglfwやglutがよく使われるのですが、 歴史的な資産の多さでSDLを選んでいます。

ANGLEは今回の目玉となるドライバで、OSの基本的なグラフィックスAPIOpenGL ESをエミュレートしてくれる優れものです。 公式にはドライバのバイナリをダウンロードできるサイトは存在していないため、PCにインストールされていたChromeからDLLだけ拾ってきて使用しています。 他にもEdgeやVSCodeから入手しても問題ないと思います。

SDLでANGLEをレンダラーとするプログラム例

上記の環境でOpenGL ESを動作させたプログラムは次のようになります。 まずはプログラムの前半としてSDLOpenGLのサーフェイスをウインドウに表示させる部分から説明します。

#define SDL_ASSERT_LEVEL 3
#include <SDL.h>
#include <GLES2/gl2.h>

#define WINDOW_WIDTH  800
#define WINDOW_HEIGHT 200

void DrawSetup(void);
void Draw(void);
void DrawCleanup(void);

int main(int argc, char *argv[])
{
    SDL_Init(SDL_INIT_EVERYTHING);
    SDL_LogSetAllPriority(SDL_LOG_PRIORITY_VERBOSE);

    /* Window and Renderer */
    SDL_SetHint(SDL_HINT_OPENGL_ES_DRIVER, "1");
    SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengles2");

    SDL_Window *win;
    win = SDL_CreateWindow("SDL-MinGW-OpenGL",
                           SDL_WINDOWPOS_UNDEFINED,SDL_WINDOWPOS_UNDEFINED,
                           WINDOW_WIDTH, WINDOW_HEIGHT,
                           SDL_WINDOW_OPENGL);
    SDL_Renderer *rend;
    rend = SDL_CreateRenderer(win,
                              -1,
                              SDL_RENDERER_ACCELERATED | SDL_RENDERER_TARGETTEXTURE);

    /* OpenGL */
    SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
    SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
    SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);

    /* Main loop */
    DrawSetup();

    int quit = 0;
    while (!quit) {
        SDL_Event event;
        while (SDL_PollEvent(&event)) {
            if(event.type == SDL_QUIT) {
                quit = 1;
                break;
            }
        }

        Draw();
        SDL_Delay(16);
        SDL_GL_SwapWindow(win);
    }

    DrawCleanup();

    /* Finalize */
    SDL_GL_MakeCurrent(win, NULL);
    SDL_DestroyRenderer(rend);
    SDL_DestroyWindow(win);
    SDL_Quit();

    return 0;
}

プログラム全体としてはSDLチュートリアルのようになっていますが、 注目すべきポイントはOpenGLを使用すためにSDLがレンダラーとしてANGLEを選択するように与えているヒントです。

ヒントはSDLの開発者が必要に応じて追加しているようなところがあり、 正しいヒントの組み合わせはSDLソースコードを読まなければ正解が分からないのですが、 今回は試行錯誤を繰り返し次の2つのヒントを与えれば目的を達成できるらしいという結論に至りました。

SDL_SetHint(SDL_HINT_OPENGL_ES_DRIVER, "1");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengles2");

これらのヒントを与えたうえでPATHが通ったディレクトリにlibGLESv2.dll libEGL.dll d3dcompiler_47.dllが存在していれば、 プログラム実行時にレンダラーとしてANGLEが選択されOpenGL ESが使用できるようになります。

しかし別途グラフィックスボードが存在する環境やソースコードからコンパイルしたANGLEをリンクした場合は少し違う結果になるかもしれません。 そのような時のために他にも関係ありそうなヒントをメモしておきます。

  • SDL_HINT_VIDEO_WIN_D3DCOMPILER
  • SDL_HINT_RENDER_OPENGL_SHADERS

またWindows環境でSDLがレンダラーを選択する仕組みはSDLのソースツリーにあるdocs/README-windows.mdに詳細があります。

Hello Triangleのプログラム例

先ほどのプログラム例の後半で実際にOpenGL ESで描画している部分は次のとおりです。

unsigned int vertexShader;
unsigned int fragmentShader;
unsigned int programObject;

#define LF "\n"

const char *vShaderStr[1] = {
    "attribute vec4 vPosition;" LF
    "varying vec3 fPosition;" LF
    "void main()" LF
    "{" LF
    "  gl_Position = vPosition - vec4(0.5, 0.5, 0.0, 0.0);" LF
    "  fPosition = vPosition.xyz;" LF
    "}" LF
    LF,
};

const char *fShaderStr[1] = {
    "precision mediump float;" LF
    "varying vec3 fPosition;" LF
    "void main()" LF
    "{" LF
    "  gl_FragColor = vec4(0.0, 0.4, 0.8, 1.0) + vec4(fPosition,0.0);" LF
    "}" LF
    LF,
};

void DrawSetup(void)
{
    /* Vertex shader */
    vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, vShaderStr, NULL);
    glCompileShader(vertexShader);

    /* Fragment shader */
    fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, fShaderStr, NULL);
    glCompileShader(fragmentShader);
    
    /* Shader program */
    programObject= glCreateProgram();
    glAttachShader(programObject, vertexShader);
    glAttachShader(programObject, fragmentShader);
    glBindAttribLocation(programObject, 0, "vPosition");
    glLinkProgram(programObject);

    glDeleteShader(fragmentShader);
    glDeleteShader(vertexShader);

    glUseProgram(programObject);

    /* Vertex */
    static GLfloat vVertices[] = {
        0.0f, 1.0f, 0.0f,
        0.0f, 0.0f, 0.0f,
        1.0f, 0.0f, 0.0f,
    };
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3*sizeof(GLfloat), vVertices);
    glEnableVertexAttribArray(0);

    /* View */
    glViewport(0,0, WINDOW_WIDTH, WINDOW_HEIGHT);
}

void DrawCleanup(void)
{
    glDeleteProgram(programObject);
}

void Draw(void)
{
    glClearColor(0.0f, 0.0f, 0.2f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    glDrawArrays(GL_TRIANGLES, 0, 3);
}

プログラムは有名な教科書のHello Triangleと似たような内容になっていますが、 シェーダーをコンパイルするために必要なDLLがANGLEやDirectXのバージョンによって違うようなのでその確認のため、シェーダー部分はバーテックスシェーダーからフラグメントシェーダーへ簡単なパラメターを渡して演算するように少し変更しています。

MinGW64のパッケージとANGLEのDLL

プログラムをコンパイルする前に必要なMinGW64のパッケージをインストールしておきます。

$ pacman -S mingw-w64-x86_64-SDL mingw-w64-x86_64-mesa

mesaはオープンソースOpenGL互換ライブラリ等のパッケージです。 実はmesaにもANGLEと同じDLLが存在しているのですが、 mesaのDLLはANGLEとは異なりDirectXを使ってOpenGLをエミュレートするようにはなっていないため、 グラフィックスボードが存在しない環境では動作しないようです。 そのため今回はOpenGLのヘッダファイルを使用するためだけにmesaをインストールしています。

DLLは別途Chrome, Edge, VSCode等がインストールされているフォルダから次のDLLをコンパイルした実行プログラムと同じフォルダにコピーしてください。

  • libGLESv2.dll
  • libEGL.dll
  • d3dcompiler_47.dll

実行時にDLLをロードするときmesaのDLLが先に見つかってしまうとおそらくウインドウが生成できないエラーになりますのでPATHの検索順序には気を付けるようにしてください。

MinGWでのコンパイル

プログラムをコンパイルするMakefileは次のようになります。

CFLAGS := $(shell pkg-config --cflags sdl2 gles2 egl)
LIBS   := ./d3dcompiler_47.dll ./libGLESv2.dll ./libEGL.dll $(shell pkg-config --libs sdl2)

all: demo

demo: demo.c
    gcc $^ -g -Og $(CFLAGS) $(LIBS) -o $@

demo.cは仮の名前ですので、実際のファイル名に変更して使用してください。

pkg-configの出力例は次のようになります。もしpkg-configが動作しない場合はこの出力例を参考にして直接指定してください。

CFLAGS := -IC:/msys64/mingw64/include/SDL2 -Dmain=SDL_main -IC:/msys64/mingw64/include -DEGL_NO_X11
LIBS   := ./d3dcompiler_47.dll ./libGLESv2.dll ./libEGL.dll -LC:/msys64/mingw64/lib -lmingw32 -lSDL2main -lSDL2 -mwindows

ここでリンクしているDLLについてですが、Chromeからコピーしてきたものであるため.a (または.lib)が存在しません。 そのため少々荒っぽいですがコピーしてきた.dllを直接リンクしています。

d3dcompiler_47.dllはこの段階でリンクしなくてもエラーにはならず、 libGLESv2.dllが実行時に必要に応じてロードする仕組みになっています。

実行結果

プログラム実行中のタスクマネージャーのGPUの負荷グラフです。

ANGLE実行時のGPU負荷

CPUでレンダリングする部分があると待ち合わせ等でGPUの3Dが100%に張り付くことはないのですが、 今回はプログラムが単純なのですべてCPU内蔵グラフィックスでレンダリングできているようです。

なおANGLEが正しく動作していることを確認するため負荷の測定中はメインループ中にあるSDL_Delayを一時的にコメントアウトしています。 通常使用時は節電と静音のため適度にSDL_Delayを入れておいても十分に高速です。

GCCによる割り込みハンドラのプログラミング

割り込みハンドラはOSが存在している場合はその流儀に従ってプログラミングすればよいのですが、 OSが存在しないベアメタルの場合にはCPUアーキテクチャや割り込み処理内で許可する動作を踏まえた専門的なプログラムが必要になります。

Arm用GCCでは関数にinterruptアトリビュートを指定すると 割り込みハンドラの入口と出口で必要になるCPUアーキテクチャに依存したプログラムをGCCに肩代わりさせることができます。 今回はarm-none-eabi-gccでinterruptアトリビュートを使ったときにどこまでできるかを確認してみます。

GCCのinterruptアトリビュート

Arm用GCCでは関数にinterruptアトリビュートを付けると、 割り込み処理をする関数ので必要となるCPUアーキテクチャに依存したコードを生成させることができます。

関数にinterruptアトリビュートを指定する場合は必ずinterruptアトリビュート付きで関数を宣言した後に関数の定義をプログラムします。 またinterruptアトリビュートはいわゆる割り込みであるIRQだけでなく、 IRQ,FIQ,SWI,ABORT,UDEFを引数に指定することで例外処理全般に対応させることができます。

関数にIRQのinterruptアトリビュートを設定する例は次のようになります。

void irq_handler() __attribute__ ((interrupt ("IRQ")));

void irq_handler()
{
   ...
}

interruptアトリビュートが生成するコード

実際にinterruptアトリビュートを設定した関数が生成するコードを確認してみます。 割り込みハンドラは確認しやすいように関数を呼び出すだけのシンプルな構造にしています。

extern void irq_main(void);

void irq_handler() __attribute__ ((interrupt ("IRQ")));

void irq_handler()
{
    irq_main();
}

コンパイルオプションは次のとおりです。

 arm-none-eabi-gcc -mcpu=cortex-a9 -mfloat-abi=hard -Os -S irq_handler.c

生成されたコードは次のようになりました。見やすくするため主要な部分だけ切り出しています。

    .cpu cortex-a9
    .text
    .arch armv7-a
    .fpu neon-fp16

irq_handler:
    @ Interrupt Service Routine.
    @ args = 0, pretend = 0, frame = 0
    @ frame_needed = 0, uses_anonymous_args = 0
    sub  lr, lr, #4
    push {r0, r1, r2, r3, ip, lr}
    bl   irq_main
    ldmfd    sp!, {r0, r1, r2, r3, ip, pc}^
    .ident    "GCC: (GNU) 8.4.0"

生成されたコードを順を追って確認していきます。

 sub  lr, lr, #4

割り込みからの復帰アドレスを補正しています。LRレジスタには割り込みがあった時処理していたPCのアドレス+8が格納されるため、割り込みから復帰後にその次の命令に戻るため-4しています。

 push {r0, r1, r2, r3, ip, lr}

クラッチレジスタを退避しています。 この他のレジスタはEABIでは関数の呼び出し先で必要に応じて退避することになっているので、 ここでは最低限必要なレジスタだけを退避しているようです。

またここで気になる点として.fpuがneon-fp16になっているのですがNEONで拡張された浮動小数レジスタであるd8-d15はおろかd0-d7さえも退避されていないことがあげられます。 GCC全体として言えることなのですが、浮動小数レジスタについては関数では呼び出し規約どおりに保護されたコードを生成するのですが、コンテキストが切り替わるときの保護はOS任せになっている感があり、この割り込みハンドラでもそのように扱われているようです。 ここはCortex-Aクラスのコアを直叩きで使用する使用する場合には注意が必要なところであると言えます。

 bl   irq_main

IRQを処理するメインの関数を呼び出しています。 irq_main関数は特にinterruptアトリビュートを指定する必要はなく通常の関数として問題ありません。

irq_main関数では少なくとも次の処理をすることが必要があります。

  • 割り込み要因のクリア
  • 割り込みコントローラーの処理

割り込み要因のクリアはOSが存在するときの割り込みハンドラでも行われる一般的な割り込み処理です。 ただし割り込み番号は自分で割り込みコントローラに問い合わせして特定する必要があります。

割り込みコントローラーの処理は割り込みコントローラによっては必要ないかもしれません。 MPCoreでよく使われているGICであれば割り込み処理が完了したことをEnd of Interruptレジスタに通知する必要があります。

 ldmfd    sp!, {r0, r1, r2, r3, ip, pc}^

退避していたレジスタを復旧し、CPUを割り込みモードから割り込みが起こる前のCPUモードへ復帰しています。 ldm命令はレジスタリストの最後に^を付けるとSPSRレジスタからCPSRレジスタへの復旧も行われる割り込み処理を意識した便利な仕様になっています。

また補正済みのLRレジスタがPCレジスタへ復旧されるようにstmとldmで微妙にレジスタリストが調整されていることろもポイントになります。 もし割り込みを契機に元のプログラム実行位置に戻らず、別のプログラムに遷移することがあり得る場合はinterruptアトリビュートは使えないことになります。

例外ベクタから割り込みハンドラの呼び出し

これまで確認してきたように割り込みハンドラの関数はGCCのinterruputアトリビュートを使うと割り込みハンドラでアーキテクチャに依存するコードのプログラムを省略できるようになりますが、 その関数を呼び出す部分を直接例外ベクタのアドレスに配置することはCコンパイラだけではできません。

そのためかなり低レベルな方法でIRQベクタの飛び先をinterruptアトリビュートを指定した関数に向ける必要があります。 armv7-aアーキテクチャアセンブラを使う場合の典型的な例は次のようになります。

LDR PC,=irq_handler /* 例外ベクタの先頭+0x18番地の命令 */

まとめ

関数にinterruptアトリビュートを指定することにより次の処理を行うコードをGCCに生成させることができます。

  • レジスタの退避避 (interruptの引数で指定した割り込みモードに対応)
  • CPUの割り込みモードからの復旧
  • 割り込みが発生した命令の次の命令からの再実行

これらの処理は通常はCPUアーキテクチャを理解しうえでアセンブラでプログラムする必要があるためかなりコストが下がったことになります。

しかしながら割り込みコントローラ等のハードウェア構成や、割り込みハンドラで浮動小数レジスタの使用を認めるか否か等ポリシーに係わるところは当然のことながらユーザー任せになっていました。

SDL2でCPU描画した画像を高速に表示する

ちょっとした実験用にCPUで描画した結果を素早くウインドウで表示できる環境があると便利です。 そこでSDL2を使い、フレームバッファに見立てた配列を画像としてウインドウに表示する簡単なプログラムを作成した例を紹介します。

SDL2でのプログラム例

まずはSDL2で配列を画像としてウインドウに表示するプログラムのメインループは次のようになります。

#include <stdint.h>
#define SDL_ASSERT_LEVEL 3
#include <SDL.h>


#define FRAME_WIDTH  320
#define FRAME_HEIGHT 240
#define FRAME_PITCH  (FRAME_WIDTH*sizeof(uint32_t))
#define WINDOW_WIDTH  (FRAME_WIDTH*2)
#define WINDOW_HEIGHT (FRAME_HEIGHT*2)


uint8_t frameBuf[FRAME_PITCH*FRAME_HEIGHT];

void Draw(void *pixels, int width, int height, int pitch);

int main(int argc, char *argv[])
{
    int err;

    err = SDL_Init(SDL_INIT_EVERYTHING);
    SDL_assert_release(err == 0);

    SDL_LogSetAllPriority(SDL_LOG_PRIORITY_VERBOSE);

    /* Window and Renderer */
    SDL_Window *win;
    SDL_Renderer *rend;
    err = SDL_CreateWindowAndRenderer(
        WINDOW_WIDTH, WINDOW_HEIGHT, 0,
        &win, &rend);
    SDL_assert_release(err == 0);

    SDL_RenderSetLogicalSize(rend, FRAME_WIDTH, FRAME_HEIGHT);
    SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "best");//"nearest");

    /* Texture */
    SDL_Texture *tex = SDL_CreateTexture(rend, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_STREAMING, FRAME_WIDTH, FRAME_HEIGHT);
    SDL_assert_release(tex != NULL);

    /* Main loop */
    while (1) {
        SDL_Event event;
        SDL_PollEvent(&event);
        if(event.type == SDL_QUIT) {
            break;
        }

        Draw(frameBuf, FRAME_WIDTH, FRAME_HEIGHT, FRAME_PITCH);
        SDL_UpdateTexture(tex, NULL, frameBuf, FRAME_PITCH);

        SDL_SetRenderTarget(rend, NULL);
        SDL_RenderCopy(rend, tex, NULL, NULL);
        SDL_RenderPresent(rend);
    }

    /* Finalize */
    SDL_DestroyTexture(tex);
    SDL_DestroyRenderer(rend);
    SDL_DestroyWindow(win);
    SDL_Quit();

    return 0;
}

SDL2でのアクセラレーション

SDLでは画像を表示する方法としてハードウェアで描画するRendererとCPUで描画するSurfaceがあります。 今回は実験用にCPUで細かく描画することを目標としているためSurfaceを使用してもよいのですが、 CPUでメインメモリに描画したデータを表示するだけの目的でRendererを使用したとしても 高速に表示できる等それなりのメリットを得ることができるためRendererを使用しています。

ウインドウとRendererの生成

ここは特に難しいところは無いと思いますが、 表示のとき拡大して表示するためにSDL_RenderSetLogicalSizeを使って Rendererが表示する実際のデータのサイズをウインドウの表示サイズの半分であることを設定しています。 このときどのように拡大または縮小するかのフィルタリングの設定をしているのが SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "nearest")です。 フィルタリングには最近傍のnearesetの他に、線形フィルタのlinearと異方性フィルタのbestがありますが、 linearとbestはRendererが対応していなければnearestになります。

表示の際のフィルタリングの設定はTextureを生成する前に行わないと効果がありませんのでこのタイミングで設定しています。

 SDL_Window *win;
    SDL_Renderer *rend;
    err = SDL_CreateWindowAndRenderer(
        WINDOW_WIDTH, WINDOW_HEIGHT, 0,
        &win, &rend);
    SDL_assert_release(err == 0);

    SDL_RenderSetLogicalSize(rend, FRAME_WIDTH, FRAME_HEIGHT);
    SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "nearest");

テクスチャのカラーフォーマットとアクセスパターン

メインメモリに描画したデータは直接Rendererで表示することができないためいったんTextureに変換してから表示します。 このときカラーフォーマットは変換されないため、 メインメモリに描画するときのカラーフォーマットとTextureのカラーフォーマットは同じにしておく必要があります。

次のプログラムはカラーフォーマットをRGBA8888としたときのテクスチャの生成とCPU描画するとき画素にRGBの各色成分を描き込む例です。

 SDL_Texture *tex = SDL_CreateTexture(rend, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_STREAMING, FRAME_WIDTH, FRAME_HEIGHT);

    Uint32 pixel = R<<24 | G<<16 | B<<8 | A<<0; /* RGBA8888での画素の生成例 */

Texutureの生成にはカラーフォーマットの他にもう一つアクセスパターンという重要なパラメータがあります。 SDL_CreateTextureの3番目の引数でそのアクセスパターンを指定することができます。 今回生成するTextureはCPUで描画したデータにより更新されることがあるためSDL_TEXTURE_ACCESS_STREAMINGを指定しています。

SDL_TEXTUREACCESS_TARGETを指定するとRendererからしか更新できなくなり、悪いことにSDLだけではRendererを使って画素単位でテクスチャを書き換える方法が無いようなので今回の目的では使用することができません。 もう一つSDL_TEXTUREACCESS_STATICというパターンがありますが、これはファイルからロードしたTextureのように、Rendererに渡した後で書き換えが起こらないときに指定します。

CPUでの描画とTextureの更新

CPUによりメインメモリ上の配列を更新し、それをTextureに反映するプログラムは次のようになります。

    ...
    Draw(frameBuf, FRAME_WIDTH, FRAME_HEIGHT, FRAME_PITCH);
    SDL_UpdateTexture(tex, NULL, frameBuf, FRAME_PITCH);
    ...

void Draw(void *pixels, int width, int height, int pitch)
{
    for (int iy = 0; iy < height; iy++) {
        for (int ix = 0; ix < width; ix++) {
            uint32_t *p = (uint32_t *)((uintptr_t)pixels + iy*pitch + ix*sizeof(*p));

            uint8_t r,g,b;
            r = 0x00;
            g = 0x00;
            b = 0xff * iy/height; /* 縦グラデーション */
                
            *p++ = r<<24 | g<<16 | b<<8 | 0xff<<0;
        }
    }
}

CPUでの描画はメインメモリ上の配列に対するものであるため、更新後にその内容をSDL_UpdateTextureでTextureに反映しています。

自作のDraw関数はCPUで描画する関数としては一般的な構成になっていると思いますが、どのようなカラーフォーマットにでも応用できるように画素の型は関数の引数としてはvoid*とし、画素を記憶している配列の横幅はピッチとして論理的な画像の横幅とは別のパラメータにしています。

Textureのウインドウへの表示

ここまでの手順でCPU描画したデータをTextureに変換するところまでが完了しました。次はいよいよよウインドウへの表示です。

その表示部分のプログラムは次のようになっています。

    SDL_SetRenderTarget(rend, NULL);
    SDL_RenderCopy(rend, tex, NULL, NULL);
    SDL_RenderPresent(rend);

SDL_SetRenderTargetの2番目の引数をNULLにすると、Rendererの描画対象がTextureではなくウインドウになります。 描画対象をウインドウにした後SDL_RenderClearでウインドウを規定色でクリアしておくこともありますが、 今回は続くSDL_RenderCopyでウインドウの全領域を書き換えているため省略しています。 最後にSDL_RenderPresentするとついに画面にCPU描画した結果が表示されます。

まとめ

CPUで描画した配列をウインドウに表示できるライブラリは他にもいろいろとありますが、 今回はC言語でプログラムできることを最重要条件として、 オープンソースで使用方法も難しくなく、将来的にマウスやキーボードによる入力や音声の再生も扱うことが可能であるSDL2を選択しました。

目的のためにはSurfaceを使用するだけでも十分ですが、Rendererを使用するとより便利であるためハードウェア的な事情で必要となるTextureの説明も交えてそのプログラム例を紹介しました。