🔍

c言語の printf("%x\n", (char)-1) の 出力

2021/04/29に公開
2

概要

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

printfの%x 指定子

printf の %x 指定子の説明を改めて確認する。
https://man7.org/linux/man-pages/man3/printf.3.html

       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型への変換

https://www.jpcert.or.jp/sc-rules/c-int02-c.html
jpcert に 型変換のページがあった。
こちらに下記の記載がある。[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に変換された値が載っていそうだ。

  1. b mainでmain関数にbreak pointを張る。
  2. runで開始し、nで逐次実行。
  3. i regでrbpの値を確認。
  4. `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でお試し。
下記の記事が大変参考になった。
https://qiita.com/tetsu_koba/items/9bdcb59f912efbff3128

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として解釈され、結果出力に差異が出るようです。

脚注
  1. この場合、hhを付与することで明示的にchar型であることを指定できる。これにより1byteの出力がなされる。printf("%hhx\n", (char)-1); ↩︎

  2. 大本はCの規格書の 6.3.1.3 Signed and unsigned integers に記載されてるらしい ↩︎

Discussion

yohhoyyohhoy

2つの環境 x86_64 と aarch64 との実行結果の違いは、C言語のchar型が符号付き/符号無しかという違いに起因しています。

C言語ではcharの符号付き有無は処理系定義(implementation-defined)項目となっており、各CPUアーキテクチャやABI(Application Binary Interface)仕様にて規定されます。

  • Intel x86_64環境:char型はデフォルトで符号付き(signed)と解釈されます。
  • ARM aarch64環境:char型はデフォルトで符号無し(unsigned)と解釈されます。

アセンブリ出力及び実行結果は https://godbolt.org/z/dxxG11M4e を参照にください。

  • GCC(x86_64)デフォルト動作:出力結果は ffffffff
  • GCC(x86_64)+unsigned明示指定:出力結果は ff
  • GCC(aarch64)デフォルト動作:出力結果は ff

printf内で、char型 から unsigned int 型への変換がなされていると思われる。

結論には直接影響しませんが、まずprintf関数の呼び出し側で「char型→int型への変換」が行われ、関数内部で「int型→unsinged int型への読み替え」となるはずです。printf関数パラメータの2個目以降は可変個数の仮引数並び(...)に対応させるため、実引数に対して整数拡張(integer promotion)が適用されます。

nokutenokute

アセンブラ付きで詳細な解説、ありがとうございます。勉強になります。