콘텐츠로 이동
무료 평가 시작

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 스레드 역할로, 명령을 큐에 넣고 결과를 큐로 받기만 합니다.

examples/qt/modbus.cpp
// 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를 예제 파일과 같은 폴더에 둡니다.

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-qt
server listening on 127.0.0.1:54095
write {"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가 아니므로 소유한 작업 객체가 같은 스레드에서만 부르게 합니다.

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 초기화 뒤 로컬 모델에 쓰기를 실행해 확인하는 콘솔 프로그램입니다.

examples/modbus-mfc/main.cpp
#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을 둡니다.

Endpoint의 Call도 막히는 호출입니다. WinForms나 WPF에서는 Task.Run처럼 스레드 풀이나 전용 작업 스레드에서 부르고, 결과를 UI 스레드로 돌려보냅니다. Endpoint는 내부 잠금으로 호출을 직렬화하며, 다 쓰면 Dispose합니다.