Repository: cilium/ebpf
Pull request #1269 (2023-12-13 12:35:20)

Add support for uprobe multi link
Adding support for uprobe multi link.

olsajiri: @lorenz @ti-mo I still need to test this with tetragon, but any early feedback would be great, thanks
olsajiri: using it in tetragon https://github.com/cilium/tetragon/pull/1914 , seems ok ;-) 
olsajiri: @lmb @mmat11 I tried to address all the comments, hopefully I did not miss anything now, sry

there's still open question of this change  https://github.com/cilium/ebpf/pull/1269#discussion_r1450718975  I can make the change if it's needed

Patch:
diff --git a/internal/cmd/gentypes/main.go b/internal/cmd/gentypes/main.go
index 5e4897c..4b31d30 100644
--- a/internal/cmd/gentypes/main.go
+++ b/internal/cmd/gentypes/main.go
@@ -450,6 +450,22 @@ import (
 				rename("relative_fd", "relative_fd_or_id"),
 			},
 		},
+		{
+			"LinkCreateUprobeMulti", retFd, "link_create", "BPF_LINK_CREATE",
+			[]patch{
+				chooseNth(4, 7),
+				replace(enumTypes["AttachType"], "attach_type"),
+				modify(func(m *btf.Member) error {
+					return rename("flags", "uprobe_multi_flags")(m.Type.(*btf.Struct))
+				}, "uprobe_multi"),
+				flattenAnon,
+				replace(pointer, "path"),
+				replace(pointer, "offsets"),
+				replace(pointer, "ref_ctr_offsets"),
+				replace(pointer, "cookies"),
+				rename("cnt", "count"),
+			},
+		},
 		{
 			"LinkUpdate", retError, "link_update", "BPF_LINK_UPDATE",
 			nil,
diff --git a/internal/sys/types.go b/internal/sys/types.go
index 137e2ac..d1ba720 100644
--- a/internal/sys/types.go
+++ b/internal/sys/types.go
@@ -775,6 +775,29 @@ func LinkCreateTracing(attr *LinkCreateTracingAttr) (*FD, error) {
 	return NewFD(int(fd))
 }
 
+type LinkCreateUprobeMultiAttr struct {
+	ProgFd           uint32
+	TargetFd         uint32
+	AttachType       AttachType
+	Flags            uint32
+	Path             Pointer
+	Offsets          Pointer
+	RefCtrOffsets    Pointer
+	Cookies          Pointer
+	Count            uint32
+	UprobeMultiFlags uint32
+	Pid              uint32
+	_                [4]byte
+}
+
+func LinkCreateUprobeMulti(attr *LinkCreateUprobeMultiAttr) (*FD, error) {
+	fd, err := BPF(BPF_LINK_CREATE, unsafe.Pointer(attr), unsafe.Sizeof(*attr))
+	if err != nil {
+		return nil, err
+	}
+	return NewFD(int(fd))
+}
+
 type LinkUpdateAttr struct {
 	LinkFd    uint32
 	NewProgFd uint32
diff --git a/internal/unix/types_linux.go b/internal/unix/types_linux.go
index bc63724..2f22b12 100644
--- a/internal/unix/types_linux.go
+++ b/internal/unix/types_linux.go
@@ -40,6 +40,7 @@ const (
 	BPF_F_MMAPABLE            = linux.BPF_F_MMAPABLE
 	BPF_F_INNER_MAP           = linux.BPF_F_INNER_MAP
 	BPF_F_KPROBE_MULTI_RETURN = linux.BPF_F_KPROBE_MULTI_RETURN
+	BPF_F_UPROBE_MULTI_RETURN = linux.BPF_F_UPROBE_MULTI_RETURN
 	BPF_OBJ_NAME_LEN          = linux.BPF_OBJ_NAME_LEN
 	BPF_TAG_SIZE              = linux.BPF_TAG_SIZE
 	BPF_RINGBUF_BUSY_BIT      = linux.BPF_RINGBUF_BUSY_BIT
diff --git a/internal/unix/types_other.go b/internal/unix/types_other.go
index 3a0f79c..e5bad04 100644
--- a/internal/unix/types_other.go
+++ b/internal/unix/types_other.go
@@ -42,6 +42,7 @@ const (
 	BPF_F_MMAPABLE
 	BPF_F_INNER_MAP
 	BPF_F_KPROBE_MULTI_RETURN
+	BPF_F_UPROBE_MULTI_RETURN
 	BPF_F_XDP_HAS_FRAGS
 	BPF_OBJ_NAME_LEN
 	BPF_TAG_SIZE
diff --git a/link/link.go b/link/link.go
index 2025b7d..16e7e06 100644
--- a/link/link.go
+++ b/link/link.go
@@ -96,6 +96,8 @@ func wrapRawLink(raw *RawLink) (_ Link, err error) {
 		return &NetNsLink{*raw}, nil
 	case KprobeMultiType:
 		return &kprobeMultiLink{*raw}, nil
+	case UprobeMultiType:
+		return &uprobeMultiLink{*raw}, nil
 	case PerfEventType:
 		return nil, fmt.Errorf("recovering perf event fd: %w", ErrNotSupported)
 	case TCXType:
@@ -324,7 +326,7 @@ func (l *RawLink) Info() (*Info, error) {
 	case XDPType:
 		extra = &XDPInfo{}
 	case RawTracepointType, IterType,
-		PerfEventType, KprobeMultiType:
+		PerfEventType, KprobeMultiType, UprobeMultiType:
 		// Extra metadata not supported.
 	case TCXType:
 		extra = &TCXInfo{}
diff --git a/link/syscalls.go b/link/syscalls.go
index 02460d9..8c1aec5 100644
--- a/link/syscalls.go
+++ b/link/syscalls.go
@@ -25,6 +25,7 @@ const (
 	PerfEventType     = sys.BPF_LINK_TYPE_PERF_EVENT
 	KprobeMultiType   = sys.BPF_LINK_TYPE_KPROBE_MULTI
 	TCXType           = sys.BPF_LINK_TYPE_TCX
+	UprobeMultiType   = sys.BPF_LINK_TYPE_UPROBE_MULTI
 )
 
 var haveProgAttach = internal.NewFeatureTest("BPF_PROG_ATTACH", "4.10", func() error {
diff --git a/link/uprobe.go b/link/uprobe.go
index 83977e0..ad85024 100644
--- a/link/uprobe.go
+++ b/link/uprobe.go
@@ -36,10 +36,10 @@ var (
 type Executable struct {
 	// Path of the executable on the filesystem.
 	path string
-	// Parsed ELF and dynamic symbols' addresses.
-	addresses map[string]uint64
+	// Parsed ELF and dynamic symbols' cachedAddresses.
+	cachedAddresses map[string]uint64
 	// Keep track of symbol table lazy load.
-	addressesOnce sync.Once
+	cacheAddressesOnce sync.Once
 }
 
 // UprobeOptions defines additional parameters that will be used
@@ -108,8 +108,8 @@ func OpenExecutable(path string) (*Executable, error) {
 	}
 
 	return &Executable{
-		path:      path,
-		addresses: make(map[string]uint64),
+		path:            path,
+		cachedAddresses: make(map[string]uint64),
 	}, nil
 }
 
@@ -153,7 +153,7 @@ func (ex *Executable) load(f *internal.SafeELFFile) error {
 			}
 		}
 
-		ex.addresses[s.Name] = address
+		ex.cachedAddresses[s.Name] = address
 	}
 
 	return nil
@@ -162,13 +162,13 @@ func (ex *Executable) load(f *internal.SafeELFFile) error {
 // address calculates the address of a symbol in the executable.
 //
 // opts must not be nil.
-func (ex *Executable) address(symbol string, opts *UprobeOptions) (uint64, error) {
-	if opts.Address > 0 {
-		return opts.Address + opts.Offset, nil
+func (ex *Executable) address(symbol string, address, offset uint64) (uint64, error) {
+	if address > 0 {
+		return address + offset, nil
 	}
 
 	var err error
-	ex.addressesOnce.Do(func() {
+	ex.cacheAddressesOnce.Do(func() {
 		var f *internal.SafeELFFile
 		f, err = internal.OpenSafeELFFile(ex.path)
 		if err != nil {
@@ -183,7 +183,7 @@ func (ex *Executable) address(symbol string, opts *UprobeOptions) (uint64, error
 		return 0, fmt.Errorf("lazy load symbols: %w", err)
 	}
 
-	address, ok := ex.addresses[symbol]
+	address, ok := ex.cachedAddresses[symbol]
 	if !ok {
 		return 0, fmt.Errorf("symbol %s: %w", symbol, ErrNoSymbol)
 	}
@@ -199,7 +199,7 @@ func (ex *Executable) address(symbol string, opts *UprobeOptions) (uint64, error
 			"(consider providing UprobeOptions.Address)", ex.path, symbol, ErrNotSupported)
 	}
 
-	return address + opts.Offset, nil
+	return address + offset, nil
 }
 
 // Uprobe attaches the given eBPF program to a perf event that fires when the
@@ -284,7 +284,7 @@ func (ex *Executable) uprobe(symbol string, prog *ebpf.Program, opts *UprobeOpti
 		opts = &UprobeOptions{}
 	}
 
-	offset, err := ex.address(symbol, opts)
+	offset, err := ex.address(symbol, opts.Address, opts.Offset)
 	if err != nil {
 		return nil, err
 	}
diff --git a/link/uprobe_multi.go b/link/uprobe_multi.go
new file mode 100644
index 0000000..9a8d329
--- /dev/null
+++ b/link/uprobe_multi.go
@@ -0,0 +1,224 @@
+package link
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"unsafe"
+
+	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/asm"
+	"github.com/cilium/ebpf/internal"
+	"github.com/cilium/ebpf/internal/sys"
+	"github.com/cilium/ebpf/internal/unix"
+)
+
+// UprobeMultiOptions defines additional parameters that will be used
+// when opening a UprobeMulti Link.
+type UprobeMultiOptions struct {
+	// Symbol addresses. If set, overrides the addresses eventually parsed from
+	// the executable. Mutually exclusive with UprobeMulti's symbols argument.
+	Addresses []uint64
+
+	// Offsets into functions provided by UprobeMulti's symbols argument.
+	// For example: to set uprobes to main+5 and _start+10, call UprobeMulti
+	// with:
+	//     symbols: "main", "_start"
+	//     opt.Offsets: 5, 10
+	Offsets []uint64
+
+	// Optional list of associated ref counter offsets.
+	RefCtrOffsets []uint64
+
+	// Optional list of associated BPF cookies.
+	Cookies []uint64
+
+	// Only set the uprobe_multi link on the given process ID, zero PID means
+	// system-wide.
+	PID uint32
+}
+
+func (ex *Executable) UprobeMulti(symbols []string, prog *ebpf.Program, opts *UprobeMultiOptions) (Link, error) {
+	return ex.uprobeMulti(symbols, prog, opts, 0)
+}
+
+func (ex *Executable) UretprobeMulti(symbols []string, prog *ebpf.Program, opts *UprobeMultiOptions) (Link, error) {
+
+	// The return probe is not limited for symbols entry, so there's no special
+	// setup for return uprobes (other than the extra flag). The symbols, opts.Offsets
+	// and opts.Addresses arrays follow the same logic as for entry uprobes.
+	return ex.uprobeMulti(symbols, prog, opts, unix.BPF_F_UPROBE_MULTI_RETURN)
+}
+
+func (ex *Executable) uprobeMulti(symbols []string, prog *ebpf.Program, opts *UprobeMultiOptions, flags uint32) (Link, error) {
+	if prog == nil {
+		return nil, errors.New("cannot attach a nil program")
+	}
+
+	if opts == nil {
+		opts = &UprobeMultiOptions{}
+	}
+
+	addresses, err := ex.addresses(symbols, opts.Addresses, opts.Offsets)
+	if err != nil {
+		return nil, err
+	}
+
+	addrs := len(addresses)
+	cookies := len(opts.Cookies)
+	refCtrOffsets := len(opts.RefCtrOffsets)
+
+	if addrs == 0 {
+		return nil, fmt.Errorf("Addresses are required: %w", errInvalidInput)
+	}
+	if refCtrOffsets > 0 && refCtrOffsets != addrs {
+		return nil, fmt.Errorf("RefCtrOffsets must be exactly Addresses in length: %w", errInvalidInput)
+	}
+	if cookies > 0 && cookies != addrs {
+		return nil, fmt.Errorf("Cookies must be exactly Addresses in length: %w", errInvalidInput)
+	}
+
+	attr := &sys.LinkCreateUprobeMultiAttr{
+		Path:             sys.NewStringPointer(ex.path),
+		ProgFd:           uint32(prog.FD()),
+		AttachType:       sys.BPF_TRACE_UPROBE_MULTI,
+		UprobeMultiFlags: flags,
+		Count:            uint32(addrs),
+		Offsets:          sys.NewPointer(unsafe.Pointer(&addresses[0])),
+		Pid:              opts.PID,
+	}
+
+	if refCtrOffsets != 0 {
+		attr.RefCtrOffsets = sys.NewPointer(unsafe.Pointer(&opts.RefCtrOffsets[0]))
+	}
+	if cookies != 0 {
+		attr.Cookies = sys.NewPointer(unsafe.Pointer(&opts.Cookies[0]))
+	}
+
+	fd, err := sys.LinkCreateUprobeMulti(attr)
+	if errors.Is(err, unix.ESRCH) {
+		return nil, fmt.Errorf("%w (specified pid not found?)", os.ErrNotExist)
+	}
+	if errors.Is(err, unix.EINVAL) {
+		return nil, fmt.Errorf("%w (missing symbol or prog's AttachType not AttachTraceUprobeMulti?)", err)
+	}
+
+	if err != nil {
+		if haveFeatErr := haveBPFLinkUprobeMulti(); haveFeatErr != nil {
+			return nil, haveFeatErr
+		}
+		return nil, err
+	}
+
+	return &uprobeMultiLink{RawLink{fd, ""}}, nil
+}
+
+func (ex *Executable) addresses(symbols []string, addresses, offsets []uint64) ([]uint64, error) {
+	n := len(symbols)
+	if n == 0 {
+		n = len(addresses)
+	}
+
+	if n == 0 {
+		return nil, fmt.Errorf("%w: neither symbols nor addresses given", errInvalidInput)
+	}
+
+	if symbols != nil && len(symbols) != n {
+		return nil, fmt.Errorf("%w: have %d symbols but want %d", errInvalidInput, len(symbols), n)
+	}
+
+	if addresses != nil && len(addresses) != n {
+		return nil, fmt.Errorf("%w: have %d addresses but want %d", errInvalidInput, len(addresses), n)
+	}
+
+	if offsets != nil && len(offsets) != n {
+		return nil, fmt.Errorf("%w: have %d offsets but want %d", errInvalidInput, len(offsets), n)
+	}
+
+	results := make([]uint64, 0, n)
+	for i := 0; i < n; i++ {
+		var sym string
+		if symbols != nil {
+			sym = symbols[i]
+		}
+
+		var addr, off uint64
+		if addresses != nil {
+			addr = addresses[i]
+		}
+
+		if offsets != nil {
+			off = offsets[i]
+		}
+
+		result, err := ex.address(sym, addr, off)
+		if err != nil {
+			return nil, err
+		}
+
+		results = append(results, result)
+	}
+
+	return results, nil
+}
+
+type uprobeMultiLink struct {
+	RawLink
+}
+
+var _ Link = (*uprobeMultiLink)(nil)
+
+func (kml *uprobeMultiLink) Update(prog *ebpf.Program) error {
+	return fmt.Errorf("update uprobe_multi: %w", ErrNotSupported)
+}
+
+func (kml *uprobeMultiLink) Pin(string) error {
+	return fmt.Errorf("pin uprobe_multi: %w", ErrNotSupported)
+}
+
+func (kml *uprobeMultiLink) Unpin() error {
+	return fmt.Errorf("unpin uprobe_multi: %w", ErrNotSupported)
+}
+
+var haveBPFLinkUprobeMulti = internal.NewFeatureTest("bpf_link_uprobe_multi", "6.6", func() error {
+	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
+		Name: "probe_upm_link",
+		Type: ebpf.Kprobe,
+		Instructions: asm.Instructions{
+			asm.Mov.Imm(asm.R0, 0),
+			asm.Return(),
+		},
+		AttachType: ebpf.AttachTraceUprobeMulti,
+		License:    "MIT",
+	})
+	if errors.Is(err, unix.E2BIG) {
+		// Kernel doesn't support AttachType field.
+		return internal.ErrNotSupported
+	}
+	if err != nil {
+		return err
+	}
+	defer prog.Close()
+
+	// We try to create uprobe multi link on '/' path which results in
+	// error with -EBADF in case uprobe multi link is supported.
+	fd, err := sys.LinkCreateUprobeMulti(&sys.LinkCreateUprobeMultiAttr{
+		ProgFd:     uint32(prog.FD()),
+		AttachType: sys.BPF_TRACE_UPROBE_MULTI,
+		Path:       sys.NewStringPointer("/"),
+		Offsets:    sys.NewPointer(unsafe.Pointer(&[]uint64{0})),
+		Count:      1,
+	})
+	switch {
+	case errors.Is(err, unix.EBADF):
+		return nil
+	case errors.Is(err, unix.EINVAL):
+		return internal.ErrNotSupported
+	case err != nil:
+		return err
+	}
+
+	// should not happen
+	fd.Close()
+	return errors.New("successfully attached uprobe_multi to /, kernel bug?")
+})


Test patch:
diff --git a/link/kprobe_multi_test.go b/link/kprobe_multi_test.go
index aceb347..a1969b6 100644
--- a/link/kprobe_multi_test.go
+++ b/link/kprobe_multi_test.go
@@ -97,7 +97,7 @@ func TestKprobeMultiProgramCall(t *testing.T) {
 
 	// For simplicity, just assert the increment happens with any symbol in the array.
 	opts := KprobeMultiOptions{
-		Symbols: []string{"__do_sys_getpid"},
+		Symbols: []string{"__do_sys_getpid", "__do_sys_gettid"},
 	}
 	km, err := KprobeMulti(p, opts)
 	if err != nil {
@@ -106,9 +106,12 @@ func TestKprobeMultiProgramCall(t *testing.T) {
 
 	// Trigger ebpf program call.
 	unix.Getpid()
+	unix.Gettid()
 
-	// Assert that the value at index 0 has been updated to 1.
-	assertMapValue(t, m, 0, 1)
+	// Assert that the value got incremented to at least 2, while allowing
+	// for bigger values, because we could race with other getpid/gettid
+	// callers.
+	assertMapValueGE(t, m, 0, 2)
 
 	// Close the link.
 	if err := km.Close(); err != nil {
@@ -122,6 +125,7 @@ func TestKprobeMultiProgramCall(t *testing.T) {
 
 	// Retrigger the ebpf program call.
 	unix.Getpid()
+	unix.Gettid()
 
 	// Assert that this time the value has not been updated.
 	assertMapValue(t, m, 0, 0)
diff --git a/link/kprobe_test.go b/link/kprobe_test.go
index 7ac0a42..85c8fa1 100644
--- a/link/kprobe_test.go
+++ b/link/kprobe_test.go
@@ -263,8 +263,9 @@ func TestKprobeProgramCall(t *testing.T) {
 	// Trigger ebpf program call.
 	unix.Getpid()
 
-	// Assert that the value at index 0 has been updated to 1.
-	assertMapValue(t, m, 0, 1)
+	// Assert that the value got incremented to at least 1, while allowing
+	// for bigger values, because we could race with other getpid callers.
+	assertMapValueGE(t, m, 0, 1)
 
 	// Detach the Kprobe.
 	if err := k.Close(); err != nil {
@@ -295,19 +296,32 @@ func newUpdaterMapProg(t *testing.T, typ ebpf.ProgramType, attach ebpf.AttachTyp
 		t.Fatal(err)
 	}
 
-	// Create ebpf program. When called, will set the value of key 0 in
-	// the map created above to 1.
+	// Create ebpf program. When called, will increase the value of key 0 by 1
+	// in the map created above.
 	p, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Type: typ,
 		Instructions: asm.Instructions{
+			// R1 map
+			asm.LoadMapPtr(asm.R1, m.FD()),
+
+			// R2 key
+			asm.Mov.Reg(asm.R2, asm.R10),
+			asm.Add.Imm(asm.R2, -4),
+			asm.StoreImm(asm.R2, 0, 0, asm.Word),
+
+			// Lookup map[0]
+			asm.FnMapLookupElem.Call(),
+			asm.JEq.Imm(asm.R0, 0, "ret"),
+
+			// u32 val = R0++
+			asm.LoadMem(asm.R1, asm.R0, 0, asm.Word),
+			asm.Add.Imm(asm.R1, 1),
+			asm.StoreMem(asm.RFP, -8, asm.R1, asm.Word),
+
 			// u32 key = 0
 			asm.Mov.Imm(asm.R1, 0),
 			asm.StoreMem(asm.RFP, -4, asm.R1, asm.Word),
 
-			// u32 val = 1
-			asm.Mov.Imm(asm.R1, 1),
-			asm.StoreMem(asm.RFP, -8, asm.R1, asm.Word),
-
 			// bpf_map_update_elem(...)
 			asm.Mov.Reg(asm.R2, asm.RFP),
 			asm.Add.Imm(asm.R2, -4),
@@ -319,7 +333,7 @@ func newUpdaterMapProg(t *testing.T, typ ebpf.ProgramType, attach ebpf.AttachTyp
 
 			// exit 0
 			asm.Mov.Imm(asm.R0, 0),
-			asm.Return(),
+			asm.Return().WithSymbol("ret"),
 		},
 		AttachType: attach,
 		License:    "Dual MIT/GPL",
@@ -347,6 +361,16 @@ func assertMapValue(t *testing.T, m *ebpf.Map, k, v uint32) {
 	}
 }
 
+func assertMapValueGE(t *testing.T, m *ebpf.Map, k, v uint32) {
+	var val uint32
+	if err := m.Lookup(k, &val); err != nil {
+		t.Fatal(err)
+	}
+	if val < v {
+		t.Fatalf("unexpected value: want >= '%d', got '%d'", v, val)
+	}
+}
+
 func TestKprobeCookie(t *testing.T) {
 	testutils.SkipOnOldKernel(t, "5.15", "bpf_perf_link")
 
diff --git a/link/tracepoint_test.go b/link/tracepoint_test.go
index a58f1f2..1aa57f4 100644
--- a/link/tracepoint_test.go
+++ b/link/tracepoint_test.go
@@ -73,8 +73,9 @@ func TestTracepointProgramCall(t *testing.T) {
 	// Trigger ebpf program call.
 	unix.Getpid()
 
-	// Assert that the value at index 0 has been updated to 1.
-	assertMapValue(t, m, 0, 1)
+	// Assert that the value got incremented to at least 1, while allowing
+	// for bigger values, because we could race with other getpid callers.
+	assertMapValueGE(t, m, 0, 1)
 
 	// Detach the Tracepoint.
 	if err := tp.Close(); err != nil {
diff --git a/link/uprobe_multi_test.go b/link/uprobe_multi_test.go
new file mode 100644
index 0000000..65f0e43
--- /dev/null
+++ b/link/uprobe_multi_test.go
@@ -0,0 +1,246 @@
+package link
+
+import (
+	"errors"
+	"math"
+	"os"
+	"os/exec"
+	"testing"
+
+	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/internal/testutils"
+	"github.com/go-quicktest/qt"
+)
+
+func TestUprobeMulti(t *testing.T) {
+	testutils.SkipIfNotSupported(t, haveBPFLinkUprobeMulti())
+
+	prog := mustLoadProgram(t, ebpf.Kprobe, ebpf.AttachTraceUprobeMulti, "")
+
+	// uprobe
+	um, err := bashEx.UprobeMulti(bashSyms, prog, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	testLink(t, um, prog)
+	_ = um.Close()
+
+	// uretprobe
+	um, err = bashEx.UretprobeMulti(bashSyms, prog, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	testLink(t, um, prog)
+	_ = um.Close()
+}
+
+func TestUprobeMultiInput(t *testing.T) {
+	testutils.SkipIfNotSupported(t, haveBPFLinkUprobeMulti())
+
+	prog := mustLoadProgram(t, ebpf.Kprobe, ebpf.AttachTraceUprobeMulti, "")
+
+	// Always doing same test for both uprobe and uretprobe
+
+	// One of symbols or offsets must be given.
+	_, err := bashEx.UprobeMulti([]string{}, prog, nil)
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	_, err = bashEx.UretprobeMulti([]string{}, prog, nil)
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	// One address, two cookies.
+	_, err = bashEx.UprobeMulti([]string{}, prog, &UprobeMultiOptions{
+		Addresses: []uint64{1},
+		Cookies:   []uint64{2, 3},
+	})
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	_, err = bashEx.UretprobeMulti([]string{}, prog, &UprobeMultiOptions{
+		Addresses: []uint64{1},
+		Cookies:   []uint64{2, 3},
+	})
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	// Two addresses, one refctr offset.
+	_, err = bashEx.UprobeMulti([]string{}, prog, &UprobeMultiOptions{
+		Addresses:     []uint64{1, 2},
+		RefCtrOffsets: []uint64{4},
+	})
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	_, err = bashEx.UretprobeMulti([]string{}, prog, &UprobeMultiOptions{
+		Addresses:     []uint64{1, 2},
+		RefCtrOffsets: []uint64{4},
+	})
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	// It's either symbols or addresses.
+	_, err = bashEx.UprobeMulti(bashSyms, prog, &UprobeMultiOptions{
+		Addresses: []uint64{1},
+	})
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	_, err = bashEx.UretprobeMulti(bashSyms, prog, &UprobeMultiOptions{
+		Addresses: []uint64{1},
+	})
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	// No addresses and no symbols
+	_, err = bashEx.UprobeMulti([]string{}, prog, nil)
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	_, err = bashEx.UretprobeMulti([]string{}, prog, nil)
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	// PID not found
+	_, err = bashEx.UprobeMulti(bashSyms, prog, &UprobeMultiOptions{
+		PID: math.MaxUint32,
+	})
+	qt.Assert(t, qt.ErrorIs(err, os.ErrNotExist))
+
+	_, err = bashEx.UretprobeMulti(bashSyms, prog, &UprobeMultiOptions{
+		PID: math.MaxUint32,
+	})
+	qt.Assert(t, qt.ErrorIs(err, os.ErrNotExist))
+}
+
+func TestUprobeMultiResolveOk(t *testing.T) {
+	addrSym1, err := bashEx.address(bashSyms[0], 0, 0)
+	qt.Assert(t, qt.IsNil(err))
+	addrSym2, err := bashEx.address(bashSyms[1], 0, 0)
+	qt.Assert(t, qt.IsNil(err))
+	addrSym3, err := bashEx.address(bashSyms[2], 0, 0)
+	qt.Assert(t, qt.IsNil(err))
+
+	addrs, err := bashEx.addresses(bashSyms, nil, nil)
+	qt.Assert(t, qt.IsNil(err))
+	qt.Assert(t, qt.DeepEquals(addrs, []uint64{addrSym1, addrSym2, addrSym3}))
+
+	addrs, err = bashEx.addresses(bashSyms, nil, []uint64{5, 10, 11})
+	qt.Assert(t, qt.IsNil(err))
+	qt.Assert(t, qt.DeepEquals(addrs, []uint64{addrSym1 + 5, addrSym2 + 10, addrSym3 + 11}))
+
+	addrs, err = bashEx.addresses(bashSyms, []uint64{1, 2, 3}, nil)
+	qt.Assert(t, qt.IsNil(err))
+
+	qt.Assert(t, qt.DeepEquals(addrs, []uint64{1, 2, 3}))
+}
+
+func TestUprobeMultiResolveFail(t *testing.T) {
+	// No input
+	_, err := bashEx.addresses(nil, nil, nil)
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	// Different dimensions for Addresses and Offsets
+	_, err = bashEx.addresses(nil, []uint64{100, 200}, []uint64{5, 10, 11})
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+
+	// Different dimensions for symbols and Offsets
+	_, err = bashEx.addresses(bashSyms, nil, []uint64{5, 10})
+	qt.Assert(t, qt.ErrorIs(err, errInvalidInput))
+}
+
+func TestUprobeMultiCookie(t *testing.T) {
+	testutils.SkipIfNotSupported(t, haveBPFLinkUprobeMulti())
+
+	prog := mustLoadProgram(t, ebpf.Kprobe, ebpf.AttachTraceUprobeMulti, "")
+
+	// uprobe
+	um, err := bashEx.UprobeMulti(bashSyms, prog,
+		&UprobeMultiOptions{
+			Cookies: []uint64{1, 2, 3},
+		})
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = um.Close()
+
+	// uretprobe
+	um, err = bashEx.UretprobeMulti(bashSyms, prog,
+		&UprobeMultiOptions{
+			Cookies: []uint64{3, 2, 1},
+		})
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = um.Close()
+}
+
+func TestUprobeMultiProgramCall(t *testing.T) {
+	testutils.SkipIfNotSupported(t, haveBPFLinkUprobeMulti())
+
+	// We execute 'bash --help'
+	args := []string{"--help"}
+	elf := "/bin/bash"
+
+	test := func(retprobe bool, expected uint32) {
+		m, p := newUpdaterMapProg(t, ebpf.Kprobe, ebpf.AttachTraceUprobeMulti)
+
+		var err error
+
+		// Load the executable.
+		ex, err := OpenExecutable(elf)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		var um Link
+
+		// Open UprobeMulti on the executable for the given symbol
+		// and attach it to the ebpf program created above.
+		if retprobe {
+			um, err = ex.UretprobeMulti(bashSyms, p, nil)
+		} else {
+			um, err = ex.UprobeMulti(bashSyms, p, nil)
+		}
+		if errors.Is(err, ErrNoSymbol) {
+			// Assume bash_Syms symbols always exist and skip the test
+			// if the symbol can't be found as certain OS (eg. Debian)
+			// strip binaries.
+			t.Skipf("executable %s appear to be stripped, skipping", elf)
+		}
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Trigger ebpf program call.
+		trigger := func(t *testing.T) {
+			if err := exec.Command(elf, args...).Run(); err != nil {
+				t.Fatal(err)
+			}
+		}
+		trigger(t)
+
+		// Detach link.
+		if err := um.Close(); err != nil {
+			t.Fatal(err)
+		}
+
+		assertMapValueGE(t, m, 0, expected)
+
+		// Reset map value to 0 at index 0.
+		if err := m.Update(uint32(0), uint32(0), ebpf.UpdateExist); err != nil {
+			t.Fatal(err)
+		}
+
+		// Retrigger the ebpf program call.
+		trigger(t)
+
+		// Assert that this time the value has not been updated.
+		assertMapValue(t, m, 0, 0)
+	}
+
+	// all 3 uprobes should trigger for entry uprobes
+	test(false, 3)
+
+	// We have return uprobe installed on main, _start and check_dev_tty
+	// functions, but only check_dev_tty is triggered, because 'bash --help'
+	// calls exit(0).
+	test(true, 1)
+}
+
+func TestHaveBPFLinkUprobeMulti(t *testing.T) {
+	testutils.CheckFeatureTest(t, haveBPFLinkUprobeMulti)
+}
diff --git a/link/uprobe_test.go b/link/uprobe_test.go
index fdc6125..bc49a1f 100644
--- a/link/uprobe_test.go
+++ b/link/uprobe_test.go
@@ -18,7 +18,8 @@ import (
 
 var (
 	bashEx, _ = OpenExecutable("/bin/bash")
-	bashSym   = "main"
+	bashSyms  = []string{"main", "_start", "check_dev_tty"}
+	bashSym   = bashSyms[0]
 )
 
 func TestExecutable(t *testing.T) {
@@ -31,36 +32,36 @@ func TestExecutable(t *testing.T) {
 		t.Fatalf("create executable: unexpected path '%s'", bashEx.path)
 	}
 
-	_, err = bashEx.address(bashSym, &UprobeOptions{})
+	_, err = bashEx.address(bashSym, 0, 0)
 	if err != nil {
 		t.Fatalf("find offset: %v", err)
 	}
 
-	_, err = bashEx.address("bogus", &UprobeOptions{})
+	_, err = bashEx.address("bogus", 0, 0)
 	if err == nil {
 		t.Fatal("find symbol: expected error")
 	}
 }
 
 func TestExecutableOffset(t *testing.T) {
-	symbolOffset, err := bashEx.address(bashSym, &UprobeOptions{})
+	symbolOffset, err := bashEx.address(bashSym, 0, 0)
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	offset, err := bashEx.address(bashSym, &UprobeOptions{Address: 0x1})
+	offset, err := bashEx.address(bashSym, 0x1, 0)
 	if err != nil {
 		t.Fatal(err)
 	}
 	qt.Assert(t, qt.Equals(offset, 0x1))
 
-	offset, err = bashEx.address(bashSym, &UprobeOptions{Offset: 0x2})
+	offset, err = bashEx.address(bashSym, 0, 0x2)
 	if err != nil {
 		t.Fatal(err)
 	}
 	qt.Assert(t, qt.Equals(offset, symbolOffset+0x2))
 
-	offset, err = bashEx.address(bashSym, &UprobeOptions{Address: 0x1, Offset: 0x2})
+	offset, err = bashEx.address(bashSym, 0x1, 0x2)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -71,7 +72,7 @@ func TestExecutableLazyLoadSymbols(t *testing.T) {
 	ex, err := OpenExecutable("/bin/bash")
 	qt.Assert(t, qt.IsNil(err))
 	// Addresses must be empty, will be lazy loaded.
-	qt.Assert(t, qt.HasLen(ex.addresses, 0))
+	qt.Assert(t, qt.HasLen(ex.cachedAddresses, 0))
 
 	prog := mustLoadProgram(t, ebpf.Kprobe, 0, "")
 	// Address must be a multiple of 4 on arm64, see
@@ -81,14 +82,14 @@ func TestExecutableLazyLoadSymbols(t *testing.T) {
 	up.Close()
 
 	// Addresses must still be empty as Address has been provided via options.
-	qt.Assert(t, qt.HasLen(ex.addresses, 0))
+	qt.Assert(t, qt.HasLen(ex.cachedAddresses, 0))
 
 	up, err = ex.Uprobe(bashSym, prog, nil)
 	qt.Assert(t, qt.IsNil(err))
 	up.Close()
 
 	// Symbol table should be loaded.
-	qt.Assert(t, qt.Not(qt.HasLen(ex.addresses, 0)))
+	qt.Assert(t, qt.Not(qt.HasLen(ex.cachedAddresses, 0)))
 }
 
 func TestUprobe(t *testing.T) {
@@ -162,7 +163,7 @@ func TestUprobeCreatePMU(t *testing.T) {
 	testutils.SkipOnOldKernel(t, "4.17", "perf_kprobe PMU")
 
 	// Fetch the offset from the /bin/bash Executable already defined.
-	off, err := bashEx.address(bashSym, &UprobeOptions{})
+	off, err := bashEx.address(bashSym, 0, 0)
 	qt.Assert(t, qt.IsNil(err))
 
 	// Prepare probe args.
@@ -189,7 +190,7 @@ func TestUprobeCreatePMU(t *testing.T) {
 // Test fallback behaviour on kernels without perf_uprobe PMU available.
 func TestUprobePMUUnavailable(t *testing.T) {
 	// Fetch the offset from the /bin/bash Executable already defined.
-	off, err := bashEx.address(bashSym, &UprobeOptions{})
+	off, err := bashEx.address(bashSym, 0, 0)
 	qt.Assert(t, qt.IsNil(err))
 
 	// Prepare probe args.
@@ -214,7 +215,7 @@ func TestUprobePMUUnavailable(t *testing.T) {
 // Test tracefs u(ret)probe creation on all kernel versions.
 func TestUprobeTraceFS(t *testing.T) {
 	// Fetch the offset from the /bin/bash Executable already defined.
-	off, err := bashEx.address(bashSym, &UprobeOptions{})
+	off, err := bashEx.address(bashSym, 0, 0)
 	qt.Assert(t, qt.IsNil(err))
 
 	// Prepare probe args.
@@ -320,8 +321,9 @@ func TestUprobeProgramCall(t *testing.T) {
 			}
 			trigger(t)
 
-			// Assert that the value at index 0 has been updated to 1.
-			assertMapValue(t, m, 0, 1)
+			// Assert that the value got incremented to at least 1, while allowing
+			// for bigger values, because we could race with other bash execution.
+			assertMapValueGE(t, m, 0, 1)
 
 			// Detach the Uprobe.
 			if err := u.Close(); err != nil {