云计算百科
云计算领域专业知识百科平台

DNP3 系列(十一):开发实现——基于 opendnp3 的 Outstation 与 Master

核心目标:能基于 opendnp3 开源库开发 DNP3 Outstation(从站/RTU)与 Master(主站/SCADA),打通轮询、事件、控制、时钟全链路,并掌握 pydnp3(Python 绑定)的快速原型方法。

前置知识:Part 2-5(协议栈三层与对象模型)、Part 8-9(机制)。本篇 C++ 示例基于 opendnp3 3.1.2 官方示例改编;文末的端到端验证使用 pydnp3 在本机真实编译并运行(附完整运行日志)。


11.1 开源方案选型

库语言许可证MasterOutstation串口TLS/SAv5适合场景
opendnp3 C++ Apache-2.0 嵌入式、生产级,事实标准
pydnp3 Python(C++ 绑定) Apache-2.0 原型、测试、工具链
dnp3 Rust Apache-2.0 内存安全、现代工具链
jdnp3 Java Apache-2.0 跨平台主站侧
openDNP3 (.NET) C# Apache-2.0 .NET 平台

选型要点:

  • 嵌入式/生产设备:opendnp3(C++),交叉编译到 ARM,资源可控
  • 快速原型/测试:pydnp3(同一个 opendnp3 代码库,Python API)
  • 现代服务端:Rust dnp3(Step Function I/O 出品,与 opendnp3 同作者,API 设计一脉相承)

Windows 平台注意:opendnp3 官方在 Windows 上支持 MSVC 编译器;用 MinGW 编译的库在运行时可能因 asio 的 Windows IOCP 兼容问题崩溃(本机实测 opendnp3 3.1.2 MinGW 编译成功但 AddTCPServer 段错误)。Windows 开发请使用 Visual Studio,或直接用 pydnp3(MSVC 预编译/源码构建)。


11.2 基于 opendnp3 开发 Outstation(从站/RTU)

11.2.1 核心对象模型

DNP3Manager(线程池入口)
└── AddTCPServer() / AddSerial() ← 通道(TCP server / 串口)
└── AddOutstation() ← 从站实例
├── DatabaseConfig ← 点表(binary/analog/counter…)
├── OutstationStackConfig← 链路/事件/参数配置
├── CommandHandler ← 遥控回调
└── OutstationApplication← 应用层回调

11.2.2 最小 Outstation(TCP Server)

以下代码改编自 opendnp3 官方示例 examples/outstation/main.cpp:

#include <opendnp3/DNP3Manager.h>
#include <opendnp3/ConsoleLogger.h>
#include <opendnp3/channel/PrintingChannelListener.h>
#include <opendnp3/logging/LogLevels.h>
#include <opendnp3/outstation/DefaultOutstationApplication.h>
#include <opendnp3/outstation/SimpleCommandHandler.h>
#include <opendnp3/outstation/UpdateBuilder.h>

using namespace opendnp3;

// 1. 点表:10 个 binary/analog/counter,模拟量 0 号配置为浮点 + 事件
DatabaseConfig ConfigureDatabase()
{
DatabaseConfig config(10);
config.analog_input[0].svariation = StaticAnalogVariation::Group30Var3; // 32 位浮点
config.analog_input[0].evariation = EventAnalogVariation::Group32Var7; // 事件:浮点+相对时间
return config;
}

int main()
{
// 2. 线程池 + 日志
DNP3Manager manager(1, ConsoleLogger::Create());
const auto logLevels = levels::NORMAL;

// 3. TCP Server(监听 20000)
auto channel = manager.AddTCPServer("server", logLevels,
ServerAcceptMode::CloseExisting,
IPEndpoint("0.0.0.0", 20000),
PrintingChannelListener::Create());

// 4. 从站配置
OutstationStackConfig config(ConfigureDatabase());
config.outstation.eventBufferConfig = EventBufferConfig::AllTypes(100); // 事件缓冲
config.outstation.params.allowUnsolicited = true; // 允许主动上报
config.link.LocalAddr = 10; // 本从站地址
config.link.RemoteAddr = 1; // 主站地址

// 5. 创建并启用
auto app = DefaultOutstationApplication::Create();
auto outstation = channel->AddOutstation("outstation",
SuccessCommandHandler::Create(), // 遥控一律成功
app, config);
outstation->Enable();

// 6. 更新数据(模拟量 + 遥信)
double value = 100.0;
while (true)
{
std::this_thread::sleep_for(std::chrono::seconds(2));
UpdateBuilder builder;
builder.Update(Analog(value, Flags(0x01)), 0); // index 0 模拟量
builder.Update(Binary(value > 100, Flags(0x01)), 0); // index 0 遥信
outstation->Apply(builder.Build()); // 原子提交一批更新
value += 10.0;
}
}

