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 位机器上,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://www.xdnf.cn/news/1489256.html

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

相关文章

通过 C# 写入数据到Excel表格

Excel 是一款广泛应用于数据处理、分析和报告制作的电子表格软件。在商业、学术和日常生活中&#xff0c;Excel 的使用极为普遍。本文将详细介绍如何使用免费.NET库将数据写入到 Excel 中&#xff0c;包括文本、数值、数组、和DataTable数据的输入。 文章目录 C# 在Excel单元格…

算法-----递归~~搜索~~回溯(宏观认识)

目录 1.什么是递归 1.1二叉树的遍历 1.2快速排序 1.3归并排序 2.为什么会用到递归 3.如何理解递归 4.如何写好一个递归 5.什么是搜索 5.1深度&#xff08;dfs&#xff09;优先遍历&优先搜索 5.2宽度&#xff08;bfs&#xff09;优先遍历&优先搜索 6.回溯 1.什…

GD 32 流水灯

前言&#xff1a; 通过后面的学习掌握了一些逻辑架构的知识&#xff0c;通过复习的方式将学到的裸机任务架构的知识运用起来&#xff0c;同时巩固前面学到的知识&#xff0c;GPIO的配置等。 开发板上LED引脚使用示意图 注&#xff1a;此次LED灯的点亮凡是是高电平点亮&#xff…

计科录取75人!常州大学计算机考研考情分析!

常州大学&#xff08;Changzhou University&#xff09;&#xff0c;简称“常大”&#xff0c;位于江苏省常州市&#xff0c;是江苏省人民政府与中国石油天然气集团有限公司、中国石油化工集团有限公司及中国海洋石油集团有限公司共建的省属全日制本科院校&#xff0c;为全国深…

AIoTedge边缘物联网平台,开启智能物联新架构

边缘物联网平台是一种将计算能力、数据处理和应用服务部署在网络边缘的解决方案&#xff0c;旨在提高响应速度、降低带宽需求和增强数据安全。根据搜索结果&#xff0c;边缘物联网平台应具备以下功能&#xff1a; 云边协同&#xff1a; 云边一体架构&#xff0c;通过云端管理边…

PHP家政系统自营+多商户独立端口系统源码小程序

家政行业的新篇章 引言&#xff1a;家政行业的数字化转型 近年来&#xff0c;随着科技的飞速发展和人们生活节奏的加快&#xff0c;家政服务行业也迎来了数字化转型的浪潮。为了提升服务效率、优化用户体验&#xff0c;越来越多的家政公司开始探索“家政系统自营多商户小程序…

Tomcat中的WebSocket是如何实现的?

Tomcat中的WebSocket是如何实现的&#xff1f; WebSocket是一种在客户端和服务器之间提供长期、双向、实时通信的协议 全双工通信&#xff1a;WebSocket允许数据同时在客户端和服务器双向通信&#xff0c;无需像HTTP等待请求和响应的循环 单个TCP连接&#xff1a;建立一次连…

解答|需要通过等保测评、密评的SSL证书应考虑哪些因素?

随着数字化转型的深入&#xff0c;网络安全成为了企业发展的基石。在中国&#xff0c;网络安全等级保护制度&#xff08;简称“等保”&#xff09;和密码安全性评估&#xff08;简称“密评”&#xff09;是确保信息系统安全的重要标准。在这一背景下&#xff0c;选择SSL证书时需…

html+css 实现悬浮按钮

前言&#xff1a;哈喽&#xff0c;大家好&#xff0c;今天给大家分享htmlcss 绚丽效果&#xff01;并提供具体代码帮助大家深入理解&#xff0c;彻底掌握&#xff01;创作不易&#xff0c;如果能帮助到大家或者给大家一些灵感和启发&#xff0c;欢迎收藏关注哦 &#x1f495; 文…

F4A0手把手教程1: 华大单片机HC32F4A0如何新建工程(ddl库版本)

开发板请点击&#xff1a;https://item.taobao.com/item.htm?spma21n57.1.item.3.5fc760c3ycChCu&priceTId2150418a17219238749041878ec06d&utparam%7B%22aplus_abtest%22:%222166044947a45798ae4c3d102fcea719%22%7D&id707262644934&ns1&abbucket20 准备…

