天天看點

浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 & 浮點數格式 & 格式化輸出 | bitset的使用 與 二進制原碼分析

這裡引用浮點數在計算機中的存儲方式一文。資料在計算機中的表示 | 進制轉換、浮點數表示

如題,為什麼

printf("%d\n", 8.0);

輸出結果為 0 。

文章目錄

        • 一、一些廢話..資料類型與存儲之類的..
        • 二、使用 bitset 輸出二進制原碼
        • 三、計算機的小端存儲方式
        • 四、分析二進制源碼與輸出過程
        • 五、探究輸出過程究竟發生了什麼
          • 補:float與double的%d輸出

以往我的文章都比較啰嗦,這次我們盡量搞簡潔一點😋。建議直接跳到第四步,直入主題。

一、一些廢話…資料類型與存儲之類的…

在計算機中,有符号的資料以補碼的方式存儲,而正數的原碼和補碼相同。是以,我們可以利用

bitset

把一個正數的二進制原碼輸出。

1.1 資料的類型

前面也提到了,資料在計算機中都是以 0 或 1的方式存儲的。并且所謂的 int類型,double類型隻是編譯器在解析該部分資料的時候決定一次讀取多少位資料。另外,值得一提的是字元類型 char 底層也是使用整型存儲的,是以我們說char類型在輸出字元時通過查ascii表輸出對應字元,在輸出整型時直接輸出ascii序号值。

舉例:

比如 有資料1234。他們每個數代表一個位元組,如果是char類型在解析的時候隻會取一個位元組的資料,輸出為4 (至于什麼會是輸出 4 而不是 1 我們下面在談)。如果是int類型,解析時使用4個位元組,輸出1234。

而我們的變量存儲在記憶體中,是以變量或者說編譯器在取值的時候都是按位址操作的,先按照記憶體首位址找到存放變量的起始位置,按照類型大小選擇取多少塊記憶體空間。

比如:對于 0x100位址,如果它儲存的變量是char類型,那麼該位址類型就為char* 類型。我們取值的時候從

0x100~0x101

之間取值。如果這塊記憶體存儲的是int類型,那麼毫無疑問,取值範圍将是

0x100~0x103

。這也就是為什麼我們

指針在 + 偏移的時候,實際上是 位址 + sizeof(類型)×偏移

1.2 資料的存儲

整數:對于資料的存儲,整型系的都是按标椎的二進制方式存儲,比如 int類型的

2

存儲為 000000...   . . . 0000 ⏟ 3 個 字 節   00000010 ⏟ 1 個 字 節 \underbrace{000000...\ ...0000}_{3個位元組}\ \underbrace{00000010}_{1個位元組} 3個位元組

000000... ...0000​​ 1個位元組

00000010​​,如果是有符号的,那麼首位空出一位用于表示符号的符号位。

浮點數:而對于浮點數就不同了,浮點數存儲時把整數部分和小數部分分為兩部分,也就是小數點前和小數點後。并且對這兩部分規定使用科學計數法表示。 如下圖所示:

浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 & 浮點數格式 & 格式化輸出 | bitset的使用 與 二進制原碼分析

具體細節,我就不多說了,有興趣的請參看文章頂部的連結。

二、使用 bitset 輸出二進制原碼

bitset

是C++庫中提供的一種方法,正如其名 bit set 一樣,可以用于初始化為二進制 形式。

之前說到,正數的原碼和補碼相同,那麼我們隻要對某個資料解析的時候以正整數的形式解析 ,就可以得到它真正的二進制存儲形式。

而C語言是一種非常靈活的語言,其中的類型強轉 和 指針 就是展現。那麼我們如和利用C語言的這個特點對其進行操作,使得它可以輸出資料的二進制形式呢?

請看代碼:

int num = 10;
	std::bitset<64> bit;
	bit = *(unsigned int*) & num;	// 核心代碼
	std::cout << num << " : " << bit << std::endl;
           

輸出:

10 : 0000000000000000000000000000000000000000000000000000000000001010

下面我來解釋以下,這段核心代碼的含義。

  • 首先,變量 num是一個整型變量,是以它占4位元組
  • 同時,按照正數的補碼與原碼相同,我們可以強轉轉換為 unsigned(無符号)類型,使之成為一個正數。
  • 其次,為了保證在對位址解析時保持與原資料同等的大小,我們需要對位址的類型進行強轉,并且隻可轉換為相同大小的類型
  • 最後,通過解引用的方式,取得該變量的值,并且以二進制原碼的方式儲存到了 “bit” 中。