要点:

  • UpdateBuilder 批量提交(Apply),事务一致性好
  • Analog(value, Flags, DNPTime) 的时标由 opendnp3 自动填充(可显式传 DNPTime)
  • 遥控回调 CommandHandler 见 11.3.3,生产实现不能直接用 SuccessCommandHandler

11.2.3 串口 Outstation

// 串口(EIA-485 总线)与 TCP 的差异仅在通道创建:
auto channel = manager.AddSerial("serial", logLevels, ChannelRetry::Default(),
SerialSettings()
.Device("COM1")
.BaudRate(9600)
.DataBits(8)
.Parity(Parity::None)
.StopBits(StopBits::One),
PrintingChannelListener::Create());
// 后续 AddOutstation 完全相同


11.3 基于 opendnp3 开发 Master(主站/SCADA)

11.3.1 最小 Master

以下代码改编自官方示例 examples/master/main.cpp:

#include <opendnp3/DNP3Manager.h>
#include <opendnp3/master/DefaultMasterApplication.h>
#include <opendnp3/master/PrintingSOEHandler.h>
#include <opendnp3/master/PrintingCommandResultCallback.h>

using namespace opendnp3;

int main()
{
DNP3Manager manager(1, ConsoleLogger::Create());
const auto logLevels = levels::NORMAL;

// 1. TCP Client 连接从站
auto channel = manager.AddTCPClient("tcpclient", logLevels, ChannelRetry::Default(),
{IPEndpoint("127.0.0.1", 20000)},
"0.0.0.0", PrintingChannelListener::Create());

// 2. 主站配置
MasterStackConfig config;
config.master.responseTimeout = TimeDuration::Seconds(2);
config.link.LocalAddr = 1; // 主站地址
config.link.RemoteAddr = 10; // 从站地址

// 3. 创建主站(SOEHandler 处理所有收到的测量值)
auto master = channel->AddMaster("master",
PrintingSOEHandler::Create(),
DefaultMasterApplication::Create(),
config);

// 4. 轮询:完整性 1 分钟 + Class 1 事件 5 秒
auto integrity = master->AddClassScan(ClassField::AllClasses(),
TimeDuration::Minutes(1));
auto events = master->AddClassScan(ClassField(ClassField::CLASS_1),
TimeDuration::Seconds(5));

// 5. 启用,开始通信
master->Enable();
// … 主线程循环(或接 GUI/服务框架)
}

11.3.2 自定义 SOEHandler(数据回调)

PrintingSOEHandler 只打印;生产代码需要自定义:

class MySOEHandler : public ISOEHandler
{
void BeginFragment(const ResponseInfo& info) override {}
void EndFragment(const ResponseInfo& info) override {}

// 遥信
void Process(const HeaderInfo& info,
const ICollection<Indexed<Binary>>& values) override
{
auto trampoline = [](const Indexed<Binary>& pair) {
// pair.value.value = 0/1
// pair.value.flags = 品质位
std::cout << "BI[" << pair.index << "] = " << pair.value.value << std::endl;
};
values.ForeachItem(trampoline);
}

// 遥测
void Process(const HeaderInfo& info,
const ICollection<Indexed<Analog>>& values) override
{
auto trampoline = [](const Indexed<Analog>& pair) {
std::cout << "AI[" << pair.index << "] = " << pair.value.value << std::endl;
};
values.ForeachItem(trampoline);
}

// 其他类型(DoubleBitBinary/Counter/OctetString…)同理,可空实现
void Process(const HeaderInfo&, const ICollection<Indexed<Counter>>&) override {}
// …
};

11.3.3 遥控(SBO / Direct Operate)

