Potrzebowałem zrobić listener dowolnej ilości zmiennych oparty na funkcji o prototypie podobnym do
int printf ( const char * format, ... );
wyzwalany jakąś funkcją (w szczególnym przypadku callbackiem). Na przykład wyświetlający informacje typu FPS w grze, albo jako reakcja na event czy przychodzący z sieci pakiet. Dla uproszczenia tej notki załóżmy, że ma obsługiwać jedynie inty i floaty. Można oczywiście napisać wielką funkcję, w której w zależności od jakiejś zmiennej będziemy wywoływać w switchu printf(napis, int1), printf(napis, int1. int2) etc. aż do 1000 zmiennych różnych typów, czego na pewno nikt nie przekroczy. Ale można też to zrobić inaczej. Moje rozwiązanie jest proste - podajmy parametry bezpośrednio z poziomu asma. W dokumentacjach można wyczytać jak VS (w moim przypadku, bo go używam) wywołuje funkcje (calling convention), jednak warto to sprawdzić w praktyce.
Na początek robimy sobie małą aplikację testową:
#include
int main() {
printf("printf call %d %f\n", 1, 2.0f);
return 0;
}
Kompilujemy, odpalamy, i (o dziwo) na ekranie wyświetla się napis „printf call 1 2.000000”. Podejrzyjmy wygenerowaną binarkę w desassemblerze, np. w IDA (program jest dostępny w wersji Freeware – troszkę ograniczonej, ale dla przeciętnego człowieka oferuje i tak o wiele za dużo). Interesuje nas poniższy fragment (jeśli nie umiesz znaleźć go w swoim disasmie, warto się pouczyć podstaw RE, choćby z [1] czy [2], czasem się przydają nawet jeśli nie chcemy być RE).
.text:004113BE mov esi, esp
.text:004113C0 sub esp, 8
.text:004113C3 fld ds:dbl_415758
.text:004113C9 fstp [esp+0D4h+var_D4]
.text:004113CC push 1
.text:004113CE push offset aPrintfCallDF
.text:004113D3 call ds:printf
.text:004113D9 add esp, 10h
Jak widać parametry funkcji wrzucane są na stos od końca. Czyli zgodnie z opisem w dokumentacji. Jeśli chodzi o inty sprawa jest banalna - znany wszystkim push wartości. Więc np. zamiast
int i = 1;
int j = 2;
printf("printf call %d %d\n", i, j);
możemy zrobić równoważne:
int i = 1;
int j = 2;
__asm {
push j
push i
}
printf("printf call %d %d\n");
int add_ = 8;
__asm add esp, add_
Na początku wrzucamy zmienne na stos zgodnie z konwencją kompilatora, potem robimy call i na końcu zwiększamy wskaźnik stosu o tyle o ile zmniejszyliśmy go niejawnie przy pushowaniu. Jaki z tego jest zysk? Ano taki:
int tablica[5] = {1, 2, 3, 4, 5};
for(int i = 4; i >= 0; --i) {
int k = tablica[i];
__asm push k
}
printf("printf call %d %d %d %d %d\n");
int add_ = 4 * 5;
__asm add esp, add_
A co z floatami? FLD wrzuca float’a na stos FPU, natomiast FSTP kopiuje pierwszą wartość z tego stosu pod wskazany adres, po czym wykonuje POPa (btw. jest też wersja bez POPa - FST). Trzeba też pamiętać, że potrzebujemy aż 64 bitów.
float f = 3.14f;
int floatSize = 8;
__asm {
fld f
sub esp, floatSize
fstp qword ptr [esp]
}
printf("float = %f\n");
__asm add esp, floatSize
Samego listenera można teraz zaimplementować na tyle sposobów ilu jest koderów. W moim przypadku cały snippet wygląda tak:
#include
#include
#include
class PrintfListener {
enum Type {
T_INT,
T_FLOAT
};
struct Value {
Type type;
union V_ {
int* i;
float* f;
} val;
Value(int* i) { type = T_INT; val.i = i; }
Value(float* f) { type = T_FLOAT; val.f = f; }
};
std::vectorvalue;
std::string str;
public:
void addParam(int* i) { value.push_back(Value(i)); }
void addParam(float* f) { value.push_back(Value(f)); }
void setString(const std::string& s) { str.assign(s); }
void use() {
std::vector::iterator it = value.end();
int totalSize = 0;
while(true) {
--it;
switch(it->type) {
case T_INT: {
int v = *(it->val.i);
__asm push v
totalSize += 4;
} break;
case T_FLOAT: {
float v = *(it->val.f);
__asm {
fld v
sub esp, 8
fstp qword ptr [esp]
}
totalSize += 8;
} break;
}
if(it == value.begin()) break;
}
printf(str.c_str());
__asm add esp, totalSize
}
};
int main() {
int i = 0;
float f = 0.0f;
PrintfListener listener;
listener.addParam(&i);
listener.addParam(&f);
listener.setString("i = %d, f = %f\n");
listener.use();
i = 10;
f = 3.14f;
listener.use();
return 0;
}
Ok, a jak to ugryźć inaczej? Można np. zastosować STLowy string stream i do printfa przekazywać gotowy poskładany string - ale wtedy właściwie tracimy największą zaletę formatującego printfa, więc przy takim podejściu byśmy już pewnie użyli iostream na konsoli i napisali inną funkcję wypisującą w przypadku aplikacji graficznej. Jak ktoś ma inne genialne sposoby (może da się to zrobić dużo prościej?) to będę wdzięczny za wszelkie info :)
[1] ReverseCraft by Gynvael – dobre źródło dla początkujących z RE: http://re.coldwind.pl/
[2] Introduction to Reverse Engineering Software by Mike Perry - świetne źródło, troszkę wiekowe, dla początkujących z RE, bardzo dużo informacji od podszewki: http://www.acm.uiuc.edu/sigmil/RevEng/
[1] ReverseCraft by Gynvael – dobre źródło dla początkujących z RE: http://re.coldwind.pl/
[2] Introduction to Reverse Engineering Software by Mike Perry - świetne źródło, troszkę wiekowe, dla początkujących z RE, bardzo dużo informacji od podszewki: http://www.acm.uiuc.edu/sigmil/RevEng/
Hah, bardzo fajny pomysł ;> Kod dość haxxorski (tj taki jak lubie) ;> Aż miło popatrzeć ;>
OdpowiedzUsuńThx for sharing ;>
p.s. chyba ci #include w kodzie źle wyświetla (html entities?)
Thx. Faktycznie, w dodatku formatowanie troszkę się sypnęło. Będzie trzeba pokminić czemu. Teoretycznie plugin do bloga miał sam konwertować na entities ale jak widać tego nie robi.
OdpowiedzUsuńBardzo ciekawa notka. Lubie czytac o taki rzeczach :)
OdpowiedzUsuńCo do formatowania kodu, u siebie uzywam pluginu do Live writera, "Paste from VS", jednak czasem potrafi on pokaszanic caly text.
Ogolnie blogspot ma cos nie tak z formatowaniem czasami.
Co do notki jeszcze. Visual ma ciekawa opcje, za pomoca ktorej mozna podejrzec wygenerowany kod wynikowy w asm.
OdpowiedzUsuńWlasciwosci projektu a tam Configuration Preperties -> C++ -> Output Files -> Assembler Output.
O thx, nie wiedziałem o tej opcji :) A przyda się na pewno.
OdpowiedzUsuń