新增 rtl-signal-mcp: RTL 信号追踪 MCP server(索引引擎+8工具+验收脚本)

This commit is contained in:
admin
2026-09-06 09:09:18 +00:00
parent 17a24e1f5e
commit ebad146b83
4 changed files with 879 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
# rtl-signal-mcp
给 AI agent 用的 RTL 信号追踪 MCP Server。解决一个具体问题:**RTL 信号的联系跨模块分布在端口连接上,读单个文件看不出来**。本工具把整个代码库索引成"模块事实库 + 跨边界信号边",agent 查一次得一条紧凑驱动链,不用通读源码、不烧 token。
实测性能:OpenTitan 全 hw 树(31 万行 RTL)→ **971 模块 / 94,891 条跨模块边 / 10.6 秒建索引 / 310MB 内存**;单次追踪查询毫秒级、返回几百字节文本。
## 包含文件
| 文件 | 说明 |
|---|---|
| `rtl_index.py` | 核心引擎:pyslang 解析 → 模块事实库(端口/网络/assign/always/实例化)→ 跨模块边 → 驱动/负载递归查询 |
| `mcp_server.py` | MCP stdio server(纯标准库实现 JSON-RPC 2.0,无 SDK 依赖) |
| `mcp_client_demo.py` | 测试客户端(模拟 agent 完整调用序列,可当验收脚本) |
| `README.md` | 本文件 |
## 8 个工具
| 工具 | 作用 | token 成本 |
|---|---|---|
| `build_index` | 建索引(roots: 文件/目录列表) | 一次性 |
| `list_modules` | 列模块及事实计数 | 极小 |
| `module_summary` | 单模块端口/实例/连接摘要 | 小 |
| `find_signal` | 正则搜信号 → 模块 + file:line | 极小 |
| **`trace_drivers`** | **谁驱动这个信号**(跨模块递归) | **每次几百字节** |
| **`trace_loads`** | **这个信号驱动谁**(上抛+下钻) | 小 |
| `get_source` | 按行取小段源码(≤200 行) | 按需 |
| `hierarchy` | 实例化树 | 小 |
## 接入 Claude / 其他 MCP 客户端
```json
{
"mcpServers": {
"rtl-signal": {
"command": "python3",
"args": ["/path/to/rtl-mcp/mcp_server.py"],
"env": { "RTL_MCP_ROOTS": "/data/repos/e203_hbirdv2/rtl/e203:/data/repos/opentitan/hw/ip" }
}
}
}
```
`RTL_MCP_ROOTS` 冒号分隔多个目录,启动时自动预建索引;不设则由 agent 首次调用 `build_index`
依赖:`pip install pyslang`(仅此一个)。Python ≥ 3.10。
## Agent 使用模式(写进系统提示词即可)
```
调试 RTL 信号问题时按此流程:
1. find_signal 找到信号的归属模块和位置
2. trace_drivers 查驱动链(跨模块),trace_loads 查影响面
3. 只对链上关键条目用 get_source 看 file:line 附近 ±20 行
4. 绝不整文件读源码;结论引用 [kind] where :: sig <- rhs 条目
```
## 已验证范围
- E203 蜂鸟 RISC-V 核(42 文件/39 模块/1789 边/0.2s):`trace_drivers(e203_exu_alu.i_valid)` 正确给出 ALU←EXU 译码器三模块链(含 file:line)
- OpenTitan `hw/ip/aes`67 模块/1037 边)
- OpenTitan `hw/ip + top_earlgrey`841 模块/84,942 边/7s
- OpenTitan 全 hw 树(971 模块/94,891 边/10.6s/310MB
## 已知边界(诚实声明)
- 语法级分析:不做参数展开/elaboration——generate for、parameter 化的端口名按字面处理;位宽/方向冲突不检测
- always 块提取覆盖 always/always_ff/always_comb 内的阻塞与非阻塞赋值(含 if/case 嵌套与时序控制穿透),函数内赋值暂不提取
- 跨模块追踪经实例端口边递归,Max depth 默认 4(可调);未索引的第三方模块(如仅黑盒实例名)标为链尾
- SystemVerilog interface/class/sequence 未索引(面向可综合 RTL 设计)
- 同名信号在不同模块中是不同节点——查询需给 module + signal 两个参数(`find_signal` 可帮定位)
## 路线图(人用的网页版后置)
- v0.2: elaboration 级准确(走 pyslang AST 编译而不是纯语法树),generate/parameter 展开
- v0.3: 波形接入(pylibfst 读 FST,值反标到驱动链 → X 态/根因追踪);参考腾讯开源 wave-mcp
- v0.4: 网页视图(驱动链图形化 + 波形联动),给人 review 用
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""mcp_client_demo.py — rtl-signal-mcp 验收脚本。
不做真 MCP 握手库,直接以子进程方式拉起 mcp_server.py,按行写 JSON-RPC、按行读响应,
模拟一个 agent 的完整调用序列:
initialize -> notifications/initialized -> tools/list
-> build_index -> list_modules -> module_summary -> find_signal
-> trace_drivers -> trace_loads -> get_source -> hierarchy
用法:
python3 mcp_client_demo.py /data/repos/e203_hbirdv2/rtl/e203
python3 mcp_client_demo.py # 缺省用 E203 路径(不存在则报错提示)
退出码 0 = 全部步骤通过;非 0 = 某步失败(打印到 stderr)。
"""
import json, os, subprocess, sys
HERE = os.path.dirname(os.path.abspath(__file__))
SERVER = os.path.join(HERE, "mcp_server.py")
DEFAULT_ROOTS = [
"/data/repos/e203_hbirdv2/rtl/e203", # 本地缺省
]
def start_server(roots):
env = dict(os.environ)
env["RTL_MCP_ROOTS"] = ":".join(roots)
return subprocess.Popen(
[sys.executable, SERVER],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
env=env, text=True, encoding="utf-8", bufsize=1)
def call(proc, method, params=None, msg_id=[0]):
"""写一条请求并读回响应(跳过通知)。返回 result 或抛 RuntimeError。"""
msg_id[0] += 1
req = dict(jsonrpc="2.0", id=msg_id[0], method=method, params=params or {})
proc.stdin.write(json.dumps(req) + "\n")
proc.stdin.flush()
while True:
line = proc.stdout.readline()
if not line:
raise RuntimeError(f"server closed during {method}")
resp = json.loads(line)
if resp.get("id") == msg_id[0]:
if "error" in resp:
raise RuntimeError(f"{method} error: {resp['error']}")
return resp["result"]
def notify(proc, method, params=None):
req = dict(jsonrpc="2.0", method=method, params=params or {})
proc.stdin.write(json.dumps(req) + "\n")
proc.stdin.flush()
def show(title, text, max_lines=12):
print(f"\n=== {title} ===")
lines = text.splitlines()
for ln in lines[:max_lines]:
print(" ", ln)
if len(lines) > max_lines:
print(f" ... ({len(lines) - max_lines} more lines)")
def fail(step, msg):
raise RuntimeError(f"step {step} failed: {msg}")
def main():
roots = [a for a in sys.argv[1:] if not a.startswith("-")] or DEFAULT_ROOTS
missing = [r for r in roots if not os.path.exists(r)]
if missing:
print(f"roots not found: {missing}\n"
f"usage: python3 mcp_client_demo.py <rtl_dir> [...]", file=sys.stderr)
return 2
proc = start_server(roots)
try:
# 1. MCP 握手
r = call(proc, "initialize", dict(
protocolVersion="2024-11-05",
capabilities={},
clientInfo=dict(name="demo-client", version="0.1")))
ver = r["protocolVersion"]
print(f"[1] initialize OK server={r['serverInfo']['name']} "
f"v{r['serverInfo']['version']} protocol={ver}")
notify(proc, "notifications/initialized")
# 2. 工具清单
r = call(proc, "tools/list")
tools = [t["name"] for t in r["tools"]]
print(f"[2] tools/list OK {len(tools)} tools: {', '.join(tools)}")
# 3. 建索引(启动时已用 RTL_MCP_ROOTS 预建过一次,这里验证重复调用幂等)
r = call(proc, "tools/call", dict(name="build_index",
arguments=dict(roots=roots)))
st = r["structuredContent"]
print(f"[3] build_index OK modules={st['modules']} "
f"cross_edges={st['cross_edges']}")
# 4. 模块列表(取第一个模块做后续实验对象)
r = call(proc, "tools/call", dict(name="list_modules", arguments={}))
mods = r["structuredContent"]["modules"]
print(f"[4] list_modules OK total={r['structuredContent']['total']} "
f"first={mods[0]['module']}")
# 5. 挑一个"实例数较多"的模块做摘要
target = max(mods, key=lambda m: m["insts"])["module"]
r = call(proc, "tools/call", dict(name="module_summary",
arguments=dict(module=target)))
show(f"module_summary {target}", r["content"][0]["text"], 8)
# 6. 找一个跨模块信号:用摘要里第一个输出端口
r2 = call(proc, "tools/call", dict(name="module_summary",
arguments=dict(module=target)))
sc = r2["structuredContent"]
port = None
for p in sc.get("ports", []):
# 端口格式是 "Output nice_xs_off" / "Input csr_ena" 这样的字符串
if str(p).startswith("Output"):
port = str(p).split(None, 1)[1]
break
if port is None: # 摘要结构兜底:直接试常见信号
port = "clk"
r = call(proc, "tools/call", dict(name="find_signal",
arguments=dict(pattern=port, limit=5)))
hits = r["structuredContent"]["hits"]
print(f"[6] find_signal '{port}' OK {len(hits)} hits")
# 7. 追驱动链(核心能力)
r = call(proc, "tools/call", dict(name="trace_drivers",
arguments=dict(module=target,
signal=port, depth=4)))
sc = r["structuredContent"]
n = len(sc.get("result", []))
print(f"[7] trace_drivers {target}.{port} OK {n} rows")
show("driver chain", r["content"][0]["text"], 12)
if n == 0 and port != "clk":
# 输出端口可能由子模块驱动也可能本就是顶层输入,空不算失败
print(" (empty chain — top-level input or tb-driven)")
# 8. 追负载
r = call(proc, "tools/call", dict(name="trace_loads",
arguments=dict(module=target,
signal=port, depth=3)))
n = len(r["structuredContent"].get("result", []))
print(f"[8] trace_loads {target}.{port} OK {n} rows")
# 9. 从驱动链里拿一个 file:line 看小段源码
r = call(proc, "tools/call", dict(name="trace_drivers",
arguments=dict(module=target,
signal=port, depth=2)))
rows = r["structuredContent"].get("result", [])
loc = None
import re
for row in rows:
# where 形如 "e203_exu_csr (e203_exu_csr.v:210)"
m = re.search(r"([\w./]+):(\d+)\)\s*$", row.get("where", ""))
if row.get("kind") == "assign" and m:
loc = (m.group(1), int(m.group(2)))
break
if loc:
fname, lineno = loc
r = call(proc, "tools/call", dict(
name="get_source",
arguments=dict(file=fname, start=max(1, lineno - 5),
end=lineno + 5)))
show(f"get_source {fname}:{lineno - 5}-{lineno + 5}",
r["content"][0]["text"], 12)
if "error" in r["structuredContent"]:
fail("get_source", r["structuredContent"]["error"])
else:
print("[9] get_source skipped (no assign location in chain)")
# 10. 层次树
r = call(proc, "tools/call", dict(name="hierarchy",
arguments=dict(module=target, depth=2)))
show("hierarchy", r["content"][0]["text"], 10)
print("\nALL CHECKS PASSED")
return 0
finally:
try:
proc.stdin.close()
except Exception:
pass
proc.terminate()
if __name__ == "__main__":
sys.exit(main())
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""rtl-signal-mcp — MCP server exposing RTL signal-flow tracing to AI agents.
Protocol: MCP over stdio (JSON-RPC 2.0). No third-party MCP SDK required.
Tools (token-efficient by design — every answer is a compact graph, not source):
build_index build/replace the index from file/dir roots
list_modules module names + instantiation counts
module_summary ports / instances / net count of one module
find_signal regex search over all signals -> module + file:line
trace_drivers who drives this signal (recursively, across module boundaries)
trace_loads what does this signal drive (recursively, up and down)
get_source small excerpt of a file by line range
hierarchy instantiation tree under a module
Config: env RTL_MCP_ROOTS="/path/a:/path/b" pre-builds at startup.
"""
import os, sys, json, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", write_through=True)
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", write_through=True)
from rtl_index import RtlIndex
IDX = None # global index
def _need_index():
if IDX is None or not IDX.db:
raise RuntimeError("index empty: call build_index first")
# ---------------- tool implementations ----------------
def t_build_index(args):
global IDX
roots = args.get("roots") or [r for r in os.environ.get("RTL_MCP_ROOTS", "").split(":") if r]
if not roots:
return dict(error="no roots given (pass roots[] or set RTL_MCP_ROOTS)")
t0 = os.times()
IDX = RtlIndex.build(roots)
n_always = sum(len(m["always"]) for m in IDX.db.values())
n_assign = sum(len(m["assigns"]) for m in IDX.db.values())
return dict(modules=len(IDX.db), cross_edges=len(IDX.edges),
assign_facts=n_assign, always_facts=n_always,
note="index in memory; use find_signal to locate, trace_drivers/trace_loads to follow")
def t_list_modules(args):
_need_index()
pat = args.get("pattern")
import re
rx = re.compile(pat, re.I) if pat else None
out = []
for name, m in IDX.db.items():
if rx and not rx.search(name):
continue
out.append(dict(module=name,
ports=len(m["ports"]), insts=len(m["insts"]),
assigns=len(m["assigns"]), always=len(m["always"])))
return dict(modules=out[:80], total=len(out))
def t_module_summary(args):
_need_index()
s = IDX.module_summary(args["module"])
if s is None:
return dict(error=f"module {args['module']} not found")
return s
def t_find_signal(args):
_need_index()
hits = IDX.search(args["pattern"], limit=int(args.get("limit", 20)))
return dict(hits=hits)
def _fmt_trace(rows):
lines = []
for r in rows:
rhs = ", ".join(r["rhs"][:6])
lines.append(f"[{r['kind']}] {r['where']} :: {r['sig']} <- {rhs}")
return lines
def t_trace_drivers(args):
_need_index()
rows = IDX.drivers(args["module"], args["signal"],
max_depth=int(args.get("depth", 4)))
if not rows:
return dict(result=[], text=f"no driver found for {args['module']}.{args['signal']} "
"(top input / testbench-driven / not indexed)")
lines = _fmt_trace(rows)
return dict(result=rows, text="\n".join(lines),
note="read chain bottom-up: leaf entries are the ultimate drivers; "
"use get_source for file:line details")
def t_trace_loads(args):
_need_index()
rows = IDX.loads(args["module"], args["signal"],
max_depth=int(args.get("depth", 3)))
if not rows:
return dict(result=[], text=f"no loads found for {args['module']}.{args['signal']}")
lines = _fmt_trace(rows)
return dict(result=rows, text="\n".join(lines))
_FMAP = None
def _file_map():
"""basename -> absolute path, built lazily by walking indexed roots."""
global _FMAP
if _FMAP is None:
_FMAP = {}
for r in getattr(IDX, "_roots", []) or []:
if os.path.isfile(r):
_FMAP.setdefault(os.path.basename(r), r)
continue
for dp, _dns, fns in os.walk(r):
for fn in fns:
_FMAP.setdefault(fn, os.path.join(dp, fn))
return _FMAP
def t_get_source(args):
path, start, end = args["file"], int(args["start"]), int(args["end"])
end = min(end, start + 200)
if not os.path.isabs(path):
# resolve by basename against indexed roots (files may sit in subdirs)
cand = _file_map().get(os.path.basename(path))
if cand:
path = cand
if not os.path.exists(path):
return dict(error=f"file not found: {path}")
out = []
with open(path, encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f, 1):
if start <= i <= end:
out.append(f"{i:5d}| {line.rstrip()}")
if i > end:
break
return dict(text="\n".join(out))
def t_hierarchy(args):
_need_index()
top = args["module"]
depth = int(args.get("depth", 2))
lines = []
def rec(mod, indent, d, seen):
if d > depth or (mod, indent) in seen or len(lines) > 80:
return
seen.add((mod, indent))
m = IDX.db.get(mod)
if not m:
return
for (itype, iname, conns) in m["insts"]:
lines.append(f"{' ' * (indent + 1)}└─ {iname} : {itype}")
rec(itype, indent + 1, d + 1, seen)
lines.append(top)
rec(top, 0, 0, set())
return dict(text="\n".join(lines))
TOOLS = {
"build_index": (t_build_index, "Build the signal-flow index. roots: list of files or directories to scan (.v/.sv)."),
"list_modules": (t_list_modules, "List indexed modules (name, ports, insts, fact counts). pattern: optional regex."),
"module_summary": (t_module_summary, "Compact summary of one module: port list, instances with connected nets, net names."),
"find_signal": (t_find_signal, "Find signals by regex. Returns module + file:line for each hit."),
"trace_drivers": (t_trace_drivers, "Trace WHO drives a signal — recursive across module boundaries (Verdi-style). Args: module, signal, depth=4."),
"trace_loads": (t_trace_loads, "Trace WHAT a signal drives — loads in same module, flows out output ports, feeds child instances. Args: module, signal, depth=3."),
"get_source": (t_get_source, "Read a small source excerpt (file, start, end; max 200 lines). Use after tracing to see the exact logic."),
"hierarchy": (t_hierarchy, "Instantiation tree under a module. Args: module, depth=2."),
}
TOOLSPEC = [dict(name=n, description=d,
inputSchema=dict(type="object",
properties={},
additionalProperties=True))
for n, (f, d) in TOOLS.items()]
# ---------------- JSON-RPC / MCP plumbing ----------------
def reply(msg_id, result):
sys.stdout.write(json.dumps(dict(jsonrpc="2.0", id=msg_id, result=result)) + "\n")
def reply_error(msg_id, code, message):
sys.stdout.write(json.dumps(dict(jsonrpc="2.0", id=msg_id,
error=dict(code=code, message=message))) + "\n")
def handle(req):
method = req.get("method")
msg_id = req.get("id")
if method == "initialize":
reply(msg_id, dict(protocolVersion="2024-11-05",
capabilities=dict(tools={}),
serverInfo=dict(name="rtl-signal-mcp", version="0.1.0")))
elif method == "notifications/initialized":
pass
elif method == "tools/list":
reply(msg_id, dict(tools=TOOLSPEC))
elif method == "tools/call":
params = req.get("params", {})
name = params.get("name")
args = params.get("arguments", {}) or {}
fn = TOOLS.get(name)
if fn is None:
reply_error(msg_id, -32602, f"unknown tool {name}")
return
try:
res = fn[0](args)
text = res.pop("text", None) or json.dumps(res, ensure_ascii=False)
reply(msg_id, dict(content=[dict(type="text", text=text)],
structuredContent=res, isError=False))
except Exception as e:
reply(msg_id, dict(content=[dict(type="text", text=f"error: {e}")], isError=True))
elif method == "ping":
reply(msg_id, {})
elif msg_id is not None:
reply_error(msg_id, -32601, f"method not supported: {method}")
def main():
if os.environ.get("RTL_MCP_ROOTS"):
try:
res = t_build_index({})
print(f"[rtl-signal-mcp] prebuilt index: {res}", file=sys.stderr)
except Exception as e:
print(f"[rtl-signal-mcp] prebuild failed: {e}", file=sys.stderr)
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except Exception:
continue
try:
handle(req)
except Exception as e:
if req.get("id") is not None:
reply_error(req["id"], -32603, str(e))
if __name__ == "__main__":
main()
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""rtl_index.py — RTL signal-flow index for cross-module tracing.
Builds a module-fact database from Verilog/SystemVerilog sources:
ports (ANSI & non-ANSI), nets, continuous assigns, always-block drivers,
instantiations with named port connections, and cross-boundary edges.
pyslang 11 API. Index build is O(source); queries are in-memory graph walks.
"""
import os, re, json, glob
import pyslang
from pyslang import syntax as sl
IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
ASSIGN_KINDS = ("AssignmentExpression", "NonblockingAssignmentExpression")
def _txt(node):
t = getattr(node, "valueText", "")
return t if t else (str(node).strip() if node is not None else "")
def _syntax_of(val_node):
s = getattr(val_node, "syntax", None)
return s if s is not None else val_node
def ids_from(expr_syntax):
out = []
def walk(n):
if n is None:
return
if n.kind in (sl.SyntaxKind.IdentifierName, sl.SyntaxKind.IdentifierSelectName):
ident = getattr(n, "identifier", None)
if ident is not None:
t = _txt(ident)
if IDENT.match(t):
out.append(t)
# IdentifierSelectName has .target identifier inside
tgt = getattr(n, "target", None)
if tgt is not None:
walk(tgt)
return
for c in n:
if hasattr(c, "__iter__"):
walk(c)
walk(expr_syntax)
return out
def _lhs_names(expr_syntax):
"""names written by an assignment LHS (plain idents only, strip selects)"""
out = []
def walk(n):
if n is None:
return
if n.kind == sl.SyntaxKind.IdentifierName:
t = _txt(n.identifier)
if IDENT.match(t):
out.append(t)
return
for c in n:
if hasattr(c, "__iter__"):
walk(c)
walk(expr_syntax)
return out
class RtlIndex:
def __init__(self):
self.sm = pyslang.SourceManager()
self.db = {} # module -> facts
self.edges = [] # (parent_mod, parent_net, child_mod, child_port, child_dir, inst_name)
self.loc = {} # (module, signal) -> "file:line"
# ---------- build ----------
def add_tree(self, path):
tree = sl.SyntaxTree.fromFile(path, self.sm)
root = tree.root
for m in root.members:
if m.kind == sl.SyntaxKind.ModuleDeclaration:
info = self._scan_module(m)
self.db[info["name"]] = info
def _record_loc(self, module, name, node):
try:
if type(node).__name__ == "Token":
tok = node
else:
tok = node.getFirstToken()
if tok is not None:
self.loc[(module, name)] = (
os.path.basename(self.sm.getFileName(tok.location)),
self.sm.getLineNumber(tok.location))
except Exception:
pass
def _scan_module(self, mod):
name = _txt(mod.header.name)
info = dict(name=name, ports=[], nets=[], assigns=[], always=[], insts=[])
self._record_loc(name, name, mod.header.name)
# ---- ports (ANSI) ----
pl = mod.header.ports
if pl is not None:
for p in pl:
if type(p).__name__ == "Token":
continue
try:
d = getattr(p, "declarator", None)
if d is None or d.name is None:
# explicit port like .name(expr) or non-ansi header decl — skip here
continue
pname = _txt(d.name)
h = p.header
dtok = getattr(h, "direction", None)
dtext = (_txt(dtok).capitalize() if dtok is not None else "In")
info["ports"].append((dtext, pname))
self._record_loc(name, pname, d.name)
except Exception:
continue
# ---- members ----
for m in mod.members:
k = m.kind
if k in (sl.SyntaxKind.DataDeclaration, sl.SyntaxKind.NetDeclaration):
for d in m.declarators:
if type(d).__name__ == "Token":
continue
dn = getattr(d, "name", None)
if dn is not None:
n = _txt(dn)
info["nets"].append(n)
self._record_loc(name, n, dn)
elif k == sl.SyntaxKind.ContinuousAssign:
for a in m.assignments:
if type(a).__name__ == "Token":
continue
lhs_ids = _lhs_names(a.left) if getattr(a, "left", None) is not None else []
rhs_ids = ids_from(a.right) if a.right is not None else []
if lhs_ids:
info["assigns"].append((lhs_ids[0], rhs_ids))
self._record_loc(name, lhs_ids[0], m.getFirstToken())
elif k in (sl.SyntaxKind.AlwaysBlock, sl.SyntaxKind.AlwaysFFBlock,
sl.SyntaxKind.AlwaysCombBlock, sl.SyntaxKind.AlwaysLatchBlock):
self._scan_always(name, m, info)
elif k == sl.SyntaxKind.HierarchyInstantiation:
self._scan_inst(name, m, info)
info["nets"] = sorted(set(info["nets"]))
return info
def _scan_always(self, mod_name, m, info):
if getattr(m, "statement", None) is None:
return
def walk_stmt(s, depth=0):
if s is None or depth > 40:
return
k = s.kind
if k == sl.SyntaxKind.ExpressionStatement:
e = s.expr
ek = str(e.kind)
if any(a in ek for a in ASSIGN_KINDS):
lt = _syntax_of(e.left) if e.left is not None else None
rt = _syntax_of(e.right) if e.right is not None else None
lhs = _lhs_names(lt) if lt is not None else []
rhs = ids_from(rt) if rt is not None else []
if lhs:
info["always"].append((lhs[0], rhs))
self._record_loc(mod_name, lhs[0], s.getFirstToken())
return
# descend through timing/if/for/case wrappers
inner = getattr(s, "statement", None)
if inner is not None and hasattr(inner, "kind"):
walk_stmt(inner, depth + 1)
if k == sl.SyntaxKind.ConditionalStatement:
cons = getattr(s, "statement", None)
alt = getattr(s, "elseStatement", None)
for part in (cons, alt):
if part is not None and hasattr(part, "kind"):
walk_stmt(part, depth + 1)
return
if hasattr(s, "__iter__"):
for c in s:
tn = type(c).__name__
if hasattr(c, "__iter__") and ("Statement" in tn or "Case" in tn
or "Generate" in tn or "Block" in tn):
walk_stmt(c, depth + 1)
walk_stmt(m.statement)
def _scan_inst(self, mod_name, m, info):
tnode = m.type
itype = _txt(tnode.valueText if hasattr(tnode, "valueText") else tnode)
itype = itype.split()[-1] if itype.split() else "?"
for inst in m.instances:
conns = []
try:
for pc in inst.connections:
if type(pc).__name__ == "Token":
continue
nm = getattr(pc, "name", None)
if nm is None:
continue
pname = _txt(nm)
expr_ids = ids_from(pc.expr) if getattr(pc, "expr", None) is not None else []
conns.append((pname, expr_ids))
except Exception:
pass
iname = _txt(inst.decl.name) if inst.decl is not None and inst.decl.name else "?"
info["insts"].append((itype, iname, conns))
# ---------- finalize ----------
def finalize(self):
self.edges = []
for mod in self.db.values():
for (itype, iname, conns) in mod["insts"]:
child = self.db.get(itype)
if not child:
continue
cports = {pn: d for d, pn in child["ports"]}
for (pname, pids) in conns:
cdir = cports.get(pname)
if cdir is None or not pids:
continue
for pid in pids:
self.edges.append((mod["name"], pid, itype, pname, cdir, iname))
# adjacency for speed
self.in_by_child = {}
for e in self.edges:
if e[4].lower().startswith("in"):
self.in_by_child.setdefault((e[2], e[3]), []).append(e)
self.out_by_child = {}
for e in self.edges:
if e[4].lower().startswith("out"):
self.out_by_child.setdefault((e[2], e[3]), []).append(e)
# ---------- queries ----------
def drivers(self, module, sig, max_depth=4, max_nodes=60):
seen, out, rowseen = set(), [], set()
def rec(mod_name, s, depth):
if len(out) >= max_nodes or depth > max_depth or (mod_name, s) in seen:
return
seen.add((mod_name, s))
m = self.db.get(mod_name)
if not m:
return
for lhs, rhs in m["assigns"]:
if lhs == s:
loc = self.loc.get((mod_name, lhs), ("?", "?"))
out.append(dict(kind="assign", where=f"{mod_name} ({loc[0]}:{loc[1]})",
sig=lhs, rhs=rhs[:8]))
for r in rhs[:3]:
rec(mod_name, r, depth + 1)
for lhs, rhs in m["always"]:
if lhs == s:
loc = self.loc.get((mod_name, lhs), ("?", "?"))
out.append(dict(kind="always", where=f"{mod_name} ({loc[0]}:{loc[1]})",
sig=lhs, rhs=rhs[:8]))
for r in rhs[:3]:
rec(mod_name, r, depth + 1)
pdirs = {pn: d for d, pn in m["ports"]}
if str(pdirs.get(s, "")).lower().startswith("in"):
for (pm, pid, cm, cp, cdir, iname) in self.in_by_child.get((mod_name, s), []):
out.append(dict(kind="port-in", where=f"from {pm}.{iname}",
sig=f".{cp}", rhs=[pid]))
rec(pm, pid, depth + 1)
for (itype, iname, conns) in m["insts"]:
for (pname, pids) in conns:
if s in pids:
child = self.db.get(itype)
if child:
cdirs = {pn: d for d, pn in child["ports"]}
if str(cdirs.get(pname, "")).lower().startswith("out"):
out.append(dict(kind="inst-out",
where=f"{mod_name}.{iname} ({itype}.{pname})",
sig=s, rhs=[f"{itype}.{pname}"]))
rec(itype, pname, depth + 1)
rec(module, sig, 0)
return [r for r in out
if not (key := (r['kind'], r['where'], r['sig'], tuple(r['rhs']))) in rowseen and not rowseen.add(key)]
def loads(self, module, sig, max_depth=3, max_nodes=60):
"""who reads sig (same module) and where does it flow (output port / down into insts)"""
seen, out, rowseen = set(), [], set()
def rec(mod_name, s, depth):
if len(out) >= max_nodes or depth > max_depth or (mod_name, s) in seen:
return
seen.add((mod_name, s))
m = self.db.get(mod_name)
if not m:
return
for lhs, rhs in m["assigns"] + m["always"]:
if s in rhs:
loc = self.loc.get((mod_name, lhs), ("?", "?"))
out.append(dict(kind="load", where=f"{mod_name} ({loc[0]}:{loc[1]})",
sig=lhs, rhs=[s]))
rec(mod_name, lhs, depth + 1)
pdirs = {pn: d for d, pn in m["ports"]}
if str(pdirs.get(s, "")).lower().startswith("out"):
for (pm, pid, cm, cp, cdir, iname) in self.out_by_child.get((mod_name, s), []):
out.append(dict(kind="port-out", where=f"into {pm} (as {pid}, via {iname})",
sig=f".{cp}", rhs=[pid]))
rec(pm, pid, depth + 1)
for (itype, iname, conns) in m["insts"]:
for (pname, pids) in conns:
if s in pids:
child = self.db.get(itype)
cdirs = {pn: d for d, pn in child["ports"]} if child else {}
kind = cdirs.get(pname, "?")
out.append(dict(kind=f"inst-load-{kind.lower() or 'unk'}",
where=f"{mod_name}.{iname} -> {itype}.{pname}",
sig=s, rhs=[pname]))
rec(module, sig, 0)
return [r for r in out
if not (key := (r['kind'], r['where'], r['sig'], tuple(r['rhs']))) in rowseen and not rowseen.add(key)]
def module_summary(self, name):
m = self.db.get(name)
if not m:
return None
return dict(name=m["name"],
ports=[f"{d} {p}" for d, p in m["ports"]],
nets=m["nets"][:120],
n_assign=len(m["assigns"]), n_always=len(m["always"]),
insts=[dict(type=t, name=i, ports=[f".{p}({','.join(ids[:3])})" for p, ids in c][:16])
for t, i, c in m["insts"]])
def search(self, pat, limit=20):
rx = re.compile(pat, re.I)
hits = []
for (mod, sig), (f, l) in self.loc.items():
if rx.search(sig) and len(hits) < limit:
hits.append(dict(module=mod, signal=sig, file=f, line=l))
return hits
def save(self, path):
with open(path, "w", encoding="utf-8") as f:
json.dump(dict(modules=list(self.db), n_edges=len(self.edges)), f)
@classmethod
def build(cls, roots):
idx = cls()
files = []
for r in roots:
if os.path.isfile(r):
files.append(r)
else:
files += glob.glob(os.path.join(r, "**", "*.v"), recursive=True)
files += glob.glob(os.path.join(r, "**", "*.sv"), recursive=True)
idx._roots = [os.path.abspath(r) for r in roots]
for f in sorted(set(files)):
try:
idx.add_tree(f)
except Exception:
pass
idx.finalize()
return idx