Lastupdate : 아직 미수행

리눅스나 스크립트에 익숙하지 않더라도 라즈베리파이 부팅 시 파이썬 스트림릿(Streamlit) 웹서버가 자동으로 실행되도록 만드는 가장 표준적이고 안전한 방법인 Systemd 서비스 등록 방법을 차근차근 배워 볼까?.

🛠️ 자동 실행 설정 단계 (따라 하기)

1단계: 서비스 설정 파일 만들기

라즈베리파이 터미널 창을 열고 아래 명령어를 입력하여 시스템 서비스 파일을 생성한다. (여기서는 서비스 이름을 streamlit.service로 지정한 다치고.)

sudo nano /etc/systemd/system/streamlit.service

2단계: 아래 내용을 복사해서 붙여 넣기

창이 열리면 아래 내용을 그대로 복사해서 붙여 넣어 준다. (단, 본인의 라즈베리파이 사용자 계정이 pi가 아니거나 스트림릿 파일 경로가 다르다면 User, WorkingDirectory, ExecStart 경로를 본인 환경에 맞게 수정하자.)

Ini, TOML

[Unit]
Description=Streamlit Web Server
After=network.target

[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/myApp
ExecStart=/usr/local/bin/streamlit run app.py --server.address=0.0.0.0
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
  • WorkingDirectory: 스트림릿 소스 코드가 들어있는 폴더 경로
  • ExecStart: 스트림릿을 실행하는 명령어 경로 (보통 /usr/local/bin/streamlit 또는 python3 -m streamlit)
  • --server.address=0.0.0.0: 라즈베리파이 내부뿐만 아니라 공유기에 연결된 다른 PC나 스마트폰에서도 웹서버에 접속할 수 있게 해주는 설정이다.

수정 후 저장하는 법: 키보드의 Ctrl + O를 누르고 Enter를 쳐서 저장한 뒤, Ctrl + X를 눌러 편집기를 빠져나온다.

3단계: 시스템에 서비스 등록 및 활성화하기

새로 만든 서비스를 리눅스 시스템에 인식시키고, 부팅 시 자동 실행되도록 설정하는 명령어들을 순서대로 입력한다.

# 1. 시스템에 새 서비스 파일 등록 새로고침
sudo systemctl daemon-reload

# 2. 부팅 시 자동 실행(Enable) 설정
sudo systemctl enable streamlit.service

# 3. 지금 당장 서비스 실행해 보기
sudo systemctl start streamlit.service

4단계: 정상 작동 확인하기

웹서버가 백그라운드에서 오류 없이 잘 돌고 있는지 아래 명령어로 상태를 확인한다.

sudo systemctl status streamlit.service
  • 중간에 초록색 글씨로 Active: active (running) 이 표시되면 성공적으로 실행 중인 것!
  • 나가고 싶을 때는 키보드의 q를 누르면 된다.

이제 라즈베리파이를 재부팅(sudo reboot) 하더라도 전원이 켜지면서 스트림릿 웹서버가 자동으로 백그라운드에서 실행될 거다.


앞서 설명한 내용 중 Ini, TOML은 뭘까?

프로그램이나 시스템의 환경 설정 파일(Configuration File)의 포맷(형식)을 뜻한다.

파이썬이나 라즈베리파이에서 프로그램을 만들다 보면 서버 주소, 포트 번호, 비밀번호, 화면 위치 같은 다양한 설정값을 코드 외부에 따로 저장해 두어야 할 때가 많다. 이때 주로 사용하는 대표적인 설정 파일 형식들입니다.

📝 설정 파일 포맷이란?

  1. INI 형식 (.ini)
    • 윈도우 프로그램 등에서 아주 오래전부터 쓰여온 전통적인 방식.
    • 대괄호로 카테고리를 나누고 키=값 형태로 간단하게 작성한다.
    • 예시:
    • Ini, TOML
       
      [Server]
      ip = 192.168.1.10
      port = 502
      
  2. TOML 형식 (.toml)
    • 최근 파이썬 진영(특히 스트림릿이나 패키지 관리 도구 등)에서 설정 파일로 매우 선호하는 최신 포맷.
    • 사람이 읽기 매우 쉽고 구조화된 데이터(주석, 숫자, 리스트 등)를 깔끔하게 표현할 수 있다.
    • 예시:
    • Ini, TOML
       
      [server]
      address = "0.0.0.0"
      port = 8501
      enableCORS = false
      

라즈베리파이에서 스트림릿(Streamlit) 같은 웹서버나 파이썬 프로그램을 실행할 때, 설정을 코드에 직접 적어두지 않고 이런 INITOML 같은 설정 파일에 따로 적어둔 뒤 불러와서 사용하면 나중에 설정을 바꿀 때 코드를 고칠 필요 없이 파일만 수정하면 되기 때문에 매우 편리할걸!

 

  • UNO-220에 탑재된 AdvRaspbian 이미지에는 시리얼 통신을 곧바로 테스트할 수 있도록 전용 명령어(uno220 uartrecv, uno220 uartsend)가 미리 빌드되어 포함되어 있다.
  • 실제 현장에서 센서나 바코드 스캐너, PLC 등의 시리얼 장비를 연결할 때 이 테스트 방식을 응용하면 포트가 정상적으로 통신하는지 쉽게 검증할 수 있다 한번 볼까!.

🔌 통신 포트 (Serial Port) 기능 및 테스트 상세 가이드

어드밴텍(Advantech)의 UNO-220은 산업용 환경에서 흔히 쓰이는 RS-232 / RS-485 시리얼 통신 기능을 지원한다.

어드벤텍 매뉴얼에서는 호스트 PC(우분투)와 라즈베리파이(UNO-220)를 시리얼 케이블(TxD, RxD, GND)로 연결해 데이터를 서로 주고받으며 테스트하는 방법을 안내하고 있지만.

여기서는 통신 테스트는 크게 "1) PC에서 보낸 데이터를 라즈베리가 잘 받는지"와 "2) 라즈베리가 보낸 데이터를 PC가 잘 받는지" 두 가지 방향으로 진행해 보자..

ㅁ 시나리오 1: PC ➔ 라즈베리파이 (PC가 데이터를 보내고 파이가 수신)

PC가 라즈베리파이로 특정 데이터를 송신했을 때, 라즈베리가 이를 정확히 받아내는지를 테스트하는 과정이다.

  1. 라즈베리파이(Pi) 측 준비 (수신 대기):
    • UNO-220 터미널에 아래 명령어를 입력하여 수신 대기 상태를 만든다.
      sudo uno220uartrecv
      
  2. 호스트 PC 측 실행 (데이터 송신):

PC 터미널에서 아래 명령어를 입력해 라즈베리파이 쪽으로 바이트 데이터(\x01\x02\x03)를 전송합니다.

./files/host-x86_64/host_send /dev/ttyUSB0 $(echo -ne "\x01\x02\x03")

결과: 전송이 완료되면 라즈베리파이(UNO-220) 화면에 PC가 보낸 데이터가 프롬프트 형태로 정상 출력된다.

ㅁ 시나리오 2: 라즈베리파이 ➔ PC (파이가 데이터를 보내고 PC가 수신)

반대로 라즈베리파이에서 시리얼 포트를 통해 PC로 데이터를 쏘았을 때, PC가 이를 올바르게 수신하는지 테스트하는 과정.

  1. 호스트 PC 측 준비 (수신 대기):
    • PC 터미널에서 라즈베리파이로부터 오는 신호를 받을 수 있도록 수신 대기 명령어를 실행한다.
      sudo ./host_recv /dev/ttyUSB0
      
  2. 라즈베리파이(Pi) 측 실행 (데이터 송신):
    • UNO-220 터미널에서 시리얼 포트(ttyS0)를 통해 데이터(\x01\x02\x03)를 PC로 전송한다.
      sudo uno220uartsend /dev/ttyS0 $(echo -ne "\x01\x02\x03")
      
    • 결과: 명령어를 실행하면 라즈베리파이가 데이터를 송신하고, PC 측 수신 프로그램 화면에 해당 데이터가 정상적으로 찍히게 된다.

 

 

1. 시리얼 관련 장치 파일 목록 보기 (ls -l /dev/tty*)

가장 널리 쓰이는 방법으로, 시스템에 존재하는 모든 시리얼 통신 장치(하드웨어 UART, USB-to-Serial 컨버터 등)를 나열.

hks003@raspberrypi:/usr/sbin $ ls -l /dev/ttyS* /dev/ttyAMA* /dev/ttyUSB* /dev/ttyACM* 2>/dev/null
crw-rw---- 1 root dialout 204, 64  8월 21 13:10  /dev/ttyAMA0
crw-rw---- 1 root dialout   4, 64  8월 21 13:09  /dev/ttyS0

확인 방법:

  • /dev/ttyS0 또는 /dev/ttyAMA0: 라즈베리파이 기판의 GPIO 핀에 연결된 기본 하드웨어 시리얼 포트다.
  • /dev/ttyUSB0 또는 /dev/ttyACM0: USB 포트에 RS-232/RS-485 컨버터나 아두이노 등을 꽂았을 때 생성되는 포트다.

2. 커널이 인식한 시리얼/UART 정보 확인하기 (dmesg 또는 lsto)

시스템이 부팅되면서 어떤 시리얼 포트를 활성화했는지 로그로 확인할 수 있다.

hks003@raspberrypi:/usr/sbin $ dmesg | grep tty
[    0.000304] printk: console [tty0] enabled
[    1.470948] fe201000.serial: ttyAMA0 at MMIO 0xfe201000 (irq = 19, base_baud = 0) is a PL011 rev2
[    1.479715] fe215040.serial: ttyS0 at MMIO 0xfe215040 (irq = 21, base_baud = 62500000) is a 16550
[    2.817069] systemd[1]: Created slice system-getty.slice.

확인 방법: 출력 결과 중에 uart-pl011이나 ttyS0, ttyAMA0 같은 하드웨어 포트가 정상적으로 등록되어 사용 가능한 상태인지 확인할 수 있다.

Lastupdate : 2026. 08.26

1. 사전 준비 (라이브러리 설치)

Visual Studio의 [도구] ➔ [NuGet 패키지 관리자] ➔ [설루션용 NuGet 패키지 관리]에서 아래 라이브러리를 검색해서 설치하자.

  • 패키지 이름: NModbus (또는 최신 .NET 환경에서 호환되는 NModbus 포크 버전) (또는 패키지 관리자 콘솔에서 Install-Package NModbus 입력)
  • 설치화면 I.

  • 설치화면 II.

Install-Package NModbus
  • 설치확인화면 III.

사용예: 

using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using NModbus;

string serverIp = "172.16.32.108"; // 대상 장치 IP
int port = 502;

CancellationTokenSource cts = new CancellationTokenSource();

Console.WriteLine("[시스템] Modbus TCP 자동 재접속 클라이언트를 시작...");

// 메인 루프: 연결이 끊기면 무한으로 재접속 시도
while (!cts.Token.IsCancellationRequested)
{
    TcpClient client = null;
    IModbusMaster master = null; // 수정됨: IModbusIpMaster -> IModbusMaster

    try
    {
        // 1. 서버 연결 시도
        client = new TcpClient();
        Console.WriteLine($"[연결 시도] {serverIp}:{port} 연결 중...");

        // 연결 타임아웃 설정 (3초 안에 응답 없으면 예외 발생)
        var connectTask = client.ConnectAsync(serverIp, port);
        if (await Task.WhenAny(connectTask, Task.Delay(3000)) != connectTask)
        {
            throw new TimeoutException("연결 시간 초과");
        }

        Console.WriteLine("[연결 성공] 통신을 시작.");

        // 2. NModbus 3.x 버전에 맞는 Master 생성 방식
        var factory = new ModbusFactory();
        master = factory.CreateMaster(client); // 수정됨: CreateIpMaster -> CreateMaster

        // 3. 수신(폴링)을 담당하는 백그라운드 스레드 실행
        await RunCommunicationLoop(master, cts.Token);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"[통신 오류 또는 연결 끊김] {ex.Message}");
    }
    finally
    {
        // 사용한 자원(소켓 등) 깨끗이 정리
        master?.Dispose();
        client?.Dispose();
        Console.WriteLine("[자원 해제] 연결을 닫고 재접속을 준비.");
    }

    // 4. 바로 재시도하면 서버에 부하를 주므로 3초 대기 후 재접속 시도
    if (!cts.Token.IsCancellationRequested)
    {
        Console.WriteLine("[재접속 대기] 3초 후 다시 연결을 시도...");
        await Task.Delay(3000, cts.Token);
    }
}

