ラダーをアセンブラとして読む ―― スタックマシンとスキャン、速くしても縮まない I/O
- plc
- 組み込み
- python
- アセンブリ
- 自動化
ソフトウェア寄りのエンジニアと話していると、PLC(Programmable Logic Controller、産業用の制御装置)やラダーという言葉が通じないことがよくあります。工場やプラントの制御盤を開けるとたいてい入っているのに、Web やアプリ側の人には、名前すら聞いたことがないかもしれません。
私は産業機器の制御まわりと、Python でのソフト作りの両方に携わってきました。この記事は、その立場から、ラダーを IT の言葉に翻訳してみる試みです。結論から言うと、ラダーの基本命令はアセンブラ相当の命令列にそのまま落ちますし、全体は無限ループで回るだけの素朴な仕組みです。ただ、その単純さの裏返しで、最後に物理 I/O の遅さという問題につながります。翻訳器を Python で書いて、実際に動かしながら確かめていきます。
ラダーは横線で書く
まずラダーがどんな見た目か、だけ簡単に触れておきます。左右に電源のレールがあると思ってください。その間を横向きに線が渡っていて、左側に「条件」を、右側に「出力(コイル)」を置きます。条件が成立すると、左から右へ電気が通って、コイルが入ります。この横線 1 本を、ラング(rung、はしごの横木)と呼びます。簡単なラダーを図にすると、次のようになります。

接点やコイルには X0、Y0 のような番号(アドレス)が振ってあり、ここでは X が入力、Y が出力を指します。右端の丸い記号が出力コイルです。
条件は接点で書きます。ふだんは開いていて条件が成立すると閉じる接点(A 接点)と、ふだんは閉じていて条件が成立すると開く接点(B 接点)があります。図の下段のように、B 接点は記号に斜線が入ります。接点を横に並べれば直列、つまり両方が成立したときに通る AND です。縦に重ねれば並列、どちらか成立で通る OR です。基本命令の世界は、本当にこの AND と OR と、B 接点の否定 NOT だけです。機械の動きを、電気回路の絵として描いていく感覚に近いです。
基本命令は命令リストに落ちる
このラダー図は、そのままテキストの命令列に変換できます。国際規格 IEC 61131-3 には、かつてラダーと並んで命令リスト(Instruction List, IL)という言語が定義されていました(新しい版では非推奨・削除の扱いになっています)。見た目はほとんどアセンブリです。三菱系の PLC で「ニモニック表示」に切り替えると出てくる LD / AND / OR / OUT の並びも、同じ系統のものです。厳密には各社の方言なので、そのまま移植できるわけではありませんが、発想は共通しています。
たとえば「X0 が入っていて、かつ X1 か X2 のどちらかが入っていて、かつ X3 は入っていない」という条件で Y0 を出す、というラングを考えます。これを命令リストにコンパイルすると、こうなります。
LD X0
LD X1
OR X2
ANB
ANI X3
OUT Y0
この命令列は、ブール値を積むスタックマシンで動きます(これは私が書いた仮想機械での動きで、実機の内部そのものではありません)。LD は値をスタックに積みます(ロード)。AND / OR は、スタックの先頭を対象の接点と論理演算します。並列のブロックを別に積んでおいて、ANB(AND block)や ORB(OR block)で先頭 2 つをまとめて畳みます。ANI は否定してから AND する B 接点版です。最後の OUT で、いまスタックに乗っている結果をコイルへ書きます。上の例なら、X0 の上に X1 OR X2 のブロックを ANB で重ね、最後に X3 の否定を ANI で足しています。
IT の言葉に置き換えると、スタックへの push と pop、それに論理演算だけで動く機械です。ジーメンス系の STL だと、途中結果が RLO(result of logic operation)という 1 ビットのレジスタに現れます。こちらはスタックというより 1 ビットのアキュムレータに近い作りですが、ビット論理を積み上げていく発想は同じです。
自分でも半信半疑だったので、ラダーの木構造(直列・並列・接点の入れ子)から命令リストへ変換するコンパイラと、それを動かすスタックマシンを Python で書いて、コンパイル結果を全入力の真理値表と突き合わせてみました。2 変数から 4 変数まで、直列と並列を混ぜたいくつかのラングで、食い違いはゼロでした。ラダーの基本命令がアセンブラ相当の命令列に落ちることは、こうして手元で確かめられました。
全体はスキャンループで回る
命令リストが分かっても、まだ「一度走って終わるプログラム」の話にはなりません。ラダーは、スキャンという周期処理でずっと回り続けるからです。
PLC は、おおよそ次のことをひたすら繰り返しています。まず物理入力をまとめて読み取り、入力イメージという内部の写しに取り込みます。次に、すべてのラングを上から順に評価して、出力イメージを更新します。最後に出力イメージを物理出力へ書き出します。この 1 周がスキャンで、それをぐるぐる回し続けます。1 周の中では上から順に評価するので、上の行が書いたコイルを下の行が読む、といった順番の依存はあります。ロジックが評価中に入力が変化しても、その周では見ません。スキャンの先頭で取り込んだスナップショットに対して、一貫した論理を回すためです。
この仕組みだと、組み合わせ回路(その瞬間の入力だけで決まる出力)しか書けないように思えます。ところが、コイルの状態が次のスキャンまで保持されて、しかもそのコイル自身を条件に読めるので、そこに順序回路が生まれます。よく使うのが自己保持です。
LD start
OR motor
ANI stop
OUT motor
motor は「start か、いま motor が入っていて、かつ stop が入っていない」ときに入ります。start を一瞬押すと motor が入り、その motor が自分の条件に効いて、次のスキャンでも入り続けます。stop を押すまで保持されます。コードで言えばラッチ、あるいは状態変数そのものです。Python で書いたスキャンエンジンで、start を 2 回目のスキャンだけ、stop を 6 回目だけ押して回すと、こうなりました。

