Cloud Native 15 min read

Dynamic Filtering of Function Parameters with eBPF

The article explains how to add runtime‑configurable filtering of kernel function arguments in eBPF programs by parsing a C‑style expression, validating its AST, converting it to BPF instructions using BTF metadata, and injecting the generated code into the probe, with a complete example for skb filtering.

Linux Kernel Journey
Linux Kernel Journey
Linux Kernel Journey
Dynamic Filtering of Function Parameters with eBPF

When tracing kernel functions with eBPF, developers often need to filter function arguments. Tools like bpftrace allow static filter expressions in scripts, but they require LLVM and cannot change filters at runtime. This article shows how to implement a command‑line‑driven, dynamic filter for function parameters, as realized in the pwru project.

Implementation Overview

Parse the filter expression and build a C‑style AST.

Validate the AST to ensure only supported operators and operand forms are used.

Translate the AST directly into BPF instructions using BTF type information.

Replace the original stub function with the generated instructions.

Parsing the Filter Expression

The rsc.io/c2go/cc library is used to parse the expression into an AST.

func parse(expr string) (*cc.Expr, error) {
    return cc.ParseExpr(expr)
}

Validating the AST

pwru only supports simple expressions such as skb->dev->ifindex == 11. The validator checks:

Supported operators: ==, !=, <, <=, >, >= (the single = is treated as ==).

The left operand must be a struct/union member access of the skb/xdp parameter; bitfields are not allowed.

The right operand must be a constant number.

func validateOperator(op cc.ExprOp) error {
    switch op {
    case cc.Eq, cc.EqEq, cc.NotEq, cc.Lt, cc.LtEq, cc.Gt, cc.GtEq:
        return nil
    default:
        return fmt.Errorf("unexpected operator: %s; must be one of =, ==, !=, <, <=, >, >=", op)
    }
}

func validateLeftOperand(left *cc.Expr) error {
    // ... implementation omitted for brevity ...
    return nil
}

func validateRightOperand(right *cc.Expr) error {
    if right.Op != cc.Number {
        return fmt.Errorf("unexpected right operand: %s; must be a constant number", right)
    }
    if _, err := parseNumber(right); err != nil {
        return fmt.Errorf("right operand is not a number: %w", err)
    }
    return nil
}

func validate(expr *cc.Expr) error {
    if err := validateOperator(expr.Op); err != nil { return err }
    if expr.Left == nil { return fmt.Errorf("left operand is missing") }
    if err := validateLeftOperand(expr.Left); err != nil { return err }
    if expr.Right == nil { return fmt.Errorf("right operand is missing") }
    return validateRightOperand(expr.Right)
}

Converting AST to BPF Instructions

There is no ready‑made library to turn an AST into BPF bytecode, so a small converter is implemented.

Getting Offsets from BTF

The converter walks the expression from left to right, uses BTF to resolve each struct member’s offset, and builds an offset array. It treats “.” and “->” uniformly, adding a new offset when “->” is encountered.

type astInfo struct {
    offsets   []uint32
    lastField btf.Type
    bigEndian bool // true if the last field is big endian
}

func expr2offset(expr *cc.Expr, ptr *btf.Pointer) (astInfo, error) {
    // Walk left side of the expression, collect members, compute offsets
    // ... implementation omitted for brevity ...
    return astInfo{}, nil
}

Offsets to BPF Instructions

The offset array is turned into a series of bpf_probe_read_kernel() helper calls that read the target field.

func offset2insns(insns asm.Instructions, offsets []uint32) asm.Instructions {
    lastIndex := len(offsets) - 1
    for i := 0; i <= lastIndex; i++ {
        if offsets[i] != 0 {
            insns = append(insns,
                asm.Add.Imm(asm.R3, int32(offsets[i])), // r3 += offset
                asm.Mov.Imm(asm.R2, 8),                 // r2 = 8
                asm.Mov.Reg(asm.R1, asm.R10),           // r1 = r10
                asm.Add.Imm(asm.R1, -8),                 // r1 = r10-8
                asm.FnProbeReadKernel.Call(),           // call helper
                asm.LoadMem(asm.R3, asm.R10, -8, asm.DWord), // r3 = *(u64*)(r10-8)
            )
        }
        if i != lastIndex {
            insns = append(insns, asm.JEq.Imm(asm.R3, 0, labelExitFail)) // if r3 == 0, exit
        }
    }
    return insns
}

Operator to BPF Instructions

Each operator is mapped to a conditional jump. The generator also handles size, sign, and endianness.

type tgtInfo struct {
    constant  uint64
    typ       btf.Type
    sizof     int
    bigEndian bool
}

func op2insns(insns asm.Instructions, op cc.ExprOp, tgt tgtInfo) asm.Instructions {
    // Convert constant to unsigned of the appropriate size
    // Apply big‑endian conversion if needed
    // Choose jump opcode based on operator and signedness
    // Emit: r0 = 1; conditional jump; fall through sets r0 = 0
    // ... implementation omitted for brevity ...
    return insns
}

Injecting the Filter into the Stub Function

The generated instructions replace the original stub’s code.

func injectFilter(spec *btf.Spec, prog *ebpf.ProgramSpec, filterExpr, stubFunc string, compile compileFunc) error {
    insns, err := compile(filterExpr, spec)
    if err != nil {
        return fmt.Errorf("failed to compile filter expression(%s): %w", filterExpr, err)
    }
    // Preserve metadata of the original instruction at the injection point
    insns[0] = insns[0].WithMetadata(prog.Instructions[injectIdx].Metadata)
    prog.Instructions = append(prog.Instructions[:injectIdx], append(insns, prog.Instructions[retInsnIdx+1:]...)...)
    return nil
}

func InjectSkbFilter(spec *btf.Spec, prog *ebpf.ProgramSpec, filterExpr string) error {
    return injectFilter(spec, prog, filterExpr, stubFuncSkb, CompileSkbExpr)
}

Resulting BPF Program

For the expression skb->dev->ifindex == 11 the generated BPF program looks like:

$ sudo bpftool p d x i 100
bool filter_skb_expr(struct sk_buff * skb):
 997: (bf) r3 = r1
 998: (07) r3 += 16
 999: (b7) r2 = 8
1000: (bf) r1 = r10
1001: (07) r1 += -8
1002: (85) call bpf_probe_read_kernel#-125280
1003: (79) r3 = *(u64 *)(r10 -8)
1004: (15) if r3 == 0x0 goto pc+10
1005: (07) r3 += 224
1006: (b7) r2 = 8
1007: (bf) r1 = r10
1008: (07) r1 += -8
1009: (85) call bpf_probe_read_kernel#-125280
1010: (79) r3 = *(u64 *)(r10 -8)
1011: (67) r3 <<= 32
1012: (77) r3 >>= 32
1013: (b7) r0 = 1
1014: (15) if r3 == 0xb goto pc+1
1015: (b7) r0 = 0
1016: (95) exit

Towards a More Generic Library

In pwru the first struct name must be skb or xdp. Removing this restriction would allow the same mechanism to filter arguments of arbitrary functions. The upcoming bice library (GitHub) will provide a generic implementation.

Conclusion

The dynamic‑filtering technique consists of three phases: parse the filter expression into an AST, validate the AST, and translate it into BPF instructions using BTF metadata before injecting the code into the probe.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

GoeBPFBPFkernel tracingBTFdynamic filtering
Linux Kernel Journey
Written by

Linux Kernel Journey

Linux Kernel Journey

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.