// ==========================================
// 통신 및 데이터 수신 루프 메서드
// ==========================================
async Task RunCommunicationLoop(IModbusMaster master, CancellationToken cancellationToken) // 수정됨
{
    byte slaveId = 1;
    ushort startAddress = 0;
    ushort numRegisters = 10;

    while (!cancellationToken.IsCancellationRequested)
    {
        // 데이터 읽기 (Function 3 예시)
        ushort[] registers = await master.ReadHoldingRegistersAsync(slaveId, startAddress, numRegisters);
        Console.WriteLine($"[수신 데이터] Reg[0]: {registers[0]}, Reg[1]: {registers[1]}");

        // 주기 조절 (1초 간격)
        await Task.Delay(1000, cancellationToken);
    }
}

라이브러리를 사용할지, 아니면 기존처럼 C# 기본 TcpClient로 직접 소켓 통신을 구현할지 고민.

두 방식의 장단점을 명확하게 비교

1. NModbus 라이브러리를 사용할 때

  • 장점:
    • 생산성 최고: Modbus 프로토콜은 바이트 단위의 프레임(Function Code, 주소, 데이터, CRC 체크섬 등)을 엄격하게 지켜야 하는데, 라이브러리가 이 복잡한 과정을 전부 알아서 처리해 줌 (master.ReadHoldingRegistersAsync 한 줄이면 끝난다.)
    • 오류 방지: 통신 과정에서 발생할 수 있는 프로토콜 규칙 오류나 CRC 에러 계산 등을 직접 구현할 필요가 없어 개발 시간이 획기적으로 줄어든다.
  • 단점:
    • 혹시 겪을수 있는 라이브러리 버전(3.x)에 따라 네임스페이스나 메서드 이름이 바뀔 때 문서를 찾아봐야 하는 약간의 러닝커브가 있다.

