ELF Symbol Handling and Abstract Machine Infrastructure

Engineering

Posted by Bruce Lee on 2024-08-25

About Me

Welcome to my blog! This is where I collect my observations and notes on programming and technology. The main subjects range from implementation details to broader ideas about programming.

Main Topics

  • Engineering Projects: Exploring implementation details and how technical systems work.
  • C/C++: Notes on language features and programming techniques.
  • The Programmer’s Perspective: Ideas about developing a career and a way of thinking as a programmer.

For more, visit the categories page.

Contact

If you have questions or would like to discuss something, please get in touch through the About page.

Thank you for reading and for your support. I hope these notes help you on your own technical journey!


Handling ELF Files

Later notes will examine how a loader uses ELF structures directly. Here the immediate goal is to obtain function symbols for tracing.

libelf.h declares data structures and functions for reading, modifying, and writing ELF objects, including section and symbol information. gelf.h provides generic interfaces that help hide differences between 32-bit and 64-bit ELF representations.

Objects Used During Parsing

  • An Elf handle refers to the opened ELF object.
  • Elf_Scn *scn identifies a section while iterating.
  • GElf_Shdr shdr holds section-header information.
  • Elf_Data *data describes a section’s data.
  • An integer fd holds the underlying file descriptor.

Open the supplied filename with open and O_RDONLY, declared in fcntl.h. Call elf_version(EV_CURRENT) to establish the ELF interface version and check compatibility. EV_NONE indicates failure. This checks the library interface version; it is not a package-update check.

Next, elf_begin(fd, ELF_C_READ, ...) creates the read-only ELF handle. On failure, elf_errmsg(-1) provides the most recent library error description.

Finding the Symbol Table

Use elf_nextscn to iterate over sections. A non-NULL result identifies the next section. gelf_getshdr obtains its header, and comparing sh_type with SHT_SYMTAB identifies a regular symbol-table section.

For that section, elf_getdata provides the data descriptor. Dividing sh_size by sh_entsize gives the entry count, assuming the entry size is valid.

My implementation allocated arrays for function names and pairs of address/size values. It initially reserved space based on the total symbol count.

Extracting Function Symbols

For each entry, gelf_getsym fills a GElf_Sym. GELF_ST_TYPE(sym.st_info) identifies the symbol type; retain entries with type STT_FUNC.

elf_strptr retrieves the function name using two different pieces of information: shdr.sh_link identifies the associated string-table section, and sym.st_name is the offset within that string table. The function address and size come from st_value and st_size.

Store those values in symtable_name and symtable_value for the tracing code. Their lifetime must remain valid while the tracer uses them.

Connecting the Data to Execution

In cpu-exec.c, declare the shared variables with extern. I used different ANSI colors for CALL and RET output and placed the address comparison in ftrace_exec.

The current instruction address and s->dnpc serve different purposes. The latter is the actual next PC after execution. Keeping the old/current PC as well helps account for the first instruction at startup and transitions into function entries. exec_once calls the tracing helper.

An early bug produced unrelated entries in the function arrays because I mixed the total symbol count with the retained-function count. Using a separate symtable_count for STT_FUNC entries fixed the indexing. The arrays remained dynamically allocated arrays; I did not introduce a linked list.

The monitor-side declarations, includes, initialization function, and init_monitor call are all guarded by the feature macro. The same applies to the declarations, helper, and call site in cpu-exec.c.

The build configuration also needs to connect the NEMU feature selection with the AM-side launch rules. In the setup described by these notes, I still adjusted the AM nemu.mk feature definition manually after selecting the NEMU option in menuconfig.

Symbols That Seem to Disappear

Preprocessor macro names generally do not appear as ordinary linker symbols because expansion has already replaced them. Local variables may be optimized away or represented as stack locations without ordinary symbol-table entries. Debug information is a separate source of information about such variables.

An ELF symbol table commonly records functions, global or static objects, section symbols, and file symbols. Symbol values must be interpreted according to their type and the ELF object’s stage; they are not always fixed physical memory addresses.

Consider:

1
2
3
4
5
6
#include <stdio.h>
int a = 0;
int main()
{
printf("hey %d", a);
}

Build it through the intermediate stages:

1
2
3
$gcc -S main.c -o main.s
$gcc -c main.s -o main.o
$gcc main.o -o main.elf

Then inspect it:

1
readelf -a main.elf

Or compare:

1
readelf -p .strtab main.elf

The filename’s position in a symbol-table entry is not the same thing as its byte offset in the associated string table. These are linked structures with different layouts.

Symbol Tables at Different Stages

Compilers maintain internal symbol information while analyzing source, and object files contain symbols needed by the linker. These should not be treated as one identical table carried unchanged through every phase.

Ordinary static symbol-table information can often be stripped from a finished executable without preventing it from running. Removing symbols from a relocatable object file can instead prevent the linker from resolving references. Dynamic linking may also require dynamic-symbol information, even when other symbols have been stripped.

Abstract Machine Infrastructure

AM allows klib to be written largely independently of the target architecture. The klib implementations need not contain RISC-V-specific instruction details when they use the AM interface.

A message such as make[1]: *** [run] Error 1 reports a failing recipe in a recursive Make invocation. It identifies the target and exit status, but the earlier output is usually needed to find the underlying program error.

Defining __NATIVE_USE_KLIB__ enables the guarded klib implementation code for native testing. The AM makefile includes the relevant library through:

1
LIBS := $(sort $(LIBS) am klib)

The project permits the low-level putch operation, declared in am.h, as the basis for output.

The Unresolved Tests at This Stage

Two failures remained: mul-longlong produced a bad trap, and the hello kernel encountered an out-of-bounds access under NEMU.

Comparing Test Platforms

With ARCH=native, a test runs on the host and normally uses the host’s library facilities. This helps check the test itself. Enabling __NATIVE_USE_KLIB__ brings in the project’s klib implementations, allowing those routines to be checked on the host.

Once the test and klib are trusted, changing the target to riscv32-nemu adds the emulator to the path. This staged approach narrows the likely source of a failure.

Resolving the Bugs

After differential testing was available, the mul-longlong failure pointed to mulh. I reviewed the C implementation and initially thought the widening was correct. Discussing it with Letong revealed a sign-extension problem. Using the framework’s SEXT facility helped correct it.

The klib failures included an incorrect strlen implementation that triggered subsequent errors, as well as mistakes in the tests themselves. Those were fixed separately.

The visible symptom in mul-longlong looked like a value from a5 appearing in a4, but the underlying cause was the extension of the multiplication operands.

Differential Testing

The register comparison followed the register ordering already used by isa_reg_display. The helper difftest_check_reg performed each comparison and returned the result.

For focused experiments I used __attribute__((optimize("O0"))) to inhibit optimization of a function and __attribute__((aligned(4))) to request four-byte alignment. Inline assembly then allowed direct tests of mulh:

1
2
3
4
uint32_t a = 0xaeb1c2aa;
uint32_t b = 0x4500ff2b;
uint32_t result;
asm("mulh %0, %1, %2" : "=r"(result) : "r"(a), "r"(b));

The operand constraints specify which values supply the sources and receive the destination.


If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. All the images used in the blog are my original works or AI works, if you want to take it,don't hesitate. Thank you !