Outstation 侧实现 CommandHandler:

class MyCommandHandler : public ICommandHandler
{
// CROB(遥控)——select 阶段校验
CommandStatus Select(const ControlRelayOutputBlock& command, uint16_t index) override
{
return (index < 10) ? CommandStatus::SUCCESS : CommandStatus::NOT_SUPPORTED;
}
// CROB ——operate 阶段执行
CommandStatus Operate(const ControlRelayOutputBlock& command, uint16_t index,
OperateType opType) override
{
bool latch = command.code == ControlCode::LATCH_ON;
std::cout << "Operate: index=" << index << " latch=" << latch << std::endl;
// 驱动真实 IO(GPIO/继电器)
return CommandStatus::SUCCESS;
}
// 遥调
CommandStatus Select(const AnalogOutputInt32& command, uint16_t index) override { /* … */ }
CommandStatus Operate(const AnalogOutputInt32& command, uint16_t index, OperateType opType) override { /* … */ }
};

Master 侧发起命令:

// SBO 两阶段:opendnp3 自动完成 SELECT → OPERATE
ControlRelayOutputBlock crob(ControlCode::LATCH_ON, 1 /*count*/, 0 /*onTime*/, 0 /*offTime*/);
master->SelectAndOperate(crob, 2 /*index*/, PrintingCommandResultCallback::Create());

机制回顾(Part 9):SelectAndOperate 对应功能码 3+4;DirectOperate 对应功能码 5。opendnp3 封装了两阶段,回调返回 CommandPointResult(状态 + 回执)。


11.4 数据处理实战

11.4.1 定时读取指定点(ScanRange)

// 读取二进制输入(G1V2)索引 0-3
master->ScanRange(GroupVariationID(1, 2), 0, 3, soe_handler);

11.4.2 主动请求完整性轮询

auto scan = master->ScanAllObjects(GroupVariationID(60, 1), soe_handler); // Class 0
// 或对已配置的轮询执行立即触发
master->Scan(integrity); // 触发 11.3.1 中定义的 integrity 扫描

11.4.3 写操作(WRITE)

// 写死区(G32)
AnalogOutputInt32 setpoint(100, CommandStatus::SUCCESS);
master->DirectOperate(setpoint, 5 /*index*/, PrintingCommandResultCallback::Create());


11.5 文件传输开发

opendnp3 提供文件传输高层 API(FileManager/FileInfo),开箱支持 Part 10 的固件升级流程:

// Outstation 侧:注册文件资源
class MyFileManager : public IFileManager
{
// 收到 OPEN/GET FILE 等请求时回调,实现按 FilePath 提供/接收数据
std::string GetFilePath(const std::string& path) override { /* … */ }
};

// Master 侧:读取文件
auto file = master->GetFile("firmware.bin", FileRequestType::READ,
PrintingFileResultCallback::Create());

底层对应功能码 25-31 与 G70-G74,opendnp3 已封装完整状态机;大文件分块、断点续传等细节由库处理。


11.6 串口与 TCP 双通道

传输通道创建典型参数
TCP Server AddTCPServer 端口 20000,ServerAcceptMode::CloseExisting
TCP Client AddTCPClient ChannelRetry::Default(),可配多个端点
串口 AddSerial 9600 8N1,EIA-485 半双工
  • 重连策略:ChannelRetry::Default()(退避重连),TCP 链路断了自动恢复
  • Keep-Alive:链路层 KeepAliveTimeout 配置,检测静默链路
  • TLS:AddTLSClient/AddTLSServer(需编译期开启 DNP3_TLS,见 Part 14)

11.7 性能优化与嵌入式移植

11.7.1 线程模型

DNP3Manager(threadCount, …) ← 线程池大小
– threadCount=1:单线程跑所有通道(嵌入式省资源)
– threadCount=N:多通道并行(性能优先)
业务线程(更新/回调)与协议线程分离,通过 Apply/回调解耦

11.7.2 内存与事件缓冲

  • EventBufferConfig::AllTypes(100):每类事件缓冲 100 条(约几十 KB)
  • 大数据量场景调大缓冲,避免溢出(Part 8 的 IIN 溢出位)
  • 事件读取分页:opendnp3 自动按最大 APDU 分帧,无需手动处理

