『低レベルプログラミング』

Written on 2022-10-27

ネットで勧められていた『低レベルプログラミング』を例題を解きながら読みすすめていた。 本書は、x86_64 + Linuxの環境でアセンブラとCを使って低レイヤーのプログラミングをする教科書。 これがわかれば、だいたい中級だと思う。本書の環境はLinuxベースだが、紹介されているテクニックはCortex-M+baremetal組み込みでも十分役に立つと思う。

家のパソコンがMacなので、x86_64+Linuxの環境を作るためにLimaをインストール

第一部がアセンブラ、第二部がC、第三部がリンカーやライブラリなど、という構成。

以下、履修メモです。

1. コンピュータ・アーキテクチャの基礎

基礎。読み物。

2. アセンブリ言語

まずは Linux システムコールの呼び出し方。

raxにシステムコール番号を入れsyscall命令を発行する。 引数は、順にrdi, rsi, rdxに入れる。

いくつかの例題をこなしながら、基本的なアセンブラの知識の導入。

  • 記法
  • フラグレジスタ(eflags)
  • 条件ジャンプ
  • 関数呼び出し(call命令)
  • 関数呼び出し規約(引数の渡し方、保存/破壊レジスタ)
  • Big/Little Endian

Intel SDMが必携だが、https://hikalium.github.io/opv86/も便利。

章終わりの課題として、簡単な入出力ライブラリをアセンブラで作る。

3. レガシー

x86_64以前の昔のx86CPUの話。読み物。

XXXXのためにYYYYという機構が追加された。しかし、OSベンダーには採用されなかった。のような機能が多くあり、intelといえども悲しい目をたくさん見たのだなぁと、遠い目になる。

4. 仮想メモリ

『OS自作入門』でも履修した、仮想メモリ関係。

x86_64のアドレス変換機構の説明とmmapシステムコール。

5. コンパイル処理のパイプライン

一般的な開発環境ではgccが使われるしアセンブラはgasが多いと思う。NASMのマクロの解説に分量が割かれているが冗長感がある。

ELFのそれぞれのセグメントの話。

リンカ、ローダ、ライブラリ、動的ライブラリの話。

6. 割り込みとシステムコール

ごく一般的な話だけでなく、TSS、IDTといったx86特有の実装についても詳しく解説されている。

7. 計算モデル

有限状態マシンとスタックマシンの紹介。

スタックマシンの実例として簡単なForth言語をアセンブラ(本書ではまだCは登場していない)で実装している。 そして、この、最初のForth処理系はブートストラップが可能、つまり、ForthでForth処理系を書けるようになっている(すごい)。

8. 基礎

ここから第2部。C言語編。

この本を手に取るだろうほとんどの人にとっては復習となるだろうが、よくまとまっているので、良い機会だ。

個人的な感想としてはC89にこだわり過ぎているような気がする。ブロックの先頭でしか変数宣言ができないC89の仕様は、読みやすさと昨今のコンパイラを考えれば無視して良い。使用する直前で宣言するほうが読みやすい。

9. 型システム

引き続きC言語の解説。C89世代のおじさんプログラマには馴染みが少ないかもしれないが、近年は当然の事柄。

signedsize_tを優先して使うべきであること、その理由については、もうすこし知られても良いと思う。 https://ttsuki.github.io/styleguide/cppguide.ja.html#Integer_Types

10. コードの構造

宣言と定義の話。分割コンパイルとシンボルの話。

11. メモリ

ポインタについて。

メモリ割り当て(malloc()/free())について。

C99の可変長配列、フレキシブル配列メンバ、文字列リテラルのインターンイングなど、マニアックな機能についても触れられている。

整数型のサイズについても触れられている。<stdint.h><inttypes.h>は明快で、標準化されているので、積極的に使っていきたい。

12. 構文と意味と実際

BNFによる文法定義手法、再帰降下法パーサの作り方。低レイヤーに興味を持てば必然的にコンパイラ・言語処理系にも興味を持ってくるものだろう。

ただ、BNFを示し再帰降下法パーサの実装例を示し、その後に「実際はパーサジェネレータなどを使う」とするのはやや不親切だろう。yaccなどのパーサジェネレータは再帰降下法ではなくプッシュダウンオートマトンによってパースすることが一般的だからだ。

その後、コンパイラの実装とからめて、メモリのアライメント、構造体のパディングの話。 packed 構造体は組み込み分野でよく出てくる。C11標準での_Alignas,_Alignofの話。

13. 良いコードを書くには

非常に危険な話題。好み、過去からの慣習などがあって、職場でこの話をすると揉めることが多い。しかし、客観的に見て「良いコード」「悪いコード」は存在して、その理由が明確であり、ある程度以上の水準のプログラマは、その良し悪しに同意できる、という前提に立っておこう。

