SNSへはこちら

リンカスクリプトの書き方

組み込み用途でプログラムの配置をする際に必要となるリンカスクリプト。本記事ではその書き方と意味を(備忘録を兼ねて)ご説明します。

複数のサイトで説明がありますが、それほど数は多くないので本記事でもまとめさせてもらいます。アクセス数欲しいし。

参考

以下のサイトを参考にしています。これを見ればたいてい分かります。

また、実際のリンカスクリプトも合わせてみたほうが理解しやすいと思いますので、お手持ちのスクリプトを見てみてください。
無いぞ!という方向けに、僕の GitHub リポジトリの一部にあるものをお見せしますので、ご参考にどうぞ。

リンカスクリプトとは

プログラムを書いたら書き込み時にもちろん ROM に書き込まれます。では書き込みの場所はどうやって決めているのでしょうか?もっと言うと、割り込みベクタ(関数テーブル)はメモリ空間上の位置(アドレス)が決まっています。そのアドレス指定はどうやるのでしょうか?C言語上で指定する?

実際C言語上でアドレス指定は無理と言っていいでしょう。そこでリンカスクリプトを使うことで、コンパイル・バイナリ生成時にアドレスや配置を決める事ができるのです。一般のPCで gcc するときもこれは利用されています。

多くのマイコンでは専用の開発環境、IDE が提供されています。が、例えば昔の 8bit or 16ibt マイコンでよくあった日本メーカーの「開発ツールは個人用途じゃ提供しないよ」状態に遭遇したら環境を自作しなければなりません。またこれが一番の理由ですがリンカスクリプトなるわけのわからないものを理解せず使うとは何事だという人もいると思います(僕だけ?)。ということでよく使われる or これだけ知っていれば作れるぞ!という程度の説明を行います。

なお、リンカはスクリプトに誤り(未定義の変数やセクション)があっても、文法の誤りが無い限りはエラーとして吐いてくれませんので注意。このエラーは基本的に無視されるだけで終わるという害悪仕様です。

また、リンカスクリプトで必須の記述は SECTIONS のみであるということも知っておいてください。それ以外は可読性や移植性のために書きます。

エントリポイント等

実際に各種コマンドを書く前に書くべきことがあります。3つほど紹介します。

/* MIPSの例 */
OUTPUT_FORMAT(elf32-littlemips)
OUTPUT_ARCH(mips)
ENTRY(start)

OUTPUT_FORMAT

出力バイナリのフォーマットです。

OUTPUT_ARCH

出力されるバイナリをどのアーキテクチャ向けにするか指定します。

これら2つは、ターゲットのアーキテクチャに対応した objdump のヘルプを見ることで確認できます。一番それっぽいのを選べばOKですw

$ mips-elf-objdump --help | grep ': supported'
mips-elf-objdump: supported targets: elf32-bigmips elf32-littlemips elf64-bigmips elf64-littlemips elf64-little elf64-big elf32-little elf32-big plugin srec symbolsrec verilog tekhex binary ihex
mips-elf-objdump: supported architectures: mips mips:3000 mips:3900 mips:4000 mips:4010 mips:4100 mips:4111 mips:4120 mips:4300 mips:4400 mips:4600 mips:4650 mips:5000 mips:5400 mips:5500 mips:5900 mips:6000 mips:7000 mips:8000 mips:9000 mips:10000 mips:12000 mips:14000 mips:16000 mips:16 mips:mips5 mips:isa32 mips:isa32r2 mips:isa32r3 mips:isa32r5 mips:isa32r6 mips:isa64 mips:isa64r2 mips:isa64r3 mips:isa64r5 mips:isa64r6 mips:sb1 mips:loongson_2e mips:loongson_2f mips:loongson_3a mips:octeon mips:octeon+ mips:octeon2 mips:octeon3 mips:xlr mips:interaptiv-mr2 mips:micromips plugin

ENTRY

リセット時に一番最初に実行される関数(エントリーポイント)を引数に指定します。マイコンだと当然決まったアドレスに配置するわけですが、その配置先アドレスは ENTRY には関係ありません。配置先は下の SECTIONS コマンドで指定します。
この ENTRY は、バイナリをリンク時に、使用していない関数をリンクしないというオペレーションで必要になります。マイコンのプログラムを書く時にはなるべくプログラムサイズを減らしたいということがよくあります。その際に利用していない関数をプログラムに組み込んでも意味がないので、コンパイラやリンカのオプションで消去してしまうということをします。エントリーポイントが指定されていないとリンカは「あ、この _start とかいうやつ使ってないんだなぁ」と消去しにかかります。そうなってしまうと正常な動作は見込めないので予め指定しておきます。

書かない場合はリンカに -e で渡すという方法も取ることができます(この場合は -e_start)。

C言語で必要となるセクション

