Práca s veľkými číslami na procesoroch x86

20.11.2007 18:45 | vid

Aritmetika veľkých čísel na architektúre x86 vyžaduje zvláštnu pozornosť. Je treba využívať zabudované vlastnosti aritmetických a dalších instrukcii, které niesú na prvý pohľad zrejmé a ktoré uľahčujú výpočty.

(sorry za formatovanie tohoto clanku, ten blackhole system je nejaky divny, clanok v povodnom formatovani po anglicky si mozete precitat tu)

Predpokladám, že čitateľ rozumie binárnej reprezentácii čísel, pozná základy binárnej aritmetiky a šestnástkový zápis.

Veľké čísla sú také čísla, ktoré pozostávajú z viacej bitov ako je slovo procesoru (machine word). Napríklad 1024-bitové číslo je považované za veľké číslo. Veľkosť takéhoto čísla býva zvyčajne násobkom veľkosti slova procesoru, aby sa s ním jednoduchšie pracovalo. Vtedy môžeme povedať že veľké číslo pozostáva z niekoľkých slov procesoru.

Procesor nemá inštrukcie na priamu manipuláciu s veľkými číslami, ale poskytuje inštrukcie na manipulovanie so slovami procesoru, ktoré sú súčasťou veľkého čísla.

Príklady kódu budú pre procesor x86-32, na ktorom je veľkost slova 32 bitov. Ďalej budeme toto slovo nazývať podľa zvyku dword.

Stiahnite si Intel Manual Volume 2 (2A a 2B), obsahuje popis všetkých inštrukcii, a v článku sa naň budem odvolávať.

Rozloženie veľkých čísel v pamäti

Ako už bolo povedané, veľké čísla pozostávajú z niekoľkých slov procesoru (dwordov).

Poradie slov procesoru vramci veľkého čísla je problém podobný poradiu bitov (alebo bytov) vrámci slova procesoru. Sú dve možnosti: buď je prvé najvýznamnejšie slovo (big endian), alebo je prvé najmenej významné slovo (little endian). Na architektúre x86 sa na poradie bitov/bytov používa little endian. Preto ho aj my použijeme na poradie slov vo veľkom čísle. Nieje v tom žiadna praktická výhoda, je to len nejaký štandard na ktorom sa dohodneme.

Napríklad 64bitové číslo 1 bude pozostávať z dvoch dwordov: Prvý (menej významný) s hodnotou 1, a ďalší s hodnotou 0.

dd 1  ;low order dword
dd 0  ;high order dword

96-bitové číslo (3 dwordy) s hodnotou (šestnástkovo) 0x0102030412345678ABCDEF00 bude:

dd 0xABCDEF00  ;low order dword
dd 0x12345678
dd 0x01020304  ;high order dword

Súčet

Prvá operácia ktorú si predvedieme je súčet. Vyhľadajte si v manuáli inštrukciu add.

Začneme s pripočítanim čísla 1 k 96bitovému veľkému číslu. Pre začiatok proste prirátame 1 k spodnému (prvému) dwordu:

bignum dd 15
        dd 0
        dd 0
...
add dword [bignum], 1

Toto ale nestačí. Ak spodný dword bude obsahovať hodnotu 0xFFFFFFFF súčet pretečie, a hodnota po súčte bude 0. V takomto musíme preniesť jednotku do vyššieho (druhého) dwordu.

Ako zistiť pretečenie? Ked inštrukcia add pretečie, nastaví CF (carry flag). Jeden spôsob ako ošetriť pretečenie je napríklad takýto:

add dword [bignum], 1
jnc done
add dword [bignum+4], 1
done:

Ten istý problém ale môže nastať aj pri druhom súčte, ak aj druhý dword obsahuje 0xFFFFFFFF. Opäť musíme v tomto prípade preniesť 1 do tretieho dwordu:

add dword [bignum], 1
jnc done
add dword [bignum+4], 1
jnc done
add dword [bignum+8], 1
done:

Ak aj posledný add pretečie, tak celá operácia pripočítania k veľkému číslu pretiekla. Podľa toho na čo veľké číslo používame môžeme toto ignorovať, alebo ohlásiť ako chybu.

add dword [bignum], 1
jnc done
add dword [bignum+4], 1
jnc done
add dword [bignum+8], 1
jc overflow
done:

Existuje aj krajší spôsob ako ten čo sme si predviedli. Procesor x86 totižto práve na tento účel obsahuje inštrukciu adc. Táto robí to isté ako add, ibaže k výsledku pripočíta ešte aj CF. Inak povedané, ak je flag CF nastavený, tak k vysledku pripočíta ešte 1. Po skončení operácie inštrukcia zase nastaví CF podľa toho či nastalo pretečenie. S použitím adc teda pripočítanie jednotky k veľkému číslu vyzerá takto:

add dword [bignum], 1
adc dword [bignum+4], 0
adc dword [bignum+8], 0

