今天我們討論一下移位的問題,這個操作是如此簡單,可又有多少人真正搞明白了呢?
問題:
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與我探討的同事。
【轉載請注明出處】