写在前面
工业自动化领域,监控 PLC 点位数据是绕不开的需求。商业方案有西门子博图(TIA Portal)的 Watch Table、各类 SCADA 系统,但要么臃肿、要么昂贵、要么闭源。
最近用 Go + Wails v2 + Vue 3 搓了一个开源桌面工具 PLC-Monitor,实现了类似博图 Watch Table 的核心功能------多连接管理、实时点位监控、趋势曲线、强制写入。本文将分享整个项目的技术架构和实现细节。
项目已开源在 Gitee:gitee.com/shenjoy/plc...
一、技术选型:为什么是 Wails?
桌面监控工具需要满足几个要求:
- 原生性能,低延迟轮询 PLC
- 跨平台(Windows + Linux 工控机都跑)
- UI 现代,不要 MFC/Winform 那种老古董
对比了几个方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Electron | 生态丰富,UI 好 | 内存占用大,打包体积大 |
| C# WPF | .NET 生态 | 跨平台麻烦 |
| Qt (C++) | 成熟稳定 | 开发效率低 |
| Wails v2 | Go 后端 + 前端 Web 技术 | 生态相对年轻 |
Wails v2 是 Go 写的桌面框架------后端用 Go 直接调 PLC 协议栈,前端用 Vue/React 写界面,原生 WebView 渲染,打包体积只有十几 MB。对工控场景来说:后端性能要硬,前端交互要活,Wails 正好卡在这个点上。
二、项目架构
bash
plc-monitor/
├── main.go # 应用入口
├── app.go # Wails 应用结构体 (IPC 绑定)
├── pkg/
│ ├── driver/ # 驱动抽象层
│ │ ├── driver.go # Driver 接口定义
│ │ ├── s7/s7.go # Siemens S7 驱动
│ │ └── modbus/modbus.go # Modbus TCP 驱动
│ ├── engine/ # 轮询引擎
│ │ ├── engine.go # 核心:轮询 + 变化检测 + 历史存储
│ │ └── engine_test.go # 单元测试
│ └── store/ # 配置持久化
└── frontend/
├── src/
│ ├── App.vue # 主布局
│ ├── components/
│ │ ├── connection/ # 连接管理组件
│ │ ├── watchtable/ # 监控表组件
│ │ └── trendchart/ # 趋势图组件 (ECharts)
│ └── composables/api.ts # Wails IPC 桥接
架构分层示意:
scss
┌─────────────────────────────────────┐
│ Vue 3 Frontend │
│ ConnectionList │ WatchTable │ Chart │
├─────────────────────────────────────┤
│ Wails IPC (Events + Bind) │
├─────────────────────────────────────┤
│ app.go (业务编排 + 事件日志) │
├─────────────────────────────────────┤
│ PollingEngine (轮询 + 变化检测) │
├──────────────┬──────────────────────┤
│ S7 Driver │ Modbus Driver │
├──────────────┴──────────────────────┤
│ PLC 设备 (物理/仿真) │
└─────────────────────────────────────┘
三、关键设计:驱动抽象层
这是项目最核心的抽象。工控协议百花齐放------S7、Modbus、OPC UA、MC Protocol......但不管什么协议,本质都是:连接、读、写。
在 pkg/driver/driver.go 中定义接口:
go
type Driver interface {
Connect(connector Connector) error
Disconnect() error
ReadTag(tag Tag) (interface{}, error)
WriteTag(tag Tag, value interface{}) error
IsConnected() bool
}
在此基础上,还抽象了一个可选的 BatchReader 接口:
go
type BatchReader interface {
ReadBatch(tags []BatchTag) []BatchResult
}
为什么拆成两个接口?因为不是所有协议都支持批量读取(Modbus 通常逐点读,S7 可以读连续内存块然后自己解析)。用接口组合代替继承,避免了强迫 Modbus 驱动实现一个空壳批量方法。
驱动工厂与注册机制
驱动采用注册式工厂模式 ,新增驱动只需在 init() 中注册:
go
// pkg/driver/factory.go
func NewDriver(protocol string) (Driver, error) {
// 从注册表查找
}
// pkg/driver/s7/s7.go
func init() {
driver.Register("s7", func() driver.Driver { return &S7Driver{} })
}
// pkg/driver/modbus/modbus.go
func init() {
driver.Register("modbus-tcp", func() driver.Driver { return &ModbusDriver{} })
}
在 main.go 中通过 blank import 注册:
go
import (
_ "plc-monitor/pkg/driver/modbus"
_ "plc-monitor/pkg/driver/s7"
)
扩展新协议只需要新建一个包,实现 Driver 接口,init() 里注册------零侵入。
四、S7 批量读取优化
S7 驱动实现了 BatchReader 接口,这是性能提升的关键。
逐点读取 vs 批量读取
假设监控表有 20 个 DB 块点位:
- 逐点读取:20 次 TCP 请求,往返延迟 = 20 × RTT
- 批量读取:1 次 TCP 请求,读取连续内存块后本地解析
实现思路
将一批点位按内存区域分组(DB 块、M 区、E 区、A 区),每组发起一次连续读,再从返回的字节数组中按偏移和类型解析出各点位值:
go
func (d *S7Driver) ReadBatch(tags []driver.BatchTag) []driver.BatchResult {
// 1. 按内存区域分组
groups := groupByArea(tags)
// 2. 每组读取连续内存块
for area, group := range groups {
buffer := make([]byte, areaSize(group))
err := d.client.AGReadDB(area.DB, area.Start, len(buffer), buffer)
// 3. 从缓冲中解析每个点位
for _, tag := range group {
value := parseFromBuffer(buffer, tag)
results = append(results, ...)
}
}
return results
}
地址格式支持
| 地址类型 | 格式 | 示例 | 说明 |
|---|---|---|---|
| DB 位 | DB{n}.DBX{a}.{b} |
DB1.DBX0.0 |
DB1 的第 0 字节的第 0 位 |
| DB 字节 | DB{n}.DBB{a} |
DB1.DBB0 |
DB1 的第 0 字节 |
| DB 字 | DB{n}.DBW{a} |
DB1.DBW2 |
DB1 的第 2 字节开始的字(16 位) |
| DB 双字 | DB{n}.DBD{a} |
DB1.DBD4 |
DB1 的第 4 字节开始的双字(32 位) |
| 中间寄存器 | M{a}.{b} |
M0.0, MW20 |
Merker/中间寄存器 |
| 输入 | E{a}.{b} / I{a}.{b} |
E0.0 |
物理输入 |
| 输出 | A{a}.{b} / Q{a}.{b} |
A0.0 |
物理输出 |
数据类型支持
bool, byte, word, int, dword, dint, real, string
五、核心引擎:轮询 + 变化检测 + 历史存储
轮询引擎
PollingEngine 是 Go 的并发经典模式------每个连接一个 goroutine,通过 context.Context 统一控制生命周期:
go
func (e *PollingEngine) startPolling(state *connectionState) {
ctx, cancel := context.WithCancel(context.Background())
state.cancel = cancel
go e.pollLoop(ctx, state)
}
func (e *PollingEngine) pollLoop(ctx context.Context, state *connectionState) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.pollOnce(connID, drv, tags, ...)
}
}
}
停止轮询只需要 cancel(),goroutine 自动退出。干净利落。
变化检测
每个点位缓存上一次的值,轮询时做比较,只把变化的数据推向前端:
go
func valuesDiffer(a, b interface{}) bool {
switch va := a.(type) {
case bool:
return va != b.(bool)
case float64:
return va != b.(float64)
// ...
}
}
前端接收到变化后,对应的单元格高亮闪烁 2 秒,视觉效果参考博图 Watch Table。
历史数据环形缓冲区
趋势图需要历史数据,直接全量存内存不现实。用环形缓冲区(Ring Buffer),每个 tag 保留最近 1000 个点:
go
type HistoryStore struct {
data map[string][]HistoryPoint
maxSize int
}
func (h *HistoryStore) Add(tagID string, point HistoryPoint) {
entries := h.data[tagID]
if len(entries) >= h.maxSize {
entries = entries[1:] // 移除最旧
}
h.data[tagID] = append(entries, point)
}
简单、够用、零依赖。
断线检测
连续 3 次轮询全部失败,判定为断线:
go
if allFailed {
state.failCount++
} else {
state.failCount = 0
}
if state.failCount >= 3 {
emitFunc("connection-status", "disconnected", ...)
}
六、前端实现
前端用 Vue 3 Composition API + TypeScript,没有引入 Pinia 或 Vuex------状态足够简单,ref 和 computed 就搞定。
界面布局