Takto isto môžeme pripočítať akúkoľvek hodnotu, nielen 1. Pri súčte nikdy nieje treba do vyššieho dwordu preniesť viac ako 1 (0xFFFFFFFF + 0xFFFFFFFF = 0x1FFFFFFFE). Nasleduje všeobecná rutina ktorá pripočíta EAX k veľkému číslu na ktoré ukazuje EDI:

add dword [edi], eax
adc dword [edi+4], 0
adc dword [edi+8], 0

S použitím adc pre vyššie dwordy tiež môžeme pripočítavať čísla väčšie ako 32bitov (resp. väčšie ako veľkosť slova procesoru). Táto ukážka pripočíta 0x12345678AABBCCDD k veľkému číslu:

add dword [edi], 0xAABBCCDD
adc dword [edi+4], 0x1234567
adc dword [edi+8], 0

Takto tiež môžeme spočítať dve veľké čísla. Nasledujúca ukážka spočíta dve 128-bitové (4 dwordy) čísla. Konkrétne, pripočíta bignum1 k bignum2:

mov eax, dword [bignum1]
add dword [bignum2], eax
mov eax, dword [bignum1+4]
adc dword [bignum2+4], eax
mov eax, dword [bignum1+8]
adc dword [bignum2+8], eax
mov eax, dword [bignum1+12]
adc dword [bignum2+12], eax
jc overflow

Rozdiel (odpočítanie)

Odpočítavanie je analogické pripočítaniu, len použijeme inštrukcie sub namiesto add a sbb namiesto adc. Pri odpočítavame nehovoríme že bit prenesieme do vyššieho dwordu, ale že sme si ho "požičali" - odtiaľ je názov sbb substract with borrow.

mov eax, dword [bignum1]
sub dword [bignum2], eax
mov eax, dword [bignum1+4]
sbb dword [bignum2+4], eax
mov eax, dword [bignum1+8]
sbb dword [bignum2+8], eax
mov eax, dword [bignum1+12]
sbb dword [bignum2+12], eax
jc underflow

Negácia

Pomerne zaujimavý problém je negácia veľkého čísla. Máme inštrukciu neg, ale tá sa nedá tak jednoducho použiť s veľkými číslami. Preto najprv začneme s metódou, ktorú už poznáme - odpočítavaním. Proste naše číslo odpočítame od nuly:

mov eax, 0
sub eax, dword [bignum]
mov dword [bignum], eax
mov eax, 0
sbb eax, dword [bignum+4]
mov dword [bignum+4], eax
mov eax, 0
sbb eax, dword [bignum+8]
mov dword [bignum+8], eax

Negácia sa dá tiež presiesť ako invertovanie všetkých bitov čísla (0->1, 1->0) a následne pripočítanie jednotky. Nebudem tu vysvetľovať prečo je to tak, musíte rozumieť tomu ako sú záporné čísla reprezentované v architektúre x86.

Na invertovanie všetkých bitov máme inštrukciu not. Pripočítavanie k veľkému číslu už bolo popísané.

not dword [bignum]
not dword [bignum+4]
not dword [bignum+8]
add dword [bignum], 1
adc dword [bignum+4], 0
adc dword [bignum+8], 0

Pokiaľ už tomuto rozumieme, môžeme si ukázať aj ako sa dá použiť inštrukcia neg na znegovanie veľkého čísla. Ako sme sa už dozvedeli, neg najprv invertuje všetky bity a potom pripočíta 1 k výsledku. Takže najprv znegujeme prvý (spodný, low order) dword. Ak hodnota pred negovaním je 0, po invertovaní bitov bude 0xFFFFFFFF, a pripočítanie 1 pretečie na 0. Toto je jediný prípad kedy musíme pripočítanú jednotku preniesť, a jediný prípad kedy NEG nastaví CF. Ak CF po negovaní nieje nastavené, v ostatných dwordoch stačí už len invertovať bity. V praxi toto riešenie nieje veľmi pekné:

  neg dword [bignum]
  jnc not1
  neg dword [bignum+4]
  jnc not2
  neg dword [bignum+8]
  jmp done
  not1:
  not dword [bignum+4]
  not2:
  not dword [bignum+8]
  done:

Bitové posúvanie

Bitové posúvanie je niekedy pri veľkých číslach užitočné (napríklad keď sa nimi reprezuntujú tzv. čísla s plávajúcou desatinnou čiarkou, tj. exponenciálne čísla).

Začneme posúvaním o 1 bit.

Ked chceme bitovo posúvať obyčajné (nie veľké) číslo, používame inštrukcie shl a shr. Keď posúvame o jedno miesto, tak bit ktorý čísla "vysunieme" preč sa uloží do CF. Problém je však ako ho následne vložiť do ďalšieho dwordu.

V tomto prípade môžeme použiť inštrukcie rcl and rcr. Tieto "rotujú bity čísla cez CF". Keď ich použijeme na rotovanie čísla o 1 miesto, tak posunú číslo, na miesto nového bitu vsunú hodnotu CF, a bit ktorý vysunuli z čísla potom uložia do CF - presne to čo potrebujeme. Pokiaľ to nieje jasné, pozrite si manuál. Posúvanie veľkých čísel pomocou rotovania teda vyzerá takto:

