Repository: cilium/ebpf
Pull request #865 (2022-11-23 10:55:30)

bpf2go: write dependencies to temporary file to support Windows
I want to develop Android eBPF programs on Windows, but found that bpf2go uses `/dev/fd/3` to accept dependencies output, which is not supported on Windows. This commit modified it to use temporary files to accept dependencies.


Patch:
diff --git a/btf/btf.go b/btf/btf.go
index a11bc03..793701b 100644
--- a/btf/btf.go
+++ b/btf/btf.go
@@ -11,6 +11,7 @@ import (
 	"math"
 	"os"
 	"reflect"
+	"sync"
 
 	"github.com/cilium/ebpf/internal"
 	"github.com/cilium/ebpf/internal/sys"
@@ -34,9 +35,9 @@ type Spec struct {
 	// Data from .BTF.
 	strings *stringTable
 
-	// All types contained by the spec. For the base type, the position of
-	// a type in the slice is its ID.
-	types types
+	// All types contained by the spec, not including types from the base in
+	// case the spec was parsed from split BTF.
+	types []Type
 
 	// Type IDs indexed by type.
 	typeIDs map[Type]TypeID
@@ -263,20 +264,67 @@ func indexTypes(types []Type, typeIDOffset TypeID) (map[Type]TypeID, map[essenti
 // Defaults to /sys/kernel/btf/vmlinux and falls back to scanning the file system
 // for vmlinux ELFs. Returns an error wrapping ErrNotSupported if BTF is not enabled.
 func LoadKernelSpec() (*Spec, error) {
+	spec, _, err := kernelSpec()
+	return spec, err
+}
+
+var kernelBTF struct {
+	sync.RWMutex
+	spec *Spec
+	// True if the spec was read from an ELF instead of raw BTF in /sys.
+	fallback bool
+}
+
+// FlushKernelSpec removes any cached kernel type information.
+func FlushKernelSpec() {
+	kernelBTF.Lock()
+	defer kernelBTF.Unlock()
+
+	kernelBTF.spec, kernelBTF.fallback = nil, false
+}
+
+func kernelSpec() (*Spec, bool, error) {
+	kernelBTF.RLock()
+	spec, fallback := kernelBTF.spec, kernelBTF.fallback
+	kernelBTF.RUnlock()
+
+	if spec == nil {
+		kernelBTF.Lock()
+		defer kernelBTF.Unlock()
+
+		spec, fallback = kernelBTF.spec, kernelBTF.fallback
+	}
+
+	if spec != nil {
+		return spec.Copy(), fallback, nil
+	}
+
+	spec, fallback, err := loadKernelSpec()
+	if err != nil {
+		return nil, false, err
+	}
+
+	kernelBTF.spec, kernelBTF.fallback = spec, fallback
+	return spec.Copy(), fallback, nil
+}
+
+func loadKernelSpec() (_ *Spec, fallback bool, _ error) {
 	fh, err := os.Open("/sys/kernel/btf/vmlinux")
 	if err == nil {
 		defer fh.Close()
 
-		return loadRawSpec(fh, internal.NativeEndian, nil, nil)
+		spec, err := loadRawSpec(fh, internal.NativeEndian, nil, nil)
+		return spec, false, err
 	}
 
 	file, err := findVMLinux()
 	if err != nil {
-		return nil, err
+		return nil, false, err
 	}
 	defer file.Close()
 
-	return loadSpecFromELF(file)
+	spec, err := loadSpecFromELF(file)
+	return spec, true, err
 }
 
 // findVMLinux scans multiple well-known paths for vmlinux kernel images.
@@ -421,11 +469,7 @@ func fixupDatasec(types []Type, sectionSizes map[string]uint32, offsets map[symb
 func (s *Spec) Copy() *Spec {
 	types := copyTypes(s.types, nil)
 
-	typeIDOffset := TypeID(0)
-	if len(s.types) != 0 {
-		typeIDOffset = s.typeIDs[s.types[0]]
-	}
-	typeIDs, typesByName := indexTypes(types, typeIDOffset)
+	typeIDs, typesByName := indexTypes(types, s.firstTypeID())
 
 	// NB: Other parts of spec are not copied since they are immutable.
 	return &Spec{
@@ -452,7 +496,14 @@ func (sw sliceWriter) Write(p []byte) (int, error) {
 // Returns an error wrapping ErrNotFound if a Type with the given ID
 // does not exist in the Spec.
 func (s *Spec) TypeByID(id TypeID) (Type, error) {
-	return s.types.ByID(id)
+	firstID := s.firstTypeID()
+	lastID := firstID + TypeID(len(s.types))
+
+	if id < firstID || id >= lastID {
+		return nil, fmt.Errorf("expected type ID between %d and %d, got %d: %w", firstID, lastID, id, ErrNotFound)
+	}
+
+	return s.types[id-firstID], nil
 }
 
 // TypeID returns the ID for a given Type.
@@ -574,6 +625,14 @@ func (s *Spec) TypeByName(name string, typ interface{}) error {
 	return nil
 }
 
+// firstTypeID returns the first type ID or zero.
+func (s *Spec) firstTypeID() TypeID {
+	if len(s.types) > 0 {
+		return s.typeIDs[s.types[0]]
+	}
+	return 0
+}
+
 // LoadSplitSpecFromReader loads split BTF from a reader.
 //
 // Types from base are used to resolve references in the split BTF.
@@ -606,138 +665,6 @@ func (iter *TypesIterator) Next() bool {
 	return true
 }
 
-// Handle is a reference to BTF loaded into the kernel.
-type Handle struct {
-	fd *sys.FD
-
-	// Size of the raw BTF in bytes.
-	size uint32
-}
-
-// NewHandle loads BTF into the kernel.
-//
-// Returns ErrNotSupported if BTF is not supported.
-func NewHandle(spec *Spec) (*Handle, error) {
-	if spec.byteOrder != nil && spec.byteOrder != internal.NativeEndian {
-		return nil, fmt.Errorf("can't load %s BTF on %s", spec.byteOrder, internal.NativeEndian)
-	}
-
-	enc := newEncoder(kernelEncoderOptions, newStringTableBuilderFromTable(spec.strings))
-
-	for _, typ := range spec.types {
-		_, err := enc.Add(typ)
-		if err != nil {
-			return nil, fmt.Errorf("add %s: %w", typ, err)
-		}
-	}
-
-	btf, err := enc.Encode()
-	if err != nil {
-		return nil, fmt.Errorf("marshal BTF: %w", err)
-	}
-
-	return newHandleFromRawBTF(btf)
-}
-
-func newHandleFromRawBTF(btf []byte) (*Handle, error) {
-	if uint64(len(btf)) > math.MaxUint32 {
-		return nil, errors.New("BTF exceeds the maximum size")
-	}
-
-	attr := &sys.BtfLoadAttr{
-		Btf:     sys.NewSlicePointer(btf),
-		BtfSize: uint32(len(btf)),
-	}
-
-	fd, err := sys.BtfLoad(attr)
-	if err == nil {
-		return &Handle{fd, attr.BtfSize}, nil
-	}
-
-	if err := haveBTF(); err != nil {
-		return nil, err
-	}
-
-	logBuf := make([]byte, 64*1024)
-	attr.BtfLogBuf = sys.NewSlicePointer(logBuf)
-	attr.BtfLogSize = uint32(len(logBuf))
-	attr.BtfLogLevel = 1
-
-	// Up until at least kernel 6.0, the BTF verifier does not return ENOSPC
-	// if there are other verification errors. ENOSPC is only returned when
-	// the BTF blob is correct, a log was requested, and the provided buffer
-	// is too small.
-	_, ve := sys.BtfLoad(attr)
-	return nil, internal.ErrorWithLog("load btf", err, logBuf, errors.Is(ve, unix.ENOSPC))
-}
-
-// NewHandleFromID returns the BTF handle for a given id.
-//
-// Prefer calling [ebpf.Program.Handle] or [ebpf.Map.Handle] if possible.
-//
-// Returns ErrNotExist, if there is no BTF with the given id.
-//
-// Requires CAP_SYS_ADMIN.
-func NewHandleFromID(id ID) (*Handle, error) {
-	fd, err := sys.BtfGetFdById(&sys.BtfGetFdByIdAttr{
-		Id: uint32(id),
-	})
-	if err != nil {
-		return nil, fmt.Errorf("get FD for ID %d: %w", id, err)
-	}
-
-	info, err := newHandleInfoFromFD(fd)
-	if err != nil {
-		_ = fd.Close()
-		return nil, err
-	}
-
-	return &Handle{fd, info.size}, nil
-}
-
-// Spec parses the kernel BTF into Go types.
-//
-// base is used to decode split BTF and may be nil.
-func (h *Handle) Spec(base *Spec) (*Spec, error) {
-	var btfInfo sys.BtfInfo
-	btfBuffer := make([]byte, h.size)
-	btfInfo.Btf, btfInfo.BtfSize = sys.NewSlicePointerLen(btfBuffer)
-
-	if err := sys.ObjInfo(h.fd, &btfInfo); err != nil {
-		return nil, err
-	}
-
-	var baseTypes types
-	var baseStrings *stringTable
-	if base != nil {
-		baseTypes = base.types
-		baseStrings = base.strings
-	}
-
-	return loadRawSpec(bytes.NewReader(btfBuffer), internal.NativeEndian, baseTypes, baseStrings)
-}
-
-// Close destroys the handle.
-//
-// Subsequent calls to FD will return an invalid value.
-func (h *Handle) Close() error {
-	if h == nil {
-		return nil
-	}
-
-	return h.fd.Close()
-}
-
-// FD returns the file descriptor for the handle.
-func (h *Handle) FD() int {
-	return h.fd.Int()
-}
-
-// Info returns metadata about the handle.
-func (h *Handle) Info() (*HandleInfo, error) {
-	return newHandleInfoFromFD(h.fd)
-}
-
 func marshalBTF(types interface{}, strings []byte, bo binary.ByteOrder) []byte {
 	const minHeaderLength = 24
 
@@ -762,7 +689,7 @@ func marshalBTF(types interface{}, strings []byte, bo binary.ByteOrder) []byte {
 
 // haveBTF attempts to load a BTF blob containing an Int. It should pass on any
 // kernel that supports BPF_BTF_LOAD.
-var haveBTF = internal.FeatureTest("BTF", "4.18", func() error {
+var haveBTF = internal.NewFeatureTest("BTF", "4.18", func() error {
 	var (
 		types struct {
 			Integer btfType
@@ -792,7 +719,7 @@ var haveBTF = internal.FeatureTest("BTF", "4.18", func() error {
 // haveMapBTF attempts to load a minimal BTF blob containing a Var. It is
 // used as a proxy for .bss, .data and .rodata map support, which generally
 // come with a Var and Datasec. These were introduced in Linux 5.2.
-var haveMapBTF = internal.FeatureTest("Map BTF (Var/Datasec)", "5.2", func() error {
+var haveMapBTF = internal.NewFeatureTest("Map BTF (Var/Datasec)", "5.2", func() error {
 	if err := haveBTF(); err != nil {
 		return err
 	}
@@ -833,7 +760,7 @@ var haveMapBTF = internal.FeatureTest("Map BTF (Var/Datasec)", "5.2", func() err
 // haveProgBTF attempts to load a BTF blob containing a Func and FuncProto. It
 // is used as a proxy for ext_info (func_info) support, which depends on
 // Func(Proto) by definition.
-var haveProgBTF = internal.FeatureTest("Program BTF (func/line_info)", "5.0", func() error {
+var haveProgBTF = internal.NewFeatureTest("Program BTF (func/line_info)", "5.0", func() error {
 	if err := haveBTF(); err != nil {
 		return err
 	}
@@ -868,7 +795,7 @@ var haveProgBTF = internal.FeatureTest("Program BTF (func/line_info)", "5.0", fu
 	return nil
 })
 
-var haveFuncLinkage = internal.FeatureTest("BTF func linkage", "5.6", func() error {
+var haveFuncLinkage = internal.NewFeatureTest("BTF func linkage", "5.6", func() error {
 	if err := haveProgBTF(); err != nil {
 		return err
 	}
diff --git a/btf/handle.go b/btf/handle.go
index 3d540e4..9a864d1 100644
--- a/btf/handle.go
+++ b/btf/handle.go
@@ -1,14 +1,155 @@
 package btf
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
+	"math"
 	"os"
 
+	"github.com/cilium/ebpf/internal"
 	"github.com/cilium/ebpf/internal/sys"
 	"github.com/cilium/ebpf/internal/unix"
 )
 
+// Handle is a reference to BTF loaded into the kernel.
+type Handle struct {
+	fd *sys.FD
+
+	// Size of the raw BTF in bytes.
+	size uint32
+
+	needsKernelBase bool
+}
+
+// NewHandle loads BTF into the kernel.
+//
+// Returns ErrNotSupported if BTF is not supported.
+func NewHandle(spec *Spec) (*Handle, error) {
+	if spec.byteOrder != nil && spec.byteOrder != internal.NativeEndian {
+		return nil, fmt.Errorf("can't load %s BTF on %s", spec.byteOrder, internal.NativeEndian)
+	}
+
+	enc := newEncoder(kernelEncoderOptions, newStringTableBuilderFromTable(spec.strings))
+
+	for _, typ := range spec.types {
+		_, err := enc.Add(typ)
+		if err != nil {
+			return nil, fmt.Errorf("add %s: %w", typ, err)
+		}
+	}
+
+	btf, err := enc.Encode()
+	if err != nil {
+		return nil, fmt.Errorf("marshal BTF: %w", err)
+	}
+
+	return newHandleFromRawBTF(btf)
+}
+
+func newHandleFromRawBTF(btf []byte) (*Handle, error) {
+	if uint64(len(btf)) > math.MaxUint32 {
+		return nil, errors.New("BTF exceeds the maximum size")
+	}
+
+	attr := &sys.BtfLoadAttr{
+		Btf:     sys.NewSlicePointer(btf),
+		BtfSize: uint32(len(btf)),
+	}
+
+	fd, err := sys.BtfLoad(attr)
+	if err == nil {
+		return &Handle{fd, attr.BtfSize, false}, nil
+	}
+
+	if err := haveBTF(); err != nil {
+		return nil, err
+	}
+
+	logBuf := make([]byte, 64*1024)
+	attr.BtfLogBuf = sys.NewSlicePointer(logBuf)
+	attr.BtfLogSize = uint32(len(logBuf))
+	attr.BtfLogLevel = 1
+
+	// Up until at least kernel 6.0, the BTF verifier does not return ENOSPC
+	// if there are other verification errors. ENOSPC is only returned when
+	// the BTF blob is correct, a log was requested, and the provided buffer
+	// is too small.
+	_, ve := sys.BtfLoad(attr)
+	return nil, internal.ErrorWithLog("load btf", err, logBuf, errors.Is(ve, unix.ENOSPC))
+}
+
+// NewHandleFromID returns the BTF handle for a given id.
+//
+// Prefer calling [ebpf.Program.Handle] or [ebpf.Map.Handle] if possible.
+//
+// Returns ErrNotExist, if there is no BTF with the given id.
+//
+// Requires CAP_SYS_ADMIN.
+func NewHandleFromID(id ID) (*Handle, error) {
+	fd, err := sys.BtfGetFdById(&sys.BtfGetFdByIdAttr{
+		Id: uint32(id),
+	})
+	if err != nil {
+		return nil, fmt.Errorf("get FD for ID %d: %w", id, err)
+	}
+
+	info, err := newHandleInfoFromFD(fd)
+	if err != nil {
+		_ = fd.Close()
+		return nil, err
+	}
+
+	return &Handle{fd, info.size, info.IsModule()}, nil
+}
+
+// Spec parses the kernel BTF into Go types.
+func (h *Handle) Spec() (*Spec, error) {
+	var btfInfo sys.BtfInfo
+	btfBuffer := make([]byte, h.size)
+	btfInfo.Btf, btfInfo.BtfSize = sys.NewSlicePointerLen(btfBuffer)
+
+	if err := sys.ObjInfo(h.fd, &btfInfo); err != nil {
+		return nil, err
+	}
+
+	if !h.needsKernelBase {
+		return loadRawSpec(bytes.NewReader(btfBuffer), internal.NativeEndian, nil, nil)
+	}
+
+	base, fallback, err := kernelSpec()
+	if err != nil {
+		return nil, fmt.Errorf("load BTF base: %w", err)
+	}
+
+	if fallback {
+		return nil, fmt.Errorf("can't load split BTF without access to /sys")
+	}
+
+	return loadRawSpec(bytes.NewReader(btfBuffer), internal.NativeEndian, base.types, base.strings)
+}
+
+// Close destroys the handle.
+//
+// Subsequent calls to FD will return an invalid value.
+func (h *Handle) Close() error {
+	if h == nil {
+		return nil
+	}
+
+	return h.fd.Close()
+}
+
+// FD returns the file descriptor for the handle.
+func (h *Handle) FD() int {
+	return h.fd.Int()
+}
+
+// Info returns metadata about the handle.
+func (h *Handle) Info() (*HandleInfo, error) {
+	return newHandleInfoFromFD(h.fd)
+}
+
 // HandleInfo describes a Handle.
 type HandleInfo struct {
 	// ID of this handle in the kernel. The ID is only valid as long as the
diff --git a/cmd/bpf2go/compile.go b/cmd/bpf2go/compile.go
index 64b4876..2aa08f9 100644
--- a/cmd/bpf2go/compile.go
+++ b/cmd/bpf2go/compile.go
@@ -70,45 +70,36 @@ func compile(args compileArgs) error {
 	)
 	cmd.Dir = args.dir
 
-	var depRd, depWr *os.File
+	var depFile *os.File
 	if args.dep != nil {
-		depRd, depWr, err = os.Pipe()
+		depFile, err = os.CreateTemp("", "bpf2go")
 		if err != nil {
 			return err
 		}
-		defer depRd.Close()
-		defer depWr.Close()
+		defer depFile.Close()
+		defer os.Remove(depFile.Name())
 
-		// This becomes /dev/fd/3
-		cmd.ExtraFiles = append(cmd.ExtraFiles, depWr)
 		cmd.Args = append(cmd.Args,
 			// Output dependency information.
 			"-MD",
 			// Create phony targets so that deleting a dependency doesn't
 			// break the build.
 			"-MP",
-			// Write it to our pipe
-			"-MF/dev/fd/3",
+			// Write it to temporary file
+			"-MF"+depFile.Name(),
 		)
 	}
 
-	if err := cmd.Start(); err != nil {
+	if err := cmd.Run(); err != nil {
 		return fmt.Errorf("can't execute %s: %s", args.cc, err)
 	}
 
-	if depRd != nil {
-		// Close our copy of the write end so that Copy will terminate
-		// when cc exits.
-		depWr.Close()
-		if _, err := io.Copy(args.dep, depRd); err != nil {
+	if depFile != nil {
+		if _, err := io.Copy(args.dep, depFile); err != nil {
 			return fmt.Errorf("error writing depfile: %w", err)
 		}
 	}
 
-	if err := cmd.Wait(); err != nil {
-		return fmt.Errorf("%s: %s", args.cc, err)
-	}
-
 	return nil
 }
 
diff --git a/features/errors.go b/features/errors.go
deleted file mode 100644
index 87e7fa6..0000000
--- a/features/errors.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package features
-
-import (
-	"errors"
-	"fmt"
-
-	"github.com/cilium/ebpf"
-)
-
-// wrapProbeErrors wraps err to prevent callers from directly comparing
-// it to exported sentinels. Error rewriting in Go can be implemented by
-// deferring a closure over a named error return variable. This gives the
-// closure access to the stack space where the return value will be written,
-// allowing it to intercept and rewrite all returns, regardless of whether
-// or not the return statements use the named return variable.
-//
-//	func foo() (err error) {
-//	  defer func() {
-//	    err = wrapProbeErrors(err)
-//	  }
-//	  return errors.New("this error will be wrapped")
-//	}
-func wrapProbeErrors(err error) error {
-	if err == nil {
-		return nil
-	}
-
-	// Wrap all errors to prevent them from being compared directly
-	// to exported sentinels by the caller.
-	errStr := "%w"
-
-	if !errors.Is(err, ebpf.ErrNotSupported) {
-		// Wrap unexpected errors with an appropriate error string.
-		errStr = "unexpected error during feature probe: %w"
-	}
-
-	return fmt.Errorf(errStr, err)
-}
diff --git a/features/map.go b/features/map.go
index c9a70df..093f37e 100644
--- a/features/map.go
+++ b/features/map.go
@@ -2,166 +2,228 @@ package features
 
 import (
 	"errors"
+	"fmt"
 	"os"
-	"sync"
 	"unsafe"
 
 	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/internal"
 	"github.com/cilium/ebpf/internal/sys"
 	"github.com/cilium/ebpf/internal/unix"
 )
 
-func init() {
-	mc.mapTypes = make(map[ebpf.MapType]error)
-	mc.mapFlags = make(map[MapFlags]error)
-}
-
-var (
-	mc mapCache
-)
-
-type mapCache struct {
-	sync.Mutex
-	mapTypes map[ebpf.MapType]error
-	mapFlags map[MapFlags]error
+// HaveMapType probes the running kernel for the availability of the specified map type.
+//
+// See the package documentation for the meaning of the error return value.
+func HaveMapType(mt ebpf.MapType) error {
+	return haveMapTypeMatrix.Result(mt)
 }
 
-func createMapTypeAttr(mt ebpf.MapType) *sys.MapCreateAttr {
-	a := &sys.MapCreateAttr{
-		MapType:    sys.MapType(mt),
-		KeySize:    4,
+func probeCgroupStorageMap(mt sys.MapType) error {
+	// keySize needs to be sizeof(struct{u32 + u64}) = 12 (+ padding = 16)
+	// by using unsafe.Sizeof(int) we are making sure that this works on 32bit and 64bit archs
+	return createMap(&sys.MapCreateAttr{
+		MapType:    mt,
 		ValueSize:  4,
-		MaxEntries: 1,
-	}
-
-	// switch on map types to generate correct MapCreateAttr
-	switch mt {
-	case ebpf.StackTrace:
-		// valueSize needs to be sizeof(uint64)
-		a.ValueSize = 8
-	case ebpf.LPMTrie:
-		// keySize and valueSize need to be sizeof(struct{u32 + u8}) + 1 + padding = 8
-		// BPF_F_NO_PREALLOC needs to be set
-		// checked at allocation time for lpm_trie maps
-		a.KeySize = 8
-		a.ValueSize = 8
-		a.MapFlags = unix.BPF_F_NO_PREALLOC
-	case ebpf.ArrayOfMaps, ebpf.HashOfMaps:
-		// assign invalid innerMapFd to pass validation check
-		// will return EBADF
-		a.InnerMapFd = ^uint32(0)
-	case ebpf.CGroupStorage, ebpf.PerCPUCGroupStorage:
-		// keySize needs to be sizeof(struct{u32 + u64}) = 12 (+ padding = 16)
-		// by using unsafe.Sizeof(int) we are making sure that this works on 32bit and 64bit archs
-		// checked at allocation time
-		var align int
-		a.KeySize = uint32(8 + unsafe.Sizeof(align))
-		a.MaxEntries = 0
-	case ebpf.Queue, ebpf.Stack:
-		// keySize needs to be 0, see alloc_check for queue and stack maps
-		a.KeySize = 0
-	case ebpf.RingBuf:
-		// keySize and valueSize need to be 0
-		// maxEntries needs to be power of 2 and PAGE_ALIGNED
-		// checked at allocation time
-		a.KeySize = 0
-		a.ValueSize = 0
-		a.MaxEntries = uint32(os.Getpagesize())
-	case ebpf.SkStorage, ebpf.InodeStorage, ebpf.TaskStorage:
-		// maxEntries needs to be 0
-		// BPF_F_NO_PREALLOC needs to be set
-		// btf* fields need to be set
-		// see alloc_check for local_storage map types
-		a.MaxEntries = 0
-		a.MapFlags = unix.BPF_F_NO_PREALLOC
-		a.BtfKeyTypeId = 1   // BTF_KIND_INT
-		a.BtfValueTypeId = 3 // BTF_KIND_ARRAY
-		a.BtfFd = ^uint32(0)
-	case ebpf.StructOpsMap:
-		// StructOps requires setting a vmlinux type id, but id 1 will always
-		// resolve to some type of integer. This will cause ENOTSUPP.
-		a.BtfVmlinuxValueTypeId = 1
-	}
-
-	return a
+		KeySize:    uint32(8 + unsafe.Sizeof(int(0))),
+		MaxEntries: 0,
+	})
 }
 
-// HaveMapType probes the running kernel for the availability of the specified map type.
-//
-// See the package documentation for the meaning of the error return value.
-func HaveMapType(mt ebpf.MapType) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	if err := validateMaptype(mt); err != nil {
-		return err
+func probeStorageMap(mt sys.MapType) error {
+	// maxEntries needs to be 0
+	// BPF_F_NO_PREALLOC needs to be set
+	// btf* fields need to be set
+	// see alloc_check for local_storage map types
+	err := createMap(&sys.MapCreateAttr{
+		MapType:        mt,
+		KeySize:        4,
+		ValueSize:      4,
+		MaxEntries:     0,
+		MapFlags:       unix.BPF_F_NO_PREALLOC,
+		BtfKeyTypeId:   1,
+		BtfValueTypeId: 1,
+		BtfFd:          ^uint32(0),
+	})
+	if errors.Is(err, unix.EBADF) {
+		// Triggered by BtfFd.
+		return nil
 	}
-
-	return haveMapType(mt)
+	return err
 }
 
-func validateMaptype(mt ebpf.MapType) error {
-	if mt > mt.Max() {
-		return os.ErrInvalid
+func probeNestedMap(mt sys.MapType) error {
+	// assign invalid innerMapFd to pass validation check
+	// will return EBADF
+	err := probeMap(&sys.MapCreateAttr{
+		MapType:    mt,
+		InnerMapFd: ^uint32(0),
+	})
+	if errors.Is(err, unix.EBADF) {
+		return nil
 	}
-	return nil
+	return err
 }
 
-func haveMapType(mt ebpf.MapType) error {
-	mc.Lock()
-	defer mc.Unlock()
-	err, ok := mc.mapTypes[mt]
-	if ok {
-		return err
+func probeMap(attr *sys.MapCreateAttr) error {
+	if attr.KeySize == 0 {
+		attr.KeySize = 4
+	}
+	if attr.ValueSize == 0 {
+		attr.ValueSize = 4
 	}
+	attr.MaxEntries = 1
+	return createMap(attr)
+}
 
-	fd, err := sys.MapCreate(createMapTypeAttr(mt))
+func createMap(attr *sys.MapCreateAttr) error {
+	fd, err := sys.MapCreate(attr)
 	if err == nil {
 		fd.Close()
+		return nil
 	}
 
 	switch {
-	// For nested and storage map types we accept EBADF as indicator that these maps are supported
-	case errors.Is(err, unix.EBADF):
-		if isMapOfMaps(mt) || isStorageMap(mt) {
-			err = nil
-		}
-
-	// ENOTSUPP means the map type is at least known to the kernel.
-	case errors.Is(err, sys.ENOTSUPP):
-		if mt == ebpf.StructOpsMap {
-			err = nil
-		}
-
 	// EINVAL occurs when attempting to create a map with an unknown type.
 	// E2BIG occurs when MapCreateAttr contains non-zero bytes past the end
 	// of the struct known by the running kernel, meaning the kernel is too old
 	// to support the given map type.
 	case errors.Is(err, unix.EINVAL), errors.Is(err, unix.E2BIG):
-		err = ebpf.ErrNotSupported
+		return ebpf.ErrNotSupported
 	}
 
-	mc.mapTypes[mt] = err
-
 	return err
 }
 
-func isMapOfMaps(mt ebpf.MapType) bool {
-	switch mt {
-	case ebpf.ArrayOfMaps, ebpf.HashOfMaps:
-		return true
-	}
-	return false
+var haveMapTypeMatrix = internal.FeatureMatrix[ebpf.MapType]{
+	ebpf.Hash:           {Version: "3.19"},
+	ebpf.Array:          {Version: "3.19"},
+	ebpf.ProgramArray:   {Version: "4.2"},
+	ebpf.PerfEventArray: {Version: "4.3"},
+	ebpf.PerCPUHash:     {Version: "4.6"},
+	ebpf.PerCPUArray:    {Version: "4.6"},
+	ebpf.StackTrace: {
+		Version: "4.6",
+		Fn: func() error {
+			return probeMap(&sys.MapCreateAttr{
+				MapType:   sys.BPF_MAP_TYPE_STACK_TRACE,
+				ValueSize: 8, // sizeof(uint64)
+			})
+		},
+	},
+	ebpf.CGroupArray: {Version: "4.8"},
+	ebpf.LRUHash:     {Version: "4.10"},
+	ebpf.LRUCPUHash:  {Version: "4.10"},
+	ebpf.LPMTrie: {
+		Version: "4.11",
+		Fn: func() error {
+			// keySize and valueSize need to be sizeof(struct{u32 + u8}) + 1 + padding = 8
+			// BPF_F_NO_PREALLOC needs to be set
+			return probeMap(&sys.MapCreateAttr{
+				MapType:   sys.BPF_MAP_TYPE_LPM_TRIE,
+				KeySize:   8,
+				ValueSize: 8,
+				MapFlags:  unix.BPF_F_NO_PREALLOC,
+			})
+		},
+	},
+	ebpf.ArrayOfMaps: {
+		Version: "4.12",
+		Fn:      func() error { return probeNestedMap(sys.BPF_MAP_TYPE_ARRAY_OF_MAPS) },
+	},
+	ebpf.HashOfMaps: {
+		Version: "4.12",
+		Fn:      func() error { return probeNestedMap(sys.BPF_MAP_TYPE_HASH_OF_MAPS) },
+	},
+	ebpf.DevMap:   {Version: "4.14"},
+	ebpf.SockMap:  {Version: "4.14"},
+	ebpf.CPUMap:   {Version: "4.15"},
+	ebpf.XSKMap:   {Version: "4.18"},
+	ebpf.SockHash: {Version: "4.18"},
+	ebpf.CGroupStorage: {
+		Version: "4.19",
+		Fn:      func() error { return probeCgroupStorageMap(sys.BPF_MAP_TYPE_CGROUP_STORAGE) },
+	},
+	ebpf.ReusePortSockArray: {Version: "4.19"},
+	ebpf.PerCPUCGroupStorage: {
+		Version: "4.20",
+		Fn:      func() error { return probeCgroupStorageMap(sys.BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE) },
+	},
+	ebpf.Queue: {
+		Version: "4.20",
+		Fn: func() error {
+			return createMap(&sys.MapCreateAttr{
+				MapType:    sys.BPF_MAP_TYPE_QUEUE,
+				KeySize:    0,
+				ValueSize:  4,
+				MaxEntries: 1,
+			})
+		},
+	},
+	ebpf.Stack: {
+		Version: "4.20",
+		Fn: func() error {
+			return createMap(&sys.MapCreateAttr{
+				MapType:    sys.BPF_MAP_TYPE_STACK,
+				KeySize:    0,
+				ValueSize:  4,
+				MaxEntries: 1,
+			})
+		},
+	},
+	ebpf.SkStorage: {
+		Version: "5.2",
+		Fn:      func() error { return probeStorageMap(sys.BPF_MAP_TYPE_SK_STORAGE) },
+	},
+	ebpf.DevMapHash: {Version: "5.4"},
+	ebpf.StructOpsMap: {
+		Version: "5.6",
+		Fn: func() error {
+			// StructOps requires setting a vmlinux type id, but id 1 will always
+			// resolve to some type of integer. This will cause ENOTSUPP.
+			err := probeMap(&sys.MapCreateAttr{
+				MapType:               sys.BPF_MAP_TYPE_STRUCT_OPS,
+				BtfVmlinuxValueTypeId: 1,
+			})
+			if errors.Is(err, sys.ENOTSUPP) {
+				// ENOTSUPP means the map type is at least known to the kernel.
+				return nil
+			}
+			return err
+		},
+	},
+	ebpf.RingBuf: {
+		Version: "5.8",
+		Fn: func() error {
+			// keySize and valueSize need to be 0
+			// maxEntries needs to be power of 2 and PAGE_ALIGNED
+			return createMap(&sys.MapCreateAttr{
+				MapType:    sys.BPF_MAP_TYPE_RINGBUF,
+				KeySize:    0,
+				ValueSize:  0,
+				MaxEntries: uint32(os.Getpagesize()),
+			})
+		},
+	},
+	ebpf.InodeStorage: {
+		Version: "5.10",
+		Fn:      func() error { return probeStorageMap(sys.BPF_MAP_TYPE_INODE_STORAGE) },
+	},
+	ebpf.TaskStorage: {
+		Version: "5.11",
+		Fn:      func() error { return probeStorageMap(sys.BPF_MAP_TYPE_TASK_STORAGE) },
+	},
 }
 
-func isStorageMap(mt ebpf.MapType) bool {
-	switch mt {
-	case ebpf.SkStorage, ebpf.InodeStorage, ebpf.TaskStorage:
-		return true
+func init() {
+	for mt, ft := range haveMapTypeMatrix {
+		ft.Name = mt.String()
+		if ft.Fn == nil {
+			// Avoid referring to the loop variable in the closure.
+			mt := sys.MapType(mt)
+			ft.Fn = func() error { return probeMap(&sys.MapCreateAttr{MapType: mt}) }
+		}
 	}
-	return false
 }
 
 // MapFlags document which flags may be feature probed.
@@ -169,11 +231,11 @@ type MapFlags = sys.MapFlags
 
 // Flags which may be feature probed.
 const (
-	BPF_F_NO_PREALLOC MapFlags = unix.BPF_F_NO_PREALLOC
-	BPF_F_RDONLY_PROG MapFlags = unix.BPF_F_RDONLY_PROG
-	BPF_F_WRONLY_PROG MapFlags = unix.BPF_F_WRONLY_PROG
-	BPF_F_MMAPABLE    MapFlags = unix.BPF_F_MMAPABLE
-	BPF_F_INNER_MAP   MapFlags = unix.BPF_F_INNER_MAP
+	BPF_F_NO_PREALLOC = sys.BPF_F_NO_PREALLOC
+	BPF_F_RDONLY_PROG = sys.BPF_F_RDONLY_PROG
+	BPF_F_WRONLY_PROG = sys.BPF_F_WRONLY_PROG
+	BPF_F_MMAPABLE    = sys.BPF_F_MMAPABLE
+	BPF_F_INNER_MAP   = sys.BPF_F_INNER_MAP
 )
 
 // HaveMapFlag probes the running kernel for the availability of the specified map flag.
@@ -181,63 +243,79 @@ const (
 // Returns an error if flag is not one of the flags declared in this package.
 // See the package documentation for the meaning of the error return value.
 func HaveMapFlag(flag MapFlags) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	return haveMapFlag(flag)
+	return haveMapFlagsMatrix.Result(flag)
 }
 
-func haveMapFlag(flag MapFlags) error {
-	mc.Lock()
-	defer mc.Unlock()
-	err, ok := mc.mapFlags[flag]
-	if ok {
-		return err
+func probeMapFlag(attr *sys.MapCreateAttr) error {
+	// For now, we do not check if the map type is supported because we only support
+	// probing for flags defined on arrays and hashs that are always supported.
+	// In the future, if we allow probing on flags defined on newer types, checking for map type
+	// support will be required.
+	if attr.MapType == sys.BPF_MAP_TYPE_UNSPEC {
+		attr.MapType = sys.BPF_MAP_TYPE_ARRAY
 	}
 
-	attr, err := createMapFlagTypeAttr(flag)
-	if err != nil {
-		return err
-	}
+	attr.KeySize = 4
+	attr.ValueSize = 4
+	attr.MaxEntries = 1
 
 	fd, err := sys.MapCreate(attr)
 	if err == nil {
 		fd.Close()
-	}
-
-	// EINVAL occurs when attempting to create a map with an unknown type or an unknown flag.
-	if errors.Is(err, unix.EINVAL) {
+	} else if errors.Is(err, unix.EINVAL) {
+		// EINVAL occurs when attempting to create a map with an unknown type or an unknown flag.
 		err = ebpf.ErrNotSupported
 	}
 
-	mc.mapFlags[flag] = err
-
 	return err
 }
 
-func createMapFlagTypeAttr(flag MapFlags) (*sys.MapCreateAttr, error) {
-	a := &sys.MapCreateAttr{
-		KeySize:    4,
-		ValueSize:  4,
-		MaxEntries: 1,
-		MapFlags:   flag,
-	}
-
-	// For now, we do not check if the map type is supported because we only support
-	// probing for flags defined on arrays and hashs that are always supported.
-	// In the future, if we allow probing on flags defined on newer types, checking for map type
-	// support will be required.
+var haveMapFlagsMatrix = internal.FeatureMatrix[MapFlags]{
+	BPF_F_NO_PREALLOC: {
+		Version: "4.6",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapType:  sys.BPF_MAP_TYPE_HASH,
+				MapFlags: BPF_F_NO_PREALLOC,
+			})
+		},
+	},
+	BPF_F_RDONLY_PROG: {
+		Version: "5.2",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapFlags: BPF_F_RDONLY_PROG,
+			})
+		},
+	},
+	BPF_F_WRONLY_PROG: {
+		Version: "5.2",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapFlags: BPF_F_WRONLY_PROG,
+			})
+		},
+	},
+	BPF_F_MMAPABLE: {
+		Version: "5.5",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapFlags: BPF_F_MMAPABLE,
+			})
+		},
+	},
+	BPF_F_INNER_MAP: {
+		Version: "5.10",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapFlags: BPF_F_INNER_MAP,
+			})
+		},
+	},
+}
 
-	switch flag {
-	case unix.BPF_F_MMAPABLE, unix.BPF_F_INNER_MAP, unix.BPF_F_RDONLY_PROG, unix.BPF_F_WRONLY_PROG:
-		a.MapType = sys.MapType(ebpf.Array)
-		return a, nil
-	case unix.BPF_F_NO_PREALLOC:
-		a.MapType = sys.MapType(ebpf.Hash)
-		return a, nil
+func init() {
+	for mf, ft := range haveMapFlagsMatrix {
+		ft.Name = fmt.Sprint(mf)
 	}
-
-	return nil, errors.New("probe not implemented")
 }
diff --git a/features/misc.go b/features/misc.go
index afe7bf7..de07d38 100644
--- a/features/misc.go
+++ b/features/misc.go
@@ -1,165 +1,79 @@
 package features
 
 import (
-	"bytes"
-	"errors"
-	"fmt"
-	"sync"
-
 	"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"
-)
-
-func init() {
-	miscs.miscTypes = make(map[miscType]error)
-}
-
-var (
-	miscs miscCache
-)
-
-type miscCache struct {
-	sync.Mutex
-	miscTypes map[miscType]error
-}
-
-type miscType uint32
-
-const (
-	// largeInsn support introduced in Linux 5.2
-	// commit c04c0d2b968ac45d6ef020316808ef6c82325a82
-	largeInsn miscType = iota
-	// boundedLoops support introduced in Linux 5.3
-	// commit 2589726d12a1b12eaaa93c7f1ea64287e383c7a5
-	boundedLoops
-	// v2ISA support introduced in Linux 4.14
-	// commit 92b31a9af73b3a3fc801899335d6c47966351830
-	v2ISA
-	// v3ISA support introduced in Linux 5.1
-	// commit 092ed0968bb648cd18e8a0430cd0a8a71727315c
-	v3ISA
-)
-
-const (
-	maxInsns = 4096
 )
 
 // HaveLargeInstructions probes the running kernel if more than 4096 instructions
 // per program are supported.
 //
-// See the package documentation for the meaning of the error return value.
-func HaveLargeInstructions() error {
-	return probeMisc(largeInsn)
-}
-
-// HaveBoundedLoops probes the running kernel if bounded loops are supported.
-//
-// See the package documentation for the meaning of the error return value.
-func HaveBoundedLoops() error {
-	return probeMisc(boundedLoops)
-}
-
-// HaveV2ISA probes the running kernel if instructions of the v2 ISA are supported.
-//
-// See the package documentation for the meaning of the error return value.
-func HaveV2ISA() error {
-	return probeMisc(v2ISA)
-}
-
-// HaveV3ISA probes the running kernel if instructions of the v3 ISA are supported.
+// Upstream commit c04c0d2b968a ("bpf: increase complexity limit and maximum program size").
 //
 // See the package documentation for the meaning of the error return value.
-func HaveV3ISA() error {
-	return probeMisc(v3ISA)
-}
-
-// probeMisc checks the kernel for a given supported misc by creating
-// a specialized program probe and loading it.
-func probeMisc(mt miscType) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	miscs.Lock()
-	defer miscs.Unlock()
-	err, ok := miscs.miscTypes[mt]
-	if ok {
-		return err
-	}
+var HaveLargeInstructions = internal.NewFeatureTest(">4096 instructions", "5.2", func() error {
+	const maxInsns = 4096
 
-	attr, err := createMiscProbeAttr(mt)
-	if err != nil {
-		return fmt.Errorf("couldn't create the attributes for the probe: %w", err)
+	insns := make(asm.Instructions, maxInsns, maxInsns+1)
+	for i := range insns {
+		insns[i] = asm.Mov.Imm(asm.R0, 1)
 	}
+	insns = append(insns, asm.Return())
 
-	fd, err := sys.ProgLoad(attr)
-	if err == nil {
-		fd.Close()
-	}
-
-	switch {
-	// EINVAL occurs when attempting to create a program with an unknown type.
-	// E2BIG occurs when ProgLoadAttr contains non-zero bytes past the end
-	// of the struct known by the running kernel, meaning the kernel is too old
-	// to support the given map type.
-	case errors.Is(err, unix.EINVAL), errors.Is(err, unix.E2BIG):
-		err = ebpf.ErrNotSupported
-	}
+	return probeProgram(&ebpf.ProgramSpec{
+		Type:         ebpf.SocketFilter,
+		Instructions: insns,
+	})
+})
 
-	miscs.miscTypes[mt] = err
-
-	return err
-}
-
-func createMiscProbeAttr(mt miscType) (*sys.ProgLoadAttr, error) {
-	var insns asm.Instructions
-	switch mt {
-	case largeInsn:
-		for i := 0; i < maxInsns; i++ {
-			insns = append(insns, asm.Mov.Imm(asm.R0, 1))
-		}
-		insns = append(insns, asm.Return())
-	case boundedLoops:
-		insns = asm.Instructions{
+// HaveBoundedLoops probes the running kernel if bounded loops are supported.
+//
+// Upstream commit 2589726d12a1 ("bpf: introduce bounded loops").
+//
+// See the package documentation for the meaning of the error return value.
+var HaveBoundedLoops = internal.NewFeatureTest("bounded loops", "5.3", func() error {
+	return probeProgram(&ebpf.ProgramSpec{
+		Type: ebpf.SocketFilter,
+		Instructions: asm.Instructions{
 			asm.Mov.Imm(asm.R0, 10),
 			asm.Sub.Imm(asm.R0, 1).WithSymbol("loop"),
 			asm.JNE.Imm(asm.R0, 0, "loop"),
 			asm.Return(),
-		}
-	case v2ISA:
-		insns = asm.Instructions{
+		},
+	})
+})
+
+// HaveV2ISA probes the running kernel if instructions of the v2 ISA are supported.
+//
+// Upstream commit 92b31a9af73b ("bpf: add BPF_J{LT,LE,SLT,SLE} instructions").
+//
+// See the package documentation for the meaning of the error return value.
+var HaveV2ISA = internal.NewFeatureTest("v2 ISA", "4.14", func() error {
+	return probeProgram(&ebpf.ProgramSpec{
+		Type: ebpf.SocketFilter,
+		Instructions: asm.Instructions{
 			asm.Mov.Imm(asm.R0, 0),
 			asm.JLT.Imm(asm.R0, 0, "exit"),
 			asm.Mov.Imm(asm.R0, 1),
 			asm.Return().WithSymbol("exit"),
-		}
-	case v3ISA:
-		insns = asm.Instructions{
+		},
+	})
+})
+
+// HaveV3ISA probes the running kernel if instructions of the v3 ISA are supported.
+//
+// Upstream commit 092ed0968bb6 ("bpf: verifier support JMP32").
+//
+// See the package documentation for the meaning of the error return value.
+var HaveV3ISA = internal.NewFeatureTest("v3 ISA", "5.1", func() error {
+	return probeProgram(&ebpf.ProgramSpec{
+		Type: ebpf.SocketFilter,
+		Instructions: asm.Instructions{
 			asm.Mov.Imm(asm.R0, 0),
 			asm.JLT.Imm32(asm.R0, 0, "exit"),
 			asm.Mov.Imm(asm.R0, 1),
 			asm.Return().WithSymbol("exit"),
-		}
-	default:
-		return nil, fmt.Errorf("misc probe %d not implemented", mt)
-	}
-
-	buf := bytes.NewBuffer(make([]byte, 0, insns.Size()))
-	if err := insns.Marshal(buf, internal.NativeEndian); err != nil {
-		return nil, err
-	}
-
-	bytecode := buf.Bytes()
-	instructions := sys.NewSlicePointer(bytecode)
-
-	return &sys.ProgLoadAttr{
-		ProgType: sys.BPF_PROG_TYPE_SOCKET_FILTER,
-		Insns:    instructions,
-		InsnCnt:  uint32(len(bytecode) / asm.InstructionSize),
-		License:  sys.NewStringPointer("MIT"),
-	}, nil
-}
+		},
+	})
+})
diff --git a/features/prog.go b/features/prog.go
index a3a3a83..5c2a2a7 100644
--- a/features/prog.go
+++ b/features/prog.go
@@ -1,11 +1,9 @@
 package features
 
 import (
-	"bytes"
 	"errors"
 	"fmt"
 	"os"
-	"sync"
 
 	"github.com/cilium/ebpf"
 	"github.com/cilium/ebpf/asm"
@@ -14,85 +12,6 @@ import (
 	"github.com/cilium/ebpf/internal/unix"
 )
 
-func init() {
-	pc.types = make(map[ebpf.ProgramType]error)
-	pc.helpers = make(map[ebpf.ProgramType]map[asm.BuiltinFunc]error)
-	allocHelperCache()
-}
-
-func allocHelperCache() {
-	for pt := ebpf.UnspecifiedProgram + 1; pt <= pt.Max(); pt++ {
-		pc.helpers[pt] = make(map[asm.BuiltinFunc]error)
-	}
-}
-
-var (
-	pc progCache
-)
-
-type progCache struct {
-	typeMu sync.Mutex
-	types  map[ebpf.ProgramType]error
-
-	helperMu sync.Mutex
-	helpers  map[ebpf.ProgramType]map[asm.BuiltinFunc]error
-}
-
-func createProgLoadAttr(pt ebpf.ProgramType, helper asm.BuiltinFunc) (*sys.ProgLoadAttr, error) {
-	var expectedAttachType ebpf.AttachType
-	var progFlags uint32
-
-	insns := asm.Instructions{
-		asm.LoadImm(asm.R0, 0, asm.DWord),
-		asm.Return(),
-	}
-
-	if helper != asm.FnUnspec {
-		insns = append(asm.Instructions{helper.Call()}, insns...)
-	}
-
-	buf := bytes.NewBuffer(make([]byte, 0, insns.Size()))
-	if err := insns.Marshal(buf, internal.NativeEndian); err != nil {
-		return nil, err
-	}
-
-	bytecode := buf.Bytes()
-	instructions := sys.NewSlicePointer(bytecode)
-
-	// Some programs have expected attach types which are checked during the
-	// BPF_PROG_LOAD syscall.
-	switch pt {
-	case ebpf.CGroupSockAddr:
-		expectedAttachType = ebpf.AttachCGroupInet4Connect
-	case ebpf.CGroupSockopt:
-		expectedAttachType = ebpf.AttachCGroupGetsockopt
-	case ebpf.SkLookup:
-		expectedAttachType = ebpf.AttachSkLookup
-	case ebpf.Syscall:
-		progFlags = unix.BPF_F_SLEEPABLE
-	default:
-		expectedAttachType = ebpf.AttachNone
-	}
-
-	// Kernels before 5.0 (6c4fc209fcf9 "bpf: remove useless version check for prog load")
-	// require the version field to be set to the value of the KERNEL_VERSION
-	// macro for kprobe-type programs.
-	v, err := internal.KernelVersion()
-	if err != nil {
-		return nil, fmt.Errorf("detecting kernel version: %w", err)
-	}
-
-	return &sys.ProgLoadAttr{
-		ProgType:           sys.ProgType(pt),
-		Insns:              instructions,
-		InsnCnt:            uint32(len(bytecode) / asm.InstructionSize),
-		ProgFlags:          progFlags,
-		ExpectedAttachType: sys.AttachType(expectedAttachType),
-		License:            sys.NewStringPointer("GPL"),
-		KernVersion:        v.Kernel(),
-	}, nil
-}
-
 // HaveProgType probes the running kernel for the availability of the specified program type.
 //
 // Deprecated: use HaveProgramType() instead.
@@ -102,49 +21,21 @@ var HaveProgType = HaveProgramType
 //
 // See the package documentation for the meaning of the error return value.
 func HaveProgramType(pt ebpf.ProgramType) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	if err := validateProgramType(pt); err != nil {
-		return err
-	}
-
-	return haveProgramType(pt)
-
-}
-
-func validateProgramType(pt ebpf.ProgramType) error {
-	if pt > pt.Max() {
-		return os.ErrInvalid
-	}
-
-	if progLoadProbeNotImplemented(pt) {
-		// A probe for a these prog types has BTF requirements we currently cannot meet
-		// Once we figure out how to add a working probe in this package, we can remove
-		// this check
-		return fmt.Errorf("a probe for ProgType %s isn't implemented", pt.String())
-	}
-
-	return nil
+	return haveProgramTypeMatrix.Result(pt)
 }
 
-func haveProgramType(pt ebpf.ProgramType) error {
-	pc.typeMu.Lock()
-	defer pc.typeMu.Unlock()
-	if err, ok := pc.types[pt]; ok {
-		return err
-	}
-
-	attr, err := createProgLoadAttr(pt, asm.FnUnspec)
-	if err != nil {
-		return fmt.Errorf("couldn't create the program load attribute: %w", err)
+func probeProgram(spec *ebpf.ProgramSpec) error {
+	if spec.Instructions == nil {
+		spec.Instructions = asm.Instructions{
+			asm.LoadImm(asm.R0, 0, asm.DWord),
+			asm.Return(),
+		}
 	}
-
-	fd, err := sys.ProgLoad(attr)
-	if fd != nil {
-		fd.Close()
+	prog, err := ebpf.NewProgramWithOptions(spec, ebpf.ProgramOptions{
+		LogDisabled: true,
+	})
+	if err == nil {
+		prog.Close()
 	}
 
 	switch {
@@ -154,19 +45,124 @@ func haveProgramType(pt ebpf.ProgramType) error {
 	// to support the given prog type.
 	case errors.Is(err, unix.EINVAL), errors.Is(err, unix.E2BIG):
 		err = ebpf.ErrNotSupported
+	}
+
+	return err
+}
+
+var haveProgramTypeMatrix = internal.FeatureMatrix[ebpf.ProgramType]{
+	ebpf.SocketFilter:  {Version: "3.19"},
+	ebpf.Kprobe:        {Version: "4.1"},
+	ebpf.SchedCLS:      {Version: "4.1"},
+	ebpf.SchedACT:      {Version: "4.1"},
+	ebpf.TracePoint:    {Version: "4.7"},
+	ebpf.XDP:           {Version: "4.8"},
+	ebpf.PerfEvent:     {Version: "4.9"},
+	ebpf.CGroupSKB:     {Version: "4.10"},
+	ebpf.CGroupSock:    {Version: "4.10"},
+	ebpf.LWTIn:         {Version: "4.10"},
+	ebpf.LWTOut:        {Version: "4.10"},
+	ebpf.LWTXmit:       {Version: "4.10"},
+	ebpf.SockOps:       {Version: "4.13"},
+	ebpf.SkSKB:         {Version: "4.14"},
+	ebpf.CGroupDevice:  {Version: "4.15"},
+	ebpf.SkMsg:         {Version: "4.17"},
+	ebpf.RawTracepoint: {Version: "4.17"},
+	ebpf.CGroupSockAddr: {
+		Version: "4.17",
+		Fn: func() error {
+			return probeProgram(&ebpf.ProgramSpec{
+				Type:       ebpf.CGroupSockAddr,
+				AttachType: ebpf.AttachCGroupInet4Connect,
+			})
+		},
+	},
+	ebpf.LWTSeg6Local:          {Version: "4.18"},
+	ebpf.LircMode2:             {Version: "4.18"},
+	ebpf.SkReuseport:           {Version: "4.19"},
+	ebpf.FlowDissector:         {Version: "4.20"},
+	ebpf.CGroupSysctl:          {Version: "5.2"},
+	ebpf.RawTracepointWritable: {Version: "5.2"},
+	ebpf.CGroupSockopt: {
+		Version: "5.3",
+		Fn: func() error {
+			return probeProgram(&ebpf.ProgramSpec{
+				Type:       ebpf.CGroupSockopt,
+				AttachType: ebpf.AttachCGroupGetsockopt,
+			})
+		},
+	},
+	// ebpf.Tracing:               {Version: "5.5"},
+	ebpf.StructOps: {
+		Version: "5.6",
+		Fn: func() error {
+			err := probeProgram(&ebpf.ProgramSpec{
+				Type:    ebpf.StructOps,
+				License: "GPL",
+			})
+			if errors.Is(err, sys.ENOTSUPP) {
+				// ENOTSUPP means the program type is at least known to the kernel.
+				return nil
+			}
+			return err
+		},
+	},
+	// ebpf.Extension:             {Version: "5.6"},
+	ebpf.LSM: {
+		Version: "5.7",
+		Fn: func() error {
+			return probeProgram((&ebpf.ProgramSpec{
+				Type:       ebpf.LSM,
+				AttachType: ebpf.AttachLSMMac,
+				AttachTo:   "file_mprotect",
+				License:    "GPL",
+			}))
+		},
+	},
+	ebpf.SkLookup: {
+		Version: "5.9",
+		Fn: func() error {
+			return probeProgram(&ebpf.ProgramSpec{
+				Type:       ebpf.SkLookup,
+				AttachType: ebpf.AttachSkLookup,
+			})
+		},
+	},
+	ebpf.Syscall: {
+		Version: "5.14",
+		Fn: func() error {
+			return probeProgram(&ebpf.ProgramSpec{
+				Type:  ebpf.Syscall,
+				Flags: unix.BPF_F_SLEEPABLE,
+			})
+		},
+	},
+}
 
-	// ENOTSUPP means the program type is at least known to the kernel.
-	case errors.Is(err, sys.ENOTSUPP):
-		if pt == ebpf.StructOps {
-			err = nil
+func init() {
+	for key, ft := range haveProgramTypeMatrix {
+		ft.Name = key.String()
+		if ft.Fn == nil {
+			key := key // avoid the dreaded loop variable problem
+			ft.Fn = func() error { return probeProgram(&ebpf.ProgramSpec{Type: key}) }
 		}
 	}
+}
 
-	pc.types[pt] = err
-
-	return err
+type helperKey struct {
+	typ    ebpf.ProgramType
+	helper asm.BuiltinFunc
 }
 
+var helperCache = internal.NewFeatureCache(func(key helperKey) *internal.FeatureTest {
+	return &internal.FeatureTest{
+		Name: fmt.Sprintf("%s for program type %s", key.helper, key.typ),
+		Fn: func() error {
+			return haveProgramHelper(key.typ, key.helper)
+		},
+	}
+})
+
 // HaveProgramHelper probes the running kernel for the availability of the specified helper
 // function to a specified program type.
 // Return values have the following semantics:
@@ -180,46 +176,45 @@ func haveProgramType(pt ebpf.ProgramType) error {
 // Only `nil` and `ebpf.ErrNotSupported` are conclusive.
 //
 // Probe results are cached and persist throughout any process capability changes.
-func HaveProgramHelper(pt ebpf.ProgramType, helper asm.BuiltinFunc) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	if err := validateProgramType(pt); err != nil {
-		return err
-	}
-
-	if err := validateProgramHelper(helper); err != nil {
-		return err
-	}
-
-	return haveProgramHelper(pt, helper)
-}
-
-func validateProgramHelper(helper asm.BuiltinFunc) error {
+func HaveProgramHelper(pt ebpf.ProgramType, helper asm.BuiltinFunc) error {
 	if helper > helper.Max() {
 		return os.ErrInvalid
 	}
 
-	return nil
+	return helperCache.Result(helperKey{pt, helper})
 }
 
 func haveProgramHelper(pt ebpf.ProgramType, helper asm.BuiltinFunc) error {
-	pc.helperMu.Lock()
-	defer pc.helperMu.Unlock()
-	if err, ok := pc.helpers[pt][helper]; ok {
+	if err := HaveProgramType(pt); err != nil {
 		return err
 	}
 
-	attr, err := createProgLoadAttr(pt, helper)
-	if err != nil {
-		return fmt.Errorf("couldn't create the program load attribute: %w", err)
+	spec := &ebpf.ProgramSpec{
+		Type: pt,
+		Instructions: asm.Instructions{
+			helper.Call(),
+			asm.LoadImm(asm.R0, 0, asm.DWord),
+			asm.Return(),
+		},
+		License: "GPL",
+	}
+
+	switch pt {
+	case ebpf.CGroupSockAddr:
+		spec.AttachType = ebpf.AttachCGroupInet4Connect
+	case ebpf.CGroupSockopt:
+		spec.AttachType = ebpf.AttachCGroupGetsockopt
+	case ebpf.SkLookup:
+		spec.AttachType = ebpf.AttachSkLookup
+	case ebpf.Syscall:
+		spec.Flags = unix.BPF_F_SLEEPABLE
 	}
 
-	fd, err := sys.ProgLoad(attr)
-	if fd != nil {
-		fd.Close()
+	prog, err := ebpf.NewProgramWithOptions(spec, ebpf.ProgramOptions{
+		LogDisabled: true,
+	})
+	if err == nil {
+		prog.Close()
 	}
 
 	switch {
@@ -232,23 +227,10 @@ func haveProgramHelper(pt ebpf.ProgramType, helper asm.BuiltinFunc) error {
 		err = nil
 
 	// EINVAL occurs when attempting to create a program with an unknown helper.
-	// E2BIG occurs when BPFProgLoadAttr contains non-zero bytes past the end
-	// of the struct known by the running kernel, meaning the kernel is too old
-	// to support the given prog type.
-	case errors.Is(err, unix.EINVAL), errors.Is(err, unix.E2BIG):
+	case errors.Is(err, unix.EINVAL):
 		// TODO: possibly we need to check verifier output here to be sure
 		err = ebpf.ErrNotSupported
 	}
 
-	pc.helpers[pt][helper] = err
-
 	return err
 }
-
-func progLoadProbeNotImplemented(pt ebpf.ProgramType) bool {
-	switch pt {
-	case ebpf.Tracing, ebpf.Extension, ebpf.LSM:
-		return true
-	}
-	return false
-}
diff --git a/go.mod b/go.mod
index 1b46625..c32e239 100644
--- a/go.mod
+++ b/go.mod
@@ -3,14 +3,13 @@ module github.com/cilium/ebpf
 go 1.18
 
 require (
-	github.com/frankban/quicktest v1.14.0
-	github.com/google/go-cmp v0.5.6
-	golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec
+	github.com/frankban/quicktest v1.14.4
+	github.com/google/go-cmp v0.5.9
+	golang.org/x/sys v0.2.0
 )
 
 require (
-	github.com/kr/pretty v0.3.0 // indirect
+	github.com/kr/pretty v0.3.1 // indirect
 	github.com/kr/text v0.2.0 // indirect
-	github.com/rogpeppe/go-internal v1.6.1 // indirect
-	golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
+	github.com/rogpeppe/go-internal v1.9.0 // indirect
 )
diff --git a/go.sum b/go.sum
index d716dfe..2a4047a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,20 +1,14 @@
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss=
-github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
-github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
-github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
+github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
-github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
-golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
-golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/internal/feature.go b/internal/feature.go
index bf25d78..b1f6507 100644
--- a/internal/feature.go
+++ b/internal/feature.go
@@ -31,10 +31,20 @@ func (ufe *UnsupportedFeatureError) Is(target error) bool {
 	return target == ErrNotSupported
 }
 
-type featureTest struct {
-	sync.RWMutex
-	successful bool
-	result     error
+// FeatureTest caches the result of a [FeatureTestFn].
+//
+// Fields should not be modified after creation.
+type FeatureTest struct {
+	// The name of the feature being detected.
+	Name string
+	// Version in in the form Major.Minor[.Patch].
+	Version string
+	// The feature test itself.
+	Fn FeatureTestFn
+
+	mu     sync.RWMutex
+	done   bool
+	result error
 }
 
 // FeatureTestFn is used to determine whether the kernel supports
@@ -47,54 +57,128 @@ type featureTest struct {
 //	err != nil: the test couldn't be executed
 type FeatureTestFn func() error
 
-// FeatureTest wraps a function so that it is run at most once.
+// NewFeatureTest is a convenient way to create a single [FeatureTest].
+func NewFeatureTest(name, version string, fn FeatureTestFn) func() error {
+	ft := &FeatureTest{
+		Name:    name,
+		Version: version,
+		Fn:      fn,
+	}
+
+	return ft.execute
+}
+
+// execute the feature test.
 //
-// name should identify the tested feature, while version must be in the
-// form Major.Minor[.Patch].
+// The result is cached if the test is conclusive.
 //
-// Returns an error wrapping ErrNotSupported if the feature is not supported.
-func FeatureTest(name, version string, fn FeatureTestFn) func() error {
-	ft := new(featureTest)
-	return func() error {
-		ft.RLock()
-		if ft.successful {
-			defer ft.RUnlock()
-			return ft.result
-		}
-		ft.RUnlock()
-		ft.Lock()
-		defer ft.Unlock()
-		// check one more time on the off
-		// chance that two go routines
-		// were able to call into the write
-		// lock
-		if ft.successful {
-			return ft.result
-		}
-		err := fn()
-		switch {
-		case errors.Is(err, ErrNotSupported):
-			v, err := NewVersion(version)
-			if err != nil {
-				return err
-			}
+// See [FeatureTestFn] for the meaning of the returned error.
+func (ft *FeatureTest) execute() error {
+	ft.mu.RLock()
+	result, done := ft.result, ft.done
+	ft.mu.RUnlock()
 
-			ft.result = &UnsupportedFeatureError{
-				MinimumVersion: v,
-				Name:           name,
-			}
-			fallthrough
+	if done {
+		return result
+	}
+
+	ft.mu.Lock()
+	defer ft.mu.Unlock()
+
+	// The test may have been executed by another caller while we were
+	// waiting to acquire ft.mu.
+	if ft.done {
+		return ft.result
+	}
+
+	err := ft.Fn()
+	if err == nil {
+		ft.done = true
+		return nil
+	}
 
-		case err == nil:
-			ft.successful = true
+	if errors.Is(err, ErrNotSupported) {
+		var v Version
+		if ft.Version != "" {
+			v, err = NewVersion(ft.Version)
+			if err != nil {
+				return fmt.Errorf("feature %s: %w", ft.Name, err)
+			}
+		}
 
-		default:
-			// We couldn't execute the feature test to a point
-			// where it could make a determination.
-			// Don't cache the result, just return it.
-			return fmt.Errorf("detect support for %s: %w", name, err)
+		ft.done = true
+		ft.result = &UnsupportedFeatureError{
+			MinimumVersion: v,
+			Name:           ft.Name,
 		}
 
 		return ft.result
 	}
+
+	// We couldn't execute the feature test to a point
+	// where it could make a determination.
+	// Don't cache the result, just return it.
+	return fmt.Errorf("detect support for %s: %w", ft.Name, err)
+}
+
+// FeatureMatrix groups multiple related feature tests into a map.
+//
+// Useful when there is a small number of discrete features which are known
+// at compile time.
+//
+// It must not be modified concurrently with calling [FeatureMatrix.Result].
+type FeatureMatrix[K comparable] map[K]*FeatureTest
+
+// Result returns the outcome of the feature test for the given key.
+//
+// It's safe to call this function concurrently.
+func (fm FeatureMatrix[K]) Result(key K) error {
+	ft, ok := fm[key]
+	if !ok {
+		return fmt.Errorf("no feature probe for %v", key)
+	}
+
+	return ft.execute()
+}
+
+// FeatureCache caches a potentially unlimited number of feature probes.
+//
+// Useful when there is a high cardinality for a feature test.
+type FeatureCache[K comparable] struct {
+	mu       sync.RWMutex
+	newTest  func(K) *FeatureTest
+	features map[K]*FeatureTest
+}
+
+func NewFeatureCache[K comparable](newTest func(K) *FeatureTest) *FeatureCache[K] {
+	return &FeatureCache[K]{
+		newTest:  newTest,
+		features: make(map[K]*FeatureTest),
+	}
+}
+
+func (fc *FeatureCache[K]) Result(key K) error {
+	// NB: Executing the feature test happens without fc.mu taken.
+	return fc.retrieve(key).execute()
+}
+
+func (fc *FeatureCache[K]) retrieve(key K) *FeatureTest {
+	fc.mu.RLock()
+	ft := fc.features[key]
+	fc.mu.RUnlock()
+
+	if ft != nil {
+		return ft
+	}
+
+	fc.mu.Lock()
+	defer fc.mu.Unlock()
+
+	if ft := fc.features[key]; ft != nil {
+		return ft
+	}
+
+	ft = fc.newTest(key)
+	fc.features[key] = ft
+	return ft
 }
diff --git a/internal/sys/mapflags_string.go b/internal/sys/mapflags_string.go
new file mode 100644
index 0000000..c80744a
--- /dev/null
+++ b/internal/sys/mapflags_string.go
@@ -0,0 +1,49 @@
+// Code generated by "stringer -type MapFlags"; DO NOT EDIT.
+
+package sys
+
+import "strconv"
+
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[BPF_F_NO_PREALLOC-1]
+	_ = x[BPF_F_NO_COMMON_LRU-2]
+	_ = x[BPF_F_NUMA_NODE-4]
+	_ = x[BPF_F_RDONLY-8]
+	_ = x[BPF_F_WRONLY-16]
+	_ = x[BPF_F_STACK_BUILD_ID-32]
+	_ = x[BPF_F_ZERO_SEED-64]
+	_ = x[BPF_F_RDONLY_PROG-128]
+	_ = x[BPF_F_WRONLY_PROG-256]
+	_ = x[BPF_F_CLONE-512]
+	_ = x[BPF_F_MMAPABLE-1024]
+	_ = x[BPF_F_PRESERVE_ELEMS-2048]
+	_ = x[BPF_F_INNER_MAP-4096]
+}
+
+const _MapFlags_name = "BPF_F_NO_PREALLOCBPF_F_NO_COMMON_LRUBPF_F_NUMA_NODEBPF_F_RDONLYBPF_F_WRONLYBPF_F_STACK_BUILD_IDBPF_F_ZERO_SEEDBPF_F_RDONLY_PROGBPF_F_WRONLY_PROGBPF_F_CLONEBPF_F_MMAPABLEBPF_F_PRESERVE_ELEMSBPF_F_INNER_MAP"
+
+var _MapFlags_map = map[MapFlags]string{
+	1:    _MapFlags_name[0:17],
+	2:    _MapFlags_name[17:36],
+	4:    _MapFlags_name[36:51],
+	8:    _MapFlags_name[51:63],
+	16:   _MapFlags_name[63:75],
+	32:   _MapFlags_name[75:95],
+	64:   _MapFlags_name[95:110],
+	128:  _MapFlags_name[110:127],
+	256:  _MapFlags_name[127:144],
+	512:  _MapFlags_name[144:155],
+	1024: _MapFlags_name[155:169],
+	2048: _MapFlags_name[169:189],
+	4096: _MapFlags_name[189:204],
+}
+
+func (i MapFlags) String() string {
+	if str, ok := _MapFlags_map[i]; ok {
+		return str
+	}
+	return "MapFlags(" + strconv.FormatInt(int64(i), 10) + ")"
+}
diff --git a/internal/sys/syscall.go b/internal/sys/syscall.go
index 3205ede..3ffcd7a 100644
--- a/internal/sys/syscall.go
+++ b/internal/sys/syscall.go
@@ -120,6 +120,24 @@ type BTFID uint32
 // MapFlags control map behaviour.
 type MapFlags uint32
 
+//go:generate stringer -type MapFlags
+
+const (
+	BPF_F_NO_PREALLOC MapFlags = 1 << iota
+	BPF_F_NO_COMMON_LRU
+	BPF_F_NUMA_NODE
+	BPF_F_RDONLY
+	BPF_F_WRONLY
+	BPF_F_STACK_BUILD_ID
+	BPF_F_ZERO_SEED
+	BPF_F_RDONLY_PROG
+	BPF_F_WRONLY_PROG
+	BPF_F_CLONE
+	BPF_F_MMAPABLE
+	BPF_F_PRESERVE_ELEMS
+	BPF_F_INNER_MAP
+)
+
 // wrappedErrno wraps syscall.Errno to prevent direct comparisons with
 // syscall.E* or unix.E* constants.
 //
diff --git a/link/kprobe_multi.go b/link/kprobe_multi.go
index f9d1f0e..151f47d 100644
--- a/link/kprobe_multi.go
+++ b/link/kprobe_multi.go
@@ -138,7 +138,7 @@ func (kml *kprobeMultiLink) Unpin() error {
 	return fmt.Errorf("unpin kprobe_multi: %w", ErrNotSupported)
 }
 
-var haveBPFLinkKprobeMulti = internal.FeatureTest("bpf_link_kprobe_multi", "5.18", func() error {
+var haveBPFLinkKprobeMulti = internal.NewFeatureTest("bpf_link_kprobe_multi", "5.18", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Name: "probe_kpm_link",
 		Type: ebpf.Kprobe,
diff --git a/link/perf_event.go b/link/perf_event.go
index 602f45b..ab2f049 100644
--- a/link/perf_event.go
+++ b/link/perf_event.go
@@ -380,7 +380,7 @@ func readUint64FromFileOnce(format string, path ...string) (uint64, error) {
 //
 // https://elixir.bootlin.com/linux/v5.16.8/source/kernel/bpf/syscall.c#L4307
 // https://github.com/torvalds/linux/commit/b89fbfbb854c9afc3047e8273cc3a694650b802e
-var haveBPFLinkPerfEvent = internal.FeatureTest("bpf_link_perf_event", "5.15", func() error {
+var haveBPFLinkPerfEvent = internal.NewFeatureTest("bpf_link_perf_event", "5.15", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Name: "probe_bpf_perf_link",
 		Type: ebpf.Kprobe,
diff --git a/link/syscalls.go b/link/syscalls.go
index 035d875..a9fed90 100644
--- a/link/syscalls.go
+++ b/link/syscalls.go
@@ -26,7 +26,7 @@ const (
 	KprobeMultiType   = sys.BPF_LINK_TYPE_KPROBE_MULTI
 )
 
-var haveProgAttach = internal.FeatureTest("BPF_PROG_ATTACH", "4.10", func() error {
+var haveProgAttach = internal.NewFeatureTest("BPF_PROG_ATTACH", "4.10", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Type:    ebpf.CGroupSKB,
 		License: "MIT",
@@ -46,7 +46,7 @@ var haveProgAttach = internal.FeatureTest("BPF_PROG_ATTACH", "4.10", func() erro
 	return nil
 })
 
-var haveProgAttachReplace = internal.FeatureTest("BPF_PROG_ATTACH atomic replacement", "5.5", func() error {
+var haveProgAttachReplace = internal.NewFeatureTest("BPF_PROG_ATTACH atomic replacement", "5.5", func() error {
 	if err := haveProgAttach(); err != nil {
 		return err
 	}
@@ -86,7 +86,7 @@ var haveProgAttachReplace = internal.FeatureTest("BPF_PROG_ATTACH atomic replace
 	return err
 })
 
-var haveBPFLink = internal.FeatureTest("bpf_link", "5.7", func() error {
+var haveBPFLink = internal.NewFeatureTest("bpf_link", "5.7", func() error {
 	attr := sys.LinkCreateAttr{
 		// This is a hopefully invalid file descriptor, which triggers EBADF.
 		TargetFd:   ^uint32(0),
diff --git a/link/tracing.go b/link/tracing.go
index 5c07b77..e26cc91 100644
--- a/link/tracing.go
+++ b/link/tracing.go
@@ -48,7 +48,7 @@ func AttachFreplace(targetProg *ebpf.Program, name string, prog *ebpf.Program) (
 		}
 		defer btfHandle.Close()
 
-		spec, err := btfHandle.Spec(nil)
+		spec, err := btfHandle.Spec()
 		if err != nil {
 			return nil, err
 		}
diff --git a/link/uprobe.go b/link/uprobe.go
index b4ce55f..aa1ad9b 100644
--- a/link/uprobe.go
+++ b/link/uprobe.go
@@ -18,7 +18,7 @@ var (
 	uprobeRefCtrOffsetPMUPath = "/sys/bus/event_source/devices/uprobe/format/ref_ctr_offset"
 	// elixir.bootlin.com/linux/v5.15-rc7/source/kernel/events/core.c#L9799
 	uprobeRefCtrOffsetShift = 32
-	haveRefCtrOffsetPMU     = internal.FeatureTest("RefCtrOffsetPMU", "4.20", func() error {
+	haveRefCtrOffsetPMU     = internal.NewFeatureTest("RefCtrOffsetPMU", "4.20", func() error {
 		_, err := os.Stat(uprobeRefCtrOffsetPMUPath)
 		if err != nil {
 			return internal.ErrNotSupported
diff --git a/linker.go b/linker.go
index 2a2c1b6..d97a44b 100644
--- a/linker.go
+++ b/linker.go
@@ -4,7 +4,6 @@ import (
 	"encoding/binary"
 	"errors"
 	"fmt"
-	"sync"
 
 	"github.com/cilium/ebpf/asm"
 	"github.com/cilium/ebpf/btf"
@@ -88,9 +87,12 @@ func applyRelocations(insns asm.Instructions, target *btf.Spec, bo binary.ByteOr
 		bo = internal.NativeEndian
 	}
 
-	target, err := maybeLoadKernelBTF(target)
-	if err != nil {
-		return err
+	if target == nil {
+		var err error
+		target, err = btf.LoadKernelSpec()
+		if err != nil {
+			return fmt.Errorf("load kernel spec: %w", err)
+		}
 	}
 
 	fixups, err := btf.CORERelocate(relos, target, bo)
@@ -216,29 +218,3 @@ func fixupProbeReadKernel(ins *asm.Instruction) {
 		ins.Constant = int64(asm.FnProbeReadStr)
 	}
 }
-
-var kernelBTF struct {
-	sync.Mutex
-	spec *btf.Spec
-}
-
-// maybeLoadKernelBTF loads the current kernel's BTF if spec is nil, otherwise
-// it returns spec unchanged.
-//
-// The kernel BTF is cached for the lifetime of the process.
-func maybeLoadKernelBTF(spec *btf.Spec) (*btf.Spec, error) {
-	if spec != nil {
-		return spec, nil
-	}
-
-	kernelBTF.Lock()
-	defer kernelBTF.Unlock()
-
-	if kernelBTF.spec != nil {
-		return kernelBTF.spec, nil
-	}
-
-	var err error
-	kernelBTF.spec, err = btf.LoadKernelSpec()
-	return kernelBTF.spec, err
-}
diff --git a/map.go b/map.go
index 953ffc4..800d59d 100644
--- a/map.go
+++ b/map.go
@@ -320,7 +320,7 @@ func newMapWithOptions(spec *MapSpec, opts MapOptions) (_ *Map, err error) {
 	if spec.Pinning == PinByName {
 		path := filepath.Join(opts.PinPath, spec.Name)
 		if err := m.Pin(path); err != nil {
-			return nil, fmt.Errorf("pin map: %w", err)
+			return nil, fmt.Errorf("pin map to %s: %w", path, err)
 		}
 	}
 
diff --git a/prog.go b/prog.go
index 4df2432..b5a9dce 100644
--- a/prog.go
+++ b/prog.go
@@ -593,7 +593,7 @@ func (p *Program) Benchmark(in []byte, repeat int, reset func()) (uint32, time.D
 	return ret, total, nil
 }
 
-var haveProgTestRun = internal.FeatureTest("BPF_PROG_TEST_RUN", "4.12", func() error {
+var haveProgTestRun = internal.NewFeatureTest("BPF_PROG_TEST_RUN", "4.12", func() error {
 	prog, err := NewProgram(&ProgramSpec{
 		// SocketFilter does not require privileges on newer kernels.
 		Type: SocketFilter,
@@ -846,17 +846,14 @@ func findTargetInKernel(name string, progType ProgramType, attachType AttachType
 		return nil, 0, errUnrecognizedAttachType
 	}
 
-	// maybeLoadKernelBTF may return external BTF if /sys/... is not available.
-	// Ideally we shouldn't use external BTF here, since we might try to use
-	// it for parsing kmod split BTF later on. That seems unlikely to work.
-	spec, err := maybeLoadKernelBTF(nil)
+	spec, err := btf.LoadKernelSpec()
 	if err != nil {
 		return nil, 0, fmt.Errorf("load kernel spec: %w", err)
 	}
 
 	err = spec.TypeByName(typeName, &target)
 	if errors.Is(err, btf.ErrNotFound) {
-		module, id, err := findTargetInModule(spec, typeName, target)
+		module, id, err := findTargetInModule(typeName, target)
 		if errors.Is(err, btf.ErrNotFound) {
 			return nil, 0, &internal.UnsupportedFeatureError{Name: featureName}
 		}
@@ -878,7 +875,7 @@ func findTargetInKernel(name string, progType ProgramType, attachType AttachType
 // vmlinux must contain the kernel's types and is used to parse kmod BTF.
 //
 // Returns btf.ErrNotFound if the target can't be found in any module.
-func findTargetInModule(vmlinux *btf.Spec, typeName string, target btf.Type) (*btf.Handle, btf.TypeID, error) {
+func findTargetInModule(typeName string, target btf.Type) (*btf.Handle, btf.TypeID, error) {
 	it := new(btf.HandleIterator)
 	defer it.Handle.Close()
 
@@ -892,7 +889,7 @@ func findTargetInModule(vmlinux *btf.Spec, typeName string, target btf.Type) (*b
 			continue
 		}
 
-		spec, err := it.Handle.Spec(vmlinux)
+		spec, err := it.Handle.Spec()
 		if err != nil {
 			return nil, 0, fmt.Errorf("parse types for module %s: %w", info.Name, err)
 		}
@@ -942,7 +939,7 @@ func findTargetInProgram(prog *Program, name string, progType ProgramType, attac
 	}
 	defer btfHandle.Close()
 
-	spec, err := btfHandle.Spec(nil)
+	spec, err := btfHandle.Spec()
 	if err != nil {
 		return 0, err
 	}
diff --git a/syscalls.go b/syscalls.go
index c100142..1ab1336 100644
--- a/syscalls.go
+++ b/syscalls.go
@@ -47,7 +47,7 @@ func progLoad(insns asm.Instructions, typ ProgramType, license string) (*sys.FD,
 	})
 }
 
-var haveNestedMaps = internal.FeatureTest("nested maps", "4.12", func() error {
+var haveNestedMaps = internal.NewFeatureTest("nested maps", "4.12", func() error {
 	_, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(ArrayOfMaps),
 		KeySize:    4,
@@ -65,7 +65,7 @@ var haveNestedMaps = internal.FeatureTest("nested maps", "4.12", func() error {
 	return err
 })
 
-var haveMapMutabilityModifiers = internal.FeatureTest("read- and write-only maps", "5.2", func() error {
+var haveMapMutabilityModifiers = internal.NewFeatureTest("read- and write-only maps", "5.2", func() error {
 	// This checks BPF_F_RDONLY_PROG and BPF_F_WRONLY_PROG. Since
 	// BPF_MAP_FREEZE appeared in 5.2 as well we don't do a separate check.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
@@ -82,7 +82,7 @@ var haveMapMutabilityModifiers = internal.FeatureTest("read- and write-only maps
 	return nil
 })
 
-var haveMmapableMaps = internal.FeatureTest("mmapable maps", "5.5", func() error {
+var haveMmapableMaps = internal.NewFeatureTest("mmapable maps", "5.5", func() error {
 	// This checks BPF_F_MMAPABLE, which appeared in 5.5 for array maps.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(Array),
@@ -98,7 +98,7 @@ var haveMmapableMaps = internal.FeatureTest("mmapable maps", "5.5", func() error
 	return nil
 })
 
-var haveInnerMaps = internal.FeatureTest("inner maps", "5.10", func() error {
+var haveInnerMaps = internal.NewFeatureTest("inner maps", "5.10", func() error {
 	// This checks BPF_F_INNER_MAP, which appeared in 5.10.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(Array),
@@ -114,7 +114,7 @@ var haveInnerMaps = internal.FeatureTest("inner maps", "5.10", func() error {
 	return nil
 })
 
-var haveNoPreallocMaps = internal.FeatureTest("prealloc maps", "4.6", func() error {
+var haveNoPreallocMaps = internal.NewFeatureTest("prealloc maps", "4.6", func() error {
 	// This checks BPF_F_NO_PREALLOC, which appeared in 4.6.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(Hash),
@@ -154,7 +154,7 @@ func wrapMapError(err error) error {
 	return err
 }
 
-var haveObjName = internal.FeatureTest("object names", "4.15", func() error {
+var haveObjName = internal.NewFeatureTest("object names", "4.15", func() error {
 	attr := sys.MapCreateAttr{
 		MapType:    sys.MapType(Array),
 		KeySize:    4,
@@ -172,7 +172,7 @@ var haveObjName = internal.FeatureTest("object names", "4.15", func() error {
 	return nil
 })
 
-var objNameAllowsDot = internal.FeatureTest("dot in object names", "5.2", func() error {
+var objNameAllowsDot = internal.NewFeatureTest("dot in object names", "5.2", func() error {
 	if err := haveObjName(); err != nil {
 		return err
 	}
@@ -194,7 +194,7 @@ var objNameAllowsDot = internal.FeatureTest("dot in object names", "5.2", func()
 	return nil
 })
 
-var haveBatchAPI = internal.FeatureTest("map batch api", "5.6", func() error {
+var haveBatchAPI = internal.NewFeatureTest("map batch api", "5.6", func() error {
 	var maxEntries uint32 = 2
 	attr := sys.MapCreateAttr{
 		MapType:    sys.MapType(Hash),
@@ -226,7 +226,7 @@ var haveBatchAPI = internal.FeatureTest("map batch api", "5.6", func() error {
 	return nil
 })
 
-var haveProbeReadKernel = internal.FeatureTest("bpf_probe_read_kernel", "5.5", func() error {
+var haveProbeReadKernel = internal.NewFeatureTest("bpf_probe_read_kernel", "5.5", func() error {
 	insns := asm.Instructions{
 		asm.Mov.Reg(asm.R1, asm.R10),
 		asm.Add.Imm(asm.R1, -8),
@@ -244,7 +244,7 @@ var haveProbeReadKernel = internal.FeatureTest("bpf_probe_read_kernel", "5.5", f
 	return nil
 })
 
-var haveBPFToBPFCalls = internal.FeatureTest("bpf2bpf calls", "4.16", func() error {
+var haveBPFToBPFCalls = internal.NewFeatureTest("bpf2bpf calls", "4.16", func() error {
 	insns := asm.Instructions{
 		asm.Call.Label("prog2").WithSymbol("prog1"),
 		asm.Return(),
diff --git a/types.go b/types.go
index 946c48b..35927e2 100644
--- a/types.go
+++ b/types.go
@@ -11,11 +11,6 @@ import (
 // that will be initialized in the kernel.
 type MapType uint32
 
-// Max returns the latest supported MapType.
-func (MapType) Max() MapType {
-	return maxMapType - 1
-}
-
 // All the various map types that can be created
 const (
 	UnspecifiedMap MapType = iota
@@ -100,8 +95,6 @@ const (
 	InodeStorage
 	// TaskStorage - Specialized local storage map for task_struct.
 	TaskStorage
-	// maxMapType - Bound enum of MapTypes, has to be last in enum.
-	maxMapType
 )
 
 // hasPerCPUValue returns true if the Map stores a value per CPU.
@@ -124,11 +117,6 @@ func (mt MapType) canStoreProgram() bool {
 // ProgramType of the eBPF program
 type ProgramType uint32
 
-// Max return the latest supported ProgramType.
-func (ProgramType) Max() ProgramType {
-	return maxProgramType - 1
-}
-
 // eBPF program types
 const (
 	UnspecifiedProgram ProgramType = iota
@@ -163,7 +151,6 @@ const (
 	LSM
 	SkLookup
 	Syscall
-	maxProgramType
 )
 
 // AttachType of the eBPF program, needed to differentiate allowed context accesses in
diff --git a/types_string.go b/types_string.go
index e80b948..5679f22 100644
--- a/types_string.go
+++ b/types_string.go
@@ -38,12 +38,11 @@ func _() {
 	_ = x[RingBuf-27]
 	_ = x[InodeStorage-28]
 	_ = x[TaskStorage-29]
-	_ = x[maxMapType-30]
 }
 
-const _MapType_name = "UnspecifiedMapHashArrayProgramArrayPerfEventArrayPerCPUHashPerCPUArrayStackTraceCGroupArrayLRUHashLRUCPUHashLPMTrieArrayOfMapsHashOfMapsDevMapSockMapCPUMapXSKMapSockHashCGroupStorageReusePortSockArrayPerCPUCGroupStorageQueueStackSkStorageDevMapHashStructOpsMapRingBufInodeStorageTaskStoragemaxMapType"
+const _MapType_name = "UnspecifiedMapHashArrayProgramArrayPerfEventArrayPerCPUHashPerCPUArrayStackTraceCGroupArrayLRUHashLRUCPUHashLPMTrieArrayOfMapsHashOfMapsDevMapSockMapCPUMapXSKMapSockHashCGroupStorageReusePortSockArrayPerCPUCGroupStorageQueueStackSkStorageDevMapHashStructOpsMapRingBufInodeStorageTaskStorage"
 
-var _MapType_index = [...]uint16{0, 14, 18, 23, 35, 49, 59, 70, 80, 91, 98, 108, 115, 126, 136, 142, 149, 155, 161, 169, 182, 200, 219, 224, 229, 238, 248, 260, 267, 279, 290, 300}
+var _MapType_index = [...]uint16{0, 14, 18, 23, 35, 49, 59, 70, 80, 91, 98, 108, 115, 126, 136, 142, 149, 155, 161, 169, 182, 200, 219, 224, 229, 238, 248, 260, 267, 279, 290}
 
 func (i MapType) String() string {
 	if i >= MapType(len(_MapType_index)-1) {
@@ -87,12 +86,11 @@ func _() {
 	_ = x[LSM-29]
 	_ = x[SkLookup-30]
 	_ = x[Syscall-31]
-	_ = x[maxProgramType-32]
 }
 
-const _ProgramType_name = "UnspecifiedProgramSocketFilterKprobeSchedCLSSchedACTTracePointXDPPerfEventCGroupSKBCGroupSockLWTInLWTOutLWTXmitSockOpsSkSKBCGroupDeviceSkMsgRawTracepointCGroupSockAddrLWTSeg6LocalLircMode2SkReuseportFlowDissectorCGroupSysctlRawTracepointWritableCGroupSockoptTracingStructOpsExtensionLSMSkLookupSyscallmaxProgramType"
+const _ProgramType_name = "UnspecifiedProgramSocketFilterKprobeSchedCLSSchedACTTracePointXDPPerfEventCGroupSKBCGroupSockLWTInLWTOutLWTXmitSockOpsSkSKBCGroupDeviceSkMsgRawTracepointCGroupSockAddrLWTSeg6LocalLircMode2SkReuseportFlowDissectorCGroupSysctlRawTracepointWritableCGroupSockoptTracingStructOpsExtensionLSMSkLookupSyscall"
 
-var _ProgramType_index = [...]uint16{0, 18, 30, 36, 44, 52, 62, 65, 74, 83, 93, 98, 104, 111, 118, 123, 135, 140, 153, 167, 179, 188, 199, 212, 224, 245, 258, 265, 274, 283, 286, 294, 301, 315}
+var _ProgramType_index = [...]uint16{0, 18, 30, 36, 44, 52, 62, 65, 74, 83, 93, 98, 104, 111, 118, 123, 135, 140, 153, 167, 179, 188, 199, 212, 224, 245, 258, 265, 274, 283, 286, 294, 301}
 
 func (i ProgramType) String() string {
 	if i >= ProgramType(len(_ProgramType_index)-1) {


Test patch:
diff --git a/btf/btf_test.go b/btf/btf_test.go
index ed0f34d..d681a16 100644
--- a/btf/btf_test.go
+++ b/btf/btf_test.go
@@ -12,15 +12,9 @@ import (
 
 	"github.com/cilium/ebpf/internal"
 	"github.com/cilium/ebpf/internal/testutils"
+	qt "github.com/frankban/quicktest"
 )
 
-// vmlinux caches the result of parsing the running kernel's BTF.
-var vmlinux struct {
-	sync.Once
-	spec *Spec
-	err  error
-}
-
 func vmlinuxSpec(tb testing.TB) *Spec {
 	tb.Helper()
 
@@ -28,14 +22,14 @@ func vmlinuxSpec(tb testing.TB) *Spec {
 	// through sysfs"), which shipped in Linux 5.4.
 	testutils.SkipOnOldKernel(tb, "5.4", "vmlinux BTF in sysfs")
 
-	vmlinux.Do(func() {
-		vmlinux.spec, vmlinux.err = LoadKernelSpec()
-	})
-	if vmlinux.err != nil {
-		tb.Fatal(vmlinux.err)
+	spec, fallback, err := kernelSpec()
+	if err != nil {
+		tb.Fatal(err)
 	}
-
-	return vmlinux.spec.Copy()
+	if fallback {
+		tb.Fatal("/sys/kernel/btf/vmlinux is not available")
+	}
+	return spec
 }
 
 // vmlinuxTestdata caches the result of reading and parsing a BTF blob from
@@ -480,6 +474,11 @@ func TestLoadSplitSpecFromReader(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
+
+	typeByID, err := splitSpec.TypeByID(typeID)
+	qt.Assert(t, err, qt.IsNil)
+	qt.Assert(t, typeByID, qt.Equals, typ)
+
 	fnType := typ.(*Func)
 	fnProto := fnType.Type.(*FuncProto)
 
diff --git a/btf/handle_test.go b/btf/handle_test.go
index d322ce5..a6c22e0 100644
--- a/btf/handle_test.go
+++ b/btf/handle_test.go
@@ -70,20 +70,10 @@ func TestParseModuleSplitSpec(t *testing.T) {
 	}
 	defer vmlinux.Close()
 
-	vmlinuxSpec, err := vmlinux.Spec(nil)
-	if err != nil {
-		t.Fatal("Parse vmlinux BTF:", err)
-	}
-
-	_, err = module.Spec(vmlinuxSpec)
+	_, err = module.Spec()
 	if err != nil {
 		t.Fatal("Parse module BTF:", err)
 	}
-
-	_, err = module.Spec(nil)
-	if err == nil {
-		t.Fatal("Parsing module BTF without vmlinux base didn't fail")
-	}
 }
 
 func ExampleHandleIterator() {
diff --git a/features/map_test.go b/features/map_test.go
index 9b08fc1..f2a0a44 100644
--- a/features/map_test.go
+++ b/features/map_test.go
@@ -2,107 +2,26 @@ package features
 
 import (
 	"errors"
-	"fmt"
 	"math"
-	"os"
 	"testing"
 
 	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/internal"
 	"github.com/cilium/ebpf/internal/testutils"
 )
 
-var mapTypeMinVersion = map[ebpf.MapType]string{
-	ebpf.Hash:                "3.19",
-	ebpf.Array:               "3.19",
-	ebpf.ProgramArray:        "4.2",
-	ebpf.PerfEventArray:      "4.3",
-	ebpf.PerCPUHash:          "4.6",
-	ebpf.PerCPUArray:         "4.6",
-	ebpf.StackTrace:          "4.6",
-	ebpf.CGroupArray:         "4.8",
-	ebpf.LRUHash:             "4.10",
-	ebpf.LRUCPUHash:          "4.10",
-	ebpf.LPMTrie:             "4.11",
-	ebpf.ArrayOfMaps:         "4.12",
-	ebpf.HashOfMaps:          "4.12",
-	ebpf.DevMap:              "4.14",
-	ebpf.SockMap:             "4.14",
-	ebpf.CPUMap:              "4.15",
-	ebpf.XSKMap:              "4.18",
-	ebpf.SockHash:            "4.18",
-	ebpf.CGroupStorage:       "4.19",
-	ebpf.ReusePortSockArray:  "4.19",
-	ebpf.PerCPUCGroupStorage: "4.20",
-	ebpf.Queue:               "4.20",
-	ebpf.Stack:               "4.20",
-	ebpf.SkStorage:           "5.2",
-	ebpf.DevMapHash:          "5.4",
-	ebpf.StructOpsMap:        "5.6",
-	ebpf.RingBuf:             "5.8",
-	ebpf.InodeStorage:        "5.10",
-	ebpf.TaskStorage:         "5.11",
-}
-
 func TestHaveMapType(t *testing.T) {
-	for mt := ebpf.UnspecifiedMap + 1; mt <= mt.Max(); mt++ {
-		minVersion, ok := mapTypeMinVersion[mt]
-		if !ok {
-			// In cases were a new map type wasn't added to testCases
-			// we should make sure the test runs anyway and fails on old kernels
-			minVersion = "0.0"
-		}
-
-		feature := fmt.Sprintf("map type %s", mt.String())
-
-		t.Run(mt.String(), func(t *testing.T) {
-			testutils.SkipOnOldKernel(t, minVersion, feature)
-
-			if err := HaveMapType(mt); err != nil {
-				t.Fatalf("Map type %s isn't supported even though kernel is at least %s: %v", mt.String(), minVersion, err)
-			}
-		})
-	}
-}
-
-type haveMapFlagsTestEntry struct {
-	flags            MapFlags
-	minKernelVersion string
-	description      string
+	testutils.CheckFeatureMatrix(t, haveMapTypeMatrix)
 }
 
 func TestHaveMapFlag(t *testing.T) {
-	mapFlagTestEntries := []haveMapFlagsTestEntry{
-		{BPF_F_RDONLY_PROG, "5.2", "read_only_array_map"},
-		{BPF_F_WRONLY_PROG, "5.2", "write_only_array_map"},
-		{BPF_F_MMAPABLE, "5.5", "mmapable_array_map"},
-		{BPF_F_INNER_MAP, "5.10", "inner_map_array_map"},
-		{BPF_F_NO_PREALLOC, "4.6", "no_prealloc_hash_map"},
-	}
-
-	for _, entry := range mapFlagTestEntries {
-		t.Run(entry.description, func(t *testing.T) {
-			err := HaveMapFlag(entry.flags)
-			if testutils.IsKernelLessThan(t, entry.minKernelVersion) {
-				if err == nil {
-					t.Fatalf("Map flag %d is supported on this kernel even though kernel is less than %s", entry.flags, entry.minKernelVersion)
-				}
-			} else {
-				if err != nil {
-					t.Fatalf("Map flag %d isn't supported even though kernel is at least %s: %v", entry.flags, entry.minKernelVersion, err)
-				}
-			}
-		})
-	}
-}
-
-func TestHaveMapTypeUnsupported(t *testing.T) {
-	if err := haveMapType(ebpf.MapType(math.MaxUint32)); !errors.Is(err, ebpf.ErrNotSupported) {
-		t.Fatalf("Expected ebpf.ErrNotSupported but was: %v", err)
-	}
+	testutils.CheckFeatureMatrix(t, haveMapFlagsMatrix)
 }
 
 func TestHaveMapTypeInvalid(t *testing.T) {
-	if err := HaveMapType(ebpf.MapType(math.MaxUint32)); !errors.Is(err, os.ErrInvalid) {
-		t.Fatalf("Expected os.ErrInvalid but was: %v", err)
+	if err := HaveMapType(ebpf.MapType(math.MaxUint32)); err == nil {
+		t.Fatal("Expected an error")
+	} else if errors.Is(err, internal.ErrNotSupported) {
+		t.Fatal("Got ErrNotSupported:", err)
 	}
 }
diff --git a/features/misc_test.go b/features/misc_test.go
index 28bb132..95fa0e0 100644
--- a/features/misc_test.go
+++ b/features/misc_test.go
@@ -6,27 +6,18 @@ import (
 	"github.com/cilium/ebpf/internal/testutils"
 )
 
-func TestHaveMisc(t *testing.T) {
-	tests := map[string]struct {
-		typ       miscType
-		probe     func() error
-		minKernel string
-	}{
-		"large instructions": {typ: largeInsn, probe: HaveLargeInstructions, minKernel: "5.2"},
-		"bounded loops":      {typ: boundedLoops, probe: HaveBoundedLoops, minKernel: "5.3"},
-		"v2 ISA":             {typ: v2ISA, probe: HaveV2ISA, minKernel: "4.14"},
-		"v3 ISA":             {typ: v3ISA, probe: HaveV3ISA, minKernel: "5.1"},
-	}
+func TestHaveLargeInstructions(t *testing.T) {
+	testutils.CheckFeatureTest(t, HaveLargeInstructions)
+}
+
+func TestHaveBoundedLoops(t *testing.T) {
+	testutils.CheckFeatureTest(t, HaveBoundedLoops)
+}
 
-	for misc, test := range tests {
-		test := test
-		t.Run(misc, func(t *testing.T) {
-			testutils.SkipOnOldKernel(t, test.minKernel, misc)
+func TestHaveV2ISA(t *testing.T) {
+	testutils.CheckFeatureTest(t, HaveV2ISA)
+}
 
-			if err := test.probe(); err != nil {
-				t.Fatalf("Feature %s isn't supported even though kernel is at least %s: %v",
-					misc, test.minKernel, err)
-			}
-		})
-	}
+func TestHaveV3ISA(t *testing.T) {
+	testutils.CheckFeatureTest(t, HaveV3ISA)
 }
diff --git a/features/prog_test.go b/features/prog_test.go
index e4141af..c512c37 100644
--- a/features/prog_test.go
+++ b/features/prog_test.go
@@ -4,7 +4,6 @@ import (
 	"errors"
 	"fmt"
 	"math"
-	"os"
 	"testing"
 
 	"github.com/cilium/ebpf"
@@ -13,86 +12,15 @@ import (
 	"github.com/cilium/ebpf/internal/testutils"
 )
 
-var progTypeMinVersion = map[ebpf.ProgramType]string{
-	ebpf.SocketFilter:          "3.19",
-	ebpf.Kprobe:                "4.1",
-	ebpf.SchedCLS:              "4.1",
-	ebpf.SchedACT:              "4.1",
-	ebpf.TracePoint:            "4.7",
-	ebpf.XDP:                   "4.8",
-	ebpf.PerfEvent:             "4.9",
-	ebpf.CGroupSKB:             "4.10",
-	ebpf.CGroupSock:            "4.10",
-	ebpf.LWTIn:                 "4.10",
-	ebpf.LWTOut:                "4.10",
-	ebpf.LWTXmit:               "4.10",
-	ebpf.SockOps:               "4.13",
-	ebpf.SkSKB:                 "4.14",
-	ebpf.CGroupDevice:          "4.15",
-	ebpf.SkMsg:                 "4.17",
-	ebpf.RawTracepoint:         "4.17",
-	ebpf.CGroupSockAddr:        "4.17",
-	ebpf.LWTSeg6Local:          "4.18",
-	ebpf.LircMode2:             "4.18",
-	ebpf.SkReuseport:           "4.19",
-	ebpf.FlowDissector:         "4.20",
-	ebpf.CGroupSysctl:          "5.2",
-	ebpf.RawTracepointWritable: "5.2",
-	ebpf.CGroupSockopt:         "5.3",
-	ebpf.Tracing:               "5.5",
-	ebpf.StructOps:             "5.6",
-	ebpf.Extension:             "5.6",
-	ebpf.LSM:                   "5.7",
-	ebpf.SkLookup:              "5.9",
-	ebpf.Syscall:               "5.14",
-}
-
 func TestHaveProgramType(t *testing.T) {
-	for progType := ebpf.UnspecifiedProgram + 1; progType <= progType.Max(); progType++ {
-		// Need inner loop copy to make use of t.Parallel()
-		pt := progType
-
-		minVersion, ok := progTypeMinVersion[pt]
-		if !ok {
-			// In cases where a new prog type wasn't added to progTypeMinVersion
-			// we should make sure the test runs anyway and fails on old kernels
-			minVersion = "0.0"
-		}
-
-		feature := fmt.Sprintf("program type %s", pt.String())
-
-		t.Run(pt.String(), func(t *testing.T) {
-			t.Parallel()
-
-			if progLoadProbeNotImplemented(pt) {
-				t.Skipf("Test for prog type %s requires working probe", pt.String())
-			}
-			testutils.SkipOnOldKernel(t, minVersion, feature)
-
-			if err := HaveProgramType(pt); err != nil {
-				if pt == ebpf.LircMode2 {
-					// CI kernels are built with CONFIG_BPF_LIRC_MODE2, but some
-					// mainstream distro's don't ship with it. Make this prog type
-					// optional to retain compatibility with those kernels.
-					testutils.SkipIfNotSupported(t, err)
-				}
-
-				t.Fatalf("Program type %s isn't supported even though kernel is at least %s: %v", pt.String(), minVersion, err)
-			}
-		})
-
-	}
-}
-
-func TestHaveProgramTypeUnsupported(t *testing.T) {
-	if err := haveProgramType(ebpf.ProgramType(math.MaxUint32)); !errors.Is(err, ebpf.ErrNotSupported) {
-		t.Fatalf("Expected ebpf.ErrNotSupported but was: %v", err)
-	}
+	testutils.CheckFeatureMatrix(t, haveProgramTypeMatrix)
 }
 
 func TestHaveProgramTypeInvalid(t *testing.T) {
-	if err := HaveProgramType(ebpf.ProgramType(math.MaxUint32)); !errors.Is(err, os.ErrInvalid) {
-		t.Fatalf("Expected os.ErrInvalid but was: %v", err)
+	if err := HaveProgramType(ebpf.ProgramType(math.MaxUint32)); err == nil {
+		t.Fatal("Expected an error")
+	} else if errors.Is(err, internal.ErrNotSupported) {
+		t.Fatal("Got ErrNotSupported:", err)
 	}
 }
 
@@ -135,26 +63,10 @@ func TestHaveProgramHelper(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		minVersion := progTypeMinVersion[tc.prog]
-
-		progVersion, err := internal.NewVersion(minVersion)
-		if err != nil {
-			t.Fatalf("Could not read kernel version required for program: %v", err)
-		}
-
-		helperVersion, err := internal.NewVersion(tc.version)
-		if err != nil {
-			t.Fatalf("Could not read kernel version required for helper: %v", err)
-		}
-
-		if progVersion.Less(helperVersion) {
-			minVersion = tc.version
-		}
-
 		t.Run(fmt.Sprintf("%s/%s", tc.prog.String(), tc.helper.String()), func(t *testing.T) {
 			feature := fmt.Sprintf("helper %s for program type %s", tc.helper.String(), tc.prog.String())
 
-			testutils.SkipOnOldKernel(t, minVersion, feature)
+			testutils.SkipOnOldKernel(t, tc.version, feature)
 
 			err := HaveProgramHelper(tc.prog, tc.helper)
 			if !errors.Is(err, tc.expected) {
@@ -165,16 +77,3 @@ func TestHaveProgramHelper(t *testing.T) {
 
 	}
 }
-
-func TestHaveProgramHelperUnsupported(t *testing.T) {
-	pt := ebpf.SocketFilter
-	minVersion := progTypeMinVersion[pt]
-
-	feature := fmt.Sprintf("program type %s", pt.String())
-
-	testutils.SkipOnOldKernel(t, minVersion, feature)
-
-	if err := haveProgramHelper(pt, asm.BuiltinFunc(math.MaxInt32)); !errors.Is(err, ebpf.ErrNotSupported) {
-		t.Fatalf("Expected ebpf.ErrNotSupported but was: %v", err)
-	}
-}
diff --git a/internal/feature_test.go b/internal/feature_test.go
index 68b7ed0..4039256 100644
--- a/internal/feature_test.go
+++ b/internal/feature_test.go
@@ -9,7 +9,7 @@ import (
 func TestFeatureTest(t *testing.T) {
 	var called bool
 
-	fn := FeatureTest("foo", "1.0", func() error {
+	fn := NewFeatureTest("foo", "1.0", func() error {
 		called = true
 		return nil
 	})
@@ -27,7 +27,7 @@ func TestFeatureTest(t *testing.T) {
 		t.Error("Unexpected negative result:", err)
 	}
 
-	fn = FeatureTest("bar", "2.1.1", func() error {
+	fn = NewFeatureTest("bar", "2.1.1", func() error {
 		return ErrNotSupported
 	})
 
@@ -54,7 +54,7 @@ func TestFeatureTest(t *testing.T) {
 		t.Error("Didn't cache an error wrapping ErrNotSupported")
 	}
 
-	fn = FeatureTest("bar", "2.1.1", func() error {
+	fn = NewFeatureTest("bar", "2.1.1", func() error {
 		return errors.New("foo")
 	})
 
diff --git a/internal/testutils/feature.go b/internal/testutils/feature.go
index efece22..0db609e 100644
--- a/internal/testutils/feature.go
+++ b/internal/testutils/feature.go
@@ -17,9 +17,10 @@ func MustKernelVersion() internal.Version {
 }
 
 func CheckFeatureTest(t *testing.T, fn func() error) {
-	t.Helper()
+	checkFeatureTestError(t, fn())
+}
 
-	err := fn()
+func checkFeatureTestError(t *testing.T, err error) {
 	if err == nil {
 		return
 	}
@@ -32,6 +33,16 @@ func CheckFeatureTest(t *testing.T, fn func() error) {
 	}
 }
 
+func CheckFeatureMatrix[K comparable](t *testing.T, fm internal.FeatureMatrix[K]) {
+	t.Helper()
+
+	for key, ft := range fm {
+		t.Run(ft.Name, func(t *testing.T) {
+			checkFeatureTestError(t, fm.Result(key))
+		})
+	}
+}
+
 func SkipIfNotSupported(tb testing.TB, err error) {
 	tb.Helper()