第1章:巨人の太い指(データ型とアライメント)
1bit CPUの夢:本質は ON/OFF のスイッチ
プログラミングの本質とは何か?
それは、1bitのスイッチを制御することだ。
1bitには、0 と 1 しかない。
OFF と ON しかない。
それで十分だ。
1bit CPUの美しさ
想像してみよう。
君の目の前に、たった1本のスイッチがある。
それを ON にするか、OFF にするか。
それだけで、君は世界を記述できる。
0 → False → 光が消えている
1 → True → 光が点いている
この単純さは、圧倒的に美しい。
もし世界が1bitだけで動いているなら、CPUは「考える」必要がない。
ただスイッチを押すだけだ。
真の物理:トランジスタという門番
実際、CPUの内部には数十億個のトランジスタが並んでいる。
それぞれが、1bitの「門」として機能する。
電圧をかけるか、かけないか。
電流を通すか、通さないか。
それだけ。
CPUは「計算」などしていない。
物理現象として、電流がスイッチングしているだけだ。
64bitの現実:CPUは指が太すぎて、1bit(米粒)をつまめない
だが、現実は残酷だ。
現代のCPUは、1bitを直接つかむことができない。
なぜか?
指が太すぎるからだ。
巨人の太い指
CPUを「巨人」だと想像してほしい。
この巨人は、指が異常に太い。
君が「この米粒(1bit)をつまんでください」と頼んでも、巨人は困る。
「指が太すぎて、米粒だけをつまめない。米袋(8byte)ごと持ち上げるしかないんだ」
64bitアーキテクチャの現実
64bit CPUは、一度に64bit(8byte)を処理する。
メモリ上では、bool 型は1byteとして確保される。
let flag: bool = true; // たった1bitの情報
この flag は:
- 論理的には1bitの情報(true/false)
- 物理的には1byteを占有(メモリ上)
- CPUが処理する時は8byte単位で読み込まれる
つまり、1byteの中で1bitしか使わず、7bitが空気だ。
さらに、CPUがメモリからこの1byteを読む時、周辺の7byteも一緒に読み込まれる(64bit単位のため)。
これが、64bit CPUの宿命だ。
パディングの正体:構造体のアライメントと物理的隙間
では、この「太い指」は、構造体に対してどんな影響を与えるのか?
実例:予想外のサイズ
次のRust構造体を見てほしい。
struct Sensor {
active: bool, // 1 byte
threshold: f64, // 8 byte
count: u32, // 4 byte
}
論理的なサイズは:
1 + 8 + 4 = 13 byte
だが、アライメントという物理的制約がある。
計算してみよう
-
active(1byte) の後、threshold(8byte) は8の倍数のアドレスから配置される → 7byteの空気 -
threshold(8byte) -
count(4byte) - 構造体全体は8の倍数にする → 4byteの空気
合計:1 + 7 + 8 + 4 + 4 = 24 byte
24byteになるはずだ!
では、実際のサイズは?
println!("{}", std::mem::size_of::<Sensor>()); // ???
実行すると...
16
16byte!!
あれ? 計算と違うぞ?
Rustコンパイラの賢さ(と生意気さ)
実は、Rustコンパイラは賢い。
Rustは、君が何も指定しなければ、勝手にフィールドを並べ替えてパディングを最小化する。
君が書いた順序:
active (1byte) → threshold (8byte) → count (4byte)
Rustコンパイラが実際に配置した順序:
threshold (8byte) → count (4byte) → active (1byte)
こうすることで、16byteに収まる。
では、本当に24byteにするには?
もし君が「書いた順序のまま配置しろ」とコンパイラに命令したいなら、#[repr(C)]という呪文を唱える必要がある。
#[repr(C)] // C言語と同じレイアウトにする(並べ替えなし)
struct Sensor {
active: bool, // 1 byte(論理)
threshold: f64, // 8 byte
count: u32, // 4 byte
}
こうすると、Rustは並べ替えをせず、君が書いた順序のまま配置する。
そして、サイズは24byteになる。
アライメントという物理的制約
では、なぜ#[repr(C)]を付けると24byteになるのか?
答えは、メモリアライメントにある。
確かに、現代の優しいCPU(特にx86)は、君がズレた場所にデータを置いても、文句を言わずに読んでくれることが多い。 キャッシュライン(64byte)の内側であれば、物理的なペナルティも隠蔽してくれるだろう。
だが、それに甘えてはいけない。
もし君が「動くからいいや」とアライメントを無視した瞬間、以下の時限爆弾が起動する。
境界跨ぎの悲劇: 運悪くキャッシュラインを跨げば、CPUは2回メモリにアクセスし、アトミック性は失われる。
SIMDの拒絶: 将来、君がコードを高速化しようとしてSIMD命令を使った瞬間、プログラムはクラッシュする。
移植性の欠如: x86以外のCPU(ARMや組み込み)に持ち込んだ瞬間、そのコードは動かなくなる。
コンパイラがパディングを入れてまでアライメントを守るのは、「どんな環境でも、どんな命令を使っても、確実に動作する」ためだ。
プロのエンジニアは「今のCPUならサボれる」とは言わない。 「アライメントは物理的な礼儀(プロトコル)である」と理解し、それを守るのだ。
-
u32(4byte) は、4の倍数のアドレスに配置されねばならない -
f64(8byte) は、8の倍数のアドレスに配置されねばならない
これがアライメント制約だ。
メモリレイアウトの可視化(#[repr(C)]の場合)
#[repr(C)]を付けた場合の、実際のSensorのメモリレイアウトは、こうなる:
メモリアドレス: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
フィールド: │ A │<─────── 空気 ───────>│<────────── threshold: f64 ─────────>│<── count: u32 ──>│<── 空気 ──>│
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
1 7 8 4 4
byte byte byte byte byte
A = active: bool (1 byte)
空気 = パディング(無駄な領域)
合計: 24 byte
Total: 24 byte
なぜこんなことになるのか?
理由1: 前のパディング
active (1byte) の後、threshold (8byte) を配置するには、8の倍数のアドレス(8番地)から始めねばならない。
→ 1〜7番地は空気
理由2: 末尾のパディング(配列の連帯責任)
「count (4byte) で終われば、合計20byteでいいじゃないか」と思ったかい?
甘い。
もし20byteで終わってしまうと、この構造体を配列にした時、2つ目の Sensor が「21byte目」から始まってしまう。
21は「8の倍数」ではない。つまり、巨人が2つ目のデータを掴めなくなる。
配列にした場合:
Sensor[0]: 0〜19byte(20byte)
Sensor[1]: 20〜39byte(21byte目から) ← アライメント違反!
だから、「次に来る誰かのために」、お尻までしっかり8の倍数(24byte)に切り上げられるのだ。
これがアライメントの「連帯責任」だ。
→ 20〜23番地も空気
物理的必然性:なぜCPUはこんなに不器用なのか?
これは、物理的な制約だ。
CPUは、メモリバス(データの通路)を通じてメモリにアクセスする。
このバスは、64bit(8byte)単位でデータを運ぶ。
もし f64 が「7番地」から始まっていたら?
CPUは以下のように動く:
- 0〜7番地を読む(1回目のメモリアクセス)
- 8〜15番地を読む(2回目のメモリアクセス)
- 両方のデータを結合して、7〜14番地のデータを取り出す
2回もメモリにアクセスし、さらにビット演算で合成する必要がある。
これは、致命的に遅い。
アライメントは「最適化」ではなく「物理的必然」
だから、CPUは「アライメントされたアドレス」にデータを配置するよう強制する。
これは設計の自由ではなく、物理の鎖だ。
君が「メモリを節約したい」と願っても、CPUは言う。
指が太いんだ。許せ
Rustコンパイラの知恵 vs C言語の職人技
さて、ここまでで重要なことが2つわかった。
- Rustは賢い: デフォルトで自動的にフィールドを並べ替え、16byteに最適化する
-
C言語(や
#[repr(C)])は愚直: 書いた順序のまま配置し、24byteになる
では、もし君がC言語で書いている場合や、FFI(Foreign Function Interface)でC言語と連携する必要があり、#[repr(C)]を使わざるを得ない場合はどうすればいいのか?
C言語や#[repr(C)]での手動最適化
C言語には「自動で並べ替えてくれる親切なコンパイラ」はいない。
君自身が、フィールドの順序を考えてパディングを最小化する必要がある。
Before:24byte(並び順が悪い)
// C言語、またはRustの #[repr(C)]
struct Sensor {
bool active; // 1 byte
double threshold; // 8 byte
uint32_t count; // 4 byte
};
// Total: 24 byte(7 + 4 = 11 byte の無駄)
After:16byte(並び順を最適化)
// C言語、またはRustの #[repr(C)]
struct Sensor {
double threshold; // 8 byte
uint32_t count; // 4 byte
bool active; // 1 byte
};
// Total: 16 byte(3 byte の無駄に削減)
なぜか?
メモリアドレス: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
フィールド: │<────────── threshold: f64 ─────────>│<── count: u32 ──>│ A │<空気>│
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
8 4 1 3
byte byte byte byte
A = active: bool
合計: 16 byte
大きいものから順に並べることで、隙間を最小化できる。
Rustの場合:コンパイラに任せるか、手動で制御するか
Rustでは、以下の選択肢がある:
- 何もしない(推奨): コンパイラが自動で最適化してくれる → 16 byte
-
#[repr(C)]: C言語との互換性が必要な場合のみ使う → 手動で順序を最適化すれば16 byte
結論: Rustを使っている限り、パディングの心配はほとんど不要だ。
だが、C言語の世界(組み込み、低レイヤ、FFI)では、君自身がこの最適化を考えなければならない。
これが、「Rustの親切さ」と「C言語の職人技」の違いだ。
巨人の箸(ビット演算):太い指で米粒をつまむ技術
さて、我々は「巨人の太い指」という宿命を受け入れた。
だが、先人たちは諦めなかった。
彼らは、「箸」を発明した。
課題の再確認:boolの贅沢
君が bool を定義するたび、1byteが消費される。
let is_active: bool = true; // 1 byte
let is_connected: bool = false; // 1 byte
let is_ready: bool = true; // 1 byte
// Total: 3 byte(実際に使っているのは3bit)
21bitが空気だ。
一見、「たった3byteなら問題ないじゃないか」と思うかもしれない。
だが、もし君が「64個のbool」を持つなら?
- 素朴な実装:
64 × 1 = 64 byte - 実際に必要な情報:
64 bit = 8 byte
56byteが無駄になる。
さらに残酷な現実:構造体でのパディング
問題は、構造体に複数のboolを入れたときに悪化する。
struct Flags {
flag1: bool, // 1 byte
flag2: bool, // 1 byte
flag3: bool, // 1 byte
value: u64, // 8 byte
}
このサイズは? 1 + 1 + 1 + 8 = 11 byte?
いいや、16 byteだ。
u64のアライメント(8 byte境界)のため、flag3の後に5 byteのパディングが入る。
解決策:u64という「箱」に64個のスイッチを詰め込む
先人たちは、こう考えた。
「u64(64bit)という箱がある。この中に、64個の1bitスイッチを詰め込めば良いのではないか?」
let flags: u64 = 0b0000_0000_0000_0000_0000_0000_0000_0101;
// ^^^
// bit 0: true (active)
// bit 1: false (connected)
// bit 2: true (ready)
8byteで64個のboolを管理できる。
これが、ビットフィールドだ。
箸さばき:ビット演算という器用な技術
だが、問題がある。
どうやって特定の1bitだけを読み書きするのか?
巨人の太い指は、8byte単位でしかつかめない。
だが、「箸」を使えば、1bitを器用につまみ出せる。
この「箸」が、ビット演算だ。
実例:特定のbitを読む
「bit 2(ready)が立っているか?」を確認したい。
let flags: u64 = 0b0000_0101; // bit 0 と bit 2 が立っている
// bit 2 をチェック
let mask = 0b0000_0100; // bit 2 だけが 1 のマスク
let is_ready = (flags & mask) != 0;
&(AND演算)は、箸でつまむ動作だ。
flags: 0000_0101
mask: 0000_0100
-----------
result: 0000_0100 → 0 でない → true
実例:特定のbitを立てる
「bit 3 を立てたい」
let mut flags = 0b0000_0101;
let mask = 0b0000_1000; // bit 3
flags |= mask; // OR演算で bit 3 を立てる
// 結果: 0b0000_1101
|(OR演算)は、箸で米粒を置く動作だ。
実例:特定のbitを消す
「bit 0 を消したい」
let mut flags = 0b0000_0101;
let mask = 0b0000_0001; // bit 0
flags &= !mask; // NOT + AND で bit 0 を消す
// 結果: 0b0000_0100
!(NOT)でマスクを反転し、&(AND)で「そのbit以外を残す」。
なぜ低レイヤのコードには 0x01 や & が多いのか?
君が、Linuxカーネルやデバイスドライバのコードを読むと、こんなコードが溢れている。
if (status & 0x01) { /* bit 0 が立っているか */ }
flags |= 0x80; /* bit 7 を立てる */
data &= ~0x04; /* bit 2 を消す */
なぜこんな書き方をするのか?
答えは、圧倒的に効率的だからだ。
理由1: メモリ効率
64個のbool型を使えば64 byteだが、
1つのu64に詰め込めば8 byteで済む。
組み込みシステムやカーネルでは、メモリが貴重だ。
1KBですら無駄にできない環境では、ビット演算は生存戦略だ。
理由2: 速度
ビット演算は、CPU命令1つで完了する。
flags |= 0x80; // 1命令(OR)
もしbool配列なら?
flags[7] = true; // メモリアクセス + 書き込み(複数命令)
ビット演算は、単一のALU演算として実行される。
メモリアクセスすら発生しない(レジスタ内で完結)。
理由3: ハードウェアの直接制御
GPIOピン、レジスタ、通信プロトコルは、bitで通信する。
// GPIO ピンを ON にする
GPIO_PORT |= 1 << 3; // bit 3 を立てる
ハードウェアは、boolなど知らない。
bit単位の物理的スイッチしか理解しない。
これらは、巨人の箸さばきだ。
太い指(8byte)でしか動けないCPUに対して、
1bitという米粒を器用につまむための、先人たちの知恵である。
ビット演算は「最適化」ではなく「物理的必然」
ビット演算は、美しい。
効率的だ。
だが、それは「テクニック」ではない。
物理的な制約への、必然的な回答だ。
巨人の太い指(64bit)で、米粒(1bit)を扱うには、
箸(ビット演算)しかないのだ。
現代への継承:ビットフラグの実例
この知恵は、今も生きている。
ファイルパーミッション(Linux/Unix)
chmod 755 deploy.sh
# 7 = 0b111 (read + write + execute) ← 所有者
# 5 = 0b101 (read + execute) ← グループ・その他
ネットワークプロトコル(TCP/IP)
Flags: 0x18 (= 0001 1000)
[FIN] bit 0: 0
[SYN] bit 1: 0
[RST] bit 2: 0
[PSH] bit 3: 1 ← データをすぐにアプリへ
[ACK] bit 4: 1 ← 受信確認
[URG] bit 5: 0
組み込みシステム(レジスタ制御)
// GPIO ピンを ON にする
GPIO_PORT |= 1 << 3; // bit 3 を立てる
すべて、巨人の箸さばきだ。
まとめ:巨人の太い指という物理的制約
CPUは、1bitを直接つかめない。
これは、物理の制約であり、言語仕様や最適化では回避できない。
- 1bit CPUの夢:本質はシンプルな ON/OFF のスイッチ
- 64bitの現実:CPUは指が太すぎて、8byte単位でしか動けない
- パディングの正体:アライメント制約による物理的な隙間
- 巨人の箸(ビット演算):太い指で米粒(1bit)をつまむ先人の知恵
君がRustで bool を定義するたび、7bitの空気が生まれる。
君がC言語とのインターフェース(FFI)で #[repr(C)] を使うとき、物理的な隙間と向き合わねばならない。
だが、君がビット演算を使うとき、君は64個のスイッチを8byteに詰め込むことができる。
Rustは賢い。構造体のパディングを自動で最適化してくれる。
だが、CPUの「太い指」という物理的制約からは、誰も逃れられない。
そして、箸を使いこなす者だけが、真の効率を得る。
次章:第2章「孤独なスピード狂と、止まった時計」
CPUは、メモリの到着を「永遠」のように待つ。その絶望と、投機実行という狂気を語る。
Discussion
そういうアーキテクチャが存在することを否定しませんが、「CPUは、特定のアドレス境界からしかデータを読めない。」と言い切ってしまうと嘘になるのでは?
たしかにそうですね。
ご意見ありがとうございます。
修正させていただきました。
現代のx86などのCPUではメモリアクセス幅も広く↑のようになる機会はかなり減少しており常にこうなるかの説明は正確ではないと思います。
重要なご指摘ありがとうございます。 おっしゃる通り、近年のx86アーキテクチャではUn-aligned accessのコストは極小化されていますね。
ただ、この記事では以下の理由から、あえて「アライメントは絶対守るべきもの(意識すべきもの)」というスタンスをとっています。
キャッシュライン跨ぎのリスク: 最悪ケースではやはり2倍のコストとアトミック性の欠如が発生すること。
SIMD/他アーキテクチャ: AVX命令やARMなど、厳密なアライメントを要求する環境へのポータビリティ。
Rustの安全性: 言語仕様としてアライメント違反がUB(未定義動作)を招くリスク。
「CPUが許してくれるからOK」ではなく、「物理的な制約を理解して設計する」ことの重要性を強調するため、ご指摘の内容を踏まえつつ、より「なぜ守る必要があるのか」を深掘りする記述に修正しました。
鋭い視点、大変勉強になります
記事のスタンスは理解しました。
あとセマフォやミューテックス、アトミックオブジェクトに拠らないところでオブジェクトアクセスのアトミック性を期待するのは間違いなのでアトミック性については言及すべきでないと思います。