これについては『リーダブル・コード』をまず読むべきだ。

本節でも、基本的な原則について触れられている。これぐらいは守っていこう。構造の側面と命名の側面がある。

モジュール化とヘッダファイルの例として、スタックの実装が挙げられている。stack.hでインターフェイスを定義してstack.cで実装するというものだ。ここで、stack.cの一番最初にstack.hをインクルードするというGoogleのコーディングルールは、もっと周知されてもよいものだろう。

https://google.github.io/styleguide/cppguide.html#Names_and_Order_of_Includes

エラー処理について、本書ではいくつかの典型的な方法が紹介されている。私がそれよりも大切だと思うのは「できるだけエラーを返す」こと。ある関数でハンドリングできない状態が発生した場合、ライブラリ的なサブルーチンは、アボートするとか無視するといったような、自分自身でエラーをハンドリングすることは避けるべきだ。アプリケーション本体のできるだけ上位・ユーザインターフェイス側までエラーを伝搬させて、ライブラリ層ではなくアプリケーション層でハンドリング・リカバリーをするべき。

Rustではエラーハンドリングの方法が標準化されている。即座にハンドリングするなら、.unwrap()メソッドが使える。例題ではそれでも良いが、実用的なアプリケーションでは、さらに上位にエラーを伝搬させるべき。そのために、?記法やanyhowなどのクレートが準備されている。

13.9 柔軟性について

関数の作り方、機能の分割について、良い例と悪い例が書かれていて、初心者にもわかりやすい。

14. 変換処理の詳細

この章から第三部「Cとアセンブラの間」ということで呼び出し規約、オブジェクトファイルの構造、リンカなどの話になっていく。

関数の呼び出し規約の詳細、プロローグとエピローグでのスタックポインタ(rbp,rsp)の操作。ここではSystemV呼び出し規約なので x86_64 linux で適用され、Windowsでは異なるということに注意。

setjmp()/longjmp()も、ここで解説される。

スタックの構造がわかれば、スタックオーバーフローやリターンアドレス書き換えなどのセキュリティも理解できる。

printf()のフォーマット文字列の脆弱性が紹介されていた。恥ずかしながら知らなかったが、printf()のフォーマット指定文字列にユーザ入力をそのまま渡すと、SQLインジェクション的な脆弱性となる。

char buffer[STRLEN];
fgets(buffer, STRLEN, stdin);
printf(buffer);       /// だめ
printf("%s", buffer); /// よい

15. 共有オブジェクトとコードモデル

共有ライブラリの話。GOT, PLTを使ったx86_64での実装についても詳しく述べられている。ディスアセンブリできちんと解説しているところが、本書の価値だ。さらにその後に、Cではなくアセンブラによる共有ライブラリの実装について例示があるところも素晴らしい。

共有ライブラリの呼び出しはコストが高いので、ライブラリ内から呼び出す場合はstaticで呼び出し、外部から呼び出す場合は、ラッパーを使う、またはGCC各区帳のaliasを使うというテクニック。

昔 x86 の時代には セグメント+オフセットをもとにした far ポインタというものがあった。64ビットの時代になっても、ripからの相対アドレスである32ビット vs 64ビットアドレスのメモリモデルがある、というのも驚きである。4GBの壁。smallコードモデルでは、32ビットのrip相対アドレスは命令中に即値で埋め込まれる。largeコードモデルでは、64ビットの絶対アドレスは、いったん mov rax 0x12345678されてからrax経由で使われ、2命令消費することになる。

16. 性能

性能を向上させるための、SSE(SIMD)、最適化、キャッシュについての話。性能向上に一番大切なのは計測。

ここでもコンパイラがどのようなアセンブラを生成するのか、アセンブラレベルでの命令実行がどうなっているのか、が丁寧に解説されている。

17. マルチスレッド

マルチスレッドは、pthreadなどのライブラリを使うことになるが、競合状態に起因するバグに対処するためには本書のスコープである低レイヤーの知識が必要になってくる。

命令の並べ替えに関しての強弱のメモリモデルなど、微細な議論も重要になってくる。これはコンパイラの最適化の話だけでなく、CPUの実行順序最適化の話でもあるので厄介だ。アセンブラで書いても影響を受ける。これが原因で発生するバグは非常にデバッグが難しい。

最適化と競合するがメモリバリア機能が提供されている。

アトミックな演算も必要になってくる。x86_64では、例えば次のような点に注意しなければならない。

  • ネイティブな型でもアライメントが自然でないときはアトミックではない。
  • inc命令は、シングルプロセッサではアトミックであるが、マルチプロセッサシステムではアトミックではない。

C11では_Atmic()型修飾子が利用可能となっているが、まだ実コードで見ることは少ない。