アセンブリ言語で書かれた大規模なプログラムでは、メモリの破損は一般的な問題でしたか?
大規模なCプログラムやプロジェクトでは、メモリ破損のバグが常に一般的な問題でした。それは当時の4.3BSDの問題でしたが、今日でも問題です。プログラムがどれほど注意深く書かれていても、それが十分に大きければ、コード内のさらに別の範囲外の読み取りまたは書き込みのバグを発見する可能性があります。
しかし、オペレーティングシステムを含む大規模なプログラムが、Cではなくアセンブリで記述されていた時期がありました。メモリの破損のバグは、大規模なアセンブリプログラムで一般的な問題でしたか?そして、それはCプログラムとどのように比較されましたか?
回答
アセンブリのコーディングは残酷です。
不正なポインタ
アセンブリ言語は(アドレスレジスタを介した)ポインタにさらに依存しているため、Cとは対照的に、コンパイラや静的分析ツールに依存して、このようなメモリの破損やバッファオーバーランについて警告することはできません。
たとえば、Cでは、優れたコンパイラがそこで警告を発行する場合があります。
char x[10];
x[20] = 'c';
それは限られています。配列がポインタに減衰するとすぐに、そのようなチェックを実行することはできませんが、それは始まりです。
アセンブリでは、適切なランタイムまたは正式な実行バイナリツールがないと、このようなエラーを検出できません。
不正な(主にアドレス)レジスタ
アセンブリのもう1つの悪化要因は、レジスタの保存とルーチンの呼び出し規約が標準/保証されていないことです。
ルーチンが呼び出され、特定のレジスタを誤って保存しない場合、ルーチンは変更されたレジスタ(終了時に破棄されることがわかっている「スクラッチ」レジスタの横)を使用して呼び出し元に戻り、呼び出し元は予期しません。それは、間違ったアドレスへの読み取り/書き込みにつながります。たとえば、68kコードの場合:
move.b d0,(a3)+
bsr a_routine
move.b d0,(a3)+ ; memory corruption, a3 has changed unexpectedly
...
a_routine:
movem.l a0-a2,-(a7)
; do stuff
lea some_table(pc),a3 ; change a3 if some condition is met
movem.l (a7)+,a0-a2 ; the routine forgot to save a3 !
rts
同じレジスタ保存規則を使用しない他の誰かによって作成されたルーチンを使用すると、同じ問題が発生する可能性があります。私は通常、他の誰かのルーチンを使用する前にすべてのレジスタを保存します。
一方、コンパイラーはスタックまたは標準のレジスターパラメーターの受け渡しを使用し、スタック/その他のデバイスを使用してローカル変数を処理し、必要に応じてレジスターを保持し、コンパイラーによって保証されたプログラム全体ですべて一貫性があります(バグがない限り、コース)
不正なアドレッシングモード
古代のAmigaゲームでの多くのメモリ違反を修正しました。MMUがアクティブになっている仮想環境でそれらを実行すると、完全な偽のアドレスで読み取り/書き込みエラーがトリガーされることがあります。ほとんどの場合、読み取りは0を返し、書き込みは森の中で行われるため、これらの読み取り/書き込みは効果がありませんが、メモリ構成によっては厄介な結果をもたらす可能性があります。
対処ミスもありました。私は次のようなものを見ました:
move.l $40000,a0
即時ではなく
move.l #$40000,a0
その場合、アドレスレジスタに$40000
は、$40000
アドレスではなく、何が入っているか(おそらくゴミ箱)が含まれています。これにより、場合によっては壊滅的なメモリ破損が発生します。ゲームは通常、これを修正せずに他の場所では機能しなかったアクションを実行することになるため、ほとんどの場合、ゲームは適切に機能します。ただし、適切な動作を復元するために、ゲームを適切に修正する必要がある場合があります。
Cでは、ポインターの値を誤解させると警告が発生します。
(「Wicked」のような、レベルが上がるにつれてグラフィックの破損が増えるゲームをあきらめましたが、レベルの通過方法とその順序によっても異なります...)
不正なデータサイズ
組み立てにはタイプはありません。それは私がそうするならそれを意味します
move.w #$4000,d0 ; copy only 16 bits
move.l #1,(a0,d0.l) ; indexed write on d1, long
d0
レジスタは変更されたデータの半分を取得します。私が欲しかったものかもしれませんが、そうではないかもしれません。次に、d0
最上位の32〜16ビットにゼロが含まれている場合、コードは期待どおりに動作します。それ以外の場合はa0
、and d0
(全範囲)を追加し、結果の書き込みは「森の中で」行われます。修正は次のとおりです。
move.l #1,(a0,d0.w) ; indexed write on d1, long
しかし、d0
>の場合、$7FFF
それも何か悪いことをします。なぜなら、それd0
は否定的であると見なされるからです(の場合はそうではありませんd0.l
)。したがって、d0
符号拡張またはマスキングが必要です...
これらのサイズエラーは、たとえばshort
変数(結果を切り捨てる)に割り当てるときにCコードで見られますが、それでも、上記のような致命的な問題ではなく、ほとんどの場合、間違った結果が得られます(つまり、 「トン嘘間違ったタイプのキャストを強制することにより、コンパイラ)
アセンブラには型がありませんが、優れたアセンブラでは、STRUCT
構造体のオフセットを自動的に計算することでコードを少し上げることができる構造体(キーワード)を使用できます。ただし、構造体/定義済みオフセットを使用しているかどうかに関係なく、読み取りサイズが正しくないと壊滅的な結果になる可能性があります
move.w the_offset(a0),d0
の代わりに
move.l the_offset(a0),d0
チェックされておらず、で間違ったデータが表示されd0
ます。コーディング中に十分なコーヒーを飲むか、代わりにドキュメントを書くようにしてください...
不正なデータアライメント
アセンブラは通常、整列されていないコードについて警告しますが、バスエラーをトリガーする可能性のある整列されていないポインタ(ポインタには型がないため)については警告しません。
高水準言語は型を使用し、位置合わせ/パディングを実行することでこれらのエラーのほとんどを回避します(もう一度嘘をつかない限り)。
ただし、アセンブリプログラムは正常に作成できます。パラメーターの受け渡し/レジスターの保存に厳密な方法を使用し、テストとデバッガー(シンボリックかどうかに関係なく、これはまだ作成したコードです)でコードの100%をカバーしようとします。これは、すべての潜在的なバグ、特に間違った入力データによって引き起こされたバグを取り除くことにはなりませんが、それは役に立ちます。
私はキャリアのほとんどをアセンブラー、ソロ、小規模チーム、大規模チーム(Cray、SGI、Sun、Oracle)の作成に費やしました。私は組み込みシステム、OS、VM、およびブートストラップローダーに取り組みました。問題が発生したとしても、メモリの破損はめったにありませんでした。私たちは鋭い人を雇い、失敗した人は彼らのスキルにより適した別の仕事に管理されました。
また、ユニットレベルとシステムレベルの両方で熱狂的にテストしました。シミュレーターと実際のハードウェアの両方で常に実行される自動テストがありました。
キャリアの終わり近くに、私は会社にインタビューし、彼らがどのように自動テストを行ったかについて尋ねました。「なに?!?」という彼らの反応 聞く必要があるのはそれだけだったので、インタビューを終了しました。
どんなに注意を払っても、組み立てには単純なばかげた誤りがたくさんあります。定義が不十分な高級言語(Cなど)用の愚かなコンパイラーでさえ、意味的または構文的に無効である可能性のある膨大な範囲のエラーを制約していることがわかりました。 1回の余分なキーストロークや忘れられたキーストロークの間違いは、アセンブルするよりもコンパイルを拒否する可能性がはるかに高くなります。アセンブリで有効に表現できる構成は、すべて間違っているため意味がありませんが、有効なCとして受け入れられるものに変換される可能性は低くなります。また、より高いレベルで操作しているため、次のようになります。目を細めて「え?」と言う可能性が高いです。書いたばかりのモンスターを書き直します。
したがって、アセンブリの開発とデバッグは、確かに、痛々しいほど寛容ではありません。しかし、そのようなエラーのほとんどは物事を大きく壊し、開発とデバッグに現れます。開発者が同じ基本アーキテクチャと同じ優れた開発慣行に従っている場合、最終製品はほぼ同じくらい堅牢であるはずだという知識に基づいた推測を危険にさらすでしょう。コンパイラーがキャッチする種類のエラーは、優れた開発手法で捕捉できます。コンパイラーが捕捉しない種類のエラーは、そのような手法で捕捉される場合とされない場合があります。ただし、同じレベルに到達するにはかなり時間がかかります。
私は1971年から72年にかけて、Lispのような言語であるMDLのオリジナルのガベージコレクターを作成しました。当時の私にとってはかなりの挑戦でした。これは、ITSを実行するPDP-10のアセンブラーであるMIDASで作成されました。
メモリの破損を回避することが、そのプロジェクトのゲームの名前でした。ガベージコレクターが呼び出されたときに、チーム全体がデモのクラッシュと書き込みの成功を恐れていました。そして、私はそのコードのための本当に良いデバッグ計画を持っていませんでした。私はこれまでまたはそれ以降に行ったよりも多くのデスクチェックを行いました。フェンスポストエラーがないことを確認するようなもの。ベクトルのグループが移動されたときに、ターゲットにゴミ以外が含まれていないことを確認してください。何度も何度も、私の仮定をテストします。
デスクチェックで見つかったものを除いて、そのコードにバグは見つかりませんでした。私たちがライブになった後、私の時計の間に誰も浮上しませんでした。
私は50年前ほど頭が良くないのは明白です。今日はそんなことはできませんでした。そして今日のシステムは、MDLの数千倍の大きさです。
メモリ破損のバグは、大規模なCプログラムでは常に一般的な問題でした[...]しかし、オペレーティングシステムを含む大規模なプログラムが、Cではなくアセンブリで記述されていた時期がありました。
すでに早い段階でかなり一般的だった他の言語があることをご存知ですか?COBOL、FORTRAN、またはPL / 1のように?
大規模なアセンブリプログラムでは、メモリ破損のバグが一般的な問題でしたか?
もちろん、これは次のような複数の要因に依存します
- 異なるアセンブラプログラムが異なるレベルのプログラミングサポートを提供するため、使用されるアセンブラ。
- 特に大規模なプログラムはチェック可能な構造に準拠しているため、プログラム構造
- モジュール化、および明確なインターフェイス
- すべてのタスクがポインタをいじる必要があるわけではないので、書かれたプログラムの種類
- ベストプラクティススタイル
優れたアセンブラは、データが整列されていることを確認するだけでなく、複雑なデータ型や構造などを抽象的な方法で処理するツールを提供し、ポインタを「手動で」計算する必要性を減らします。
深刻なプロジェクトに使用されるアセンブラは、いつものようにマクロアセンブラ(* 1)であるため、プリミティブ操作をより高いレベルのマクロ命令にカプセル化でき、ポインタ処理の多くの落とし穴を回避しながら、よりアプリケーション中心のプログラミングを可能にします(* 2)。
プログラムの種類も非常に影響力があります。アプリケーションは通常、さまざまなモジュールで構成されており、それらの多くは、ポインターを使用せずに(または制御されただけで)ほぼまたは完全に作成できます。繰り返しになりますが、アセンブラーによって提供されるツールの使用は、コードの障害を減らすための鍵です。
次はベストプラクティスです-これは以前の多くと密接に関連しています。複数のベースレジスタを必要とするプログラム/モジュールを記述しないでください。専用の要求構造などの代わりに、メモリの大きなチャンクを渡します。
しかし、ベストプラクティスは、すでに早い段階で、一見単純なことから始まります。パフォーマンスのためにすべてページの境界に調整されたテーブルのセットを持っている6502のようなプリミティブ(申し訳ありません)CPUの例を見てください。これらのテーブルの1つのアドレスを、インデックス付きアクセスのためにゼロページポインタにロードする場合、アセンブラが実行することを意味するツールの使用
LDA #<Table
STA Pointer
私が見たかなりのいくつかのプログラムはむしろ行きます
LDA #0
STA Pointer
(さらに悪いことに、65C02の場合)
STZ Pointer
通常の議論は「しかし、とにかく整列されている」です。それは...ですか?それは将来のすべての反復で保証できますか?アドレス空間が狭くなり、整列されていないアドレスに移動する必要がある日はどうですか?予想される多くの大きな(別名、見つけるのが難しい)エラー。
したがって、ベストプラクティスにより、アセンブラーとそれが提供するすべてのツールの使用に戻ることができます。
アセンブラーの代わりにアセンブラーをプレイしようとしないでください-彼にあなたのために彼の仕事をさせてください。
そして、ランタイムがあります。これはすべての言語に適用されますが、忘れられがちです。スタックチェックやパラメータの境界チェックなどのほかに、ポインタエラーをキャッチする最も効果的な方法の1つは、書き込みと読み取りに対して最初と最後のメモリページをロックすることです(* 3)。これは、すべての最愛のnullポインターエラーだけでなく、以前のインデックス作成が失敗した結果であることが多いすべての低い正または負の数もキャッチします。確かに、ランタイムは常に最後の手段ですが、これは簡単な方法です。
とりわけ、おそらく最も関連性のある理由は
- マシンのISA
ポインタで処理する必要性をまったく減らすことにより、メモリ破損の可能性を減らすことになります。
一部のCPU構造は、他のCPU構造よりも必要な(直接)ポインター操作が少ないだけです。アキュムレータベースのロード/ストアアーキテクチャのように、メモリ間操作を含むアーキテクチャと含まないアーキテクチャの間には大きなギャップがあります。本質的に、単一の要素(バイト/ワード)よりも大きいものにはポインター処理が必要です。
たとえば、フィールド、たとえばメモリ内の顧客名を転送する場合、/ 360は、データ定義からアセンブラによって生成されたアドレスと転送長を使用して単一のMVC操作を使用しますが、ロード/ストアアーキテクチャは各バイトを処理するように設計されています個別に、レジスタにポインタと長さを設定し、移動する単一要素をループする必要があります。
このような操作は非常に一般的であるため、エラーが発生する可能性も一般的です。または、より一般的な方法で、次のように言うことができます。
CISCプロセッサ用のプログラムは、通常、RISCマシン用に作成されたものよりもエラーが発生しにくいです。
もちろん、いつものように、悪いプログラミングによってすべてが台無しになる可能性があります。
そして、それはCプログラムとどのように比較されましたか?
ほぼ同じか、それ以上に、Cは最も原始的なCPU ISAと同等のHLLであるため、より高いレベルの命令を提供するものはどれもかなり優れています。
Cは本質的にRISCy言語です。提供される操作は最小限に抑えられ、意図しない操作をチェックするための最小限の機能が提供されます。チェックされていないポインタを使用することは標準であるだけでなく、多くの操作に必要であり、メモリ破損の多くの可能性を開きます。
対照的に、ADAのようなHLLを考えてみましょう。ここでは、ポインターの大混乱を作成することはほとんど不可能です。それが意図され、オプションとして明示的に宣言されていない限りです。その大部分は(以前のISAと同様に)より高いデータ型とタイプセーフな方法でのそれらの処理によるものです。
経験の部分では、80%メインフレーム(/ 370)20%マイクロ(主に8080 / x86)のようなアセンブリプロジェクトで私の職業生活のほとんど(> 30年)を行いました-さらにプライベートはもっとたくさんあります:)メインフレームプログラミングはプロジェクトをカバーしましたマイクロプロジェクトが約1万から2万のLOCを維持している間、2億以上のLOC(指示のみ)。
* 1-いいえ、テキストのパッセージを事前に作成されたテキストに置き換えることを提案するものは、せいぜい一部のテキストプリプロセッサですが、マクロアセンブラではありません。マクロアセンブラは、プロジェクトに必要な言語を作成するためのメタツールです。アセンブラがソースに関して収集する情報(フィールドサイズ、フィールドタイプなど)をタップするツールと、適切なコードを生成するために使用される処理を定式化するための制御構造を提供します。
* 2-Cが深刻なマクロ機能に適合しなかったことを嘆くのは簡単です。それは多くのあいまいな構造の必要性を取り除くだけでなく、新しいものを書く必要なしに言語を拡張することによって多くの進歩を可能にしました。
* 3-個人的には、ページ0を書き込み保護のみにし、最初の256バイトを2進数のゼロで埋めることを好みます。そうすれば、すべてのnull(またはlow)ポインター書き込みは依然としてマシンエラーになりますが、nullポインターからの読み取りは、タイプに応じて、ゼロを含むバイト/ハーフワード/ワード/ダブルワート、またはnull文字列を返します:)私は知っています、それは怠惰ですが、他の人々のコードを簡単に協力しなければならない場合、それは人生をはるかに良くします。また、残りのページは、さまざまなグローバルソースへのポインタ、ID文字列、定数フィールドコンテンツ、変換テーブルなどの便利な定数値に使用できます。
私は、CDC G-21、Univac 1108、DECSystem-10、DECSystem-20、すべての36ビットシステム、および2つのIBM1401アセンブラーのアセンブリでOSmodを作成しました。
「メモリの破損」は、主に「すべきでないこと」リストのエントリとして存在していました。
Univac 1108で、ハードウェア割り込み後の最初のハーフワードフェッチ(割り込みハンドラアドレス)がアドレスの内容ではなくすべて1を返すというハードウェアエラーを見つけました。割り込みを無効にして、メモリ保護なしで雑草に飛び込みます。それはぐるぐる回って行きます、そこでそれは誰も知らないところで止まります。
あなたはリンゴとナシを比較しています。プログラムがアセンブラでは管理できないサイズに達したため、高級言語が発明されました。例:「V1には、カーネル、初期化、シェル用に4,501行のアセンブリコードがありました。そのうち、3,976行がカーネル、374行がシェルです。」(この回答から。)
。V1。シェル。持っていました。347.行。の。コード。
今日のbashには、readlineやローカリゼーションなどの中央ライブラリを除いて、おそらく100,000行のコードがあります(リポジトリのwcは170kになります)。高水準言語は、移植性のためだけでなく、今日のサイズのプログラムをアセンブラーで作成することが事実上不可能であるために使用されています。エラーが発生しやすいだけでなく、ほぼ不可能です。
同様のタスクを実行するプログラムを比較する場合、メモリの破損は、チェックされていない配列添え字演算を使用する他の言語よりも、アセンブリ言語の方が一般的に問題になるとは思いません。正しいアセンブリコードを書くには、Cのような言語に関連するもの以外の詳細に注意を払う必要があるかもしれませんが、アセンブリ言語のいくつかの側面は実際にはCより安全です。アセンブリ言語では、コードが一連のロードとストアを実行する場合、アセンブラはすべてが必要かどうかを疑うことなく、指定された順序でロードおよびストア命令を生成します。対照的に、Cでは、clangのような巧妙なコンパイラーが-O0
、次のようなもの以外の最適化設定で呼び出された場合。
extern char x[],y[];
int test(int index)
{
y[0] = 1;
if (x+2 == y+index)
y[index] = 2;
return y[0];
}
それはの価値と判断してもよいy[0]
ときreturn
の文の実行は常に1になり、そしてへの書き込み後、その値をリロードする必要はこれがないy[index]
場合は、インデックスへの書き込みが発生する可能性があるだけで定義された状況は次のようになりにもかかわらず、x[]
2バイトであるが、y[]
起こりますすぐにそれに続き、index
ゼロであり、y[0]
実際には数値2を保持したままになることを意味します。
アセンブラーには、CやJavaなどの他の言語よりも、使用しているハードウェアに関するより深い知識が必要です。真実は、しかし、アセンブラーは、最初のコンピューター化された車、初期のビデオゲームシステムから1990年代まで、そして今日私たちが使用しているモノのインターネットデバイスまで、ほとんどすべてで使用されてきました。
Cは型の安全性を提供しましたが、voidポインターのチェックや制限付き配列などの他の安全対策は提供していませんでした(少なくとも、追加のコードがないわけではありません)。アセンブラプログラムと同様に、クラッシュして書き込むプログラムを作成するのは非常に簡単でした。
数万のビデオゲームがアセンブラーで作成され、数十年にわたってわずか数キロバイトのコード/データで小さいながらも印象的なデモを作成するためのコンポであり、今日でも数千の車が何らかの形のアセンブラーを使用しています。オペレーティングシステム(例:MenuetOS)。あなたの家には、あなたが知らないアセンブラーでプログラムされたものが何十、あるいは何百もあるかもしれません。
アセンブリプログラミングの主な問題は、Cのような言語よりも精力的に計画する必要があることです。アセンブラで10万行のコードを含むプログラムをバグなしで作成することは完全に可能であり、 5つのバグがある20行のコードを含むプログラム。
問題になるのはツールではなく、プログラマーです。メモリーの破損は、一般的な初期のプログラミングでは一般的な問題だったと思います。これは、アセンブラだけでなく、C(メモリリークや無効なメモリ範囲へのアクセスで悪名高い)、C ++、およびメモリに直接アクセスできるその他の言語、さらにはBASIC(特定のI /を読み書きする機能を備えていた)にも限定されませんでした。 CPUのOポート)。
セーフガードを備えた現代の言語でも、ゲームをクラッシュさせるプログラミングエラーが発生します。どうして?アプリケーションの設計に十分な注意が払われていないためです。メモリ管理は消えたわけではなく、視覚化が難しいコーナーに押し込まれ、現代のコードであらゆる種類のランダムな大混乱を引き起こしています。
誤って使用すると、事実上すべての言語がさまざまな種類のメモリ破損の影響を受けやすくなります。今日、最も一般的な問題はメモリリークです。これは、クロージャと抽象化のために誤って導入することがこれまでになく簡単になっています。
アセンブラが本質的に他の言語よりも多かれ少なかれメモリを破壊していたと言うのは不公平です。適切なコードを書くのがいかに難しいかという理由で、それはただひどいラップを得ました。
それは非常に一般的な問題でした。1130用のIBMのFORTRANコンパイラーには、かなりの数がありました。私が覚えているものには、検出されなかった誤った構文のケースが含まれていました。マシンに移行する-より高いレベルの言語に近いことは明らかに役に立ちませんでした:PL / Iで書かれた初期のMulticsシステムは頻繁にクラッシュしました。プログラミングの文化と技術は、言語よりもこの状況を改善することに関係していると思います。
私は数年間のアセンブラープログラミングを行い、その後数十年のCを行いました。アセンブラープログラムにはCよりも悪いポインターバグはないようですが、その重要な理由は、アセンブラープログラミングの作業が比較的遅いことでした。
私が所属していたチームは、機能の増分を作成するたびに、通常は10〜20のアセンブラー命令ごとに、作業をテストしたいと考えていました。高水準言語では、通常、より多くの機能を備えた同様の数行のコードの後でテストします。これは、HLLの安全性とトレードオフになります。
アセンブラは、生産性が低下し、通常は他の種類のコンピュータに移植できないため、大規模なプログラミングタスクへの使用を停止しました。過去25年間で、私は約8行のアセンブラーを作成しました。これは、エラーハンドラーをテストするためのエラー条件を生成することでした。
当時、私がコンピューターを使っていたときではありませんでした。多くの問題が発生しましたが、メモリ破損の問題は発生しませんでした。
今、私はいくつかのIBMマシン7090、360、370、s / 3、s / 7と、8080およびZ80ベースのマイクロで作業しました。他のコンピュータにはメモリの問題があった可能性があります。