C語言你沒搞清的東西——移位

本文深入探讨了C语言中的移位操作,包括左移、右移及其在负数情况下的应用,解释了补码的概念,并通过实例展示了如何正确理解和处理溢出问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

今天我們討論一下移位的問題,這個操作是如此簡單,可又有多少人真正搞明白了呢?

問題:

printf("0x%X\n", (0x80 << 24) >> 31);

結果是多少?
如果你不能很肯定的得出正確結論,並且加以解釋,建議你閱讀本文。

答案:0xFFFFFFFF

 

C語言你沒搞清的東西——移位

【c_bg44】

轉載請注明出處

 

        C語言中,有左移和右移操作,分別是<<和>>。我們那字長32位的處理器來講,例如,把二進制數0000 0000, 0000 0000, 0000 0000, 0000 0001b 左移1位,

就得倒0000 0000, 0000 0000, 0000 0000, 0000 0010b,右邊多出來的一位用0補。這個動作在C語言里寫成1 << 1 = 2,如果是2 << 1 = 4。可見任何一個數,左移1位就相當於乘以2,。爲什麽呢?因為二進制的權是2,就像十進制,加個0就大了十倍,二進制加個零大兩倍,左移相當於加零。

好了,略有基礎的人到這裡大略都不會有問題,可是。。。你有沒有想過負數的情況?-1 << 1又等於幾何?答案是-1 << 1 = -2,-2 << 1 = -4,與正數的規則一樣。如果你就這樣忽略了細節,你大概不是一個優秀的嵌入式程序員。在這裡我們仔細看看。

數在計算機里用補碼表示,負數的補碼與正數算法不同,於是-1 == 1111 1111, 1111 1111, 1111 1111, 1111 1111b(補碼),對這麼一長串數左移1位,自然得到

1111 1111, 1111 1111, 1111 1111, 1111 1111b(補碼) << 1 = 1111 1111, 1111 1111, 1111 1111, 1111 1110b(補碼),記住移完後還是補碼,這個補碼換算成有符號數竟然是-2,這不是巧合,我們不得不佩服大師們當初設計計算機時的遠見!(關於原碼、反碼、補碼的細節請查閱相關資料)。

這裡我們第一次提到了“有符號數”這個概念。你需要牢記的是任何東西在計算機看來就是一個數,你讓計算機把它解釋成有符號數它就按有符號數的規則來解析,無符號數亦然。如果是個變量做移位操作,計算機能夠得到變量的類型,有符號或無符號,那麼它可以按照相應的規則來移位,可是如果是個立即數呢?-1這樣的立即數計算機一眼就看出是個有符號數,可如果是100,計算機也不知道有沒有符號。那怎麼辦呢?位還是要移的。

我們構造這麼一個數:1000 0000, 0000 0000, 0000 0000, 0000 0001b,也不知道有沒有符號,如果無符號,它就 == 0x80 00 00 01 == 2147483649;如果是有符號數,它肯定是個負數,因為最高位為符號位被置位了,於是乎它就是 == -111 1111, 1111 1111, 1111 1111, 1111 1111b == -0x7F FF FF FF == -2147483647,與剛才那個正數在絕對值上只差了2,呵呵,有興趣的朋友可以研究下為什麼會“這麼巧”,是不是+0、-0的原因。不扯遠了,開始移位。

1000 0000, 0000 0000, 0000 0000, 0000 0001b << 1 = 0000 0000, 0000 0000, 0000 0000, 0000 0010b。這麼一移,我們發現最高位清零了,那麼無論解釋成無符號數還是有符號數都 == 2,沒錯,正是這樣。這是爲什麽呢?因為左移1位相當於乘以2,這就溢出了,翻轉后成為一個整數所以一樣。我們有時候需要這種溢出,比如取一個數的某幾位,就可以吧別的位溢出掉。

 

左移基本如此,我們再來談談更為複雜的右移。

不知道你想過沒有,這該死的右移竟會引來如此麻煩。問題的關鍵就是高位來填充。比如這麼兩個數,下面那兩個結果是正確的?

0000 0000, 0000 0000, 0000 0000, 0000 0010b >> 1 =? 0000 0000, 0000 0000, 0000 0000, 0000 0001b   (甲)

