NOTES / SYSTEMS

bpftime · three parts, one function call

Follow the detour

See how a function enters the hook, runs the counter, and still returns its original result. Then follow a syscall through zpoline.

PART 02 OF 03 · ABOUT 14 MIN · NOVEMBER 4, 2026
C and Linux · a runnable experiment in Part 3

In Part 1, our program called do_work(4) and got 13 back. We wanted to count that call without changing the function. Now follow what happens between the call and the result.

Frida-Gum—the C library that bpftime uses to install function hooks—has already set up the detour. The bpftime agent is already inside the target process. Part 3 explains that setup; this part follows the running code.

The function’s first instructions become a redirect

After compilation, do_work has an address in the process’s memory. A call transfers execution to that address. Normally the CPU would immediately start executing the function’s original instructions.

Gum changes the instructions at that entry point. They now transfer execution to Gum’s interception code. That code invokes a callback: a function supplied by bpftime to handle the event. In our case, bpftime’s callback runs the entry probe.

The application’s call site can keep calling the same function address. What changes is the code it reaches there.

Control flow / simplified
Before the hook:
    call do_work → original instructions → result

After the hook:
    call do_work → redirect → Gum → bpftime entry probe
                                 ↓
                   original instructions → result

This changes the running process’s memory. It does not rewrite the executable file on disk. That distinction will matter when we inspect the function with tools.

The original instructions still have to run

The overwritten instructions may be important: they might reserve stack space or save an argument. Executing the rest of the function without them would break the function.

Gum therefore preserves those instructions in another small piece of executable memory. This code runs the displaced instructions and transfers execution back to the rest of the original function. That small bridge is called a trampoline.

05 / Resume the original function correctly
THE DETOUR
Gum’s entry handling → bpftime’s probeThe entry probe finishes before the original computation continues.
THE CONTINUATION
Preserved entry instructions → remaining function bodyThe caller still gets the original result.

Moving an instruction can require rewriting it. For example, an instruction that calculates an address relative to its own position needs adjustment when its position changes. Gum includes instruction relocation code to do that work.

A hook can fail if the function is too short or its instructions cannot be handled by the selected hooking method. That is why our first lab uses a separate, non-inlined function and turns optimization off.

A real entry patch: the lab on ARM64

During validation on Ubuntu 24.04 ARM64, the executable on disk began with these instructions. Offsets are relative to do_work:

