GNU/Linux >> Linux Esercitazione >  >> Linux

Un tutorial pratico per l'utilizzo di GNU Project Debugger

Se sei un programmatore e vuoi inserire una certa funzionalità nel tuo software, inizi pensando a come implementarla, come scrivere un metodo, definire una classe o creare nuovi tipi di dati. Quindi scrivi l'implementazione in un linguaggio che il compilatore o l'interprete può comprendere. Ma cosa succede se il compilatore o l'interprete non comprende le istruzioni come le avevi in ​​mente, anche se sei sicuro di aver fatto tutto bene? Cosa succede se il software funziona bene per la maggior parte del tempo ma causa bug in determinate circostanze? In questi casi, devi sapere come utilizzare correttamente un debugger per trovare l'origine dei tuoi problemi.

Il GNU Project Debugger (GDB) è un potente strumento per trovare bug nei programmi. Ti aiuta a scoprire il motivo di un errore o di un arresto anomalo tenendo traccia di ciò che sta accadendo all'interno del programma durante l'esecuzione.

Questo articolo è un tutorial pratico sull'utilizzo di base di GDB. Per seguire gli esempi, apri la riga di comando e clona questo repository:

git clone https://github.com/hANSIc99/core_dump_example.git

Scorciatoie

Più risorse Linux

  • Comandi Linux cheat sheet
  • Cheat sheet sui comandi avanzati di Linux
  • Corso online gratuito:Panoramica tecnica RHEL
  • Cheat sheet della rete Linux
  • Cheat sheet di SELinux
  • Cheat sheet dei comandi comuni di Linux
  • Cosa sono i container Linux?
  • I nostri ultimi articoli su Linux

Ogni comando in GDB può essere abbreviato. Ad esempio, info break , che mostra i punti di interruzione impostati, può essere abbreviato in i break . Potresti vedere quelle abbreviazioni altrove, ma in questo articolo scriverò l'intero comando in modo che sia chiaro quale funzione viene utilizzata.

Parametri della riga di comando