Wails IPC 通信
Wails 自动将 Go 方法暴露给前端,通过 window.go.main.App.xxx() 调用:
typescript
export const api = {
getConnections: (): Promise<ConnectionDTO[]> =>
window.go.main.App.GetConnections(),
addTag: (connId: string, name: string, address: string, dataType: string) =>
window.go.main.App.AddTag(connId, name, address, dataType),
startMonitor: () => window.go.main.App.StartMonitor(),
// ...
}
实时数据推送走 Wails 的 Events 机制------后端 runtime.EventsEmit(),前端 EventsOn() 监听:
typescript
events.onDataUpdate((update) => {
for (const snap of update.snapshots) {
values.value.set(snap.tagId, snap)
}
values.value = new Map(values.value) // 触发响应式
})
趋势图的性能优化
趋势图用 ECharts 6 实时展示,这里有一个关键优化------鼠标悬停时暂停更新:
typescript
let hovered = false
let dirty = false
chart.on('mouseover', () => { hovered = true })
chart.on('mouseout', () => {
hovered = false
if (dirty) flushToChart() // 移开时一次性刷新
})
function addDataPoint(idx, timestamp, value) {
dataStore[idx] = [...dataStore[idx], [timestamp, value]]
if (hovered) {
dirty = true // 悬停中:标记脏数据,不触发重绘
return
}
flushToChart()
}
不做这个优化的话,鼠标悬停看数据点时,图表每 500ms 重绘一次,Tooltip 会疯狂闪烁,体验极差。
CSV 批量导入
工控工程师习惯用 Excel 排点位表,CSV 导入直接打通这个工作流:
go
name,address,dataType
温度,DB1.0,uint16
压力,DB1.2,uint16
启停,DB1.DBX0.0,bool
转速,DB1.DBD4,float32
后端解析 CSV 后自动加入监控表,几十个点位一次搞定。
七、使用流程

