組み込みソフト開発101 ~ ハードウェア

プログラマーなのにハードウェア?

プログラマーといえども、ある程度はハードウェアの知識を持っておくと便利です。特にデバッグ時の調査アプローチの幅が広がると思います。

そう言う私も、専門家ではないので簡素化した話ししかできませんが、逆に言えば、ここに書いてある程度のことを知っていれば、ソフトウェア開発には十分ということでしょう。

コンピュータの構成要素

コンピュータの基本的な構成要素を挙げます。

  • CPU(Central Processing Unit)
  • ROM(Read Only Memory)
  • RAM(Random Access Memory)
  • アドレスバス
  • データバス

CPUが頭脳、ROMとRAMは記憶領域です。ROMには変化しないデータ(プログラム本体など)を、RAMには変化するデータ(プログラムの変数など)を記憶させます。CPUとROM/RAMは、アドレスバスとデータバスでつながります。

アドレスが9bit、データが8bitのシステムの場合を図示してみます。

コンピュータの構成要素

アドレス端子(A8~A0)をつないでいる9本の線がアドレスバス、データ端子(D7~D0)をつないでいる8本の線がデータバスです。

ROMとRAMは、CS(Chip Select)端子を持ち、ここに0が入力されたときのみ動作するようになっています。つまりCPUは、CS端子を使ってROM/RAMを使い分けるわけです。上図の、△に○が付いた記号はNOT回路と呼ばれるもので、入力信号を反転して出力します。よって上図の場合、アドレスのA8(最上位ビット)が0の場合はROMのCSが0になり、A8が1の場合はRAMのCSが0になるように配線されているわけです。

ROMにもRAMにも8bit分のアドレス端子があるので、表現できるアドレスは0000_0000~1111_1111の256個です。それぞれのアドレスに1バイト(8bit)分のデータを記憶できるので、ROM/RAMそれぞれのデータ容量は256バイトということになります。

一方、CPUから見ると、アドレス端子は9bitあるので、表現できるアドレスは0_0000_0000~1_1111_1111の512バイトです。前述の通り、アドレスの最上位ビットはROMとRAMを使い分けるために使われます。余談ですが、アドレスにしろデータにしろ、複数ビットで表現される値の最上位ビットのことをMSB(Most Significant Bit)、最下位ビットのことをLSB(Least Significant Bit)と呼びます。

アドレス空間を図示すると次のようになります。

アドレス空間

ROM/RAMへのアクセス

例えば、CPUがアドレスバスに0_0011_0101という信号を出したとしましょう。MSBが0なのでROMが選択され、ROMのアドレス端子に0011_0101が入力されます。すると、ROMが0011_0101番地のデータをデータバス(D8~D0)へ出力し、その信号がCPUのデータ端子に入力されます。このようにして、CPUはROMのデータを読み取ることができます。

RAMの場合も似たような感じですが、読むだけのROMと違い、RAMには書くこともできる点が異なります。そのため、読むのか書くのかを指定するための端子と配線が必要です。詳しいことは知らないのですが、WR(Write)端子とRD(Read)端子の2つを持つ場合や、WE(Write Enable)端子1つで切り替えたりする場合があるようです。その辺は、先に図示した回路図では省略しています。

例えばCPUがROMのデータを読むとき、アドレスバスにアドレスを出力した瞬間にデータバスへROMからのデータが出力されるわけではありません。デジタル回路と言えども、信号が瞬時に変化するわけではないし、ROMが反応するのにも時間がかかります。

ROMのアドレス0011_0101のデータが0011_0011だった場合の、A0とD0のみに着目して信号電圧の変化の様子を図示してみます。

信号タイミング

このように、1つの端子が0から1に変化するには時間(t1)がかかるし、アドレスが決まってからデータバスの信号が安定するのにも時間(t2)がかかります。よってCPUは、ある程度の遅延を見込んで歩調を合わせながら動作する必要があります。この時間調整の基準になるのがクロックと呼ばれる信号です。クロックは周期的に0と1を繰り返す信号で、水晶振動子などを使って生成します。上図の信号にクロックも入れてみましょう。

信号タイミング(クロック付き)

この場合CPUは、少なくともクロック3つ分待ってからでないと正確なデータが読めないことになります。このような「待ち」をメモリ・ウェイトと呼びます。最適なウェイト数はCPUやROM/RAMの性能、およびクロックの周波数などによって異なります。

プログラム実行の原理

ROMには、プログラム(つまり、CPUが実行すべき命令の羅列)が保存されています。ROMから命令を一つずつ読みながら実行するのがCPUの仕事です。命令(インストラクションとも呼ぶ)と言っても単なるデータ値に過ぎませんので、ROMから読むときの原理は前述の通りです。CPUがROMから命令を読み出すことをフェッチする(fetch)と言います。

命令の値によって、CPUの動作が決まります。命令の値と動作との対応は、命令コードセットと呼ばれる表で規定されます。命令コードセットはCPUのメーカーや型番によって異なります。

