Published on 2003-10-30 at root.cz

Napište si debugger

Michal Ludvig <michal@logix.cz> (c) 2003

Při čtení Johančina úvodu do GDB vás možná napadlo, že by stálo za pokus zjistit, jak ta černá skříňka funguje uvnitř. Snad vám to po přečtení tohoto článku bude o malinko jasnější.

GDB a jiná zvířátka

Nejpoužívanějším debuggerem ve světě Linuxu je nejspíš GDB, The GNU Debugger. V dalším textu budu pojmy GDB a debugger celkem volně zaměňovat, i když je mi jasné, že debuggerů existuje víc. Ale popravdě řečeno, on mezi nimi zas takový rozdíl není. Základní úkoly jsou totiž u všech zhruba stejné:

Že po debuggeru obvykle chcete i jiné úkoly? Třeba vypsat hodnotu proměnné? Tak to je plus mínus druhý bod - čtení paměti. Nebo chcete skočit na další řádek ve zdrojáku? Tak to je nastavení breakpointu (čili zapisování paměti), pak spuštění a nakonec automatické zastavení.

Ladící informace

Aby na vás debugger pouze nechrlil sled hexadecimálních čísel bez ladu a skladu, je celkem vhodné, aby dokázal porozumět ladícím informacím obsaženým v laděném, nebo též trasovaném programu. Tyto informace obvykle obsahují:

Pokud například budete chtít vypsat hodnotu proměnné something, debugger se koukne do seznamu symbolů, najde položku s názvem something, zjistí že je celočíselného typu o velikosti 4 bajty a že leží na adrese 0x12345678. A protože základní dovedností debuggeru je čtení paměti, sáhne do adresního prostoru laděného procesu, z uvedené adresy přečte 4 bajty a nějak hezky vám jejich hodnotu vypíše.

Takže k užitečným dovednostem debuggeru můžeme ještě přidat:

A to už je vážně všechno. Prakticky cokoliv dalšího, jako třeba zmíněný výpis backtrace, je jen kombinace výše uvedeného. Mimochodem opravdový programátor se bez těchto třešniček na dortu v podobě ladících informací samozřejmě v pohodě obejde a vystačí si s hexadecimálním výpisem paměti :-)

Píšeme debugger

Abyste si dokázali názorně představit jak debugger funguje, provedu vás tvorbou jednoduchého udělátka, na kterém si některé principy ukážeme. Zaměříme se především na první dvě výše uvedené debuggerské dovednosti. Možná vás překvapí, jak je to jednoduché.

Naším cílem bude vyzkoušet si zastavení procesu, čtení a zápis jeho paměti a registrů a opětovné spuštění. Zatím se vůbec nebudeme zabývat ladícími informacemi, možné někdy příště. Ale spíš ani pak ne :-)

Takže zadání: Budiž program s jednou globální proměnnou zvanou var a s funkcí zvanou print_var(), která hodnotu této proměnné vypíše. Dále budiž funkce set_var(), která bude do var nějakou hodnotu zapisovat. Naším cílem bude "z vnějšku" změnit hodnotu proměnné var mezi jejím zapsáním ve funkci set_var() a vytisknutím funkcí print_var().
Relevantní kus kódu bude vypadat zhruba takto:

int var;
void set_var (int value) {
	var = value;
	printf ("Hodnota nastavena na %d\n", var);
}
void print_var () {
	printf ("Hodnota proměnné var je %d\n", var);
}
int main () {
	set_var (10);
	print_var ();
	return 0;
}

Uznávám, že je to celkem málo inteligentní příklad, ale pro naše hraní postačí. Očekávaný výstup tohoto programu je:

$ ./pokus
Hodnota nastavena na 10
Hodnota proměnné var je 10

Zastavení nulté - příprava

Pro jednoduchost budeme postupovat následovně - z počátku budeme mít jeden proces. Ten zavolá fork(), čímž vznikne rodič parent() a potomek child(). Potomka záhy po vzniku zastavíme a předáme řízení rodiči. Abychom mohli změnit hodnotu proměnné mezi jejím nastavením a vypsáním, vyrobíme breakpoint na adrese první instrukce funkce print_var() a necháme potomka pokračovat. Jakmile potomek na nastavený breakpoint narazí, opět dostane slovo rodič a bude moci upravit hodnotu proměnné var.

S výhodou zde využijeme faktu, že jak rodič tak potomek mají stejně rozvržený adresový prostor, tedy že funkce a proměnné leží v paměti obou procesů na stejných adresách (toto ovšem nemusí platit po aplikování různých bezpečnostní patchů do kernelu!), takže v rodiči známe jak adresu potomkovy funkce print_var(), tak adresu jeho proměnné var. Pokud by se adresy v obou procesech lišily, museli bychom se k těmto hodnotám dostat přes ladící informace, což by ovšem vydalo na samostatný článek (nebo spíš na otravně dlouhý seriál článků).