2. 직접 TcpClient로 구현할 때

  • 장점:
    • 외부 의존성 제로: NuGet 패키지를 쓸 필요가 없으므로 버전 호환성 문제로 골치 아플 일이 전혀 없다.
    • 완벽한 제어: Modbus TCP 패킷(MBAP Header + PDU) 구조를 직접 바이트 배열(byte [])로 조립하고 파싱 하므로, 프로토콜 구조를 뼛속까지 이해할 수 있다.
  • 단점:
    • 구현의 번거로움: 소켓으로 바이트를 보낸 뒤, 응답 데이터의 바이트 배열을 받아와서 Function Code가 에러인지 정상인지, CRC나 트랜잭션 ID는 맞는지 직접 쪼개고 검증하는 코드를 일일이 다 짜야 한다. (생각보다 손이 많이 가고 실수가 발생하기 쉽다.)

💡 최종 추천

  • 안전하고 빠른 개발을 원한다면 👉 NModbus 라이브러리 사용을 추천.
    • 비록 처음에 설정과 버전 차이 때문에 약간의 시행착오가 있었지만, 일단 한번 세팅해 두면 이후부터는 센서나 PLC 데이터를 읽고 쓰는 비즈니스 로직(핵심 기능) 개발에만 집중할 수 있어 훨씬 효율적.
  • 만약 프로토콜을 직접 바이트 단위로 제어하는 재미를 느끼고 싶거나 외부 라이브러리 자체를 배제하고 싶다면 👉 직접 구현도 나쁘지 않지만, 산업용 현장에서 안정성과 유지보수를 고려한다면 검증된 라이브러리를 쓰는 것이 실무 표준이래나 뭐래나.

이미 NModbus 패키지도 설치했고 기본 구조를 잡아가고 있으니, 이번 기회에 라이브러리를 활용해 빠르게 기능을 완성해 볼가 한다 나는~~ 

using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using NModbus;

