3편에서 래더와 C#으로 앱을 작성하는 것까지 다뤘다. 이번 편은 그렇게 작성한 앱이 라즈베리파이에서 어떻게 동작하는지, 런타임 이야기다.
현장에서 PLC를 믿고 쓰는 데는 이유가 있다. 정해진 주기로 동작하고 전원을 끄지 않고 프로그램을 받고 정전이 나도 값이 남고 전원을 넣으면 알아서 뜨고 로직 버그 하나가 컨트롤러를 죽이지 않는다. 라즈베리파이에 .NET을 올려 PLC를 만들었다고 하면 의심받는 게 바로 이 지점들이다. 나라도 의심한다.
그래서 이번 편은 런타임이 이것들을 어떻게 갖췄는지를 짚는다. 앱을 받아 상주하며 실행하는 프로세스, 10ms를 지키는 사이클, 사용자 C#의 격리, 멈추지 않는 교체, 그리고 실기 구동 순서다. 확인용으로 예제 앱을 하나 준비했고, 각 절에서 한 조각씩 실행해 가며 본다.
실행할 앱
먼저 실행할 앱의 구성이다. 3편의 제품 카운터는 기능이 카운트 하나라 런타임 쪽을 보이기엔 좁다. 그래서 기능을 나눠 담은 예제를 하나 새로 짰다.

보드는 IO-8 하나를 1번 슬롯에 꽂았다. 입력이 P0P3, 출력이 P4P7로 잡힌다. 버튼 넷과 램프 넷이 붙는 셈이다. 영역 크기는 기본값 그대로고 통신은 없다.

심볼은 버튼AD(P0P3), 램프03(P4P7), 타이머 둘(T0·T1), 카운트값(C0)이다. 카운트값에만 유지가 걸려 있다. 이 유지 체크 하나가 무슨 일을 하는지는 마지막 절에서 본다.

