はじめに
先日のROSCon JP 2026のライトニングトークで、このツールを紹介しました。
……が、「ライトニング」の名に忠実過ぎました。
久しぶりの口頭発表で心臓バクバクしてまして、なぜこれが必要なのかも、中で何をしているのかも、ほとんど伝えられないまま終了。
会場で「で、結局あれは何だったんだ」となった方も多かったと思います。さすがにあれで終わりにするのは酷いので、あらためて書き直したのがこの記事です。
ROS 2のノードが持つcallbackとCallbackGroupの構造を、ビルドせず・ROS 2環境なしで・実行せずにソースコードから抽出するCLIツール cie-inspector を作りました。
$ cie-inspector extract ~/ros2_ws/src --format table
NODE GROUP TYPE MEMBERS
---------------------- ------------- ----------------- -------
component_pkg/listener default_group MutuallyExclusive 1
demo_pkg/talker sensor_group_ MutuallyExclusive 1
worker_group_ Reentrant 1
default_group MutuallyExclusive 3
2 node(s), 4 group(s), 6 member(s), 0 unresolved item(s)
この記事では、なぜこれが必要だったのか、既存ツールで代替できないか調べた結果、そしてtree-sitterでC++を解析する際にハマった点を書きます。
なお、実装にはClaude Codeを使っています。どこまでを人間が決めて、どこからを任せたのかは末尾の「この記事とツールについて」に正直に書きました。
もうひとつ先に白状しておくと、実プロジェクトでの評価はまだできていません。試していただく際の前提と、既知の弱点は「動作検証の状況」にまとめてあります。バグ報告・PRは大歓迎です。
なぜ作ったか
CallbackIsolatedExecutor (CIE)
TIER IV / Autoware Foundationが公開している CallbackIsolatedExecutor(以下CIE)は、CallbackGroupごとに専用のOSスレッドを割り当てるrclcpp Executorです。これにより、Linuxのスケジューリング属性(SCHED_FIFOなどのpolicy、priority、CPU affinity)をcallback単位で適用できます。
設定は cie_thread_configurator が読むYAML 1枚で行います。
callback_groups:
- id: /camera_node@Subscription(/camera/image)
policy: SCHED_FIFO
priority: 90
affinity: [0, 1]
- id: /planning_node@Timer(100000000)
policy: SCHED_DEADLINE
runtime: 5000000
deadline: 50000000
period: 100000000
affinity: [4]
id のフォーマットは /<node_name>@<Callback1>@<Callback2>... で、callbackの種類は Subscription(<topic>) / Service(<name>) / Client(<name>) / Timer(<period_ns>) です(YAML仕様)。
課題:「どのグループに何があるか」を知る手段が実行ベースしかない
上のYAMLを書くには、当然ながらどのノードにどんなcallbackがあり、どのCallbackGroupに属するかを事前に知っている必要があります。
ところが現状、この情報を設定ファイルのYAMLにするには、CIEのprerunを使用するしか方法がありません。
「実行して調べる」ということは組込みや大規模なソフトではでは実行自体が重いうえ、そもそも設定を書きたいのは実行の前なのに、情報は実行の後にしか手に入りません。「どのcallbackをどう分離するか」を設計している段階で、肝心の構造が見えないわけです。
もちろん、設計をきっちりやって、YAMLをprerunに頼らずに書くという方法もあります。
というかそっちが正しいのでしょう。Afinityや優先度の設計ができることこそ、CIEの一番の価値だと思いますし。が、私は怠け者なので、どうせなら自動化したい。
ソースコードには答えが書いてるはずなので、それを読む道具がない。
ならば作ろう、というのが出発点です。
既存実装の調査
作る前に「同じことをするツールが既にないか」を調べました。結論としてはCallbackGroup粒度でビルド不要・実行不要という組み合わせを満たすものは見つかりませんでしたが、近いものはいくつかあるので共有します。
Autoware / TIER IV本家
callback_isolated_executor のリポジトリは callback_isolated_executor / cie_config_msgs / cie_sample_application / cie_thread_configurator の4パッケージ構成で、すべて実行時のものです。設定YAMLのテンプレート生成はprerunノードを起動して対象アプリを走らせる方式で、ソースから静的に生成する仕組みはありません。
CARET はcallback単位の性能解析ができますが、トレースポイントを仕込んだビルド + 実走行が前提です。
学術・OSSの静的解析ツール
-
HAROS(論文): ROSの計算グラフを静的抽出する代表格。ただし主にROS 1向けで、C++解析はlibclangベースのため
compile_commands.json(=ビルド設定)が必須。抽出粒度はノード/トピック/サービスで、CallbackGroupはモデル化していない - ROSDiscover(CMU, 論文): launch + CMake + C++からアーキテクチャを静的復元する点で思想が最も近い。ROS 1中心でROS 2対応は開発途上、clangベースでコンパイル環境を要求、粒度はノード/トピック/パラメータまで
- LiSA4ROS2(論文): 静的解析だがrclpy(Python)専用でセキュリティポリシー抽出が目的。rclcpp対応は「LLVMフロントエンドを開発予定」の段階
整理するとこうなります。
| 実行不要 | ビルド不要 | CallbackGroup粒度 | |
|---|---|---|---|
| CIE prerun(本家) | ✗ | ✗ | ✓ |
| CARET / tracing | ✗ | ✗ | △ |
| HAROS / ROSDiscover | ✓ | ✗(compile DB必須) | ✗ |
| LiSA4ROS2 | ✓ | ✓(Pythonのみ) | ✗ |
| roscope | ✓ | ✓ | ✗(launch層のみ) |
| cie-inspector | ✓ | ✓ | ✓ |
つまりこのツールは本家prerunの競合ではなく、実行工程の静的な代替という位置づけです。
設計方針
実装前に決めた原則が5つあります。ここが実装全体の性格を決めています。
1. No build, no ROS
ROS 2のインストール・ビルド成果物・実行環境を一切要求しない。依存にrclpy等のROSパッケージを入れない。ソースツリーだけで動く。
2. best-effortであることを明示する
静的解析で確定できないものを推測で埋めない。確定できなかったものは unresolved として場所と理由つきで出力する。「ここまで確定・ここから先は実行時依存」という線引き自体が成果物だと考えました。
3. CallbackGroup粒度を一級市民に
CIEの分離単位はCallbackGroupであり、これはremapの影響を受けない純粋なソース構造です。一方トピック名はremapで変わりうるので参考情報として扱います。
4. launch解析は再実装しない
launch層の静的解決は既存OSSの roscope が担っています。roscopeはノード起動境界で停止し、ノード内側は明示的に範囲外としているので、その内側を埋める補完レイヤーに徹します。連携はroscopeのresolved XMLファイル経由の疎結合にしました。
5. C++解析はtree-sitter、libclangは使わない
これが一番重要な判断です。libclangはincludeパスなどのビルド環境を要求しがちで、原則1と真っ向から衝突します。一方tree-sitterはコンパイル不能・ヘッダ欠損でも構文木が取れるエラー耐性があります。
型解決を諦める代わりに、どんな状態のソースでも動く。これはトレードオフですが、「設定を書く前に構造を眺めたい」という用途では正しい方に倒したと思っています。
実装
Python 3.10+ / uv管理、依存は tree-sitter + tree-sitter-cpp + click + pydantic v2 のみ。XMLは標準ライブラリで足ります。全体で約1,600行です。
パイプラインは3フェーズ構成です。
workspace src/
↓ Phase 1: package.xml + CMakeLists 軽量パース
package → executables → source files
↓ Phase 2: tree-sitter で C++ 構文解析
callback groups + members
↓ Phase 3: roscope resolved XML 統合(任意)
JSON インベントリ / サマリ表 / CIE設定スケルトン
Phase 1: CMakeLists.txtの軽量パース
完全なCMake評価はしません。コメント除去 → 括弧の対応をとってコマンド分割 → 単純な set() の変数置換、という程度です。
for cmd, args in iter_cmake_commands(text):
if cmd == "set":
values, raw_unresolved = _expand_all(scope, args[1:])
if not raw_unresolved:
scope.vars[args[0]] = values
else:
scope.vars.pop(args[0], None) # 部分的に不明なら変数ごと未知扱い
elif cmd in ("add_executable", "ament_auto_add_executable"):
...
ポイントは展開できない ${...} を推測しないことです。原則2の具体化で、解決できなければ unresolved として記録します。
$ cie-inspector scan tests/fixtures/ws_edge --format table
...
"unresolved variable in source list of 'mystery': ${UNKNOWN_SRCS}"
実際 demo_nodes_cpp はCMakeの function(custom_executable ...) で実行ファイルを定義しているため、関数展開をしない方針の帰結としてここは未解決になります。これは「解析漏れ」ではなく「静的には確定できない」の正しい報告だと考えています。
Phase 2: tree-sitterでrclcppのパターンを拾う
ここが本体です。検出対象は以下。
| パターン | 抽出内容 |
|---|---|
create_callback_group(...) |
group変数名・種別(MutuallyExclusive/Reentrant)・宣言位置 |
create_subscription<T>(topic, qos, cb [, options]) |
トピック名・メッセージ型・callback・group割当 |
create_wall_timer(period, cb [, group]) |
周期(リテラルならns変換)・callback・group割当 |
create_service<T> / create_client<T>
|
名前・callback・group割当 |
create_publisher<T> |
参考情報(callbackを持たないのでグループ外に記録) |
セットアップは3行です。
import tree_sitter_cpp
from tree_sitter import Language, Parser
_LANGUAGE = Language(tree_sitter_cpp.language())
tree = Parser(_LANGUAGE).parse(src) # src は bytes
ハマったところ
ここからが本題かもしれません。tree-sitterでC++を扱う際に踏んだ落とし穴を挙げます。
1. has_error はそのままだと使えない
「パースエラーがあったか」を tree.root_node.has_error で見ようとしたら、正常なROS 2のソースがほぼ全部エラー扱いになりました。
原因は、ファイルスコープに置かれるコンポーネント登録マクロです。
RCLCPP_COMPONENTS_REGISTER_NODE(component_pkg::Listener) // ← セミコロンがない
tree-sitterはマクロを知らないので関数呼び出し + 欠けたセミコロンと解釈し、幅ゼロのmissingノードを挿入します。これで has_error が立つ。しかし内容は一切失われていないので、実用上エラーではありません。
そこで「実際にソースのバイトを飲み込んだ ERROR ノードがあるか」で判定するようにしました。
error_bytes = 0
stack = [tree.root_node]
while stack:
node = stack.pop()
stack.extend(reversed(node.children))
if node.type == "ERROR":
error_bytes += node.end_byte - node.start_byte
# ...同じ走査で本来の抽出も行う
facts.had_parse_errors = error_bytes > 0
これで、本当に壊れたC++(意図的にfixtureを用意しました)だけがパースエラーとして報告され、かつ壊れた箇所より前のsubscriptionはちゃんと抽出されるという挙動になります。エラー耐性というtree-sitterの利点が実際に効いた場面でした。
2. メソッド呼び出しの「名前」を取るのが地味に面倒
this->create_subscription<T>(...) のようなテンプレートメソッド呼び出しは、call_expression の function フィールドが素直な identifier になりません。field_expression → template_method → field_identifier のように入れ子になります。
再帰で剥がす関数を書きました。テンプレート引数(=メッセージ型)も同時に取れて一石二鳥です。
def _callee(node):
"""呼び出しの関数部分を (名前ノード, テンプレート引数ノード) に解決する"""
t = node.type
if t in ("identifier", "field_identifier"):
return node, None
if t in ("template_function", "template_method", "template_type"):
return node.child_by_field_name("name"), node.child_by_field_name("arguments")
if t == "field_expression": # obj.method / this->method
inner = node.child_by_field_name("field")
return _callee(inner) if inner is not None else (None, None)
if t == "qualified_identifier": # ns::func
inner = node.child_by_field_name("name")
return _callee(inner) if inner is not None else (None, None)
return None, None
node.type の名前はgrammarのバージョンで変わりうるので、想定外の型は黙って None を返して「取れなかった」に倒すのが安全です。
3. 変数追跡をどこで打ち切るか
CallbackGroupの割当には主に3パターンあります。
// (a) 直接引数
timer_ = create_wall_timer(500ms, cb, worker_group_);
// (b) SubscriptionOptions 経由
rclcpp::SubscriptionOptions opts;
opts.callback_group = sensor_group_;
sub_ = create_subscription<T>("/scan", qos, cb, opts);
// (c) 割当なし → デフォルトグループ
(a)(b)は追える。問題は「どこまで追うか」です。関数越し・コンテナ経由・条件分岐内まで追い始めると、実質的にデータフロー解析を書くことになり、しかも正しさの保証がないまま結果だけそれらしくなるという最悪の状態になります。
そこで同一実行ファイルのソース内・単純代入のみと決め打ちしました。追えないものはこう出ます。
{
"kind": "timer",
"reason": "callback group variable 'group_' was not seen at any create_callback_group site",
"declared_at": "src/tricky.cpp:27"
}
さらに全エンティティに confidence を持たせました。
-
resolved: リテラル・単純変数で完全解決 -
partial: エンティティは確定したが一部属性が未解決(トピック名が実行時の式、など) -
unresolved: 存在は検出したが主要属性が取れない
追跡不能なgroupに属するメンバーは unresolved:<変数名> という専用バケットに入れ、デフォルトグループに混ぜません。間違ったグループに入れるくらいなら「分からない」と言う方針です。
4. コンポーネントは1ライブラリに複数プラグインが同居する
rclcpp_components_register_node は「ライブラリ + PLUGINクラス名 + EXECUTABLE名」の対応を宣言しますが、実際には1つのライブラリに複数のプラグインが登録されます。demo_nodes_cpp の topics_library には talker / listener など8つが同居していました。
CMakeだけ見るとどのプラグインも「ライブラリの全ソース」を持つことになり、talkerのノードにlistenerのcallbackが混ざります。そこで登録マクロとクラス定義でソースを絞るヒューリスティックを入れました。
macro_re = re.compile(
r"RCLCPP_COMPONENTS_REGISTER_NODE\s*\(\s*" + re.escape(exe.plugin_class) + r"\s*\)"
)
class_re = re.compile(r"\bclass\s+" + re.escape(class_name) + r"\b")
# マクロ一致 → クラス定義一致 → 全ソース、の順にフォールバック
return macro_hits or class_hits or exe.sources
ヒューリスティックなので外すこともありますが、フォールバックが「全ソース」なので取りこぼしはしない構成にしています。
5. chronoリテラルのns変換
CIEのgroup idは Timer(500000000) のようにナノ秒で書きます。ソース側は 500ms や std::chrono::milliseconds(500) なので、正規表現で拾って変換します。リテラルでない場合(変数・メンバ変数)は partial にします。
_PERIOD_LITERAL_RE = re.compile(r"([0-9]+(?:\.[0-9]+)?)\s*(ns|us|ms|s|min|h)")
_PERIOD_CHRONO_RE = re.compile(
r"(?:std::)?chrono::(nanoseconds|microseconds|milliseconds|seconds|minutes|hours)"
r"\s*\(\s*([0-9]+)\s*\)"
)
Phase 3: roscopeとの連携
roscopeが出力するresolved XMLから <node pkg exec name namespace> と <remap from to> を読み、(pkg, exec) でPhase 1の実行ファイルと突合します。
roscope resolve my_robot.launch.py -o resolved.launch.xml
cie-inspector resolve ~/ros2_ws/src --roscope-xml resolved.launch.xml --format cie-yaml
これで「起動されるノードだけ」に絞り込まれ、namespace + remapを適用した resolved_topic が declared_topic と併記されます。
突合できなかったlaunchノードは黙って落とさず unmatched_launch_nodes に残します。ここでも原則2です。
出力
JSONインベントリ
{
"package": "demo_pkg",
"executable": "talker",
"node_hint": "Talker",
"launch": { "node_name": "talker", "namespace": "/robot1",
"remaps": { "/scan": "/lidar/scan" } },
"callback_groups": [
{
"id": "sensor_group_",
"type": "MutuallyExclusive",
"declared_at": "src/talker.cpp:17",
"confidence": "resolved",
"members": [
{
"kind": "subscription",
"declared_topic": "/scan",
"resolved_topic": "/lidar/scan",
"msg_type": "sensor_msgs::msg::LaserScan",
"callback": "Talker::on_scan",
"callback_kind": "bind",
"declared_at": "src/talker.cpp:22",
"confidence": "resolved"
}
]
}
]
}
CIE設定スケルトン
# Thread configuration skeleton generated by cie-inspector 0.1.0
# Group ids are reconstructed statically and are best-effort:
# verify them against the ids emitted by the CIE prerun tool.
callback_groups:
# demo_pkg/talker group: sensor_group_ (MutuallyExclusive) src/talker.cpp:17
- id: /robot1/talker@Subscription(/lidar/scan)
policy: TODO # SCHED_OTHER | SCHED_FIFO | SCHED_RR | SCHED_DEADLINE
priority: TODO # FIFO/RR: 1-99 (99 highest); CFS: nice -20..19
affinity: TODO # CPU core list, e.g. [2, 3]; ~ allows all cores
# demo_pkg/talker group: worker_group_ (Reentrant) src/talker.cpp:18
- id: /robot1/talker@Timer(500000000)
...
policy / priority / affinity は意図的に TODO のままです。スケジューリング値の判断は人間の仕事であり、ツールが推薦すると根拠のない数字が既成事実化してしまいます。構造の列挙は機械、判断は人間、という分担にしました。
なお静的に再構成したgroup idは best-effort なので、デプロイ前にprerunの出力と照合することをREADMEにも書いています。ここは本家ワークフローとの接続点です。
テスト戦略
3層で組みました。
-
fixtureパッケージ: 検出パターンごとの最小C++。group割当3方式の正常系に加え、追跡不能ケース(関数越しのgroup、実行時式のトピック名)が正しく
unresolvedになることと、コンパイル不能なC++でクラッシュせず部分結果が出ることを明示的にテスト - スナップショットテスト(syrupy): JSON出力全体の回帰検出
-
実プロジェクト: CIで
ros2/demosをshallow cloneし、talker/listenerが既知の構成で抽出されることを確認
特に「壊れた入力で落ちない」テストは、tree-sitterを選んだ理由そのものなので最初から入れました。
def test_broken_cpp_yields_partial_results(ws_edge):
analysis = _analyze(ws_edge, "broken_pkg", ["src/broken.cpp"])
default = _group(analysis, "default_group")
(sub,) = [m for m in default.members if m.kind == "subscription"]
assert sub.declared_topic == "ok_topic" # 壊れる前の部分は取れている
assert any(u.kind == "parse" for u in analysis.unresolved) # かつ報告もされる
限界
v1でやらないと決めたことです。
- Pythonノード(rclpy)の解析: entry pointの検出と「未対応」記録のみ
- launch解析の自前実装: roscopeに委譲
- スケジューリング属性の推薦: スケルトンのみ
- テンプレート/マクロ越しの解決、ヘッダ内クラス階層の完全解決: tree-sitterを選んだ時点のトレードオフ
- micro-ROS / rclc executor <- 組み込みが〜とか言ってるのに対応してない点はご勘弁を。。
動作検証の状況:実プロジェクトではほぼ試せていません
ここも正直に書いておきます。このツールはまだ実プロジェクトでまともに評価できていません。 現時点で通しているのは次の範囲だけです。
- 検出パターンごとに用意した最小のfixture(正常系 + 追跡不能ケース + 壊れたC++)
-
ros2/demosのdemo_nodes_cppに対する煙テスト(talker / listenerが抽出できることの確認)
つまり、やれていないことのほうが重要です。
- Autowareのような大規模な実プロジェクトへの適用
- 生成したgroup idを、CIEのprerunが実際に吐くidと突き合わせる検証
特に2つ目が効きます。group idはノード名とトピック名から静的に組み立てているので、実行時に決まる要素 — パラメータ由来のノード名、ソース上では追えないremap、launchによるコンポーネントのノード名上書きなど — が絡むと簡単にズレます。**現状の出力は「そのまま使える設定ファイル」ではなく、「prerunの結果と照合するための下書き」**だと思ってください。生成されるYAMLの冒頭にもその旨のコメントを入れてあります。
というわけで、実際のワークスペースに当てればバグが山ほど出てくるはずです。むしろ出てこないほうが不自然だと思っています。issue・PRは大歓迎です。 特にありがたいのは次のような報告です。
- 「このCMakeの書き方だと実行ファイルが拾えない」
- 「このcallbackの登録パターンが検出漏れしている」
- 「
resolvedと出ているが実際のprerun出力と違う」(いちばん深刻なやつです)
最後のものは、静的解析ツールとしていちばん出してはいけない類のバグです。「分からない」と言うべきところを「分かった」と言ってしまっている状態なので、見つけたらぜひ教えてください。
この記事とツールについて:Claude Codeを使いました
正直に書いておくと、このツールの実装はほぼすべて Claude Code にやってもらいました。分担はこうです。
人間(私)がやったこと
- 課題設定。「CIEの設定を書くのに必要な情報を、ビルドせず実行せず取りたい」
- 設計判断。上の「設計方針」で挙げた5原則 — tree-sitterを選ぶ(libclangはビルド環境を要求するのでNo build原則と衝突する)、best-effortを明示する、CallbackGroup粒度を軸にする、launch解析はroscopeに委譲する — はすべて理由つきで先に決めて仕様書として渡しました
- 出力の受け入れ判断と、この記事の執筆
Claude Codeがやったこと
- 仕様に沿った実装(約1,600行)、テスト、CI設定
- CIEのYAML仕様やroscopeの出力形式を公式ドキュメントから調べて合わせる作業
- 既存実装の調査(前述の比較表)
- 「ハマったところ」で挙げた問題の発見と解決
つまり本記事の「ハマったところ」は、私が一人でデバッグして掘り当てたものではなく、実装の過程で出てきた問題とその解法です。ただし内容は実際のコード・出力・テストに照らして確認しています。
おわりに
「実行しないと分からない」を「ソースを読めば分かる」に変えるツールでした。作ってみて改めて思ったのは、静的解析ツールの価値は"何が分かるか"と同じくらい"何が分からないかを正確に言えるか"にあるということです。
推測で埋めた結果は一見きれいですが、それを信じて設定を書いた人が実機で初めて食い違いに気づく。それなら最初から「ここは追えませんでした、ソースのこの行です」と言う方が、次のアクションに繋がります。unresolved を出力の隅に追いやらず一級市民として扱ったのは、そういう考えからです。
リポジトリはこちらです(Apache-2.0)。
PyPIにはまだ登録していないので、試す場合はGitHubから直接どうぞ。uv が入っていれば、クローンなしで実行できます。
uvx --from git+https://github.com/MrBearing/cie-inspector cie-inspector extract ~/ros2_ws/src --format table
クローンして動かす場合はこちらです。
git clone https://github.com/MrBearing/cie-inspector
cd cie-inspector
uv run cie-inspector extract ~/ros2_ws/src --format table
反響次第でPyPIにも上げようと思っています(そうなれば uvx cie-inspector ... で済むようになります)。
CIEを使っている方、あるいはこれから使おうとしている方の役に立てば幸いです。パターンの検出漏れやCMakeの書き方で拾えないケースなど、issueで教えていただけると助かります。