class Program
{
    static async Task Main(string[] args)
    {
        // 1. 설정 정보 (라즈베리파이에서 접속할 대상 장치 IP와 포트)
        string serverIp = "192.168.0.4"; // 대상 장치 IP (필요시 변경)
        int port = 502;                  // Modbus TCP 기본 포트

        CancellationTokenSource cts = new CancellationTokenSource();

        // 콘솔 종료 시 안전하게 취소하기 위한 이벤트 처리 (Ctrl + C)
        Console.CancelKeyPress += (sender, e) =>
        {
            e.Cancel = true;
            cts.Cancel();
            Console.WriteLine("\n[시스템] 종료 요청을 받았습니다. 자원을 정리합니다...");
        };

        Console.WriteLine("[시스템] 라즈베리파이 Modbus TCP 콘솔 클라이언트를 시작합니다...");
        Console.WriteLine($"[대상 정보] IP: {serverIp}, Port: {port}");
        Console.WriteLine("------------------------------------------------------------");

        // 메인 루프: 연결이 끊기면 3초 후 무한 재접속 시도
        while (!cts.Token.IsCancellationRequested)
        {
            TcpClient client = null;
            IModbusMaster master = null;

            try
            {
                // 2. TCP 서버 연결 시도 (3초 타임아웃)
                client = new TcpClient();
                Console.WriteLine($"\n[연결 시도] {serverIp}:{port} 연결 중...");

                var connectTask = client.ConnectAsync(serverIp, port);
                if (await Task.WhenAny(connectTask, Task.Delay(3000, cts.Token)) != connectTask)
                {
                    throw new TimeoutException("연결 시간 초과 (3초)");
                }

                Console.WriteLine("[연결 성공] Modbus 통신을 시작합니다. (1초 주기 요청)");

                // 3. NModbus Master 생성
                var factory = new ModbusFactory();
                master = factory.CreateMaster(client);

                byte slaveId = 1;             // 장비 ID (Unit ID)
                ushort startAddress = 0x0004;   // 시작 주소 (0x04)
                ushort numRegisters = 0x59;   // 요청 개수 (0x59 = 89개)

                // 4. 1초 간격 데이터 요청 및 디스플레이 루프
                while (!cts.Token.IsCancellationRequested)
                {
                    try
                    {
                        // Function Code 4: Read Input Registers
                        ushort[] registers = await master.ReadInputRegistersAsync(slaveId, startAddress, numRegisters);

                        // 수신된 데이터를 1궤도(Hex 형식)로 변환
                        string hexValues = string.Join(", ", Array.ConvertAll(registers, r => $"0x{r:X4}"));

                        // 화면 출력 (디스플레이)
                        Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] ➔ [수신 성공] 총 {registers.Length}개 레지스터 읽기 완료");
                        Console.WriteLine($"      Hex 데이터: {hexValues}");

                        // 예시로 첫 번째 레지스터 값(Reg[0])의 십진수 값도 함께 출력
                        if (registers.Length > 0)
                        {
                            Console.WriteLine($"      Reg[0] Dec 값: {registers[0]}");
                        }
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine($"  ➔ [통신 에러 발생]: {ex.Message}");
                        break; // 통신 에러 발생 시 내부 루프를 탈출하여 재접속 수행
                    }

                    // 1초 대기
                    await Task.Delay(1000, cts.Token);
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"[연결 오류]: {ex.Message}");
            }
            finally
            {
                // 자원 정리
                master?.Dispose();
                client?.Dispose();
                Console.WriteLine("[자원 해제] 연결을 닫았습니다. 3초 후 재접속을 시도합니다.");
            }

            // 재접속 대기 (취소 요청이 없을 경우)
            if (!cts.Token.IsCancellationRequested)
            {
                try
                {
                    await Task.Delay(3000, cts.Token);
                }
                catch (TaskCanceledException)
                {
                    break;
                }
            }
        }

        Console.WriteLine("[시스템] 프로그램이 완전히 종료되었습니다.");
    }
}

CModbusClientTcp.cs

using NModbus;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;

namespace ENS_RaspberryPi
{
    /// <summary>
    /// 윈도우용 CTcpModbusClient 역할을 대체하는 라즈베리파이용 Modbus TCP 워커 클래스
    /// </summary>
    class CModbusClientTcp
    {
        private readonly string _deviceName;
        private readonly string _ipAddress;
        private readonly int _port;
        private readonly int _intervalMs;

        public CModbusClientTcp(string deviceName, string ipAddress, int port, int intervalMs)
        {
            _deviceName = deviceName;
            _ipAddress = ipAddress;
            _port = port;
            _intervalMs = intervalMs;
        }