初めてのC言語 - 第9回 リンカスクリプトを参考にすると、以下が必要になります。

セクション名 役割
.text プログラムの命令が入る
.rodata const な定数値が入る
.data 初期値ありグローバル変数が入る
.bss 初期値なしグローバル変数(= 初期値がゼロ)が入る

また、constructor 属性や destructor 属性を使う場合に備えて、*(.init)*(.fini) の配置もしておきましょう(必須ではない、というか通常は要らない)。

基本コマンド

主に MEMORYSECTIONS に分かれます。MEMORYは必須ではないですが、これがないと不便極まりないので、今回は必須ということで説明します。

MEMORY

物理的なメモリ領域を定義するコマンドです。例えば RAM だとか、コンフィグ用の ROM だとか。後に SECTIONS コマンドで「この関数は .text セクションにある。それ全体は ROM に置く」と書くことがあるので、そのために定義しておきます。

/* ARM Cortex-M のやつ */
MEMORY { /* memory command defining memory area */
    RAM (xw): ORIGIN = 0x20000000, LENGTH = 12K /* executable & readable & writable */
    ROM (xr): ORIGIN = 0x08000000, LENGTH = 64K /* flash rom area */
}

RAM のところを見ていきましょう。この RAM は領域名で、自由に決められます。その後の (xw)は属性を指定していて、実行・リード・ライト可能という意味です。もっとも、属性は省略可能ですが。

ORIGIN はその領域の開始物理アドレス、LENGTH はその名の通りサイズを指定します。

ここで指定した値は、これ以降のスクリプト内で参照可能です。例えば ORIGIN(RAM) とすると 0x20000000 を得ることができ、LENGTH(RAM) とすると 12000 を得ることができます。

SECTIONS

セクションを定義、配置します。上の MEMORY コマンドを受けてセクションをどこに置くのかという記述を行うものです。ここでは物理アドレスも仮想アドレスもどちらも記述可能です。まずは記述例です。以下は ARM Cortex-M のものです。

SECTIONS {
    .isr_vector : {
        . = ALIGN(4);
        KEEP(*(.isr_vector)) /* vector array. '*' outside of () means 'any object file'. */
        /* KEEP() does not allow unused symbols into garbage collections. */
        . = ALIGN(4);
    } > ROM /* the function pointer is written on ROM. */

    .text : { /* program area */
        . = ALIGN(4);
        *(.text)
        *(.text*)
        . = ALIGN(4);
    } > ROM
...
}

.isr_vectorではそのような名前のセクション変数を作っています。まず最初にアラインメントを揃えるためにロケーションカウンタ(.)に ALIGN(4)を代入しています(これはプロセッサの仕様による)。ロケーションカウンタとはメモリでのアドレスを表すもので、何かしらオブジェクトを配置したら自動でインクリメントされるものです。例えば 0x0000 にサイズ 4bytes のオブジェクトを配置したらロケーションカウンタは4だけ加算されて . == 0x0004 となっています。
では、. == 0x0000 の条件で「4byteのアラインメントを整えたい」として . = ALIGN(4); としたらロケーションカウンタの値はどうなるでしょうか??答えは 0x0000 です。ALIGN(n) はアラインメントがすでに整っている場合、特に値を先に進めないでおきます。

最後の > ROM は、「このセクションを ROM に配置する」という MEMORY コマンドの領域名を記したものです。

続いて中にある記述についてです。*(.isr_vector) がありますが、例えば init.o(.isr_vector)とすると「init.o 内にある、ソースコード上で.isr_vector セクションと指定されたオブジェクトを配置する」という意味です。ちょっとわかりにくいので次の例を見てください。

SECTIONS {
    .text: {
        . = ALIGN(4);
        *(.text)
        *(.text.*)
        init.o(.hello)
    } > ROM
}

init.o(.hello) が追加されていますが、これは「init.o というオブジェクトファイルに記載された、.hello というセクション指定に該当するオブジェクトを配置してね」という意味になります。よって以下のC言語ソースコードが対象になります。

// init.c
__attribute__((section(".hello")))
void func(void) {
    ;
}

これをコンパイル・リンクすると関数 func.text セクションに配置されます。

一方で * が使われていると思うのですがこれはワイルドカードで、() の外部に用いられる場合は「任意のファイル名」であること、() の内部の場合はセクション名に関して任意のマッチを表します。

上では .text だけでなく .text.* がありますが、これは実際にコンパイラの -ffunfction-sections-fdata-sections に対応するために付けておいたほうが良いでしょう。また、KEEP() はリンカによるリンク省略を避けるための対策です。

続いて以下の例も見てみます。