Zastavení první - první zastavení

První zastavení potomka se koná záhy po jeho spuštění:

void child () {
	ptrace (PTRACE_TRACEME, 0, NULL, NULL);
	kill (getpid (), SIGSTOP);
	// Rovnou uvedeme i zbytek těla
	set_var (10);
	print_var ();
}

Syscall ptrace() je základ veškerého debugování, ladění, trasování, nebo jakkoliv jinak tomu chcete říkat. S jeho pomocí může jeden proces (debugger) přistupovat k paměti procesu jiného, zastavovat ho, spouštět a činit mu spoustu dalších ošklivých věcí. Nebudu sem opisovat manuálovou stránku - pilný čtenář si jistě podrobnosti nastuduje sám.

V našem případě potomek zavolal ptrace(PTRACE_TRACEME), čímž kernelu řekl, že všechny signály, které mu mají být doručeny, bude místo něj obsluhovat jeho rodič, zatímco on sám bude zastaven. A v zápětí použije první fígl - pošle sám sobě signál SIGSTOP, který ho jednak zastaví a druhak předá řízení rodiči. Rodič na potomka už totiž dávno čeká:

void parent (pid_t child_pid) {
	int status;

	wait4 (child_pid, &status, WUNTRACED, NULL);
	if (! WIFSTOPPED (status) || WSTOPSIG (status) != SIGSTOP)
		[ něco je spatně ... ]
}

Co znamenají použitá makra WIFSTOPPED() a WSTOPSIG se dozvíte v manuálové stránce k funkci wait4().

V tomto okamžiku tedy máme potomka zastaveného v syscallu kill() a rodiče, který má za úkol nastavit breakpoint na první instrukci funkce print_var() a nechat potomka pokračovat.

Zastavení druhé - nastavení breakpointu

Nastavování breakpointu se bohužel liší v závislosti na architektuře procesoru. Pro Intel x86 a AMD64 existují dva způsoby:

  1. Zapsání adresy do jednoho z debug registrů DR0DR3 a nastavení patřičných bitů v DR7.
  2. Zapsání instrukce int3 na místo prvního bajtu instrukce původní.

Protože použitelných debug registrů je velmi omezené množství (slovy: čtyři), používají debuggery obvykle metodu druhou a debug registry si šetří na watchpointy, tedy na jakési breakpointy hlídající čtení nebo zápis určitého místa v paměti. Další informace o těchto registrech získáte například v sekci Debug Registers příručky Intel 80386 Programmer's reference manual.

Zastavení dvaapůlté - instrukce int3

Půjdeme tedy druhou, běžnější cestou a k nastavení breakpointu použijeme instrukci int3, což je jednobajtová instrukce, hexadecimálně vyjádřená jako 0xcc. Pokud procesor při běhu programu narazí na tuto instrukci, vyvolá debugovací vyjímku, která se navenek projeví zasláním signálu SIGTRAP laděnému procesu. V našem případě všechny signály chytá rodičovský proces, takže na SIGTRAP budeme čekat podobně jako jsme zpočátku čekali na SIGSTOP. Ale o tom později.

Je zřejmé, že až proces narazí na breakpoint, budeme muset instrukci int3 opět nahradit původní hodnotou. Zde využijeme syscall ptrace(PTRACE_PEEKTEXT) a výsledek si pro pozdější použití uschováme do proměnné saved_long:

void parent (pid_t child_pid) {
	union {
		long insn_long;
		char insn_char[sizeof(long)];
	} insn;
	long saved_long;
	[...]
	insn.insn_long = ptrace (PTRACE_PEEKTEXT, child_pid, print_var, NULL);
	saved_long = insn.insn_long;
	[...]
}

Právě jsme přečetli jeden long int (4 nebo 8 bajtů v závislosti na architektuře) obsahující instrukce ze začátku funkce print_var(). Do proměnné insn.insn_char[0] nyní můžeme zapsat naší breakpointovací instrukci a celý long int vrátit pomocí PTRACE_POKETEXT zpět do programu potomka:

void parent (pid_t child_pid) {
	[...]
	insn.insn_char[0] = 0xcc;
	ptrace (PTRACE_POKETEXT, child_pid, print_var, insn.insn_long);
	[...]
}

Zastavení třetí - opětovné spuštění

Breakpoint máme nastaven a nyní musíme potomka opět probudit, aby pokračoval ve své těžké práci s proměnnou var:

void parent (pid_t child_pid) {
	[...]
	ptrace (PTRACE_CONT, child_pid, NULL, NULL);
	[...]
}

Zároveň si připravíme půdu pro okamžik, kdy se náš utlačovaný potomek dohrabe až na začátek hlídané funkce a šlápne na nastraženou past:

void parent (pid_t child_pid) {
	[...]
	wait4 (child_pid, &status, WUNTRACED, NULL);
	if (! WIFSTOPPED(status) || WSTOPSIG (status) != SIGTRAP)
		[ něco je spatně ... ]
	[...]
}