        public async Task StartAsync(CancellationToken cancellationToken)
        {
            //취소 신호가 들어오기 전까지 계속해서 통신을 시도하고 유지하는 큰 틀의 while 반복문.
            while (!cancellationToken.IsCancellationRequested)
            {
                TcpClient client = null;
                IModbusMaster master = null;

                try
                {
                    // TCP 연결 준비: 소켓 통신을 위한 TcpClient를 만들고, 지정한 IP와 포트로 비동기 연결을 시도.
                    Console.WriteLine($"\n[{_deviceName}] 연결 시도중... ({_ipAddress}:{_port})");

                    client = new TcpClient();
                    var connectTask = client.ConnectAsync(_ipAddress, _port);

                    // 3초 안에 연결되지 않으면 타임아웃 예외 발생 :
                    // 장치가 꺼져있거나 네트워크가 끊겨서 무한 대기에 빠지는 걸 막기 위해,
                    // "딱 3초만 기다려보고 연결 안 되면 에러를 발생시키고(TimeoutException)" 타이머를 건다.
                    if (await Task.WhenAny(connectTask, Task.Delay(3000, cancellationToken)) != connectTask)
                    {
                        throw new TimeoutException("TCP 연결 시간 초과");
                    }

                    // 모드버스 마스터 생성:
                    // TCP 연결이 성공하면 NModbus 라이브러리의 ModbusFactory를 이용해 통신 master를 만든다.
                    // 그리고 슬레이브 ID는 1, 읽기 시작할 주소는 0x0004, 개수는 0x59(89개)로 정의합니다.
                    Console.WriteLine($"[{_deviceName}] 연결 성공! 1초 주기 데이터 요청 시작.");

                    var factory = new ModbusFactory();
                    master = factory.CreateMaster(client);

                    byte unitId = 1;
                    ushort startAddress = 0x0004; // 윈도우 코드에서 사용한 시작 주소 예시
                    ushort registerCount = 0x59;  // 89개 레지스터 요청

                    // 순차적 단일 스레드 비동기 루프 (교착 상태 없음) 데이터 반복 수신 루프:
                    // 1.master.ReadInputRegistersAsync: Func 4번을 이용해 장치로부터 입력 레지스터 데이터를 싹 긁어옴.
                    // 2.DisplayReceivedData: 받아온 데이터를 화면에 출력.
                    // 3.통신 도중 에러가 나면 break로 안쪽 루프를 탈출해 재접속 단계로 넘어감.
                    // 4.await Task.Delay: 설정한 주기(여기서는 0.2초)만큼 쉬었다가 다시 데이터를 요청한다.                    

                    // [수정 포인트 1] 내부 루프에서 복잡한 통신 로직을 별도 메서드로 분리하여 깔끔하게 호출
                    while (!cancellationToken.IsCancellationRequested)
                    {
                        bool isSuccess = await ReadAndDisplayDataAsync(master, unitId, startAddress, registerCount, cancellationToken);

                        // 통신 에러가 발생했다면(false 리턴) 내부 루프를 탈출하여 재접속 단계로 이동
                        if (!isSuccess)
                        {
                            break;
                        }

                        // 설정된 간격 만큼 대기
                        await Task.Delay(_intervalMs, cancellationToken);
                    }
                }
                
                // 통신이 끊기거나 에러가 나서 루프를 빠져나오면
                
                catch (Exception ex)
                {
                    Console.WriteLine($"[{_deviceName}] 연결/통신 오류: {ex.Message}");
                }
                finally // 자원 정리 및 재시도 
                {
                    //열어뒀던 통신망을 정리(Dispose) 메모리 누수막음
                    master?.Dispose();
                    client?.Dispose();
                    Console.WriteLine($"[{_deviceName}] 연결 해제. 3초 후 재접속을 시도합니다.");
                }

                // 3초 뒤에 다시 처음부터 while 문을 돌며 자동 재접속을 시도.
                // 재접속 전 3초 대기
                if (!cancellationToken.IsCancellationRequested)
                {
                    try
                    {
                        await Task.Delay(3000, cancellationToken);
                    }
                    catch (TaskCanceledException)
                    {
                        break;
                    }
                }
            }
        }
        private async Task<bool> ReadAndDisplayDataAsync(IModbusMaster master, byte unitId, ushort startAddress, ushort registerCount, CancellationToken cancellationToken)
        {
            try
            {
                // Function Code 4 (Read Input Registers) - 취소 토큰을 함께 전달하여 안전성 확보
                ushort[] registers = await master.ReadInputRegistersAsync(unitId, startAddress, registerCount);

                // 수신 데이터 디스플레이 출력
                DisplayReceivedData(registers);

                return true; // 성공 시 true 반환
            }
            catch (Exception ex)
            {
                Console.WriteLine($"[{_deviceName}] 통신 에러 발생: {ex.Message}");
                return false; // 에러 발생 시 false를 반환하여 바깥쪽 루프가 재접속을 하도록 유도
            }
        }

        private void DisplayReceivedData(ushort[] registers)
        {
            string timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
            Console.WriteLine($"[{timestamp}] [{_deviceName}] 수신 완료 (총 {registers.Length}개 레지스터)");

            // 수신된 데이터를 1궤도(Hex 형식)로 변환
            string hexValues = string.Join(", ", Array.ConvertAll(registers, r => $"0x{r:X4}"));

            // 화면 출력 (디스플레이)
            Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] ➔ [수신 성공] 총 {registers.Length}개 레지스터 읽기 완료");
            Console.WriteLine($"      Hex 데이터: {hexValues}");

            // 예시로 첫 번째 레지스터 값(Reg[0])의 십진수 값도 함께 출력
            if (registers.Length > 0)
            {
                Console.WriteLine($"  ➔ Reg[0] Dec: {registers[0]}, Hex: 0x{registers[0]:X4}");
            }
        }

    }
}

Program.cs

/*
 * 모듈이름 :
 * 작성일자 : 2026. 08. 25
 * 작성자명 : Hwang KyuSeok
 * 
 * 
 * **/

using ENS_RaspberryPi;  // CModbusClientTcp 클래스 사용
using NModbus;          // NModbus 라이브러리 사용
using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

Console.WriteLine("[시스템] 라즈베리파이 Modbus TCP 클라이언트 시작...");

// 윈도우용 소스의 Cfg_Tcp 설정값들을 빗대어 객체 생성
// (예: 설비용 PCS 또는 피시험품 PCS 접속 정보)
var modbusWorker = new CModbusClientTcp(
    deviceName: "PCS_Equipment",
    ipAddress: "192.168.0.4",
    port: 502,
    intervalMs: 200
);

// 취소 토큰 소스(버튼장치) 생성 : 언제든지 취소 요청 가능
CancellationTokenSource cts = new CancellationTokenSource();

// Ctrl + C 입력 시 안전하게 종료 :
// Ctrl + C를 누를 경우, 프로그램을 강제로 튕기지 않고 안전하게 멈추기 위해
// 위에서 만든 취소 리모컨(cts.Cancel())을 누르도록 이벤트를 등록.
Console.CancelKeyPress += (sender, e) =>
{
    e.Cancel = true;
    cts.Cancel();
    Console.WriteLine("\n[시스템] 종료 중...");
};

