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.
Before the hook:
call do_work → original instructions → result
After the hook:
call do_work → redirect → Gum → bpftime entry probe
↓
original instructions → resultThis 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.
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:
+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, #1After starting the target with the agent, GDB stopped at main and saw the first 16 bytes replaced:
+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, #1The 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.
__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
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
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
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
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
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
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
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.
# 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 raxThe 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.
NOP1
NOP
↑ write2 … N−1
NOPstrampoline
→ 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.