11.7.3 ARM 交叉编译

# 交叉编译(以 aarch64-linux-gnu 为例)
cmake -S . -B build-arm \\
-DCMAKE_TOOLCHAIN_FILE=…/aarch64-toolchain.cmake \\
-DDNP3_STATIC_LIBS=ON -DDNP3_TLS=OFF
cmake –build build-arm -j8
# 产物:静态库 libopendnp3.a(约 1-2 MB),直接链接进固件


11.8 端到端验证:pydnp3 真实运行

本机(Windows 11 + Python 3.11)通过 MSVC 源码构建 pydnp3 0.1.0(opendnp3 2.2.1 绑定),完成一次真实的 Master ↔ Outstation 端到端通信验证。完整修复过程见本节末尾说明。

11.8.1 最小端到端脚本

# e2e_dnp3.py —— Master + Outstation 同一进程,TCP 通信
import os, sys, threading, time
from pydnp3 import opendnp3, openpal, asiopal, asiodnp3

FILTERS = opendnp3.levels.NORMAL
HOST, LOCAL, PORT = "127.0.0.1", "0.0.0.0", 20000

class OutstationApp(opendnp3.IOutstationApplication):
pass

class CommandHandler(opendnp3.ICommandHandler):
pass

def run_outstation(stop):
manager = asiodnp3.DNP3Manager(1, asiodnp3.ConsoleLogger().Create())
channel = manager.AddTCPServer("server", FILTERS, asiopal.ChannelRetry().Default(),
LOCAL, PORT, asiodnp3.PrintingChannelListener().Create())
config = asiodnp3.OutstationStackConfig(opendnp3.DatabaseSizes.AllTypes(2))
config.outstation.eventBufferConfig = opendnp3.EventBufferConfig().AllTypes(100)
config.outstation.params.allowUnsolicited = True
config.link.LocalAddr = 10
config.link.RemoteAddr = 1
outstation = channel.AddOutstation("outstation", CommandHandler(), OutstationApp(), config)
outstation.Enable()

value, binary = 100.0, False
while not stop.is_set():
time.sleep(2)
builder = asiodnp3.UpdateBuilder()
builder.Update(opendnp3.Analog(value, opendnp3.Flags(0x01), opendnp3.DNPTime(0)), 0)
builder.Update(opendnp3.Binary(binary, opendnp3.Flags(0x01), opendnp3.DNPTime(0)), 0)
outstation.Apply(builder.Build())
print("[outstation] updated: analog[0]={} binary[0]={}".format(value, binary))
value += 10.0
binary = not binary

def run_master(stop):
manager = asiodnp3.DNP3Manager(1, asiodnp3.ConsoleLogger().Create())
channel = manager.AddTCPClient("tcpclient", FILTERS, asiopal.ChannelRetry().Default(),
HOST, LOCAL, PORT, asiodnp3.PrintingChannelListener().Create())
config = asiodnp3.MasterStackConfig()
config.master.responseTimeout = openpal.TimeDuration().Seconds(2)
config.link.LocalAddr = 1
config.link.RemoteAddr = 10
# PrintingSOEHandler 会把收到的所有测量值打印出来
master = channel.AddMaster("master", asiodnp3.PrintingSOEHandler().Create(),
asiodnp3.DefaultMasterApplication().Create(), config)
master.AddClassScan(opendnp3.ClassField().AllClasses(),
openpal.TimeDuration().Seconds(4)) # 完整性轮询
master.AddClassScan(opendnp3.ClassField(opendnp3.ClassField.CLASS_1),
openpal.TimeDuration().Seconds(1)) # Class 1 事件轮询
master.Enable()
while not stop.is_set():
time.sleep(0.5)

def main():
stop = threading.Event()
threading.Thread(target=run_outstation, args=(stop,), daemon=True).start()
time.sleep(1)
threading.Thread(target=run_master, args=(stop,), daemon=True).start()
time.sleep(14)
stop.set()
print("=" * 60)
print("E2E RESULT (14 秒): 上方 '[0] : 值 : 品质 : 时标' 行 = Master 收到的数据")
print("=" * 60)
os._exit(0) # 跳过解释器清理(opendnp3 C++ 线程池会阻塞退出)

