Amikor egy natív szolgáltatás profilozása egy rövid C függvényre mutat, a forrás önmagában nem árulja el, milyen utasítások hajtják végre a munkát. Egy változó eltűnhet, egy szorzás címképző utasítássá alakulhat, egy forrássorhoz pedig több, egymástól távoli gépi utasítás tartozhat. Az assembly olvasásának célja ezért nem a C „visszafordítása”, hanem a fordító által választott megvalósítás megértése.
A fordítási fázisoknál megkülönböztettük a fordítást, az assemblálást és a linkelést. Most ezeket megfigyelési pontként használjuk. A paraméterátadás nyelvi szabályait és a méret, igazítás, offset fogalmait ismertnek tekintjük; a hívási konvenció részletes tárgyalása a következő egység feladata.
Az assembly egy utasításkészlet szöveges jelölése, kiegészítve az assemblernek szóló direktívákkal, címkékkel és szimbólumhivatkozásokkal. A gépi kód az utasítások processzor által értelmezett bináris kódolása. A disassembly ennek visszaalakítása olvasható utasításokká; nem állítja vissza az eredeti C programot.
Négy különböző állítási szintet kell elválasztani:
| Szint | Mit határoz meg? | Példa |
|---|---|---|
| ISO C17 | A program nyelvi jelentését | Az unsigned aritmetika moduláris |
| Hívási ABI | A külön fordított kódrészek együttműködését | Mely regiszterben érkezik egy argumentum |
| Utasításkészlet, ISA | A gépi utasítás jelentését és kódolását | Mit csinál az x86-64 add |
| Fordító és kapcsolói | Egy konkrét megvalósítás kiválasztását | Szorzás helyett használ-e lea utasítást |
Az alkalmazási bináris interfész (Application Binary Interface, ABI) nem nyelvi garancia. Az, hogy egy paraméter egy adott regiszterben érkezik, nem következik a C függvénydeklarációjából önmagában.
Az alábbi példák választott célprofilja:
| Tulajdonság | Érték |
|---|---|
| Nyelv | ISO C17, -std=c17 |
| Platform | Linux, x86-64, System V AMD64 ABI, LP64 |
| Alaptípusok | CHAR_BIT == 8, 32 bites unsigned, 64 bites
size_t |
| Fordítók | GCC 14.2.0 és Clang 19.1.7 |
| Bináriseszközök | GNU Binutils 2.43.1 |
| Rendszerfejlécek | glibc 2.40 profil |
| Kódgenerálás | -march=x86-64 -mtune=generic, LTO nélkül |
| Diagnosztika | DWARF 5 debugadatok, -O0 és -O2 |
Itt egy C bájt, a sizeof egysége, nyolc
bites oktett. Más célplatformra ezt nem visszük át automatikusan.
A közölt assemblyrészletek kézzel összeállított, a profilhoz illeszkedő szemléltetések, nem dokumentált fordítási eredmények. A kapcsolók rögzítik a vizsgálat körülményeit, de a fordító tényleges kimenetét a saját laborban kell előállítani. Más fordítókiadás eltérő, mégis helyes utasítássorozatot választhat.
Az alábbi inspect.c önálló fordítási egység, minimum C17
követelménnyel. Objektumfájllá fordítható; nem teljes végrehajtható
program. A report csak deklarált külső függvény,
definíciója egy későbbi linkeléshez kellene.
#include <limits.h>
#include <stddef.h>
_Static_assert(CHAR_BIT == 8, "8-bit C bytes required");
_Static_assert(sizeof(unsigned) == 4 &&
UINT_MAX == 0xffffffffU,
"32-bit unsigned required");
_Static_assert(sizeof(size_t) == 8, "64-bit size_t required");
unsigned scale(unsigned x)
{
unsigned twice = 2U * x;
unsigned result = twice + 3U;
return result;
}
unsigned bump(unsigned *items, size_t i, unsigned delta)
{
unsigned old = items[i];
unsigned next = old + delta;
items[i] = next;
return next;
}
extern void report(unsigned code);
unsigned report_if_nonzero(unsigned code)
{
if (code == 0U)
return 0U;
report(code);
return 1U;
}A fordítási parancsok Bash alatt, a táblázat szerinti eszközökkel értendők:
flags=(
-std=c17 -Wall -Wextra -Wpedantic
-march=x86-64 -mtune=generic
-fno-lto -fcf-protection=none
-g -gdwarf-5
)
for cc in gcc clang; do
for opt in 0 2; do
"$cc" "${flags[@]}" "-O${opt}" -S -masm=intel \
inspect.c -o "${cc}-O${opt}.s"
"$cc" "${flags[@]}" "-O${opt}" -c \
inspect.c -o "${cc}-O${opt}.o"
done
done
objdump -dr -Mintel gcc-O2.o
objdump -dS -Mintel gcc-O2.o
readelf --debug-dump=decodedline gcc-O2.oFordítóeszköz: a GCC és Clang -S
kapcsolója assemblyszövegnél állítja meg a feldolgozást. A
-c objektumfájlt készít, linkelés nélkül. A
-masm=intel a szöveges assembly megjelenítési módját
választja ki, nem másik utasításkészletet.
Bináriseszköz: az objdump -d
disassemblyt készít; a -r a relokációkat is megmutatja. Ez
a külső report hívásánál fontos: az objektumfájl még nem
tartalmazza a végleges célcímet. Az objdump -S ezzel
szemben forrásszöveget próbál keverni a disassembly mellé. Ugyanaz a
kapcsolóbetű itt mást jelent, mint a fordítónál.
A fordító assemblykimenetében látható .loc,
.cfi_* vagy .section sorok direktívák. Nem
mindegyik sor jelent végrehajtandó gépi utasítást.
A regiszter a processzor architekturálisan
megnevezett tárolója. x86-64-en a rax 64 bites regiszter,
az eax ugyanennek az alsó 32 bites része.
ISA-szabály: az eax írása a
rax felső 32 bitjét nullázza.
Az operandus egy utasítás bemenete vagy célja. Lehet regiszter, közvetlen konstans vagy memóriaoperandus. A memóriaoperandus nem magát a pointert jelenti, hanem az általa kijelölt tárolóhoz történő hozzáférést.
Az x86 assembly két gyakori szöveges alakja:
| Művelet | Intel | AT&T |
|---|---|---|
| Regisztermásolás | mov eax, edi |
movl %edi, %eax |
| Konstans hozzáadása | add eax, 3 |
addl $3, %eax |
| 32 bites betöltés | mov eax, DWORD PTR [rdi] |
movl (%rdi), %eax |
| Indexelt betöltés | mov eax, DWORD PTR [rdi+rsi*4] |
movl (%rdi,%rsi,4), %eax |
Intel szintaxisban általában a cél áll elöl, AT&T alakban a
forrás. AT&T-ben a % regisztert, a $
közvetlen értéket jelöl; az utasításnév l utótagja itt 32
bites operandusméretre utal, nem a C long típusára.
A további szemléltetések Intel szintaxisú assemblyrészletek, nem önálló assemblyprogramok.
Az utasításkódolás (instruction encoding) az a szabályrendszer, amely az utasítást és operandusait bináris mezőkre képezi. Például:
| Utasítás | Egy érvényes x86-64 kódolás, hexadecimális oktettekkel |
|---|---|
add eax, edx |
01 d0 |
ret |
c3 |
A 01 műveleti kód és a d0 operandusválasztó
mező együtt adja meg az összeadást. A processzor nem az add
karaktereit hajtja végre.
Az x86-64 utasításhossz változó: nem minden utasítás ugyanannyi oktett. Emiatt a disassemblernek helyes utasításhatárról kell indulnia. A rövidebb kód ugyanakkor nem szükségképpen gyorsabb: a függőségek, memóriahozzáférések és a konkrét mikroarchitektúra is számítanak.
A scale egy lehetséges optimalizált megvalósítása:
scale:
lea eax, [rdi+rdi+3] # az eredmény alsó 32 bitje: 2*x + 3
retABI-feltétel: ebben a profilban az első
unsigned argumentum az edi regiszterben, a
visszatérési érték az eax regiszterben jelenik meg.
A lea, vagyis load effective address, itt
megtévesztő nevű: kiszámítja a címzési kifejezést, de nem olvas
memóriát. A szögletes zárójel önmagában tehát nem bizonyít
memóriahozzáférést; az utasítás jelentését is ismerni kell.
ISO C17: az előjel nélküli műveletek az adott típus értéktartományához igazodó moduláris aritmetikát követik. A választott profilban:
Bizonyításvázlat. Ha két egész szám különbsége többszöröse, ugyanannak a konstansnak a hozzáadása után is az marad. Ezért a közbülső redukció elhagyható, ha a végén az alsó 32 bitet tartjuk meg. Ez az érvelés az előjel nélküli műveletre épül; signed túlcsordulásra nem alkalmazható.
A bump lehetséges megvalósítása:
bump:
mov eax, DWORD PTR [rdi+rsi*4] # load: old = items[i]
add eax, edx # next = old + delta
mov DWORD PTR [rdi+rsi*4], eax # store: items[i] = next
retItt az ABI szerint rdi hordozza az items,
rsi az i, edx a
delta értékét. A memóriaoperandus effektív címe:
ahol a tömb kezdőcíme, az index, C bájt.
Például i == 3 esetén az eltolás
C bájt. A hardveres címképzés azonban nem végez C
tömbhatár-ellenőrzést.
A függvény teljes hozzáférési szerződése:
| Szempont | Követelmény |
|---|---|
| Tulajdonos | A hívó kezeli a tömb tárolóját és élettartamát |
| Kölcsönzött hozzáférés | Az items nem tulajdonló pointer, a függvény nem őrzi
meg |
| Tartomány | i < n, ahol n az elérhető tömbelemek
száma |
| Tároló | Az elem élő, inicializált, írható és megfelelően igazított |
| Érvénytelenítés | Felszabadítás, sikeres átfoglalás vagy élettartamvég nem történhet a hozzáférés közben |
| Konkurencia | Nem lehet szinkronizálatlan, ütköző hozzáférés |
| Hibakimenet | Nincs hibajelzés; a szerződés megsértése UB-hez vezethet |
Érvényes hozzáférés mellett az előjel nélküli összeg körbefordulása nem hiba. Ezzel szemben egy hozzáférhetőnek látszó, de már felszabadított cím használatát az assembly nem teszi helyessé.
A report_if_nonzero lehetséges alakja:
report_if_nonzero:
test edi, edi # állapotjelzők beállítása code alapján
je .Lzero # ugrás, ha code == 0
sub rsp, 8 # hívás előtti veremigazítás ebben az alakban
call report # objektumfájlban relokálható hivatkozás
mov eax, 1
add rsp, 8
ret
.Lzero:
xor eax, eax # nulla visszatérési érték
retA test nem módosítja az edi értékét, de
beállítja többek között a nullaállapot-jelzőt. A je ezt
vizsgálja. Ez a feltételes elágazás (conditional
branch) egyik alapmintája.
A call vezérlést ad át és visszatérési információt hoz
létre; a ret visszatér. A veremigazítás ABI-követelmény,
részleteit a hívási konvencióról szóló egység
bontja ki. A report normál visszatérése esetén a függvény
eredménye egy; ha a külső függvény nem tér vissza, ez a visszatérési út
sem valósul meg.
Egy C if nem feltétlenül eredményez ugrást: a fordító
választhat feltételes adatmozgatást, aritmetikai megoldást vagy teljes
egyszerűsítést is.
-O0,
-O2 és a megszűnő változókA scale optimalizálatlan alakjában megjelenhetnek külön
tárolóhelyek a paraméterhez és a lokális változókhoz. Az alábbi
részletből a veremkeret létrehozását és lebontását elhagytuk:
mov DWORD PTR [rbp-20], edi
mov eax, DWORD PTR [rbp-20]
add eax, eax
mov DWORD PTR [rbp-4], eax # twice tárolása
mov eax, DWORD PTR [rbp-4]
add eax, 3
mov DWORD PTR [rbp-8], eax # result tárolása
mov eax, DWORD PTR [rbp-8]Ezek az offsetek szemléltető fordítói választások a megadott x86-64 profilban, nem a C lokális változóinak előírt elrendezései.
-O2 mellett a teljes számítás elférhet az előző
lea utasításban. A twice és
result nyelvi változókhoz ekkor nem tartozik külön gépi
tárolóhely.
Ez nem azt jelenti, hogy a fordító „megsértette” a forrást. A változók önálló tárolása itt nem szükséges a program megfigyelhető viselkedésének előállításához. A részletes optimalizálási indoklást az as-if szabály tárgyalása adja.
Gyakori tévedés a -O0 kimenetet közvetlen nyelvi
modellnek tekinteni. Az is fordítói megvalósítás, és nem garantál sem
egy utasítást forrássoronként, sem minden változóhoz külön stackhelyet.
A volatile hozzáadása pusztán a változó „láthatóvá
tételéért” szintén félrevezető: megváltoztatja a program
szerződését.
A DWARF debugadat-formátum többek között típusokat,
változóleírásokat és gépikód-címekhez kapcsolt forráshelyeket tárolhat.
A -g nem kapcsolja ki az optimalizálást; a
-O2 -g értelmes kombináció.
A forrás és a gépi kód kapcsolata azonban nem egy-egy megfeleltetés:
optimized out
állapotot jelezhet.Az objdump -dS ezért tájékozódási segédlet, nem
szemantikai bizonyíték. A readelf --debug-dump=decodedline
a sortábla cím–forráshely kapcsolatait teszi vizsgálhatóvá. A
forrásszöveg megjelenítéséhez a megfelelő fájlnak is elérhetőnek kell
lennie.
Egy utólag módosított forrás mellé tett régi objektumfájl megtévesztő diagnózist adhat. Incidensvizsgálatnál ezért a binárist, debugadatokat, pontos forrásrevíziót és fordítási profilt együtt kell megőrizni.
Mit csinál a mov eax, DWORD PTR [rdi+rsi*4]? Melyik
információ C-szintű, ABI-szintű, illetve utasításkészlet-szintű? Mitől
lenne más a lea eax, [rdi+rsi*4]?
Bővítsd az inspect.c fájlt egy
unsigned affine(unsigned x) függvénnyel, amely
5U * x + 7U értékét adja vissza. Fordítsd mindkét
fordítóval -O0 és -O2 mellett. Keress
szorzást, összeadást vagy címképző utasítást.
Egy kolléga szerint az optimalizált bump biztonságosan
kezeli az i == n esetet, mert a disassemblyben nem látható
hibára ugrás, és a debugger olvasható memóriát mutat a számított címen.
Cáfold az állítást. Magyarázd meg azt is, miért nem bizonyít hibát, ha
az old változó optimized out.
A mov négy oktettet olvas az rdi + 4*rsi
címen, és az értéket az eax regiszterbe helyezi. A
címképzés és az utasítás jelentése ISA-szabály. Az, hogy
rdi a tömbpointer és rsi az index, itt
ABI-következmény. A hozzáférés megengedettségét a C objektumhatár-,
igazítás- és élettartamszabályai döntik el.
A lea csak kiszámítaná a címkifejezés alsó 32 bitjét,
memóriaolvasás nélkül.
Ellenőrzési feltétel: külön tudd választani a cím kiszámíthatóságát a címzett objektumhoz való hozzáférés jogosságától.
Az előző fájlhoz illeszthető C17-részlet:
unsigned affine(unsigned x)
{
return 5U * x + 7U;
}A korábbi buildparancsok változatlanul használhatók. Egy lehetséges optimalizált alak:
affine:
lea eax, [rdi+rdi*4+7]
retA címkifejezés . A célregiszterben megtartott alsó 32 bit megfelel a profil szerinti előjel nélküli eredménynek.
Ellenőrzési feltétel: nem pontosan ezt az utasítássort kell kapnod. Rögzítsd a tényleges kimenetet, és vezesd le, hogyan állítja elő ugyanazt a moduláris értéket.
Az i == n index a tömb utáni pozíciót jelöli. Az oda
mutató one-past pointer bizonyos műveletekhez képezhető, de nem
dereferálható. A bump olvasása és írása ezért megsérti a
szerződést; az esethez nincs garantált kimenet.
A hardveresen olvasható lap nem bizonyít élő, megfelelő típusú C objektumot. A hiányzó ellenőrzés pedig nem bizonyít biztonságot: a függvény a hívóra bízza a tartomány helyességét.
Az old tárolása szükségtelenné válhatott, illetve a
debugadat nem feltétlenül teszi rekonstruálhatóvá.
Ellenőrzési feltétel: a javításnak a memóriaelérés
előtt kell biztosítania az i < n feltételt, vagy
dokumentált hívói invariánssal kell garantálnia azt.
Az assembly egy konkrét fordítás megvalósítását mutatja, nem a C
nyelv univerzális működését. A -S fordítói nézetet, az
objdump bináris nézetet ad; a DWARF ezekhez kapcsol
forrásinformációt. Load, store, branch és call felismerésével már
értelmezhető számos kis függvény, de a helyességet továbbra is a nyelvi
szerződésből kell levezetni. Az optimalizálás megszüntethet változókat
és összevonhat műveleteket, miközben a program előírt viselkedése
változatlan marad.