How bpftime Handles eBPF Virtual Machines: A Plugin Architecture Deep Dive

bpftime abstracts concrete eBPF virtual machine implementations behind a unified C API, delegating execution to pluggable backends like uBPF through an opaque compatibility layer that supports interpretation, JIT compilation, and future AOT extensions.

The bpftime project provides a userspace runtime for eBPF programs that decouples the execution engine from calling code. By handling eBPF virtual machines through a modular architecture, bpftime allows developers to swap between interpreters and JIT compilers without modifying their application logic, maintaining compatibility with the standard libbpf interface.

The Unified C API Facade

The public interface resides in vm/vm-core/include/ebpf-vm.h, which declares an opaque struct ebpf_vm and a set of lifecycle functions (ebpf_create, ebpf_load, ebpf_exec, ebpf_compile, etc.) that mirror the kernel's libbpf API. Internally, this struct wraps a polymorphic backend instance:

struct ebpf_vm {
    std::string vm_name;                                   // backend identifier (e.g., "ubpf")
    std::unique_ptr<bpftime::vm::compat::bpftime_vm_impl> vm_instance;
};

When a client calls ebpf_create("ubpf"), the implementation in vm/vm-core/src/ebpf-vm.cpp forwards the request to a factory that instantiates the concrete backend:

extern "C" ebpf_vm *ebpf_create(const char *vm_name_str)
{
    auto vm = new ebpf_vm;
    vm->vm_instance = bpftime::vm::compat::create_vm_instance(std::string(vm_name_str));
    return vm;
}

All subsequent operations on the VM—loading bytecode, registering helpers, executing programs, or JIT-compiling—are delegated through the vm_instance pointer to the specific backend implementation.

The Compatibility Layer Interface

The abstraction is defined in vm/compat/include/bpftime_vm_compat.hpp via the abstract base class bpftime_vm_impl. This interface captures the complete VM lifecycle through pure virtual methods that every backend must implement:

  • load_code(const void *code, size_t len) – Ingest raw eBPF bytecode and prepare it for execution
  • exec(void *mem, size_t mem_len, uint64_t &ret) – Interpret the loaded program against provided memory
  • compile() – JIT-compile the program to native machine code and return a function pointer
  • unload_code() – Release the current program and reset the VM state
  • set_lddw_helpers(...) – Bind helper functions invoked by the lddw pseudo-instruction
  • set_unwind_function_index(size_t) – Configure tail-call-like unwind semantics
  • set_pointer_secret(uint64_t) – Set an optional ROP-hardening secret for JIT-compiled code

The header also maintains a factory map (std::map<std::string, create_vm_instance_func>) that associates string names with constructor functions. Backends register themselves using register_vm_factory("name", create_function), enabling runtime selection via ebpf_create("name").

The uBPF Backend Implementation

The reference backend implementation lives in vm/compat/ubpf-vm/compat_ubpf.cpp. It bridges bpftime's generic interface to the upstream uBPF library through the bpftime_ubpf_vm class.

Factory Registration occurs via a constructor that executes before main():

register_vm_factory("ubpf", create_ubpf_vm_instance);

Concrete Implementation Details:

The bpftime_ubpf_vm class inherits from bpftime_vm_impl and owns a ubpf_vm* handle from the underlying library. When ebpf_load is called, the backend:

  1. Translates the raw byte buffer into a vector of ebpf_inst structures
  2. Patches CALL instructions to resolve helper indices and LDDW instructions to bind map/variable helpers
  3. Invokes ubpf_load to ingest the prepared bytecode

Execution modes are thin wrappers around uBPF primitives:

  • exec delegates to ubpf_exec for interpretation
  • compile calls ubpf_compile and returns a precompiled_ebpf_function pointer
  • Helper registration stores function pointers for the patching logic used during loading
  • Unwind and security settings forward to ubpf_set_unwind_function_index and ubpf_set_pointer_secret

All errors encountered during these operations are captured in error_string and returned to the caller through the C API's error output parameters.

Extending the Architecture with New Backends