// 백그라운드에서 주기적 통신 시작:
// 앞에서 만든 통신 로봇에게 '지금부터 비동기(StartAsync)로 통신을 시작해 줘!' 하고 명령을 내림.
// 프로그램이 멈추지 않고 계속 데이터를 주고받도록 백그라운드에서 실행된다.
var workTask = modbusWorker.StartAsync(cts.Token);

// 프로그램이 종료되지 않도록 대기 :
// 프로그램이 사용자나 시스템에 의해 종료될 때까지 안전하게 기다리다,
// 완전히 끝나면 마지막 문구를 띄우고 프로그램을 종료다.

try
{
    await workTask;
}
catch (TaskCanceledException)
{
    // 정상 취소
}

Console.WriteLine("[시스템] 프로그램이 종료되었습니다.");
using NModbus;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;

namespace ENS_RaspberryPi
{
    class CModbusClientTcp
    {
        private readonly string _deviceName;
        private readonly string _ipAddress;
        private readonly int _port;
        private readonly int _intervalMs;

        public CModbusClientTcp(string deviceName, string ipAddress, int port, int intervalMs)
        {
            _deviceName = deviceName;
            _ipAddress = ipAddress;
            _port = port;
            _intervalMs = intervalMs;
        }

        public async Task StartAsync(CancellationToken cancellationToken)
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                TcpClient client = null;
                IModbusMaster master = null;

                try
                {
                    Console.WriteLine($"\n[{_deviceName}] 연결 시도중... ({_ipAddress}:{_port})");

                    client = new TcpClient();
                    var connectTask = client.ConnectAsync(_ipAddress, _port);

                    if (await Task.WhenAny(connectTask, Task.Delay(3000, cancellationToken)) != connectTask)
                    {
                        throw new TimeoutException("TCP 연결 시간 초과");
                    }

                    Console.WriteLine($"[{_deviceName}] 연결 성공! 개별 주기 다중 블록 요청 시작.");

                    var factory = new ModbusFactory();
                    master = factory.CreateMaster(client);
                    byte unitId = 1;

                    // 1초나 0.2초 간격으로 각 블록을 순차적으로 타격하는 루프
                    while (!cancellationToken.IsCancellationRequested)
                    {
                        // 1번 블록 요청
                        if (!await ReadAndDisplayBlockAsync(master, unitId, 0x0004, 0x56, "블록1")) break;
                        await Task.Delay(_intervalMs, cancellationToken);

                        // 2번 블록 요청
                        if (!await ReadAndDisplayBlockAsync(master, unitId, 0x005a, 0x68, "블록2")) break;
                        await Task.Delay(_intervalMs, cancellationToken);

                        // 3번 블록 요청
                        if (!await ReadAndDisplayBlockAsync(master, unitId, 0x00c2, 0x4e, "블록3")) break;
                        await Task.Delay(_intervalMs, cancellationToken);
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"[{_deviceName}] 연결/통신 오류: {ex.Message}");
                }
                finally
                {
                    master?.Dispose();
                    client?.Dispose();
                    Console.WriteLine($"[{_deviceName}] 연결 해제. 3초 후 재접속을 시도합니다.");
                }

                if (!cancellationToken.IsCancellationRequested)
                {
                    try
                    {
                        await Task.Delay(3000, cancellationToken);
                    }
                    catch (TaskCanceledException)
                    {
                        break;
                    }
                }
            }
        }

        private async Task<bool> ReadAndDisplayBlockAsync(IModbusMaster master, byte unitId, ushort startAddress, ushort registerCount, string blockName)
        {
            try
            {
                // Function Code 4: Read Input Registers
                ushort[] registers = await master.ReadInputRegistersAsync(unitId, startAddress, registerCount);

                DisplayReceivedData(registers, blockName, startAddress);
                return true;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"[{_deviceName}] [{blockName}] 통신 에러: {ex.Message}");
                return false; // 에러 발생 시 false를 반환해 전체 루프를 끊고 재접속 유도
            }
        }

        private void DisplayReceivedData(ushort[] registers, string blockName, ushort startAddress)
        {
            string timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
            string hexValues = string.Join(", ", Array.ConvertAll(registers, r => $"0x{r:X4}"));

            Console.WriteLine($"[{timestamp}] [{_deviceName}] [{blockName}] 수신 성공 (Start: 0x{startAddress:X4}, 개수: {registers.Length})");
            Console.WriteLine($"      Hex: {hexValues}");
        }
    }
}

- 기존에 익숙했던 Visual Studio (Community 2026 버전 기준)를 활용하여, 메인 윈도 PC에서 프로젝트를 생성하고,

- 리눅스(라즈베리파이 4B)에 맞게 빌드한 뒤

- 결과물만 복사해 실행하는 과정을

1단계: 윈도우 PC의 Visual Studio에서 프로젝트 생성하기

  1. Visual Studio 2026을 실행하고 [새 프로젝트 만들기]를 선택
  2. 프로젝트 템플릿 검색창에 "콘솔" 또는 "Console"을 입력.
  3. C# 언어 기반의 [콘솔 앱 (Console App)]을 선택하고 다음으로 넘어간다. (이때. NET 버전은 라즈베리파이에 설치하신 버전에 맞춰. NET 8.0 이상을 선택.)
  4. 프로젝트 이름과 저장 위치를 지정하고 [만들기]를 누르면 기본 코드가 생성.

2단계: 리눅스(라즈베리파이) 타깃에 맞게 빌드(게시)하기

