メモリの何たるか

今回はメモリについて解説します。

メモリアドレスとポインタ

プログラム上でデータを保持する際、そのデータはメモリと呼ばれる記憶領域に格納される、ということはご存知ですよね。それらデータを格納したメモリブロックを指定するために、そのデータを記憶させた領域の場所を表わす数値(番地)が使われます。これを『メモリアドレス (memory address)』といいます。(単に『アドレス』と呼ばれることが多い。)

HSPでは『変数』というものが使われていますよね。もちろん、HSPに限らず、さまざまなプログラミング言語では変数が使われます。「変数とは数値(あるいは文字列)を自由に格納しておくことのできる容器のようなもの」というのが一般的な説明であると思います。しかし実際には、「ある特定のアドレスで示されるメモリ領域につけられた名前」であるというほうがより近いのです。

プログラムをマシン語のレベルで考えたとき、データを保持するためにはアドレスを直接指定しなければなりません。たとえば、

メモリの1000番地に数値  33を入れる
メモリの3000番地に数値1242を入れる

というように処理をしていかなければならないのです。しかし、プログラミングをする上で数々の種類のデータを保持しなければならないとき、それらのデータを入れておいた番地を覚えておいて、コードを書くたびにその番地を指定する、などということをしていると、非常に複雑な作業であるし、間違った番地を指定してしまうなどのバグが頻発してしまうことでしょう。そこで、そのデータが格納されている番地にわかりやすい名前をつけて、その名前を使ってその番地に格納されているデータを操作する、という方法がとられるようになりました。プログラマーは番地を覚えておく必要がなくなったのです。その番地につけられた名前というのが変数というわけです。


さて、変数という便利なものがあるのだから、メモリアドレスを意識する必要はなくなったのかな〜、などと思ったらそれは間違いです。プログラミングをする上ではメモリアドレスというものは非常に重要かつ必要なものなのです。

そこでC言語などのプログラミング言語では「アドレスを格納するための変数」として、『ポインタ』というものが採用されています。こういった言語の中では、原則として、ポインタには通常の数値を代入することはできず、メモリアドレスのみを代入できるのです。(厳密に言えばアドレスも「番地」である以上は数値なんですけどね。)

残念ながら、現在のHSPでは「ポインタ」の概念は採用されていません。現在の最新版(ver2.6)ではLOADLIB命令群の中にメモリアドレス関連の命令が実装されていて、変数のアドレス取得や指定アドレス領域へのデータアクセスができるようになってはいますが、ポインタを使うことのできるC言語などと比べると、その操作は少々面倒なものになってしまっています。

bit(ビット)とbyte(バイト)、数値と文字列

bit と byte

「コンピュータはデータを 0 と 1 のみで処理する」とか、「コンピュータは2進数の世界」というのを聞いたことがあるでしょう。なぜ2進数なのか? それは、コンピュータのデータのやりとりを細かく見ていくと、すべてはON(電気が流れる状態)とOFF(電気が流れない状態)の2つの状態によって成り立っているということにあります。そこで、このONとOFFを数値の1と0で表し、さらにこの最小データ(0か1か)の単位を『ビット(bit)』と定めました。たとえば、1ビットの整数値のデータといったら0か1になり、2ビットの整数値のデータといったら0〜3のいずれかになります(符号なし整数の場合)。一般には n ビットのデータは、符号なし整数値の場合に 0 から (2n-1) までの数値を格納できます。(符号ありの場合は -2n から (2n-1-1) まで。)

さらに、8ビットの数値を1つのまとまりとしてこのデータのサイズの単位を『バイト(byte)』としました。コンピュータはこれをデータの基本単位としています。1バイトのデータは、符号なし整数値の場合 0 〜 255 の数値を表現できます。(符号あり整数では -128 〜 127 です。)


さて、HSPで使っている変数に代入されたデータも実際はあるアドレスで示されているメモリに格納されているわけです。メモリにどのような形で格納されるのかを見てみることにしましょう。

メモリ上でデータを考える場合は16進数を使うほうが簡単になります。というのも、16進数では2桁でちょうど1バイトの数値を表現できるからです。以下では16進数を使用しています。(0xは16進数であることを示します。)

数値

HSPで使われる数値の形式は「符号付き32ビット整数値」と呼ばれます。書いた字のごとく符号付きの(正負を含めた)データサイズが32ビット(4バイト)の整数ということです。実際の数値の範囲は -2147483648 から 2147483647 までとなっています。

メモリ領域をバイト単位で区切って考えたとき、数値は下位バイトほどメモリアドレスの小さいほうに格納されることになっています。たとえば、変数 a に代入された数値 0x12345678 (10進数では 305419896)は以下のように格納されています。

