本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。
C言語のsigned/unsignedをアセンブラで見る|charの範囲と2の補数の仕組み | UNIX Cafe

この記事では、C言語の signed と unsigned の違い、signed char が -128 〜 127 になる理由、そして unsigned int に -1 を渡すと何が起きるのかを、実際のアセンブラ出力を見ながら確認します。
プログラミングの教科書を開くと、最初の方に必ず出てくる「データ型の一覧表」。 「signed char は1バイトの符号付き整数で、範囲は -128 〜 127」 そんな無機質な文字列を眺めていたとき、ふと、ある素朴な疑問が頭をよぎりました。
「-128 から 127 って……全部で255個じゃないの? 1バイトって256通りじゃなかったっけ?」
これが、私がコンピュータの舞台裏を覗き込む大冒険の始まりでした。
C言語のcharはなぜ-128〜127なのか
結論から言うと、-128 〜 127 をすべて数え上げると、0を含めてきっちり256個になります。
1バイト(8ビット)が持つ「256通りの部屋」は、1つも余ることなく使い切られているのです。
ただし厳密には、C言語の char そのものが符号付きになるか符号なしになるかは処理系によって異なります。数値として符号付きの1バイト整数を扱いたい場合は、この記事のように signed char と明示して考える方が安全です。
| 型 | 代表的な範囲 | 意味 |
|---|---|---|
signed char | -128 〜 127 | 負数も扱う1バイト整数 |
unsigned char | 0 〜 255 | 負数を扱わない1バイト整数 |
2の補数で見るsigned charの範囲
現在の多くの環境では、符号付き整数は2の補数という表現で扱われます。8ビットの signed char では、1番左のビットを負数側の重みとして解釈するため、表せる範囲は -128 から 127 になります。同じ8ビットでも、unsigned char として読むなら範囲は 0 から 255 です。
コンピューターは、私たち人間が「符号付き(signed)」というルールを選ぶか、「符号なし(unsigned)」というルールを選ぶかで、その256個の部屋の割り振りを変更します。
- 符号なし:
0からスタートして、純粋にプラス方向に256個の席を並べる(0 〜 255)。 - 符号付き: 256個の席をマイナスとプラスで半分ずつ(128個ずつ)分ける。
ここで「おや?」と思った方は鋭いです。半分にするなら正の数側も 128 まで行けそうなものですが、最大値は 127 になっています。理由は「ゼロ(0)」です。0は負の数ではないため、「0以上のグループ(定員128個)」の1つを使います。その結果、正の数として使える席は 1 から 127 までの127個になり、もう片方の128個が -1 から -128 までの負の数に割り当てられるのです。
つまり、同じ8ビットの並びでも、unsigned の世界では「これはすべて非負の整数として読む」というルールで解釈しているのです。
CPUはデータの正体を知らない
しかし、ここで一つ大きな疑問が浮かび上がります。
「CPUは、目の前にある 1 と 0 のデータが『255』なのか『-1』なのか、どうやって見分けているのだろう?」
実は、CPU自身は目の前のデータが正の数か負の数かを知りません。CPUにとっては、どちらもただの「電気のON/OFFの並び」に過ぎないのです。
それなのに、私たちがC言語で if (a > 0) と書いたとき、プログラムは迷うことなく正しいルートへ分岐します。データの中身を知らないはずのCPUが、なぜ人間の意図通りに「プラスかマイナスか」を判断できるのでしょうか。
その秘密こそが、C言語の「型(符号付きか、符号なしか)」というルールにあります。私たちが型を指定した瞬間に、コンパイラはCPUに対して「このデータはこう扱いなさい」という命令を送っているのです。
「暗黙の了解でCPUの動きが変わる」この言葉の意味を、ただの概念ではなく「事実」として私たちの目で確認するために、ここからはアセンブラという顕微鏡を覗き込んでみることにしましょう。
C言語のsignedとunsignedの違いはアセンブラでどう変わるのか
「暗黙の了解でCPUの動きが変わる」と言われても、いまいちピンとこないですよね。
そこで、C言語のコードを「機械語(0と1)に翻訳される直前の姿」であるアセンブラ(CPUへの生の命令テキスト)に出力させて、その舞台裏を覗いて見ることにしましょう。
この実験をあなたの手元で再現し、自分の目で確かめるための手順をまとめましたので、ターミナルを開いて、次の手順で進めてみてください。
手順1:検証用のC言語ファイルを作成する
実験に用意するのは、まったく同じ if (a > 0) という比較を行う2つの関数です。違いは引数の型が int(符号付き)か unsigned int(符号なし)か、だけのコードです。
最初に compare.c という名前のファイルを作成し、次のコードをそのまま保存してください。実行結果を確認できるように、テスト用のメイン処理(main 関数)も含めてあります。
#include <stdio.h>
// 符号付き(int)の比較関数
void check_signed(int a) {
if (a > 0) {
printf(" signed : %d は0より大きいです!\n", a);
} else {
printf(" signed : %d は0以下です。\n", a);
}
}
// 符号なし(unsigned int)の比較関数
void check_unsigned(unsigned int a) {
if (a > 0) {
printf(" unsigned: %u は0より大きいです!\n", a);
} else {
printf(" unsigned: %u は0以下です。\n", a);
}
}
int main() {
// テストデータとして「-1」を準備する
// 32ビットの2進数では 11111111 11111111 11111111 11111111
int test_val = -1;
printf("【実験開始】ビット列がすべて1のデータを渡してみる\n");
// 符号付きとして判断させる場合
check_signed(test_val);
// 符号なしとして判断させる場合
check_unsigned((unsigned int)test_val);
return 0;
}手順2:「魔法のコマンド」でアセンブラを出力する
ファイルが保存できたら、いよいよ魔法のコマンドを叩きます。ターミナルで大文字の -O1(最適化オプション)と -S を指定して実行しましょう。
gcc -S -O1 compare.c※ コマンド内の -O1(アルファベットの大文字のオー)は、余計なデバッグコードを取り除いてアセンブラをすっきり読みやすく最適化する呪文です。実行すると、同じ場所に compare.s というテキストファイルが生成されます。
手順3:顕微鏡のピントを合わせる(確認)
出力されたアセンブラ(compare.s)を vi などのエディタで開き、それぞれの関数ラベル(_check_signed や _check_unsigned)の直下を比べてみると、そこにはコンパイラの職人技がはっきりと刻まれています。(※環境によって細部が異なる場合があります)
; --- 符号付き(int)の比較の裏側 ---
add x9, x9, l_.str@PAGEOFF
cmp w0, #0
csel x8, x9, x8, gt ; 0より大きい(Greater Than)ならx9(正の数用)、それ以外ならx8(0以下用)の文字列を選択!
; --- 符号なし(unsigned int)の比較の裏側 ---
add x9, x9, l_.str.2@PAGEOFF
cmp w0, #0
csel x8, x9, x8, hi ; 0より大きい(Higher)ならx9(正の数用)、それ以外ならx8(0以下用)の文字列を選択!まったく同じ > 0 というC言語のコードを処理しているのに、コンパイラは条件選択命令の末尾を gt(Greater Than) と hi(Higher) という、全く異なる2つの「より大きい」に切り替えていたのです。
コンピューターにとって、この数文字の違いは決定的な意味を持ちます。CPUに対して「符号付きの世界の物差し(gt)で測れ」と命じるか、「符号なしの世界の物差し(hi)で測れ」と命じるか。コンパイラは型に合わせて、使う物差しをコントロールしているのです。
コンパイルして実行してみる
では、この物差しの違いがどんなドラマを生むのか、実際にプログラムを動かして確かめてみましょう。第1章の「1バイトの部屋」から、現代の一般的な標準サイズである「4バイト(32ビット)の広い部屋」に舞台を移して実験します。すべてのビットに 1 をぎっしり詰め込んだデータ(0xffffffff)をそれぞれの関数に放り込んでみます。
【実験開始】ビット列がすべて1のデータを渡してみる
signed : -1 は0以下です。
unsigned: 4294967295 は0より大きいです!画面に表示されたのは、正反対の判定です。
符号付きの物差し(gt)で測られたデータは、1番左のビットを「マイナス」のサインと見なし、1バイトの時と同じロジックで -1 と解釈されて「0以下」の判定を受けました。しかし、符号なしの物差し(hi)で測られたデータは、「この世にマイナスなんてない!」とばかりに、同じビット列を 42億9496万7295 という巨大なプラスの数として扱い、「0より大きい!」と条件を突破してしまったのです。
「CPUはデータの正体を知らない」からこそ、コンパイラがアセンブラに仕込んだたった2文字の物差しの切り替え(gt と hi)が、デジタル世界の法則を一変させてしまう。型という名の暗黙の了解が持つ本当の意味を、確認することができました。
Mac ARM64で手書きアセンブラを動かす
アセンブラの面白さに気づいた私は、「C言語を仲介させず、最初からテキストエディタでアセンブラを書いてコンパイルする方法」を探してみました。
普段私たちが使っている gcc や clang というコマンドは、「C言語 ➔ アセンブラ ➔ 機械語」という重い文法チェックや翻訳作業を行っています。
しかし、最初から .s ファイルを作成すれば、コンパイラは「翻訳」のプロセスをスキップして、コードを機械語に変換するだけの単純作業(アセンブルとリンク)を行います。
さっそく vi を開き、Macの(ARM64 / Apple Silicon)で動作する最小限の手書きアセンブラをゼロから打ち込んでみました。
.section __TEXT,__text,regular,pure_instructions
.globl _main
.p2align 2
_main:
; --- 舞台の準備(Macの決まり文句) ---
sub sp, sp, #32
stp x29, x30, [sp, #16]
add x29, sp, #16
; --- printfに渡す引数のセット(x0に文字列の住所を入れる) ---
adrp x0, l_.str@PAGE
add x0, x0, l_.str@PAGEOFF
bl _printf
; --- 終了処理(return 0; の再現) ---
mov w0, #0 ; 戻り値「0」を w0 レジスタにセット
ldp x29, x30, [sp, #16]
add sp, sp, #32
ret
.section __TEXT,__cstring,cstring_literals
l_.str:
.asciz "Hello, Handcrafted Assembler World!\n"このコードを my_printf.s という名前でファイルに保存して、コンパイルします。
clang -o my_printf my_printf.sコンパイルされた、my_printf を実行します。
./my_printf表示されたメッセージです。アセンブラでコードを作成し、実行することができました。
Hello, Handcrafted Assembler World!OSのルール(規約)と繋がる瞬間
アセンブラの海の最下層(main関数の終わり際)を眺めていると、最後にこんな2行が並んでいるのを見つけることができます。
mov w0, #0 ; =0x0
retC言語のソースコードで最後に書いた return 0; が翻訳された姿です。このシンプルな処理の裏側では、人間が定めた「OSとプログラムの間のコンセント(規約)」がカチッと繋がる、とても美しいバトンリレーが行われています。
なぜ「mov」した値が「echo $?」で取得できるのか
そのバトンが届くまでのステップを、少し細かく追ってみましょう。
1. レジスタへ値を置く
手書きしたコードの終了処理にある mov w0, #0 という命令。これは、CPUの内部にある w0 という名前のデータ置き場(レジスタ)に、0 という数字を直接書き込む命令です。
レジスタはCPUのまさに手元(内部)に存在するため、この書き込み処理は一瞬で完了します。
2. OSへ処理を引き渡す
続く ret(リターン)が実行されると、プログラムはすべての処理を終えて、コンピューターの管理権を OS へと戻します。
このとき、OSには「終了ステータスは、CPUの w0 レジスタに入れて戻さなければならない」という厳密な呼び出し規約があるので、レジスタに置いた 0 は、プログラムが正常に終了した報告として、OSへそのまま引き渡されることになります。
3. ターミナル(シェル)が数字を受け取る
プログラムが終了した直後、OSは w0 レジスタから回収した 0 という数字を、プログラムの呼び出し元であるシェル(zshなど)へと引き渡します。
シェルはこの数字を受け取ると、自分が管理している ? という名前の特殊な変数(隠しポケット) の中に、その 0 を格納するのです。
ターミナルで確認する
この一連のデータ移動の結果は、ターミナルからいつでも呼び出して確認することができます。それが、次のコマンドです。
echo $?
0画面に表示された 0 という数字。これこそが、アセンブラで mov w0, #0 と打ち込み、CPUのシリコン半導体の上にある超高速なスイッチ(レジスタ)へ、直接刻み込んだ、あの「0」そのものです。
高級言語(C言語)の下に隠された生身のコードが、OSの規約というルートを通じて、手元のターミナルに届いている。コンピューターの最深部の動きを目にする瞬間です。
xxdで実行ファイルのバイナリを読む
アセンブラの旅の最終章は、出来上がった実行ファイル、マシン語の正体を確かめることです。
完成したバイナリをそのまま vi や less で開くと、画面は暗号のような記号と「文字化け」で埋め尽くされてしまいます。コンピュータが放つ生の 0 と 1 の羅列を、エディタが無理やり文字に翻訳しようとして悲鳴を上げているのです。
1 Ïúíþ^L^@^@^A^@^@^@^@^B^@^@^@^Q^@^@^@Ð^C^@^@<85>^@ ...そこで、 xxd コマンドを実行して、バイナリデータを16進数のHEX ダンプして出力してみます。
xxd my_printf | lessファイルに保存したい場合は、リダイレクトでファイル名を指定してください。
xxd my_printf > xxd.txt実際に出力された結果です。
00000000: cffa edfe 0c00 0001 0000 0000 0200 0000 ................
00000010: 1100 0000 d003 0000 8500 2000 0000 0000 .......... .....
00000020: 1900 0000 4800 0000 5f5f 5041 4745 5a45 ....H...__PAGEZE
00000030: 524f 0000 0000 0000 0000 0000 0000 0000 RO..............
00000040: 0000 0000 0100 0000 0000 0000 0000 0000 ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000060: 0000 0000 0000 0000 1900 0000 3801 0000 ............8...
00000070: 5f5f 5445 5854 0000 0000 0000 0000 0000 __TEXT..........
00000080: 0000 0000 0100 0000 0040 0000 0000 0000 .........@......
00000090: 0000 0000 0000 0000 0040 0000 0000 0000 .........@......
000000a0: 0500 0000 0500 0000 0300 0000 0000 0000 ................
000000b0: 5f5f 7465 7874 0000 0000 0000 0000 0000 __text..........
000000c0: 5f5f 5445 5854 0000 0000 0000 0000 0000 __TEXT..........
000000d0: 1004 0000 0100 0000 2800 0000 0000 0000 ........(.......
000000e0: 1004 0000 0200 0000 0000 0000 0000 0000 ................
000000f0: 0004 0080 0000 0000 0000 0000 0000 0000 ................
00000100: 5f5f 7374 7562 7300 0000 0000 0000 0000 __stubs.........この表を眺めていると、 左端に並ぶメモリの住所(アドレス)が、00000000 ➔ 00000010 ➔ 00000020 と、16進数の 10(10進数でいう16)ずつ増えていることに気づきます。
これは、この表が「実行ファイルという長いデータ列を、1行に16バイトずつ折り返して並べたもの」であることを意味しています。
この縦横のグリッド(住所)の仕組みが分かれば、右側のテキスト表示エリアと中央の16進数のデータを照らし合わせながら、どのバイトがどの文字として表示されているのかを追っていくことができます。
3行目からASCIIコードを読んでみる
48は大文字のH5fはアンダースコア_504147455aはそれぞれPAGEZ746578は小文字のtex5458は大文字のTX737562は小文字のsub
右側のテキスト表示エリアと中央の16進数のデータが、どうやって結びついているのか、その仕組みを自分の手で解明することができました。このパズルが綺麗に解けていく感覚が、低レイヤハックの一番の醍醐味ですね。
💡 ここでさらにニヤリとできる「文字コードの秘密」
先ほどの文字コードには、コンピュータの歴史に裏付けされた「とっておきの美学」が隠されています。
大文字の __TEXT と、小文字の __text のコードを見比べてみてください。
- 大文字
T➔54 - 小文字
t➔74 - 大文字
X➔58 - 小文字
x➔78
気づきましたか?大文字と小文字で、左側の桁(16進数の上の桁)が 5 と 7 で綺麗に 20(10進数で32)だけズレているのです。
実は、ASCIIのアルファベットの「大文字」と「小文字」は、この規則性(2進数で見るとちょうど1ビットだけが違う状態)で並んでいます。だからコンピュータは、大文字を小文字に変換するときは 0x20 を足し、小文字を大文字に戻すときは 0x20 を引く、という単純な操作で変換できるのです。
結び:コンピュータの舞台裏を旅して
最初は「データ型の符号ってなんだろう?」という、教科書の片隅の素朴な疑問から始まったこの旅は、一歩足を踏み出せば、C言語の裏側に現れるアセンブラを読み、エディタでCPUのレジスタを直接扱うコードを書き、最後は実行ファイルを16進数でダンプして読むところまで辿り着きました。
普段私たちが何気なく書いているC言語の綺麗なコードの裏側には、こんなにも美しく、そして精密に組み立てられた「マシン語の世界」が広がっています。
ここまでの旅を終え、静まり返ったターミナルでもう一度 echo $? を叩いてみると、画面にぽつんと表示される 0 という素っ気ない数字が、C言語、アセンブラ、OS、シェルをつなぐ小さなサインに見えてきます。