0000 0000, 0000 0000, 0000 0000, 0000 0010b >> 1 =? 1000 0000, 0000 0000, 0000 0000, 0000 0001b   (乙)

 

1000 0000, 0000 0000, 0000 0000, 0000 0010b >> 1 =? 0100 0000, 0000 0000, 0000 0000, 0000 0001b   (丙)

1000 0000, 0000 0000, 0000 0000, 0000 0010b >> 1 =? 1100 0000, 0000 0000, 0000 0000, 0000 0001b   (丁)

不難看出這是一個正數和一個”潛在的負數”在做右移。毫無疑問,乙是錯的,這種情況下高位用0補齊,甲正確。我們最關心第二個數,做個實驗。1000 0000, 0000 0000, 0000 0000, 0000 0010b = 0x80 00 00 02,用十六進制數可以避免符號的糾結。
printf("0x%X\n", 0x80000002 >> 2),結果0x40000001,那麼也就是說高位用0補了,丙正確。如此一來,甲、丙正確,證明“在任何情況下”右移時,高位都是用0補齊,果真如此嗎?看如下例子:

printf("0x%X\n", (0x80 << 24) >> 31);

按照上述規律,0x80 = 0000 0000, 0000 0000, 0000 0000, 1000 0000b << 24 = 1000 0000, 0000 0000, 0000 0000, 0000 0000b >> 31 == 0000 0000, 0000 0000, 0000 0000, 0000 0001b = 0x01,可是運行結果卻是震驚的0xFFFFFFFF!
為此,我們需要一步步調查。複查0x80 << 24 = ?結果是0x80 00 00 00,沒錯,那麼照這麼說0x80000000 >> 31應該 == 0xFF FF FF FF,也不是!
printf("0x%X\n", 0x80000000 >> 31)結果是0x1,這說明高位用0補齊,是遵照剛才丙的法則。那麼就怪了,0x80 << 24 == 0x80000000而(0x80 << 24) >> 31 != 0x80000000 >> 31,神馬道理?莫非在0x80 << 24後這個臨時結果被當做有符號數操作,所以在後面的右移當中高位被破例用1補齊了?

為了解答這個問題,我們參照彙編來看。打開VC,調試時轉到彙編代碼,如下:

 printf("0x%X\n", (0x80 << 24) >> 31);
0046353D  mov         esi,esp
0046353F  push        0FFFFFFFFh ;【一】
00463541  push        offset string "0x%X\n" (4F40A4h)
00463546  call        dword ptr [__imp__printf (516654h)]
 ...

我們注意到【一】處,編譯器已經直接把結果算出來了,看不到過程。我們再拿gcc看看,gcc -S -o test.s test.c,結果如下(局部):

 call ___main
 addl $-8,%esp
 pushl $-1    ;【二】
 pushl $LC0
 ...

【二】處,顯然gcc也把結果直接算出來了,但是注意看,它的寫法值得玩味:同樣是這個數,vc寫0xFFFFFFFF令我們迷惑,但gcc明確寫的是-1,說明gcc認為它是個有符號數。這暗示著任何一個沒有說明類型的立即數(整數),編譯器都是把它當成有符號數處理的,除非你直接說明,C語言里不是有這麼一種聲明方式嗎:

100UL

聲明100是個無符號數,如果你不寫後面那個UL,編譯器就默認把它當成有符號數處理了。其實我們當初學習C語言時,書上是有這麼一句話的,包括本大人在內的同學大略都沒當回事兒。

有符號數右移時,使用的是算術右移,用符號位填充高位。這就可以解釋爲什麽剛才一會兒用0填充一會兒用1填充了,原來計算機只不過把它當做有符號數,我們那兩個例子正巧一個符號位是0,一個是1。

回溯VC
VC怎麼那麼邪惡,直接寫一個十六進制數讓我們看不出類型?我們看一下如下兩句話在VC中的反彙編:

printf("0x%X\n, 100");
printf("0x%X\n, -100");

 

 printf("0x%X\n", 100);
0046363B  mov         esi,esp
0046363D  push        64h              ;【三】0x64 == 100
0046363F  push        offset string "0x%X\n" (4F50A4h)
00463644  call        dword ptr [__imp__printf (517654h)]
  ...
 printf("0x%X\n", -100);
