blob: d63f82ea19092f7fb823bfe6f478211ace5f28cf [file]
#!/usr/bin/env python3
# tools/stackusage.py
#
# SPDX-License-Identifier: Apache-2.0
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership. The
# ASF licenses this file to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance with the
# License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""Static stack usage analyzer using DWARF debug info and disassembly.
Extracts per-function stack frame sizes from DWARF .debug_frame CFA offsets
(via readelf -wF) and builds call graphs from objdump -d disassembly.
Reports worst-case total stack depth through the call chain, flagging
uncertain cases such as recursion and indirect calls (function pointers).
Works with both GNU and LLVM toolchains (readelf/objdump/addr2line).
No Python package dependencies required.
"""
import argparse
import os
import re
import subprocess
import sys
# Call mnemonics across all supported architectures.
# Direct vs indirect is determined by whether objdump annotates
# the target with <function_name>.
CALL_MNEMONICS = {
"bl",
"blx",
"blr", # ARM / AArch64
"call",
"callq", # x86 / x86_64 / SPARC
"jal",
"jalr", # MIPS / RISC-V
"bctrl", # PowerPC
"jmpl", # SPARC
"bsr",
"jsr", # SuperH
"call0",
"call4",
"call8",
"call12", # Xtensa
"callx0",
"callx4",
"callx8",
"callx12", # Xtensa indirect
"calli", # TriCore
}
# Function names that indicate dynamic stack allocation.
ALLOCA_NAMES = frozenset(
{
"alloca",
"__alloca",
"__builtin_alloca",
"__builtin_alloca_with_align",
}
)
def build_symbol_map(prefix, elf):
"""Build address<->name maps by parsing ``readelf -sW`` output.
Returns (addr_to_name, name_to_addr, func_sizes, is_thumb, machine).
"""
out = subprocess.run(
[prefix + "readelf", "-hsW", elf],
capture_output=True,
text=True,
)
if out.returncode != 0:
return {}, {}, {}, False, ""
addr_to_name = {}
name_to_addr = {}
func_sizes = {}
is_thumb = False
machine = ""
for line in out.stdout.splitlines():
# Detect machine type from readelf header
if "Machine:" in line:
machine = line.split(":", 1)[1].strip()
if "ARM" in machine and "AArch64" not in machine:
is_thumb = True
continue
# Symbol table line format (readelf -sW):
# Num: Value Size Type Bind Vis Ndx Name
# 1: 08000100 64 FUNC GLOBAL DEFAULT 1 main
parts = line.split()
if len(parts) < 8:
continue
if parts[3] != "FUNC":
continue
if parts[6] == "UND":
continue
try:
addr = int(parts[1], 16)
size = int(parts[2])
except ValueError:
continue
name = parts[7]
if name.startswith("$"):
continue
if is_thumb:
addr = addr & ~1
if addr in addr_to_name and len(addr_to_name[addr]) >= len(name):
continue
addr_to_name[addr] = name
name_to_addr[name] = addr
func_sizes[addr] = size
return addr_to_name, name_to_addr, func_sizes, is_thumb, machine
def parse_stack_sizes(prefix, elf, addr_to_name, name_to_addr, func_sizes):
"""Parse ``readelf -wF`` output for per-function CFA stack sizes.
Returns funcs dict: name -> {file, line, self, code_size, qualifier,
reasons}.
"""
out = subprocess.run(
[prefix + "readelf", "-wF", elf],
capture_output=True,
text=True,
)
funcs = {}
current_addr = None
max_offset = 0
for line in out.stdout.splitlines():
# FDE header: look for pc=ADDR..ADDR
m = re.search(r"pc=([0-9a-f]+)\.\.", line, re.IGNORECASE)
if m:
# Flush previous FDE
if current_addr is not None:
name = addr_to_name.get(current_addr)
if name and name not in funcs:
code_size = func_sizes.get(current_addr, 0)
if not code_size:
sym_addr = name_to_addr.get(name)
if sym_addr is not None:
code_size = func_sizes.get(sym_addr, 0)
funcs[name] = {
"file": "?",
"line": "?",
"self": max_offset,
"code_size": code_size,
"qualifier": "static",
"reasons": [],
}
current_addr = int(m.group(1), 16)
# Also try with Thumb bit cleared
if current_addr not in addr_to_name:
alt = current_addr & ~1
if alt in addr_to_name:
current_addr = alt
max_offset = 0
continue
# CFA row: extract offset from e.g. "sp+16" or "r13+24"
m = re.search(r"[a-zA-Z]\w*\+(\d+)", line)
if m and current_addr is not None:
val = int(m.group(1))
if val > max_offset:
max_offset = val
# Flush last FDE
if current_addr is not None:
name = addr_to_name.get(current_addr)
if name and name not in funcs:
code_size = func_sizes.get(current_addr, 0)
if not code_size:
sym_addr = name_to_addr.get(name)
if sym_addr is not None:
code_size = func_sizes.get(sym_addr, 0)
funcs[name] = {
"file": "?",
"line": "?",
"self": max_offset,
"code_size": code_size,
"qualifier": "static",
"reasons": [],
}
return funcs
def resolve_sources(prefix, elf, funcs, name_to_addr):
"""Batch-resolve source file:line via ``addr2line -fe``."""
addrs = []
names = []
for name in funcs:
addr = name_to_addr.get(name)
if addr is not None:
addrs.append("%x" % addr)
names.append(name)
if not addrs:
return
out = subprocess.run(
[prefix + "addr2line", "-fe", elf],
capture_output=True,
text=True,
input="\n".join(addrs) + "\n",
)
lines = out.stdout.splitlines()
# addr2line outputs two lines per address: function_name, file:line
for i in range(0, min(len(lines), len(names) * 2), 2):
idx = i // 2
if idx >= len(names):
break
if i + 1 < len(lines):
loc = lines[i + 1]
if loc and loc != "??:0" and loc != "??:?":
parts = loc.rsplit(":", 1)
if len(parts) == 2:
funcs[names[idx]]["file"] = parts[0]
funcs[names[idx]]["line"] = parts[1]
def _sp_pattern(machine):
"""Return a compiled regex matching the stack pointer register.
Architecture-specific SP register names:
ARM: sp, r13
AArch64: sp
x86: %esp, %rsp
MIPS: $sp, $29
RISC-V: sp, x2
Xtensa: a1
PowerPC: r1
SPARC: %sp, %o6
SuperH: r15
TriCore: a10, sp
"""
m = machine.upper()
if "ARM" in m and "AARCH64" not in m:
return re.compile(r"\bsp\b|\br13\b", re.IGNORECASE)
if "AARCH64" in m:
return re.compile(r"\bsp\b", re.IGNORECASE)
if "80386" in m or "X86" in m or "AMD" in m:
return re.compile(r"%[er]sp\b", re.IGNORECASE)
if "MIPS" in m:
return re.compile(r"\$sp\b|\$29\b", re.IGNORECASE)
if "RISC-V" in m:
return re.compile(r"\bsp\b|\bx2\b", re.IGNORECASE)
if "XTENSA" in m or "TENSILICA" in m:
return re.compile(r"\ba1\b", re.IGNORECASE)
if "POWER" in m or "PPC" in m:
return re.compile(r"\br1\b", re.IGNORECASE)
if "SPARC" in m:
return re.compile(r"\bsp\b|%sp\b|%o6\b", re.IGNORECASE)
if "SH" in m or "SUPER" in m or "RENESAS" in m:
return re.compile(r"\br15\b", re.IGNORECASE)
if "TRICORE" in m:
return re.compile(r"\ba10\b|\bsp\b", re.IGNORECASE)
# Fallback: common SP names
return re.compile(r"\bsp\b", re.IGNORECASE)
def _is_dynamic_sp_sub(mnemonic, operand_str, sp_re):
"""Return True if instruction dynamically adjusts the stack pointer.
Detects VLA / alloca patterns where a sub instruction adjusts SP
by a register operand (not an immediate constant).
"""
if not mnemonic.startswith("sub"):
return False
ops = operand_str.lower()
if not sp_re.search(ops):
return False
# ARM/AArch64: immediates use # prefix
if "#" in ops:
return False
# x86 AT&T syntax: $ means immediate
if "%" in ops and "$" in ops:
return False
return True
def parse_call_graph(prefix, elf, machine):
"""Build call graph by parsing ``objdump -d`` output.
Returns (graph, dynamic_funcs) where:
graph: dict mapping caller -> set of (callee_name_or_None, is_indirect).
dynamic_funcs: set of function names with dynamic stack (alloca/VLA).
"""
sp_re = _sp_pattern(machine)
graph = {}
dynamic_funcs = set()
current_func = None
proc = subprocess.Popen(
[prefix + "objdump", "-d", elf],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
for line in proc.stdout:
# Function boundary: "0000abcd <func_name>:"
m = re.match(r"^[0-9a-f]+ <(.+)>:\s*$", line)
if m:
current_func = m.group(1)
graph.setdefault(current_func, set())
continue
if current_func is None:
continue
# Instruction line: " addr: bytes mnemonic operands"
parts = line.split("\t")
if len(parts) < 3:
continue
mnemonic = parts[2].strip().split()[0].lower()
# Extract operand string (used by both call and dynamic-SP checks)
operands = parts[2].strip().split(None, 1)
operand_str = operands[1] if len(operands) > 1 else ""
if len(parts) > 3:
operand_str += " " + parts[3]
# Check for call instructions
if mnemonic in CALL_MNEMONICS:
target_m = re.search(r"<([^>+]+)>", operand_str)
if target_m:
callee = target_m.group(1)
if callee in ALLOCA_NAMES:
dynamic_funcs.add(current_func)
else:
graph[current_func].add((callee, False))
else:
graph[current_func].add((None, True))
continue
# Check for dynamic stack pointer adjustment (VLA / inlined alloca)
if _is_dynamic_sp_sub(mnemonic, operand_str, sp_re):
dynamic_funcs.add(current_func)
proc.wait()
return graph, dynamic_funcs
def compute_worst_stack(funcs, graph, recursion_depth):
"""Compute worst-case total stack for every function via memoized DFS.
Args:
funcs: dict from parse_stack_sizes
graph: dict from parse_call_graph
recursion_depth: how many times a recursive cycle body is counted
(0 = back-edges contribute nothing)
Returns dict mapping function name to result dict with keys:
self, total, uncertain, reasons, stack, self_reasons
"""
cache = {}
def dfs(func, path):
if func in cache:
return cache[func]
info = funcs.get(func)
self_size = info["self"] if info else 0
self_reasons = set(info["reasons"]) if info else {"no DWARF data"}
reasons = set(self_reasons)
uncertain = bool(reasons)
callees = graph.get(func, set())
worst_callee = 0
best_substack = []
for callee, is_indirect in callees:
if is_indirect:
uncertain = True
reasons.add("indirect call (function pointer)")
self_reasons.add("indirect call (function pointer)")
continue
cur_path = path + [func]
if callee in cur_path:
# Recursion detected
uncertain = True
idx = cur_path.index(callee)
cycle = cur_path[idx:] + [callee]
reason = "recursion: %s" % "->".join(cycle)
reasons.add(reason)
self_reasons.add(reason)
if recursion_depth > 0:
cycle_nodes = cycle[:-1]
cycle_cost = sum(
funcs[c]["self"] if c in funcs else 0 for c in cycle_nodes
)
cycle_total = cycle_cost * recursion_depth
if cycle_total > worst_callee:
worst_callee = cycle_total
est_frames = []
for _ in range(recursion_depth):
for node in cycle_nodes:
est_frames.append((node, "recursive estimate"))
best_substack = est_frames
continue
callee_total, callee_unc, callee_reasons, callee_stack, _ = dfs(
callee, cur_path
)
if callee_unc:
uncertain = True
reasons.update(callee_reasons)
if callee_total > worst_callee:
worst_callee = callee_total
best_substack = callee_stack
total = self_size + worst_callee
result = (
total,
uncertain,
reasons,
[(func, None)] + best_substack,
self_reasons,
)
cache[func] = result
return result
results = {}
all_funcs = set(funcs.keys()) | set(graph.keys())
for func in all_funcs:
total, uncertain, reasons, stack, self_reasons = dfs(func, [])
# Skip symbols with no DWARF data and zero stack
if func not in funcs and total == 0:
continue
results[func] = {
"self": funcs[func]["self"] if func in funcs else 0,
"total": total,
"uncertain": uncertain,
"reasons": list(reasons),
"stack": stack,
"self_reasons": list(self_reasons),
}
return results
def reason_marker(reasons):
"""Return a single-char marker for uncertainty.
Marker priority (highest first):
? = no DWARF data
* = dynamic stack (alloca/VLA)
@ = recursion
^ = indirect call (function pointer)
"""
for reason in reasons:
if reason == "no DWARF data":
return "?"
for reason in reasons:
if reason.startswith("dynamic stack"):
return "*"
for reason in reasons:
if reason.startswith("recursion:"):
return "@"
for reason in reasons:
if reason == "indirect call (function pointer)":
return "^"
return ""
def frame_info(funcs, results, frame):
"""Return (size_str, func_name, file:line) for one stack frame.
size_str is prefixed with a marker indicating the function's own
uncertainty type (see reason_marker). func_name includes the code
size in parentheses when available, e.g. ``main(128)``.
"""
func, _note = frame
info = funcs.get(func)
res = results.get(func)
if info:
self_reasons = res["self_reasons"] if res else info["reasons"]
prefix = reason_marker(self_reasons)
code_size = info.get("code_size", 0)
name = "%s(%d)" % (func, code_size) if code_size else func
return (
"%s%d" % (prefix, info["self"]),
name,
"%s:%s" % (info["file"], info["line"]),
)
return ("?", func, "?:?")
def format_text(results, funcs, rank):
"""Format results as aligned table with deepest call stack frames."""
# Compute common path prefix to shorten File:Line column
paths = [
f["file"]
for f in funcs.values()
if f["file"] not in ("?", "") and f["file"].startswith("/")
]
if paths:
prefix = os.path.commonpath(paths)
if not prefix.endswith("/"):
prefix += "/"
else:
prefix = ""
maxname = 40
items = sorted(results.items(), key=lambda x: x[1]["total"], reverse=True)
if rank > 0:
items = items[:rank]
hdr = ("Total", "Self", "Backtrace", "File:Line")
w = list(map(len, hdr))
rows = []
for func, info in items:
frames = [frame_info(funcs, results, f) for f in info["stack"]]
# Strip common prefix from file paths
if prefix:
plen = len(prefix)
frames = [
(
sz,
fn,
loc[plen:] if loc.startswith(prefix) else loc,
)
for sz, fn, loc in frames
]
# Derive Total marker from the displayed stack frames' own reasons,
# not from all propagated reasons across every branch.
stack_reasons = []
for f, _note in info["stack"]:
res = results.get(f)
if res:
stack_reasons.extend(res["self_reasons"])
elif f not in funcs:
stack_reasons.append("no DWARF data")
marker = reason_marker(stack_reasons) if info["uncertain"] else ""
total_str = "%s%d" % (marker, info["total"])
w[0] = max(w[0], len(total_str))
for sz, fn, loc in frames:
w[1] = max(w[1], len(sz))
w[2] = max(w[2], min(len(fn), maxname))
w[3] = max(w[3], len(loc))
rows.append((total_str, frames))
def trunc(s):
return s[: maxname - 3] + "..." if len(s) > maxname else s
fmt = "%%%ds %%%ds %%-%ds %%s" % (w[0], w[1], w[2])
lines = [fmt % hdr]
lines.append(fmt % ("-" * w[0], "-" * w[1], "-" * w[2], "-" * w[3]))
for total_str, frames in rows:
for i, (sz, fn, loc) in enumerate(frames):
if i == 0:
lines.append(fmt % (total_str, sz, trunc(fn + "~"), loc))
else:
lines.append(fmt % ("", sz, trunc(fn), loc))
if not frames:
lines.append(fmt % (total_str, "", "", ""))
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Analyze per-function stack usage from DWARF debug info, "
"combined with disassembly-based call graph analysis to compute "
"worst-case total (self + callees) stack depth.",
)
parser.add_argument(
"elf",
help="path to ELF file with DWARF debug info",
)
parser.add_argument(
"-p",
"--prefix",
default="",
help="toolchain prefix (e.g. arm-none-eabi- or llvm-)",
)
parser.add_argument(
"-n",
"--rank",
type=int,
default=0,
help="show top N functions (default: 0 = all)",
)
parser.add_argument(
"-r",
"--recursion-depth",
type=int,
default=0,
help="assumed recursion depth for stack estimation (default: 0). "
"When > 0, recursive cycle cost is multiplied by this value.",
)
args = parser.parse_args()
if not os.path.isfile(args.elf):
print("ELF file not found: %s" % args.elf, file=sys.stderr)
sys.exit(1)
# Phase 1: Symbol table
addr_to_name, name_to_addr, func_sizes, is_thumb, machine = build_symbol_map(
args.prefix,
args.elf,
)
if not addr_to_name:
print("No function symbols found in ELF", file=sys.stderr)
sys.exit(1)
# Phase 2: Stack sizes from DWARF .debug_frame
funcs = parse_stack_sizes(
args.prefix,
args.elf,
addr_to_name,
name_to_addr,
func_sizes,
)
if not funcs:
print(
"No stack size data found in DWARF .debug_frame",
file=sys.stderr,
)
sys.exit(1)
# Phase 3: Source locations via addr2line
resolve_sources(args.prefix, args.elf, funcs, name_to_addr)
# Phase 4: Call graph via objdump
graph, dynamic_funcs = parse_call_graph(args.prefix, args.elf, machine)
# Mark functions with dynamic stack allocation (alloca/VLA)
for name in dynamic_funcs:
if name in funcs:
funcs[name]["reasons"].append("dynamic stack (alloca/VLA)")
results = compute_worst_stack(funcs, graph, args.recursion_depth)
print(format_text(results, funcs, args.rank))
if __name__ == "__main__":
main()