Qt와 MFC에서 사용하기
Modbus SDK는 스레드를 만들지 않고, 클라이언트 요청은 응답이나 timeout_ms까지 호출한 스레드를 막습니다. GUI 애플리케이션은 다음 규칙을 지킵니다.
- 엔드포인트는 작업 스레드(worker thread)에서만 호출합니다. GUI 스레드에서
connect나request를 부르면 그동안 화면이 멈춥니다. - 서버와 게이트웨이는 작업 스레드에서
{"action":"poll"}을 짧은 주기(예: 5ms)로 반복해야 요청에 응답합니다. - 결과는 큐에 넣은 신호나 메시지로 GUI 스레드에 넘깁니다.
- 같은 엔드포인트의 호출은 라이브러리가 직렬화합니다. 그래도 엔드포인트 하나는 작업 스레드 하나가 맡는 구성이 단순합니다.
QtEndpoint는 QJsonObject 명령을 받아 결과의 result 객체를 QJsonObject로 돌려줍니다. 실패하면 std::runtime_error를 던집니다. 헤더는 SDK의 bindings/qt/PdcommModbusQt.hpp이고 QtCore만 씁니다. 신호와 스레드는 직접 만들지 않습니다.
다음 예제는 TCP 서버와 클라이언트를 각각 작업 스레드에 둡니다. 서버 작업 스레드는 5ms마다 poll을 보내고, 클라이언트 작업 스레드는 막히는 connect와 request를 실행합니다. 주 스레드는 GUI 스레드 역할로, 명령을 큐에 넣고 결과를 큐로 받기만 합니다.
// Modbus TCP server and client on Qt worker threads (QtCore only).//// Each endpoint is used only from its own worker thread. The server worker// sends {"action":"poll"} every 5 ms, because the SDK has no background// thread; the client worker runs the blocking connect and request commands.// The main thread, which stands in for a GUI thread, only queues commands to// the workers and receives their results as queued calls, so it never blocks.//// With --tls <directory> both endpoints use Modbus/TCP Security with the files// of the Modbus/TCP Security tutorial in that directory: ca.pem,// server-chain.pem and server.key, operator-chain.pem and operator.key.#include <PdcommModbusQt.hpp>
#include <QCoreApplication>#include <QJsonArray>#include <QJsonDocument>#include <QJsonObject>#include <QStringList>#include <QThread>#include <QTimer>
#include <cstdio>#include <functional>#include <memory>#include <stdexcept>
namespace {
using Endpoint = pdcomm::modbus::QtEndpoint;using Done = std::function<void(const QJsonObject &result, const QString &error)>;
// A worker thread that owns one endpoint.class Worker {public: Worker() { context_->moveToThread(&thread_); // The context and its poll timer are deleted on the worker thread. QObject::connect(&thread_, &QThread::finished, context_, &QObject::deleteLater); thread_.start(); } ~Worker() { thread_.quit(); thread_.wait(); }
// Runs one command on the worker thread and hands the result to `done` on // the main thread. void call(const QJsonObject &command, Done done) { QMetaObject::invokeMethod(context_, [this, command, done] { QJsonObject result; QString error; try { result = endpoint_.call(command); } catch (const std::exception &failure) { error = QString::fromUtf8(failure.what()); } QMetaObject::invokeMethod(qApp, [done, result, error] { done(result, error); }, Qt::QueuedConnection); }, Qt::QueuedConnection); }
// Serves requests: polls the endpoint every 5 ms on the worker thread. void startPolling() { QMetaObject::invokeMethod(context_, [this] { auto *timer = new QTimer(context_); QObject::connect(timer, &QTimer::timeout, context_, [this, timer] { try { endpoint_.call(QJsonObject{{"action", "poll"}}); } catch (const std::exception &failure) { timer->stop(); std::fprintf(stderr, "Poll failed: %s\n", failure.what()); } }); timer->start(5); }, Qt::QueuedConnection); }
private: QThread thread_; QObject *context_ = new QObject; Endpoint endpoint_;};
// Returns null (plain Modbus TCP) without a certificate directory.QJsonValue security(const QString &directory, const QString &identity, bool server) { if (directory.isEmpty()) return QJsonValue(); QJsonObject value{{"mode", "encrypted"}, {"trust", directory + "/ca.pem"}, {"chain", directory + "/" + identity + "-chain.pem"}, {"private_key", directory + "/" + identity + ".key"}}; if (server) { // Clients with the "operator" role may use functions 03 and 06 only. value.insert("roles", QJsonObject{{"operator", QJsonArray{3, 6}}}); value.insert("null_role", QJsonArray{}); } return value;}
QJsonObject tcp(const char *role, const QString &address, const QJsonValue &security) { return {{"transport", "tcp"}, {"role", role}, {"address", address}, {"port", ""}, {"unit", 1}, {"baud", 19200}, {"parity", "even"}, {"stop_bits", 1}, {"timeout_ms", 1000}, {"security", security}};}
void print(const char *label, const QJsonObject &result) { std::printf("%s %s\n", label, QJsonDocument(result).toJson(QJsonDocument::Compact).constData());}
} // namespace
int main(int argc, char **argv) { QCoreApplication application(argc, argv); const QStringList arguments = application.arguments(); const bool tls = arguments.size() == 3 && arguments.at(1) == "--tls"; if (arguments.size() != 1 && !tls) { std::fprintf(stderr, "Usage: %s [--tls <certificate directory>]\n", argv[0]); return 2; } const QString certificates = tls ? arguments.at(2) : QString(); Worker server; Worker client; int exitCode = 1; auto fail = [&exitCode](const QString &error) { std::fprintf(stderr, "Command error: %s\n", qPrintable(error)); exitCode = 1; QCoreApplication::quit(); };
// Each step runs after the previous result arrives on the main thread. auto readBack = [&] { client.call({{"action", "request"}, {"pdu", "0300000001"}}, [&](const QJsonObject &result, const QString &error) { if (!error.isEmpty()) return fail(error); print("read", result); exitCode = result.value("response").toArray() == QJsonArray{3, 2, 0, 42} ? 0 : 1; QCoreApplication::quit(); }); }; auto write = [&] { client.call({{"action", "request"}, {"pdu", "060000002A"}}, [&](const QJsonObject &result, const QString &error) { if (!error.isEmpty()) return fail(error); print("write", result); readBack(); }); }; auto connectClient = [&](const QString &address) { client.call({{"action", "configure"}, {"config", tcp("client", address, security(certificates, "operator", false))}}, [&](const QJsonObject &, const QString &error) { if (!error.isEmpty()) return fail(error); client.call({{"action", "connect"}}, [&](const QJsonObject &, const QString &error) { if (!error.isEmpty()) return fail(error); write(); }); }); }; // Port 0 lets the system choose a free port; connect reports the address. server.call({{"action", "configure"}, {"config", tcp("server", "127.0.0.1:0", security(certificates, "server", true))}}, [&](const QJsonObject &, const QString &error) { if (!error.isEmpty()) return fail(error); server.call({{"action", "connect"}}, [&](const QJsonObject &state, const QString &error) { if (!error.isEmpty()) return fail(error); const auto address = state.value("config") .toObject() .value("address") .toString(); std::printf("server listening on %s\n", qPrintable(address)); server.startPolling(); connectClient(address); }); }); QTimer::singleShot(10000, &application, [&] { fail("No result within 10 s"); }); application.exec(); return exitCode;}SDK로 빌드하려면 다음 CMakeLists.txt를 예제 파일과 같은 폴더에 둡니다.
cmake_minimum_required(VERSION 3.20)project(modbus_qt_example LANGUAGES CXX)set(PDCOMM_MODBUS_SDK "" CACHE PATH "Installed Modbus SDK root")find_package(Qt6 REQUIRED COMPONENTS Core)find_library(MODBUS_LIBRARY NAMES pdcomm_modbus_ffi PATHS "${PDCOMM_MODBUS_SDK}/lib" NO_DEFAULT_PATH REQUIRED)add_executable(modbus-qt modbus.cpp)target_compile_features(modbus-qt PRIVATE cxx_std_17)target_include_directories(modbus-qt PRIVATE "${PDCOMM_MODBUS_SDK}/include" "${PDCOMM_MODBUS_SDK}/bindings/qt")target_link_libraries(modbus-qt PRIVATE "${MODBUS_LIBRARY}" Qt6::Core)set_target_properties(modbus-qt PROPERTIES BUILD_RPATH "${PDCOMM_MODBUS_SDK}/lib")cmake -S qt-example -B qt-build -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=/path/to/Qt -DPDCOMM_MODBUS_SDK="$MODBUS_SDK"cmake --build qt-build./qt-build/modbus-qtserver listening on 127.0.0.1:54095write {"response":[6,0,0,0,42],"source":"wire","status":"success"}read {"response":[3,2,0,42],"source":"wire","status":"success"}qt-example은modbus.cpp와CMakeLists.txt를 둔 폴더입니다.CMAKE_PREFIX_PATH는 Qt 6 설치 경로입니다.- 서버는 포트 0으로 열리므로 첫 줄의 포트 번호는 실행마다 다릅니다.
- 끝까지 기대한 값을 읽으면 종료 코드 0입니다. 10초 안에 끝나지 않으면
No result within 10 s를 쓰고 1로 끝납니다.
위젯 애플리케이션에서는 QMetaObject::invokeMethod(qApp, ...) 자리의 콜백에서 위젯을 갱신합니다. QtEndpoint 자체는 QObject가 아니므로 소유한 작업 객체가 같은 스레드에서만 부르게 합니다.
MFC
섹션 제목: “MFC”MfcEndpoint는 UTF-16 CString JSON 명령을 받아 응답 전체({"ok":true,"result":...})를 CString으로 돌려줍니다. UTF-8 변환은 어댑터가 합니다. 헤더는 SDK의 bindings/mfc/PdcommModbus.hpp입니다.
- 애플리케이션을 유니코드(
UNICODE,_UNICODE)로 빌드합니다.CString이CStringW여야 합니다. - 실패하면
std::runtime_error를 던지며,what()은 UTF-8 오류 JSON입니다. request와connect는AfxBeginThread등으로 만든 작업 스레드에서 호출하고, 결과는PostMessage로 창에 보냅니다. 창 메시지 처리 함수에서 직접 부르지 않습니다.- 서버라면 작업 스레드에서
poll을 반복합니다.
다음은 MFC 초기화 뒤 로컬 모델에 쓰기를 실행해 확인하는 콘솔 프로그램입니다.
#include "PdcommModbus.hpp"#include <afxwin.h>#include <cstdio>CWinApp application;int wmain() { if (!AfxWinInit(GetModuleHandle(nullptr), nullptr, GetCommandLineW(), 0)) return 2; try { pdcomm::modbus::MfcEndpoint endpoint; const auto reply = endpoint.Call(L"{\"action\":\"execute\",\"pdu\":[6,0,0,0,9]}"); if (reply.Find(L"\"response\":[6,0,0,0,9]") < 0) return 1; std::puts("Modbus MFC consumer passed"); return 0; } catch (const std::exception &) { return 1; }}MFC를 포함한 Visual Studio C++ 도구로 빌드하고, 실행 파일과 같은 폴더나 PATH에 SDK의 pdcomm_modbus_ffi.dll과 lib 폴더의 의존 DLL을 둡니다.
.NET 애플리케이션
섹션 제목: “.NET 애플리케이션”Endpoint의 Call도 막히는 호출입니다. WinForms나 WPF에서는 Task.Run처럼 스레드 풀이나 전용 작업 스레드에서 부르고, 결과를 UI 스레드로 돌려보냅니다. Endpoint는 내부 잠금으로 호출을 직렬화하며, 다 쓰면 Dispose합니다.
다음 단계
섹션 제목: “다음 단계”- 명령 형식은 JSON 명령 레퍼런스에 있습니다.
- API 계층별 소유권과 오류 처리는 첫 TCP 통신 자세히 보기에서 설명합니다.