↓変数 a のアドレスの示す領域
0x78 0x56 0x34 0x12

配列変数に数値を代入した場合、それらはメモリ領域に4バイトずつ格納されていきます。実際には以下のようになります。

a.0 = 0x11223344
a.1 = 0x55667788
a.2 = 0x99AABBCC
↓変数 a のアドレスの示す領域
0x44 0x33 0x22 0x11 0x88 0x77 0x66 0x55 0xCC 0xBB 0xAA 0x99
a.0 のデータ領域→ a.1 のデータ領域→ a.2 のデータ領域→  

何となくわかるでしょうか?

このように、より下位のバイトから格納されていく方式をリトルエンディアンと呼びます。これは Intel 系 CPU の特徴の1つです。別の種類の CPU (例えばMac系のマシンに搭載されるもの)には、上位バイトから格納されるもの(ビッグエンディアン)もあります。

文字列

HSPで使われる数値は4バイト単位で扱われていましたね。文字列の場合は数値とはやや扱いが異なります。文字列はメモリ上では「1バイト単位の数値の配列」として格納されています。

ASCII(アスキー)コードというのを聞いたことがあるでしょうか。これはASCII文字を表す1バイト整数値のことです。(ASCII文字とは半角英数字や記号などのことです。漢字などはこれには含まれません。) 文字列がASCII文字のみからなる場合はメモリ上ではそのASCIIコードの配列として格納されています。

HSPではASCII文字を’’(シングルクォーテーション)で囲むことでその文字のASCIIコードに変換することができます。たとえば

code = 'A'

とした場合、変数 code には文字‘A’のASCIIコードである 0x41 (10進数で 65)が代入されます。シングルクォーテーションで囲まれた文字は、あくまで数値として扱われます。

一方、HSPでは、ダブルクォーテーションで囲まれた文字並びを文字列として扱います。例えば、

b = "abcdEFGH"

とした場合は、文字列はメモリ上では次のように格納されています。

↓変数 b のアドレスの示す領域
'a' 'b' 'c' 'd' 'E' 'F' 'G' 'H' 0x00

ここでは 'a' は文字「a」のASCIIコードである 0x62 を示しています。他の文字についても同様です。

文字コードの配列の最後には必ず 0x00 が入ります。これは文字列がここで終わっていることを示しています。これを終端文字あるいはヌル文字 (null character) と呼ぶことがあります。

ここでは詳しくは述べませんが、漢字などの文字は数が多いために1バイトのみで表すことが不可能であるため、こういった文字は2バイト分のデータ領域を用いて表されます。日本語ではShift-JISコードなどがそうです。

仮想メモリ機能

最近のOSは大概、仮想メモリ機能を備えています。これは、仮想記憶装置(ハードディスクなど)の空き領域を、あたかも物理メモリであるかのように見せかける機能のことです。この機能により、実際にマシンに搭載されているRAMよりも多くのメモリを使用することができるようになっています。

一般に、マシンに搭載されているRAMに割り当てられるメモリに対して、ディスクスペース上に割り当てられるメモリを仮想メモリといいます。仮想メモリは、実際にはディスク上のファイルとして存在するわけですが、このファイルはページングファイルと呼ばれています。すべての仮想メモリの内容がこのページングファイルに格納されています。(Windows 9x 系では一般に「スワップファイル」と呼ばれていますが、ここでは「ページングファイル」に統一しています。)

アプリケーション側の視点から見れば、仮想メモリの機能によって、アプリケーションが利用できるメモリ(物理ストレージなどと呼ばれる)の容量が増加しているように見えるわけです。例えば、マシンに64MバイトのRAMが搭載されていて、ハードディスクに100Mバイトのページングファイルがあるなら、アプリケーションからは、あたかも164Mバイトのメモリが存在するかのように見えるのです。

プログラムが実際にメモリに読み書きするためには、結局は、その目的となる領域が物理RAM上に存在しなくてはなりません。したがって、メモリアクセス時に、目的の領域がRAM上に存在しない場合には、いったん、ページングファイル上に存在するその内容をRAM上に読み込まなくてはならないわけです。

アクセスしようとしているデータが物理RAM上になく、ページングファイルのどこかに存在する場合には、ページフォールトと呼ばれる例外が発生し、その通知を受けたOSは、物理RAM上からフリーページ(RAM上の使われていない部分)を探して、見つかったフリーページを割り当てます。フリーページが見つからない場合には、RAM上のどこかの部分をいったんページングファイル上に移し、その後で、その部分にアクセスするページを読み込む、といった動作をします。