if __name__ == "__main__":
main()

11.8.2 运行结果(本机真实输出)

[outstation] enabled, listening on 20000
[master] enabled, polling…
[outstation] updated: analog[0]=100.0 binary[0]=False
[0] : 100 : 1 : 0 ← Master 收到 Analog index0 = 100.0
[0] : 0 : 129 : 0 ← Master 收到 Binary index0 = False
[outstation] updated: analog[0]=110.0 binary[0]=True
[0] : 110 : 1 : 0
[0] : 1 : 129 : 0 ← Binary = True

[outstation] updated: analog[0]=170.0 binary[0]=True
[0] : 170 : 1 : 0
============================================================
E2E RESULT (14 秒): 上方 '[0] : 值 : 品质 : 时标' 行 = Master 收到的数据
============================================================

验证结论:

  • Outstation 每 2 秒更新模拟量(100→170)与遥信(翻转),Apply 提交
  • Master 通过完整性轮询(4s)+ 事件轮询(1s)完整收到全部数据,值、品质位、索引均正确
  • 品质位 1(ONLINE)与 129(ONLINE|STATE)符合 Part 6 的字节定义
  • 一轮 14 秒共执行 22 次轮询任务,端到端通信稳定
  • 11.8.3 本机构建 pydnp3 的踩坑记录

    pydnp3 0.1.0(2018 年,绑定 opendnp3 2.2.1)在 Python 3.11 + 新 MSVC 上无法直接 pip install,需要修复(已在本机验证通过):

    #问题修复
    1 中文用户名路径下 CMake 找不到编译器 构建目录放纯 ASCII 路径(如 E:\\build)
    2 opendnp3 2.x 缺 #include <string> 等(新标准库不再间接暴露) 脚本扫描 55 个库头补 include
    3 捆绑 pybind11 过旧,与 Python 3.11 不兼容(_frame 未定义) 替换为 pybind11 2.12.0
    4 绑定头写死 #include <python2.7/Python.h> 全局替换为 <Python.h> + 指向 Python 3.11 include
    5 绑定头无 include guard,MSVC 二次包含导致重定义 216 个绑定头批量补 #pragma once
    6 部分绑定头缺类型头文件(CommandPointResult 等) 逐个补 include
    7 opendnp3 2.2.1 源码引用不存在的 ChannelStatistics.h 按 2.x 定义补建该头文件

    实践建议:生产环境优先用官方支持的组合(Linux + GCC / Windows + MSVC + 最新版 opendnp3 3.x);pydnp3 0.1.0 仅适合快速原型,若需 Python 侧完整能力可评估 Rust dnp3 crate 或 opendnp3 3.x 的官方 Python 绑定。


    小结与导航

    本篇完成了从"协议理解"到"能写代码"的跨越:

  • 选型 —— opendnp3(C++ 生产)/ pydnp3(Python 原型)双路线
  • Outstation —— 点表配置、TCP/串口通道、UpdateBuilder 更新、遥控回调
  • Master —— 通道连接、SOEHandler 数据回调、Class 轮询配置、命令下发
  • 数据处理 —— ScanRange、完整性轮询触发、写操作
  • 文件传输 —— IFileManager/GetFile 高层 API
  • 嵌入式 —— 线程模型、事件缓冲、ARM 交叉编译
  • 端到端验证 —— pydnp3 真实构建运行,Master 完整收到 Outstation 数据
  • 下期预告

    [Part 12:测试与工具链] 将覆盖:

    • Wireshark DNP3 深度分析(过滤器、逐字段标注)
    • 模拟器联调与自动化测试(pytest + pydnp3)
    • 一致性测试(Conformance Test)与互操作性测试
    • 边界与健壮性测试(畸形报文注入)

    参考资源

    • opendnp3 源码与示例:https://github.com/automatak/dnp3(Apache-2.0)
    • pydnp3:https://github.com/Kisensum/pydnp3(PyPI: pydnp3)
    • Rust dnp3:https://github.com/stepfunc/dnp3(Apache-2.0)
    • opendnp3 文档:https://stepfunc.io/blog 与官方 API 文档
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » DNP3 系列(十一):开发实现——基于 opendnp3 的 Outstation 与 Master
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!