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)

知道printf是如何工作了之后,先从简单的开始。

使用%d就是获取这部分内存栈的对应 2 字节。

IA-32 architecture is the instruction set architecture and programming environment for Intel’s 32-bit microprocessors.

前者很好理解,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是怎么工作的

其实这个问题的具体原因与printf的实现方法有关系。

对于 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':  // strings = va_arg(ap, char *);printf("string %s\n", s);break;case 'd':  // intd = va_arg(ap, int);printf("int %d\n", d);break;case 'c':  // charc = 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

可以看到值和顺序都是格式化字符串规定好的。

这个stdarg和 Table 其实蛮重要的,可以用来实现编译器。

实现这个是为了搞清楚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':  // intd = 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: 7ff7bfeff3200x7ff84f1e86c0
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: 7ff7be204300b1e2042f0
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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://xiahunao.cn/news/3267640.html

如若内容造成侵权/违法违规/事实不符,请联系瞎胡闹网进行投诉反馈,一经查实,立即删除!

相关文章

Java语言程序设计——篇八(1)

&#x1f33f;&#x1f33f;&#x1f33f;跟随博主脚步&#xff0c;从这里开始→博主主页&#x1f33f;&#x1f33f;&#x1f33f; Java常用核心类 主要内容Object: 终极父类toString( )方法equals( )方法getClass( )方法hashCode( )方法clone( )方法finalize( )方法实战演练 …

c语言之给三个数字排大小

写代码将三个整数数按从大到小输出。 例如&#xff1a; 输入&#xff1a;2 3 1 输出&#xff1a;3 2 1 首先三个整数从大到小排&#xff0c;先创建三个变量 输入数字大小 通过冒泡排序派大小最后在输出出来。 简单介绍一下冒泡排序&#xff0c;后期在完整的写出来 冒泡排…

文件上传总结

一、原理 通过界面上的上传功能上传了一个可执行的脚本文件&#xff0c;而WEB端的系统并未对其进行检测或者检测的逻辑做的不够好&#xff0c;使得恶意用户可以通过文件中上传的一句话木马获得操控权 二、绕过方法 1>前端绕过 1.删除前端校验函数 checkFile() 2.禁用js…

华为Ascend C算子开发(中级)考试

华为Ascend C算子开发&#xff08;中级&#xff09;考试题 提示&#xff1a;这个是河北廊坊Ascend C算子开发考试题和答案&#xff0c;仅供参考&#xff0c;因为不确定其他城市的考试题是否也是一样 文章目录 华为Ascend C算子开发&#xff08;中级&#xff09;考试题一、op_ho…

捉虫笔记(1)之 WinDbg符号配置

WinDbg符号配置 1、WinDbg简单介绍 WinDbg 是微软的一款强大的调试工具&#xff0c;用于 Windows 平台的内核和用户模式调试。它提供了一系列强大的功能&#xff0c;包括内存和寄存器的查看、断点设置、堆栈跟踪、性能分析等。 WinDbg 的历史可以追溯到微软早期的调试工具&a…

最新风车IM即时聊天源码及完整视频教程2024年7月版

堡塔面板 试验性Centos/Ubuntu/Debian安装命令 独立运行环境&#xff08;py3.7&#xff09; 可能存在少量兼容性问题 不断优化中 curl -sSO http://io.bt.sy/install/install_panel.sh && bash install_panel.sh 1.宝塔环境如下: Nginx 1.20 Tomcat 8 MySQL 8.0 R…

从0到1搭建一个组件库

最近我开启了一个新项目&#xff0c;基于echarts进行二次封装&#xff0c;希望能为Vue3项目量身打造一套高效、易用的图表组件库&#xff0c;取名为 v-echarts。 目前雏形已经搭建完成&#xff0c;先把整个搭建过程做一个记录。后续再持续迭代、完善该图表组件库。 v-echarts 文…

RustDesk远程控屏软件使用教学

RustDesk自建服务器使用教学RustDesk远程控屏软件使用教学 下载软件后 右键管理员运行 点击右上角设置按钮 管理员运行 保证启动服务 点击左侧导航栏网络按钮 复制域名或者ip地址到 ID服务器 输入框 然后点击应用即可

移动式气象站:科技赋能,精准预报的新篇章

在这个气候多变、极端天气频发的时代&#xff0c;气象信息的准确性与及时性成为了社会各界关注的焦点。从农业生产到城市规划&#xff0c;从航空航海到日常生活&#xff0c;气象服务无处不在&#xff0c;其重要性不言而喻。而在这场气象科技的变革中&#xff0c;移动式气象站以…