【ffmpeg命令入门】Nginx的安装与制作HLS流媒体服务器

文章目录 前言Nginx简介Ubuntu安装Nginxffmpeg生成HLS流媒体1. 生成HLS流媒体命令说明 配置Nginxffplay播放m3u8 总结 前言 在数字内容传输和流媒体服务中&#xff0c;HLS&#xff08;HTTP Live Streaming&#xff09;已经成为一种流行的解决方案&#xff0c;特别是在视频直播…

电缆规格型号对照表

一、电线电缆产品主要分为五大类: 1、裸电线及裸导体制品本类产品的主要特征是&#xff1a;纯的导体金属&#xff0c;无绝缘及护套层&#xff0c;如钢芯铝绞线、铜铝汇流排、电力机车线等&#xff1b;加工工艺主要是压力加工&#xff0c;如熔炼、压延、拉制、绞合/紧压绞合等&…

C# 使用pythonnet 迁入 python 初始化错误解决办法

pythonnet 从 3.0 版本开始&#xff0c;必须设置Runtime.PythonDLL属性或环境变量 例如&#xff1a; string pathToVirtualEnv ".\\envs\\pythonnetTest"; Runtime.PythonDLL Path.Combine(pathToVirtualEnv, "python39.dll"); PythonEngine.PythonHom…

【SpringBoot】2 项目搭建

创建项目 1&#xff09;确实本地 jdk 版本 打开命令行窗口&#xff1a;快捷键 Windows R&#xff0c;输入 CMD&#xff0c;敲回车 执行命令&#xff1a;java -version 2&#xff09;在项目 clone 的位置创建 Spring Boot 项目&#xff0c;使用 Maven 进行依赖管理&#xff…

LoRA:低秩自适应

LoRA:低秩自适应 本章节是对轻松上手微调大语言模型——QLORA篇中提到的LoRA的原理解释。 背后动机 现今模型的参数量变得越来越大&#xff0c;对预训练模型进行全微调变得越来越不可行。为了解决这个问题有了LoRA&#xff08;Low-Rank Adaption&#xff09;的诞生。将可训练…

【更新2022】各省农业科技活动经费(RD)测算 1999-2022 无缺失

各省农业科技活动经费&#xff08;R&D&#xff09;测算数据在农业经济学、政策研究和农村发展规划等领域的论文研究中具有重要应用价值。首先&#xff0c;这些数据可以用于分析不同省份在农业科技投入上的差异及其对农业生产力和产出的影响&#xff0c;帮助揭示不同地区农业…

算法——二分查找(day10)

目录 69. x 的平方根 题目解析&#xff1a; 算法解析&#xff1a; 代码&#xff1a; 35. 搜索插入位置 题目解析&#xff1a; 算法解析&#xff1a; 代码&#xff1a; 69. x 的平方根 69. x 的平方根 - 力扣&#xff08;LeetCode&#xff09; 题目解析&#xff1a; 老…

Tenable Nessus 10.7.5 (macOS, Linux, Windows) 发布 - #1 漏洞评估解决方案

Tenable Nessus 10.7.5 (macOS, Linux, Windows) 发布 - #1 漏洞评估解决方案 发布 Nessus 试用版自动化安装程序&#xff0c;支持 macOS Sonoma、RHEL 9 和 Ubuntu 24.04 请访问原文链接&#xff1a;https://sysin.org/blog/nessus-10/&#xff0c;查看最新版。原创作品&…

人工智能类——计算机科学与技术

计算机科学与技术是一个非常大的门类。目前计算机科学与技术类招生的专业主要有计算机科学与技术、软件工程、网络工程、信息安全、物联网工程等&#xff0c;后面的几个专业是计算机科学与技术的重要分支&#xff0c;而这个门类的其他分支并没有单列出来一个本科专业&#xff0…

步入新时代,使用区块链服务API打造创新应用

随着区块链技术的兴起&#xff0c;我们正步入一个全新的数据时代——一个由透明性、安全性和去中心化定义的时代。Blockchain公司的区块链API&#xff0c;作为连接现实世界与区块链世界的桥梁&#xff0c;为全球开发者和企业提供了一种前所未有的方式&#xff0c;以访问、交互并…