래더는 네 묶음이다.
| 묶음 | 동작 | 확인하는 것 |
|---|---|---|
| 하트비트 | 램프3이 1초 점멸 | 사이클이 살아 있다 |
| 직결 | 버튼A → 램프0 | 입력과 출력이 왕복한다 |
| 타이머 | 버튼B 2초 → 램프1 | 시간이 정확하다 |
| 카운터 | 버튼C 다섯 번 → 램프2 + C# 호출, 버튼D 리셋 | 래더가 C#을 부른다, 값이 남는다 |
렁 하나하나는 각 절에서 그 절의 이야기와 같이 본다. C#은 카운터가 부르는 함수 하나뿐이다.
런타임은 남고, 앱은 교체된다
라즈베리파이에서 동작하는 것은 런타임이다. ASP.NET Core로 만든 프로세스 하나가 타깃에 계속 떠 있다. 배포한 앱은 이 프로세스 자체가 아니라 그 안에 실렸다 빠지는 내용물이다. 프로세스는 런타임이 쥐고 그 위에 앱을 올려 실행한다.
이렇게 나눈 이유는 이렇다. 런타임은 앱만 실행하는 게 아니다. 프로세스가 뜰 때 네 가지 창구를 함께 연다.
- 배포 (HTTP ): 에디터가 빌드 결과를 여기로 올린다.
- 모니터 (TextComm ): 에디터가 실행 값을 여기서 되받는다.
- 발견 (mDNS): 장치명과 포트를 광고한다.
- CAN 버스: I/O 보드가 여기 붙는다.
이 창구들은 앱을 갈아끼우는 동안에도 살아 있어야 한다. 새 앱을 올리는 중에 배포 창구가 죽으면 곤란하고, 앱이 바뀌었다고 모니터가 끊기면 곤란하다. 그래서 창구는 프로세스가 쥐고 앱만 들고 난다.
창구가 여럿이지만 뒤에서 하는 일은 같다. 지금 어느 상태인지, 값이 얼마인지, 어디를 제어할지 같은 조회·제어 로직을 창구마다 새로 짜면 어긋나기 쉽다. 그래서 접근 계층을 하나 두고 창구는 전부 그것만 부르게 했다. REST든 TextComm이든 같은 계층을 거쳐 같은 답을 낸다.
앱의 수명은 배포마다 다시 일어나는 일이라 호스트 수명에 이어 붙이는 어댑터 하나가 그 시점을 잡는다. 부팅 때 Apps 폴더에 배포물이 있으면 자동으로 시작하고 종료 때 정지한다. 전원이 들어오면 런타임이 뜨고 올려둔 앱이 있으면 알아서 실행된다.
위의 예제 앱도 빌드하면 DLL 하나가 되어 이 프로세스에 실려 동작한다.
결정적 10ms 사이클
런타임의 본체는 스캔 루프다. 2편에서 그린 그 결정적 래더 루프가 여기서 실제로 동작한다. 한 사이클은 이 순서로 흐른다.
- 통신 입력을 읽고(
Load) - 버스에서 보드 입력을 받아 메모리에 넣고
- 래더 틱을 진행하고
- 래더 로직을 수행하고(
LadderLoop) - 보드 출력을 내보내고
- 통신 출력을 내보내고
- 모니터링 중이면 값 스냅샷을 넘긴다
입력을 사이클 앞에서 모아 읽고 출력을 뒤에서 모아 내보내는 것도 그대로다. 수행 도중에는 입력이 바뀌지 않으니 로직이 예측 가능하다.
10ms를 지키는 방식이 핵심이다. 매 사이클 그냥 10ms씩 대기하면 처리가 길어지는 날엔 주기가 조금씩 밀린다(드리프트). 그래서 스톱워치(Stopwatch)를 하나 두고 실제 경과 시간을 잰다. 다음 틱 시각을 10ms씩 올려 두고 경과가 그 시각을 넘어섰으면 넘긴 만큼 틱을 따라잡는다.
var elapsed = sw.ElapsedMilliseconds;
if (elapsed - nextTickMs > 1000) nextTickMs = elapsed - 10; // 너무 밀리면 따라잡기 포기
while (nextTickMs <= elapsed) { LadderTick(); nextTickMs += 10; }
대기 시간이 아니라 스톱워치 경과를 기준으로 삼으니 부하가 흔들려도 결국 10ms 간격에 맞춰진다. 단서 하나를 달았다. 어쩌다 1초 넘게 밀리면 그 밀린 만큼을 다 따라잡으려 하지 않는다. 백 번의 틱을 한꺼번에 몰아서 실행하면 그게 또 사이클을 잡아먹기 때문이다. 그럴 땐 따라잡기를 포기하고 지금 시각으로 다시 맞춘다. 타이머도 이 틱 위에서 재니, 시간 기반 로직이 실제 시간에 정직하다.
예외는 사이클이 삼킨다. 로직 한 줄에서 예외가 나도 그 사이클만 넘기고 계속 동작한다. 메모리 부족(OutOfMemoryException)만 예외로, 이건 억지로 계속할 수 없으니 상태를 ERROR로 바꾸고 멈춘다. 로직 버그 하나가 컨트롤러 전체를 멈추게 하지는 않는다.
예제 — 하트비트와 타이머
이 주기가 정직한지를 보는 게 예제의 하트비트와 타이머다.
@F1000R 램프3
──┤ ├───────────────────────────────────────────( )
하트비트는 한 줄로 끝난다. @F1000R(1초 플리커)이 램프3을 점멸시킨다. 특수릴레이가 이 틱 위에서 갱신되니 램프3이 1초마다 꼬박꼬박 뒤집히면 사이클이 제 주기로 동작하고 있다는 뜻이다. 죽었는지 살았는지를 눈으로 본다.
버튼B TON(T0, 200)
──┤ ├───────────────────────────────────────────[ ]
T0값 램프1
──┤ ├───────────────────────────────────────────( )
타이머에는 두 줄을 썼다. 버튼B가 눌려 있는 동안 TON(T0, 200)이 잰다. 10ms 단위라 200이면 2초다. 다 재면 T0 접점이 붙어 램프1이 켜진다. 같은 이유로 이 2초도 정확하다. 타이머가 위에서 본 스톱워치 누적 틱으로 재기 때문에 부하가 흔들려도 2초는 2초다.
자유 C#은 따로 동작한다
결정성의 진짜 관건은 사용자 C#이다. 사용자가 Loop에 오래 걸리는 코드를 쓰면 그게 래더 사이클 안에서 실행될 경우 10ms가 그만큼 밀린다.
그래서 사용자 C#은 래더 사이클과 같이 실행하지 않는다. Setup과 Loop은 시작할 때 별도 Task로 떼어내 실행한다.
// 사용자 코드는 별도 Task — 래더 사이클에 영향 없음
Task.Run(() =>
{
Setup();
while (!token.IsCancellationRequested) { Loop(); Thread.Sleep(1); }
});
래더 사이클은 이 Task를 기다리지 않는다. 2편에서 “결정적 래더 루프와 자유로운 C# 루프가 별도 Task로 분리돼 서로의 타이밍을 막지 않는다”고 말했던 게 말이 아니라 이 구조다.
따로 동작하지만 남남은 아니다. 둘은 같은 메모리를 만진다. 심볼이 같은 프로퍼티로 노출되니 래더가 쓴 값을 C#이 읽고 C#이 쓴 값을 래더가 되받는다. 그리고 래더는 C# 함수를 부른다.
경계가 하나 있다. 3편에서 말한 그 경계다. 래더가 부르는 함수는 래더 사이클 안에서 실행되기 때문에 블로킹하면 안 되고 자유 Loop은 별도 Task라 마음대로 써도 된다. 사이클 안이냐 밖이냐만 지키면 된다.
예제 — 카운터가 C#을 부른다
예제의 카운터 묶음이 이 호출을 실제로 한다. 다섯 줄에 걸쳐 있다.
버튼C TON(T1, 5)
──┤ ├───────────────────────────────────────────[ ]
디바운스T 카운트값 CTU(C0, 5)
──┤ ├────────┤/├────────────────────────────────[ ]
버튼D CTR(C0)
──┤ ├───────────────────────────────────────────[ ]
카운트값 OnCountReached()
──┤ ├────┤↑├────────────────────────────────────[ ]
카운트값 램프2
──┤ ├───────────────────────────────────────────( )
첫 줄은 디바운스다. 물리 버튼은 누르는 순간 접점이 튀어서(채터링) 한 번 누른 게 여러 번으로 세질 수 있다. TON(T1, 5)으로 50ms를 재서 그만큼 눌려 있어야 한 번으로 친다. 둘째 줄이 카운트다. 디바운스를 통과한 신호의 상승 엣지마다 CTU(C0, 5)가 하나씩 세고 다섯에 닿으면 C0 접점이 붙는다. 아직 도달 전일 때만 세도록 카운트값의 B접점을 직렬로 걸었다. 셋째 줄의 버튼D는 CTR(C0)로 카운터를 리셋한다.
넷째 줄이 이 절의 주인공이다. 카운트값 접점 뒤에 상승 엣지를 두었으니 다섯에 닿는 그 순간 한 사이클만 통하고 통하면 OnCountReached()를 부른다. 다섯째 줄은 도달 표시로 램프2를 켠다.

불리는 쪽은 이렇다.
public partial class App : PlcApp
{
// 카운터가 5에 도달한 횟수 (래더가 리셋 후 다시 5에 도달할 때마다 +1).
private int _reachCount;
protected override void Setup() { }
protected override void Loop() { }
// 래더 "카운터"의 5도달 엣지 렁이 이 함수를 부른다.
// 래더 사이클 안에서 실행되므로 블로킹 금지 — 로그 1줄 후 즉시 반환.
public void OnCountReached()
{
_reachCount++;
Console.WriteLine($"[Sample2] 카운트 5 도달 — {_reachCount}회째 (래더→C# 호출)");
}
}
제어 흐름이 전부 래더에 있어서 Setup과 Loop은 비워 뒀고, 사이클 안에서 실행되는 OnCountReached는 값 하나 올리고 로그 찍고 바로 나온다. await도 블로킹도 없다.
멈추지 않고 갈아끼우기
현장 PLC를 믿는 이유 중 하나는, 프로그램을 바꿀 때 장비 전원을 안 끄고 다운로드한다는 점이다. 런타임도 그렇게 만들었다.
배포할 때는 세 단계를 밟는다. 에디터의 Deploy 버튼이 이 순서대로 호출한다.
- start: 실행 중인 앱을 정지하고
Apps폴더를 비운다. - file: 빌드 결과(
ladder.json과 DLL들)를 올린다. - complete: 검증하고 새 앱을 자동으로 실행한다.
이 교체의 중심에 앱 전용 로더가 있다. 배포된 앱은 전용 로드 컨텍스트(AssemblyLoadContext)에 로드하는데, 이 컨텍스트는 통째로 걷어낼 수 있게(collectible) 만들었다. 재배포는 곧 앱을 정지하고 → 이 컨텍스트를 걷어내고(Unload) → 새 컨텍스트에 새 DLL을 로드하는 일이다. 호스트 프로세스는 그동안 죽지 않는다. 앞서 창구를 프로세스가 쥐게 한 이유가 여기 있다. 앱만 교체되고 배포·모니터 창구는 그대로다.
DLL은 파일에서 바이트로 읽어 로드한다. 파일 핸들을 잡지 않으니 다음 배포 때 그 파일을 덮어쓸 수 있다.
한 가지 조심한 게 타입이다. 배포된 앱과 호스트가 같은 코어(Senbrix.Controller)를 봐야 로드한 앱을 PlcApp으로 캐스트하는 게 성립한다. 그래서 이 코어만은 앱 로더가 로드하지 않고 호스트로 넘긴다. 페이로드에 코어 사본이 딸려 와도 그건 의도적으로 무시한다. 이렇게 해야 배포된 앱의 App이 호스트가 아는 것과 정확히 같은 타입이 된다.
걷어낸 컨텍스트가 실제로 수거됐는지도 확인한다. 언로드하고 몇 번 GC를 돌려 그래도 안 죽고 남은 세대가 있으면(사용자 코드가 스레드나 정적 참조를 붙들고 있으면 그렇다) 그 수를 센다. 재배포를 거듭해도 메모리가 새지 않도록 지켜본다.
예제 — 상한을 바꿔 다시 배포
예제의 카운터 상한을 다섯에서 열로 고치고 Deploy를 다시 누르면 위 세 단계가 다시 수행된다. 라즈베리파이는 재부팅하지 않는다. 동작 중에 앱만 새것으로 교체되고 이제 램프2는 열에서 켜진다. 갈아끼우는 사이에도 모니터 창구는 살아 있다.
라즈에서 진짜 동작한다
여기까지가 구조 이야기였다면, 이번엔 진짜 라즈베리파이에 올려 실행했다.
라즈에서는 런타임을 systemd 서비스로 등록했다.
[Service]
Type=notify
ExecStart=/.../dotnet /.../Senbrix.Runtime.dll
Restart=always
Type=notify라 런타임이 준비되면 시스템에 알리고 Restart=always라 크래시가 나거나 메모리가 터지면 알아서 재시작한다. 부팅에 자동으로 뜨게 걸어 뒀으니 전원 인가 하나로 런타임이 서고 올려둔 앱이 있으면 함께 실행된다.
그렇게 등록해 두고 확인한 상태가 이렇다.

부팅 로그에 지금까지 말한 것들이 순서대로 찍혀 있다. 모니터(
)·mDNS·배포() 창구가 열리고 keep 파일을 복원하고 IO-8 보드를 로드하고 엔진이 앱을 시작한다. systemd의 시작 선언은 그 뒤에야 온다.Type=notify가 이 일을 한다. 로그는 journald로 모이니 journalctl로 언제 뜨고 뭘 했는지 되짚을 수 있고, 맨 아래 두 줄처럼 사용자 C#이 찍는 콘솔 출력도 같이 모인다. 캡처 시점에 18시간째 떠 있고 누적 CPU는 1분이다.
방금 로그에 keep 복원이 한 줄 찍혀 있었다. 실행할 앱 절에서 카운트값에만 걸어 둔 유지 체크가 이 keep이다. 유지 값은 별도 스레드가 지킨다. 10ms 스캔을 건드리지 않고 메모리 버퍼를 직접 읽어 값이 변하면 3초쯤 잠잠해진 뒤에 파일로 쓴다(길게 안 변하면 최대 30초마다 한 번은 쓴다). 쓸 때는 임시 파일에 적고 디스크에 확실히 내린 다음 이름을 바꿔 갈아끼워서, 쓰다 전원이 나가도 파일이 깨지지 않는다. 되살릴 때는 프로젝트와 서명이 맞고 해시가 맞을 때만 복원한다. 현장 PLC가 배터리로 지키던 그 유지 영역을, 파일로 지킨다.
I/O는 CAN으로 붙는다. IO-8 보드가 CAN 버스로 런타임에 붙어 버튼을 누르면 입력이 들어오고 출력을 쓰면 릴레이가 딸깍인다. 이 버스도 런타임이 쥐고 있어서 앱을 갈아끼워도 유지된다.
그리고 이 동작을 라이브로 본다. 에디터가 모니터 창구(
)에 붙어 모니터링을 켜면 그때부터 런타임은 매 사이클 끝에 값 스냅샷을 모아 둔다. 에디터는 그 스냅샷을 계속 받아 화면에 뿌린다. 접점이 통하면 색이 바뀌고 타이머와 카운터 값이 실시간으로 뜬다.예제 — 실기 데모
예제 앱을 배포하고 보드의 버튼과 램프로 확인했다.

하트비트 램프가 1초마다 점멸한다. 버튼A를 누르면 램프0이 따라 켜진다. 버튼B를 누르고 있으면 타이머 값이 차오르다 2초에 램프1이 켜진다. 버튼C를 누를 때마다 카운트가 하나씩 오르고 다섯에서 램프2가 켜지며 C# 로그가 찍힌다. 위 journald 캡처 끝의 두 줄이 그 로그다. 카운트를 넷까지 세 두고 장비를 재부팅하면 런타임이 스스로 뜨고 앱이 실행되고 카운트는 넷에서 이어진다. 작성한 래더가 라즈베리파이에서 구동한다.
정리
전원을 넣으면 systemd가 런타임을 세우고 올려둔 앱이 자동으로 실행된다. 앱은 10ms 주기에 맞춰 결정적으로 동작하고 사용자 C#은 그 옆에서 따로 동작한다. 로직 한 줄이 예외를 내도 컨트롤러는 멈추지 않는다. 프로그램을 바꿀 땐 전원을 끄지 않고 앱만 갈아끼운다. 전원이 나갔다 들어와도 유지 값은 남는다. 그리고 그 동작하는 모습을 에디터가 라이브로 되받아 본다. 도입에서 꼽은 다섯이 이렇게 채워졌다.
3편 끝에서 라즈베리파이에서 실제로 실행하는 건 다음 편이라 했다. 이번 편에서 실제로 실행했다.
다음 편 — AI가 래더를 짠다
사실 이번 예제의 래더도 AI를 시켜 작성했다. 다음 편은 AI가 어떻게 래더를 짜는지, 그리고 래더 작성과 코드 작업을 어떻게 지원하는지 이야기한다.

댓글
닉네임만 입력하면 바로 댓글을 남길 수 있어요. Google/GitHub 로그인도 가능합니다.