라즈베리파이 4B(64비트 기준)에서 바로 실행할 수 있도록 윈도 PC에서 리눅스용 파일로 변환(Publish)하는 과정.

  1. Visual Studio 상단 메뉴에서 [빌드] ➔ [게시(Publish)...]를 선택합니다. (또는 설루션 탐색기에서 프로젝트 우클릭 ➔ [게시])
  2. 대상(Target) 선택 창이 뜨면 [폴더(Folder)]를 선택하고 다음으로 넘어간다.
  3. 폴더 위치를 지정하고 완료하면 게시 프로필이 생성된다.
  4. 게시 설정 화면에서 우측 상단의 [편집(Edit)] 아이콘을 클릭한다.
  5. 설정 창에서 아래 항목들을 변경한다.
    • 대상 프레임워크(Target Framework): net8.0
    • 배포 모드(Deployment Mode): 프레임워크 종속(Framework-dependent) 또는 단일 파일(Self-contained) 중 선택
      • : 라즈베리파이에 .NET 8 런타임이 이미 설치되어 있다면 용량이 가벼운 프레임워크 종속을, 런타임 설치 없이 단일 파일로 실행하고 싶다면 단일 파일을 선택한다.
    • 대상 런타임(Target Runtime): linux-arm64 (라즈베리파이 4B 64비트 OS 기준)로 변경하고 [저장]을 클릭.
  6. 화면 우측 상단의 [게시(Publish)] 버튼을 클릭.
  7. 빌드가 완료되면 지정한 폴더에 라즈베리파이에서 구동될 실행 파일과 라이브러리 결과물들이 쏙 뽑혀 나오게 된다.

3단계: 결과물을 라즈베리파이로 복사하고 실행하기

이제 윈도우에서 추출된 결과물 파일들을 라즈베리파이로 이동시켜 실행하면 됨.

  1. 파일 전송하기:
    • 방금 게시(Publish) 폴더에 생성된 모든 파일과 폴더를 복사한다.
    • 네트워크를 통해 라즈베리파이로 보내거나, USB 메모리에 담아 라즈베리파이에 꽂은 뒤 원하는 폴더(예: /home/pi/myApp)로 붙여 넣기 한다.
  2. 실행 권한 부여하기 (터미널에서 진행):
    • 라즈베리파이 터미널을 열고, 파일이 복사된 폴더로 이동한다.
    cd /home/pi/myApp
    
    • 메인 프로그램(프로젝트 이름으로 된 실행 파일)에 실행 권한을 부여합니다.
    chmod +x YourProjectName
    
  3. 프로그램 실행하기:
    ./YourProjectName
    

이 워크플로우를 사용면 성능이 제한된 라즈베리파이에서 직접 무겁게 코딩하거나 빌드할 필요 없이, 익숙하고 강력한 Visual Studio에서 편하게 개발한 뒤 결과물만 쏙 넣어 가볍고 빠르게 테스트할 수 있다!


다음은 Windows에서 개발된 프로젝트를 컴파일 후 리눅스용으로 배포하기 위한 위에 설명된 내용일부를 캡처한 화면.

1) [빌드] 메뉴에서 

2) 또는 [설루션 탐색기]에서

3). 폴더선택

4) 구성 Release를 클릭해서 편집한다.

 

5) 설치할 대상으로 프로필 설정

6) <게시> 버튼을 클릭해 파일을 생성한다.

 

생성된 디렉터리를 통째로 복사해서 설치할 장치에 붙여 넣으면 끝!

실행 전에 chmod로 속성을 변경하지 않으면 아래 화면처럼 허가거부!

한번 더 설명  chmode +x 프로젝트명


윈도 작업 PC에서 PuTTY로 SSH 접속해 사용 중, 윈도우와 라즈베리파이 간에 파일을 손쉽고 안전하게 업로드/다운로드할 수 있는 공개 버전(무료) 전용 툴들 추천!

1. WinSCP (가장 추천하는 툴)

PuTTY와 짝꿍처럼 가장 많이 쓰이는 대표적인 무료(Open Source) 파일 전송 프로그램.

  • 특징: 윈도우 탐색기처럼 좌측(내 PC)과 우측(라즈베리파이) 화면이 나뉘어 있어서 마우스 드래그 앤 드롭으로 파일을 편하게 복사·이동할 수 있습니다. SSH(SFTP) 기반이라 PuTTY를 쓸 때 입력하던 라즈베리파이 IP, 계정, 비밀번호 그대로 연동된다.
  • 다운로드 공식 사이트: WinSCP 공식 홈페이지

2. FileZilla (파일질라 클라이언트) - 나는 비추

대용량 파일이나 여러 파일을 동시에 안정적으로 전송할 때 유용한 유명한 무료 FTP/SFTP 클라이언트.

  • 특징: 직관적인 인터페이스와 빠른 전송 속도를 자랑하며, 오픈소스(GPL) 기반의 완전한 무료 공개 버전을 제공.
  • 다운로드 공식 사이트: FileZilla 공식 다운로드 페이지 (클라이언트 버전을 받으시면 된다)

💡 PuTTY와 함께 쓸 때의 꿀팁 (PSCP)

만약 프로그램을 따로 설치하기 귀찮고 명령어 기반이 편하시다면, PuTTY를 설치할 때 함께 다운로드되는 PSCP(PuTTY Secure Copy client)라는 도구를 활용해 윈도우 명령 프롬프트(CMD)에서 명령어로 바로 파일을 보낼 수도 있다.

이 방법은 별도로 작성.

하지만 일반적인 파일 업로드 작업에는 화면을 보며 마우스로 끌어다 넣을 수 있는 WinSCP를 설치해서 사용하시는 것이 가장 편리하고 직관적이지!