Zastavení čtvrté - v pasti

Potomek skočil do funkce print_var() a zastavil se na breakpointu, takže teď nás čeká spousta práce:

  1. Obnovit původní instrukci na začátku funkce.
  2. Snížit program counter (neboli instruction pointer) o jedničku - viz níže.
  3. Změnit hodnotu proměnné var v procesu potomka.
  4. Znovu probudit potomka a tvářit se jako by nic.

Bod číslo jedna je celkem jednoduchý - celý long int obsahující původní instrukce máme uschován, takže ho prostě zapíšeme na původní místo, čímž přepíšeme nyní již nežádoucí breakpoint:

void parent (pid_t child_pid) {
	[...]
	ptrace (PTRACE_POKETEXT, child_pid, print_var, saved_long);
	[...]
}

Protože instrukce int3 již byla úspěšně vykonána, ukazuje nyní program counter (PC), neboli instruction pointer, neboli registr EIP (neboli RIP na AMD64) zastaveného potomka na další bajt za adresou breakpointu. Snížit hodnotu PC o jedničku je velice důležité, protože jinak bychom vynechali jednu instrukci nebo dokonce pokračovali v běhu uprostřed vícebajtové instrukce. Ani jedna situace by nejspíš nakonec nedopadla moc dobře a chudák potomek by skončil neelegantním segfaultem. Takže PC musíme dekrementovat.

Zastavení čtyři a kousek - měníme registry

Hodnoty registrů potomka jsou dostupné v takzvané oblasti USER, kde vyplňují strukturu struct user (její deklarace je k dostání v <sys/user.h>). Protože instruction pointer je registr jako každý jiný, budeme jeho hodnotu modifikovat právě přístupem do této struktury. Využijeme k tomu příkazy PTRACE_PEEKUSER a PTRACE_POKEUSER, kterým jako argument musíme předat offset požadovaného registru od začátku struktury, k čemuž si napíšeme makro offsetof(). Snížení hodnoty PC tedy bude vypadat zhruba následovně:

#define offsetof(STRUCT,MEMBER) ((long)&((STRUCT *)0)->MEMBER)
void parent (pid_t child_pid) {
	long pc, offset;
	[...]
	offset = offsetof (struct user, regs.eip);
	pc = ptrace (PTRACE_PEEKUSER, child_pid, offset, NULL);
	ptrace (PTRACE_POKEUSER, child_pid, offset, pc - 1);
	[...]
}

Poznámka pod čarou: Pokud si ještě vzpomínáte na zmínku o registrech DR0DR7, tak vězte, že i ty jsou přístupné přes PTRACE_{PEEK,POKE}USER. Konkrétně třeba čtení by probíhalo zhruba takto:

long readDR (pid_t child_pid, int dr) {
	long offset;
	offset = offsetof (struct user, u_debugreg[dr]);
	return ptrace (PTRACE_PEEKUSER, child_pid, offset, NULL);
}

Ovšem pozor v případě zápisu do DR registrů - ne všechny jsou zapisovatelné a do některých nemůžete zapsat kdejakou ptákovinu, ale jen přesně definované hodnoty.

Zastavení ctyři a větší kousek - přepisujeme proměnnou

Možná vás už ani nepřekvapí, že použijeme ptrace(), tentokrát nás bude zajímat příkaz PTRACE_POKEDATA:

void parent (pid_t child_pid) {
	long new_value;
	[...]
	new_value = 123;
	ptrace (PTRACE_POKEDATA, child_pid, &var, new_value);
	[...]
}

Zjištění předchozí hodnoty pomocí PTRACE_PEEKDATA nechám na laskavém čtenáři.

Zastavení páté - co dál?

Jak znovu probudit zastavený proces jsme si už ukázali (viz PTRACE_CONT o něco výše), takže se nebudu opakovat. Nyní nám zbývá jen korektní ukončení - rodič by měl pomocí wait() počkat na potomka, napsat že děkuje za pozornost a nějak hezky se rozloučit. To už ale s debugováním nemá nic společného, takže opět vynechávám.

Na tomto krátkém příkladu jsme si ukázali základní princip fungování prakticky libovolného debuggeru. Pokud vás tyto hrátky zaujaly, doporučuji jako domácí cvičení nastudovat příkaz PTRACE_SYSCALL a s jeho pomocí napsat něco-jako-strace. Neděste se, není to nic složitého. Strace je ve skutečnosti jen volání uvedeného ptracu obaleného tunami tabulek pro převod všemožných čísel na úledné názvy syscallů a konstant.

Pokud byste chtěli vidět funkci výše uvedených fragmentů kódu naživo a pěkně pohromadě, tak si mrkněte sem: http://www.logix.cz/michal/devel/ptrace-demo. A to už je opravdu vše. Přeji příjemné ptracování a nashledanou třeba v CVSku GDB :-)

Place for your feedback...
No messages entered so far.