Captured / original target disassembly
+00  d10083ff  sub sp, sp, #0x20
+04  b9000fe0  str w0, [sp, #12]
+08  b9400fe1  ldr w1, [sp, #12]
+12  2a0103e0  mov w0, w1
+16  531f7800  lsl w0, w0, #1

After starting the target with the agent, GDB stopped at main and saw the first 16 bytes replaced:

Captured / live entry, annotated
+00  58000050  ldr x16, .+8
+04  d61f0200  br x16
+08  9db29000  // low half of a 64-bit destination address
+12  0000ffff  // high half of that address
+16  531f7800  lsl w0, w0, #1

The ldr loads 0x0000ffff9db29000 into x16; br x16 redirects execution there. The two words at +8 and +12 are address data, so a linear disassembler can misleadingly decode them as invalid instructions. The displaced stack adjustment and register operations must still execute through the relocated path. The destination address changes across runs; the pattern shown is one observed Frida choice, not a universal patch layout.

What bpftime runs in the callback

Our BPF program needs an execution engine. One option is an interpreter, software that reads BPF instructions and performs each operation. Another is to translate the program into the CPU’s own instructions before using it. Those CPU instructions are called native code.

That second option is just-in-time compilation, usually shortened to JIT. In this workflow, bpftime prepares the program when the agent sets up the requested probes. On an event, it calls the prepared code; it does not need to compile the BPF program again for every function call.

Our probe then needs to find its counter. bpf_map_lookup_elem is a helper: an operation exposed to BPF programs and implemented by the runtime. It returns a pointer to a map value, or no value if the lookup fails.

Inside our BPF counter / excerpt
__u64 *value = bpf_map_lookup_elem(&counts, &key);
if (value)
    __sync_fetch_and_add(value, 1);

__u64 means an unsigned 64-bit integer. key is 0 for entries or 1 for returns. The last line increments the selected counter atomically, so concurrent updates do not overwrite each other. The complete map declaration and probe programs are in probe.bpf.c.

The helper runs inside the target too. A call to a helper is not automatically a system call into the kernel. A particular helper could make a system call, but that depends on what its implementation does.

What can the probe learn about the call?

Besides running the callback, Gum captures the CPU state around the function call. That state includes registers, the CPU’s small named storage locations, which calling conventions use for arguments and results.

bpftime converts the saved register values into a structure that a BPF probe can inspect. This is the probe’s context; in this implementation the register structure is called pt_regs. Our counter ignores it. Reading an argument would require using the appropriate context layout for the target architecture.

Follow one complete call

06 / Follow the complete call
Each step below is in the target, on the thread that called do_work.
Target process · calling thread

01 / Call do_work(4)

The application transfers execution to the same function address as before. Its entry now contains Gum’s redirect.

caller → do_work entry

Target process · same thread

02 / Take Gum’s detour

The redirect enters Gum’s interception code. Gum preserves the state needed to continue the original call and invokes bpftime’s callback.

entry redirect → Gum → bpftime callback

Target process · same thread

03 / Run the entry BPF program

bpftime executes the prepared entry program. That program uses the map-lookup helper to find entry 0, then increments the entry count.

entry probe → counts[0] += 1

Target process · same thread

04 / Continue the original function

The entry probe finishes. Gum continues through the preserved original instructions and the rest of do_work. The function computes 13.

trampoline → do_work body

Target process · same thread

05 / Run the return BPF program

For a return probe, Gum also arranges to visit its leave handling when this invocation returns normally. bpftime’s leave callback runs the return program, which increments entry 1.

normal return → return probe → counts[1] += 1

Target process · same thread

06 / Return 13 to the caller

Gum completes the normal return to the caller. Later, the separate loader reads the counters from shared memory and prints them. That read does not participate in this call’s execution.

caller receives 13; loader reads the map later

1 / 6

The entry and return probes borrow the application thread while they run. A slow probe therefore delays the call. A helper that blocks can block that thread. Calling into another language’s runtime also requires respecting its rules for stacks and scheduling; sharing a process is not enough to establish a safe call.

A return probe needs more than an entry redirect: Gum must retain enough information about the invocation to handle its return and resume the original caller. Our lab uses ordinary C returns. Exceptions, nonlocal jumps, and recursion need additional reasoning before you rely on entry/return probes to acquire and release resources.

Match the explanation to bpftime’s source

Gum calls uprobe_listener_on_enter for entry and uprobe_listener_on_leave for return. The entry listener receives Gum’s saved-call information in a GumInvocationContext, converts its register state, and invokes the registered bpftime callbacks. Program execution eventually reaches bpftime_prog::bpftime_prog_exec, which calls the prepared JIT function or interpreter.

Read the listeners and program execution once the control-flow drawing is familiar.

When you need a native function of your own

The map helper was already supplied by bpftime. Suppose your research probe needs an operation that is not built in—for example, calling a function in a native library loaded into the target.

That requires an explicit connection between BPF and the native function. bpftime has a user-function registry, often called ufunc. Native code registers a function there; BPF calls a dispatcher helper with the function’s identifier and arguments.

You need the implementation and registration inside the target’s runtime, ready before the probe uses them. Adding a function to the separate loader is not enough. The first lab uses only built-in helpers, so you can run it without changing the agent.

Next boundary: expose your own native operation

A raw helper and a ufunc are distinct interfaces. A raw helper registers a native implementation at a BPF helper ID. A ufunc registers a function in a separate registry; BPF calls the dispatcher helper with that function’s ID and an argument list. Choose an existing built-in helper when it provides the operation; use a deliberate native integration when it does not.

For a small pinned example, read the native add-function registration test, then its BPF caller and UFUNC_CALL declarations. The native side uses ebpf_ufunc_func_info and bpftime_ufunc_register_ufunc; the BPF side uses UFUNC_CALL_2 through the dispatcher.

This upstream example is an in-process runtime test, not a complete injected-agent deployment. To deploy the same idea, put the native implementation and registration in the target-side runtime integration—for example a deliberately modified agent—and rebuild that native artifact. Register it in the runtime instance that will execute the BPF program, enable the ufunc helper group (enable_ufunc_helper_group in the runtime configuration), and establish readiness before installing or firing the probe. Adding registration only to the separate loader does not supply the target’s native function pointer.

Another kind of hook: intercept a syscall

This section explains a second interception mechanism. The function-hook lab does not depend on it; you can continue to the lab and return here later.

A program sometimes needs the kernel to do something, such as write bytes to a file. It makes a system call, or syscall. On Linux x86-64, the program puts the operation’s number in a register called rax and executes the syscall instruction.

zpoline is a method for redirecting those syscall instructions to a userspace hook. Its trick depends on two instructions having the same size. The syscall instruction occupies two bytes; so does an indirect call to the address stored in rax.

A syscall request becomes a call
# Linux x86-64, AT&T assembly syntax
mov $1, %eax        # operation 1: write
syscall            # bytes: 0f 05

# After rewriting that instruction:
mov $1, %eax
call *%rax         # bytes: ff d0; call the address stored in rax

The register contains the number 1, so the replacement calls virtual address 1. Ordinarily that would be invalid. zpoline first prepares executable memory near address zero so that small syscall numbers become valid entry addresses.

That region starts with NOPs—instructions that do nothing and advance to the next instruction. A call landing at address 1 runs through the NOPs into a common trampoline, which transfers execution to the syscall hook.

07 / Turn the operation number into a landing address
0
NOP
1
NOP
↑ write
2 … N−1
NOPs
trampoline
→ syscall hook

The hook receives the requested operation and arguments. It can run a probe, change the handling, or forward the request through a real syscall instruction. If it forwards a write, the kernel still performs the write; we have intercepted the request before it enters the kernel.

bpftime includes syscall interception using this approach on x86-64. Its transformer library rewrites the executable memory, and bpftime start -s selects that startup route. Mapping address zero can be restricted by the host OS. The ARM64 function-hook lab uses Gum instead and does not need this address-zero setup.

Both mechanisms redirect execution, but at different points: Gum intercepts our chosen function; the syscall rewrite intercepts a syscall instruction. In each case, setup must put code and routing into the target before the event reaches them.

Next / install and run

Put the machinery into the process →

Follow the two launches, then run the same five-call experiment yourself.

Read the mechanism

Implementation details follow upstream snapshot 25e250d. Commands and platform limits are stated where they matter; diagrams simplify control flow and do not claim measured timings.