start を離した 3 回目以降も motor が保持され、stop を押した 6 回目で落ちています。組み合わせ命令とスキャンの保持だけで、順序回路になっているのが分かります。
拡張命令はアセンブラでは足りない
あらかじめ断っておくと、きれいにアセンブラ相当に落ちるのは、ここまでの基本命令(ビットの論理)だけです。実際の PLC には拡張命令がたくさんあって、MOVE のようなデータ転送、タイマ、カウンタ、四則演算、位置決めや PID のブロックまであります。これらは数個の命令には落ちません。状態を持つランタイム関数の呼び出し、と考えるほうが近いです。
タイマが分かりやすい例です。タイマはスキャンごとに経過時間を足し込む内部状態を持っていて、指定時間に達したら出力を入れます。命令 1 個では表せませんし、しかも命令の中身はメーカーごとに方言があります。基本命令は AND / OR の共通語ですが、拡張命令から先はベンダの世界です。
スキャンを速くしても、実 I/O の遅さは縮まらない
最後に、スキャン周期と実 I/O の関係に触れます。スキャンタイムを縮めれば応答は速くなる、と思いがちですが、物理 I/O の遅さに引きずられて、あるところから速くなりません。
入力が変化してから物理出力が実際に動くまでを、区間の足し算として分けてみます。まず入力にはノイズ除去のフィルタ(デバウンス)があって、数ミリ秒は素通りしません。フィルタを抜けて入力イメージが確定しても、次のスキャンで読み取られるまで待ちます。読み取られて評価され、出力イメージが更新され、スキャン末に物理出力へ書き出されます。そして出力がリレーなら、接点が機械的に閉じるのにまた数ミリ秒から十数ミリ秒かかります。
この区間を、入力フィルタ 3 ms、出力デバイス(リレー)8 ms という代表的なオーダーで置いて、Python で計算してみました。数字はハードの実測ではなく、この単純なモデルが返す目安です。モジュール間のバスやネットワーク I/O、タスクのゆらぎ、半導体出力かリレーか、といった実機の要素は入れていません。

図は入力が 1 回来たときの内訳で、合計 18 ms です。上段の「標準」がふつうの I/O イメージ方式、下段の「イミディエイト出力」は後で説明します。入力の来るタイミングは毎回違うので、ならすとスキャン 5 ms の標準構成で平均 18.5 ms、幅は 16 〜 21 ms でした。振れるのは、入力がスキャンの読み取り直後に来ると、次の読み取りまでほぼ 1 周期待たされ、出力リフレッシュでもう 1 周期ぶん乗るからです。スキャン起因の遅れは、最悪でおよそ 2 スキャンぶんになります。
ここでスキャンを速くしていくと何が起きるか。スキャンを振ってレイテンシを測ると、こうなりました。

スキャンを 5 ms から 1 ms に縮めても、平均は 18.5 ms から 12.5 ms までしか下がりません。そしてスキャンをいくら 0 に近づけても、入力フィルタと出力デバイスを足した 11 ms より速くはなりませんでした。CPU をどれだけ速くしても、物理 I/O の時間は縮まないからです。厳密にはこれはフォンノイマン・ボトルネックそのものではありません。あれは CPU とメモリの帯域の話です。ただ、計算を速くすると律速がメモリや I/O、物理デバイスの側へ移っていく、という性能の構図はよく似ていると思います。
現場の PLC は、全体を「速くする」のではなく「スキャンから外す」方向で逃げます。イミディエイト I/O 命令は、入力イメージや出力イメージを介さず、スキャンの途中で物理点を直接読み書きします。ここでのモデルは出力側をイミディエイトにした場合で、出力リフレッシュ待ちの 1 周期ぶんが削れます(実際にどれだけ縮むかは、命令の位置や構成によります)。上のグラフでイミディエイト側が緩やかなのがそれです。ほかにも、スキャンを待たず I/O イベントで起動する割り込みタスクや、速い事象をモジュール側のハードで処理してしまう高速カウンタなどがあります。時間にシビアな一点だけをスキャンの外に出す、という考え方です。
この発想は、IT 側にとっても馴染み深いはずです。CPU を速くしても I/O バウンドなら意味がなく、クリティカルパスから外して非同期にする、というサーバやデータベースの話と、よく似た形をしています。畑は違っても、ボトルネックの動き方は近いと感じます。
おわりに
ラダーは、見た目こそ電気回路の絵ですが、基本命令はアセンブラ相当の命令列で、全体は入力を読んで評価して出力を書く無限ループです。そこに自己保持のような順序回路が乗り、拡張命令でデータ処理が加わります。そしてスキャンを速くしても実 I/O の遅さは縮まらない、という性能全体の話にもつながっていました。
翻訳器とスキャンエンジン、I/O レイテンシのモデルは Python で書いて GitHub に置きました(logicia32/ladder-lab)。手元で命令列に落として、真理値表と突き合わせたり、スキャンを振ってレイテンシを測ったりできます。FA の人には当たり前の世界かもしれませんが、IT 側から眺めると案外、知っている道具の集まりだと思います。
この記事は Zenn に初出したものを加筆・補足したものです ── Zenn の元記事を見る