Rusty trap icon
This post is part of my Writing a Debugger series.
View all posts and upcoming topics →

A few posts ago, we used ownership in Rust to drive the design of our API when setting multiple breakpoints in our rusty_trap debugger. Then we did some vibe coded further refactoring of the API. The API we have for inferiors and breakpoints now is pretty clean and we can move onto the next feature. This has been a long time coming, and I’m proud to announce we are finally going to implement support for looking up symbols from the symbol table when setting breakpoints.

Exploring ELF Files and Symbol Tables

Symbol lookup, or symbol resolution, means that we will no longer have to specify raw addresses for breakpoints. Instead, our users (and tests) can set breakpoints in “twelve::main” or “loop::foo”. rusty_trap will do the work of figuring out what the address is for the breakpoint and then once the address is known the breakpoint can be set and handled the same way we have been doing this entire time.

For reference, I’m starting at commit c8fc68e.

Now, it turns out there are a lot of different symbol tables available. There are dynamic tables used for dynamic linking, static tables, and debug information. For this post we will work with the norm symbol table. Here’s an example of what I mean taken from one of our inferiors. Our inferior (all programs) gets compiled and linked into an object file or some type. The format used on Linux is usually ELF or some variant of ELF. There’s a handy tool called readelf that can show us the content of an ELF object file. readelf -S shows the different sections of the ELF file. Below is the output after running readelf -S on our inferior named twelve and it gives a taste of what we are looking at.

readelf -S twelve
There are 41 section headers, starting at offset 0x37f610:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000000350  00000350
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.gnu.pr[...] NOTE             0000000000000370  00000370
       0000000000000020  0000000000000000   A       0     0     8
  [ 3] .note.gnu.bu[...] NOTE             0000000000000390  00000390
       0000000000000024  0000000000000000   A       0     0     4
  [ 4] .note.ABI-tag     NOTE             00000000000003b4  000003b4
       0000000000000020  0000000000000000   A       0     0     4
  [ 5] .gnu.hash         GNU_HASH         00000000000003d8  000003d8
       0000000000000024  0000000000000000   A       6     0     8
  [ 6] .dynsym           DYNSYM           0000000000000400  00000400
       0000000000000660  0000000000000018   A       7     1     8
  [ 7] .dynstr           STRTAB           0000000000000a60  00000a60
       0000000000000412  0000000000000000   A       0     0     1
  [ 8] .gnu.version      VERSYM           0000000000000e72  00000e72
       0000000000000088  0000000000000002   A       6     0     2
  [ 9] .gnu.version_r    VERNEED          0000000000000f00  00000f00
       0000000000000110  0000000000000000   A       7     3     8
  [10] .rela.dyn         RELA             0000000000001010  00001010
       0000000000004740  0000000000000018   A       6     0     8
  [11] .rela.plt         RELA             0000000000005750  00005750
       0000000000000030  0000000000000018  AI       6    28     8
  [12] .init             PROGBITS         0000000000006000  00006000
       000000000000001b  0000000000000000  AX       0     0     4
  [13] .plt              PROGBITS         0000000000006020  00006020
       0000000000000030  0000000000000010  AX       0     0     16
  [14] .plt.got          PROGBITS         0000000000006050  00006050
       0000000000000008  0000000000000008  AX       0     0     8
  [15] .text             PROGBITS         0000000000006060  00006060
       000000000003ceeb  0000000000000000  AX       0     0     16
  [16] .fini             PROGBITS         0000000000042f4c  00042f4c
       000000000000000d  0000000000000000  AX       0     0     4
  [17] .rodata           PROGBITS         0000000000043000  00043000
       0000000000005550  0000000000000000   A       0     0     16
  [18] .debug_gdb_s[...] PROGBITS         0000000000048550  00048550
       0000000000000022  0000000000000001 AMS       0     0     1
  [19] .eh_frame_hdr     PROGBITS         0000000000048574  00048574
       0000000000000fdc  0000000000000000   A       0     0     4
  [20] .eh_frame         PROGBITS         0000000000049550  00049550
       0000000000005700  0000000000000000   A       0     0     8
  [21] .gcc_except_table PROGBITS         000000000004ec50  0004ec50
       0000000000000fac  0000000000000000   A       0     0     4
  [22] .tdata            PROGBITS         0000000000050fe8  0004ffe8
       0000000000000020  0000000000000000 WAT       0     0     8
  [23] .tbss             NOBITS           0000000000051008  00050008
       0000000000000040  0000000000000000 WAT       0     0     8
  [24] .init_array       INIT_ARRAY       0000000000051008  00050008
       0000000000000010  0000000000000008  WA       0     0     8
  [25] .fini_array       FINI_ARRAY       0000000000051018  00050018
       0000000000000008  0000000000000008  WA       0     0     8
  [26] .data.rel.ro      PROGBITS         0000000000051020  00050020
       00000000000025a8  0000000000000000  WA       0     0     8
  [27] .dynamic          DYNAMIC          00000000000535c8  000525c8
       0000000000000210  0000000000000010  WA       7     0     8
  [28] .got              PROGBITS         00000000000537d8  000527d8
       0000000000000828  0000000000000008  WA       0     0     8
  [29] .data             PROGBITS         0000000000054000  00053000
       0000000000000978  0000000000000000  WA       0     0     8
  [30] .bss              NOBITS           0000000000054978  00053978
       00000000000000e8  0000000000000000  WA       0     0     8
  [31] .comment          PROGBITS         0000000000000000  00053978
       0000000000000057  0000000000000001  MS       0     0     1
  [32] .debug_aranges    PROGBITS         0000000000000000  000539cf
       00000000000074c0  0000000000000000           0     0     1
  [33] .debug_info       PROGBITS         0000000000000000  0005ae8f
       00000000000fae6f  0000000000000000           0     0     1
  [34] .debug_abbrev     PROGBITS         0000000000000000  00155cfe
       00000000000010f2  0000000000000000           0     0     1
  [35] .debug_line       PROGBITS         0000000000000000  00156df0
       000000000006830e  0000000000000000           0     0     1
  [36] .debug_str        PROGBITS         0000000000000000  001bf0fe
       0000000000147907  0000000000000001  MS       0     0     1
  [37] .debug_ranges     PROGBITS         0000000000000000  00306a05
       00000000000672c0  0000000000000000           0     0     1
  [38] .symtab           SYMTAB           0000000000000000  0036dcc8
       0000000000004ae8  0000000000000018          39   461     8
  [39] .strtab           STRTAB           0000000000000000  003727b0
       000000000000ccbb  0000000000000000           0     0     1
  [40] .shstrtab         STRTAB           0000000000000000  0037f46b
       000000000000019e  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)

