ASM
-=-=-
To be or not to be? It's simple: 2b or (not 2b) = ff
Jako skoro každý překladač C i GCC má možnost vkládání inline
assembleru. V GCC to je ale řešeno o dost jinak než v ostatních a
většina lidí se toho děsí a ptá se, proč to u GNU neudělali
normálně. Nevědí ale, že to bylo takto vymyšleno pro jejich dobro.
Řešení v GCC má totiž několik výhod.
Na první pohled každého překvapí změněná syntax. Nejjednodušší
použití ASM vypadá asi takto:
GCC BC
asm("cli"); asm { cli }
Proč to bylo takto uděláno? No výhoda je jednoduchá - nemate to
programy, co znají C, ale neznají GCC. Pokud vidí zápis z GCC,
řeknou si, že to je volání funkce a předání stringu, zatímco u verze
z BC se několikráte podívají na asm a pak dojdou k záveřu, že tam
chybí středník, to samé se opakuje u cli...
Dalším rozdílem je to, že GCC používá AT&T syntax assembleru(o
té jsem psal posledně - jsou tam například operátory obráceně). To
samo osobě nepřináší žádnou výhodu ale ani nevýhodu. GCC funguje
tak, že celý řetězec v asm prostě pošle dál assembleru a tak vůbec
nic o assembleru vědět nemusí. To má tu výhodu, že například můžete
použít MMX instrukce (pokud je umí assembler -- a ten je umí) a
nemusíte kvůli tomu shánět novou verzi GCC, která by proto měla
nějakou podporu této podivné instrukční sady.
Navíc jsou programátoři, (jako já) kteří považují AT&T syntax za
normální a nechápou jak mohli intelové tu jejich tak zkazit.
Nejdůležitější rozdíl ale je v optimalizaci. Pokud překladač
uvidí jednoduchý zápis:
asm {
mov ex,16
mov cx,si
int 17
}
už si nemůže být ničím jist - interrupt mohl klidně změnit
všechny registry, globální proměné a ještě přerovnat zásobník.
Prostě jeho představa o světě se zhroutí a nezbývá mu, než aby
všechny snahy o optimalizaci vzdal.
Ale ani u jednodušších příkladů si nemůže být jist. Nemůže znát
celou instrukční sadu (protože se stále objevují nové procesory a
klony s novými instrukcemi - viz MMX) a tak jednodušše nemůže nic
pořádného o takovém kusu assembleru předpokládat. A to ani o samotné
instrukci cli v minulém prípadě. Představte si, že píšete program,
který často zapína a výpíná interrupty, uděláte si tedy inline
funkce pro cli a sti:
static inline cli(void) { asm {cli}}
static inline sti(void) { asm {sti}}
Tyto funkce voláte z nejrůznějších interních smyček (což je
celkem běžné u ovladačů), chudák překladač musí být zmaten a
vyprodukovat strašný kód.
GCC je na tom lépe. Pokud u asm explicitně neřeknete, že něco
mění, předpokládá se, že nemění nic. To jde tak daleko, že u
funkcí:
static inline cli(void) { asm("cli");}
static inline sti(void) { asm("sti");}
dojde někdy dokonce k závěru, že když taková funkce nic nemění,
je nejlepší ji vůbec nevolat, začne chytračit a volání
vyoptimalizuje pryč (nebo alespoň strčí před smyčku, aby se to
neprovádělo zbytečně často). Tomu se dá zamezit pomocí volatile. V
ansi C je definováno, že když uvedete flag volatile u proměné, je
nutné předpokládat, že má nějaky speciální význam (například je
hlídána a měněna z časovače) a tak není možné na ní dělat některé
optimalizace (jako předpokládat jakou bude mít hodnotu, ukládat
každou chvíli jinam, vyhodit ji úplně apod.) U asm toto funguje
podobně. Zápis:
static inline cli(void) { asm volatile ("cli");}
static inline sti(void) { asm volatile ("sti");}
už všechno bude fungovat tak, jak má a kód bude optimalizován
jako kdyby tam žádné cli nebo sti nebylo.
Ale pořád to není ono - optimalizace jde používat jen u
některých hodně hloupých funkcí jako je cli, které nic nemění (ani
registry) ani nic nečtou (protože optimizer může usoudit, že se mu
hodí váš assembler provédst jindy a funkci může celou přeházet.
proto ani nemůžete předpokládat, že už všechno co jste napsali před
asm voláním je už hotové.
A proto má asm další rošíření. Za samotným stringem můžete
napsat : a zadat jaké má funkce vstupy, jaké výstupy a co
modifikuje. To dá optimizeru pěkný obrázek o tom, co vlastně váš
program dělá a je možné dělat další optimalizace.
Vstupní a výstupní parametry jsou v assembleru potom přístupné
jako %0 %1... (vstupy napřed, výstupy potom.) při kompilaci GCC
potom projde na % kombinace a nahradí je pravým umístěním proměné.
Aby ale nedocházelo ke kolizím z očíslovanými registry, je nutné u
asm ze vstupy a výstupy psát dvě %% u registrů, tedy %%eax místo
%eax. Například:
asm volatile ("outb %1, %0"
:
: "d" (port),
"a" (data));
říká, že assembler má dva parametry (port a data), a ty nemění.
Protože funkce má vedlejší účínek, který lze težko definovat, je
nutné použít volatile. první dvojtečka říká, že assembler nemá žádné
výstupy, další dvojtečka odděluje vstupy (to je port a data) a tak
je tam "d"(port), tato magická kombinace se skládá ze dvou částí -
třídy ("d") a parametru(port) a říká, že proměná port má být
uložena v edx. Druhý parametr oddělený , má třídu "a" tedy eax. GCC
podporuje mnoho tříd pro uložení dat. Základní jsou:
g - cokoliv (konstanta, registr, paměť)
r - libovolná hodnota v registru
m - hodnota musí být v paměti
i - hodnota musí být "immediate" tedy konstanta známá při
kompilaci
a - eax, d-edx, c-ecx, d-edx
D - edi, S-esi
q - 'a', 'b', 'c' nebo 'd'
f - floating point registr
t - prvni fp registr (top of stack)
s - druhy fp registr (second)
Ale existují i další exotičtější, kdo to myslí s psaním asm
konstrukcí vážně by měl prostudovat manuál (info system). Například
'N' znamená konstantu mezi 0-255, 'M' mezi 0-3, 'O' je adresa, ke
které jde přičítat offset ('M' jako taková může být vypočtená adresa
a compiler tam pak dosadí, celý výpočet). Je možně uvédst víc tříd
naráz ("SD" znamená, že parametr má být v edi nebo esi)
Tento formát vychází ze způsobu, jakým GCC uchovává RTL
instrukce a machine description (popis architektury).
Funkce out fungující i pro konstantí port je tedy:
asm volatile ("outb %1, %0"
:
: "Nd" (port),
"a" (data));
A ušetříte tím jeden registr a instrukci pro nastavování eax.
Teď to vpodstatě říká, že instrukci outb jde používat buď pro
konstantní port, nebo pro hodnotu uloženou v dx a pro data uložená v
ax, což je podle mé chabé paměti přesně to, jak se out chová. U %
parametrů je také možné přetypovávat, pokud není zaručeno, že
parametry jsou stejného typu. Třeba jde použít %b0 pokud to má být
byte a tak assembler nebude řvát ani když parametr nebude char. Jsou
podporovány následující typy:
k - cele slovo (eax)
b - byte (al)
h - horní byta (ah)
w - word (ax)
Pokud potřebuje kód například registr ax nastavený na 1, je
mnohem lepší uvédst ax mezi vstupy (a jako parametr napsat 1), než
začínat kód příkazem movw 1,%ax, protože GCC tak může nějak jinak
zařídit nastavení ax na 1 a ušetřit tak instrukci.
Za první dvojtečku se píše výstup - to je parametr, který musí
být lvalue (tedy to, co je možné psát před =). GCC počítá s tím, že
jeho hodnota se pouze zapisuje ale nečte. U třídy je nutné psát '=':
asm volatile ("inb %1, %0"
: "=a" (rv)
: "Nd" (port));
Toto načte z portu port hodnotu do proměné rv.
Pokud chcete vstupně výstupní proměné, můžete použít následující
konstrukci:
asm ("incl %0": "=g" (i): "0" (i));
Toto provede i++ pro proměnou i uloženou kdekoliv. Podobnou
řádku najdete i v souboru i386.md (machine description pro 386).
"0" říká, že tento parametr musí být uložen na stejném místě jako
parametr číslo 0 (výstupní i). Pokud to tam použijete "g", gcc
nebude mít pocit, že to první i nějak souvisí s tím druhým a bude na
ně nahlížet jako na dvě různé proměné a pro každou z nich může třeba
zvolit jiný registr, podle toho jak se to ve zbytku kódu hodí. Navíc
gcc může výstupní parametry umístit na stejné místo jako vstupní,
protože předpokládá, že vstupy se napřed načtou, pak se provede
nějaké zpracování a potom se uloží do výstupů. Pokud váš kód mixuje
vstupy a výstupy, je nutné k výstupní třídě přidat "&" jako v
následujícím getpixelu:
asm (
"movw %w1, %%fs
.byte 0x64
movb (%2, %3), %b0"
: "=&q" (result) /* výstup in al, bl, cl, nebo dl */
: "rm" (seg), /* segment selector v reg, nepo paměti */
"r" (pos), /* začátek řádky*/
"r" (x) /* pozice na řádce*/
);
Poslední důležitá věc je to, že občas takové assemblerové
programy potřebují registry. Jedna z cest je na začátku popnout
modifikované registry a na konci pushnout. Není to ale nejlepší a
GCC nabízí jinou cestu. Za poslední : můžete napsat soupis registrů,
které jste modifikovali, cc pro změnu flagů. Pokud kód modifikuje a
čte paměť nejakým podivným způsobem (než jsou jenom změny
proměných), je nutne napsat i "memory". To zařídí, aby se všechny
proměné uložily do paměti, než se kód provede a potom se
předpokládalo, že se mohly změnit. Navíc je u asm statementů
modifikujících paměť (například ekvivalent pro memcpy) často nutné
používat volatile, protože paměť není vedena ani mezi vstupy ani
mezi výstupy a tak optimizer nevidí důvod, proč by takovoý kód
nemohl přemísťovat, vyhodit ze smyčky apod.
Například následující kód funguje jako memcpy a kopíruje n bytů
ze src do dest (toto je ale jen ukázkový příklad a cesta přes rep
movsb je VELMI pomalá):
asm volatile (
"cld
rep
movsb"
: bez výstupních proměných
:"c" (n),"S" (src),"D" (dest) do cx počet, do si zdroj, di cíl
:"cx","si","di","memory","cc"); modifikované registry, paměť a flagy
To je asi kompletní syntax. Možná vám není úplně jasné k čemu je
takto obecná syntax nutná. Je to právě kvůli inlinování funkcí.
Pokud píšete kus assembleru do svého kódu, je situace mnohem
jednodušší - ušijete ho na míru dané situaci. Ale když děláte inline
funkci, je lepší dát optimizeru větší volnost.
Nakonec jedno velké varování. NAUČTE SE POŘÁDNĚ TUTO SYNTAX, než
začnete programovat. Je zdrojem častých chyb. Zapomenete na nějakou
drobnost - třeba uvédst volatile a ono to fungovat může a nemusí.
Také se může dost dobře stát, že to funguje ale jen 999 z tisíce
pokusů, nebo tak, že to je nakonec pomalejší, než kdybyste to
napsali v C. Nejčastější chyby jsou:
- použijete 'g' a předpokládáte, že to je registr
(tato buga je například v ctrl387.c ve zdrojáku DJGPP)
- použijete globální návěští, které pak koliduje. Skoky se dělají
zásadně:
1:
...
jne 1b
tedy aby assembler věděl, že se odkazujete na nejbližší návěští
jménem 1 dozadu (nebo 1f pro dopředu)
- zapomenete uvédst modifikované registry
- zapomenete uvédst, že to modifikuje flagy (to je na mnoha místech
zdrojáků Linuxu)
- neuvedete "memory" (najdete tamtéž)
- neuvedete volatile, kde je třeba
- použijete 'r' nebo 'g' ale předpokládáte, že to nebude v nějakém
registru (třeba eax)
- uvedete příliš omezující podmínky, které pak zbytečně zpomalují
kód.
Pokud chcete, aby gcc kompilovalo vaše funkce bez řečí i v
-pedantic módu, je nutné nepsat stringy na několik řádek a každou
ukončit pomocí "\n" a novou začít pomocí ". Vypadá to potom strašně,
ale co se dá dělat. Staré C neumělo víceřádkové stringy. Také je možné
používat __asm__ místo asm a volatile místo __volatile__
Nakonec jenom několik chybých a neefektivních funkcí, které jsem
při psaní tohoto článku náhodou objevil v různých zdrojácích.
Nalezení nedostatků ponechám čtenáři jako jednoduché cvičení. Jak
vidíte i velcí mistři se občas utnou (a občas jim to i projde).
extern inline void * memmove(void * dest,const void * src, size_t n)
{
register void *tmp = (void *)dest;
if (dest<src)
__asm__ __volatile__ (
"cld\n\t"
"rep\n\t"
"movsb"
: /* no output */
:"c" (n),"S" (src),"D" (tmp)
:"cx","si","di");
else
__asm__ __volatile__ (
"std\n\t"
"rep\n\t"
"movsb\n\t"
"cld\n\t"
: /* no output */
:"c" (n), "S" (n-1+(const char *)src), "D" (n-1+(char *)tmp)
:"cx","si","di","memory");
return dest;
}
-- linux kernel, linux/include/asm/string-486.h
extern __inline__ void
outportb (unsigned short _port, unsigned char _data)
{
__asm__ __volatile__ ("outb %1, %0"
:
: "d" (_port),
"a" (_data));
}
-- djgpp, include/inline/pc.h
int i = 0;
__asm__("
pushl %%eax\n
movl %0, %%eax\n
addl 1, %%eax\n
movl %%eax, %0\n
popl %%eax"
:
: "g" (i)
);
/* i++; */
-- tutoriál k assembleru djasm.html
Smutné je, že takových příkladů je všude habaděj a téměř každá
asm konstrukce, na kterou se kouknu je špatně. Já jsem napočítal
minimálně 6 nedostatků v těchto příkladech a co vy?
výheň