diff --git a/app/cosmo/base.toml b/app/cosmo/base.toml index aec1453aee..5c4d7010c1 100644 --- a/app/cosmo/base.toml +++ b/app/cosmo/base.toml @@ -132,7 +132,7 @@ notifications = ["i2c1-irq", "i2c2-irq", "i2c3-irq", "i2c4-irq"] [tasks.packrat] name = "task-packrat" priority = 1 -stacksize = 1696 +stacksize = 1712 start = true task-slots = ["jefe"] features = ["cosmo", "ereport"] @@ -172,7 +172,7 @@ name = "task-hiffy" features = ["h753", "stm32h7", "i2c", "gpio", "spi", "qspi", "hash", "sprot", "turbo"] priority = 7 max-sizes = {flash = 32768, ram = 65536 } -stacksize = 1200 +stacksize = 1232 start = true task-slots = ["sys", "hf", "i2c_driver", "hash_driver", "update_server", "sprot", "net"] notifications = ["timer"] @@ -259,7 +259,7 @@ uses = ["usart6", "dbgmcu"] interrupts = {"usart6.irq" = "usart-irq"} priority = 9 max-sizes = {flash = 76000, ram = 65536} -stacksize = 7632 +stacksize = 7664 start = true task-slots = ["sys", { cpu_seq = "cosmo_seq" }, "hf", "control_plane_agent", "net", "packrat", "i2c_driver", { spi_driver = "spi2_driver" }, "sprot", "auxflash"] notifications = ["jefe-state-change", "usart-irq", "multitimer", "control-plane-agent"] @@ -385,7 +385,7 @@ start = true notifications = ["jefe-state-change", "timer"] task-slots = ["jefe", "spartan7_loader", "packrat", "sensor"] uses = ["mmio_dimms"] -stacksize = 904 +stacksize = 920 [tasks.auxflash] name = "drv-auxflash-server" diff --git a/app/demo-stm32h7-nucleo/app-h743.toml b/app/demo-stm32h7-nucleo/app-h743.toml index d7077f216c..da0d4de5ad 100644 --- a/app/demo-stm32h7-nucleo/app-h743.toml +++ b/app/demo-stm32h7-nucleo/app-h743.toml @@ -152,7 +152,7 @@ priority = 4 max-sizes = {flash = 32768, ram = 2048 } start = true task-slots = ["jefe"] -stacksize = 1200 +stacksize = 1264 extern-regions = [ "sram1", "sram2", "sram3", "sram4" ] [config] diff --git a/app/gimlet/base.toml b/app/gimlet/base.toml index ace1dce1e0..7caa925ddc 100644 --- a/app/gimlet/base.toml +++ b/app/gimlet/base.toml @@ -125,7 +125,7 @@ notifications = ["i2c1-irq", "jefe-state-change"] [tasks.packrat] name = "task-packrat" priority = 1 -stacksize = 1600 +stacksize = 1616 start = true task-slots = ["jefe"] features = ["gimlet", "ereport"] @@ -244,7 +244,7 @@ uses = ["uart7", "dbgmcu"] interrupts = {"uart7.irq" = "usart-irq"} priority = 8 max-sizes = {flash = 75000, ram = 65536} -stacksize = 7280 +stacksize = 7312 start = true task-slots = ["sys", { cpu_seq = "gimlet_seq" }, "hf", "control_plane_agent", "net", "packrat", "i2c_driver", { spi_driver = "spi2_driver" }, "sprot"] notifications = ["jefe-state-change", "usart-irq", "multitimer", "control-plane-agent"] diff --git a/app/gimletlet/app.toml b/app/gimletlet/app.toml index dfd91fd8fa..38aac965c7 100644 --- a/app/gimletlet/app.toml +++ b/app/gimletlet/app.toml @@ -52,7 +52,7 @@ name = "task-packrat" priority = 1 start = true task-slots = ["jefe"] -stacksize = 1480 +stacksize = 1520 features = ["ereport"] notifications = ["task-faulted"] diff --git a/app/grapefruit/base.toml b/app/grapefruit/base.toml index 49e173abf4..6eaeea5f78 100644 --- a/app/grapefruit/base.toml +++ b/app/grapefruit/base.toml @@ -111,7 +111,6 @@ start = true # task-slots is explicitly empty: packrat should not send IPCs! task-slots = [] features = ["grapefruit", "boot-kmdb"] -stacksize = 1496 [tasks.hiffy] name = "task-hiffy" diff --git a/app/grapefruit/standalone.toml b/app/grapefruit/standalone.toml index 18e1c61408..be6a0f0907 100644 --- a/app/grapefruit/standalone.toml +++ b/app/grapefruit/standalone.toml @@ -11,7 +11,7 @@ interrupts = {"uart8.irq" = "usart-irq"} # Ereport stuff [tasks.packrat] -stacksize = 1600 +stacksize = 1616 task-slots = ["jefe"] features = ["ereport"] notifications = ["task-faulted"] diff --git a/app/observer/base.toml b/app/observer/base.toml index 1a495ca1c9..ee30cff5e0 100644 --- a/app/observer/base.toml +++ b/app/observer/base.toml @@ -120,7 +120,7 @@ notifications = ["i2c2-irq", "i2c3-irq", "i2c4-irq"] [tasks.packrat] name = "task-packrat" priority = 1 -stacksize = 1480 +stacksize = 1520 start = true task-slots = ["jefe"] features = ["ereport"] diff --git a/app/psc/base.toml b/app/psc/base.toml index 8a1c203a2b..83c3d984c1 100644 --- a/app/psc/base.toml +++ b/app/psc/base.toml @@ -121,7 +121,7 @@ notifications = ["i2c2-irq", "i2c3-irq"] [tasks.packrat] name = "task-packrat" priority = 1 -stacksize = 1480 +stacksize = 1520 start = true task-slots = ["jefe"] features = ["ereport"] diff --git a/app/sidecar/base.toml b/app/sidecar/base.toml index b4897a4a72..f9f502d0d2 100644 --- a/app/sidecar/base.toml +++ b/app/sidecar/base.toml @@ -272,7 +272,7 @@ notifications = ["timer"] [tasks.packrat] name = "task-packrat" priority = 1 -stacksize = 1480 +stacksize = 1520 start = true task-slots = ["jefe"] features = ["ereport"] diff --git a/build/stack/src/lib.rs b/build/stack/src/lib.rs index a9114999fa..112db5f86c 100644 --- a/build/stack/src/lib.rs +++ b/build/stack/src/lib.rs @@ -2,12 +2,227 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +//! ## Static Stack Analysis +//! +//! The current static analysis implemented works roughly as follows: +//! +//! 1. We configure LLVM to emit `.stack_sizes` debug information using a `-Z` +//! flag, which means that the `elf` file compiled for each task contains a +//! debuginfo section listing the size of stack frames on a per-function +//! basis. +//! 2. We use the `goblin` crate to parse the compiled `elf` files, and obtain: +//! 1. The per-function stack frame sizes: [`extract_stack_sizes_section()`] +//! 2. "Raw" function information per-function, see +//! [`extract_raw_function_data`], containing: +//! 1. The list of all function symbols contained in the elf file +//! 2. The `.text` section of the elf file, which contains executable +//! instructions +//! 3. Information about where data is inlined within the `.text` +//! section +//! 3. For each function in the elf file, we: +//! 1. Use the [Capstone library](https://www.capstone-engine.org/) (by way +//! of the [`capstone`](https://docs.rs/capstone/latest/capstone/) +//! FFI crate) to disassemble the instruction code +//! 2. Iterate through each instruction in the function, and determine all +//! "outgoing" branches which are calls to other functions +//! 3. Produce a report called [`FunctionData`] for that function which +//! contains the name, local stack usage, and a list of all called +//! functions by that function +//! 4. We then start from the entrypoint of the task, `_start`, and recurse +//! through each node, to calculate the deepest stack usage of any chain of +//! function calls. See [`get_max_stack()`] and [`Resolver`]'s `resolve_*` +//! methods. +//! 5. This "max stack" usage is compared against the `stacksize` for the task +//! in the manifest, and compilation fails if the calculated max stack +//! exceeds the written stacksize (outside of this crate). +//! +//! ## Known Limitations +//! +//! This approach already had a couple of known limitations, as they are +//! typical for this approach of static analysis. +//! +//! 1. This approach does not handle recursion, as we have no way to annotate a +//! potential upper bound of recursive iterations. Currently, the code +//! counts the number of direct recursion instances detected (e.g. self-calls +//! of a function), and refuses to resolve call stacks with cycles, UNLESS +//! a member of the cycle is included on the "allowed_recurses" list or +//! "ignored_functions" list, in which case these items are skipped and not +//! counted. +//! 2. This approach does not handle "indirect" branching, which looks something +//! like `blx r5` in assembly, and is often (but not exclusively) generated +//! when calling a function through a vtable method, like `dyn Format`. +//! Currently, the code silently skips considering instances of indirect +//! branching found. +//! 3. This approach does not handle functions without `.stack_sizes` metadata. +//! This is commonly present in assembly functions such as +//! `userlib::sys_send_stub`, or in `compiler-rt`/`llvm` builtins like +//! `__aeabi_memcpy`. These functions are silently assumed to use zero stack. + use std::{ collections::{BTreeMap, BTreeSet}, + ops::Range, path::Path, + rc::Rc, }; use anyhow::{Context, Result, anyhow, bail}; +use capstone::{ + Capstone, InsnDetail, InsnGroupId, InsnGroupType, + arch::{ + ArchDetail, BuildsCapstone, BuildsCapstoneExtraMode, DetailsArchInsn, + arm, + }, +}; +use goblin::elf::{Elf, SectionHeader, Sym}; + +pub const KNOWN_RECURSORS: &[&str] = &[ + // slice_error_fail calls slice_error_fail_rt which calls slice_error_fail + "str::slice_error_fail", + // `smoltcp::iface::interface::InterfaceInner::dispatch_ip::` + // self-recurses. This fragment is a little weird because it looks like: + // + // ``` + // + // ::dispatch_ip:: + // ``` + "dispatch_ip", + // We have limited recursion here, see + // https://github.com/oxidecomputer/hubris/issues/2593 + "Vsc7448Spi", +]; + +pub const KNOWN_TO_IGNORE: &[&str] = &[ + // Our on-target test framework has an explicit "stackblow" function that + // intentionally causes a stack overflow. This unsurprisingly makes the + // max stack analysis upset. + "stackblow", +]; + +/// Configuration for public methods +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Config { + /// Function names containing any of these patterns will be allowed to + /// recurse + pub allowed_recurses: Vec, + + /// Functions names containing any of these patterns will be totally ignored + pub ignored_functions: Vec, +} + +/// Information derived about a given function +#[derive(Debug, Clone)] +pub struct FunctionData { + pub name: String, + pub frame_size: Option, + pub calls: BTreeSet, + pub missing_calls: usize, + pub recursive_calls: usize, +} + +/// The resulting report of all metadata obtained for functions after +/// extracting from the elf, but before attempting to "resolve" or visit each +/// function to determine max stack usage. +pub struct FunctionReport { + pub function_items: BTreeMap, + pub addr_to_frame_size: BTreeMap, +} + +/// A function that has been visited and "filled in" by the [`Resolver`]. +/// +/// Typically stored in an [`Rc`] as each node may appear multiple times across +/// the call graph of a program. +pub struct ResolvedNode { + pub addr: u32, + pub name: String, + pub local_size: Option, + pub max_children: u64, + pub children: BTreeMap>, +} + +/// A tool for turning a [`FunctionReport`] into a hydrated call graph. +/// +/// This visits a call graph of [`FunctionData`] recursively, turning each node +/// into a memoized `Rc`, and keeps a mapping of all nodes by +/// address. +pub struct Resolver { + pub call_stack: Vec, + pub all_resolved: BTreeMap>, + pub fn_items: BTreeMap, + pub config: Config, +} + +/// Describes where a symbol lives. +#[derive(Debug)] +enum SymSection<'a> { + /// Defined in a real section; carries the section name. + Section(&'a str), + /// Undefined (imported / external) — st_shndx == SHN_UNDEF. + Undefined, + /// Absolute value, not tied to a section — SHN_ABS. + Absolute, + /// Common block (tentative definition) — SHN_COMMON. + Common, + /// Real index lives in a SHT_SYMTAB_SHNDX section — SHN_XINDEX. + Extended, +} + +/// "Raw" function data, extracted from the elf metadata and section headers +#[derive(Debug)] +struct RawFunctionData { + /// Demangled name of the function + name: String, + /// Address of the function + base_addr: u32, + /// The size of the function, in bytes, according to the symbol table + symbol_table_size: Option, + /// The calculated size of the function, counting until the next symbol + /// starts (useful when the symbol table is missing size) + calc_size: u64, + /// The address ranges that contain executable code (and not inline data) + text_ranges: Vec, +} + +/// A range of executable "Text" data +#[derive(Debug)] +enum TextRange { + /// An open-ended range starting at the given address + TextStart { start: u32 }, + /// A closed-ended range `start..end`. + TextRange { start: u32, end: u32 }, +} + +/// Private helper type to group up all of the context information we need when +/// processing the raw assembly of each function to figure out which functions +/// are called by this function +struct FunctionCallCollector<'a> { + /// The Capstone disassembler + cs: &'a Capstone, + /// The name of this function + name: &'a str, + /// The base address of this function + base_addr: u32, + /// The range of addresses on the target that contain this function, as well + /// as any data sections within/after this function + function_range: Range, + /// The stack frame size for this function, as reported by LLVM's + /// `.stack_sizes` section + frame_size: Option, + /// Whether to print debugging info + verbose: bool, + /// The number of outgoing calls we were unable to resolve, likely because + /// of indirect addressing via function pointers/dyn Trait + missing_calls: usize, + /// The number of self-recursive calls, e.g. this function directly calling + /// itself + recursive_calls: usize, + /// All outgoing calls from this function + calls: BTreeSet, +} + +//////////////////////////////////////////////////////////////////////////////// +// Public methods +//////////////////////////////////////////////////////////////////////////////// /// Estimates the maximum stack size for the given task /// @@ -16,72 +231,51 @@ use anyhow::{Context, Result, anyhow, bail}; /// there are logically impossible call trees (e.g. `A -> B` and `B -> C`, but /// `B` never calls `C` if called by `A`). pub fn get_max_stack( + config: Config, elf: &Path, - task_name: &str, verbose: bool, ) -> Result> { - // Open the statically-linked ELF file - let data = std::fs::read(elf).context("could not open ELF file")?; - let elf = goblin::elf::Elf::parse(&data)?; + let fns = extract_function_items(elf, verbose, &config)?; + let mut resolver = Resolver::new(fns.function_items, &config); + let node = resolver.resolve_by_name("_start")?; + let chain = node.worst_chain(); + let mut chain_compat = chain + .into_iter() + .map(|n| (n.local_size.unwrap_or(0), n.name.clone())) + .collect::>(); - // Read the .stack_sizes section, which is an array of - // `(address: u32, stack size: unsigned leb128)` tuples - let sizes = build_elf::get_section_by_name(&elf, ".stack_sizes") - .context("could not get .stack_sizes")?; - let mut sizes = &data[sizes.sh_offset as usize..][..sizes.sh_size as usize]; - let mut addr_to_frame_size = BTreeMap::new(); - while !sizes.is_empty() { - let (addr, rest) = sizes.split_at(4); - let addr = u32::from_le_bytes(addr.try_into().unwrap()); - sizes = rest; - let size = leb128::read::unsigned(&mut sizes)?; - addr_to_frame_size.insert(addr, size); + let missing = resolver.find_missing_nodes()?; + + // Find the largest missing item so that we can add it to the call stack as + // a "fudge factor" for unresolved dynamic/indirect dispatch. + let largest = missing.iter().map(|n| n.max_stack()).max().unwrap_or(0); + if let Some(node) = missing.iter().find(|n| n.max_stack() == largest) { + chain_compat.push((largest, format!("missing::{}", node.name))); } - // There are `$t` and `$d` symbols which indicate the beginning of text - // versus data in the `.text` region. We collect them into a `BTreeMap` - // here so that we can avoid trying to decode inline data words. - let mut text_regions = BTreeMap::new(); - for sym in elf.syms.iter() { - if sym.st_name == 0 - || sym.st_size != 0 - || sym.st_type() != goblin::elf::sym::STT_NOTYPE - { - continue; - } + Ok(chain_compat) +} - let addr = sym.st_value as u32; - let is_text = match elf.strtab.get_at(sym.st_name) { - Some("$t") => true, - Some("$d") => false, - Some(_) => continue, - None => { - bail!("bad symbol in {task_name}: {}", sym.st_name); - } - }; - text_regions.insert(addr, is_text); - } - let is_code = |addr| { - let mut iter = text_regions.range(..=addr); - *iter.next_back().unwrap().1 - }; - - // We'll be packing everything into this data structure - #[derive(Debug)] - struct FunctionData { - name: String, - short_name: String, - frame_size: Option, - calls: BTreeSet, - } +/// Load and parse the `elf` file at the given path, producing a report of +/// metadata about all functions in the `elf` file. +pub fn extract_function_items( + elf: &Path, + verbose: bool, + config: &Config, +) -> Result { + // Open the statically-linked ELF file + let data = std::fs::read(elf).with_context(|| { + format!("could not open ELF file: {}", elf.display()) + })?; + let elf = goblin::elf::Elf::parse(&data) + .with_context(|| format!("could not parse {}", elf.display()))?; + + // Get sizes of stack frames by addr from the elf + let addr_to_frame_size = extract_stack_sizes_section(&data, &elf)?; let text = build_elf::get_section_by_name(&elf, ".text") .context("could not get .text")?; - use capstone::{ - Capstone, InsnGroupId, InsnGroupType, - arch::{ArchOperand, BuildsCapstone, BuildsCapstoneExtraMode, arm}, - }; let cs = Capstone::new() .arm() .mode(arm::ArchMode::Thumb) @@ -92,181 +286,701 @@ pub fn get_max_stack( // Disassemble each function, building a map of its call sites let mut fns = BTreeMap::new(); - for sym in elf.syms.iter() { - // We only care about named function symbols here - if sym.st_name == 0 || !sym.is_function() || sym.st_size == 0 { - continue; - } - let Some(name) = elf.strtab.get_at(sym.st_name) else { - bail!("bad symbol in {task_name}: {}", sym.st_name); - }; + let functions = extract_raw_function_data(&elf, text)?; - // Clear the lowest bit, which indicates that the function contains - // thumb instructions (always true for our systems!) - let val = sym.st_value & !1; - let base_addr = val as u32; + for (base_addr, function) in functions { + // This is the stack frame size of the current function + let frame_size = addr_to_frame_size.get(&base_addr).copied(); + let function_range = function.function_range(); - // Get the text region for this function - let offset = (val - text.sh_addr + text.sh_offset) as usize; - let text = &data[offset..][..sym.st_size as usize]; - - // Split the text region into instruction-only chunks - let mut chunks = vec![]; - let mut chunk = None; - for (i, b) in text.iter().enumerate() { - let addr = base_addr + i as u32; - if is_code(addr) { - chunk.get_or_insert((addr, vec![])).1.push(*b); - } else { - chunks.extend(chunk.take()); - } - } - chunks.extend(chunk); // don't forget the trailing chunk! + // Gather up all the information we need to find all the calls in this + // function + let mut fc = FunctionCallCollector { + cs: &cs, + name: &function.name, + base_addr, + function_range, + frame_size, + verbose, + missing_calls: 0, + recursive_calls: 0, + calls: BTreeSet::new(), + }; - let frame_size = addr_to_frame_size.get(&base_addr).copied(); - let mut calls = BTreeSet::new(); - for (addr, chunk) in chunks { - let instrs = cs - .disasm_all(&chunk, addr.into()) - .map_err(|e| anyhow!("disassembly failed: {e:?}"))?; - for (i, instr) in instrs.iter().enumerate() { - let detail = cs.insn_detail(instr).map_err(|e| { - anyhow!("could not get instruction details: {e}") - })?; - - // Detect tail calls, which are jumps at the final instruction - // when the function itself has no stack frame. - let can_tail = frame_size == Some(0) && i == instrs.len() - 1; - if detail.groups().iter().any(|g| { - g == &InsnGroupId(InsnGroupType::CS_GRP_CALL as u8) - || (g == &InsnGroupId(InsnGroupType::CS_GRP_JUMP as u8) - && can_tail) - || g == &InsnGroupId( - InsnGroupType::CS_GRP_BRANCH_RELATIVE as u8, - ) - }) { - let arch = detail.arch_detail(); - let ops = arch.operands(); - let op = ops.last().unwrap_or_else(|| { - panic!("missing operand!"); - }); - - let ArchOperand::ArmOperand(op) = op else { - panic!("bad operand type: {op:?}"); - }; - // We can't resolve indirect calls, alas - let arm::ArmOperandType::Imm(target) = op.op_type else { - continue; - }; - let target = u32::try_from(target).unwrap(); - - // Avoid recursive calls into the same function (or midway - // into the function, which is a thing we've seen before! - // it's weird!) - if !(base_addr..base_addr + sym.st_size as u32) - .contains(&target) - { - calls.insert(target); - } - } - } + // Walk through each "text range", which is an island of executable code + // inside of each function, and collect all the out-bound calls. We + // disassemble ranges rather than functions, as functions might contain + // puddles of inline data which we don't want to (mis)-disassemble. + for chunk in function.text_ranges.iter() { + let (data_addr, data) = chunk.text_data(&data, text)?; + fc.extract_calls(data, data_addr)?; } - let name = rustc_demangle::demangle(name).to_string(); + if fc.recursive_calls != 0 { + let allow_match = config.match_allowed_recurses(fc.name); - // Strip the trailing hash from the name for ease of printing - let short_name = if let Some(i) = name.rfind("::") { - &name[..i] - } else { - &name + if let Some(am) = allow_match { + // For now, pragmatically, we'll just ignore this recursive + // call site. + println!( + "WARN: Allowing {} to self-recurse, matching {}", + fc.name, am + ); + } else { + bail!("Refusing to handle self-recursion of {}", fc.name); + } } - .to_owned(); fns.insert( base_addr, FunctionData { - name, - short_name, + calls: fc.calls, + missing_calls: fc.missing_calls, + recursive_calls: fc.recursive_calls, + name: function.name, frame_size, - calls, }, ); } - fn recurse( - call_stack: &mut Vec, - recurse_depth: usize, - mut stack_depth: u64, - fns: &BTreeMap, - deepest: &mut Option<(u64, Vec)>, - verbose: bool, - ) { - let addr = *call_stack.last().unwrap(); - let Some(f) = fns.get(&addr) else { - panic!("found jump to unknown function at {call_stack:08x?}"); - }; - let frame_size = f.frame_size.unwrap_or(0); - stack_depth += frame_size; - if verbose { - let indent = recurse_depth * 2; - println!( - " {:indent$}{addr:08x}: {} [+{frame_size} => {stack_depth}]", - "", - f.short_name, - indent = indent + Ok(FunctionReport { + function_items: fns, + addr_to_frame_size, + }) +} + +//////////////////////////////////////////////////////////////////////////////// +// impls +//////////////////////////////////////////////////////////////////////////////// + +impl Default for Config { + fn default() -> Self { + Self { + allowed_recurses: KNOWN_RECURSORS + .iter() + .map(|s| s.to_string()) + .collect(), + ignored_functions: KNOWN_TO_IGNORE + .iter() + .map(|s| s.to_string()) + .collect(), + } + } +} + +impl Config { + fn match_allowed_recurses(&self, name: &str) -> Option<&str> { + self.allowed_recurses + .iter() + .find(|am| name.contains(am.as_str())) + .map(String::as_str) + } + + fn match_ignored_functions(&self, name: &str) -> Option<&str> { + self.ignored_functions + .iter() + .find(|am| name.contains(am.as_str())) + .map(String::as_str) + } +} + +impl ResolvedNode { + /// Print the entire stack trace to stdout + pub fn debug_all(&self) { + self.debug_all_depth(0, 0); + } + + /// Print the entire stack trace to stdout, with a given starting depth and + /// current stack usage at the present depth + pub fn debug_all_depth(&self, depth: usize, current_stack: u64) { + let frame_size = self.local_size; + let stack_depth = current_stack + frame_size.unwrap_or(0); + for _ in 0..depth { + print!(" "); + } + println!( + "- 0x{:08X} {} [+{frame_size:?} => {stack_depth}]", + self.addr, self.name, + ); + for (_addr, child) in self.children.iter() { + child.debug_all_depth( + depth + 1, + current_stack + frame_size.unwrap_or(0), ); } + } + + /// Used to determine if `self` could be potentially discarded as a dupe + pub fn is_same_or_child_of(&self, other: &Self) -> bool { + if other.addr == self.addr { + return true; + } + for (_caddr, child) in other.children.iter() { + if self.is_same_or_child_of(child) { + return true; + } + } + false + } + + /// The max stack used by this node, considering this node's stack usage + /// (if known), and the max stack usage of this node's largest child. + pub fn max_stack(&self) -> u64 { + self.local_size.unwrap_or(0) + self.max_children + } + + /// The worst case call chain starting from this node + pub fn worst_chain(self: &Rc) -> Vec> { + let mut chain = vec![]; + self.worst_chain_inner(&mut chain); + chain + } - if deepest - .as_ref() - .map(|(max_depth, _)| stack_depth > *max_depth) - .unwrap_or(true) + /// used for recursively calculating [`Self::worst_chain_inner`] + fn worst_chain_inner(self: &Rc, chain: &mut Vec>) { + chain.push(self.clone()); + if let Some(child) = self + .children + .iter() + .find(|c| c.1.max_stack() == self.max_children) { - *deepest = Some((stack_depth, call_stack.to_owned())); + child.1.worst_chain_inner(chain) } - for j in &f.calls { - if call_stack.contains(j) { - // Skip recursive / mutually recursive calls, because we can't - // reason about them. + } +} + +impl Resolver { + /// Create a new resolver from the given map of [`FunctionData`] items + pub fn new(fn_items: BTreeMap, config: &Config) -> Self { + Self { + call_stack: vec![], + all_resolved: BTreeMap::new(), + fn_items, + config: config.clone(), + } + } + + /// Attempt to resolve a function by name into a [`ResolvedNode`]. + pub fn resolve_by_name(&mut self, entry: &str) -> Result> { + let Some(item) = self.fn_items.iter().find(|(_k, v)| v.name == entry) + else { + bail!("function '{entry}' not found"); + }; + let addr = *(item.0); + self.resolve_addr(addr) + } + + /// Resolve a function by address into a [`ResolvedNode`]. + pub fn resolve_addr(&mut self, addr: u32) -> Result> { + // Have we already resolved this node? + if let Some(node) = self.all_resolved.get(&addr) { + return Ok(node.clone()); + } + + // no, we havent. Get the node info from the fn data + let Some(item) = self.fn_items.get(&addr) else { + bail!("no function data for {addr:08X}"); + }; + self.call_stack.push(addr); + let children = item.calls.clone(); + let name = item.name.clone(); + let local_size = item.frame_size; + + let mut res_children = BTreeMap::new(); + let mut max_children = 0; + for child in children { + // Is this a recursive callsite? + if self.call_stack.contains(&child) { + let Some(child_info) = self.fn_items.get(&child) else { + bail!("{child:08X}: no child function data"); + }; + let allow_match = + self.config.match_allowed_recurses(&child_info.name); + + if let Some(am) = allow_match { + // For now, pragmatically, we'll just ignore this recursive + // call site. + println!( + "WARN: Allowing {} to recurse, matching {}", + child_info.name, am + ); + continue; + } + + bail!( + "Refusing to handle recursion of {}, {:08X?} + {:08X}", + child_info.name, + self.call_stack, + child + ); + } + + // Resolve the child + let rchild = self + .resolve_addr(child) + .with_context(|| format!("While resolving {}", name))?; + + // Is this an ignored function? + let ignore_match = + self.config.match_ignored_functions(&rchild.name); + if let Some(ignore) = ignore_match { + println!("WARN: Ignoring {}, matching {}", rchild.name, ignore); continue; - } else { - call_stack.push(*j); - recurse( - call_stack, - recurse_depth + 1, - stack_depth, - fns, - deepest, - verbose, + } + + // Keep it! + max_children = max_children.max(rchild.max_stack()); + res_children.insert(child, rchild); + } + + let new_node = Rc::new(ResolvedNode { + addr, + name, + local_size, + max_children, + children: res_children, + }); + self.all_resolved.insert(addr, new_node.clone()); + self.call_stack.pop(); + Ok(new_node) + } + + /// Finds all functions *not* already resolved by the given [`Resolver`] and + /// attempts to resolve them. + /// + /// This finds functions that exist in the elf file, but were not visited + /// while resolving the call graph, likely due to being called indirectly or + /// through vtable methods. + fn find_missing_nodes(&mut self) -> Result>> { + // Find all of the functions we know about in `fn_items`, and find + // any that haven't already been resolved into `all_resolved` from + // previous resolution, usually the `_start` entry point + let mut found = self + .fn_items + .keys() + .copied() + .filter(|addr| !self.all_resolved.contains_key(addr)) + .collect::>() // necessary to avoid double borrowing self + .into_iter() + .map(|addr| self.resolve_addr(addr)) + .collect::, _>>()?; + + // Filter out any ignored functions + found.retain(|rn| { + self.config.match_ignored_functions(&rn.name).is_none() + }); + + Ok(found) + } +} + +/// Gets the linking section that the given symbol is a part of. +fn section_of<'a>(elf: &'a Elf<'_>, sym: &Sym) -> SymSection<'a> { + match sym.st_shndx as u32 { + goblin::elf::section_header::SHN_UNDEF => SymSection::Undefined, + goblin::elf::section_header::SHN_ABS => SymSection::Absolute, + goblin::elf::section_header::SHN_COMMON => SymSection::Common, + goblin::elf::section_header::SHN_XINDEX => SymSection::Extended, + idx => { + match elf.section_headers.get(idx as usize) { + Some(shdr) => elf + .shdr_strtab + .get_at(shdr.sh_name) + .map(SymSection::Section) + .unwrap_or(SymSection::Undefined), + None => SymSection::Undefined, // out-of-range / malformed + } + } + } +} + +impl RawFunctionData { + /// Obtain "Raw" function data for a given [`Sym`]. + /// + /// This starts with no text ranges yet, and requires calls to + /// `push_text_*`. + pub fn new(entry: Sym, parsed_elf: &Elf<'_>, base_addr: u32) -> Self { + // Get the name, if there is one in the symbol table, otherwise + // make one up. + let name = if entry.st_name == 0 { + None + } else { + parsed_elf + .strtab + .get_at(entry.st_name) + .map(|n| rustc_demangle::demangle(n).to_string()) + }; + let name = name.unwrap_or_else(|| format!("anon_fn_0x{base_addr:08X}")); + + // Get the size, if there is one + let size = if entry.st_size == 0 { + None + } else { + Some(entry.st_size) + }; + + RawFunctionData { + name, + base_addr, + symbol_table_size: size, + text_ranges: vec![], + + // For now, we don't calculate the size + calc_size: 0, + } + } + + /// Note where a text section ends + pub fn push_text_end(&mut self, mut addr: u32) -> Result<()> { + // We may push the text end when we reach the end of the function. If + // there is padding, this may be a garbage D4D4 instruction inserted by + // the linker! + if let Some(size) = self.symbol_table_size.as_ref() { + addr = addr.min(self.base_addr + (*size as u32)); + } + + match self.text_ranges.last_mut() { + None => { + bail!("Unexpected data/end of fn before text at {addr:08X}") + } + Some(val) => match val { + TextRange::TextStart { start } => { + let start = *start; + *val = TextRange::TextRange { start, end: addr }; + } + TextRange::TextRange { .. } => { + // This could happen if we had text, then data, then the + // next function, because we call `push_text_end` both when + // we see a `$d` record or when the function ends. + } + }, + } + Ok(()) + } + + /// Not where a text section starts + pub fn push_text_start(&mut self, addr: u32) -> Result<()> { + if let Some(TextRange::TextStart { start: old }) = + self.text_ranges.last() + { + bail!("Back to back Text Starts! {old:08X} => {addr:08X}"); + } + self.text_ranges.push(TextRange::TextStart { start: addr }); + Ok(()) + } + + /// Get the range of addresses contained by this function. This does + /// include any inline data. + pub fn function_range(&self) -> Range { + let start = self.base_addr; + let size = if let Some(addr) = self.symbol_table_size.as_ref() { + *addr + } else { + self.calc_size + } as u32; + start..(start + size) + } +} + +impl TextRange { + /// Extract the raw bytes of assembly for this text range + fn text_data<'a>( + &self, + raw_elf: &'a [u8], + text_section: &SectionHeader, + ) -> Result<(u64, &'a [u8])> { + let (start, end) = match self { + TextRange::TextStart { start } => { + bail!("Missing text end: {start:08X}") + } + TextRange::TextRange { start, end } => (*start, *end), + }; + // Get the text region for this function + let start_offset = (start as u64 - text_section.sh_addr + + text_section.sh_offset) as usize; + let end_offset = (end as u64 - text_section.sh_addr + + text_section.sh_offset) as usize; + Ok((start as u64, &raw_elf[start_offset..end_offset])) + } +} + +impl FunctionCallCollector<'_> { + // attempt to extract all outgoing calls from the given chunk of executable + // code within this function + fn extract_calls(&mut self, data: &[u8], data_addr: u64) -> Result<()> { + let instrs = self + .cs + .disasm_all(data, data_addr) + .map_err(|e| anyhow!("disassembly failed: {e:?}"))?; + + let Some((last, all_except_last)) = instrs.split_last() else { + bail!("Empty chunk in {} at {:08X}", self.name, data_addr); + }; + + // See https://www.nmichaels.org/musings/d4d4/d4d4/ + let to_consider = if last.bytes() == [0xD4, 0xD4] { + all_except_last + } else { + &instrs + }; + + // Walk through each instruction inside of each chunk + for (i, instr) in to_consider.iter().enumerate() { + // We need to get details for the instruction, which we should + // always have for well-formed programs + let detail = self.cs.insn_detail(instr).map_err(|e| { + anyhow!("could not get instruction details: {e}") + })?; + self.process_one_instruction( + &detail, + i == (to_consider.len() - 1), + )?; + } + Ok(()) + } + + /// Process a single instruction, and determine if it is making an outgoing + /// call + fn process_one_instruction( + &mut self, + detail: &InsnDetail<'_>, + last_in_chunk: bool, + ) -> Result<()> { + let can_tail = self.frame_size == Some(0) && last_in_chunk; + let is_grp_call = + |g| g == &InsnGroupId(InsnGroupType::CS_GRP_CALL as u8); + let is_grp_jump = + |g| g == &InsnGroupId(InsnGroupType::CS_GRP_JUMP as u8); + let is_grp_rel = + |g| g == &InsnGroupId(InsnGroupType::CS_GRP_BRANCH_RELATIVE as u8); + + // Detect tail calls, which are jumps at the final instruction + // when the function itself has no stack frame. + let is_tail_call = |g| is_grp_jump(g) && can_tail; + let is_branching_instr = detail.groups().iter().any(|g| { + // NOTE: `is_tail_call` was a check before we started checking + // `is_grp_rel`. As of 2026-07-09, James *thinks* the latter check + // is a superset of the former, and we can probably remove it, but + // isn't that sure about it, and hasn't had time to look deeper. + // For now, we leave this as a tombstone to see if we ever observe + // this in practice. + if !is_grp_rel(g) && is_tail_call(g) { + panic!( + "this check is NOT obsolete, please @jamesmunns about this" ); - call_stack.pop(); + } + + is_grp_call(g) || is_grp_rel(g) || is_tail_call(g) + }); + + if is_branching_instr { + // On Arm/Thumb, a jump always has some kind of operand, + // which is where we are jumping to. Get the last operand so + // we can determine if we can follow this. + let arch = detail.arch_detail(); + let ArchDetail::ArmDetail(details) = arch else { + panic!("Unsupported arch"); + }; + let op = details.operands().last().unwrap_or_else(|| { + panic!("jumps on ARM should always have an operand!"); + }); + // We can't resolve indirect calls, alas + // + // TODO: We could consider keeping track of register ops + // and potentially figure out the location of the register + // based jump here + let arm::ArmOperandType::Imm(target) = op.op_type else { + if self.verbose { + println!( + "Failed to resolve indirect call in {}!", + self.name + ); + } + // TODO: we record that we are missing a call, and + // ideally we would plug the worst of the missing funcs + // here. Unfortunately, that means we end up needing to + // invalidate the RC, which would be disappointing. We + // could potentially add an AtomicU64 here to add + // "bonus" items, but that would again invalidate the + // pre-computed "max stack" numbers. + self.missing_calls += 1; + return Ok(()); + }; + let target = u32::try_from(target).unwrap(); + + // Note any recursive calls to the same function, and ignore + // any control flow that directs back inside this function. + // Any other jumps should be recorded as a call. + // + // We'll check this at the end if there were any instances + // of self-recursion + if target == self.base_addr { + self.recursive_calls += 1; + } else if !self.function_range.contains(&target) { + self.calls.insert(target); } } + Ok(()) } +} - // Find stack sizes by traversing the graph - if verbose { - println!("finding stack sizes for {task_name}"); +//////////////////////////////////////////////////////////////////////////////// +// Pulling data from the elf file +//////////////////////////////////////////////////////////////////////////////// + +/// Read the .stack_sizes section, which is an array of +/// `(address: u32, stack size: unsigned leb128)` tuples. +fn extract_stack_sizes_section( + raw_elf: &[u8], + parsed_elf: &Elf<'_>, +) -> Result> { + let sizes = build_elf::get_section_by_name(parsed_elf, ".stack_sizes") + .context("could not get .stack_sizes")?; + let mut sizes = + &raw_elf[sizes.sh_offset as usize..][..sizes.sh_size as usize]; + let mut addr_to_frame_size = BTreeMap::new(); + while !sizes.is_empty() { + let (addr, rest) = sizes.split_at(4); + let addr = u32::from_le_bytes(addr.try_into().unwrap()); + sizes = rest; + let size = leb128::read::unsigned(&mut sizes)?; + addr_to_frame_size.insert(addr, size); + } + + Ok(addr_to_frame_size) +} + +/// Extract "raw" function data, which contains information local to each +/// function, without yet considering call graph data. +fn extract_raw_function_data( + parsed_elf: &Elf<'_>, + text_section: &SectionHeader, +) -> Result> { + // We're going to walk through each of the symbols in the symbol table, + // pulling any item that is part of the `.text` or executable section. + // This section may include multiple entries for the same address, we'll + // explain that more shortly! But for now, "bunch" them into buckets of + // symbols at the same address. We're *also* doing this because the symbols + // may not necessarily be in-order in the symbol table. + let mut buckets = BTreeMap::>::new(); + for sym in parsed_elf.syms.iter() { + // If it's not in the `.text` table, we're not interested! + let section = section_of(parsed_elf, &sym); + let SymSection::Section(".text") = section else { + continue; + }; + + // Mask the lowest bit off, this is because thumb functions are listed + // with this bit set, because that controls the "mode" on jump in arm + // processors. + let base_addr = sym.st_value & !1; + + // Get the existing bucket for this address, or make a new bucket. + let syms = buckets.entry(base_addr as u32).or_default(); + syms.push(sym); + } + + // Great, now we have a bunch of buckets! We expect to see something that + // looks like this, if you were to look at it with `objdump`: + // + // ```text + // 00012078 l .text 00000000 $t + // 00012078 l F .text 00000064 func_one + // 000120d0 l .text 00000000 $d + // 000120dc l .text 00000000 $t + // 000120dc l F .text 00000014 func_two + // ``` + // + // This is a bit harder to read, because it's out of order, as mentioned + // above, but we would expect three buckets in our code now: + // + // * 00012078 with two items ($t and func_one) + // * 000120d0 with one item ($d) + // * 000120dc with two items ($t and func_two) + // + // This might make more sense if we arrange this correctly: + // + // ```text + // 00012078 l F .text 00000064 func_one + // 00012078 l .text 00000000 $t + // 000120d0 l .text 00000000 $d + // 000120dc l F .text 00000014 func_two + // 000120dc l .text 00000000 $t + // ``` + // + // What we actually have here is two functions, `func_one` at 00012078, and + // `func_two` at `000120dc`. `func_one` has three symbols: The one marked + // `F`, which is the actual function symbol, showing it has a length of 64, + // and the name "func_one". Then it has `$t` at the same address, which + // shows that it starts with "executable code" first. Later, it has a `$d` + // symbol, which notes that inline data is here at the end of the function. + // After this, `func_two` begins. + // + // The following code is a small state machine that tries to extract all the + // symbols related to a single function into an aggregated "ElfFunctionData" + // report, which we will later use. + let mut functions = BTreeMap::::new(); + let mut current: Option = None; + + // For each bucket... + for (base_addr, mut syms) in buckets { + // ... while there is still any contents in this bucket... + while !syms.is_empty() { + // ..see if there is a function entry still in this bucket. If there + // is, remove it from the bucket and process it. + if let Some(entry) = { + syms.iter() + .position(|s| s.is_function()) + .map(|idx| syms.remove(idx)) + } { + // Found one, if there was a pending ElfFunctionData, "commit" + // it, using the current address to calculate the size, in case + // the function item was missing "function size" metadata, which + // we have observed with some externally linked static + // libraries, like `salty`. + if let Some(mut ch) = current.take() { + let calc_size = (base_addr - ch.base_addr) as u64; + ch.calc_size = calc_size; + ch.push_text_end(base_addr)?; + functions.insert(ch.base_addr, ch); + } + + // Store off the current chunk + current = + Some(RawFunctionData::new(entry, parsed_elf, base_addr)); + continue; + } + + // We know `syms` is non-empty, take some item in it + let entry = syms.pop().unwrap(); + + // Nope, see if we can extract $d/$t symbols to the current fn. + // We *should* always have a function entry by now, but if not, + // just log a warning. + let Some(current) = current.as_mut() else { + bail!("Discarded {entry:?} at {base_addr:08X}"); + }; + + match parsed_elf.strtab.get_at(entry.st_name) { + Some("$t") => current.push_text_start(base_addr)?, + Some("$d") => current.push_text_end(base_addr)?, + _other => { + // Ignore other symbols, like `_stext`, etc. + } + } + } } - let start_addr = fns - .iter() - .find(|(_addr, v)| v.name.as_str() == "_start") - .map(|(addr, _v)| *addr) - .ok_or_else(|| anyhow!("could not find _start"))?; - let mut deepest = None; - recurse(&mut vec![start_addr], 0, 0, &fns, &mut deepest, verbose); - - // Check against our configured task stack size - let Some((_max_depth, max_stack)) = deepest else { - unreachable!("must have at least one call stack"); - }; - - let mut out = vec![]; - for m in max_stack { - let f = fns.get(&m).unwrap(); - let name = &f.short_name; - out.push((f.frame_size.unwrap_or(0), name.clone())); + + // Finally push the last chunk onto the list + if let Some(mut cur) = current.take() { + // TODO! We need to figure out the end of the section here to fill in + // calculated length! + let text_end = text_section.sh_addr + text_section.sh_size; + cur.calc_size = text_end - cur.base_addr as u64; + cur.push_text_end(text_end as u32)?; + functions.insert(cur.base_addr, cur); } - Ok(out) + + Ok(functions) } diff --git a/build/xtask/src/dist.rs b/build/xtask/src/dist.rs index 259ddcb9ae..2fd2c438ed 100644 --- a/build/xtask/src/dist.rs +++ b/build/xtask/src/dist.rs @@ -1437,7 +1437,10 @@ fn task_can_overflow( .join(&toml.name) .join("dist") .join(format!("{task_name}.tmp")); - let max_stack = get_max_stack(&f, task_name, verbose)?; + let config = build_stack::Config::default(); + let max_stack = get_max_stack(config, &f, verbose).with_context(|| { + format!("Failed getting max stack for task: {task_name}") + })?; let max_depth: u64 = max_stack.iter().map(|(d, _)| *d).sum(); let task_stack_size = toml.tasks[task_name] diff --git a/build/xtask/src/sizes.rs b/build/xtask/src/sizes.rs index 01ae9fc590..40892a019c 100644 --- a/build/xtask/src/sizes.rs +++ b/build/xtask/src/sizes.rs @@ -440,7 +440,8 @@ fn print_task_stacks(toml: &Config) -> Result<()> { .join(&toml.name) .join("dist") .join(format!("{task_name}.tmp")); - let max_stack = get_max_stack(&f, task_name, false)?; + let config = build_stack::Config::default(); + let max_stack = get_max_stack(config, &f, false)?; let total: u64 = max_stack.iter().map(|(n, _)| *n).sum(); println!("{task_name}: {total} bytes (limit is {task_stack_size})"); for (frame_size, name) in max_stack {