ここでは架空のCPUを想定してみましょう。このCPUはP0端子を持ち、命令コードセットのコード0010_0001には「P0端子に1を出力する」、コード0010_0000には「P0端子に0を出力する」と規定されています。そこで、ROMの0000_0000番地から順に、0010_0001と0010_0000を交互に保存しておき、CPUに実行させましょう。

単純なプログラム

まずCPUはROMの0000_0000番地をフェッチし、命令0010_0001を実行(つまりP0端子に1を出力)します。続いて次のアドレス0000_0001番地をフェッチし、命令0010_0000を実行(P0端子に0を出力)します。さらに0000_0002番地の命令0010_0001(P0に1)、0000_0003番地の命令0010_0000(P0に0)、と続いていきます。

これだけでは何の役にたつのか分かりませんが、例えばP0端子にLEDを接続しておけば、LEDを点滅させるプログラムになるし、スピーカをつなげれば音を鳴らすプログラムができるかもしれません。

レジスタ

データを記憶する場所はROM/RAMだけではありません。CPU内部にも、容量は少ないですがレジスタと呼ばれる記憶領域があります。CPUは複数のレジスタを持っており、1つのレジスタには8bit~32bit程度のデータを保存することができます。

レジスタの名称はCPUによって異なりますが、一般に、汎用のレジスタにはA、B、C(あるいはAX、BX、CX)、特定用途向けのレジスタにはPC(Program Counter)、SP(Stack Pointer)といった名前が付くようです。汎用レジスタには、計算の途中結果や、ROM/RAMから読み出したデータを一時的に保存します。PCには、いま実行中の命令のフェッチ先アドレスを保存します。SPについては後述します。

先の例では、ROMの0000_0000番地から順にプログラムを実行する様子を示しましたが、この実行の流れを制御するのがPCレジスタです。この例では1つの命令が1バイトだったので、CPUは1つの命令を実行するたびにPCを1ずつ増やしていたわけです。しかし、中には2バイト以上の命令や、オペランドと呼ばれる付加データを持つ命令もあります。さらに、離れたアドレスへジャンプする命令もあります。次の命令のためにPCをどれだけ進ませるか(あるいはどれだけ戻すか)は、命令によって異なるという点に注意して下さい。

RAMのデータを足し算するプログラムの例を見てみましょう。C言語で言うなら、a += b; のようなプログラムです。

足し算プログラム

①まずPCが0_0000_0000にセットされて最初の命令を実行します。命令0001_0000は、メモリからデータを読んで汎用レジスタへ格納する命令です。命令に続く3バイトのオペランドが、メモリのアドレスとレジスタの番号を示します。このケースでは、アドレス0000_0001_0000_0000の内容をレジスタ0000_0000(0番はAレジスタに対応)に格納します。

②次にPCが4進み0_0000_0100にセットされます(最初の命令が1バイトでオペランドが3バイトだったので)。またしても命令0001_0000ですが、最初とはオペランドが異なります。ここでは、アドレス0000_0001_0000_0001の内容をレジスタ0000_0001(1番はBレジスタ)に格納します。

足し算中のメモリ1

③PCが0_0000_1000に変わり、命令0001_1000をフェッチします。これは、オペランドで指定されたレジスタの値をAレジスタに加算する命令です。オペランドは0000_0001なので、Bレジスタの値がAレジスタへ加算されます。

④PCが0_0000_1010に変わり(命令1バイト+オペランド1バイト)、命令0001_0001をフェッチします。これは命令0001_0000の逆で、メモリへレジスタの値を書き込む命令です。ここでは、アドレス0000_0001_0000_0000へレジスタ0000_0000(Aレジスタ)の値を書き込みます。

足し算中のメモリ2

これで、足し算の結果がRAMに書き込まれました。

メモリマップ

ここまでは、ROM/RAMそれぞれ256バイトという狭いアドレス空間を例に説明してきましたが、実際のシステムではアドレスを32bitで表現するのが(本記事の執筆時点では)一般的です。広大なアドレス空間にROMとRAMを配置し、さらに用途別にエリアを区切って使用します。この区切られたエリアをセグメント(あるいはセクション)と呼びます。

セグメントには以下のようなものがあります。

TEXTセグメント(ROM)
プログラム本体を置く。通常はアドレス空間の先頭(0番地)に配置。
データセグメント(RAM)
静的変数のうち、初期値を持つものを置く。
BSSセグメント(RAM)
静的変数のうち、初期値を持たないもの(0で初期化されるもの)を置く。Block Started by Symbolの略。
ヒープセグメント(RAM)
動的メモリ管理領域を置く。C言語のmalloc()などで使われる。
スタックセグメント(RAM)
関数呼び出しにおける引数やローカル変数、戻り値などを格納するために使う。通常はアドレス空間の最後尾に配置。

メモリマップ

割り込みベクタテーブル

通常、CPUはPCレジスタが指す番地の命令を逐次実行しますが、CPUの外部で起きる事象をキッカケにして、逐次実行を一時中断し、別の処理を行う場合があります。このとき、キッカケとなる事象を「割り込み」と呼び、逐次実行を中断して行われる別の処理を「割り込み処理」と呼びます。割り込み処理が終わると、中断していた逐次実行が再開されます。割り込み処理も、ROM上のプログラムの一部です。