00463654  mov         esi,esp
00463656  push        0FFFFFF9Ch       ;【四】負數的補碼
00463658  push        offset string "0x%X\n" (4F50A4h)
0046365D  call        dword ptr [__imp__printf (517654h)]

看了上面【三】、【四】處,就知道原來不管正數負數在VC中一律使用一個數,它就是補碼,神奇的補碼啊!你現在才會理會到。所以當我們在內存或寄存器中看到一個數時,第一反應就是這是個補碼,然後再看有無符號,再推算它的“真值”。我想99%的程序員看到一個形如0x00F01000的數字,就趕緊拿起計算器把它換算成十進制的了吧,這或許就是我們跟大師的差別。

 

補記
上文中提到了邏輯右移,當然就有算數右移、邏輯左移、算術左移了。這裡稍微擴展一下。

x86指令

算術左移  SAL
邏輯左移  SHL
算術右移  SAR
邏輯右移  SHR
循環移位  ROL和RCL

由於C語言中僅有<<和>>操作符,並且未必所有處理器都像x86一樣有循環指令,所以循環移位我們不討論。何時用邏輯、和使用算術移位是我們關心的。

算術移位用於有符號數,保持最高位(符號位)不變,擴展符號位

反之,邏輯移位用於無符號數,不擴展符號位

用彙編驗證一下:

 signed int si1 = 0x80000000;//這個數最高位(符號位) == 1,所以是個負數
0046353D  mov         dword ptr [ebp-14h],80000000h  
 printf("0x%X\n", si1 >> 31);
00463544  mov         eax,dword ptr [ebp-14h]  
00463547  sar         eax,1Fh  ;【五】有符號數,算數右移。擴展符號位1,結果0xFFFFFFFF
0046354A  mov         esi,esp  
...
 si1 = 0x40000000;          //這個數最高位(符號位) == 0,所以是個正數
00463562  mov         dword ptr [ebp-14h],40000000h  
 printf("0x%X\n", si1 >> 30);
00463569  mov         eax,dword ptr [ebp-14h]  
0046356C  sar         eax,1Eh  ;【六】有符號數,算數右移。擴展符號位0,結果0x1
0046356F  mov         esi,esp 

 unsigned int ui2 = 0x80000000;
00463562  mov         dword ptr [ebp-20h],80000000h  
 printf("0x%X\n", ui2 >> 31);
00463569  mov         eax,dword ptr [ebp-20h]  
0046356C  shr         eax,1Fh  ;【七】無符號數,邏輯右移
0046356F  mov         esi,esp  
...  

 unsigned int ui3 = 0x80;
00463587  mov         dword ptr [ebp-2Ch],80h  
 printf("0x%X\n", (ui3 << 24) >> 31);
0046358E  mov         eax,dword ptr [ebp-2Ch]  
00463591  shl         eax,18h  ;【八】無符號數,邏輯左移。不擴展符號位,結果0x80000000
00463594  shr         eax,1Fh  ;【九】無符號數,邏輯右移。不擴展符號位,結果0x1
00463597  mov         esi,esp  
....

 signed int si4 = 0x80;
004635AF  mov         dword ptr [ebp-38h],80h  
 printf("0x%X\n", (si4 << 24) >> 31);
004635B6  mov         eax,dword ptr [ebp-38h]  
004635B9  shl         eax,18h  ;【十】邏輯左移!有符號數應該做算數左移!
004635BC  sar         eax,1Fh  ;【十一】算數右移
004635BF  mov         esi,esp  
... 

上面的驗證基本沒問題,就是最後一段(【十】處)爲什麽有符號數做的是邏輯左移呢?

大家可以想一想,其實左移不存在是否擴展符號位的問題——都是最低位补零,所以SAL == SHL。VC在編譯時左移一律使用邏輯左移,也無所謂。

 

後記

一個看似簡單的問題,洋洋灑灑這麼一大段,原因在於我們許多從業者說起來已經很資深,其實并不瞭解計算機的本質和細節,一個很簡單的問題,難倒一片人。記得大一時,微型計算機技術老師說:你們計算機系的人學了四年後出來後,絕大多數人連BCD碼都搞不清楚。現在看來就是再工作四年估計還是絕大多數人搞不清楚這東西吧。

感謝IBM與我探討的同事。

 

【轉載請注明出處】
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值