Posúvanie doprava:

;shift left
shl dword [bignum], 1
rcl dword [bignum+4], 1
rcl dword [bignum+8], 1
jc overflow

Posúvanie doľava:

;shift right
shr dword [bignum+8], 1
rcr dword [bignum+4], 1
rcr dword [bignum], 1
jc overflow    ;casto nieje treba

Bitové posuny sa stávajú zaujímavejšími keď chceme posúvať o viac ako 1 bit. V tomto prípade musíme medzi dwordami prenášať viac ako jeden bit. Naštastie procesor 386 priniesol nové inštrukcie špeciálne na tento úšel. Sú to shld a shrd. Tieto posunú operand rovnako ako shl a shr/sar, ale narozdiel od nich vsunuté bity vezmú z ďalšieho operandu.

Posúvanie doprava:

mov  eax, [bignum+4]
shrd [bignum], eax, 5
mov  eax, [bignum+8]
shrd [bignum+4], eax, 5
shr  [bignum+8], 5

Posúvanie doľava:

mov  eax, [bignum+4]
shld [bignum+8], eax, 5
mov  eax, [bignum]
shld [bignum+4], eax, 5
shl  [bignum], 5

Testovanie pretečenia je už náročnejšie. Musíme ešte pred posunom overiť či horných/spodných N bitov (N je množstvo bitov o ktoré posúvame) sú nulové. Pri posúvaní o konštantnú vzialenosť môžeme použiť klasické testovanie maskou:

Pri posúvaní doprava:

test [bignum+8], 0x0000001F  ;00000000 00000000 00000000 00011111 binary
jnz  overflow

Pri posúvaní doľava:

test [bignum+8], 0xF8000000  ;11111000 00000000 00000000 00000000 binary
jnz  overflow

Ibaže keď počet bitov o ktorý posúvame nieje konštanta, museli by sme si najprv túto masku vytvoriť. Namiesto toho, môžeme znovu použiť shld a shrd aby sme z čísla vytiahli tie bity ktoré nás zaujímajú:
Pri posúvaní doprava:

xor  eax, eax  ;eax = 0
mov  ebx, [bignum]
shrd eax, ebx, 5
test eax, eax  ;eax ?= 0
jnz  overflow

Pri posúvaní doľava:
xor  eax, eax  ;eax = 0
mov  ebx, [bignum+8]
shld eax, ebx, 5
test eax, eax  ;eax ?= 0
jnz  overflow

Násobenie

Na násobenie bežných čísel máme inštrukciu mul. Budeme používať iba jej formu s jedným operandom, ostatné formy niesú použiteľné pre veľké čísla.

Inštrukcia mul vynásobí EAX iným 32bitovým číslom, a 64bitový výsledok uloží v EDX:EAX (to znamená že horných 32bitov výsledku bude v EDX a dolných 32 bitov v EAX). Vďaka tomu, časť výsledku ktorú treba po každej elementárnej operácii preniesť do vyššieho dwordu už máme pripravenú v EDX. Ukážkové násobenie desiatkou:

mov ebx, 10            ;EBX = cislo ktorym nasobime
mov eax, [bignum]
mul ebx                ;EDX:EAX = EAX*EBX
mov [bignum], eax ;zapis vysledok nasobenia dwordu
mov ecx, edx          ;ecx = cast ktory prenesieme do vyssieho dwordu
mov eax, [bignum+4]
mul ebx
add eax, ecx          ;pripocitaj prenesenu cast z predchadzajuceho nasobenia
mov [bignum+4], eax
mov ecx, edx
mov eax, [bignum+8]
mul ebx
add eax, ecx
mov [bignum+8], eax
cmp edx, 0
jne overflow

Násobenie číslami väčšími ako 32bitov (napríklad násobenie dvoch veľkých čísel) sa už nedá riešiť takto jednoducho.

Delenie

Delenie veľkých čísel je veľmi podobné násobeniu. Budeme používať verziu inštrukcie div s jedným operandom. Táto vydelí 64-bitové číslo v EDX:EAX operandom, podiel uloží v EAX a zvyšok v EDX. Ak je výsledok väčší ako 32-bitov, procesor hodí výnimku, takže si musíme dať pozor aby sa toto nestalo.

Začneme "odvrchu" čísla (high order dword). Po každom delení zvyšok v EDX použijeme ako horných 32 bitov delenca pre ďalšie delenie. Posledný zvyšok v EDX je zvyšok po celom delení veľkého čísla.

mov ebx, 10  ;delitel
mov edx, 0
mov eax, [bignum+8]  ;EDX:EAX = delenec
div ebx
mov [bignum+8], eax
mov eax, [bignum+4]
div ebx
mov [bignum+4], eax
mov eax, [bignum]
div ebx
mov [bignum], eax
;edx tu obsahuje zvyšok delenia

Opäť, delenie číslom väčším ako 32 bitov sa nedá spraviť takto jednoducho.