SECTIONS {
...
    _sidata = LOADADDR(.data); /* LOADADDR() returns the address on ROM(AT>ROM), whereas ADDR() RAM(>RAM). */
    .data : { /* variables with its initial value */
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM AT > ROM 
...
}

上では_sidata にロケーションカウンタの値を代入しています。これは後でC言語内で使いたいから記述しているのであって、変数宣言をしていることになります。これには型はありません。定義したシンボルはアドレスの別名を指すので、使用する際はポインタとして用います。

// C言語にて
extern int _sdata; // 型は無い。取り敢えず外部シンボル読み込み

...

// 使うときはポインタにする(上のように外部読み込みでは非ポインタ型を使うのが普通)
char *p = &_sdata;

int value = *(p + 4);

ここでハマりがちなのは、ロケーションカウンタは現在のセクションブロック(この場合は .data セクション)の先頭から数えたオフセットを表しているということです。一方でロケーションカウンタを代入した変数は絶対物理アドレスを指しているのです。
例えば ROM (開始アドレスが 0x80000000) の先頭からこの .data セクションを配置することを考えます。1k bytes のオブジェクトを配置した時のロケーションカウンタはどうなるでしょうか?これはそのまま 1k bytes インクリメントされていて、. = 0x400 です(0x400 == 1024)。そこから以下の構成を考えます。

ENTRY(start)
MEMORY {
    ROM : ORIGIN = 0x80000000, LENGTH = 64k
}
SECTIONS {
    .data : {
        *(.data) /* 1k (0x400) */
        a = .;
        . = 0x1000;
        b = .;
    } > ROM AT > ROM /* ROMは0x80000000をORIGINに持つ */
    c = .;
    .init : {
        *(.init) /* 8bytes (0x08) */
        d = .;
    } > ROM
}

では、変数 a のアドレスはどこでしょう?0x400 でしょうか??
答えは 0x80000400 です。ロケーションカウンタから代入された変数の値は、ロケーションカウンタ値 + 該当セクション変数の先頭アドレスになります。
続いてロケーションカウンタの値を無理やり 0x1000 にしていますが、この直後の b も同様で 0x80001000 となります。
.data セクションが終わってもその先頭アドレスは受け継ぎますので c0x80001000 となります。

LOADADDR に関しては後に述べます。

ここで着目点は AT >です。これはただの > とは異なり、「一旦 ROM に配置する」という意味です。直接 RAM にデータを配置できたら良いのですが、一般に RAM は揮発性なので、一旦 ROM に書いてから、マイコン起動時に RAM にコピーをするという作業をします。このコピーの作業はスタートアップルーチンで行うとC言語では規定されています。

ということで、AT > を使うと「バイナリとしては配置先はとりあえず AT > の部分に、プログラム中の分岐等、アドレスを用いる値は > に」という事になっているわけです。

仮想アドレス と AT

上では物理アドレスをそのまま使っていましたが、仮想アドレスも用いることができます。例えば MIPS アーキテクチャを用いている PIC32 ではよく使うことになります。まずは例です。

_offset_kseg0 = 0x80000000;
SECTIONS {
    .text
    _offset_kseg0 + ORIGIN(ROM) : {
        . = ALIGN(4);
        *(.text)
        *(.text.*)
        *(.rodata)
        *(.rodata.*)
    } AT > ROM
...
}

これまでと違うのは .text の後に謎の数値があることです。そこから : があり中括弧が始まります。ここの部分が仮想アドレスで、外部から見たアドレスとは違って、CPU から見たアドレスになります。

_offset_kseg0 は変数で、こうやって SECTIONS コマンドの外部でも定義できます。実際 PIC32 の仮想アドレスは、物理アドレスから 0x80000000 だけ加算されたものになります。こうするとどうなるかと言うと、以下に列挙します。

  • ロケーションカウンタは物理アドレスのままになる。
  • が、オブジェクト中のアドレスは仮想アドレスになる。
  • 一時格納先を AT > で指定する。

LOADADDR, ADDR, ORIGIN, LENGTH

これらは "関数" ですので、以下にそれぞれ列挙して説明します。

  • LOADADDR() は括弧で囲まれたセクションの一時配置先(AT >)アドレスを値で返す
  • ADDR() は括弧で囲まれたセクションの配置先( >)アドレスを値で返す
  • ORIGIN() は括弧で囲まれた MEMORY 内の領域の ORIGIN アドレスを値で返す
  • LENGTH() は括弧で囲まれた MEMORY 内の領域の LENGTH を値で返す

PROVIDE

変数を weak で定義します。

PROVIDE(end = .);

/DISCARD/

ここに記述された オブジェクトセクションはリンクされません。

SECTIONS {
    /DISCARD/ : {
        *(.tmp)
        *(.tmp.*)
    }
}

だいたいこれで所望のリンカスクリプトは書けるのではないでしょうか。ということで書き方説明の記事でした。

コメント