|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Score the kernel's BPF selftest programs against what PythonBPF can express. |
| 3 | +
|
| 4 | +Point it at a checkout of tools/testing/selftests/bpf/progs and it prints, for |
| 5 | +every real program there, the constructs that keep it out of PythonBPF today. |
| 6 | +Programs with no hard blocker are the porting candidates; the soft flags on |
| 7 | +them say what a port has to rewrite by hand. |
| 8 | +
|
| 9 | + python3 tools/selftest-audit.py path/to/linux/tools/testing/selftests/bpf/progs |
| 10 | + python3 tools/selftest-audit.py progs/ --json > audit.json |
| 11 | + python3 tools/selftest-audit.py progs/ --histogram |
| 12 | +
|
| 13 | +This is a heuristic scan of the C source, not a compiler: it has false |
| 14 | +negatives in both directions, and the envelope it encodes (the helper, map and |
| 15 | +construct lists below) must be kept in step with the compiler by hand. Re-run |
| 16 | +it after a feature lands to see what the feature unlocked. The lists were last |
| 17 | +reconciled against pythonbpf/helper and pythonbpf/maps when scalar @bpfglobal |
| 18 | +support and integer signedness merged. |
| 19 | +""" |
| 20 | + |
| 21 | +import argparse |
| 22 | +import json |
| 23 | +import re |
| 24 | +import sys |
| 25 | +from collections import Counter |
| 26 | +from pathlib import Path |
| 27 | + |
| 28 | +# ── the envelope ───────────────────────────────────────────────────────────── |
| 29 | + |
| 30 | +# Kernel helpers pythonbpf/helper can emit (bpf_trace_printk is `print`). |
| 31 | +SUPPORTED_HELPERS = { |
| 32 | + "bpf_get_current_cgroup_id", |
| 33 | + "bpf_get_current_comm", |
| 34 | + "bpf_get_current_pid_tgid", |
| 35 | + "bpf_get_current_uid_gid", |
| 36 | + "bpf_get_prandom_u32", |
| 37 | + "bpf_get_smp_processor_id", |
| 38 | + "bpf_get_stack", |
| 39 | + "bpf_ktime_get_ns", |
| 40 | + "bpf_map_delete_elem", |
| 41 | + "bpf_map_lookup_elem", |
| 42 | + "bpf_map_update_elem", |
| 43 | + "bpf_perf_event_output", |
| 44 | + "bpf_printk", |
| 45 | + "bpf_trace_printk", |
| 46 | + "bpf_probe_read", |
| 47 | + "bpf_probe_read_kernel", |
| 48 | + "bpf_probe_read_kernel_str", |
| 49 | + "bpf_ringbuf_output", |
| 50 | + "bpf_ringbuf_reserve", |
| 51 | + "bpf_ringbuf_submit", |
| 52 | + "bpf_skb_store_bytes", |
| 53 | +} |
| 54 | + |
| 55 | +# BPF_MAP_TYPE_* that pythonbpf/maps lowers (ArrayMap is still a stub). |
| 56 | +SUPPORTED_MAP_TYPES = {"HASH", "PERF_EVENT_ARRAY", "RINGBUF"} |
| 57 | + |
| 58 | +# Things that look like helper calls but are libbpf macros, not helpers. |
| 59 | +NOT_HELPERS = { |
| 60 | + "bpf_htons", |
| 61 | + "bpf_ntohs", |
| 62 | + "bpf_htonl", |
| 63 | + "bpf_ntohl", |
| 64 | + "bpf_be64_to_cpu", |
| 65 | + "bpf_cpu_to_be64", |
| 66 | + "bpf_printk_", |
| 67 | +} |
| 68 | + |
| 69 | +# (label, kind, regex). kind is "hard" (a language gap) or "soft" (a porting |
| 70 | +# cost a careful rewrite can absorb). Order does not matter. |
| 71 | +PATTERNS = [ |
| 72 | + # control flow |
| 73 | + ("loop", "hard", r"\b(for|while)\s*\(|\bbpf_for\b|\bbpf_repeat\b|\bbpf_loop\s*\("), |
| 74 | + ("goto", "hard", r"\bgoto\s+\w+"), |
| 75 | + ("switch", "hard", r"\bswitch\s*\("), |
| 76 | + ("ternary", "soft", r"\?[^?:]*:"), |
| 77 | + # entry-point shape |
| 78 | + ( |
| 79 | + "typed_prog_macro", |
| 80 | + "hard", |
| 81 | + r"\bBPF_(PROG|PROG2|KPROBE|KRETPROBE|KSYSCALL|KPROBE_SYSCALL|UPROBE|URETPROBE|USDT|" |
| 82 | + r"TRACE_\w+|ITER\w*|LSM\w*)\s*\(", |
| 83 | + ), |
| 84 | + ("struct_ops", "hard", r'SEC\s*\(\s*"\.?struct_ops'), |
| 85 | + ("freplace", "hard", r'SEC\s*\(\s*"freplace'), |
| 86 | + ("sleepable_or_special_sec", "soft", r'SEC\s*\(\s*"\?'), |
| 87 | + # verifier-test harness |
| 88 | + ( |
| 89 | + "verifier_annotation", |
| 90 | + "hard", |
| 91 | + r"\b__(failure|success|msg|retval|naked|log_level|flag|arch_\w+|description|" |
| 92 | + r"jited|xlated|caps_unpriv|load_if_JITed|not_msg|failure_unpriv|success_unpriv)\b", |
| 93 | + ), |
| 94 | + # functions |
| 95 | + ("subprog_call", "hard", r"\b__noinline\b|\b__weak\b|\bSEC\s*\(\s*\"\?"), |
| 96 | + ( |
| 97 | + "static_helper", |
| 98 | + "soft", |
| 99 | + r"\bstatic\s+(__always_inline\s+|inline\s+|__noinline\s+)?\w[\w\s\*]*\s+\**\w+\s*\([^;]*\)\s*\{", |
| 100 | + ), |
| 101 | + # kernel features |
| 102 | + ( |
| 103 | + "kfunc", |
| 104 | + "hard", |
| 105 | + r"__ksym\b|bpf_experimental\.h|bpf_kfuncs\.h|\bbpf_(obj_new|obj_drop|refcount|task_from|" |
| 106 | + r"task_acquire|task_release|cgroup_acquire|cgroup_release|cpumask_\w+|rbtree_\w+|list_\w+|" |
| 107 | + r"rcu_read_lock|rcu_read_unlock|arena_\w+|key_put|lookup_user_key|dynptr_\w+|iter_\w+|" |
| 108 | + r"wq_\w+|timer_\w+|throw|percpu_obj_\w+|res_spin_\w+|preempt_\w+|local_irq_\w+|" |
| 109 | + r"session_\w+|get_dentry_xattr|get_file_xattr|kptr_xchg|sk_assign|xdp_\w+|skb_\w+)\s*\(", |
| 110 | + ), |
| 111 | + ("inline_asm", "hard", r"\basm\s*(volatile)?\s*\(|__asm__"), |
| 112 | + ("atomic", "hard", r"__sync_\w+|__atomic_\w+|\bbpf_spin_(lock|unlock)\b"), |
| 113 | + ("tail_call", "hard", r"\bbpf_tail_call\w*\s*\("), |
| 114 | + ( |
| 115 | + "core_read", |
| 116 | + "hard", |
| 117 | + r"\bBPF_CORE_READ\w*\b|\bbpf_core_\w+|__builtin_preserve\w*|\bbpf_probe_read_user\w*", |
| 118 | + ), |
| 119 | + ( |
| 120 | + "builtin", |
| 121 | + "hard", |
| 122 | + r"__builtin_(memcpy|memset|memcmp|bswap\w*|ctz|clz|popcount|expect)\b|\b(memcpy|memset|memcmp)\s*\(", |
| 123 | + ), |
| 124 | + ( |
| 125 | + "endian_macro", |
| 126 | + "hard", |
| 127 | + r"\bbpf_(htons|ntohs|htonl|ntohl|be64_to_cpu|cpu_to_be64)\s*\(", |
| 128 | + ), |
| 129 | + ("kconfig_or_extern", "hard", r"__kconfig\b|^\s*extern\s"), |
| 130 | + ("arena_or_iter_sec", "hard", r'SEC\s*\(\s*"(iter|arena)'), |
| 131 | + # data |
| 132 | + ("ctx_field_write", "soft", r"\bctx\s*->\s*\w+\s*(\+|-|\||&|\^|<<|>>)?=[^=]"), |
| 133 | + ("local_struct", "soft", r"^\s+struct\s+\w+\s+\w+\s*(=\s*\{|;)"), |
| 134 | + ( |
| 135 | + "local_array", |
| 136 | + "soft", |
| 137 | + r"^\s+(const\s+)?(char|__u8|__u16|__u32|__u64|int|long|unsigned|u8|u16|u32|u64|__s\d+)\s+(?!_?_?license\b)\w+\s*\[[^\]]*\]", |
| 138 | + ), |
| 139 | + ( |
| 140 | + "string_or_char_global", |
| 141 | + "hard", |
| 142 | + r"^(static\s+)?(volatile\s+)?(const\s+)?(volatile\s+)?char\s+\w+\s*\[[^\]]*\]\s*(SEC\s*\(\s*\"\.rodata\"\s*\))?\s*=", |
| 143 | + ), |
| 144 | +] |
| 145 | + |
| 146 | +# File-scope declarations that are not ordinary scalar globals. |
| 147 | +ARRAY_GLOBAL = re.compile( |
| 148 | + r"^(static\s+)?(volatile\s+)?(const\s+)?(volatile\s+)?" |
| 149 | + r"(struct\s+\w+|__?[us]\d+|u\d+|s\d+|int|long|short|char|bool|unsigned\s+\w+|" |
| 150 | + r"uintptr_t|size_t|__wsum|__be\d+|__le\d+|\w+_t)\s*\**\s*\w+\s*\[", |
| 151 | + re.M, |
| 152 | +) |
| 153 | +STRUCT_GLOBAL = re.compile( |
| 154 | + r"^(static\s+)?(volatile\s+)?(const\s+)?(volatile\s+)?struct\s+\w+\s+\w+\s*(=|;)", |
| 155 | + re.M, |
| 156 | +) |
| 157 | +ANON_STRUCT_GLOBAL = re.compile(r"^struct\s*\{", re.M) |
| 158 | +MAP_DECL = re.compile(r"__uint\s*\(\s*type\s*,\s*BPF_MAP_TYPE_(\w+)\s*\)") |
| 159 | +SEC_RE = re.compile(r'SEC\s*\(\s*"([^"]+)"\s*\)') |
| 160 | +HELPER_CALL = re.compile(r"\b(bpf_\w+)\s*\(") |
| 161 | +INCLUDE_C = re.compile(r'^\s*#include\s+"[^"]+\.c"', re.M) |
| 162 | +BTF_DUMP_FIXTURE = re.compile(r"btf_dump|btf__|__attribute__\(\(btf_decl_tag", re.I) |
| 163 | + |
| 164 | + |
| 165 | +def strip_comments(src: str) -> str: |
| 166 | + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) |
| 167 | + return re.sub(r"//[^\n]*", "", src) |
| 168 | + |
| 169 | + |
| 170 | +def classify(path: Path) -> dict | None: |
| 171 | + raw = path.read_text(errors="replace") |
| 172 | + src = strip_comments(raw) |
| 173 | + secs = [s for s in SEC_RE.findall(src) if s not in ("license", ".maps", "version")] |
| 174 | + is_prog = bool(secs) and not INCLUDE_C.search(src) |
| 175 | + if not is_prog: |
| 176 | + return None # wrapper shim, header-only fixture, or library file |
| 177 | + |
| 178 | + hard: set[str] = set() |
| 179 | + soft: set[str] = set() |
| 180 | + detail: dict[str, list[str]] = {} |
| 181 | + |
| 182 | + for label, kind, rx in PATTERNS: |
| 183 | + if re.search(rx, src, re.M): |
| 184 | + (hard if kind == "hard" else soft).add(label) |
| 185 | + |
| 186 | + maps = set(MAP_DECL.findall(src)) |
| 187 | + bad_maps = sorted(m for m in maps if m not in SUPPORTED_MAP_TYPES) |
| 188 | + if bad_maps: |
| 189 | + hard.add("unsupported_map") |
| 190 | + detail["unsupported_map"] = bad_maps |
| 191 | + if re.search(r'SEC\s*\(\s*"\.maps"\s*\)', src) and not maps: |
| 192 | + # a map with no __uint(type) is a legacy bpf_map_def or an extern |
| 193 | + hard.add("legacy_map_def") |
| 194 | + |
| 195 | + helpers = set(HELPER_CALL.findall(src)) - NOT_HELPERS |
| 196 | + helpers = { |
| 197 | + h for h in helpers if not h.startswith("bpf_map_") or h in SUPPORTED_HELPERS |
| 198 | + } |
| 199 | + unsupported = sorted( |
| 200 | + h |
| 201 | + for h in helpers |
| 202 | + if h not in SUPPORTED_HELPERS |
| 203 | + and not h.startswith(("bpf_core_", "bpf_probe_read_user")) |
| 204 | + ) |
| 205 | + if unsupported: |
| 206 | + hard.add("unsupported_helper") |
| 207 | + detail["unsupported_helper"] = unsupported |
| 208 | + |
| 209 | + # file-scope globals that scalar @bpfglobal cannot hold. Map declarations |
| 210 | + # are anonymous structs too, so take them out first. |
| 211 | + no_maps = re.sub( |
| 212 | + r"struct\s*\{[^}]*\}\s*\w+\s*SEC\s*\(\s*\"\.maps\"\s*\)\s*;", |
| 213 | + "", |
| 214 | + src, |
| 215 | + flags=re.S, |
| 216 | + ) |
| 217 | + body_stripped = re.sub(r"\{[^{}]*\}", "{}", no_maps) # crude: drop innermost bodies |
| 218 | + for _ in range(6): |
| 219 | + body_stripped = re.sub(r"\{[^{}]*\}", "{}", body_stripped) |
| 220 | + top = "\n".join( |
| 221 | + line |
| 222 | + for line in body_stripped.splitlines() |
| 223 | + if not line.lstrip().startswith(("#", "SEC", "}", "{")) |
| 224 | + ) |
| 225 | + if ARRAY_GLOBAL.search(top) and not re.search( |
| 226 | + r"^char\s+_?_?license", top, re.M | re.I |
| 227 | + ): |
| 228 | + hard.add("array_global") |
| 229 | + elif ARRAY_GLOBAL.search(top): |
| 230 | + # licence/version aside, any other array is still a blocker |
| 231 | + others = [ |
| 232 | + m.group(0) |
| 233 | + for m in ARRAY_GLOBAL.finditer(top) |
| 234 | + if "license" not in m.group(0).lower() |
| 235 | + ] |
| 236 | + if others: |
| 237 | + hard.add("array_global") |
| 238 | + if STRUCT_GLOBAL.search(top) or ANON_STRUCT_GLOBAL.search(top): |
| 239 | + hard.add("struct_global") |
| 240 | + |
| 241 | + # a global written from a static helper etc. is fine; a global at all is |
| 242 | + # informational now that scalars are supported |
| 243 | + if re.search( |
| 244 | + r"^(volatile\s+)?(const\s+)?(volatile\s+)?(__?[us]\d+|u\d+|s\d+|int|long|short|bool|unsigned\s+\w+|uintptr_t|size_t)\s+\w+\s*(=[^=]|;)", |
| 245 | + top, |
| 246 | + re.M, |
| 247 | + ): |
| 248 | + soft.add("scalar_global") |
| 249 | + |
| 250 | + return { |
| 251 | + "file": path.name, |
| 252 | + "bytes": len(raw), |
| 253 | + "sections": sorted(set(secs)), |
| 254 | + "hard": sorted(hard), |
| 255 | + "soft": sorted(soft), |
| 256 | + "detail": detail, |
| 257 | + } |
| 258 | + |
| 259 | + |
| 260 | +def main() -> int: |
| 261 | + ap = argparse.ArgumentParser( |
| 262 | + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter |
| 263 | + ) |
| 264 | + ap.add_argument( |
| 265 | + "progs", type=Path, help="tools/testing/selftests/bpf/progs checkout" |
| 266 | + ) |
| 267 | + ap.add_argument( |
| 268 | + "--json", action="store_true", help="emit one JSON object per program" |
| 269 | + ) |
| 270 | + ap.add_argument( |
| 271 | + "--histogram", action="store_true", help="print the blocker histogram" |
| 272 | + ) |
| 273 | + ap.add_argument( |
| 274 | + "--all", |
| 275 | + action="store_true", |
| 276 | + help="list every program, not only the portable ones", |
| 277 | + ) |
| 278 | + ap.add_argument( |
| 279 | + "--max-hard", |
| 280 | + type=int, |
| 281 | + default=0, |
| 282 | + help="list programs with at most this many hard blockers", |
| 283 | + ) |
| 284 | + args = ap.parse_args() |
| 285 | + |
| 286 | + results = [] |
| 287 | + skipped = 0 |
| 288 | + for c in sorted(args.progs.glob("*.c")): |
| 289 | + r = classify(c) |
| 290 | + if r is None: |
| 291 | + skipped += 1 |
| 292 | + else: |
| 293 | + results.append(r) |
| 294 | + |
| 295 | + if args.json: |
| 296 | + for r in results: |
| 297 | + print(json.dumps(r)) |
| 298 | + return 0 |
| 299 | + |
| 300 | + real = len(results) |
| 301 | + clean = [r for r in results if not r["hard"]] |
| 302 | + print( |
| 303 | + f"{real} real programs ({skipped} shims/fixtures skipped); {len(clean)} with no hard blocker\n" |
| 304 | + ) |
| 305 | + |
| 306 | + if args.histogram: |
| 307 | + hist = Counter(b for r in results for b in r["hard"]) |
| 308 | + for label, n in hist.most_common(): |
| 309 | + print(f" {label:28s} {n:4d} {100 * n / real:4.0f}%") |
| 310 | + print() |
| 311 | + |
| 312 | + rows = ( |
| 313 | + results if args.all else [r for r in results if len(r["hard"]) <= args.max_hard] |
| 314 | + ) |
| 315 | + rows.sort(key=lambda r: (len(r["hard"]), r["bytes"])) |
| 316 | + for r in rows: |
| 317 | + flags = " ".join(r["hard"]) or "-" |
| 318 | + soft = " ".join(r["soft"]) or "-" |
| 319 | + extra = "; ".join(f"{k}={','.join(v)}" for k, v in r["detail"].items()) |
| 320 | + print( |
| 321 | + f"{r['file']:44s} {r['bytes']:6d} hard: {flags:30s} soft: {soft} {extra}" |
| 322 | + ) |
| 323 | + return 0 |
| 324 | + |
| 325 | + |
| 326 | +if __name__ == "__main__": |
| 327 | + sys.exit(main()) |
0 commit comments