Metal.Serial: ELFs & DWARFs

Today we have a guest post from Klemens Morgenstern, an embedded C++ consultant. You can learn more about Klemens on his website.


Metal.Serial: ELFs & DWARFs

I am currently working on my metal.serial tool. It will make it easy to communicate through a serial port (or any byte steam) with a device, with minimal overhead on the target. This series of articles goes through some issues that I found interesting.

The Problem

In the previous post, I went through my solution for marking code locations with a minimal footprint using a combination of preprocessor and inline assembly. I think the limitations of portability are worth it, given the minimal footprint. However, there is another problem we need to solve: the program addresses change rather easily. That means we need the binary on the target to be the same as on the host and we need addr2line to be present as well. In this article, we’ll streamline the process and remove those two requirements.

Example Program

For our test program, we’ll create a simple logger. It writes the address in binary form followed by a string log message. We’ll print the information through stdout so you can run this on a Linux machine. Of course, you can just log with anything that boils down to a byte-stream and run the program on any system that supports elf.

We’ll also be dealing with outlines & disassembly, so I’ll stick to C for simplicity’s sake.

#include <stdio.h>
#include <stdint.h>
#include <string.h>

#define FPRINT_LOCATION_IMPL(CNT) \
    { \
       __asm("__metal_serial_" #CNT ":" ); \
        extern const uintptr_t __code_location ## CNT __asm("__metal_serial_" #CNT);   \
        const void* location_ptr =  & __code_location ## CNT; \
        fwrite(&location_ptr, sizeof(void*), 1, stdout) ; \
    }

#define FPRINT_LOCATION_IMPL2(CNT) FPRINT_LOCATION_IMPL(CNT)
#define FPRINT_LOCATION() FPRINT_LOCATION_IMPL2(__COUNTER__)

//This is our log, write the location first, then the message
#define LOG(Message) FPRINT_LOCATION(); fwrite(Message, 1, strlen(Message) + 1, stdout);

//Write the location of main, so we know the offset
#define INIT() { const void * main_ptr = &main; fwrite(&main_ptr, sizeof(void*), 1, stdout); }

int main(int argc, char * argv[])
{
    freopen(NULL, "wb", stdout); //so we can write binary data
    INIT();
    
    LOG("Hello host machine!");
    
    LOG("Signing off.");
    return 0;
}

The LOCATION_IMPL macro is changed so it writes to stdout , since C doesn’t allows us to use a lambda function. The LOG macro takes the message, prints the current location, and then prints the message itself (including the null-terminator). Let’s run it:

gcc example.c -g -o example
./example 
:w��U�w��UHello host machine!�w��USigning off.

As you can see, the binary location data in the output isn’t all that helpful. Let’s store the information in a file to avoid encoding issues.

./example > example.log

Note: We’ll use this file later in the article.

Obtaining the location data

In the original article, we used addr2line to interpret the address after we read it from target. This is nice as long as we have the binary available. There’s another tool we can use to interpret addresses: objdump.

objdump ./example --dwarf=decodedline
./example:     file format elf64-x86-64

Contents of the .debug_line section:

CU: ./example.c:
File name                            Line number    Starting address    View
example.c                                     23               0x73a
example.c                                     23               0x749
example.c                                     24               0x758
example.c                                     25               0x773
example.c                                     27               0x79e
example.c                                     29               0x7e9
example.c                                     30               0x834
example.c                                     31               0x839
example.c                                     31               0x84f

We can use objdump to get the filename and line number of every symbol in the .debug_line section. We would just need to store this information in a file, and we could look up every address to get the proper location. But doing this manually is tedious, so let’s automate the process.

Reading ELF with python

One approach would be to parse the objdump output. But maintaining parsers is a lot of work, those solutions are quite brittle since formatting may change in a new release. Since we are most likely only using elffiles, I much prefer to use pyelftools to create a pure Python tool.

Note: You can install pyelftools with pip.

The ideas behind our tool is that it generates a file that contains the line & address information. It gets the symbol table similar to what nm does. Then it checks the debug symbols and get all the code locations we need.

The following example can be found as a single file here.

# usage python generate.py <binary> 

from elftools.elf.elffile import ELFFile
from elftools.elf.sections import SymbolTableSection

import json
import sys
import os

with open(sys.argv[1], "rb") as binary:
  elffile = ELFFile(binary)
 

The ELFFile gives us all the tools we need to access the information we need. The file needs to remain open while we read from it.

The following is essenially what nm does, we go into the Symbol Table section and get name & value for all the symbols.

  # This is essentially what `nm` does
  symbols = []
  for section in elffile.iter_sections():
    if isinstance(section, SymbolTableSection):
      symbols = [{'name': sym.name, 'address': sym['st_value']} for sym in section.iter_symbols() if len(sym.name) > 0 ]
    continue

However, we only need three symbols: the main function and the two marks. We know the mark names start with __metal_serial_ so it is almost trivlal to filter them.

  main = next(sym for sym in symbols if sym['name'] == 'main')
  marks = [sym for sym in symbols if sym['name'].startswith('__metal_serial_')] 

The next step is a bit more tricky, since we need to get into debug symbols. But first check we have the debug info overall, so there’s an error when they are missing.

  if not elffile.has_dwarf_info():
    raise Exception("This tool needs debug info.")

  dbg = elffile.get_dwarf_info()

We get every mark and try to find it’s location as file/line.

The debug symbols have a page per compile unit, so we need to find the compile unit with the matching entry. This is a nested for loop: we iterate over every compile unit and then get the ‘.debug_line’ section for it .

  for mark in marks:
    compile_unit, location = next((cu, entry) for cu in dbg.iter_CUs() for entry in dbg.line_program_for_CU(cu).get_entries() 
                                            if entry.state is not None and entry.state.address == mark['address'])
                                            
    debug_line = dbg.line_program_for_CU(compile_unit)

We got the compile unit and the entry in the debug symbols from the .debug_line section.

There is one more complication: the file in the entry is an index referring to the file table, which in turn refers to the directory table. And they are 1-indexed. So let’s add a quick function for that.

    def file_entry_to_abs(file_entry, linep):
      di = file_entry.dir_index
      if di > 0:
        return os.path.join(linep['include_directory'][di-1].decode(), file_entry.name.decode())
      else:
        return os.path.join('.', file_entry.name.decode())
    
    mark['filename'] =  file_entry_to_abs(debug_line['file_entry'][location.state.file - 1], debug_line)
    mark['line']   = location.state.line

We got the address of main and the relevant code location marks, so let’s dump it as a json:

  print(json.dumps({'main': main, 'marks': marks}))  

With this generate.py file we can now create a JSON payload telling us all of the file and line number information we need for our logger.

python3 generate.py ./example
{"main": {"name": "main", "address": 1850}, "marks": [{"name": "__metal_serial_0", "address": 1950, "filename": "./example.c", "line": 27}, {"name": "__metal_serial_1", "address": 2025, "filename": "./example.c", "line": 29}]}

We’ll store this in a JSON file. As you can see, this step would be easy to integrate with any build system.

python3 generate.py ./example > example_marks.json

We have our mark-locations and the log file, so let’s automate the location replacement process.

You can find the full script here.

# usage python interpret.py <database> 

import sys
import json

We will read the input from stdin to keep the example simple. First we load the previously generated location database.

db = None
with open(sys.argv[1]) as f:
    db = json.load(f)

Then we need two functions: one to read integers as binary and one to read the strings.
For the read_ptr function we use 8 bytes with little endian, since I am running this on an x64 machine. This needs to adjusted depending on the target. This can be detected by sending a proper header before the pointer, but we skip that for the example.


def read_ptr():
  bytes = sys.stdin.buffer.read(8)
  
  # This means it's closed, so we stop execution.
  if len(bytes) == 0: 
      return None
  return int.from_bytes(bytes, byteorder="little")

Secondly read the string until the null terminator.

def read_string():
  chars = bytearray()
  while True:
    c = sys.stdin.buffer.read(1)
    if c == b'\x00': # Null terminator, so return the previous bytes decoded as a string
      return chars.decode()
    chars.extend(c)

We start by reading the address of main and calculating the offset.

main_addr = read_ptr()
offset = main_addr - db['main']['address']

The loop just reads a pointer and then a string. The break condition is when read_ptr returns None, which means stdin has been closed.

After we have both values, we find the mark in the database at the written address and with that we generate an output string.

while True: 
  addr_raw = read_ptr()
  if addr_raw is None:
      break
  
  addr = addr_raw - offset

  msg = read_string()
  # If this throws StopIterator we have an error in the pre generated data
  mark = next(mark for mark in db['marks'] if mark['address'] == addr)

  # This is the formatting to so we get {filename}({line}): {message}
  print('{}({}): {}'.format(mark['filename'], mark['line'], msg))

Now we can pipe the log file into the script we just created (or a serial port for that matter):

cat ./example.log | python3 interpret.py example_marks.json
./example.c(27): Hello host machine!
./example.c(29): Signing off.

Since our script reads from stdin and the program prints to stdout, we can even do the following:

./example | python3 interpret.py example_marks.json
./example.c(27): Hello host machine!
./example.c(29): Signing off.

Please take note, that the machine running interpret.py only needs the example_marks.json and no sources, binaries or other tools. This means you only need to keep track of one JSON file for debugging purposes.

Summary

In this example we used the pyelftools library to replace objdump & nm, while also showing how easily custom tooling can be created with python. Debug symbols are an extensive topic that no one article can completely cover, but I hope this demonstration might show why it might be worth it., to invest some time into understanding debug symbols and develop custom tooling.

It was also important to get started with a creating python tooling, because in the next article we will use pcpp to parse preprocessor macros and give our interpreter script superpowers.

Share Your Thoughts

This site uses Akismet to reduce spam. Learn how your comment data is processed.