友思特应用 | 硅片上的光影贴合:UV-LED曝光系统在晶圆边缘曝光中的高效应用

导读 晶圆边缘曝光是帮助减少晶圆涂布过程中多余的光刻胶对电子器件影响的重要步骤。友思特 ALE/1 和 ALE/3 UV-LED 高性能点光源&#xff0c;作为唯一可用于宽带晶圆边缘曝光的 i、h 和 g 线的 LED 解决方案&#xff0c;可高效实现WEE系统设计和曝光需求。 晶圆边缘曝光及处…

The Llama 3 Herd of Models.Llama 3 模型论文全文

现代人工智能(AI)系统是由基础模型驱动的。本文提出了一套新的基础模型,称为Llama 3。它是一组语言模型,支持多语言、编码、推理和工具使用。我们最大的模型是一个密集的Transformer,具有405B个参数和多达128K个tokens的上下文窗口。本文对Llama 3进行了广泛的实证评价。我们…

day06 1.算法的相关概念2.排序算法3.查找算法

一、算法的相关概念 程序 数据结构 算法 算法是程序设计的灵魂&#xff0c;结构是程序设计的肉体 算法&#xff1a;计算机解决问题的方法或步骤 1.1 算法的特性 1> 确定性&#xff1a;算法中每一条语句都有确定的含义&#xff0c;不能模棱两可 2> 有穷性&#xff1a;…

【Linux】从零开始认识多线程 --- 线程ID

在这个浮躁的时代 只有自律的人才能脱颖而出 -- 《觉醒年代》 1 前言 上一篇文章中讲解了线程控制的基本接口&#xff1a; 线程创建pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);: pthread_t *thread :输出…

使用API有效率地管理Dynadot域名,设置过期域名抢注请求

简介 Dynadot是通过ICANN认证的域名注册商&#xff0c;自2002年成立以来&#xff0c;服务于全球108个国家和地区的客户&#xff0c;为数以万计的客户提供简洁&#xff0c;优惠&#xff0c;安全的域名注册以及管理服务。 Dynadot平台操作教程索引&#xff08;包括域名邮箱&…

深入理解SQL中的INNER JOIN操作

本文介绍了INNER JOIN的定义、使用场景、计算方法及与其他JOIN的比较。INNER JOIN是关系数据库中常用的操作&#xff0c;用于返回两个表中匹配的行&#xff0c;只有在连接条件满足时才返回数据。本文详细解释了INNER JOIN的语法及其在一对多、多对多关系中的应用&#xff0c;通…

【github】使用KeepassXC 解决github Enable two-factor authentication (2FA) 第二因子认证

下载 https://github.com/keepassxreboot/keepassxc/releases/download/2.7.9/KeePassXC-2.7.9-Win64.msi 代理地址 https://dgithub.xyz/keepassxreboot/keepassxc/releases/download/2.7.9/KeePassXC-2.7.9-Win64.msi 由于该软件不允许截图&#xff0c;以下操作参考官网 …

如何检查代理IP地址是否被占用

使用代理IP时&#xff0c;有时候会发现IP仍然不可用&#xff0c;可能是因为已经被其他用户或者网络占用了。为了检测代理IP是否被占用&#xff0c;我们可以采用一些方法进行验证测试&#xff0c;以保证代理IP的有效性和稳定性。 1.ARP缓存方法 ARP缓存法是一种简单有效的检测代…

【Python面试题收录】Python编程基础练习题①(数据类型+函数+文件操作)

本文所有代码打包在Gitee仓库中https://gitee.com/wx114/Python-Interview-Questions 一、数据类型 第一题&#xff08;str&#xff09; 请编写一个Python程序&#xff0c;完成以下任务&#xff1a; 去除字符串开头和结尾的空格。使用逗号&#xff08;","&#…

kotlin示例

以下代码是我写的练习程序&#xff0c;更好的代码可以从这里查看&#xff1a;代码 生日卡片 package com.example.happybirthdayimport android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity…

使用echo写入多行文字到文件时换行的处理

目标 想使用echo写入如下内容到文件program.c里 #include<stdio.h> int main(){printf("hello!\n"); } 需要处理 1、如何处理行换 2、代码中的换行如何处理 实际例子 创建文件夹 mkdir test cd test chmod 777 . 创建文件写入内容 查看 cat -n program.c…