2023年12月06日
GNU ldとLLVM lldのロケーションカウンタの扱いの違い
従来は Linux や Apple などのリッチ OS のアプリ向けというイメージだった LLVM の高速リンカ lld ですが、LLVM 17 で GNU ld との互換性がほぼ完璧になり、AArch64/ARM/RISC-V のベアメタルツールチェーンでも GNU ld を置き換えできることが確認できました。そこで弊社の SOLID もリンクの高速化や Clang での LTO などを期待して lld 対応を進めているのですが、その時に 1 点だけ非常にわかりにくい非互換性に悩まされたのでメモしておきます。
以下は問題が再現する最少例からさらに抜粋したものです。ポイントは MEMORY 定義があるかどうかです。定義が無い場合は単一のフラットなアドレス空間全てが有効となり、ロケーションカウンタは 1 つだけなので、今回の問題は発生しません。
リンカスクリプトの MEMORY 定義は飛び飛びの分割されたアドレス空間のみを有効にする機能です。それぞれのアドレス空間はメモリリージョンと呼ばれ、それぞれ固有のロケーションカウンタを持ちます。SECTIONS の中で定義される全てのアウトプットセクションは、MEMORY 定義が存在する場合、いずれかのメモリリージョンに所属します。(> ram のように指定する場合と、暗黙的に RWX 属性がマッチする最初に見つかったメモリリージョンに割り当てられる場合があります。)
全く同じ書き方なので、.baz セクションも当然 0xc0020000 になるはず…と思いきや、GNU ld は期待通りの値になりますが、lld の場合は .baz が所属する ram メモリリージョンのロケーションカウンタ(直前の .bar セクションの最後のアドレス)を 4096 バイト ALIGN したアドレスになります。
これは lld のバグでは?と最初は思ったのですが、よくよく考えてみると、.foo や .bar、.baz のようなアウトプットセクションの開始アドレスは、MEMORY 定義が存在する場合、所属するメモリリージョンごとに異なるアドレスに成り得ます。(今回は全部 ram なのでわかりにくいですが。)
そうなるとアウトプットセクションの外側でロケーションカウンタ(dot)を変更する場合、どこのリージョンのロケーションカウンタを変更するのでしょうか???直前のセクションが所属するリージョンでしょうか?各セクションは独立しているのに???
GNU ld のマニュアルにもこの場合の挙動は明確に記述されていないと思われます。lld は最も妥当で一貫性のある実装、つまり外側で変更されたトップレベルのロケーションカウンタは無視して、所属するリージョンのロケーションカウンタを参照するという実装になったのでしょう。
さらに混乱を呼ぶのは、先頭の .foo セクションだけ期待通りに動いているように見えることです。実は先頭の . = 0xc0000000 は無視されていますが、.foo は ram メモリリージョンに所属しているので、その先頭アドレス 0xc0000000 からたまたま開始しているので正しく見える…というのが真相です。
この問題の回避方法(と言いますか、アウトプットセクションの開始アドレスを絶対アドレスで指定する正しい記述)は、アウトプットセクション定義の address に絶対アドレスを指定することとなります。
ALIGN(N) は現在のロケーションカウンタを ALIGN したアドレス値を返すので、コロンの左側に記述した時は address となり、右側に記述した場合はセクションの開始アドレスの align(section_align)を変更する構文となります。というわけで、多くのケースではコロンの左側に ALIGN(N) を書いても右側に書いても、結果的には同じ(セクションの開始アドレスを ALIGN するという)意味になります。(詳細は上記サイトを参照してください。)
2023/12/11 追記:
セクション定義の外側のロケーションカウンタ(dot)はあてにならないとすると、以下のような ROM-RAM コピーなどに使用するシンボルもあてにならなそうで大変困ることに気付いたのですが…。
リンカスクリプトの MEMORY 定義は飛び飛びの分割されたアドレス空間のみを有効にする機能です。それぞれのアドレス空間はメモリリージョンと呼ばれ、それぞれ固有のロケーションカウンタを持ちます。SECTIONS の中で定義される全てのアウトプットセクションは、MEMORY 定義が存在する場合、いずれかのメモリリージョンに所属します。(> ram のように指定する場合と、暗黙的に RWX 属性がマッチする最初に見つかったメモリリージョンに割り当てられる場合があります。)
MEMORY { ram (RWX) : ORIGIN = 0xc0000000, LENGTH = 0x1000000 } SECTIONS { . = 0xc0000000; .foo ALIGN(8) : { ... } > ram ... .bar ALIGN(8) : { ... } > ram . = 0xc0020000; .baz ALIGN(4K) : { ... } > ram ... }.foo セクションの開始アドレスは GNU ld も lld も 0xc0000000 になります。
全く同じ書き方なので、.baz セクションも当然 0xc0020000 になるはず…と思いきや、GNU ld は期待通りの値になりますが、lld の場合は .baz が所属する ram メモリリージョンのロケーションカウンタ(直前の .bar セクションの最後のアドレス)を 4096 バイト ALIGN したアドレスになります。
これは lld のバグでは?と最初は思ったのですが、よくよく考えてみると、.foo や .bar、.baz のようなアウトプットセクションの開始アドレスは、MEMORY 定義が存在する場合、所属するメモリリージョンごとに異なるアドレスに成り得ます。(今回は全部 ram なのでわかりにくいですが。)
そうなるとアウトプットセクションの外側でロケーションカウンタ(dot)を変更する場合、どこのリージョンのロケーションカウンタを変更するのでしょうか???直前のセクションが所属するリージョンでしょうか?各セクションは独立しているのに???
GNU ld のマニュアルにもこの場合の挙動は明確に記述されていないと思われます。lld は最も妥当で一貫性のある実装、つまり外側で変更されたトップレベルのロケーションカウンタは無視して、所属するリージョンのロケーションカウンタを参照するという実装になったのでしょう。
さらに混乱を呼ぶのは、先頭の .foo セクションだけ期待通りに動いているように見えることです。実は先頭の . = 0xc0000000 は無視されていますが、.foo は ram メモリリージョンに所属しているので、その先頭アドレス 0xc0000000 からたまたま開始しているので正しく見える…というのが真相です。
この問題の回避方法(と言いますか、アウトプットセクションの開始アドレスを絶対アドレスで指定する正しい記述)は、アウトプットセクション定義の address に絶対アドレスを指定することとなります。
.baz 0xc0020000 : { ... } > ram参考: https://lld.llvm.org/ELF/linker_script.html
Output section description¶ The description of an output section looks like: section [address] [(type)] : [AT(lma)] [ALIGN(section_align)] [SUBALIGN](subsection_align)] { output-section-command ... } [>region] [AT>lma_region] [:phdr ...] [=fillexp] [,]補足ですが、ALIGN(N) はコロンの左側に書かれているケースと、右側に書かれているケースの両方を見かけますが、どちらが正しいのでしょうか?あるいは使い分ける必要がある?という疑問が生まれます。
ALIGN(N) は現在のロケーションカウンタを ALIGN したアドレス値を返すので、コロンの左側に記述した時は address となり、右側に記述した場合はセクションの開始アドレスの align(section_align)を変更する構文となります。というわけで、多くのケースではコロンの左側に ALIGN(N) を書いても右側に書いても、結果的には同じ(セクションの開始アドレスを ALIGN するという)意味になります。(詳細は上記サイトを参照してください。)
2023/12/11 追記:
セクション定義の外側のロケーションカウンタ(dot)はあてにならないとすると、以下のような ROM-RAM コピーなどに使用するシンボルもあてにならなそうで大変困ることに気付いたのですが…。
RAM_ADDR = 0xAAAA; RAM_SIZE = 0xBBBB; MEMORY { ram (RWX) : ORIGIN = RAM_ADDR, LENGTH = RAM_SIZE } SECTIONS { . = RAM_ADDR; __start_ram = . ; .foo ALIGN(8) : { ... } > ram ... .bar ALIGN(8) : { ... } > ram __end_ram = . ; ... }GNU ld のマニュアルには明確に書かれていないので確証は無いのですが、SECTIONS 定義開始直後のロケーションカウンタの変更と使用、セクション定義から出た直後の dot の値(アドレス)は(セクションが所属するリージョン内の)ロケーションカウンタとして信用して良さそうです。ただし dot の値はリージョンごとに当然異なることに注意しなければならないので、あまり良くない書き方かもしれません。また、この記事の本題の繰り返しとなりますが、セクション定義の外側でロケーションカウンタを変更しても、セクション内部の(所属リージョンの)ロケーションカウンタを変更することはできません。