解引用    強轉    取位址
bit = *(unsigned int*) & num;
           

好了,不管你看懂沒看懂,至少你已經學會怎麼輸出一個資料的的二進制存儲格式了。下面讓我們進入正題。

三、計算機的小端存儲方式

通常在我們使用的個人計算機上,資料都以小端的方式存儲。何謂小端,簡單來說就是,是指資料的高位元組儲存在記憶體的高位址中,而資料的低位元組儲存在記憶體的低位址中。

例如:對于 123456 而言,高位 十萬位 上的 1 權重最大,低位 個位 上的 6 權重最小。

高位元組位:或者說高位,即我們的十萬位。

低位元組位:或者說低位,即我們的個位。

因為記憶體位址是連續的,并且我們使用時按從小到大排列,是以對于小端存儲來說就是交錯存儲。如圖:按照 高位址——高位, 低位址——低位 的小端存儲方式。

浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 &amp; 浮點數格式 &amp; 格式化輸出 | bitset的使用 與 二進制原碼分析

是以,小端存儲方式可記憶為“小端先存儲”,這裡的 ‘小端’ 即權重小的那一端。

正如上圖所描述的一樣,小端存儲方式是以 低 位 數 據 ( 我 們 可 以 直 觀 的 想 象 成 最 右 邊 的 一 個 字 節 數 據 ) 低位資料_{(我們可以直覺的想象成最右邊的一個位元組資料)} 低位資料(我們可以直覺的想象成最右邊的一個位元組資料)​ 先存儲,高位資料後存儲的方式存儲。隻要了解這一點,對于本例的了解就綽綽有餘了。

四、分析二進制源碼與輸出過程

先寫如下程式:

double a = 8.0;
	printf("%ld , %f , %lf\n", a, a, a);
           

輸出結果如圖:

浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 &amp; 浮點數格式 &amp; 格式化輸出 | bitset的使用 與 二進制原碼分析

思考: 為什麼明明是

8.0

在格式化輸出為整型時卻輸出了

檢視二進制原碼

double a = 8.0;
	printf("%ld , %f , %lf\n", a, a, a);

	/* 輸出二進制 */
	std::bitset<64> mybit;
	mybit = *(unsigned long long*) & a;
	std::cout << a << "\t--a--\t" << mybit << std::endl;
           

輸出如圖所示:

浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 &amp; 浮點數格式 &amp; 格式化輸出 | bitset的使用 與 二進制原碼分析

可以看到,在二進制原碼中就前幾位有幾個1,後面全是 0 。

咳…咳… ,有沒有想起點啥😎

  • 想起 1:浮點數存儲是整數部分和小數部分 分開存儲的。前面一部分是整數部分後面一部分是小數部分。
    浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 &amp; 浮點數格式 &amp; 格式化輸出 | bitset的使用 與 二進制原碼分析
    按圖中所說的,前 12 位應該就是表示 8 的二進制了,後面 52個0就是小數部分了。

關于整數部分為什麼是

010000000010

這裡我們不做讨論,有興趣可以參看文章頂部的連結,有關知識點為 浮點數存儲IEEE754标準

  • 想起 2:小端存儲,我們都知道如果int類型占4個位元組,而double類型占8個位元組 浮 點 數 默 認 為 d o u b l e 類 型 _{浮點數預設為double類型} 浮點數預設為double類型​

    我們在輸出為 %d 時 或輸出為 %ld 時,都是32位,也就是4位元組。如果按照小端存儲的理論,先把二進制原碼中後面四個位元組存儲到目前的位址,而二進制原碼的前四個位元組存儲存儲到緊挨着的下一個記憶體單元中。而我們輸出的是目前記憶體單元的結果,那麼自然為0了。

是以,我們如果想輸出正确的結果,即輸出整數8,我們隻需要這樣操作即可。

double a = 8.0;
	/* 強轉為整形輸出 */ 
   printf("%d\n", (int)a);	
   // 把浮點數轉換為整數,會截斷浮點數,造成精度丢失。如果我們要輸出整數部分,無法自己完成
   // 但是編譯器知道如何進行轉換,我們把這一工作交個編譯器完成。
           

那麼整個存儲的過程,是不是這樣的呢?

浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 &amp; 浮點數格式 &amp; 格式化輸出 | bitset的使用 與 二進制原碼分析