RAMへのアクセスとハードディスクへのアクセスとを比べると、ハードディスクアクセスの方がはるかに低速であるため、このようなRAMとページングファイルとの間でのコピーが頻繁になると、それらの処理に多くのCPU時間を消費してしまい、システムが低速になってしまいます。このような状態をスラッシングと呼びます。一般的に、マシンのRAM搭載量を増やすことで、スラッシングを防ぐことができるので、システムの処理性能を向上させることができます。

Windowsの仮想アドレス空間

プロセスの仮想アドレス空間

Windowsは新しいプロセス(実行されるプログラムのこと。正確にはメモリにロードされたプログラム実体(インスタンス)のこと。詳細は後述)が起動するときにそのプロセス用に4G(ギガ)バイトの仮想アドレス空間論理アドレス空間とも言う)を用意し、そのアドレス空間にプロセスを生成します。簡単に言うと、すべてのプロセスは4Gバイトのメモリを割り当てる準備がされている、というわけです。

「あれ、僕のパソコンにはメモリは4Gバイトもないよ?」と、不思議に思う人もいるでしょう。大体私も、4Gバイトものメモリを積んだパソコンなんて、今のところは見たことがありません。(まあ、すぐに出てくるでしょうけど……。) もちろん、アプリケーションが起動されたからって、4GバイトものメモリがRAMから確保されるわけじゃありませんし、先に述べた仮想メモリ機能を使って、4Gバイトものハードディスクスペースを消費してしまうということもありません。あくまで、「仮想的な」アドレス空間が用意されるのです。

仮想アドレス空間というのは、実際に存在する物理ストレージの大きさには関係なく、その名が示す通り、「仮想的な」アドレス空間であり、それは単にメモリアドレスの範囲に過ぎない、ということです。32ビット変数は(16進数で) 0x00000000 〜 0xFFFFFFFF までの任意の数値を格納することができますが、同様に32ビットポインタもこの範囲での任意の値(メモリアドレス)を格納することができます。したがって、32ビットポインタを使用する限り(32ビットプラットフォームでは必ずそうなるわけですが)、アドレス 0x00000000 〜 0xFFFFFFFF までの4Gバイト範囲でメモリアドレスを指定することができる、すなわち、その範囲の仮想的な(あるいは論理的な)アドレス空間が存在している、ということができるわけです。

実際にデータにアクセスするためには、仮想アドレス空間と実際のメモリ(物理ストレージ)との間の対応付けをする必要があります。この操作を、アドレス空間に物理ストレージを割り当てる(マッピングする)と言います。特に、Windowsでは、この対応付けがそれぞれのプロセスごとに独立して行なわれます。「それぞれのプロセスが独自に4Gバイトの仮想アドレス空間を持つ」のはこのためです。

アプリケーション側からは、自身のプロセスの仮想アドレス空間しか見えません。通常の方法では、どのようなメモリアドレスを指定したとしても、(仮想アドレス空間でなく)実際の物理ストレージ上の任意の位置のデータを読み込んだり、自分以外のほかのプロセスのアドレス空間にあるデータを読み書きすることはできないのです。このことから、一般に、単に「アドレス(メモリアドレス)」といった場合には、そのプロセスの仮想アドレス空間内におけるアドレスを指します。

アドレス空間内の領域の状態

アドレス空間内のデータにアクセスするためには、仮想アドレス空間と物理ストレージとの間の対応付け(マッピング)が必要だといいましたが、実際の動作はもう少し複雑です。Windowsでは、プロセスの仮想アドレス空間のそれぞれの領域に次の3つの状態を与えてそれらを管理しています。

アプリケーションプログラムの起動によってプロセスが作成されると、そのプロセスにアドレス空間が与えらるのですが、その広大な空間のほとんどは確保されていない状態、すなわちフリー状態にあります。アドレス空間のどこかの領域を使用するためには、その領域を確保しなければならないのです。この、仮想アドレス空間から領域を確保することを予約と呼びます。

しかし、アドレス空間内の領域を予約(確保)しただけでは、まだ、その領域にアクセスすることができません。実際にアクセスするためには、先ほども述べた通り、予約された領域と同じだけの物理ストレージ(メモリ)を確保し、それを仮想アドレス空間の予約済み領域と対応付ける(物理ストレージをアドレス空間の領域に割り当てる(マッピングする))ということをしなければなりません。この処理を、物理ストレージのコミットと呼びます。物理ストレージがコミットされていないアドレス空間の領域にアクセスしようとすると、(たとえ予約されている領域でも)アクセス違反が発生して、プログラムが強制終了されてしまいます。

アドレス空間の予約済み領域にコミットされた物理ストレージが不要になった場合には、物理ストレージ解放して、元の予約済みの状態に戻すことができます。(さらに、予約済み領域を解除して、フリー状態まで戻すこともできます。) こうなると、それまでその領域に格納されていたデータはなくなってしまいます。また、解放された物理ストレージ(アドレス空間の領域じゃなくて)は、他のプロセスから使用できるようになります。


