Files
coven/modules/ufbx/misc/analyze_stack.py

415 lines
13 KiB
Python

from typing import NamedTuple, List, Mapping, Optional, Tuple, Set, DefaultDict
from collections import defaultdict
import pickle
import argparse
import os
import io
import re
import pcpp
from pycparser import c_ast, CParser
g_failed = False
def verbose(msg):
print(msg, flush=True)
def error(msg):
global g_failed
print(f"\n{msg}", flush=True)
g_failed = True
fake_includes = { }
fake_includes["stdint.h"] = """
#pragma once
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long uint64_t;
typedef signed char int8_t;
typedef signed short int16_t;
typedef signed int int32_t;
typedef signed long int64_t;
"""
fake_includes["stdbool.h"] = """
#pragma once
#define bool _Bool
#define true 1
#define false 0
"""
fake_includes["stddef.h"] = """
typedef unsigned long size_t;
typedef unsigned long uintptr_t;
typedef long ptrdiff_t;
"""
fake_includes["stdarg.h"] = """
typedef int va_list;
"""
fake_includes["stdio.h"] = """
typedef int FILE;
typedef unsigned long fpos_t;
"""
fake_includes["string.h"] = ""
fake_includes["stdlib.h"] = ""
fake_includes["locale.h"] = ""
fake_includes["math.h"] = ""
fake_includes["assert.h"] = ""
class Preprocessor(pcpp.Preprocessor):
def __init__(self):
super().__init__()
def on_file_open(self, is_system_include, includepath):
if is_system_include and os.path.basename(includepath) in fake_includes:
return io.StringIO(fake_includes[os.path.basename(includepath)])
return super().on_file_open(is_system_include, includepath)
class ValuePreprocessor(pcpp.Preprocessor):
def __init__(self, func: str, expr: str):
super().__init__()
self.func = func
self.expr = expr
def on_unknown_macro_in_expr(self,tok):
raise RuntimeError(f"Bad recursion bound on function {self.func}: '{self.expr}'")
class FuncInfo:
stack_usage: int
max_recursion: Optional[int]
calls: Set[str]
defined: bool
recursion_stack: List[str]
def __init__(self):
self.stack_usage = 0
self.max_recursion = None
self.calls = set()
self.defined = False
self.recursion_stack = []
self.largest_call = ""
class StackUsage(NamedTuple):
usage: int
largest_call: str
class File:
functions: DefaultDict[str, FuncInfo]
stack_usage: Mapping[str, StackUsage]
addresses: Set[str]
max_dynamic_usage: int
recursion_errors: Mapping[str, List[str]]
def __init__(self):
self.functions = defaultdict(FuncInfo)
self.stack_usage = {}
self.addresses = set()
self.max_dynamic_usage = 0
self.recursion_errors = {}
class AstVisitor(c_ast.NodeVisitor):
file: File
current_func: str
def __init__(self, file: File):
self.file = file
self.current_func = None
def visit_FuncDef(self, node: c_ast.FuncDecl):
if node.body:
func = node.decl.name
if not (func.startswith("ufbx_") or func.startswith("ufbxi_")):
error(f"Bad function definition: {func}")
self.file.functions[func].defined = True
self.current_func = node.decl.name
self.visit(node.body)
self.current_func = None
def visit_UnaryOp(self, node: c_ast.UnaryOp):
if node.op == "&" and isinstance(node.expr, c_ast.ID):
self.file.addresses.add(node.expr.name)
self.visit(node.expr)
def visit_FuncCall(self, node: c_ast.FuncCall):
src = self.current_func
if src:
dst = node.name.name
if (isinstance(dst, str) and (dst.startswith("ufbxi_") or dst.startswith("ufbx_"))):
self.file.functions[src].calls.add(dst)
if node.args:
self.visit(node.args)
def get_stack_usage_to(file: File, func: str, target: str, seen: Set[str] = set()) -> Optional[Tuple[int, List[str]]]:
if func in seen:
return (0, []) if func == target else None
info = file.functions.get(func)
if not info:
raise RuntimeError(f"Function not found: {func}")
seen = seen | { func }
max_path = None
for call in info.calls:
path = get_stack_usage_to(file, call, target, seen)
if path is not None:
if max_path:
max_path = max(max_path, path)
else:
max_path = path
if max_path:
max_usage, stack = max_path
usage = info.stack_usage + max_usage
return (usage, [func] + stack)
else:
return None
def add_ignore(ignores: str, ignore: str) -> str:
if not ignores: ignores = "/"
parts = ignores[1:].split(",") + [ignore]
return "/" + ",".join(sorted(p for p in parts if p))
def is_ignored(ignores: str, func: str) -> bool:
return ignores and func in ignores[1:].split(",")
def get_stack_usage(file: File, func: str, ignores: str = "", stack: List[str] = []) -> StackUsage:
if is_ignored(ignores, func):
return StackUsage(-1, "")
key = f"{func}{ignores}" if ignores else func
existing = file.stack_usage.get(key)
if existing is not None: return existing
info = file.functions.get(func)
if not info:
raise RuntimeError(f"Function not found: {func}")
if info.max_recursion:
rec_path = get_stack_usage_to(file, func, func)
if rec_path is None:
error(f"Unnecessary recursion tag in {func}()\nContains ufbxi_assert_max_recursion() but could not find recursive path to itself")
rec_path = (0, [])
rec_usage, rec_stack = rec_path
info.recursion_stack = rec_stack
self_usage = rec_usage * (info.max_recursion - 1) + info.stack_usage
child_ignores = add_ignore(ignores, func)
stack = []
else:
self_usage = info.stack_usage
child_ignores = ignores
if func in stack:
pos = stack.index(func)
error_stack = stack[pos:] + [func]
prev_error = file.recursion_errors.get(func)
if not prev_error or len(prev_error) > len(error_stack):
file.recursion_errors[func] = error_stack
return StackUsage(0, "")
stack = stack + [func]
max_usage = StackUsage(0, "")
for call in info.calls:
usage = get_stack_usage(file, call, child_ignores, stack).usage
max_usage = max(max_usage, StackUsage(usage, f"{call}{child_ignores}"))
usage = StackUsage(self_usage + max_usage.usage, max_usage.largest_call)
file.stack_usage[key] = usage
return usage
def parse_file(c_path: str, su_path: str, cache_path: Optional[str]) -> File:
pp = Preprocessor()
pp.define("UFBX_STANDARD_C")
pp.define("UFBXI_ANALYSIS_PARSER")
pp.define("UFBXI_ANALYSIS_RECURSIVE")
if cache_path and os.path.exists(cache_path) and os.path.getmtime(cache_path) > os.path.getmtime(c_path):
verbose(f"Loading AST cache: {cache_path}")
with open(cache_path, "rb") as f:
ast, max_recursions = pickle.load(f)
else:
max_recursions = { }
verbose(f"Preprocessing C file: {c_path}")
pp_stream = io.StringIO()
with open(c_path) as f:
pp.parse(f.read(), "ufbx.c")
pp.write(pp_stream)
pp_source = pp_stream.getvalue()
re_recursive_function = re.compile(r"UFBXI_RECURSIVE_FUNCTION\s*\(\s*(\w+),\s*(.+)\s*\);")
for line in pp_source.splitlines():
m = re_recursive_function.search(line)
if m:
name, rec_expr = m.groups()
lit_pp = ValuePreprocessor(name, rec_expr)
toks = lit_pp.tokenize(rec_expr)
rec_value, _ = lit_pp.evalexpr(toks)
if not rec_value:
raise RuntimeError(f"Bad recursion bound on function {name}: '{rec_expr}'")
max_recursions[name] = rec_value
pp_source = re_recursive_function.sub("", pp_source)
verbose("Parsing C file")
parser = CParser()
ast = parser.parse(pp_source)
if cache_path:
verbose(f"Writing AST cache: {cache_path}")
with open(cache_path, "wb") as f:
pickle.dump((ast, max_recursions), f)
verbose("Visiting AST")
file = File()
visitor = AstVisitor(file)
visitor.visit(ast)
# Gather maximum recursion from file
for func, rec in max_recursions.items():
file.functions[func].max_recursion = rec
if su_path:
verbose(f"Reading stack usage file: {su_path}")
with open(su_path) as f:
for line in f:
line = line.strip()
if not line: continue
m = re.match(r".*:\d+:(?:\d+:)?(\w+)(?:\.[a-z0-9\.]+)?\s+(\d+)\s+([a-z,]+)", line)
if not m:
raise RuntimeError(f"Bad .su line: {line}")
func, stack, usage = m.groups()
assert usage in ("static", "dynamic,bounded")
file.functions[func].stack_usage = int(stack)
return file
def get_max_dynamic_usage(file: File) -> Tuple[int, str]:
max_dynamic_usage = (0, "")
addr_funcs = file.addresses & set(file.functions.keys())
for func in addr_funcs:
usage = get_stack_usage(file, func).usage
max_dynamic_usage = max(max_dynamic_usage, (usage, func))
return max_dynamic_usage
def dump_largest_stack(file: File, func: str) -> List[str]:
usage = file.stack_usage[func]
print(f"{func}() {usage.usage} bytes")
index = 0
while True:
if "/" in func:
raw_func, _ = func.split("/")
else:
raw_func = func
info = file.functions.get(raw_func)
stack_usage = file.stack_usage.get(func)
if not info: break
if info.recursion_stack:
rec_usage = 0
for ix, frame in enumerate(info.recursion_stack):
rec_info = file.functions[frame]
ch = "|" if ix > 0 else ">"
fn = f"{frame}()"
usage = f"+{rec_info.stack_usage} bytes"
rec_usage += rec_info.stack_usage
print(f"{ch}{index:3} {fn:<40}{usage:>14}")
index += 1
rec_extra = info.max_recursion - 1
usage = f"(+{rec_usage * rec_extra} bytes)"
prefix = f"(recursion {info.max_recursion} times)"
index += rec_extra * len(info.recursion_stack)
dots = "." * len(str(index - 1))
print(f"|{dots:>3} {prefix:<39}{usage:>16}")
fn = f"{raw_func}()"
usage = f"+{info.stack_usage} bytes"
print(f"{index:4} {fn:<40}{usage:>14}")
func = stack_usage.largest_call
index += 1
if __name__ == "__main__":
parser = argparse.ArgumentParser("analyze_stack.py")
parser.add_argument("su", nargs="?", help="Stack usage .su file")
parser.add_argument("--source", help="Path to ufbx.c")
parser.add_argument("--cache", help="Cache file to use")
parser.add_argument("--limit", help="Maximum stack usage in bytes")
parser.add_argument("--no-su", action="store_true", help="Allow running with no .su file")
argv = parser.parse_args()
if argv.su:
su_path = argv.su
elif not argv.no_su:
raise RuntimeError("Expected an .su file as a positional argument")
else:
su_path = ""
if argv.source:
c_path = argv.source
else:
c_path = os.path.relpath(os.path.join(os.path.dirname(__file__), "..", "ufbx.c"))
file = parse_file(c_path, su_path, argv.cache)
if su_path:
file.max_dynamic_usage = get_max_dynamic_usage(file)
max_usage = (0, "")
for func in file.functions:
usage = get_stack_usage(file, func)
max_usage = max(max_usage, (usage.usage, func))
if argv.limit:
limit = int(argv.limit, base=0)
total = max_usage[0] + file.max_dynamic_usage[0]
if total >= limit:
error(f"Stack overflow in {max_usage[1]}: {max_usage[0]} bytes + {file.max_dynamic_usage[0]} dynamic\noverflows limit of {limit} bytes")
print("\nLargest stack:")
dump_largest_stack(file, max_usage[1])
print("\nLargest dynamic stack:")
dump_largest_stack(file, file.max_dynamic_usage[1])
for func, stack in file.recursion_errors.items():
stack_str = "\n".join(f"{ix:3}: {s}()" for ix, s in enumerate(stack))
error(f"Unbounded recursion in {func}()\nStack trace:\n{stack_str}")
if not g_failed:
interesting_functions = [
"ufbx_load_file",
"ufbx_evaluate_scene",
"ufbx_subdivide_mesh",
"ufbx_tessellate_nurbs_surface",
"ufbx_tessellate_nurbs_curve",
"ufbx_evaluate_transform",
"ufbx_generate_indices",
"ufbx_inflate",
"ufbx_triangulate_face",
]
for func in interesting_functions:
print()
dump_largest_stack(file, func)
print()
print("Largest potentially dynamically called stack:")
dump_largest_stack(file, file.max_dynamic_usage[1])
else:
print("Skipping further tests due to no .su file specified")
if g_failed:
exit(1)
else:
print()
print("Success!")