小端存儲是以位元組為機關的,是以因該是分成了8份,分别存儲進各個記憶體單元,而不是以上圖中的一分為2 的方式。 下面我們繼續探究。

五、探究輸出過程究竟發生了什麼

根據我們上面提到的,1234以char類型輸出為4是因為存儲過程以位元組為機關,把最後的一個位元組資料存在了首位址所在的第一個記憶體單元,… 第一個位元組的資料存儲在了第四塊記憶體單元。 并且,在解析資料的時候也以小端的方式恢複解析,是以,整型資料解析出來就是原來的資料内容1234。

為了探究這一過程,我們使用 char* 類型的指針,分别通路double類型變量位址上的每一塊記憶體。

double a = 8.0;
	printf("%ld , %f , %lf\n", a, a, a);

	/* 輸出二進制 */
	std::bitset<64> mybit;
	mybit = *(unsigned long long*) & a;
	std::cout << a << "  " << mybit << std::endl;
	std::cout << std::endl;

	/* 使用指針p指向a的首位址*/
	unsigned char* p = (unsigned char*)&a;
	std::bitset<8> cBit;

	std::cout <<  "分别輸出每一塊記憶體單元的二進制格式資料:" <<  std::endl;
	
	/* 輸出位址位址範圍 */
	std::cout << "位址: \t";
	std::cout << (void*)p << "  ~  " << (void*)(p+8) <<  std::endl;
	
	/* 分别輸出每一塊記憶體塊的二進制原碼資料 */
	int i = 8;
	while (i--)
	{
		cBit = *(unsigned char*)p;

		std::cout << cBit << " ";
		p++;
	}
	std::cout << std::endl;
           

輸出結果如圖所示:

浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 &amp; 浮點數格式 &amp; 格式化輸出 | bitset的使用 與 二進制原碼分析
浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 &amp; 浮點數格式 &amp; 格式化輸出 | bitset的使用 與 二進制原碼分析

可以看到,二進制資料在存儲時确實是以小端的方式存儲到計算機記憶體中的,并且是以位元組為機關。

下面為了輸出對比,另外準備一個值為 1.1 的double類型,并按上述方法輸出其二進制格式資料。

#include <cstdio>
#include <iostream>
#include <bitset>

//#if 0
int main()
{
	double a = 8.0;
	printf("%ld , %f , %lf\n\n", a, a, a);

	/* 輸出二進制 */
	std::bitset<64> mybit;
	mybit = *(unsigned long long*) & a;
	std::cout << a << "  " << mybit << std::endl;


	/* 強轉為整形輸出 */ 
	//printf("%d\n", (int)a);

	unsigned char* p = (unsigned char*)&a;
	std::bitset<8> cBit;

	//std::cout <<  "分别輸出每一塊記憶體單元的二進制格式資料:" <<  std::endl;
	
	//std::cout << "位址: \t";
	//std::cout << (void*)p << "  ~  " << (void*)(p+8) <<  std::endl;
	int i = 8;
	while (i--)
	{
		cBit = *(unsigned char*)p;

		std::cout << cBit << " ";
		p++;
	}
	std::cout << "\n" << std::endl;


	
	/*                    double 型 1.1 資料                 */
	double d = 1.1;
	mybit = *(unsigned long long*) & d;
	std::cout << d << "  " << mybit << std::endl;

	p = (unsigned char*)&d;
	cBit = *(unsigned char*)p;

	i = 8;
	while (i--)
	{
		cBit = *(unsigned char*)p;

		std::cout << cBit << " ";
		p++;
	}
	std::cout << std::endl;

	return 0;
}
           

輸出結果:

0 , 8.000000 , 8.000000

8  0100000000100000000000000000000000000000000000000000000000000000
00000000 00000000 00000000 00000000 00000000 00000000 00100000 01000000

1.1  0011111111110001100110011001100110011001100110011001100110011010
10011010 10011001 10011001 10011001 10011001 10011001 11110001 00111111

           

分割線

補:float與double的%d輸出

在之前的測試中,我們用的都是 double 類型資料測試,并且已經已經對其進行分析。而在後續的測試中使用 float 類型測試時又發現一個有意思的問題。

float m = 5.6;
	printf("%d\n",m);			// 輸出 1610612736
           

在對其進行

bitset < 32>

操作,讀取其記憶體中的二進制原碼時,發現與我們程式輸出的

1610612736

的二進制數相差甚遠。

