今天早上网上冲浪的时候看到了 2016 年的一篇文章,里面提到了一段代码:
#include <stdio.h>
int main() {
double a = 10;
printf("a = %d\n", a);
return 0;
}
说这段代码在 x86(IA-32)上运行时,输出为0
;在 x86-64 上运行时,每次输出是一个不同的数。
试了一下,确实是这样的:
zhonguncle@ZhongUncle-Mac-mini test % ./a.out
a = -1194089144
zhonguncle@ZhongUncle-Mac-mini test % ./a.out
a = -1094355640
然后我就非常好奇为什么?因为0
很好理解,但是 64 位为什么是不同的数呢?
其实盲猜都能猜个大概,和地址有关呗(一般冒出来奇奇怪怪数都是和地址有关,“跑错地方了”),但是盲猜很容易翻车,还是要验证一下。先看看网上有没有人研究过。
后来查了一下发现,这个问题的原型最早能追溯到近 20 年前,不过那时候由于都是 32 位机器,所以还好。但是 08 年前后,64 位机器开始普及之后,这个问题升级了,就是又了后半部分。
由于早些年国内还没有发展起来,加上微软当时巨大的影响力,所以那时候国内大部分的研究博客和记录都是 Windows 上的,其中有很多非常不错的内容我会放到最后的“参考/扩展资料”中,感兴趣的小伙伴可以看看。
国外的话虽然有 macOS 和 Linux 的,但是相对来说没有那么深入。刚好缺我要的,就自己动手研究一下吧。
本文使用 Intel 的 Mac 进行说明,Linux 上的原因相似,其实本质上和 Windows 的原因都差不多,但是略有不同。
为什么x86-32返回0(IA-32)
IA-32 architecture is the instruction set architecture and programming environment for Intel’s 32-bit microprocessors.
printf
使用%d
就是获取这部分内存栈的对应 2 字节。
所以 32 位的问题很好理解,32 位机器上,int
一般为 2 字节,double
是 4 字节的,机器的寄存器最大也就 32 位,
由于浮点数10
的十六进制进制为00 00 24 40
,所以入栈是顺序是40-24-00-00
,最后栈顶为00
,那么printf
获取%d
的时候,出栈 2 字节,就是 4 个十六进制数,也就是00-00
,也就是0
。
所以如果用以下方式输出:
double int a = 10;
printf("%d %d",a,a);
你会发现第二个输出的就不是0
了。因为此时栈里还有一个,而这个就是10
。
macOS 现在不支持 32 位,但是 64 位上同样也会出现类似的情况,因为这不是简单的溢出。
printf是怎么工作的
在进入 64 位这个问题之前,需要了解一下printf
是如何工作的。因为这个问题的具体原因与printf
的实现方法有关系。(其实 32 位的你看到了,也有关系,但是不需要深入去看)
对于 Linux 和 macOS,可以使用man 3 printf
查看库函数的手册,会发现都有这么一句话讲结构的:
简而言之就是:函数由格式字符串和stdarg
库函数实现。
stdarg
手册最后给了一个案例,就可以实现一个简化版的printf
(多看手册就是有好处):
#include <stdio.h>
#include <stdarg.h>
void foo(char *fmt, ...) {
va_list ap, ap2;
int d;
char c, *s;
va_start(ap, fmt);
va_copy(ap2, ap);
while (*fmt) {
switch (*fmt++) {
case 's': // string
s = va_arg(ap, char *);
printf("string %s\n", s);
break;
case 'd': // int
d = va_arg(ap, int);
printf("int %d\n", d);
break;
case 'c': // char
c = va_arg(ap, int);
printf("char %c\n", c);
break;
}
}
va_end(ap);
while (*fmt) {
switch (*fmt++) {
case 's':
s = va_arg(ap2, char *);
break;
case 'd':
d = va_arg(ap2, int);
break;
case 'c':
c = va_arg(ap2, int);
break;
}
}
va_end(ap2);
}
int main() {
double a=10;
foo("sdc", "Today", a, 'C');
return 0;
}
这里的foo()
便是我们实现的printf
,foo("sdc", "Today", a, 'C');
中,第一个参数“sdc”
就是格式化字符串, "Today"
对应s
字符串,后面对应。
不过我们要看类型和对应的值,所以输出如下:
string Today
int 10
char C
可以看到值和顺序都是格式化字符串规定好的。
实现这个是为了搞清楚printf
是如何运行的。上面这个程序可以发现是通过格式化字符串自增,然后对应合适的参数,再输出。
printf
的模式字符串要复杂的多,所以我们就可以使用一个栈,从右到左压入其余参数,然后过模式字符串的时候,从栈里弹出参数对应。
这正是printf
的工作方式。所以我们可以利用这个类似的做实验,发现为什么!
LLDB 和其他调试手段动不了printf
,我还动不了自己写的代码了嘛。
我们看代码的时候可以注意到:va_arg(ap2, int);
的第二个参数决定了参数的类型,也就是说如果我们输入一个其他格式的值,会发生一些未定义的事情。(因为我们没有写判断语句)
那么会发生什么呢?试试看:
把那个整数改成浮点数:
foo("sdc", "Today", 1.2, 'C');
输出:
string Today
int 67
char X
测试发现无论改成什么浮点数,都是int 67 char X
。
是溢出嘛?试试看改成很大的整数:
foo("sdc", "Today", 11231121212132, 'C');
输出:
string Today
int -218266908
char C
会发现哪怕溢出了char C
也没变。也就是说,对于浮点数溢出可能对范围外部分造成影响,而整数溢出并没有对外面造成影响。
这其实是va_arg
干的,va_arg
的第一个参数是变量参数列表,第二个是类型,也就是说明如何处理这些变量。可以看到上面写的是int
,我们将其改成double
case 'd': // int
d = va_arg(ap, double);
printf("%x\n",ap);
printf("int %d\n", d);
printf("%p\n",ap);
break;
会发现输出居然对了:
string Today
int 10
char C
手册中有这样一段话:如果没有下一个参数,或者类型和下一个参数不匹配(自提升后),那么会发生随机错误:
果然是它,效果都一样。
自提升就是说,把变成范围更大的类型,然后进行操作,操作完再变回去。比如64位的float
会先变成double
,再进行操作。int
会先被当作unsigned
无符号数,然后进行操作。
64 位
Intel® 64 architecture is the instruction set architecture and programming environment which is the superset of Intel’s 32-bit and 64-bit architectures. It is compatible with the IA-32 architecture.
现在要解决真正的问题了,为什么 64 位就是一个奇奇怪怪的数了呢?
这里把那段代码稍微改一下:
#include <stdio.h>
int main(void)
{
double a = 10;
int b = 20;
printf("%d %d\n", a, b);
return 0;
}
你猜猜看,这个代码输出的情况是什么样的?
如下:
20 -1133869736
我没打反,就是这样的输出(只有 Mac 是这样,Linux 不会这样)。为什么b
的值输出到前面去了呢?
这里你需要了解程序的内存布局是什么样的(其实上一节就要理解,但是不了解也行,这里是逃不掉了),现在程序运行的时候,内存布局大致如下:
我们声明局部变量的时候,就会存放在在stack区域。
所以现在变量从高到低是a
和b
。
这部分还和函数调用有关,包括调用函数就会给它在这创建一个帧,然后存放返回地址、参数,以及局部变量。当然帧也包括main
函数了。
需要注意一点,它增长的时候是从高地址到低地址,所以你会发现汇编代码中,都是减法:
subq $16, %rsp
movl $0, -4(%rbp)
movl $10, -8(%rbp)
movl -8(%rbp), %esi
leaq L_.str(%rip), %rdi
movb $0, %al
callq _printf
x86-64 的情况有点复杂,因为 32 位的寄存器、内存大小都差不多,各种处理、转换、移动也比较简单。对于早期 16 位的数据来说,一些 32 位的寄存器也就是能分两个部分,分别存储两个 16 位的数据。
Intel 后来搞了一个 MMX(整数),让 x86 的 CPU 多了一种名为 64 位的XMM
的寄存器,后来又搞出 SSE(浮点)指令集系列,在XMM
寄存器的尺寸变成了 128 位,现在搞得 AVX 系列特性更加多,寄存器叫YMM
和ZMM
,尺寸甚至能到 512 位,这些指令集主要负责 SIMD 并行计算(SIMD 的另一种实现就是 GPU)。
在有 YMM 的设备上,YMM 的低 128 位就是 XMM。
SIMD 就是可以对多对数据进行同一种计算,然后得到结果。但这只需要一条指令,而不是每一对一条指令,这大大提高了性能,所以 XMM 和 YMM 的寄存器是可以分成多块的。
Clang 编译器在声明浮点数的时候,直接将其放到 XMM 寄存器里了:
这里后面的注释表示:放到xmm0
寄存器里了,前面是放的位置,后面全是0
。
不同的是如果是个浮点数组,会先放到内存中(应该是方便多个数组并行计算):
作为对比,单个整数int
甚至不用放内存里,直接给值:
研究了一下发现这其实和printf
获取参数的方法有关系。当然也需要你知道浮点数和整数格式上有什么区别。这部分请见《IEEE 754浮点数构成与转换》和《原码、补码、反码、移码是什么?》。
printf
压栈的时候,帧指针寄存器EBP
和ESP
会指向当前帧的栈底和栈顶。
写个获取EBP
的函数:
void printEBP(){
unsigned long ebp;
asm("mov %%rbp, %0" : "=r" (ebp));
printf("EBP: %lx\n", ebp);
}
不论你是在我们自己写的printf
(要在函数里使用,不能在前后,不然帧不同了)还是开头示例中,使用都会发现以下的现象:
#include <stdio.h>
int main() {
double a = 10;
printf(" %p\n", (void*)&a);
unsigned long ebp;
asm("mov %%rbp, %0" : "=r" (ebp));
printf("EBP: %lx\n", ebp);
return 0;
}
输出如下:
0x7ff7bfeff310
EBP: 7ff7bfeff320
你会发现除了最后两位之外,奇怪的值和EBP
存放的地址都是一样的。变量a
就在前帧指针(EBP
寄存器指向的就是这个地方)下面:
这里需要强调一点,局部变量的顺序、区域的实际大小由编译器决定,不应该假定就在这里,但是结构是不会变的。因为很多编译器为了防止缓冲区溢出攻击,对栈的地址有随机化,比如在栈里再放一个指针,指向真正的位置。如下可以看到地址不在帧的范围内:
EBP: 7ff7bfeff320
0x7ff84f1e86c0
ESP: 7ff7bfeff300
所以会出现多次结果一样的,尤其是 Xcode 运行可能是防止挪来挪去出现问题,短时间内多次运行,内存地址可能根本不变。但是这不表示永远一样。
现在的情况就很复杂,我们并不知道编译器最后把局部变量放哪去了,以及如何处理。就假设我们能一下找到真正的位置,只考虑这种情况。
那么你可以使用一个指针来获取a
的地址,然后再获取值,会发现这时候操作和 32 位的操作一模一样了。
#include <stdio.h>
int main() {
double a = 10;
printf(" %x\n", &a);
unsigned long ebp;
asm("mov %%rbp, %0" : "=r" (ebp));
printf("EBP: %lx\n", ebp);
int *p=&a;
printf(" %x\n", p);
unsigned long esp;
asm("mov %%rsp, %0" : "=r" (esp));
printf("ESP: %lx\n", esp);
return 0;
}
输出:
be2042f0
EBP: 7ff7be204300
b1e2042f0
ESP: 7ff7be2042d0
而指针p
存放的值,或者说变量a
的地址,就是那个奇怪的数字。你可以试试看*p
(也就是a
)的输出和 32 位一样,为0
。
此外,如果你把浮点数改成浮点指针,那么就可以完美转换:
#include <stdio.h>
int main() {
double *a = 10;
printf("a = %d\n", a);
return 0;
}
输出
a = 10
我没法获得特别精确的答案,也就是具体每一步发生了什么,苹果没有公开 ABI 和具体实现的文档。这也是为什么很多人用一句“未定义行为”表达这个,因为没文档。
所以我猜具体实现中,有一些指针的跳转插在不同的局部变量之间。因为我发现,声明一个指针,指针的位置和它指向的地址是紧挨着的(其实我怀疑的原因是栈帧的范围内有空的地方,比如变量与栈顶和栈底都有两个字节左右的空白,可能就是个地址),可能是为了方便对齐?
所以有些地方的地址长度可能也不一样(测试过程中确实出现了这种情况)。甚至我怀疑不同类型变量放的具体地址区域都可能不太一样。
而这导致直接按int
地址获取内容的时候,很可能会出现按一个较短地址+后面的内容
或者较长地址截取
,这肯定会出错。
也就是说,64 位的时候并不是内容格式不对的问题,反而是有一定的识别和转换手段,出问题的是地址。
指针的话就可以保证得到的一定是double
的地址。所以在测试中发现,简单的用指针居然就解决了这个问题。
此外可以注意到一点:某些时候返回的地址都很统一,都是0x120a8
,这可能是什么特别的内容,不过无从查找。
我在测试中,手动把
xmm0
低位设置为0,返回的内容依旧是这个0x120a8
,所以才猜测0x120a8
是个恢复地址,发生错误到这里之后就会返回到下一个可以运行的位置。
也就是说,printf
在处理两个参数的时候,可能是因为第一个不匹配,就放到后面处理,所以先处理了第二个,也就是第一个输出第二个参数,发现格式匹配,就正确输出了。处理完之后,继续处理第二个参数,然后继续发生之前的事情。
所以我推测 Mac 的printf
会逐个对比参数和类型是否匹配,不匹配就先下一个,直到匹配好了为止。为什么这么猜呢,修改一下程序你就知道了:
#include <stdio.h>
int main(void)
{
double a = 10;
int b = 20;
printf("%d %f\n", a, b);
return 0;
}
我们将第二个参数的格式设置为浮点数,但是对应的参数是整数。这时候输出结果是反着的:
20 10.000000
虽然对,但是是反的,很神奇是不是。
这也是我为什么觉得 Mac 这里一定“有问题”的原因,因为 Linux 上这些行为直接就是一些随机的数,并不会出现这种“有迹可循”的现象,着实是把我好奇心勾引起来了。
希望能帮到有需要的人~
参考/扩展资料
How does this program work? - Stack Overflow:这个帖子虽然是 2010 年的,本身的问题却和本文是一样的,也含 64 位的。第二高赞回答是对我帮助很大的,本文中很多例子都参考了他的,比如最后反转的那个操作,但可惜的是,Alok Singhal并没有深入研究,思路也不太对。他和我中间阶段猜的一样:可能是寄存器不同的原因),但是最后看汇编发现并不是这个原因,不过反转的那个例子为我最后的猜测提供了帮助,没有那个例子我也想不到可能还有个处理的部分。其实另外一个帖子也猜测是寄存器的问题,所以我在中间提及了一下xmm
寄存器,但 Mac 上似乎和这个关系不大?
How does printf handle its arguments? - Stack Overflow:这个帖子列出了一些printf
的实现和方法,虽然我文中没有使用,但还是有一些帮助的。
Passing Parameters to printf - Halo Linux Services:这篇营销文章讲述了printf
是如何获取参数的。
Stack and Frames Demystified CSCI如果你对操作系统不熟悉的话,帧栈部分可以看这个 PPT