割り込みには、ボタンやキー押下、外部からのデータ受信、タイマーからのタイムアウト通知などが考えられます。CPUは複数のINT(Interrupt)端子を持っており、ここから外部からの割り込みを受け取ります。INT端子に割り込み信号が届くと、CPUはPCや他のレジスタの内容を別の場所へ退避して、割り込み処理が書かれたROM上のアドレスをPCへセットします。割り込み処理のアドレスは割り込み番号ごとにことなるので、プログラムは、割り込み番号とアドレスの対応を「割り込みベクタテーブル」に管理しています。

通常、プログラムの割り込みベクタテーブルは、アドレス空間の先頭(0番地)に配置されます。テーブルの先頭は割り込み番号0(INT0)の割り込み処理へのアドレスです。INT0はリセット割り込みとして使われ、プログラムの逐次実行部分の先頭アドレスへジャンプするようになっています。

割り込みベクタテーブル

スタック、SPレジスタ、関数呼び出し

最後に、スタックとSPレジスタ、及び関数呼び出しの仕組みを説明します。

関数を呼び出して実行するとき、通常の逐次実行のときとは異なり、以下の情報を管理する必要があります。

  • 関数の戻り先アドレス(関数が終了したあとでPCを何番地に戻すか)
  • 関数の引数
  • 関数のローカル変数
  • 関数の戻り値

こういった情報一式を、関数が呼び出されてから終了するまで保管しておく必要があります。関数が終了すれば必要なくなるので、関数ごとに固定領域を静的に確保してしまうのは非効率です。また、1つの関数が多重に(再帰的に)呼ばれる可能性もあるので、関数ごとに1セット分の記憶領域を用意するだけでは足りません。

そこで、スタックと呼ばれるデータ管理手法(データ構造)を使って管理します。スタックは、後入れ先出し(LIFO, Last In First Out)とも呼ばれ、データ集合に最後に入れたデータが最初に出てくる(最初に入れたデータが最後に出てくる)ように管理する手法です。

スタックには、pushとpopの2つの操作があります。言葉で説明するより図示した方が早いでしょう。

スタック

pushを関数コールに、popを関数のreturnに対応付けると、複数の関数が次々と呼び出されては終了する様子が、スタックの動きとマッチするのが分かるでしょう。

スタックと関数コール

スタックは、RAMのスタックセグメントに配置します。通常、スタックセグメントはアドレス空間の最後尾に配置され、アドレス空間の最後尾がスタックの底になります。スタックの次のpush先アドレスを保持するのがSPレジスタで、スタックが空っぽのときはSPはスタックの底を指します。データをpushするとSPが減り、データをpopするとSPが増えます。

以下のC言語のプログラムを例にして、スタックの動きを見てみましょう。

void main()
{
    int a = 100;
    a = add_random(a);
    if ( a > 100 ) {
       ...
}

int add_random(int base)
{
    int r = get_random();
    return base + r;
}

main()がadd_random()を呼ぶところを考えてみます。必要なスタックサイズは以下の通りです。

  • 関数の戻り先アドレス用に4バイト
  • 関数の引数base用に4バイト
  • 関数のローカル変数r用に4バイト
  • 関数の戻り値用に4バイト

この時点で、SPの値が0xffff000fだったと仮定します。

関数コール前のスタック

add_random()呼び出しの流れは以下のようになります。

  1. 関数の戻り先アドレスをスタックへpush(SPが4減る)
  2. 関数の引数として100をpush(SPが4減る)
  3. 関数のロカール変数rと戻り値用の8バイト分だけ、SPを減らす
  4. 関数の先頭番地へジャンプ(PCを関数の先頭番地へ変更)

関数コール後のスタック

add_random()の実行中は、SP+1が示す番地を戻り値用に、SP+5が示す番地をローカル変数r用に、SP+9が示す番地を引数base用に使います。

add_random()が終了するときの流れは以下のようになります。

  1. スタックから4バイトをpopして(SPが4増える)、AXレジスタへ格納(これが戻り値)
  2. ローカル変数rと引数base用の8バイト分だけSPを増やす
  3. 4バイトをpopして(SPが4増える)、PCへ格納(これが戻り先アドレス)

これでSPの値は、add_random()を呼ぶ前と同じ値に戻ります。この後CPUは、add_random()呼び出し命令の次の命令(つまり関数の戻り値AXを変数aのアドレスへ格納する)へPCを変更し、逐次実行を続けます。

この流れは、あくまでも例に過ぎません。関数の呼び出し側がデータをpushする順番はこの通りとは限りませんし、呼ばれた関数側が戻り値をAXレジスタへ(スタック経由でなく)直接格納しても良いでしょう。重要なのは、呼び出し側と呼ばれる側とが、同じルールで動くことです。このルールのことを呼び出し規約と呼びます。具体的には、cdecl、pascal、stdcallといった呼び出し規約があり、OSやプログラミング言語によって使われる呼び出し規約が異なります(詳細については割愛)。