AVRとArduinoのGCC C ++を改善しましょう。





こんにちはHabraplusplusians!



AVRとArduinoに関するさまざまな議論では、「C ++はマイクロコントローラ用ではなく、C ++はメモリを消費し、C ++は肥大化したコードを生成します-裸のCで記述しますが、ASMではより良い」というavr-g ++コンパイラの問題を解決したいと思います。



最初に、Cに対するC ++の利点を理解しましょう。C++が追加する概念はたくさんありますが、最も重要で最も活用されているのはOOPサポートです。 OOPとは何ですか?





C ++で最初の2つのポイントを使用するのは「無料」です。 純粋なCプログラムは、カプセル化と継承を備えたC ++プログラムよりも利点がありません。 ポリモーフィズムをアクションに関連付けると、状況が変わります。 多態性は異なる場合があります:コンパイル時、リンク時、実行時。 私は古典的なランタイム、つまり 仮想機能について。 クラスに仮想メソッドを追加し始めるとすぐに、フラッシュメモリとSRAMの両方の消費が奇跡的に増加します。



これがなぜ起こるのか、これで何ができるのかを説明します。



仮想関数を使用しない例



1つの基本クラスと2つの子孫を持つプログラムを見てみましょう。



volatile unsigned char var; class Base { public: void foo() { var += 19; } void bar() { var += 29; } void baz() { var += 39; } }; class DerivedOne : public Base { public: void foo() { var += 17; } void bar() { var += 27; } void baz() { var += 37; } }; class DerivedTwo : public Base { public: void foo() { var += 18; } void bar() { var += 28; } void baz() { var += 38; } }; DerivedOne dOne = DerivedOne(); DerivedTwo dTwo = DerivedTwo(); int main() { Base* b; if (var) b = &dOne; else b = &dTwo; asm("nop"); b->foo(); for (;;) ; return 0; }
      
      







コンパイラーが知らない「var」の値に基づいた「main」関数では、ベースクラス「b」へのポインターに、最初に継承されたクラスのオブジェクトまたは2番目のオブジェクトへの参照のいずれかへのリンクを割り当てます。 そして、基底クラスへのポインターを使用して `foo`メソッドを呼び出します。



この例はばかげている、なぜなら 子クラスとの大騒ぎに関係なく、基本クラスのBaseからの「foo」の実装が呼び出されます。 例は出発点として役立ちます。



 $ avr-g++ -O0 -c novirtual.cpp -o novirtual.o $ avr-gcc -O0 novirtual.o -o novirtual.elf $ avr-size -C --format=avr novirtual.elf AVR Memory Usage ---------------- Device: Unknown Program: 104 bytes (.text + .data + .bootloader) Data: 3 bytes (.data + .bss + .noinit)
      
      







そのため、プログラムは104バイトのフラッシュメモリと3バイトのSRAMを使用します。 最適化フラグを使用すると、104 + 3バイトは最大34 + 3になり、デッドコードクリアフラグを使用すると、16 + 0バイトになります。



コンパイラーによって生成されたアセンブラーを開き、関数呼び出しの場所を見つけると、図が表示されます。



  ldd r24,Y+1 ldd r25,Y+2 rcall _ZN4Base3fooEv
      
      







「this」値は「r24:r25」レジスタにプッシュされ、「Base :: foo」への直接呼び出しが行われます。 シンプルで効果的。 もちろん、オプティマイザーはこれが役に立たないことに気づき、一般的にインラインの可能性を見ますが、最適化されていないレベルで話しましょう。



仮想を追加