このような「領域の予約」という動作は、多少面倒に感じるかもしれませんね。Windowsがなぜこのような動作をする必要があるのかを少し考えてみることにしましょう。

そもそも、予約されている状態の領域は、以下のような特徴があります。

「予約」の概念が必要となるのは、非常に大きなメモリを要するアプリケーションを作成する場合です。ここでは、例として、大きなサイズ(それこそ、1Gバイトくらい)のテキストファイルを編集できるテキストエディタを考えましょう。

領域の予約というものが存在しないとしたら、アプリケーションを起動していきなり、1Gバイトの物理ストレージを割り当てる、ということになりますよね。すなわち、1Gバイトのテキストであろうが、数十Mバイトのテキストであろうが、はたまた、ほんの数バイト程度のテキストであろうが、そのテキストファイルを作成・編集するために、1Gバイト分のRAM(もしくはページングファイルのためのハードディスクスペース)を消費してしまうというのです。それだけのRAMやハードディスク容量が存在しなければ、アプリケーションを実行することすらできない、ということになってしまいますね。

もちろん、サイズが大きくなるにしたがって、コミットする物理ストレージの大きさを少しずつ増やしていく、という方法もあります。現在の最大サイズを超えそうになったら、より大きいサイズの領域を確保して、内容をそちらの領域にコピーし、その後で元のメモリ領域を解放する、というものです。しかし、これでは必要以上の物理ストレージを消費してしまうことになります。例えば、領域を600Mバイトから800Mバイトへと拡張する際に、一時的にとはいえ、1400Mバイトの領域が使われてしまっていることがわかるでしょう。そもそも、再確保する際に、それだけの連続した(切れ切れになっていない)領域がアドレス空間内に残っているのかどうかも怪しいものです(特にサイズが大きい場合には)。

予約という概念を導入することで、これらの問題は一気に解決します。アプリケーション起動時に、まず1Gバイトの領域を予約してしまうのです。こうすると、まず「連続した1Gバイトの領域」というものが保証されます。また、予約されているだけで、まだ物理ストレージをコミットしているわけではないので、実際に1GバイトものRAMやディスクスペースを消費するといったこともありません。テキストのサイズが増えていくにつれて、コミットするサイズを先頭から順に増やしていけばいいのです。(まあ、実際にテキストエディタを作成する場合には、アンドゥデータの管理とか、他にもいろいろ処理すべきことがあって、こう簡単にはいかないでしょうけどね……。)

アドレス空間の割り当て

32ビットWindowsでは4Gバイトの仮想アドレス空間が割り当てられると言いましたが、そのうちの上位2Gバイト(アドレス 0x80000000〜0xFFFFFFFF)はWindowsのシステム(カーネル)用に予約されているために、アプリケーションのプロセス側からアクセスすることはできません。したがって、プロセスは下位2Gバイトの領域を使用することになります。

さらに、Windowsはこの2Gバイトの領域(アドレス 0x00000000 〜 0x7FFFFFFF)のうち、先頭および末尾のいくらかの領域も予約しています。どのくらいの領域が予約されているのかは、Windowsカーネルが 9x カーネルか NT カーネルかによって異なります。Windowsがこういった領域を予約しているからといっても、それらのうちのほとんどは物理ストレージがコミットされていない状態です。したがって、それらの領域のために物理ストレージが実際に消費されてわけではないというのは先ほど説明した通りです。

プロセスが起動されているとき、仮想アドレス内には実行ファイル(EXE)、システムDLL、アプリケーションDLLのからロードされたプログラムコード、およびプロセス内で使われている各データのブロックが配置されています。プログラムはここにロードされたコードに沿って実行されていくのです。

Windows APIとメモリアドレス

Windows APIのいくらかの関数はその引数(パラメータ)としてメモリアドレス(ポインタ)を指定しなければならないものだったり、あるいは、関数の戻り値がメモリアドレスだったりします。これらのAPIで使用されているアドレスは、すべてプロセスの仮想アドレス空間上におけるメモリアドレスになっています。

HSPでも、メモリアドレスによるデータ操作をする方法があります。それはLOADLIB命令群およびLLMODモジュール命令群として用意されているメモリアクセス命令やアドレス取得命令を使うことです。これらの命令群には、任意のメモリアドレスに格納されているデータを取得したり、逆にデータを書きこんだり、また、変数に代入されているデータが実際に格納されているアドレスを取得したりする命令があります。これらの命令を使うことによって、HSPにおけるWindows APIの使用範囲も広がっていくことでしょう。