There are 40 sections here, the sections are things like .symtab which is the symbol table we will be looking at, .strtab which holds string that can be referenced in other sections (reused strings only get stored once), .data which holds an initialized image of all the global variables. .bss which represents uninitialized (or zeroed) global data but usually doesn’t occupy any space in the object file. The size for .bss given as 0xe8 bytes is likely the size of the section header. There are also .dynsym and .dynstr which are similar to .symtab and .strtab but are used for dynamic linking.

We can take a look at the symbol table using the -s option, though it prints with mangled names. We can use the --demangle=rust option to get nice readable symbols.

$ readelf -s --demangle=rust twelve | head

Symbol table '.dynsym' contains 68 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
     3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND free@GLIBC_2.2.5 (2)
     4: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _[...]@GLIBC_2.34 (3)
     5: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND abort@GLIBC_2.2.5 (2)
     6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _Unw[...]@GCC_3.3 (4)

There are a lot of symbols and it seems that readelf has opted to give us the dynamic symbol table (.dynsym) this may be because our executable is linked PIE and uses, effectively, dynamic loading or at least relocation by default.

We can search for “main” to find:

$ readelf -s --demangle=rust twelve | grep main
   381: 0000000000007820    12 FUNC    LOCAL  DEFAULT   15 twelve::main[...]
   692: 0000000000007830    32 FUNC    GLOBAL DEFAULT   15 main

Now this is all fine and good but we don’t really want to exec readelf and grep from within rusty_trap. Instead, there is a crate for rust called object that can read the object files for us.

Using the object Crate for Symbol Resolution

Taking a look at the object create I see that the read module provides a unified read api with a nice example which lists the sections. It specifically has SymbolTable with a symbols iterator - this might be what we want. Looking deeper, it has symbol_by_name. This is perfect. And of course there is Symbol with name and address Let’s put this into action.

Tests for Symbol Resolution

Instead of writing a new test, instead we’ll update the existing tests to set breakpoints by address. To pass the tests:

  1. Add the object crate to our build.
  2. In trap_inferior_exec following the example from the unified read API and load the inferior file as an Object.
  3. Store the Object in the inferior.
  4. When setting breakpoints

I guess this leaves us with the question of supporting setting breakpoints by address?

  • If do this it should be a different interface that takes a proper InferiorPointer.
    • Oh it actually takes a u64 which is basically InferiorPointer. I guess I couldn’t play so fast and loose with the types in Rust, in C it takes a char * which I could no interpret as a string.
  • Tests for this would be very fragile, as we’ve seen already. I suppose the test could use object to look up the address and then pass in that address to rusty_trap but that feels a little bit like overkill.
  • If this is really for educational purposes then I think we don’t need this feature as we have learned this already. But if you want to be able to set breakpoints on raw addresses you should know how to implement it.

Here are the updated tests:

