多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

cargo-call-stack 源码解析指南:用 nom 手写 LLVM IR 解析器,构建全程序调用图

cargo-call-stack 源码解析指南:用 nom 手写 LLVM IR 解析器,构建全程序调用图 cargo-call-stack 源码解析指南用 nom 手写 LLVM IR 解析器构建全程序调用图【免费下载链接】cargo-call-stackWhole program static stack analysis项目地址: https://gitcode.com/gh_mirrors/ca/cargo-call-stackcargo-call-stack是一个 Rust 编写的全程序静态栈使用分析工具它最核心的引擎就是一个用nom组合子库手写的 LLVM IR 解析器。工具先用 nightly 编译器生成 LLVM IR.ll文本再用这个解析器把每个函数定义、调用语句读成内存数据结构最终借助petgraph组装出全程序调用图输出给 Graphviz 渲染。本文带你潜入 Cargo.toml 声明的nom 7.1.3之下看看这个不到 2000 行的解析器是如何分层的、有哪些巧妙和偷懒的设计。为什么不用现成的解析库先看工具的数据流水线cargo nightly call-stack以 fat LTO 重新构建你的程序拿到合并后的 LLVM IR 文本src/ir.rs的入口函数parse()把整篇 IR 解析为VecItem从Item中抽取函数名与签名从函数体中抽取调用边调用边装入petgraph有向图输出.dot文件为什么不直接用inkwell或反汇编二进制因为工具依赖的符号表、-Z stack-sizes信息都以文本形式存在而.ll文件恰好是结构非常规则的文本——手写一个够用的解析器比引入重量级 LLVM 绑定更轻、更可控。这也是本项目最值得关注的设计决策它不需要解析完整的 LLVM IR只需要解析出构建调用图所需的最小子集。解析器分层设计Item → Define → Stmt解析器按 IR 的结构分成三个模块层级关系一目了然模块职责关键类型src/ir.rs顶层入口、基础词法解析parse()、FnSigsrc/ir/item.rs顶层项行级结构Item枚举11 个变体src/ir/define.rs函数定义与函数体内语句Define、Stmtsrc/ir/ty.rs类型系统Type枚举Item枚举item.rs覆盖了 IR 文件的全部顶层结构Alias、Global、Type、Define、Declare、Metadata、ModuleAsm等。顶层item()解析器就是对这些解析函数的一次alt分支尝试pub fn item(i: str) - IResultstr, Item { alt(( comment, source_filename, target, type_, global, alias, map(super::define::parse, Item::Define), declare, attributes, metadata, module_asm, ))(i) }真正有价值的是Define变体。函数体内Stmt枚举define.rs只保留了 7 种语句其中对调用图分析至关重要的是这三种DirectCall(str)—— 直接调用直接给出被调函数名一条确定的边IndirectCall(FnSig)—— 间接调用函数指针、trait 对象动态分发只给出签名Asm(str)—— 内联汇编LLVM 的栈分析对它失明工具需要另行处理也就是说解析阶段就已经完成了降噪解析器只关心会形成调用边的语句其余语句统统归入Stmt::Other丢弃。手写 nom 解析器的几个关键技巧1. 用有意的失败划清词法边界attribute解析器ir.rs处理internal、fastcc等函数属性时遇到double、float、bitcast、alias这类关键词会故意返回错误// have this branch always error because this is not an attribute but part of a type double | float | void | ptr { return Err(nom::Err::Error(error_position!(i, ErrorKind::Switch))); }这样alt组合子会自动回退让类型解析器或语句解析器接手——用解析失败代替显式判断是 nom 中处理词法歧义的惯用手法。2. 够用就好的快捷跳过解析器大量使用not_line_ending直接吞掉整行剩余内容例如declare遇到llvm.开头的内建函数item.rsif name.starts_with(llvm.) { // llvm intrinsic; we dont care about these let i not_line_ending(i)?.0; Ok((i, Item::Declare(Declare { name, sig: None }))) }内建函数不会出现在调用图里所以整行跳过签名记为None。这种局部精确、全局粗略的取舍是嵌入式小工具写解析器的典型思路——不为完备性买单只为下游需求买单。3. 宽松匹配loosely_equal应对类型信息丢失Rust 有u32但 LLVM 只有无符号整数i32新版 LLVM 还使用不透明指针ptr把fn f(x: i32)和impl Foo { fn f(self) }都压成fn(ptr) - i1。因此Type不能简单比较而是实现了loosely_equal()ty.rspub fn loosely_equal(self, other: Self) - bool { match (self, other) { (Self::OpaquePointer, Self::OpaquePointer) true, (Self::OpaquePointer, Self::Pointer(_)) true, (Self::Pointer(_), Self::OpaquePointer) true, // ... } }这正是工具处理间接调用时的核心逻辑把IndirectCall(FnSig)与所有已解析函数的签名做宽松比较把签名兼容的函数全部连边。代价是可能出现多余边详见 README.md 的 Lossy type information 章节收益是不依赖 Rust 类型信息也能工作。4. 精确的报错定位parse()ir.rs把 nom 返回的失败偏移量换算回行号再报错而不是抛出一个无法定位的偏移值——毕竟.ll文件动辄上万行。5. 用真实 IR 片段做测试src/ir/define/目录下有 11 个真实编译器输出的测试文件parse1.ll 到 parse11.ll覆盖内联汇编、间接调用、packed struct 等边缘场景。写解析器时来自真实编译器的测试样例比手写字符串可靠得多。从调用边到全程序调用图解析完成后工具把每条Stmt翻译成图论操作DirectCall连一条确定边IndirectCall按loosely_equal向所有签名匹配的函数连边再结合-Z emit-stack-sizes提供的每个函数本地栈用量做全图最大栈使用量计算最后输出 dot 格式。以示例程序为例工具产出的全程序调用图如下每个节点同时标注local与max栈用量图中Reset同时调用main和DefaultPreInit这正是解析器从define体里抽出的DirectCall边。给新手的可借鉴清单✅组合子思维tag/char拼词法alt/many0/separated_list0/delimited搭结构解析器就是可组合的小函数✅为下游需求裁剪文法不解析完整 IR只保留调用图所需的最小子集用not_line_ending大胆跳过✅失败也是信息用故意返回错误引导alt回退解决词法歧义✅测试用真实输入保存真实编译器输出作为回归测试语料如果你想继续深挖建议按 src/ir.rs → src/ir/item.rs → src/ir/define.rs → src/ir/ty.rs 的顺序通读源码再跑一遍 tests/firmware.rs 的固件级集成测试即可完整复现文本 IR → 全程序调用图的全过程。【免费下载链接】cargo-call-stackWhole program static stack analysis项目地址: https://gitcode.com/gh_mirrors/ca/cargo-call-stack创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表