c言語の printf("%x\n", (char)-1) の 出力
概要
printf("%x\n", (char) -1);
というコードの出力結果で悩んだので、調査した結果をまとめる。
x86_64での結果とaarch64での結果は異なることがわかり、x86_64の方は説明できそうだが、aarch64の結果はよくわからなかった。別途深追いしたい。
Ubuntu20.04.1 (x86_64)の出力結果
下記のようなコードをコンパイルし、実行した。
#include <stdio.h>
int main(void) {
printf("%x\n", (char)-1);
return 0;
}
結果は下記の通り。char型だけど4byte分のffffffff
が出力される。
$ gcc a.c
$ ./a.out
ffffffff
%x
指定子
printfのprintf の %x
指定子の説明を改めて確認する。
o, u, x, X
The unsigned int argument is converted to unsigned octal
(o), unsigned decimal (u), or unsigned hexadecimal (x and
X) notation.
頭に書かれているように、%x
は、unsigned int 型が引数として与えられることが期待されている。[1]
ということで、printf内で、char型 から unsigned int 型への変換がなされていると思われる。
char型 から unsigned int型への変換
こちらに下記の記載がある。[2]
新しい型で表現できない場合、新しい型が符号無し整数型であれば、
新しい型で表現しうる最大の数に1加えた数を加えることまたは減じることを、
新しい型の範囲に入るまで繰り返すことによって得られる値に変換する。
符号ありの-1 から符号無し整数型への変換のため、-1に対してunsigned int の最大値 + 1 を足した結果、最終的にunsigned int の最大値(=0xffffffff)になるようだ。
アセンブラで見る char型 から unsigned int型への変換
下記のようなコードをコンパイルし、アセンブラを確認する。
int main(void) {
char a = -1;
unsigned int b = (unsigned int)a;
}
gccのバージョンは下記。
$ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
これをobjdump -d して、main関数を抜粋したものが下記。
0000000000001129 <main>:
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: c6 45 fb ff movb $0xff,-0x5(%rbp)
1135: 0f be 45 fb movsbl -0x5(%rbp),%eax
1139: 89 45 fc mov %eax,-0x4(%rbp)
113c: b8 00 00 00 00 mov $0x0,%eax
1141: 5d pop %rbp
1142: c3 retq
1143: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
114a: 00 00 00
114d: 0f 1f 00 nopl (%rax)
gdb で見てみる。charの-1は0xffの直値がメモリ上のアドレス-0x5(%rbp)に、最終的に-0x4(%rbp)にunsigned intに変換された値が載っていそうだ。
-
b main
でmain関数にbreak pointを張る。 -
run
で開始し、n
で逐次実行。 -
i reg
でrbpの値を確認。 - `x/4xb (rbp-4)でメモリをダンプ。期待値は0xffffffff。
$ gcc -g a.c
$ gdb a.out
(中略)
(gdb) b main
Breakpoint 1 at 0x1129: file a.c, line 1.
(gdb) run
Starting program: /home/taka/a.out
Breakpoint 1, main () at a.c:1
1 int main(void) {
(gdb) n
2 char a = -1;
(gdb) n
3 unsigned int b = (unsigned int)a;
(gdb) n
4 }
(gdb) i reg
rax 0x0 0
rbx 0x555555555150 93824992235856
rcx 0x555555555150 93824992235856
rdx 0x7fffffffe018 140737488347160
rsi 0x7fffffffe008 140737488347144
rdi 0x1 1
rbp 0x7fffffffdf10 0x7fffffffdf10
rsp 0x7fffffffdf10 0x7fffffffdf10
r8 0x0 0
r9 0x7ffff7fe0e10 140737354010128
r10 0x7ffff7ffcf78 140737354125176
r11 0x206 518
r12 0x555555555040 93824992235584
r13 0x7fffffffe000 140737488347136
r14 0x0 0
r15 0x0 0
rip 0x555555555141 0x555555555141 <main+24>
eflags 0x246 [ PF ZF IF ]
cs 0x33 51
ss 0x2b 43
ds 0x0 0
es 0x0 0
fs 0x0 0
--Type <RET> for more, q to quit, c to continue without paging--
gs 0x0 0
(gdb) x/4xb 0x7fffffffdf0c
0x7fffffffdf0c: 0xff 0xff 0xff 0xff
unsigned int b = (unsigned int)a;
の結果が 0xffffffff となっていることがわかった。
aarch64編
わけあって、これのaarch64版を見る必要があった。aarch64のマシンは持ってないのでqemuでお試し。
下記の記事が大変参考になった。
qemu環境構築
sudo apt install g++-aarch64-linux-gnu qemu-user-binfmt
スタティックビルド
最初に記載の下記コードをaarch64用にクロスコンパイルする。
#include <stdio.h>
int main(void) {
printf("%x\n", (char)-1);
return 0;
}
簡単のためにスタティックリンクして単体で動作可能なようにコンパイルする。
$ aarch64-linux-gnu-gcc -o a.out --static a.c
$ file a.out
a.out: ELF 64-bit LSB executable, ARM aarch64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=7c4a8e0e41744f1a5302fff86e929172b253d225, for GNU/Linux 3.7.0, not stripped
出力結果(aarch64)
下記のように0xffが出力された。なんで。。。
$ ./a.out
ff
aarch64用のobjdumpを取得し、x86_64版と同様にアセンブラを眺めたいが、今日はここまで。
追記(2022/12/04)
@yohhoy さんよりコメントで詳細いただきました。ありがとうございます。
C言語のchar型が符号付き/符号無しかという違いに起因しており、これがx86_64 と aarch64で異なるためとのことでした。aarch64の場合は、charはunsigned charとして解釈され、結果出力に差異が出るようです。
Discussion
2つの環境 x86_64 と aarch64 との実行結果の違いは、C言語の
char
型が符号付き/符号無しかという違いに起因しています。C言語では
char
の符号付き有無は処理系定義(implementation-defined)項目となっており、各CPUアーキテクチャやABI(Application Binary Interface)仕様にて規定されます。char
型はデフォルトで符号付き(signed)と解釈されます。char
型はデフォルトで符号無し(unsigned)と解釈されます。アセンブリ出力及び実行結果は https://godbolt.org/z/dxxG11M4e を参照にください。
ffffffff
ff
ff
結論には直接影響しませんが、まず
printf
関数の呼び出し側で「char
型→int
型への変換」が行われ、関数内部で「int
型→unsinged int
型への読み替え」となるはずです。printf
関数パラメータの2個目以降は可変個数の仮引数並び(...
)に対応させるため、実引数に対して整数拡張(integer promotion)が適用されます。アセンブラ付きで詳細な解説、ありがとうございます。勉強になります。