Implementing and Using Memory and Function Tracing

Engineering

Posted by Bruce Lee on 2024-08-20

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!


Implementing mtrace

The suggested implementation records memory addresses in paddr_read and paddr_write. My initial design instead put address logging in in_pmem, because both physical-access functions call it to check whether an address belongs to ordinary memory.

The motivation was to reuse one shared path instead of duplicating most of the logging code. With itrace available, I expected to infer whether an access was a read or write from the associated instruction, rather than include that distinction in the address log itself. This was a design tradeoff for my debugging needs, not a requirement of memory tracing. A shared logging helper called by the access functions would also avoid duplication while retaining operation details.

Configuration and the Address Range

Kconfig already contained ITRACE and ITRACE_COND options and depends on relationships. I planned an optional MTRACE setting, disabled by default, under the tracing configuration.

The trace range must be known before guest instructions run. I chose the monitor’s welcome phase because it already reports tracing-related configuration. The range endpoints, memory_left and memory_right, would be conditionally defined globals available to the physical-memory code.

in_pmem is normally a short inline predicate. Adding logging changes its role and cost. That deserves reconsideration even though adding a side effect does not, by itself, make a function impossible to inline. Its static linkage remains a separate question.

Instruction Fetches Also Reach Memory

This design records instruction fetches as well as explicit data loads and stores when they fall inside the selected range. The fetch path passes through inst_fetch, vaddr_ifetch, and ultimately paddr_read.

Suppressing fetch events therefore requires distinguishing access types somewhere in that chain. Some earlier code also called paddr_read or paddr_write directly, rather than always going through the virtual-address wrappers. A consistent tracing design needs to account for those paths.

Implementing ftrace

Cost and Dependencies

Checking function ranges for every instruction adds work to the execution loop. In my initial assessment, the additional comparisons were substantial relative to the small emulator’s existing per-instruction work. The exact overhead depends on the implementation and should be measured.

The existing instruction trace already contains the PC, instruction bytes, and disassembly. Memory tracing mainly performs range checks and output, so it does not provide the function information needed here. I therefore made CONFIG_FTRACE depend on CONFIG_ITRACE in this design.

Where to Obtain the PC

One early idea was to run function tracing after the CONFIG_ITRACE block in trace_and_difftest and parse the address from the first characters of a ring-buffer entry.

That works against the form of the data: ELF symbol addresses are numbers already. Taking the numeric PC directly in exec_once avoids converting it to a string and back again. Since instruction-trace handling is already present there, adding the function-trace work nearby also keeps the implementation compact.

The basic operation is to compare the PC with each function symbol’s interval, from its address up to its address plus size, and keep enough state to avoid printing a call for every instruction inside the same function.

An Entry-Address Heuristic

In the examples I examined, reaching a function’s first address indicated a call, including recursion. I tested several call and loop patterns. I even disabled optimization with __attribute__((optimize("O0"))) and put this loop at the beginning of a function:

1
2
3
here:	p = p + 1;
if (p < 5)
goto here;

The generated loop still did not jump back through the function’s entry sequence. The prologue and the loop body occupied different places.

Those observations motivated a heuristic: print a call when the PC equals a known function entry, and print a return when execution leaves one function and resumes inside another without entering at its first address.

This should not be mistaken for a general proof that every entry-address transition is a call. Tail calls, hand-written assembly, unusual control flow, and optimized code can violate simple range-based assumptions. A more complete tracer should also consider the control-transfer instruction and calling convention.

Tracking the Current Function

The proposed bookkeeping works as follows:

  1. Find the function interval containing the current PC.
  2. If the PC is at that function’s entry, report a call. If it is the same function as before, treat the event as recursive entry.
  3. If execution moves to a different function’s interior, report a return under this heuristic.
  4. Mark the new function as current and clear the previous function’s mark.
  5. If execution remains within the current function away from its entry, print nothing.

The marks track where the PC is, rather than identify jal or jalr directly. A function that jumps elsewhere without returning need not produce a matching return event in this scheme. If it transfers to another entry, another call-like event is printed. That was intended to make the observed execution path visible.

Loading Function Symbols and Updating the Build

Function-symbol information must be prepared before tracing starts. This also requires linking NEMU with the ELF library and adding a function-trace option to the configuration.

The initial plan was to add -lelf to LDFLAGS when CONFIG_FTRACE is enabled, and to append an option such as -f $(shell dirname $(IMAGE).elf)/$(NAME).elf to the arguments used to launch NEMU. parse_args then needs to handle -f.

I initially found that selecting FTRACE in menuconfig did not make the condition work in the makefile where I had placed it. The C configuration macro and the variable namespace of each participating makefile must be connected explicitly; defining a C macro alone does not automatically populate every Make invocation.

This clarified the build-system boundary. NEMU is a host program running under GNU/Linux, so -lelf belongs in NEMU’s own build. The guest image and its ELF file are produced by AM, so the launch argument referring to that file belongs in the AM-side rules that invoke NEMU.

AM, in turn, supplies the target runtime on top of the emulated machine, including the basic facilities that the guest cannot assume a host OS will provide.

The resulting makefile changes automate selection of the ELF file instead of requiring it to be entered manually for each run. During implementation I also changed the symbol-table storage to use memory allocated with malloc.


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 !