środa, 12 sierpnia 2009

printf - based - listener

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::vector value;
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/