GDB Tutorial
In this tutorial, we will walk through some of the features of gdb by running a program called highscore.
First get a copy of the program by running the following commands in your CS Linux account.
cd ~/cs224
wget --no-check-certificate https://cs224.cs.vassar.edu/labs/highscore.tar
tar xvf highscore.tar
cd highscore
We can see that is a RISC-V binary by using the file command.
file highscore
highscore: ELF 32-bit LSB executable, UCB RISC-V, soft-float ABI, version 1 (SYSV), statically linked, with debug_info, not stripped
Compare that to one of built-in Linux binaries, ls
file /usr/bin/ls
/usr/bin/ls: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=897f49cafa98c11d63e619e7e40352f855249c13,
for GNU/Linux 3.2.0, stripped
Since we are cross compiling our RISC-V binaries, our programs need to run in an emulator. We will use the QEMU emulator.
Let’s try running our program in the emulator.
qemu-riscv32 ./highscore
Enter four integers separated spaces.
Try to get a score of 80.
As you can see from the program’s output, it takes four integers as input. Your goal is to find those numbers that gives you the highest possible score (80).
qemu-riscv32 ./highscore 10 20 30 40
score1(10) returned 0.
score2(20) returned 0.
score3(30) returned 0.
score4(40) returned 0.
Your score is 0.
We have some work to do! To solve this problem we are going to need to figure
out how the program works. Let’s start by looking at highscore.c.
#include <stdio.h>
#include <stdlib.h>
#include "score.h"
int main(int argc, char *argv[]) {
int arg1, score;
int sum = 0;
/* Make sure that we have exactly 5 arguments: the name of the
executable, and 4 numbers */
if (argc != 5) {
printf("Enter four integers separated spaces.\n");
printf("Try to get a score of 80.\n");
return -1;
}
/* Convert the inputs to integer */
arg1 = strtol(argv[1], NULL, 10);
score = score1(arg1);
printf("score1(%d) returned %d.\n", arg1, score);
sum += score;
arg1 = strtol(argv[2], NULL, 10);
score = score2(arg1);
printf("score2(%d) returned %d.\n", arg1, score);
sum += score;
arg1 = strtol(argv[3], NULL, 10);
score = score3(arg1);
printf("score3(%d) returned %d.\n", arg1, score);
sum += score;
arg1 = strtol(argv[4], NULL, 10);
score = score4(arg1);
printf("score4(%d) returned %d.\n", arg1, score);
sum += score;
printf("Your score is %d.\n", sum);
return 0;
}
Unfortunately, you only have source code for the main() function and not the
score functions. But no worries! We’ll figure out what is going on by
looking at the assembly version of those functions.
To do this, we are going to use gdb the Gnu
Debugger.
To run our program under the debugger, use the following command:
riscv32-none-elf-gdb -tui highscore
This starts the debugger in “text UI mode”. The screen is divided into two parts. The bottom part is the command area, where you interact with the debugger by entering in gdb commands. When you first start up gdb, it shows you a “welcome” screen. Press ‘c’ to exit the welcome message. The top screen shows you the the C code that we have provided for you.
You can move the focus between the two windows. The focus starts in the command
window. To move between windows Type ctrl-x o.
Since we are running an RISC-V binary on an x86-64 machine, we have to debug our binary using QEMU. Luckily, QEMU knows how to talk to GDB, so we can still debug our binary.
In a another terminal window, run the highscore binary in QEMU in debug mode.
qemu-riscv32 -g 1234 highscore
The -g 1234 option says to run the binary in debug mode and communicate on
port 1234. There is nothing special about the number 1234, other numbers over
1024 will also work (as long as another application is not using that number).
QEMU is now waiting for gdb commands to tell it how to proceed.
Let’s now tell gdb to connect to the highscore process running in
QEMU. Go back to your first terminal which is running GDB.
From the GDB prompt, type the following GDB command:
(gdb) target remote :1234
It is now at the start of our binary, in a function called _start. This is
low-level code that sets up the binary to run correctly and then calls main.
By default, gdb is set up to debug source code, C. Since we don’t have the full
source code for this binary, we need to change out layout to the assembly view
with the following gdb command:
(gdb) layout asm
We now see the assembly instructions for our program. To run our program type
cont, which is an abbreviation for continue.
Your output should look something like this:
(gdb) cont
Continuing.
[Inferior 1 (process 1) exited with code 0377]
Notice that in the other window, QEMU showed us the output of the program.
Enter four integers separated spaces.
Try to get a score of 80.
Because we didn’t give four integers as input, the program exited immediately.
Let’s run it again with four inputs. From the QEMU window:
qemu-riscv32 -g 1234 highscore 10 20 30 40
Now back at the gdb window, we have reconnect to QEMU. Use the
target remote :1234 command again.
Since our program really starts at main() Let’s set a breakpoint there.
Setting a breakpoint means we’ll run our program until that we hit that line of
code.
(gdb) b main
Now type continue in the GDB window. The program ran until it hit the
main() function. This time the program didn’t run all the way
to the end. Since we have the source code for main.c, we can switch gdb back to
viewing source code.
(gdb) layout src
Execute one line of the program with the next (n) command.
(gdb) n
The highlighted line is the line of code that will be executed next, but hasn’t been run yet. Let’s run two more lines of the program.
(gdb) n
(gdb) n
You can inspect values of variables by printing them with the print (p)
command.
(gdb) p arg1
This shows that the value of arg1 is 10. It assigns it to the variable $1 in
case you want to use that value later.
Since we want to understand how the score functions works, let’s set a
breakpoint at score1.
(gdb) b score1
Since we don’t have the source code to the score1 function, we have to switch
our view from the source code view to assembly view.
(gdb) layout asm
Now we can start running our program again with the continue (c) command.
(gdb) c
Now you should see the assembly output on the top half of the screen. You can
also print the values of registers, just like you can print the values of
variables. Use the p (print) command followed by the register name, with a
dollar sign ($). Let’s look at the value of the a0 register. Before we do,
take a moment to think what should the value of a0 should be?
(gdb) p $a0
Were you right? Remember a0 holds the value of the first argument to
score1.
There is also a view where we can see all of our registers.
(gdb) layout registers
To move through the program one instruction at a time, you can use the gdb “next
instruction” (ni) command.
(gdb) ni
To repeat the last gdb instruction, you can press the “return” key. Press
return until you exit score1 and return to main. Since we have changed our
layout to assembly, we’re still seeing the assembly instructions to main, even
though we have the source code to main.
We can use the built in help system of gdb to figure out how to change back to
the source code layout. We can ask gdb to show us the documentation for the
layout command by using the help instruction.
(gdb) help layout
From the output we can see that layout src would change us back. If you are
not sure how do do something in gdb or need help with a command, the help
function will, er, help you out!
To explore more features of gdb let’s take another look at score1. First
let’s remove our breakpoint for main, since we are only interested in score1
right now.
To see your current breakpoints use the info command.
(gdb) info b
To delete breakpoints, use the delete command.
(gdb) delete 1
Let’s run our program again. First we have to let it finish.
(gdb) c
Restart the highscore program in the QEMU window. Since we restarted the
program, inside of gdb, we now have to reconnect to the new QEMU process
with the target remote command again.
(gdb) target remote :1234
Pro tip: If you don’t want to keep typing the target remote:1234 instruction every time
you restart the program, gdb supports command aliases.
(gdb) alias trc = target remote :1234
Now, to reconnect to a new QEMU debugging session you can simply type trc in
gdb.
OK, back to the lab.
Now run to the score1 breakpoint.
(gdb) c
We can see the first instruction is lw a5, -612(gp). That instruction is
loading a word from memory. To figure out the memory address, we can look at
the value of the gp register and add the displacement. gdb will let us
write expressions.
(gdb) p/x $gp - 612
This is the address in memory that was used in the lw instruction. Since I’m
printing a memory address, I told gdb I want the output in hexadecimal, using
p/x. The /x is a formatting option which tells gdb to give the output in
hexadecimal.
While that is interesting, what we really want is the value located at that
memory address. This is where the gdb command x (examine) is used.
Since we are looking at memory, gdb needs to know how to interpret the bytes
of memory is examining. Look at the help for x instruction to see the
formatting options.
(gdb) help x
Since we are comparing this memory address to a0 let’s try interpret this
value as a decimal integer.
(gdb) x/dw $gp - 612
0x145a4 <secret>: 224
Now we see the value we are comparing to. This enough information to see what
input we should give to get a score of 20 for the first stage! This concludes
our tour of gdb. See if you can figure out the last three stages of the
highscore program.
To quit out of gdb type quit (q).
Good luck!