While the current repository ships only the uBPF implementation, the architecture anticipates multiple execution engines. Adding a new backend (such as an LLVM-based JIT or AOT compiler) requires:

  1. Implementing bpftime_vm_impl with backend-specific logic for loading and execution
  2. Registering the factory with register_vm_factory("backend_name", create_instance)
  3. Users instantiating it via ebpf_create("backend_name")

The base class also declares optional AOT extension points—do_aot_compile, load_aot_object, and generate_ptx—that future backends can override to support ahead-of-time compilation workflows without changing the public C API.

Complete VM Lifecycle Example

The following demonstrates the full lifecycle of a bpftime eBPF virtual machine, from creation to cleanup:

#include <bpftime.hpp>   // Re-exports the C API

int main()
{
    // 1. Create a uBPF VM instance
    struct ebpf_vm *vm = ebpf_create("ubpf");

    // 2. Register a helper function (e.g., bpf_trace_printk equivalent)
    ebpf_register(vm, 5, "bpf_trace_printk", (void *)my_printk);

    // 3. Load raw eBPF bytecode
    char *err = NULL;
    if (ebpf_load(vm, prog_bytes, prog_len, &err) < 0) {
        fprintf(stderr, "Load error: %s\n", err);
        free(err);
        return 1;
    }

    // 4. Execute in interpreter mode
    uint64_t ret;
    if (ebpf_exec(vm, mem_buf, mem_sz, &ret) != 0)
        fprintf(stderr, "Execution failed\n");

    // 5. JIT-compile and execute directly (optional)
    char *jit_err = NULL;
    ebpf_jit_fn fn = ebpf_compile(vm, &jit_err);
    if (fn) {
        uint64_t jit_ret = fn(mem_buf, mem_sz);
        printf("JIT return value: %llu\n", (unsigned long long)jit_ret);
    } else {
        fprintf(stderr, "JIT compile error: %s\n", jit_err);
        free(jit_err);
    }

    // 6. Release resources
    ebpf_destroy(vm);
    return 0;
}

Summary

  • bpftime virtualizes eBPF VMs through an opaque struct ebpf_vm that hides backend-specific details behind a stable C API.
  • The compatibility layer (bpftime_vm_impl) defines pure virtual methods for loading, executing, and compiling bytecode, enabling plug-and-play execution engines.
  • Factory registration allows runtime selection of backends via string identifiers passed to ebpf_create().
  • The uBPF backend in compat_ubpf.cpp demonstrates the pattern: it patches bytecode for helpers during load, then delegates execution to the uBPF interpreter or JIT compiler.
  • Future extensions such as LLVM-JIT or AOT compilers can integrate by implementing the same interface and registering with the factory map.

Frequently Asked Questions

How do I select a specific eBPF VM backend in bpftime?

Pass the backend name as a string to ebpf_create(). For example, ebpf_create("ubpf") instantiates the uBPF backend by looking up the registered factory in the compatibility layer's internal map. If you implement a custom LLVM-JIT backend and register it as "llvm", you would instantiate it with ebpf_create("llvm").

Where does bpftime handle bytecode patching for eBPF helpers?

The patching occurs within the backend's load_code implementation. In the uBPF backend (vm/compat/ubpf-vm/compat_ubpf.cpp), the concrete class translates the raw byte buffer into instruction structures, resolves helper indices for CALL instructions, and binds LDDW helpers before forwarding the processed code to the underlying uBPF loader.

Can bpftime load pre-compiled AOT eBPF objects?

The architecture supports AOT through optional methods in bpftime_vm_impl (do_aot_compile, load_aot_object, generate_ptx), but the current uBPF backend does not implement them. Future backends can enable AOT by overriding these methods to serialize compiled artifacts or load them from disk, while maintaining the same ebpf_create and ebpf_load entry points.

What happens if a VM backend fails to load a program?

If loading fails (for example, due to invalid bytecode or unresolved helpers), the backend stores an error description in its internal error_string member. The C API function ebpf_load then returns a negative value and sets the caller's error pointer to a newly allocated string containing the message, which the caller must free after handling the error.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →