3편부터는 본격적으로 구현 내용을 이야기하려 한다. 이번 편에서는 C#과 PLC의 기능이 어떻게 구현되었는지, 어떻게 사용하는지를 다뤄 보려 한다.

설명의 편의를 위해 간단한 예제를 하나 준비하였다. 제품이 지나갈 때마다 수량을 카운트하고 목표 수량에 도달하면 서버에 보고하는 내용이다. 수량을 세는 것은 래더가 늘 하던 일이지만 서버에 보고하는 것은 원래 PLC가 직접 하던 일이 아니다. 보통은 옆에 PC를 세우거나 수집기를 붙여 그쪽에 맡겼다. 1편에서 말한 그 노가다다. 여기서는 래더가 C# 함수를 호출해 이 보고를 직접 처리한다.

이 예제를 어떻게 설정하고 구현하는지를 설명해 나가며 프로그램의 구성 요소들을 살펴보고 사용법과 만든 기준을 짚어 보겠다.

프로젝트

Senbrix 프로젝트 페이지 - 메모리 영역 크기, 슬롯의 IO-8 보드, Modbus TCP 슬레이브 설정

프로젝트 페이지에서는 이 PLC의 구성을 정한다. 메모리 영역의 크기, 슬롯에 꽂을 보드, 통신 연결을 여기서 지정할 수 있다.

영역 크기

영역 크기 섹션에서는 각 메모리 영역의 크기를 지정할 수 있다. 메모리는 다음 다섯 영역으로 나뉜다.

영역단위기본값용도
P비트32768물리 입출력. 센서·모터 같은 실제 IO가 붙는다
M비트32768프로그램 내부에서 쓰는 플래그
T워드2048래더 타이머 명령이 쓰는 자리
C워드2048래더 카운터 명령이 쓰는 자리
D워드4096수치를 담는 데이터 레지스터

타이머는 T, 카운터는 C에만 잡을 수 있다.

보드

보드 섹션은 프로젝트에서 사용할 보드를 지정하는 기능이다. 슬롯은 최대 9개까지 있으며 해당 슬롯을 클릭하면 보드를 골라 꽂을 수 있다. 꽂힌 보드는 입/출력은 P영역으로, AD/DA는 D영역으로 자동으로 할당되고, 바꾸고 싶으면 I/O 매핑에서 바꾼다.

선택할 수 있는 보드 목록은 고정된 것이 아니며 Modules 폴더에 저장된 dll을 스캔하여 구성하기 때문에 확장이 용이하다. 이 dll은 인터페이스 규약에 따라 제작할 수도 있다.

통신

통신 섹션은 외부 장비와 주고받을 통신 연결을 지정하는 기능이다. 추가 버튼으로 프로토콜을 골라 연결 설정을 잡는다. 기본으로 Modbus RTU/TCP의 마스터·슬레이브 네 종류가 제공된다.

통신 프로토콜 목록도 보드와 같은 방식이다. Modules 폴더의 dll을 스캔하여 구성하고 인터페이스 규약에 따라 제작할 수도 있다.

슬레이브를 추가하면 PLC 메모리가 표준 Modbus 테이블로 외부에 노출된다. D 영역은 경계 번지(기본 100)를 기준으로 앞은 읽기 전용, 뒤는 쓰기 가능으로 나뉜다. 상위 시스템이나 HMI가 붙는 자리다.

예제

이 예제는 영역 크기를 기본 제공 크기 그대로 이용한다. 보드는 제품 감지 센서를 받기 위해 IO-8을 1번 슬롯에 넣었다. 입력이 P0P3, 출력이 P4P7로 잡히고 센서는 P0로 들어온다. 통신은 Modbus TCP Slave를 502 포트로 추가했다. 외부에서 생산수량(D0)을 읽어가고 목표수량(D100)을 바꿔 넣을 수 있다. 목표수량 심볼을 D100에 둔 것도 이 경계 때문이다.

심볼

심볼 페이지 - 제품감지 P0, 생산수량 D0, 목표수량 D100, 보고완료 M10, 목표도달 M11

심볼은 메모리 주소에 붙이는 이름이다. 주소로만 짜면 나중에 못 읽으니, P0 대신 제품감지처럼 의미가 보이는 이름을 붙여 래더와 C#에서 그대로 쓴다. 심볼 페이지에서는 이 심볼들을 표로 등록하고 관리한다.

심볼 구성 요소

내용
이름코드에서 쓸 식별자. 대소문자를 구분하고 한글도 된다
주소메모리 주소. 접미사가 타입을 정한다
경로설비 계층. 어느 장치의 신호인지 트리로 묶는다
접근읽기 전용인지 읽고 쓰기인지
설명·단위신호의 의미와 단위를 적는 메모
유지재기동 후에도 값을 남길지

메모리는 재기동하면 0으로 초기화된다. 유지를 켠 심볼은 전원이 나갔다 들어와도 값이 남는다. 유지는 M·C·D에만 걸린다. P는 물리 입출력이라 재기동하면 하드웨어에서 다시 읽어 오니 저장할 이유가 없다. 빌드할 때 유지 심볼의 주소를 범위로 모아 서명을 붙여 런타임으로 보내고, 런타임은 그 범위만 파일에 저장했다가 재기동 때 복원한다. 프로젝트와 서명이 맞아야만 복원한다.

주소·경로·설명 같은 칸은 사람이 파악하기 좋으라고만 있는 게 아니다. AI가 래더와 코드를 제작하고 분석하고 진단할 때 이 심볼 정보를 근거로 동작한다.

주소 타입

그런데 이 표에는 데이터 타입을 고르는 칸이 없다. 타입은 주소가 정한다. 정확히는 주소 뒤에 붙는 접미사가 정한다. 메모리는 결국 바이트 버퍼고 D 영역은 그걸 워드 단위로 본다. 같은 자리를 어떻게 읽을지가 접미사다.

접미사의미C# 타입래더 접근식C# 접근식
(없음)부호 없는 16비트 — .W와 같다1워드intD0D[0].W
.W부호 없는 16비트1워드intD0.WD[0].W
.I부호 있는 16비트1워드intD0.ID[0].I
.DW부호 없는 32비트2워드uintD0.DWD[0].DW
.DI부호 있는 32비트2워드intD0.DID[0].DI
.Rfloat2워드floatD0.RD[0].R
.S문자열(UTF-8, 최대 256바이트)가변stringD0.SD[0].S
.L하위 바이트1바이트intD0.LD[0].L
.H상위 바이트1바이트intD0.HD[0].H
.0~.15워드 내 개별 비트1비트boolD0.3D[0].Bit[3]

같은 D0이 접미사에 따라 정수가 되기도 실수가 되기도 문자열이 되기도 한다. 한 자리를 여러 타입으로 겹쳐 보는 것이다. 다만 .DW·.DI·.R은 워드 두 칸을 차지하니 D0.R은 D0과 D1을 함께 쓴다.

래더 접근식과 C# 접근식이 다른 건 C# 문법의 한계 때문이다. D0.3 같은 주소 표기는 C#에서 유효한 식이 아니라서 C# 쪽은 같은 자리를 인덱서와 프로퍼티(D[0].Bit[3])로 푼다.

P와 M은 비트라 접미사를 못 붙인다. 이 비트 영역을 워드로 보고 싶으면 WP·WM을 쓴다. WP·WM은 별도 메모리가 아니라 P·M과 같은 버퍼를 워드로 본 것이다. WP0에 쓰면 P0부터 P15까지가 바뀐다.

예제

이 예제 심볼은 다섯이다. 제품감지 P0, 생산수량 D0, 목표수량 D100, 보고완료 M10, 목표도달 M11. 물리 입력은 P, 수치는 D, 내부 상태는 M이다. 타입은 하나도 안 골랐다.

래더

래더 페이지 - 초기화, 카운트, 판정, C# 호출, 리셋, 클리어 여섯 줄

래더는 접점과 코일로 기계 제어를 그리는 언어다. 래더 페이지에서는 이 래더로 프로그램을 작성할 수 있다. 이를 위해 필요한 작성법과 펑션, 특수릴레이 사용법을 차례로 짚어 본다.

래더 작성법

래더는 그리드의 셀에 요소를 하나씩 놓아 작성한다. 요소는 오른쪽 팔레트에서 고르거나 단축키로 찍으며, 놓을 수 있는 요소는 다음과 같다.

요소단축키종류설명
NONESpace삭제셀을 비운다. 놓은 걸 지울 때
IN AF3입력 · A접점상시 열림. 값이 켜지면 통한다
IN BF4입력 · B접점상시 닫힘. 값을 반전 — 꺼져 있을 때 통한다
NOTF9입력 · 반전그 지점까지의 결과를 반전한다
R EdgeF11입력 · 엣지상승엣지. 꺼졌다 켜지는 순간만 한 사이클 통한다
F EdgeF12입력 · 엣지하강엣지. 켜졌다 꺼지는 순간만 한 사이클 통한다
LINE HF5연결선수평 도선. 왼쪽에서 오른쪽으로 통과시킨다
LINE VF6연결선수직 도선. 위아래 줄을 병렬로 묶는다
OUT COILF7출력도통 결과를 비트에 넣는다
OUT FUNCF8출력도통하면 펑션이나 C# 함수를 부른다

한 셀은 위치와 종류, 그리고 코드(주소나 식)를 갖는다. 맨 왼쪽 0열이 전원 레일이고 접점을 왼쪽에서 오른쪽으로 이으면 직렬 AND, 세로선으로 위아래 줄을 묶으면 병렬 OR이다. 왼쪽 전원에서 오른쪽 끝 출력까지 통하면 그 줄이 도통한 것이다.

출력은 두 가지다. 코일은 도통 결과를 비트에 넣고 펑션은 코드를 실행한다. 생산수량 = 생산수량 + 1 같은 대입식이든 함수 호출이든, 펑션 자리에 쓴 코드는 도통하면 실행된다.

래더 펑션

래더 펑션은 펑션 자리에 쓰는 내장 명령이다. 타이머·카운터처럼 PLC가 기본으로 갖추는 명령들이 제공되며 지금 열여섯 개, 다섯 갈래다.

갈래명령설명
타이머TONON 딜레이. 조건이 켜져 있는 동안 재다가 설정값에 닿으면 릴레이를 켠다. 10ms 단위
TAONON 딜레이. 100ms 단위
TOFFOFF 딜레이. 조건이 꺼진 뒤 설정 시간이 지나면 릴레이를 끈다. 10ms 단위
TAOFFOFF 딜레이. 100ms 단위
TMON모노스테이블. 조건이 순간만 켜져도 설정 시간 동안 릴레이를 켠다. 10ms 단위
TAMON모노스테이블. 100ms 단위
카운터CTU상승 엣지마다 증가. 설정값에 닿으면 릴레이를 켠다
CTD상승 엣지마다 감소. 0에 닿으면 릴레이를 켠다
CTR조건이 켜지면 카운터를 리셋한다
출력SETOUT조건이 켜지면 릴레이를 세운다(래치)
RSTOUT조건이 켜지면 릴레이를 내린다
마스터 컨트롤MCS구간 시작. 번호 0~15
MCSCLR같은 번호의 구간 끝. MCS와 쌍으로 쓴다
데이터WXCHG두 워드 값을 맞바꾼다
DIST16비트를 4비트 단위로 쪼개 저장한다
UNIT하위 4비트들을 모아 한 워드로 합친다

카운트를 예로 들면, 생산수량 = 생산수량 + 1처럼 대입으로 직접 짤 수도 있지만 CTU에 카운터 주소와 설정값을 주면 입력이 올라올 때마다 세고 설정값에 닿으면 릴레이를 켠다.

이 명령들은 인자를 검사한다. 타이머 릴레이는 T 영역, 카운터는 C 영역, 설정값은 1부터 65535까지처럼, 정해진 자리에 정해진 값만 받는다. 틀리면 빌드가 잡는다.

이 열여섯 개는 래더에서 가장 기초가 되는 펑션들이다. 펑션을 이 이상 늘리지 않은 것은 C# 때문이다. 보통 PLC는 산술·비교·변환·통신 같은 기능도 명령어 세트로 갖추지만, Senbrix에서는 그 자리를 C#이 맡는다. 수학 함수든 문자열 파싱이든 날짜 계산이든 .NET에 이미 있는 것을 래더 명령으로 다시 만들 이유가 없다. 래더 펑션은 기계 제어에 필요한 바닥만 갖춘다.

특수릴레이

접점 자리에는 주소 대신 @로 시작하는 특수릴레이도 놓을 수 있다. 메모리 주소가 아니라 런타임이 갱신하는 릴레이다.

토큰의미
@ON / @OFF항상 ON / 항상 OFF
@BEGIN첫 사이클에서만 ON
@10R ~ @1000R주기 펄스. 해당 ms마다 1사이클 ON (10·20·50·100·200·250·500·1000ms 8종)
@F10R ~ @F1000R플리커. 해당 ms마다 ON↔OFF 토글 (주기는 위와 같은 8종)

예제

  @BEGIN                                     목표수량 = 10
──┤ ├───────────────────────────────────────────[ ]

첫 줄은 초기화다. @BEGIN 접점에 목표수량을 10으로 넣는다. @BEGIN이 첫 사이클에만 켜지니, 목표수량은 시작할 때 한 번만 10이 된다.

  제품감지                            생산수량 = 생산수량 + 1
──┤ ├────┤↑├────────────────────────────────────[ ]

둘째 줄이 카운트다. 제품감지 접점 뒤에 상승엣지를 두고 생산수량을 하나 올린다. 엣지는 이전 사이클의 상태를 들고 있다가 지금과 비교해서 꺼졌다 켜지는 순간에만 한 사이클 통한다. 이게 없으면 센서가 눌려 있는 매 10ms마다 계속 올라간다.

  @ON                  목표도달 = 목표수량 > 0 && 생산수량 >= 목표수량
──┤ ├───────────────────────────────────────────[ ]

셋째 줄은 판정이다. 항상 켜진 @ON 접점에 목표도달을 대입한다. 목표수량이 잡혀 있고(>0) 생산수량이 목표에 닿았는지를 매 사이클 다시 계산해 목표도달 비트를 갱신한다.

  목표도달     보고완료                     ReportProduction()
──┤ ├────────┤/├────────────────────────────────[ ]

넷째 줄에서 C#을 부른다. 목표도달 접점과 보고완료의 B접점을 직렬로 걸었다. 목표에 도달했고 아직 보고하지 않았을 때만 통한다. 통하면 ReportProduction()을 부른다.

  보고완료                                    생산수량 = 0
──┤ ├───────────────────────────────────────────[ ]

다섯째 줄은 리셋이다. C#이 쓴 보고완료를 되받아 생산수량을 0으로 되돌린다.

  보고완료     목표도달                       보고완료 = false
──┤ ├────────┤/├────────────────────────────────[ ]

여섯째 줄은 클리어다. 생산수량이 리셋되어 목표도달이 내려간 뒤에 보고완료를 내리고 다음 라운드를 준비한다. 래더는 줄을 위에서 아래로 순서대로 평가하니, 이 순서가 곧 동작 순서다.

코드

코드 페이지 - App.cs, App.Ladder.cs, App.Symbols.cs 세 파일과 ReportProduction 함수

코드 페이지에서는 사용자 C#을 작성한다. 에디터는 신택스 하이라이팅까지만 지원해서 자동완성 같은 기능을 써가며 하는 본격적인 코드 작업은 ‘IDE로 열기’로 외부 IDE에서 하는 편이 훨씬 수월하다. 탐색기에는 파일이 셋 보인다. App.cs, App.Ladder.cs, App.Symbols.cs. App.cs는 내가 손으로 쓰는 파일이고, App.Ladder.cs는 래더에서, App.Symbols.cs는 심볼에서 빌드할 때 생성된다. 앞에서 그린 래더와 등록한 심볼이 여기서 C# 코드가 되어 합류한다. 생성되는 두 파일은 열어서 고칠 수는 있지만 빌드 때마다 다시 생성되니 고쳐 봐야 의미가 없다. 원본은 어디까지나 래더와 심볼 페이지다.

Ladder + C# = App

App은 래더와 C#이 합쳐진 하나의 클래스이자, 이 PLC 프로그램의 실체다. 코드 페이지의 세 파일은 PlcApp을 상속한 프로그램의 몸체, App의 파셜(partial)들이다. 그래서 빌드하면 래더 로직도 심볼 프로퍼티도 내가 쓴 C#도 전부 같은 App의 멤버로 합쳐지고 결과물도 App.dll 하나다.

App이 상속하는 PlcApp을 미니멀하게 추리면 이렇다.

public abstract class PlcApp : CsApp
{
    // PLC 메모리 — 래더와 C#이 같이 만진다
    public IBitMemory P { get; }
    public IBitMemory M { get; }
    public IWordMemory T { get; }
    public IWordMemory C { get; }
    public IWordMemory D { get; }

    // 래더 진입점 — 10ms 사이클이 부른다. App.Ladder.cs가 재정의
    public virtual void LadderLoop() { }

    // 사용자 C# 진입점 — 별도 Task로 돈다. App.cs에서 재정의
    protected virtual void Setup() { }
    protected virtual void Loop() { }

    // 래더 펑션 명령들
    protected void TON(int idx, int val, bool condition) { /* … */ }
    protected void CTU(int idx, int preset, bool condition) { /* … */ }
    // …

    // 보드·통신
    public List<IBoard> Boards { get; }
    public List<ICommProtocol> Communications { get; }
}

PlcApp은 이 프로젝트가 아니라 Senbrix.Controller 코어에 있다. 에디터도 이 코어로 빌드하고 라즈베리파이에서 도는 런타임도 같은 코어를 쓰니, 에디터에서 짠 것과 현장에서 도는 게 어긋나지 않는다.

빌드 자체는 특별할 게 없다. 래더와 심볼을 .cs 파일로 디스크에 쓰고 그 프로젝트를 그냥 dotnet build 한다. 내장 컴파일러가 따로 있는 게 아니라 평범한 C# 빌드다. 그래서 에러도 평범한 C# 컴파일 에러로 나온다. 다만 생성된 래더 코드에는 줄마다 원래 래더 좌표가 주석으로 붙어 있어서 컴파일러가 App.Ladder.cs의 몇 번째 줄을 짚으면 그게 래더의 몇 행 몇 열인지로 되짚어 보여준다.

세 파일에 각각 무엇이 담기는지 하나씩 보자.

사용자 C# (App.cs)

App.cs는 세 파일 중 내가 손으로 쓰는 파일이다. 여기 쓰는 C#은 두 갈래로 산다. 하나는 래더가 부르는 함수다. 또 하나는 아두이노식 SetupLoop이다. 앞의 스케치에서 본 진입점을 재정의하는 것으로, Setup은 시작에 한 번, Loop은 계속 도는데 래더 사이클과 부딪히지 않게 별도 Task로 돈다.

심볼 접근 (App.Symbols.cs)

App.Symbols.cs에는 심볼 페이지에 등록한 이름들이 프로퍼티로 생성된다.

public int 생산수량 { get => D[0].W; set => D[0].W = value; }

이름 뒤는 주소 그대로다. 래더도 코드도 이 프로퍼티를 거쳐 같은 이름으로 같은 메모리를 만진다. C#에서 생산수량을 그냥 변수처럼 읽고 쓸 수 있는 이유다.

C# 호출 (App.Ladder.cs)

App.Ladder.cs에는 래더가 C# 코드로 바뀌어 들어간다. 래더 예제의 넷째 줄은 대략 이렇게 나온다.

_result_ = 목표도달 & !보고완료;
if (_result_)
{
    ReportProduction();
}

접점이 조건이 되고 도통하면 함수가 실행된다. 조건식의 목표도달·보고완료는 방금 본 심볼 프로퍼티고, ReportProduction() 호출은 같은 클래스 안이라 리플렉션이나 이벤트가 아니라 그냥 메소드 호출이다.

그런데 이 넷째 줄의 출력도 펑션 자리다. 래더 펑션의 타이머·카운터가 놓이던 그 자리다. 그럼 ReportProduction()은 왜 래더 명령이 아니라 C# 호출이 되나. 래더는 펑션 이름을 먼저 명령어 세트에서 찾는다. TON이나 CTU처럼 아는 이름이면 그 내장 명령을 심고 모르는 이름이면 C# 식으로 그대로 넣는다. ReportProduction은 명령어 세트에 없으니 C# 함수 호출로 들어간다. 같은 펑션 자리가, 아는 이름이면 래더 명령이 되고 모르는 이름이면 내가 쓴 C#이 된다.

확장성

보통 래더는 벤더가 준 명령어 안에서만 논다. 그 목록 밖의 일이 필요하면 거기서 막힌다. 그래서 밖으로 넘긴다. Senbrix 래더는 막히면 C#을 부른다. C#은 .NET 전체를 그대로 쓴다. HTTP로 API를 부르든, DB에 넣든, 파일을 읽든, 복잡한 계산을 하든. 래더가 할 수 있는 일이 래더 명령어에 묶이지 않는다. 래더 펑션의 명령어 세트가 바닥이고, C#이 그 위 천장을 연다.

멀리 갈 것도 없다. 제어·신호·계량 알고리즘 라이브러리가 기본으로 들어 있어서 PID나 필터, 적산 같은 건 직접 짜지 않고 부른다. 그걸로도 모자라면 NuGet 패키지를 붙인다. 빌드 설정 파일에 한 줄 적어두면 다음 빌드에도 살아남는다.

역할은 나뉜다. 언제 할지, 안전한지, 지금 어느 상태인지는 래더가 판단하고, 무엇을 계산하고 어디로 보낼지는 C#이 한다. 펑션 호출이 그 둘을 잇는 다리다.

천장이 열렸다고 아무거나 되는 건 아니다. 래더가 부르는 함수는 래더 사이클 안에서 돈다. 오래 걸리면 그 사이클이 거기서 멈춘다. 10ms가 밀린다. 그래서 await나 블로킹을 쓰지 않는다. 서버로 보내는 것도 직접 던지지 않고 큐에 넣어 뒤에서 처리한다. 함수는 값 읽고 표시만 하고 바로 나온다. 천장은 열렸고, 사이클을 막지 말라는 규칙 하나가 붙는다.

예제

이 예제의 사용자 C#은 래더 넷째 줄이 부른 ReportProduction() 하나다.

public void ReportProduction()
{
    int qty = 생산수량;              // 생산수량(D0) 읽기
    // 서버 보고 자리 — HTTP POST나 DB INSERT
    Console.WriteLine($"[생산보고] 생산수량={qty} 목표 도달");
    보고완료 = true;                 // 보고 끝났다고 래더에 알림
}

생산수량을 읽고, 서버로 보내고, 끝났다고 표시한다. 서버로 보내는 자리는 지금 로그로 뒀지만 HTTP POST든 DB INSERT든 C#으로 쓰면 된다. C#이 쓴 보고완료를 래더 다섯째 줄이 되받아 리셋하는 건 둘이 같은 메모리를 만지기 때문이다. 제어 흐름이 전부 래더에 있어서 SetupLoop은 비워 뒀다.

래더 엔지니어에게 서버 보고나 DB 적재는 원래 남의 영역이다. 그래서 옆에 PC를 세웠다. 여기서는 래더가 기계를 세다가, 밖으로 손을 뻗어야 할 때만 이 함수를 부른다. 래더를 떠나지 않고, 프로그래머가 하던 일을 같은 프로그램 안에서 한다.

정리

현장의 센서가 P0로 들어오면 래더가 세고, 목표에 닿으면 래더가 C#을 부르고, C#이 생산수량을 읽어 보고하고, 보고완료를 쓰면 래더가 그걸 받아 리셋한다. 그 사이 상위 시스템은 Modbus로 생산수량을 읽어가고, 필요하면 목표수량을 바꿔 넣는다.

1편에서 PLC 옆에 PC를 한 대 더 세워 수집 프로그램을 돌리던 일을, 여기서는 래더 몇 줄과 C# 함수 하나로 처리한다. 래더를 짜던 사람이, 래더를 떠나지 않고 서버에 값을 보낸다.

다음 편 — 라즈베리파이 구동

여기까지가 작성이다. 이걸 라즈베리파이에서 실제로 돌리는 건 다음 편에서.

런타임에 배포한 래더를 라이브로 모니터링하는 화면