Rework max-stack analysis crate, address some limitations#2589
Rework max-stack analysis crate, address some limitations#2589jamesmunns wants to merge 14 commits into
Conversation
I intend to do some rework of the max-stack analysis code, and would like to break out the max-stack analysis into its own crate to make testing this in isolation easier. As this code also depends on the `xtask::elf` module, I broke that out to avoid circular deps. This is the minimum change necessary to split things out before any refactoring.
No functional changes expected.
|
Issues I've found so far: At least some code does meaningfully have recursion cyclesAt least some code is missing proper symbol table metadatathe function gets filtered out because of this // The original |
hawkw
left a comment
There was a problem hiding this comment.
this is mostly style nitpicking because i didn't really understand the old code and i am not sure if i fully understand the new code either.
| /// A function that has been visited and "filled in" by the [`Resolver`]. | ||
| /// | ||
| /// This does not take dynamic function calls into account, which could cause | ||
| /// underestimation. Overestimation is less likely, but still may happen if | ||
| /// 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( | ||
| elf: &Path, | ||
| task_name: &str, | ||
| verbose: bool, | ||
| ) -> Result<Vec<(u64, String)>> { | ||
| // 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)?; | ||
| /// 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, |
There was a problem hiding this comment.
Take it or leave it: it seems like these are (almost?) always being stored in BTreeMaps with the addr as the key. This seems like it might be a perfect candidate for using iddqd::IdOrdMap and implementing IdOrdItem for Rc<ResolvedNode>, rather than std::collections::BTreeMap, so that we can ensure that the value of the addr field stored by the ResolvedNode is always what we use as the key into those maps.
There was a problem hiding this comment.
I'll look into iddqd, or at least clarify why they are separate if I can't do that. The short answer is "these didn't used to be bundled up, it was more sequential, but now I put them all together so you could look at all the intermediary analysis stages", but that's more of an explanation rather than a defense of a good API.
| pub function_items: BTreeMap<u32, FunctionData>, | ||
| pub addr_to_frame_size: BTreeMap<u32, u64>, | ||
| pub names: BTreeMap<u32, String>, |
There was a problem hiding this comment.
do we expect that a given address will always have an entry in all these maps, or not? what is the difference between something having an entry in addr_to_frame_size or names from a FunctionData's name or frame_size values? It would be nice if there was a comment explaining this.
| pub struct Resolver { | ||
| pub call_stack: Vec<u32>, | ||
| pub all_resolved: BTreeMap<u32, Rc<ResolvedNode>>, | ||
| pub fn_items: BTreeMap<u32, FunctionData>, |
There was a problem hiding this comment.
similarly, I wonder if we might want FunctionData to have an addr field, ane make this an IdOrdMap as well.
| pub fn resolve_by_name(&mut self, entry: &str) -> Result<Rc<ResolvedNode>> { | ||
| let Some(item) = self.fn_items.iter().find(|(_k, v)| &v.name == entry) | ||
| else { | ||
| bail!("Not found"); |
There was a problem hiding this comment.
nit: maybe
| bail!("Not found"); | |
| bail!("function `{entry}` not found"); |
|
|
||
| // no, we havent. Get the node info from the fn data | ||
| let Some(item) = self.fn_items.get(&addr) else { | ||
| bail!("no function data"); |
There was a problem hiding this comment.
nit: maybe
| bail!("no function data"); | |
| bail!("no function data for {addr:#x}"); |
| // based jump here | ||
| let arm::ArmOperandType::Imm(target) = op.op_type else { | ||
| if verbose { | ||
| println!( |
There was a problem hiding this comment.
nit: should this perhaps be
| println!( | |
| eprintln!( |
There was a problem hiding this comment.
I think the old code was printing to stdout with --verbose, not stderr, but I will double check.
| // Check if this is now not needed anymore | ||
| if !is_grp_rel(g) && is_tail_call(g) { | ||
| panic!("this check is NOT obsolete!"); | ||
| } |
There was a problem hiding this comment.
will do, I think the new change superceded the old "tail call" check, but wasn't very confident about this. This was a tombstone to tell me if that was in fact not true. I will add a comment or remove this.
| // 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 ops = arch.operands(); | ||
| let op = ops.last().unwrap_or_else(|| { | ||
| let ArchDetail::ArmDetail(details) = arch else { | ||
| panic!("Unsupported arch"); | ||
| }; | ||
| let op = details.operands().last().unwrap_or_else(|| { | ||
| panic!("missing operand!"); |
There was a problem hiding this comment.
nit, take it or leave it: maybe the panic message could be where we say that ARM jumps always have an operand?
| // Avoid recursive calls to the same function, and ignore | ||
| // any control flow that directs back inside this function. |
There was a problem hiding this comment.
nit: maybe this comment ought to be reworded a bit --- "avoid recursive calls" felt confusing to me especially since we are now counting the recursive calls, which feels like a substantially different thing than "avoiding" them...
There was a problem hiding this comment.
This is true, I used to bail! here, until I realized that we couldn't actually do this in practice. Will update the comment.
| let name = &f.short_name; | ||
| out.push((f.frame_size.unwrap_or(0), name.clone())); | ||
| fn name_shortener(name: &str) -> String { | ||
| // Strip the trailing hash from the name for ease of printing |
There was a problem hiding this comment.
are there situations in which this might be collidey?
This is a more targeted, partial fix for #2588, and contains the most immediate relief from the larger refactoring in #2589. This catches the largest "novel" defect: ignoring of relative branches, and raises all the stack numbers to what the new analysis in #2589 shows. This is intended as a stop-gap to prevent seeing stack overflows in test benches. Split into two commits, one with manifest updates, and one with the stack analysis change.
42089bb to
50a9c90
Compare
labbott
left a comment
There was a problem hiding this comment.
I think this is a good approach, I'll take another pass later
| //! 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. |
There was a problem hiding this comment.
Would this also let us detect recursion? I almost want to say we should be disallowing recursion in hubris given the difficulties with stack sizing but I would settle for a warning.
There was a problem hiding this comment.
Unfortunately, I tried to disallow this, but at least tests-stm32g0 does meaningfully have recursion: #2589 (comment)
| //! 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. |
There was a problem hiding this comment.
Insert rant about panic and formatting (I agree we can't fix that here)
Note: this builds on top of #2587, which broke out the relevant functions into a standalone crate.
This PR consists of four main parts:
The 4th part isn't strictly necessary, however while investigating and making myself familiar with how our current code works, it was useful to me to split things out so I could follow what was going on, and restructure the analysis to allow for some more interactive debugging/diffing. I personally think the code is a bit more accessible now, but that could be more personal preference than anything else.
The 1st and 3rd parts are achievable with a much smaller (a few lines) diff. If this PR is too difficult to review, happy to bail on this or at least defer it.
The 2nd part would be more difficult (but still possible) to achieve without the refactorings made in the 4th part, as the new call graph analysis lets us more easily keep track of which functions have been visiting when traversing the graph.
If we merge this PR, I'd consider the "urgent" part of #2588 resolved, though there are still some "known defects", and we might want to increase the baseline stack requirements to handle register stacking (this would bump most task stack sizes in most manifests by 104 bytes, and +36 for gimletlet).