modified   tests/lib.rs
@@ -17,7 +17,7 @@ fn it_can_set_breakpoints() {
 
     let inferior = rusty_trap::trap_inferior_exec(Path::new("./target/debug/twelve"), &[]).unwrap();
     let expected_pid = inferior.pid;
-    let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, 0x000055555555b821);
+    let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, "twelve::main");
     let (_, _) = rusty_trap::trap_inferior_continue(inferior, |passed_inferior, passed_bp| {
         assert_eq!(passed_inferior.pid, expected_pid);
         assert_eq!(passed_bp, bp);
@@ -33,7 +33,7 @@ fn it_can_handle_a_breakpoint_more_than_once() {
 
     let inferior = rusty_trap::trap_inferior_exec(Path::new("./target/debug/loop"), &[]).unwrap();
     let expected_pid = inferior.pid;
-    let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, ADDRESS_OF_FOO);
+    let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::foo");
     rusty_trap::trap_inferior_continue(inferior, |passed_inferior, passed_bp| {
         assert_eq!(passed_inferior.pid, expected_pid);
         assert_eq!(passed_bp, bp);
@@ -50,8 +50,8 @@ fn it_can_handle_more_than_one_breakpoint() {
 
     let inferior = rusty_trap::trap_inferior_exec(Path::new("./target/debug/loop"), &[]).unwrap();
     let expected_pid = inferior.pid;
-    let (inferior, bp_main) = rusty_trap::trap_inferior_set_breakpoint(inferior, ADDRESS_OF_MAIN);
-    let (inferior, bp_foo) = rusty_trap::trap_inferior_set_breakpoint(inferior, ADDRESS_OF_FOO);
+    let (inferior, bp_main) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::main");
+    let (inferior, bp_foo) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::foo");
     let (_, _) = rusty_trap::trap_inferior_continue(inferior, |passed_inferior, passed_bp| {
         assert_eq!(passed_inferior.pid, expected_pid);
         if passed_bp == bp_main {

When we run the tests the fail, as expected:

   Compiling rusty_trap v0.1.0 (/home/jkain/projects/rusty_trap)
error[E0308]: mismatched types
  --> tests/lib.rs:20:77
   |
20 |     let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, "twelve::main");
   |                          ----------------------------------------           ^^^^^^^^^^^^^^ expected `u64`, found `&str`
   |                          |
   |                          arguments to this function are incorrect
   |
note: function defined here
  --> /home/jkain/projects/rusty_trap/src/breakpoint/mod.rs:71:8
   |
71 | pub fn trap_inferior_set_breakpoint(
   |        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error[E0308]: mismatched types
  --> tests/lib.rs:36:77
   |
36 |     let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::foo");
   |                          ----------------------------------------           ^^^^^^^^^^^ expected `u64`, found `&str`
   |                          |
   |                          arguments to this function are incorrect
   |
note: function defined here
  --> /home/jkain/projects/rusty_trap/src/breakpoint/mod.rs:71:8
   |
71 | pub fn trap_inferior_set_breakpoint(
   |        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error[E0308]: mismatched types
  --> tests/lib.rs:53:82
   |
53 |     let (inferior, bp_main) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::main");
   |                               ----------------------------------------           ^^^^^^^^^^^^ expected `u64`, found `&str`
   |                               |
   |                               arguments to this function are incorrect
   |
note: function defined here
  --> /home/jkain/projects/rusty_trap/src/breakpoint/mod.rs:71:8
   |
71 | pub fn trap_inferior_set_breakpoint(
   |        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error[E0308]: mismatched types
  --> tests/lib.rs:54:81
   |
54 |     let (inferior, bp_foo) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::foo");
   |                              ----------------------------------------           ^^^^^^^^^^^ expected `u64`, found `&str`
   |                              |
   |                              arguments to this function are incorrect
   |
note: function defined here
  --> /home/jkain/projects/rusty_trap/src/breakpoint/mod.rs:71:8
   |
71 | pub fn trap_inferior_set_breakpoint(
   |        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `rusty_trap` (test "lib") due to 4 previous errors

Passing symbol names through rusty_trap

So, it’s not surprising we need to update the API to take a string with the symbols name. But as we do this let’s rename the current trap_inferior_set_breakpoint because ultimately we will end up setting a breakpoint at an address, that’s just how this works, so we want to keep this functionality. I’ll call it set_breakpoint_at_address.

modified   src/breakpoint/mod.rs
@@ -68,7 +68,7 @@ where
     };
 }
 
-pub fn trap_inferior_set_breakpoint(
+fn set_breakpoint_at_address(
     mut inferior: TrapInferior,
     location: u64,
 ) -> (TrapInferior, TrapBreakpoint) {
@@ -91,3 +91,11 @@ pub fn trap_inferior_set_breakpoint(
 
     (inferior, InferiorPointer(location))
 }
+
+pub fn trap_inferior_set_breakpoint(
+    mut inferior: TrapInferior,
+    location: &str,
+) -> (TrapInferior, TrapBreakpoint) {
+    let address: u64 = 0x55555555b9f4;
+    return set_breakpoint_at_address(inferior, address);
+}

So I have a placeholder address in trap_inferior_set_breakpoint. And of course that causes the test to fail. So, now we need to follow our plan and use the object crate.

  1. Add the object crate to our build.
modified   Cargo.toml
@@ -6,6 +6,7 @@ authors = ["Joseph Kain <joekain@gmail.com>"]
 [dependencies]
 libc = "0.2.174"
 nix = {version = "0.30.1", features = ["process", "ptrace", "signal"]}
+object = "0.37"
 
 [lib]
 name = "rusty_trap"

Our next steps are:

  1. In trap_inferior_exec following the example from the unified read API and load the inferior file as an Object.
  2. Store the Object in the inferior.

This should be, easy, right?

modified   src/inferior/mod.rs
@@ -4,6 +4,9 @@ use libc::pid_t;
 use std::collections::HashMap;
 use std::fmt;
 use std::ops::{Add, Sub};
+use object;
+use std::fs;
+use std::path::Path;
 
 #[derive(Copy, Clone)]
 pub enum InferiorState {
@@ -17,14 +20,17 @@ pub struct Inferior {
     pub pid: pid_t,
     pub state: InferiorState,
     pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
+    obj: object::File,
 }
 
 impl Inferior {
-    pub fn new(pid: pid_t) -> Inferior {
+    pub fn new(pid: pid_t, path: &Path) -> Inferior {
+	let data = fs::read(path).unwrap();
         Inferior {
             pid,
             state: InferiorState::Stopped,
             breakpoints: HashMap::new(),
+	    obj: object::File::parse(&*data).unwrap(),
         }
     }
 }
modified   src/lib.rs
@@ -1,5 +1,6 @@
 extern crate libc;
 extern crate nix;
+extern crate object;
 
 use libc::pid_t;
 use nix::sys::wait::*;
@@ -45,10 +46,10 @@ fn exec_inferior(filename: &Path, _args: &[&str]) {
     unreachable!();
 }
 
-fn attach_inferior(raw_pid: pid_t) -> Result<Inferior, Error> {
+fn attach_inferior(raw_pid: pid_t, filename: &Path) -> Result<Inferior, Error> {
     let nix_pid = Pid::from_raw(raw_pid);
     match waitpid(nix_pid, None) {
-        Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(Inferior::new(pid.into())),
+        Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(Inferior::new(pid.into(), filename)),
         Ok(_) => panic!("Unexpected stop in attach_inferior"),
         Err(e) => Err(e),
     }
@@ -61,7 +62,7 @@ pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<Inferior, Er
                 exec_inferior(filename, args);
                 unreachable!();
             }
-            Ok(ForkResult::Parent { child: pid }) => return attach_inferior(pid.into()),
+            Ok(ForkResult::Parent { child: pid }) => return attach_inferior(pid.into(), filename),
             Err(Error::EAGAIN) => continue,
             Err(e) => return Err(e),
         }

Lifetimes? What is this about lifetimes?

Lifetimes and Self-Referential Structs

error[E0106]: missing lifetime specifier
  --> src/inferior/mod.rs:23:18
   |
23 |     obj: object::File,
   |                  ^^^^ expected named lifetime parameter
   |
help: consider introducing a named lifetime parameter
   |
19 ~ pub struct Inferior<'a> {
20 |     pub pid: pid_t,
21 |     pub state: InferiorState,
22 |     pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
23 ~     obj: object::File<'a>,

Ok, I don’t really understand lifetimes that well. Well, I understand what lifetimes are conceptually: when object A references object B then B must live longer that A, otherwise when B goes out of scope object A would be left with an invalid or dangling reference. And in our particular case I know that I want this object::File to live as long as the inferior. I just don’t have a good understanding of the Rust syntax. That said, I can follow the compiler’s suggestion.

But, I think I can understand that the compiler is saying what I just said I wanted. The lifetime of the Inferior and of the object::File are the same and we express that as lifetime 'a.

Fixing this just unlocks follow on errors:

error[E0726]: implicit elided lifetime not allowed here
  --> src/inferior/mod.rs:26:6
   |
26 | impl Inferior {
   |      ^^^^^^^^ expected lifetime parameter
   |
help: indicate the anonymous lifetime
   |
26 | impl Inferior<'_> {
   |              ++++

error[E0106]: missing lifetime specifier
  --> src/inferior/mod.rs:38:25
   |
38 | pub type TrapInferior = Inferior;
   |                         ^^^^^^^^ expected named lifetime parameter
   |
help: consider introducing a named lifetime parameter
   |
38 | pub type TrapInferior<'a> = Inferior<'a>;
   |                      ++++           ++++

error[E0106]: missing lifetime specifier
  --> src/breakpoint/mod.rs:34:84
   |
34 | fn find_breakpoint_matching_inferior_instruction_pointer(inf: &Inferior) -> Option<&Breakpoint> {
   |                                                               ---------            ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say which one of `inf`'s 2 lifetimes it is borrowed from
help: consider introducing a named lifetime parameter
   |
34 | fn find_breakpoint_matching_inferior_instruction_pointer<'a>(inf: &'a Inferior<'a>) -> Option<&'a Breakpoint> {
   |                                                         ++++       ++         ++++             ++

error[E0106]: missing lifetime specifier
  --> src/lib.rs:58:69
   |
58 | pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<Inferior, Error> {
   |                                     -----        -------            ^^^^^^^^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `filename` or one of `args`'s 2 lifetimes
help: consider introducing a named lifetime parameter
   |
58 | pub fn trap_inferior_exec<'a>(filename: &'a Path, args: &'a [&'a str]) -> Result<Inferior<'a>, Error> {
   |                          ++++            ++              ++   ++                         ++++

error[E0277]: the trait bound `object::File<'_, &[u8]>: Clone` is not satisfied
  --> src/inferior/mod.rs:23:5
   |
18 | #[derive(Clone)]
   |          ----- in this derive macro expansion
...
23 |     obj: object::File<'a>,
   |     ^^^^^^^^^^^^^^^^^^^^^ the trait `Clone` is not implemented for `object::File<'_, &[u8]>`

error[E0515]: cannot return value referencing local variable `data`
  --> src/inferior/mod.rs:29:9
   |
29 | /         Inferior {
30 | |             pid,
31 | |             state: InferiorState::Stopped,
32 | |             breakpoints: HashMap::new(),
33 | |             obj: object::File::parse(&*data).unwrap(),
   | |                                        ---- `data` is borrowed here
34 | |         }
   | |_________^ returns a value referencing data owned by the current function

A couple of these stand out as something more than just propagating the lifetime annotation.

error[E0515]: cannot return value referencing local variable `data`
  --> src/inferior/mod.rs:29:9
   |
29 | /         Inferior {
30 | |             pid,
31 | |             state: InferiorState::Stopped,
32 | |             breakpoints: HashMap::new(),
33 | |             obj: object::File::parse(&*data).unwrap(),
   | |                                        ---- `data` is borrowed here
34 | |         }
   | |_________^ returns a value referencing data owned by the current function

Ok, so the data is read outside of setting up the Inferior struct and then discarded when Inferior::new returns. I guess it is borrowed into object::File::parse? Need to fix that. And the second error that stands out is:

error[E0106]: missing lifetime specifier
  --> src/inferior/mod.rs:38:25
   |
38 | pub type TrapInferior = Inferior;
   |                         ^^^^^^^^ expected named lifetime parameter

I guess on one hand this is just propagation of the lifetime parameter, but on the other this is a leftover wart from some prior refactoring. TrapInferior used to be just a handle that we returned to the user and Inferior was the real struct. Now there is no handle and we return the real struct to the user. And this was a hack to avoid renaming everything. I want to clean this up. Ideally, I would clean this up before making the changes the required addition of lifetimes so that I could compile successfully to verify the rename is correct. I guess I’ll back up and do that first. The way I do this is to:

  • save the commit so I can cherry-pick it later: f3f9a19
  • reset back to origin/inferior-ownership-api (starting point for this post)
  • Verify that build is good
  • Rename all occurrences
  • Verify that the build is still good

Ok, this was just a straightforward search and replace.

modified   src/breakpoint/mod.rs
@@ -31,13 +31,13 @@ fn set(inferior: &TrapInferior, bp: &Breakpoint) {
     poke_text(inferior.pid, bp.aligned_address, modified);
 }
 
-fn find_breakpoint_matching_inferior_instruction_pointer(inf: &Inferior) -> Option<&Breakpoint> {
+fn find_breakpoint_matching_inferior_instruction_pointer(inf: &TrapInferior) -> Option<&Breakpoint> {
     let InferiorPointer(ip) = get_instruction_pointer(inf.pid);
     let ip = InferiorPointer(ip - 1);
     return inf.breakpoints.get(&ip);
 }
 
-pub fn handle<F>(inferior: &mut Inferior, callback: &mut F) -> InferiorState
+pub fn handle<F>(inferior: &mut TrapInferior, callback: &mut F) -> InferiorState
 where
     F: FnMut(&TrapInferior, TrapBreakpoint),
 {
modified   src/inferior/mod.rs
@@ -13,15 +13,15 @@ pub enum InferiorState {
 }
 
 #[derive(Clone)]
-pub struct Inferior {
+pub struct TrapInferior {
     pub pid: pid_t,
     pub state: InferiorState,
     pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
 }
 
-impl Inferior {
-    pub fn new(pid: pid_t) -> Inferior {
-        Inferior {
+impl TrapInferior {
+    pub fn new(pid: pid_t) -> TrapInferior {
+        TrapInferior {
             pid,
             state: InferiorState::Stopped,
             breakpoints: HashMap::new(),
@@ -29,8 +29,6 @@ impl Inferior {
     }
 }
 
-pub type TrapInferior = Inferior;
-
 #[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Eq, Hash)]
 pub struct InferiorPointer(pub u64);
 impl InferiorPointer {
modified   src/lib.rs
@@ -45,16 +45,16 @@ fn exec_inferior(filename: &Path, _args: &[&str]) {
     unreachable!();
 }
 
-fn attach_inferior(raw_pid: pid_t) -> Result<Inferior, Error> {
+fn attach_inferior(raw_pid: pid_t) -> Result<TrapInferior, Error> {
     let nix_pid = Pid::from_raw(raw_pid);
     match waitpid(nix_pid, None) {
-        Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(Inferior::new(pid.into())),
+        Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(TrapInferior::new(pid.into())),
         Ok(_) => panic!("Unexpected stop in attach_inferior"),
         Err(e) => Err(e),
     }
 }
 
-pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<Inferior, Error> {
+pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<TrapInferior, Error> {
     loop {
         match unsafe { fork() } {
             Ok(ForkResult::Child) => {

The build looks good and the tests pass.

Now, I did this in a side branch and I think I can just rebase my current changes on that branch.

$ git checkout object-files
Switched to branch 'object-files'
$ git rebase one-trap-inferior-type
Auto-merging src/inferior/mod.rs
CONFLICT (content): Merge conflict in src/inferior/mod.rs
Auto-merging src/lib.rs
CONFLICT (content): Merge conflict in src/lib.rs
error: could not apply f3f9a19... lifetimes?
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".

Ok, so let’s fix the merge conflicts starting with inferior/mod.rs:

#[derive(Clone)]
pub struct TrapInferior {
    pub pid: pid_t,
    pub state: InferiorState,
    pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
    obj: object::File,
}

<<<<<<< HEAD
impl TrapInferior {
    pub fn new(pid: pid_t) -> TrapInferior {
        TrapInferior {
=======
impl Inferior {
    pub fn new(pid: pid_t, path: &Path) -> Inferior {
	let data = fs::read(path).unwrap();
        Inferior {
>>>>>>> f3f9a19 (lifetimes?)
            pid,
            state: InferiorState::Stopped,
            breakpoints: HashMap::new(),
            obj: object::File::parse(&*data).unwrap(),
        }
    }
}

Ok, so we want the bottom version which we updated with the Path and we need to change Inferior to TrapInferior.

Then the conflict in src/lib.rs:

<<<<<<< HEAD
fn attach_inferior(raw_pid: pid_t) -> Result<TrapInferior, Error> {
    let nix_pid = Pid::from_raw(raw_pid);
    match waitpid(nix_pid, None) {
        Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(TrapInferior::new(pid.into())),
=======
fn attach_inferior(raw_pid: pid_t, filename: &Path) -> Result<Inferior, Error> {
    let nix_pid = Pid::from_raw(raw_pid);
    match waitpid(nix_pid, None) {
        Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(Inferior::new(pid.into(), filename)),
>>>>>>> f3f9a19 (lifetimes?)
        Ok(_) => panic!("Unexpected stop in attach_inferior"),
        Err(e) => Err(e),
    }
}

Again, I want the bottom version that has the filename handling and I’ll have to do the rename.

Ok, git rebase --continue and we are done. Note this does not build right now which is to be expected. We are now back to working on getting the lifetimes right. And it’s pretty much the list of errors we had before minus needing a lifetime on the TrapInferior type alias as we have just removed the alias altogether.

So I want to try to address the one that seems most important first.

error[E0515]: cannot return value referencing local variable `data`
  --> src/inferior/mod.rs:29:9
   |
29 | /         TrapInferior {
30 | |             pid,
31 | |             state: InferiorState::Stopped,
32 | |             breakpoints: HashMap::new(),
33 | |             obj: object::File::parse(&*data).unwrap(),
   | |                                        ---- `data` is borrowed here
34 | |         }
   | |_________^ returns a value referencing data owned by the current function

I ended up spending a lot of of time working on this after writing the above part of the blog. And this is more of a rabbit hole that I expected. At first I wasn’t quite sure what was going on. But it turns out that object::File::parse just builds up references to parts of data and uses that to provide the sections. It’s zero-copy. Cool, that’s efficient. But it means I have to keep the data around for the lifetime of TrapInferior.

My first inclination was to just keep the data in the TrapInferiorlike this:

modified   src/inferior/mod.rs
@@ -21,16 +21,19 @@ pub struct TrapInferior<'a> {
     pub state: InferiorState,
     pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
     obj: object::File<'a>,
+    data: Vec<u8>,
 }
 
 impl TrapInferior {
     pub fn new(pid: pid_t, path: &Path) -> TrapInferior {
 	let data = fs::read(path).unwrap();
+	let obj = object::File::parse(&*data).unwrap();
         TrapInferior {
             pid,
             state: InferiorState::Stopped,
             breakpoints: HashMap::new(),
-            obj: object::File::parse(&*data).unwrap(),
+            obj,
+	    data,
         }
     }
 }

Now, there are a bunch of problems with this. The first, is that I still have data and now also obj on the stack in this function so I can’t return them.

Now, it’s hard to try to get them into the struct together but even if I could it wouldn’t work. I didn’t know this but Rust does not support self-referential structs. That is, one field in a struct can’t contain a reference to another field. I found this both by asking GitHub Copilot and from this Stack Overflow answer. To summarize, the reason Rust does not support self-referential structs is because they can lead to unsafe memory accesses. The recommendation for how to solve this is to have the user call fs::read and pass in the data.

Google Gemini proposed an alternative of using the ouroboros crate which allows creation of self referential structs. However, Gemini recommended that having the user call fs::read is idiomatic Rust.

Coming from languages other than Rust this seemed strange to me. I would favor building an API that takes on the responsibility of reading the file so as to:

  1. Unburden the user from having to do it.
  2. Hide the implementation detail of where the data comes from and what format it is in.

Reading a file isn’t really a detail that needs to be hidden, I suppose. But the fact that it matches the input to object::File::parse is. What if I switched to a different crate one day? Now, it’s also just a vector of bytes so maybe any object parser would want that. But maybe it wouldn’t. In my opinion this couples my API to the implementation a little too tightly.

Gemini points out that the flip side of burdening the user with responsibility is empowering them with control over the lifetime of these objects. Which does sound like a good trade. Gemini provided a number of examples of popular crates that have APIs like this (serde, gimli).

Gemini goes to say

This pattern is a cornerstone of a philosophy in the Rust community called “Parse, Don’t Validate.” Instead of repeatedly passing around raw data (like a &[u8]) and validating it in every function, you parse it once into a structured type (like your TrapInferior<'data>) that guarantees validity through the type system. The lifetime parameter is the mechanism that makes this zero-copy parsing possible and safe.

That makes sense and I buy into “parse, don’t validate”. But, one thing about this bothered me: trap_inferior_exec is where we are currently passed the binary filename. And it will need to be updated to take the binary data as well. So it will look something like:

pub fn trap_inferior_exec(filename: &Path, args: &[&str], data: vec<u8>) -> Result<TrapSession, Error>

This looks great. Well except for one thing, if I’m going to validate up front and then encode all the validated inputs in the type system then do I need to validate that filename and data actually correspond to each other? That is, how do I validate that the user actually called fs::read on the right file? Or should I validate this?

Encapsulating Validated File Names and File Data

If I really wanted to validate it then that would mean calling fs::read inside of trap_inferior_exec and comparing that the data I read matches the data the user passed in. This seems inefficient at best and silly at worse. This is a (proposed) code smell that this is not a good API choice.

When I asked Gemini it agreed that this was overkill (it’s very agreeable) and then ran off to tell me all about how to implement it.

So instead of blindly accepting Gemini’s validation code, I sat back and thought a little bit. Parse, don’t validate–right? What I would really like is for there to be a type, a struct, that encapsulates both the filename and the data. And that type should indicate that the two are in correspondence. I see no reason why such a thing can’t exist. rusty_trap should just provide this type and a API to create it. The type can be created from just the filename and the API can do the fs::read. Then it can return the validated type to the user and the user can in turn pass it (lend) into trap_inferior_exec. The user can control the lifetime of this thing.

Ok, so let’s build that. I guess call it TrapData.

Ideally, I should go back to an earlier commit where tests pass and add this but I’m tired from arguing with Gemini. Let’s just do it.

pub struct TrapData<'a> {
    pub filename: &'a Path,
    pub data: Vec<u8>,
}

impl <'a> TrapData<'a> {
    pub fn new(filename: &Path) -> TrapData {
	TrapData {
	    filename: filename,
	    data: fs::read(filename).unwrap()
	}
    }
}

Now, let’s see if I can implement TrapInferior using this.

Ok, this much of it seems to compile:

#[derive(Clone)]
pub struct TrapInferior<'a> {
    pub pid: pid_t,
    pub state: InferiorState,
    pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
    obj: object::File<'a>,
}

impl <'a> TrapInferior<'a> {
    pub fn new(pid: pid_t, trap_data: &'a TrapData<'a>) -> TrapInferior<'a> {
        TrapInferior {
            pid,
            state: InferiorState::Stopped,
            breakpoints: HashMap::new(),
            obj: object::File::parse(&*trap_data.data).unwrap(),
        }
    }
}

Well, actually it doesn’t because object::File doesn’t implement Clone so TrapInferior can’t derive Clone but I’ll come back to that later.

I think the lifetimes here are correct. For TrapInferior: the object::File lifetime has to be at least as long as the TrapInferior lifetime as a whole.

And for TrapInferior::new we require that TrapData live at least as long as the returned TrapInferior because the TrapInferior will reference the data.

Ok, now we can push this interface change up the call chain.

modified   src/lib.rs
@@ -46,23 +46,23 @@ fn exec_inferior(filename: &Path, _args: &[&str]) {
     unreachable!();
 }
 
-fn attach_inferior(raw_pid: pid_t, filename: &Path) -> Result<TrapInferior, Error> {
+fn attach_inferior<'a>(raw_pid: pid_t, data: &'a TrapData) -> Result<TrapInferior<'a>, Error> {
     let nix_pid = Pid::from_raw(raw_pid);
     match waitpid(nix_pid, None) {
-        Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(TrapInferior::new(pid.into(), filename)),
+        Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(TrapInferior::new(pid.into(), data)),
         Ok(_) => panic!("Unexpected stop in attach_inferior"),
         Err(e) => Err(e),
     }
 }
 
-pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<TrapInferior, Error> {
+pub fn trap_inferior_exec<'a>(data: &'a TrapData, args: &[&str]) -> Result<TrapInferior<'a>, Error> {
     loop {
         match unsafe { fork() } {
             Ok(ForkResult::Child) => {
-                exec_inferior(filename, args);
+                exec_inferior(data.filename, args);
                 unreachable!();
             }
-            Ok(ForkResult::Parent { child: pid }) => return attach_inferior(pid.into(), filename),
+            Ok(ForkResult::Parent { child: pid }) => return attach_inferior(pid.into(), data),
             Err(Error::EAGAIN) => continue,
             Err(e) => return Err(e),
         }

Ok, this was pretty straightfoward and seems ok so far. There are some other compile errors to fix and who knows if the next compile pass will find some new problems here after that.

Let’s clean up the leftover errors:

error[E0106]: missing lifetime specifier
  --> src/breakpoint/mod.rs:34:88
   |
34 | fn find_breakpoint_matching_inferior_instruction_pointer(inf: &TrapInferior) -> Option<&Breakpoint> {
   |                                                               -------------            ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say which one of `inf`'s 2 lifetimes it is borrowed from
help: consider introducing a named lifetime parameter
   |
34 | fn find_breakpoint_matching_inferior_instruction_pointer<'a>(inf: &'a TrapInferior<'a>) -> Option<&'a Breakpoint> {
   |                                                         ++++       ++             ++++             ++

Ok, we need some lifetimes here because TrapInferior just needs one and the returned Breakpoint reference is a borrow from inf.breakpoints.

modified   src/breakpoint/mod.rs
@@ -31,7 +31,7 @@ fn set(inferior: &TrapInferior, bp: &Breakpoint) {
     poke_text(inferior.pid, bp.aligned_address, modified);
 }
 
-fn find_breakpoint_matching_inferior_instruction_pointer(inf: &TrapInferior) -> Option<&Breakpoint> {
+fn find_breakpoint_matching_inferior_instruction_pointer<'a>(inf: &'a TrapInferior) -> Option<&'a Breakpoint> {
     let InferiorPointer(ip) = get_instruction_pointer(inf.pid);
     let ip = InferiorPointer(ip - 1);
     return inf.breakpoints.get(&ip);
error[E0106]: missing lifetime specifier
  --> src/breakpoint/mod.rs:98:7
   |
96 |     mut inferior: TrapInferior,
   |                   ------------
97 |     location: &str,
   |               ----
98 | ) -> (TrapInferior, TrapBreakpoint) {
   |       ^^^^^^^^^^^^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `inferior` or `location`
help: consider introducing a named lifetime parameter
   |
95 ~ pub fn trap_inferior_set_breakpoint<'a>(
96 ~     mut inferior: TrapInferior<'a>,
97 ~     location: &'a str,
98 ~ ) -> (TrapInferior<'a>, TrapBreakpoint) {
   |

This is the nice API where we borrow in and then give back. So it’s borrowed from inferior.

-pub fn trap_inferior_set_breakpoint(
-    mut inferior: TrapInferior,
+pub fn trap_inferior_set_breakpoint<'a>(
+    mut inferior: TrapInferior<'a>,
     location: &str,
-) -> (TrapInferior, TrapBreakpoint) {
+) -> (TrapInferior<'a>, TrapBreakpoint) {
     let address: u64 = 0x55555555b9f4;
     return set_breakpoint_at_address(inferior, address);
 }

Ok, now we are down to this error about clone:

error[E0277]: the trait bound `object::File<'_, &[u8]>: Clone` is not satisfied
  --> src/inferior/mod.rs:38:5
   |
33 | #[derive(Clone)]
   |          ----- in this derive macro expansion
...
38 |     obj: object::File<'a>,
   |     ^^^^^^^^^^^^^^^^^^^^^ the trait `Clone` is not implemented for `object::File<'_, &[u8]>`

Some errors have detailed explanations: E0106, E0277.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `rusty_trap` (lib) due to 3 previous errors
warning: build failed, waiting for other jobs to finish...
error: could not compile `rusty_trap` (lib test) due to 3 previous errors

Ok, so I guess to start with let me ask: why do I need clone?

  • I think it’s because I have something like this:
pub fn trap_inferior_continue<F>(mut inferior: TrapInferior, mut callback: F) -> (TrapInferior, i32)
where
    F: FnMut(&TrapInferior, TrapBreakpoint)

So that I’m passing these TrapInferior objects around without references and they would shallow copy. As a reminder this is what’s in there:

#[derive(Clone)]
pub struct TrapInferior<'a> {
    pub pid: pid_t,
    pub state: InferiorState,
    pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
    obj: object::File<'a>,
}

Before I added obj we could easily clone this because it’s just some ints and a HashMap that can be cloned. If I got rid of Clone I would have to copy and copying the HashMap seems like a bad idea.

So, what if instead I get rid of Clone and pass by reference? I guess let’s try it.

FWIW just removing clone actually builds (though the tests need some modifications). But, it’s not efficient so let’s keep fixing (honestly maybe that’s not the best choice in real life - get your tests to pass before optimizing).

Interesting, because I’m allowed to pass a reference through a non-reference I can’t use the compiler to find all the places I need to update. But, I think this does it:

modified   src/breakpoint/mod.rs
@@ -68,10 +68,10 @@ where
     };
 }
 
-fn set_breakpoint_at_address(
-    mut inferior: TrapInferior,
-    location: u64,
-) -> (TrapInferior, TrapBreakpoint) {
+fn set_breakpoint_at_address<'a>(
+    mut inferior: &'a mut TrapInferior<'a>,
+    location: u64
+) -> (&'a TrapInferior<'a>, TrapBreakpoint) {
     let aligned_address = location & !0x7u64;
     let target_address = InferiorPointer(location);
     inferior.breakpoints.insert(
@@ -93,9 +93,9 @@ fn set_breakpoint_at_address(
 }
 
 pub fn trap_inferior_set_breakpoint<'a>(
-    mut inferior: TrapInferior<'a>,
+    mut inferior: &'a mut TrapInferior<'a>,
     location: &str,
-) -> (TrapInferior<'a>, TrapBreakpoint) {
+) -> (&'a TrapInferior<'a>, TrapBreakpoint) {
     let address: u64 = 0x55555555b9f4;
     return set_breakpoint_at_address(inferior, address);
 }
modified   src/inferior/mod.rs
@@ -30,7 +30,6 @@ pub enum InferiorState {
     SingleStepping,
 }
 
-#[derive(Clone)]
 pub struct TrapInferior<'a> {
     pub pid: pid_t,
     pub state: InferiorState,
modified   src/lib.rs
@@ -69,7 +69,7 @@ pub fn trap_inferior_exec<'a>(data: &'a TrapData, args: &[&str]) -> Result<TrapI
     }
 }
 
-pub fn trap_inferior_continue<F>(mut inferior: TrapInferior, mut callback: F) -> (TrapInferior, i32)
+pub fn trap_inferior_continue<'a, F>(mut inferior: &'a mut TrapInferior, mut callback: F) -> (&'a TrapInferior<'a>, i32)
 where
     F: FnMut(&TrapInferior, TrapBreakpoint),
 {

Now we just need to fix the tests. I have a bunch of errors like this:

error[E0308]: mismatched types
  --> tests/lib.rs:9:51
   |
9  | ... = rusty_trap::trap_inferior_exec(Path::new("./target/debug/twelve"), &...
   |       ------------------------------ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `&TrapData<'_>`, found `&Path`
   |       |
   |       arguments to this function are incorrect
   |
   = note: expected reference `&rusty_trap::inferior::TrapData<'_>`
              found reference `&Path`
note: function defined here
  --> /home/jkain/projects/rusty_trap/src/lib.rs:58:8
   |
58 | pub fn trap_inferior_exec<'a>(data: &'a TrapData, args: &[&str]) -> ...
   |        ^^^^^^^^^^^^^^^^^^

There is one of these per test. We need to fix them all by creating a TrapData and passing that instead of the filename.

Ok, that’s done. Now tests compile but fail because they are passing in breakpoint names and we are setting some bogus breakpoint address. E.g. we’ve finally got our types, lifetimes, and references right and can get back to work on adding symbol table support to our debugger. Let’s use the object::File to look up the addresses for the symbol names:

pub fn trap_inferior_set_breakpoint<'a>(
    inferior: &'a mut TrapInferior<'a>,
    location: &str,
) -> (&'a mut TrapInferior<'a>, TrapBreakpoint) {
    let mut address: u64 = 0;
    for symbol in inferior.obj.symbols() {
	let name = symbol.name().unwrap();
	let symbol_address = symbol.address();
	println!("Checking symbol {name} at {symbol_address}");
	if name == location {
	    address = symbol_address;
	    break;
	}
    }

    return set_breakpoint_at_address(inferior, address);
}

Implementing Symbol Resolution

But this never finds the symbol because the symbols look like this:

Checking symbol _ZN4loop3foo17hcca06054783c5707E at 0x7990
Checking symbol _ZN4loop4main17h32a7c34928272921E at 0x79a0

This is the name mangling that I mentioned before. If you are not familiar with these, they are essentially loop::foo and loop::main with additional information encoded such as namespaces, type signatures, and a hash. This ensures uniqueness (think function overloading). It’s also worth noting that the address is the small address and doesn’t include the offset where the binary gets loaded in memory (e.g. the 0x55555… we keep seeing). We’ll need to come back to that. Demangling is the process of converting back to the human readable names and is useful so we can match the names form the symbol table against the function names where we want to set breakpoints.

First, we can use the rustc_demangle crate to decode symbol names. I’m just following the example and ignorign the hash. Ok, this much seems to be working:

Found symbol loop::foo at 0x79c0

Now we need to work on the address. There are two things really:

  1. We need to find the offset to add to get the real address.
  2. It looks like there is something else a little off in that I thought the lower 3 nibbles should match (the page offest) but they don’t. We used to have:
const ADDRESS_OF_FOO: u64 = 0x55555555b9e0;

Let’s start by just taking the offset as a constant and adding it on. That way we can have passing tests and then we can figure out where to get the offset from properly. I’m assuming that both (all) inferiors would have the same offset.

    const BASE: u64 = 0x555555554000;

And the tests pass. Where did I get this address, well I computed

0x55555555b9e0 - 0x79c0

Though this actually works out to 0x555555554020 but that address wouldn’t make any sense. It should aligned to 4KB (4096 or 0x1000 bytes). So I rounded it down. Now, why doesn’t the subtraction give this number to being with? I’m not sure, lihely the inferior has changed since the last time I checked. Let’s check again by loading the inferior in gdb:

Breakpoint 1, 0x000055555555ba20 in main ()
(gdb) b loop::main
Breakpoint 2 at 0x55555555b9d4: file tests/inferiors/loop.rs, line 6.
(gdb) b loop::foo
Breakpoint 3 at 0x55555555b9c0: file tests/inferiors/loop.rs, line 3.

So, it seems they have moved and in fact by 0x20 bytes. This is rather timely as we’ve just implemented a feature (symbol lookup) that our tests immune to these changes.

And now when I do the subtraction it works out properly:

(gdb) p /x 0x55555555b9c0 - 0x79c0
$1 = 0x555555554000

The tests now pass:

running 4 tests
test it_can_exec ... ok
test it_can_handle_a_breakpoint_more_than_once ... ok
test it_can_set_breakpoints ... ok
test it_can_handle_more_than_one_breakpoint ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Finding the Base Address

Now that we know that having the correct base address will make our tests pass we need to add support to rusty_trap to look it up rather than hardcode it. I poked around at a few different fields in the object file trying to figure out where to get the load address. But ultimately, I ended up asking Bing Copilot:

Me:

When a PIE binary is loaded into memory on Linux to be executed what is the load address? Usually there is address space randomization but I have disabled that using the personality flag before exexcing my process because I am building a debugger. I can find symbol addresses in the ELF object but they need to be offset by the load address. Where should I find this?

Copilot:

When you compile with -pie, the kernel treats your executable as an ET_DYN object and maps its PT_LOAD segments at a runtime-chosen “load base.” With ASLR disabled (ADDR_NO_RANDOMIZE), the kernel uses a fixed, architecture-specific base (on x86_64 it’s traditionally 0x400000).

Why the load base matters Symbol values in the ELF file (st_value) are relative to the link-time base (often 0 for ET_DYN). To turn a symbol’s file offset into the actual virtual address, you add the runtime load base—aka the load bias or l_addr.

Ok, this is basically matches what I understood. Note that there is a lot of jargon here. Let me try to explain:

  • PIE binary: Position Independent Executable - meaning that the binary can be loaded into any address in memory. Older styles of object files did not do this for the main executable but did for dynamic libraries.
  • ET_DYN: I think Copilot is saying that the binary gets treated the same way that a dynamic library or other dynamic object would be treated.
  • PT_LOAD Segment: A segment is a range of an ELF object that gets loaded into memory. It can contain sections. PT_LOAD includes the main binary code sections.

Anyway, I vaguely remember the address 0x400000 being the Linux default load address but that’s clearly not what we are seeing here. Copilot goes on to give some practical advice:

Three ways to discover the load base

  • /proc/[pid]/maps: Open the target’s /proc/[pid]/maps and locate the entry whose pathname matches your binary. The start address of that VMA is your load base.
  • Auxiliary vector (auxv): Read /proc/[pid]/auxv and find the AT_PHDR entry (pointer to the in-memory program headers). Compute:
    • load_base = AT_PHDRELF_HDR.e_phoff**
    • where e_phoff comes straight from your ELF header.
  • Dynamic loader APIs: Use dl_iterate_phdr. In its callback, when dlpi_name[0] == ‘\0’, the field dlpi_addr is the main executable’s base. Alternatively, dladdr(&main, &info) returns info.dli_fbase.

Copilot goes on to give an example of how to implement the first option in C:

uint64_t get_load_base(pid_t pid, const char* exe_path) {
    char filename[64], line[256];
    uint64_t base = 0;
    snprintf(filename, sizeof(filename), "/proc/%d/maps", pid);
    FILE *f = fopen(filename, "r");
    while (fgets(line, sizeof(line), f)) {
        if (strstr(line, exe_path)) {
            sscanf(line, "%lx-", &base);
            break;
        }
    }
    fclose(f);
    return base;
}

This sounds pretty good. And we may need the maps file again later if we want to find any other dynamic libraries that are loaded (if we wanted to set breakpoints in them). Let’s implement a function like this in Rust and then we will call it after we exec and ptrace the inferior and it stops for the first time. That’s our first opportunity to poke around.

I ended up with this function:

// Helper function to look up the base address by inspected /proc/pid/maps
fn get_base_address(pid: pid_t, filename: &Path) -> u64 {
    let proc_filename = format!("/proc/{pid}/maps");
    let file = fs::File::open(proc_filename).expect("Unable to open file");
    let expected = fs::canonicalize(filename).unwrap();
    let expected = expected.to_str().unwrap();
    let reader = BufReader::new(file);
    for line in reader.lines() {
	let line = line.unwrap();
	if line.contains(expected) {
	    let addr_str = line.split('-').next().unwrap();
	    println!("Found base address 0x{addr_str}");
	    return u64::from_str_radix(addr_str, 16).unwrap();
	}
    }
    // This should be an error, there should be error handling.
    println!("Could not find base address for {expected}");
    assert!(false);
    0
}

Which in fact finds the address and prints:

Found base address 0x555555554000

Now we just use this instead of our hard coded version and find that the tests still pass.

Wow, that was a lot of work, but we’ve done it. We’ve implemented symbol look for our inferior and can use it to set breakpoints! Here’s the final set of changes:

modified   src/breakpoint/mod.rs
@@ -100,14 +100,12 @@ pub fn trap_inferior_set_breakpoint<'a>(
 ) -> (&'a mut TrapInferior<'a>, TrapBreakpoint) {
     let mut address: u64 = 0;
 
-    const BASE: u64 = 0x555555554000;
-
     for symbol in inferior.obj.symbols() {
 	let name = format!("{:#}", demangle(symbol.name().unwrap()));
 	let symbol_address = symbol.address();
 	if name == location {
 	    println!("Found symbol {name} at 0x{symbol_address:x}");
-	    address = symbol_address + BASE;
+	    address = symbol_address + inferior.base_address;
 	    break;
 	}
     }
modified   src/inferior/mod.rs
@@ -7,6 +7,7 @@ use std::ops::{Add, Sub};
 use object;
 use std::fs;
 use std::path::Path;
+use std::io::{BufRead, BufReader};
 
 
 pub struct TrapData<'a> {
@@ -35,6 +36,7 @@ pub struct TrapInferior<'a> {
     pub state: InferiorState,
     pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
     pub obj: object::File<'a>,
+    pub base_address: u64,
 }
 
 impl <'a> TrapInferior<'a> {
@@ -44,10 +46,33 @@ impl <'a> TrapInferior<'a> {
             state: InferiorState::Stopped,
             breakpoints: HashMap::new(),
             obj: object::File::parse(&*trap_data.data).unwrap(),
+	    base_address: get_base_address(pid, trap_data.filename),
         }
     }
 }
 
+// Helper function to look up the base address by inspected /proc/pid/maps
+fn get_base_address(pid: pid_t, filename: &Path) -> u64 {
+    let proc_filename = format!("/proc/{pid}/maps");
+    let file = fs::File::open(proc_filename).expect("Unable to open file");
+    let expected = fs::canonicalize(filename).unwrap();
+    let expected = expected.to_str().unwrap();
+    let reader = BufReader::new(file);
+    for line in reader.lines() {
+	let line = line.unwrap();
+	if line.contains(expected) {
+	    let addr_str = line.split('-').next().unwrap();
+	    println!("Found base address 0x{addr_str}");
+	    return u64::from_str_radix(addr_str, 16).unwrap();
+	}
+    }
+    // This should be an error, there should be error handling.
+    println!("Could not find base address for {expected}");
+    assert!(false);
+    0
+}
+
+
 #[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Eq, Hash)]
 pub struct InferiorPointer(pub u64);
 impl InferiorPointer {

I’ve pushed a pull request for Symbol Lookup in breakpoints.

Enjoying the post? Sign up for the newsletter or support the work on Buy Me a Coffee .

Lessons Learned

So, we set out implement symbol resolution and along the way learned about lifetimes, self referential structs, and applied systems thinking to our API design (again). In the end we made our debugger much more powerful in and less fragile.

In C, I would have just stuffed everything into one struct, given only a cursory thought to lifetime, and focused on the symbol resolution and breakpoint setting. Rust forces us to think deeply about systems-level problems and that’s a good thing. In C I might think about the lifetimes of the various objects and I might conclude that things look good inside of the debugger but it would be hard to convey those requirements back to the user. Rust makes this explicit and the trade off is that I have to think a little more to make sure it’s going to work both for rusty_tray and for the user of rusty_trap. I have to solve that up front and then I can encode it the type system. This way I solve it on behalf of all the user as well. In C each user must independently think through this proble.. In some way this is like code-usability except here it’s lifetime reusablity. The solution to the lifetime issues for rusty_trap are solved once and reused. The extra thinking required is a good investment.

The self referential structs are also a interesting topic. In C, or maybe C++, I would actually prefer to keep the file data and my uses of the file data together. They are used together so store them together. Their lifetimes are coupled so keep them together. In fact, for my use their lifetimes are basically the same - I only want the file data so I can get an object::File to work with. I suppose though if I wanted to use the file data for something else then coupling tightly together would be a bad idea. I am happy though that we were able to build a nice way to insure that the file name and file data were in correspondence and encode that in the type system.

I like the fact that Rust is pushing my understanding of API design by forcing me to think harder about lifetimes, abstractions, and how information should flow through my systems.

In our next post we’ll see how Rust can inform our design when we work on supporting multithreaded inferiors in our debugger.

Rusty trap icon
This post is part of my Writing a Debugger series.
View all posts and upcoming topics →