Puoi allegare GDB a ogni eseguibile. Passa al repository che hai clonato e compilalo eseguendo make . Ora dovresti avere un eseguibile chiamato coredump . (Vedi il mio articolo su Creazione e debug dei file di dump di Linux per maggiori informazioni..

Per allegare GDB all'eseguibile, digita:gdb coredump .

Il tuo output dovrebbe assomigliare a questo:

Dice che non sono stati trovati simboli di debug.

Le informazioni di debug fanno parte del file oggetto (l'eseguibile) e includono i tipi di dati, le firme delle funzioni e la relazione tra il codice sorgente e il codice operativo. A questo punto, hai due opzioni:

  • Continua a eseguire il debug dell'assembly (consulta "Debug senza simboli" di seguito)
  • Compila con le informazioni di debug utilizzando le informazioni nella sezione successiva

Compila con informazioni di debug

Per includere le informazioni di debug nel file binario, devi ricompilarlo. Apri il file di creazione e rimuovi l'hashtag (# ) dalla riga 9:

CFLAGS =-Wall -Werror -std=c++11 -g

Il g opzione dice al compilatore di includere le informazioni di debug. Esegui make clean seguito da make e invoca di nuovo GDB. Dovresti ottenere questo output e iniziare a eseguire il debug del codice:

Le ulteriori informazioni di debug aumenteranno la dimensione dell'eseguibile. In questo caso, aumenta l'eseguibile di 2,5 volte (da 26.088 byte a 65.480 byte).

Avvia il programma con il -c1 cambia digitando run -c1 . Il programma si avvierà e si arresterà in modo anomalo quando raggiunge lo State_4 :

È possibile recuperare ulteriori informazioni sul programma. Il comando info source fornisce informazioni sul file corrente:

  • 101 righe
  • Lingua:C++
  • Compiler (versione, ottimizzazione, architettura, flag di debug, standard linguistico)
  • Formato di debug:DWARF 2
  • Nessuna informazione disponibile sulle macro del preprocessore (se compilate con GCC, le macro sono disponibili solo se compilate con -g3 bandiera).

Il comando info shared stampa un elenco di librerie dinamiche con i relativi indirizzi nello spazio indirizzi virtuale che è stato caricato all'avvio in modo che il programma venga eseguito:

Se vuoi saperne di più sulla gestione delle librerie in Linux, consulta il mio articolo Come gestire le librerie dinamiche e statiche in Linux .

Esegui il debug del programma

Potresti aver notato che puoi avviare il programma all'interno di GDB con run comando. Il run comando accetta argomenti della riga di comando come si utilizzerebbe per avviare il programma dalla console. Il -c1 switch causerà l'arresto anomalo del programma nella fase 4. Per eseguire il programma dall'inizio, non è necessario uscire da GDB; usa semplicemente il run comando di nuovo. Senza il -c1 switch, il programma esegue un ciclo infinito. Dovresti interromperlo con Ctrl+C .

È anche possibile eseguire un programma passo dopo passo. In C/C++, il punto di ingresso è main funzione. Usa il comando list main per aprire la parte del codice sorgente che mostra il main funzione:

Il main la funzione è sulla riga 33, quindi aggiungi un punto di interruzione digitando break 33 :

Esegui il programma digitando run . Come previsto, il programma si ferma al main funzione. Digita layout src per mostrare il codice sorgente in parallelo:

Ora sei nella modalità dell'interfaccia utente di testo (TUI) di GDB. Usa i tasti freccia su e giù per scorrere il codice sorgente.

GDB evidenzia la riga da eseguire. Digitando next (n), puoi eseguire i comandi riga per riga. GBD esegue l'ultimo comando se non ne specifichi uno nuovo. Per scorrere il codice, premi semplicemente Invio chiave.

Di tanto in tanto, noterai che l'output di TUI viene leggermente danneggiato:

In questo caso, premi Ctrl+L per ripristinare lo schermo.

Usa Ctrl+X+A per entrare e uscire dalla modalità TUI a piacimento. Puoi trovare altre associazioni di tasti nel manuale.

Per uscire da GDB, digita semplicemente quit .

Punti di osservazione

Il cuore di questo programma di esempio è costituito da una macchina a stati in esecuzione in un ciclo infinito. La variabile n_state è un semplice enum che determina lo stato corrente:

while(true){
        switch(n_state){
        case State_1:
                std::cout << "State_1 reached" << std::flush;
                n_state = State_2;
                break;
        case State_2:
                std::cout << "State_2 reached" << std::flush;
                n_state = State_3;
                break;
       
        (.....)
       
        }
}

Vuoi fermare il programma quando n_state è impostato sul valore State_5 . Per farlo, ferma il programma in main funzione e impostare un punto di controllo per n_state :

watch n_state == State_5

L'impostazione dei punti di osservazione con il nome della variabile funziona solo se la variabile desiderata è disponibile nel contesto corrente.

Quando continui l'esecuzione del programma digitando continue , dovresti ottenere un output come:

Se continui l'esecuzione, GDB si fermerà quando l'espressione del punto di osservazione restituisce false :

È possibile specificare punti di controllo per modifiche di valori generali, valori specifici e accesso in lettura o scrittura.

Modifica dei punti di interruzione e dei punti di osservazione

Digita info watchpoints per stampare un elenco di punti di osservazione precedentemente impostati:

Elimina punti di interruzione e punti di controllo

Come puoi vedere, i punti di osservazione sono numeri. Per eliminare un punto di osservazione specifico, digita delete seguito dal numero del punto di osservazione. Ad esempio, il mio punto di osservazione ha il numero 2; per rimuovere questo punto di osservazione, inserisci delete 2 .

Attenzione: Se usi delete senza specificare un numero, tutti punti di osservazione e punti di interruzione verranno eliminati.

Lo stesso vale per i punti di interruzione. Nello screenshot qui sotto, ho aggiunto diversi punti di interruzione e ne ho stampato un elenco digitando info breakpoint :

Per rimuovere un singolo punto di interruzione, digita delete seguito dal suo numero. In alternativa, puoi rimuovere un punto di interruzione specificandone il numero di riga. Ad esempio, il comando clear 78 rimuoverà il punto di interruzione numero 7, che è impostato sulla riga 78.

Disabilita o abilita breakpoint e watchpoint

Invece di rimuovere un punto di interruzione o un punto di osservazione, puoi disabilitarlo digitando disable seguito dal suo numero. Di seguito, i punti di interruzione 3 e 4 sono disabilitati e sono contrassegnati da un segno meno nella finestra del codice:

È anche possibile modificare un intervallo di punti di interruzione o punti di osservazione digitando qualcosa come disable 2 - 4 . Se vuoi riattivare i punti, digita enable seguito dai loro numeri.

Punti di interruzione condizionali

Innanzitutto, rimuovi tutti i punti di interruzione e i punti di controllo digitando delete . Vuoi comunque che il programma si fermi al main funzione, ma invece di specificare un numero di riga, aggiungi un punto di interruzione nominando direttamente la funzione. Digita break main per aggiungere un punto di interruzione in main funzione.

Digita run per avviare l'esecuzione dall'inizio, e il programma si fermerà al main funzione.

Il main la funzione include la variabile n_state_3_count , che viene incrementato quando la macchina a stati raggiunge lo stato 3.

Per aggiungere un punto di interruzione condizionale basato sul valore di n_state_3_count digita:

break 54 if n_state_3_count == 3

Continua l'esecuzione. Il programma eseguirà la macchina a stati tre volte prima di fermarsi alla riga 54. Per controllare il valore di n_state_3_count , digita:

print n_state_3_count

Rendi condizionali i punti di interruzione

È anche possibile rendere condizionale un punto di interruzione esistente. Rimuovi il punto di interruzione aggiunto di recente con clear 54 e aggiungi un semplice punto di interruzione digitando break 54 . Puoi rendere condizionale questo punto di interruzione digitando:

condition 3 n_state_3_count == 9

Il 3 si riferisce al numero del punto di interruzione.

Imposta punti di interruzione in altri file sorgente

Se si dispone di un programma composto da più file sorgente, è possibile impostare punti di interruzione specificando il nome del file prima del numero di riga, ad esempio break main.cpp:54 .

Catchpoint

Oltre a punti di interruzione e punti di osservazione, puoi anche impostare punti di riferimento. I punti di riferimento si applicano agli eventi del programma come l'esecuzione di syscall, il caricamento di librerie condivise o la generazione di eccezioni.

Per catturare il write syscall, che viene utilizzato per scrivere su STDOUT, immettere:

catch syscall write

Ogni volta che il programma scrive sull'output della console, GDB interromperà l'esecuzione.

Nel manuale puoi trovare un intero capitolo che copre break, watch e catchpoints.

Valuta e manipola i simboli

La stampa dei valori delle variabili avviene con print comando. La sintassi generale è print <expression> <value> . Il valore di una variabile può essere modificato digitando:

set variable <variable-name> <new-value>.

Nello screenshot qui sotto, ho dato la variabile n_state_3_count il valore 123 .

Il /x espressione stampa il valore in esadecimale; con il & operatore, puoi stampare l'indirizzo all'interno dello spazio degli indirizzi virtuali.

Se non sei sicuro del tipo di dati di un certo simbolo, puoi trovarlo con whatis :

Se vuoi elencare tutte le variabili disponibili nell'ambito di main funzione, digita info scope main :

Il DW_OP_fbreg i valori si riferiscono all'offset dello stack in base alla subroutine corrente.

In alternativa, se sei già all'interno di una funzione e vuoi elencare tutte le variabili sullo stack frame corrente, puoi usare info locals :

Consulta il manuale per ulteriori informazioni sull'esame dei simboli.

Allega a un processo in esecuzione

Il comando gdb attach <process-id> consente di collegarsi a un processo già in esecuzione specificando l'ID del processo (PID). Fortunatamente, il coredump il programma stampa il suo PID corrente sullo schermo, quindi non è necessario trovarlo manualmente con ps o top.

Avvia un'istanza dell'applicazione coredump:

./coredump

Il sistema operativo fornisce il PID 2849 . Apri una finestra della console separata, spostati nella directory di origine dell'applicazione coredump e allega GDB:

gdb attach 2849

GDB interrompe immediatamente l'esecuzione quando lo alleghi. Digita layout src e backtrace per esaminare lo stack di chiamate:

L'output mostra il processo interrotto durante l'esecuzione di std::this_thread::sleep_for<...>(...) funzione che è stata chiamata nella riga 92 di main.cpp .

Non appena esci da GDB, il processo continuerà a essere eseguito.

Puoi trovare ulteriori informazioni sull'associazione a un processo in esecuzione nel manuale di GDB.

Sposta attraverso lo stack

Torna al programma usando up due volte per salire nello stack in main.cpp :

Di solito, il compilatore creerà una subroutine per ogni funzione o metodo. Ogni subroutine ha il proprio stack frame, quindi spostarsi verso l'alto nello stackframe significa spostarsi verso l'alto nello stack di chiamate.

Puoi trovare ulteriori informazioni sulla valutazione dello stack nel manuale.

Specifica i file di origine

Quando si collega a un processo già in esecuzione, GDB cercherà i file di origine nella directory di lavoro corrente. In alternativa, puoi specificare manualmente le directory di origine con la directory comando.

Valuta i file di dump

Leggi Creazione e debug dei file di dump di Linux per informazioni su questo argomento.

TL;DR:

  1. Suppongo che tu stia lavorando con una versione recente di Fedora
  2. Richiama coredump con l'opzione c1:coredump -c1

  3. Carica l'ultimo dumpfile con GDB:coredumpctl debug
  4. Apri la modalità TUI e inserisci layout src

L'output di backtrace mostra che l'arresto anomalo è avvenuto a cinque frame di stack da main.cpp . Immettere per passare direttamente alla riga di codice errata in main.cpp :

Uno sguardo al codice sorgente mostra che il programma ha tentato di liberare un puntatore che non è stato restituito da una funzione di gestione della memoria. Ciò si traduce in un comportamento indefinito e ha causato il SIGABRT .

Debug senza simboli

Se non ci sono fonti disponibili, le cose si fanno molto difficili. Ho avuto la mia prima esperienza con questo quando ho cercato di risolvere le sfide del reverse engineering. È anche utile avere una certa conoscenza del linguaggio assembly.

Scopri come funziona con questo esempio.

Vai alla directory di origine, apri il Makefile e modifica la riga 9 in questo modo:

CFLAGS =-Wall -Werror -std=c++11 #-g

Per ricompilare il programma, esegui make clean seguito da make e avvia GDB. Il programma non ha più alcun simbolo di debug per aprire la strada attraverso il codice sorgente.

Il comando info file rivela le aree di memoria e il punto di ingresso del binario:

Il punto di ingresso corrisponde all'inizio del .text area, che contiene il codice operativo effettivo. Per aggiungere un punto di interruzione nel punto di ingresso, digita break *0x401110 quindi avvia l'esecuzione digitando run :

Per impostare un punto di interruzione a un determinato indirizzo, specificalo con l'operatore di dereferenziazione * .

Scegli il gusto del disassemblatore

Prima di approfondire l'assemblaggio, puoi scegliere quale tipo di assemblaggio utilizzare. L'impostazione predefinita di GDB è AT&T, ma preferisco la sintassi Intel. Modificalo con:

set disassembly-flavor intel

Ora apri l'assieme e registra la finestra digitando layout asm e layout reg . Ora dovresti vedere l'output in questo modo:

Salva i file di configurazione

Sebbene tu abbia già inserito molti comandi, in realtà non hai avviato il debug. Se stai eseguendo pesantemente il debug di un'applicazione o stai cercando di risolvere una sfida di reverse engineering, può essere utile salvare le impostazioni specifiche di GDB in un file.

Il file di configurazione gdbinit nel repository GitHub di questo progetto contiene i comandi utilizzati di recente:

set disassembly-flavor intel
set write on
break *0x401110
run -c2
layout asm
layout reg

Il set write on comando consente di modificare il binario durante l'esecuzione.

Esci da GDB e riaprilo con il file di configurazione: gdb -x gdbinit coredump .

Leggi le istruzioni

Con il c2 interruttore applicato, il programma andrà in crash. Il programma si ferma alla funzione di ingresso, quindi devi scrivere continue per procedere con l'esecuzione:

Il idiv l'istruzione esegue una divisione intera con il dividendo nel RAX register e il divisore specificato come argomento. Il quoziente viene caricato nel RAX registrati e il resto viene caricato in RDX .

Dalla panoramica del registro, puoi vedere il RAX contiene 5 , quindi devi scoprire quale valore è memorizzato nello stack nella posizione RBP-0x4 .

Leggi memoria

Per leggere il contenuto della memoria grezza, è necessario specificare alcuni parametri in più rispetto alla lettura dei simboli. Quando scorri un po' verso l'alto nell'output dell'assieme, puoi vedere la divisione della pila:

Sei più interessato al valore di rbp-0x4 perché questa è la posizione in cui si trova l'argomento per idiv è memorizzato. Dallo screenshot, puoi vedere che la prossima variabile si trova in rbp-0x8 , quindi la variabile in rbp-0x4 è largo 4 byte.

In GDB, puoi usare x comando per esaminare qualsiasi contenuto di memoria:

x/ n f u> addr>

Parametri opzionali:

  • n :Il conteggio delle ripetizioni (predefinito:1) si riferisce alla dimensione dell'unità
  • f :identificatore di formato, come in printf
  • u :Dimensione dell'unità
    • b :byte
    • h :mezze parole (2 byte)
    • w :parola (4 byte)(predefinito)
    • g :parola gigante (8 byte)

Per stampare il valore in rbp-0x4 , digita x/u $rbp-4 :

Se tieni presente questo schema, è semplice esaminare la memoria. Controllare la sezione di esame della memoria nel manuale.

Manipolare l'assembly

L'eccezione aritmetica si è verificata nella subroutine zeroDivide() . Quando scorri un po' verso l'alto con il tasto freccia su, puoi trovare questo schema:

0x401211 <_Z10zeroDividev>              push   rbp
0x401212 <_Z10zeroDividev+1>            mov    rbp,rsp  

Questo è chiamato il prologo della funzione:

  1. Il puntatore di base (rbp ) della funzione chiamante viene memorizzato nello stack
  2. Il valore del puntatore dello stack (rsp ) viene caricato nel puntatore di base (rbp )

Salta completamente questa subroutine. Puoi controllare lo stack delle chiamate con backtrace . Sei solo uno stack frame davanti al tuo main funzione, così puoi tornare a main con un unico up :

Nel tuo main funzione, puoi trovare questo modello:

0x401431 <main+497>     cmp    BYTE PTR [rbp-0x12],0x0
0x401435 <main+501>     je     0x40145f <main+543>
0x401437 <main+503>     call   0x401211<_Z10zeroDividev>

La subroutine zeroDivide() viene inserito solo quando jump equal (je) restituisce true . Puoi facilmente sostituirlo con un jump-not-equal (jne) istruzione, che ha il codice operativo 0x75 (a condizione che tu sia su un'architettura x86/64; gli opcode sono diversi su altre architetture). Riavvia il programma digitando run . Quando il programma si ferma alla funzione di ingresso, manipolare l'opcode digitando:

set *(unsigned char*)0x401435 = 0x75

Infine, digita continue . Il programma salterà la subroutine zeroDivide() e non andrà più in crash.

Conclusione

Puoi trovare GDB che funziona in background in molti ambienti di sviluppo integrati (IDE), inclusi Qt Creator e l'estensione Native Debug per VSCodium.

È utile sapere come sfruttare le funzionalità di GDB. Di solito, non tutte le funzioni di GDB possono essere utilizzate dall'IDE, quindi puoi trarre vantaggio dall'esperienza nell'uso di GDB dalla riga di comando.


Linux
  1. 7 trucchi pratici per usare il comando wget di Linux

  2. Suggerimenti Linux per l'utilizzo di GNU Screen

  3. 8 suggerimenti per la riga di comando di Linux

  4. 5 suggerimenti per GNU Debugger

  5. Kali sul sottosistema Windows per Linux

Tutorial sui comandi Linux ss per principianti (8 esempi)

GalliumOS:la distribuzione Linux per i Chromebook

La guida completa per l'utilizzo di ffmpeg in Linux

Tutorial sull'uso del comando Timeout su Linux

Tutorial sull'utilizzo dell'ultimo comando nel terminale Linux

I 20 migliori debugger Linux per i moderni ingegneri del software