Mac printf处理参数的奇特之处(macOS中,printf使用%d输出一个浮点数会发生什么情况?)

今天早上网上冲浪的时候看到了 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()便是我们实现的printffoo("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区域。
所以现在变量从高到低是ab

这部分还和函数调用有关,包括调用函数就会给它在这创建一个帧,然后存放返回地址、参数,以及局部变量。当然帧也包括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 系列特性更加多,寄存器叫YMMZMM,尺寸甚至能到 512 位,这些指令集主要负责 SIMD 并行计算(SIMD 的另一种实现就是 GPU)。

在有 YMM 的设备上,YMM 的低 128 位就是 XMM。

SIMD 就是可以对多对数据进行同一种计算,然后得到结果。但这只需要一条指令,而不是每一对一条指令,这大大提高了性能,所以 XMM 和 YMM 的寄存器是可以分成多块的。

请添加图片描述

Clang 编译器在声明浮点数的时候,直接将其放到 XMM 寄存器里了:

请添加图片描述

这里后面的注释表示:放到xmm0寄存器里了,前面是放的位置,后面全是0

不同的是如果是个浮点数组,会先放到内存中(应该是方便多个数组并行计算):
请添加图片描述

作为对比,单个整数int甚至不用放内存里,直接给值:

请添加图片描述

研究了一下发现这其实和printf获取参数的方法有关系。当然也需要你知道浮点数和整数格式上有什么区别。这部分请见《IEEE 754浮点数构成与转换》《原码、补码、反码、移码是什么?》

printf压栈的时候,帧指针寄存器EBPESP会指向当前帧的栈底和栈顶。

写个获取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

参考资源链接:[苹果电脑与Windows忘记开机密码解决方案](https://wenku.csdn.net/doc/3z0jy8ehx6?utm_source=wenku_answer2doc_content) 如果您不幸忘记了MacOS系统的开机密码,但又没有安装介质如系统恢复盘,您仍然有方法可以重置密码。首先,需要了解的是,这种方法涉及到操作系统级别的操作,如果您不熟悉命令行操作,请谨慎尝试或寻求专业技术支持。以下是详细的步骤: 1. 关闭Mac电脑。 2. 开机的同时,按住`Command+R`键,直至看到苹果标志或旋转地球图标,此时进入Mac的恢复模式。 3. 在菜单栏选择“实用工具”>“终端”。 4. 在终端输入`sodu resetpassword`命令并回车。请注意,从MacOS Sierra开始,必须使用`sodu`命令来获取管理员权限。 5. 在弹出的“重设密码”窗口,选择忘记密码的用户账户。 6. 输入并确认新密码,然后点击“重设密码”按钮。 7. 退出终端,并重启电脑。 8. 使用新设置的密码登录您的Mac。 在进行这些操作时,确保您的Mac已连接到互联网,并且Apple ID账户已与您的Mac关联。如果您之前设置了Apple ID的双重认证,那么重设密码时系统要求输入Apple ID的密码或使用双重认证来进行验证。这个过程不需要使用安装介质,但是需要通过网络连接和Apple ID来验证您的身份。重置密码后,建议您尽快登录到iCloud,并使用“查找我的Mac”功能,以便在未来可以远程锁定或擦除您的设备,以保护数据安全。 参考资源链接:[苹果电脑与Windows忘记开机密码解决方案](https://wenku.csdn.net/doc/3z0jy8ehx6?utm_source=wenku_answer2doc_content)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值