1단계: 명령 프롬프트(CMD) 창 열기

  1. 윈도 작업 툴바의 검색창이나 실행 창(Win + R)에 cmd를 입력하고 엔터를 쳐서 명령 프롬프트를 연다.
  2. 윈도의 기본 명령 프롬프트나 PowerShell 어디서든 사용 가능하다.

2단계: 기본 명령어 구조 알아두기

PSCP의 기본적인 전송 명령어 구조는 다음과 같다.

pscp [옵션] [보낼 로컬 파일 경로] [라즈베리파이계정@아이피주소:받을라즈베리파이경로]

3단계: 실제 파일 전송해 보기 (예시)

만약 윈도우 PC의 C:\MyProjects\MyApp 폴더에 있는 MyApp (또는 빌드된 파일들)을 라즈베리파이의 /home/pi/myApp 폴더로 보내고 싶다면 아래와 같이 입력한다.

1) 단일 파일 전송할 때

pscp C:\MyProjects\MyApp\MyApp pi@192.168.0.50:/home/pi/myApp/
  • 192.168.0.50 부분은 본인의 라즈베리파이 IP 주소로 입력해야 한다.
  • 엔터를 치면 라즈베리파이 비밀번호를 물어봅니다. 비밀번호를 입력하면(입력할 때 화면에 별표나 글자가 안 나오는 것은 리눅스 보안상 정상입니다) 전송이 시작된다.

2) 폴더 통째로 전송할 때 (-r 옵션)

빌드된 결과물 폴더 안에 여러 파일과 하위 폴더가 함께 있다면 -r (Recursive) 옵션을 붙여주어야 폴더 통째로 안전하게 복사된다.

Bash
 
pscp -r C:\MyProjects\MyApp\publish\ pi@192.168.0.50:/home/pi/myApp/

💡 처음 접속할 때 참고할 팁

  • 서버 보안 경고(Fingerprint): 처음 pscp를 이용해 라즈베리파이에 접속할 때 Store key in cache? (y/n) 같은 메시지가 뜰 수도 있다. 이때는 y를 입력하고 엔터를 치시면 다음부터는 묻지 않고 바로 전송된다.
  • 경로에 띄어쓰기가 있을 때: 윈도우 파일 경로에 띄어쓰기가 포함되어 있다면 경로 전체를 큰따옴표(" ")로 묶어주면 오류를 방지할 수 있다. (예: pscp -r "C:\My Project\publish\" pi@192.168.0.50:/home/pi/myApp/)

이렇게 CMD창에서 명령어 한 줄만 치면 윈도에서 개발한 결과물이 라즈베리파이로 쏙 전송되므로, 익숙해지면 아주 편리하게 쓰실 수 있다!

라즈베리파이 터미널이나 원격 접속(PuTTY 등) 화면에서 한글이 네모(□) 형태로 깨지는 현상은 시스템에 한글 폰트가 설치되어 있지 않거나, 터미널 프로그램의 문자 인코딩 설정이 UTF-8로 지정되지 않았을 때 발생한다.

이 문제를 해결하기 위해 시스템 설정과 원격 접속 설정 순서대로 해결 방법을 적용해 보자.

1단계: 라즈베리파이 시스템에 한글 폰트 설치하기

라즈베리파이 터미널에 직접 접속하여 아래 명령어를 차례대로 입력해 한글 폰트를 설치한다.

Bash
 
# 1. 패키지 목록 업데이트
sudo apt update

# 2. 나눔고딕 폰트(한글 지원) 설치
sudo apt install -y fonts-nanum

2단계: 로케일(Locale) 및 언어 설정 변경

시스템이 한글(UTF-8)을 올바르게 처리할 수 있도록 언어 환경을 설정한다.

  1. 설정 도구 실행:
  2. Bash
     
    sudo raspi-config
    
  3. 메뉴 이동: Localisation OptionsLocale 순으로 선택한다.
  4. 목록에서 아래 두 항목을 찾아 체크(Space 바)한다.
    • ko_KR.UTF-8 UTF-8
    • en_US.UTF-8 UTF-8 (기본 시스템 언어용)
  5. 기본(Default) 로케일 설정 화면이 나오면 en_US.UTF-8 또는 ko_KR.UTF-8을 선택하고 완료한다.
  6. 설정이 완료되면 라즈베리파이를 재부팅한다.
  7. Bash
     
    sudo reboot
    

3단계: 원격 접속 프로그램(PuTTY) 설정 확인

만약 PC에서 PuTTY를 통해 원격으로 접속했을 때 한글이 깨진다면, PuTTY 자체의 인코딩과 폰트 설정이 잘못되었을 확률이 높다.

  1. 인코딩 설정 (가장 중요)
    • PuTTY 초기 설정 창(또는 접속 중 상단 타이틀바 우클릭 ➔ Change Settings...)을 연다.
    • 좌측 메뉴에서 WindowTranslation으로 이동한다.
    • Received data assumed to be in which character set: 항목을 기존 값에서 UTF-8로 변경한다.
  2. 한글 지원 폰트 설정
    • 좌측 메뉴에서 WindowAppearance로 이동한다.
    • Font settings 아래의 Change... 버튼을 클릭한다.
    • 글꼴을 한글 출력이 원활한 '나눔 고딕', 'Malgun Gothic'(맑은 고딕) 등으로 변경하고 확인을 클릭한다.
  3. 설정 저장
    • 다시 좌측 메뉴 상단의 Session으로 돌아간 후, 저장된 세션을 선택하고 Save 버튼을 눌러 설정을 영구 저장한다.

+ Recent posts