整个流程围绕工控工程师的真实操作路径设计,上手成本很低。
运行效果:
wails dev
Wails 会自动打开桌面窗口(1280×800),也可以在浏览器访问 http://localhost:34115 调试。
八、快速体验
前置条件
- Go 1.21+
- Node.js 18+
- Wails v2 CLI
bash
go install github.com/wailsapp/wails/v2/cmd/wails@latest
克隆并运行
bash
git clone https://gitee.com/shenjoy/plc-monitor.git
cd plc-monitor
wails dev
打包构建
bash
wails build
产物在 build/bin/ 目录。
九、总结与下一步
这个项目展示了 Go + Wails + Vue 3 在工业桌面工具领域的可行性------Go 后端直接对接 PLC 协议栈,前端用现代 Web 技术做交互,打包成十几 MB 的原生应用,即开即用。
目前支持
- Siemens S7 协议(含批量读取优化)
- Modbus TCP 协议
- 实时轮询 + 变化高亮
- 趋势曲线(ECharts 时间序列)
- 强制写入 / CSV 批量导入
- 事件日志 / 配置持久化
技术栈一览
| 层 | 技术 |
|---|---|
| 桌面框架 | Wails v2 |
| 后端语言 | Go |
| 前端框架 | Vue 3 + TypeScript |
| 图表库 | ECharts 6 |
| S7 协议 | gos7 |
| Modbus 协议 | goburrow/modbus |
| 构建工具 | Vite |
计划中
- OPC UA 协议支持
- 报警规则引擎
- 数据导出(CSV/Excel)
- Web 远程监控模式
- Docker 部署版本
开源地址: gitee.com/shenjoy/plc...
欢迎 Star、Fork、提 Issue。工控开源生态需要大家一起建设。