次に、ポリモーフィズムを追加しましょう。 メソッドを仮想化しましょう:



 volatile unsigned char var; class Base { public: virtual void foo() { var += 19; } virtual void bar() { var += 29; } virtual void baz() { var += 39; } }; class DerivedOne : public Base { public: virtual void foo() { var += 17; } virtual void bar() { var += 27; } //virtual void baz() { var += 37; } }; class DerivedTwo : public Base { public: virtual void foo() { var += 18; } //virtual void bar() { var += 28; } virtual void baz() { var += 38; } }; DerivedOne dOne = DerivedOne(); DerivedTwo dTwo = DerivedTwo(); int main() { Base* b; if (var) b = &dOne; else b = &dTwo; asm("nop"); b->foo(); for (;;) ; return 0; }
      
      







私たちはチェックします:



 AVR Memory Usage ---------------- Device: Unknown Program: 312 bytes (.text + .data + .bootloader) Data: 25 bytes (.data + .bss + .noinit)
      
      







わあ! 25バイトのSRAM。 クラスの別のインスタンスを作成すると、さらに2バイトを消費することを確認するのは簡単です。 これらの2バイトは、仮想関数のテーブルへのポインターです。これにより、基本クラスへのポインターを使用してメソッドを呼び出すときに、名目上の子クラスの特定の実装を実行できます。



しかし、1バイトにつき2つのグローバルオブジェクトと1つの不幸な変数しかありません。 誰が記憶の残りを食べましたか? だから私たちは問題の中心に来ます。 これら仮想テーブルそのものです。 各クラスの作品に。 それぞれのサイズは、仮想関数の数に線形に依存します。



ポリモーフィズムの価格



仮想関数テーブルをスケッチしましょう。 この例では、クラスごとに1つずつ、合計3つあります。



 vtable for Base: foo -> Base::foo bar -> Base::bar baz -> Base::baz vtable for DerivedOne: foo -> DerivedOne::foo bar -> DerivedOne::bar baz -> Base::baz vtable for DerivedTwo: foo -> DerivedTwo::foo bar -> Base::bar baz -> DerivedTwo::baz
      
      





8ビットAVRへの各ポインターは2バイトです。 階層内のクラスごとにそのようなテーブルを一度作成し、特定のインスタンスで特定のテーブルを指す1つの非表示フィールド `__vtbl *`を追加するだけで十分です。 そのため、ポインターによって呼び出されるメソッドのタイプに関係なく、各インスタンスは「それが誰であるかを知っています」。 つまり 1つのオブジェクトのポリモーフィズムのオーバーヘッドは、 `__vtbl *`ごとに+2バイトだけであり、間接呼び出しのコストです。 メソッドは直接呼び出されませんが、最初にテーブルからそのアドレスがプルされ、次に呼び出しが行われます。



  ldd r24,Y+1 ldd r25,Y+2 mov r30,r24 mov r31,r25 ld r24,Z ldd r25,Z+1 mov r30,r24 mov r31,r25 ld r18,Z ldd r19,Z+1 ldd r24,Y+1 ldd r25,Y+2 mov r30,r18 mov r31,r19 icall
      
      





間接呼び出しの追加コストは、実行時に非常に重要なコード内の複数の呼び出しに関して重要です。 しかし、その後、疑問が生じます。そのようなコードではポリモーフィズムは何をするのでしょうか? 各タスクには独自のツールがあります。 高レベルの問題を解決するには、OOPが適しています。



avr-gccが間違っている場合



仮想関数のアクティブな使用による実際のSRAMペナルティは、インスタンスごとに2バイトであることを示しました。 このような豊富な機会に非常に適しています。 しかし、avr-gccは何をしますか? 彼は仮想テーブル自体をSRAMに押し込みます! このため、仮想関数、その子孫、またはインターフェース(純粋な抽象クラス)を備えた新しいクラスが出現すると、消費されるSRAMが増加します。



これは完全に不当です、なぜなら 仮想テーブルはプログラム実行中に変更できません。 フラッシュメモリ内にまさにその場所があり、通常はSRAMよりもはるかに遅く「終了」します。 このトピックは、 さまざまな コミュニティで 100回提起されました



皮肉なことに、これらのテーブルは既にフラッシュに配置されており、コントローラーの起動時にSRAMにもコピーされます。 生成されたASMで、関数の実装のアドレスを取得するには、「ldd」ではなく「lpm」を「ただ」使用する必要があります。 SRAMのテーブルのコピーではなく、フラッシュの元のアドレスにアクセスします。



なぜこの最適化をまだ誰も行っていないのですか? すべては、いつものように、技術ではなく人にかかっています。 GCCは真に大規模なオープンソースプロジェクトであり、その背後にはお金を稼ぐ大父はいません。 GCCは非常に大きく、その文化、構造、知識のスーツケースなどがあります。 彼の背景に対して、ある種のハーバードアーキテクチャでC ++がある種のものに望んでいることについて叫んでいる少数の人々は非常に小さいです。 両方の世界に属し、修正の動機が十分にある人はいませんでした。



どうする?



GCCは、ASTからアセンブラーまでのチェーンのどこにでも介入できるプラグインメカニズムを長い間導入しています 。 仮想テーブルの最適化は、プラグインレベルで実装できます。 唯一の問題は、プラグインを作成するには、すべての仕様、API、およびエントリポイントを理解するGCCインサイダーであるか、GCCマニュアルとソースコードを非常にすばやく吸うuberprogrammerである必要があるということです。



そのような人が存在することを本当に願っています。 このプラグインがコミュニティに表示され、利用できるようになり、私たちの生活が少し良くなります。 Amperkaは、プラグインあたり150キロブルブルの開発をサポートする準備ができています。これにより、例のプログラムが25バイトのSRAMから7バイトに乾燥します。



GCCですでにレーキを収集している人を知っている場合は、この投稿に注意を払ってください。 よろしくお願いします! PM、または勝利者[dog] amperka.ruにコメントを書いてください。



All Articles