Uprobes let you dynamically instrument user-space code without touching the source or recompiling, acting like breakpoints that log data and don’t halt execution.

Let’s see uprobes in action by tracing a simple C program that calls malloc.

// malloc_test.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    char *buf = malloc(100);
    if (buf) {
        strcpy(buf, "hello");
        printf("Allocated buffer at %p, content: %s\n", buf, buf);
        free(buf);
    }
    return 0;
}

Compile this: gcc malloc_test.c -o malloc_test

Now, let’s attach an uprobe to the malloc function before running our program. We’ll use bpftrace, a powerful tracing tool that leverages uprobes.

First, find the address of malloc in the current executable. pgrep malloc_test (let’s say it’s PID 12345) pmap 12345 | grep malloc (this might require some digging, but you’re looking for the address of the malloc symbol within the executable’s memory map). A more direct way with bpftrace itself is to use bpftrace -l 'p:malloc' which will list available probes for malloc.

Let’s assume malloc is at offset 0x123450 within the executable. The uprobe would be p:./malloc_test:malloc. The p: indicates a probe point, ./malloc_test is the binary, and malloc is the symbol we’re targeting.

Now, run bpftrace to probe malloc:

sudo bpftrace -e '
  probe:./malloc_test:malloc {
    printf("malloc called with size %d\n", arg0);
  }
' & # Run in background

Here, arg0 refers to the first argument passed to the malloc function, which is the size requested.

Now, run your malloc_test program in a separate terminal: ./malloc_test

You’ll see output like this from the bpftrace terminal: malloc called with size 100

We can also trace the return value of malloc using a kretprobe (kernel return probe) or, more relevantly here, an uretprobe (user-space return probe).

sudo bpftrace -e '
  probe:./malloc_test:malloc {
    printf("malloc called with size %d\n", arg0);
  }
  uretprobe:./malloc_test:malloc /pid == 12345/ {
    printf("malloc returned address %p\n", retval);
  }
' &

uretprobe: specifies a return probe. /pid == 12345/ is a filter to only trace our specific malloc_test process. retval is a special variable in bpftrace that holds the return value of the probed function.

Running ./malloc_test again will now show: malloc called with size 100 malloc returned address 0x7f.... (the actual address)

This allows you to understand how functions are being used, what arguments they receive, and what they return, all without modifying the original code. You can probe any exported symbol in a shared library or executable. For dynamically linked libraries like libc, you’d use their path, e.g., probe:/lib/x86_64-linux-gnu/libc.so.6:malloc.

The core problem uprobes solve is the need for deep introspection into running user-space applications when source code is unavailable or modifying it is impractical. Traditional debugging with gdb requires attaching a debugger, which halts execution and can significantly alter program timing. Uprobes, by contrast, are lightweight and can be attached and detached dynamically, allowing for observation of production systems with minimal overhead.

The magic lies in the kernel’s ability to dynamically insert breakpoints into user-space code. When a function is called, the kernel intercepts the execution flow at the entry point of the function. For return probes, it intercepts the flow just before the function returns. This interception is managed by the kernel’s tracing infrastructure, making it efficient and safe. bpftrace then provides a high-level scripting language to define what actions to take upon hitting these probes, such as printing arguments or return values.

The real power comes from combining uprobes with other BPF features. You can use bpftrace to collect data from uprobes and then process it using BPF programs. For instance, you could count how many times malloc is called with a specific size, or measure the latency between a malloc call and its corresponding free.

One piece of insight many people miss is how bpftrace maps function symbols to memory addresses. When you specify probe:./my_program:my_function, bpftrace first looks up my_program’s memory map to find the base address of the executable. Then, it resolves my_function’s offset relative to that base address. For shared libraries, it does a similar lookup for the library’s base address in the process’s memory map and resolves the function’s offset within that library. This dynamic resolution is what allows uprobes to work even if the program’s memory layout changes between runs (due to ASLR).

The next step in understanding dynamic instrumentation is exploring how to use uprobes to trace custom applications where you do have the source, but want to add fine-grained, non-intrusive logging.

Want structured learning?

Take the full Ebpf course →