// float m = 5.6; 的二進制表示形式
01000000101100110011001100110011
// int num = 1610612736; 的二進制表示形式
01100000000000000000000000000000
           

我們都知道float類型占四個位元組32位,我把測試對象從double轉換為float怎末還就行不通了?我不經開始懷疑之前的推論是否正确。

經過多番測試,發現使用以下測試代碼可以解釋其原理:

int main( )
{
	float m = 5.6;

	printf("%d\n",m);			// 輸出 1610612736

	bitset<64> mybit;			// 浮點數 float
	mybit = *(unsigned long long*)& m;
	cout << mybit << endl;		// 其二進制代碼

	double n = (double)m;		// 強轉為 double
	bitset<64> mybit2;
	mybit2 = *(unsigned long long*)& n;
	cout << mybit2 << endl;		// 其二進制代碼


	cout << (bitset < 32>)1610612736 << endl;  // 1610612736 的二進制
	cout << 0b01100000000000000000000000000000 << endl;
	return 0;
}
           
浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 &amp; 浮點數格式 &amp; 格式化輸出 | bitset的使用 與 二進制原碼分析

在這段代碼中,

  • 我們把浮點數以64位的二進制方式輸出。

    将原float型浮點數 m 轉換為 double型浮點數 n,輸出其二進制。

    最後輸出

    1610612736

    的二進制資料。
  • 發現double型資料的二進制位中,後四個位元組就是我們所輸出的

    1610612736

    的二進制。

具體原理大概就是,

float 浮點數在使用printf() 函數用 "%d"格式輸出時,會将 浮點數以 8 位元組方式入棧,轉化為double方式存儲。

ps:部落客沒系統學過彙編,隻懂一些基礎的指令,隻能強行分析一波。以下時通過vs 2019反彙編代碼。彙編代碼如下:

float m = 5.6;
00092CE2  movss       xmm0,dword ptr [[email protected]40400000 (0A0180h)]  
00092CEA  movss       dword ptr [m],xmm0  

	printf("%d\n",m);
00092CEF  cvtss2sd    xmm0,dword ptr [m]  		// xmm0~xmm7八個128位的寄存器
00092CF4  sub         esp,8  					// ※開辟 8位元組 棧空間※
00092CF7  movsd       mmword ptr [esp],xmm0     // MMWORD is the type for a 64-bit multimedia value.
00092CFC  push        offset string "%d\n" (0A017Ch)  
00092D01  call        _printf (0810D2h)  
00092D06  add         esp,0Ch  
           

查閱資料發現确實有float類型轉為 double類型 通過double型浮點數寄存器存儲的說法,而在輸出時以“%d” 4位元組的32位整形資料的格式輸出,會對資料強行截斷(資料截斷保留低位,舍棄高位 資料截斷測試)。是以最終輸出的時候輸出了其後的四個位元組資料。

輸出:

1610612736		//printf("%d\n",m);
1100110011001100110011001100110001000000101100110011001100110011	// float類型5.6
0100000000010110011001100110011001100000000000000000000000000000	// 強轉為double類型
01100000000000000000000000000000	// 1610612736 的二進制原碼
1610612736							// int num = 0b01100000000000000000000000000000;
           

如下圖所示。

浮點數問題詳解,printf(“%d\n“, 8.0); 為什麼輸出 0 ?——小端存儲 &amp; 浮點數格式 &amp; 格式化輸出 | bitset的使用 與 二進制原碼分析

最後,至于為什麼要把float轉換為double入棧,我暫時也不清楚,大概與效率等有關把。這裡連結兩篇文章,作為參考。

SSE指令學習

代碼優化-之-優化浮點數取整

關于強轉時,資料截斷實驗。 将8位元組的 long long 類型強轉為 4位元組的int 類型時,會

丢到前四個位元組

int main()
{
	bitset<64> mybit;

			// 0b1 00000000 00000000 00000000 00000000  33位
	long long  n = 0b100000000000000000000000000000000;

	printf("%lld\n", n);
	cout << (bitset<64>)(*(unsigned long long*) & n) << endl;		// 其二進制代碼

	int m = (int)n;	// 強轉,大資料轉小資料 “資料截斷”,保留低位

	printf("%d\n", m);
	cout << bitset<32>(*(unsigned long long*) & m) << endl;			// 其二進制代碼

	return 0;
}
           

輸出:

4294967296
0000000000000000000000000000000100000000000000000000000000000000
0
00000000000000000000000000000000
           

繼續閱讀