在 C++/Qt 上位机中嵌入 Python Runtime 与 pybind11,实现脚本化自动控制

在工业制造类上位机软件中,经常会遇到这类需求:

  • 设备调试流程频繁变化;
  • 不同产线、不同客户需要定制动作流程;
  • 工程师希望不用重新编译 C++ 程序,就能修改测试逻辑;
  • 需要把设备控制、数据采集、视觉检测、MES 通讯等能力开放给脚本调用。

一种常见方案是:

在 C++/Qt 上位机程序中嵌入 Python Runtime,通过 pybind11 把 C++ 设备控制接口暴露给 Python 脚本,使 Python 脚本可以调用 C++ 能力完成自动化任务。

本文介绍一种适用于工业上位机软件的脚本化架构设计,并给出核心代码示例。


一、整体架构

典型架构如下:

+--------------------------------------------------+
|                 Qt 上位机程序                     |
|                                                  |
|  +----------------+      +--------------------+  |
|  |    UI界面       | ---> |  ScriptManager     |  |
|  +----------------+      +--------------------+  |
|                                |                 |
|                                v                 |
|                      +--------------------+      |
|                      |  Python Runtime    |      |
|                      +--------------------+      |
|                                |                 |
|                                v                 |
|                      +--------------------+      |
|                      |  pybind11 API模块  |      |
|                      +--------------------+      |
|                                |                 |
|                                v                 |
|  +----------------+  +--------------------+      |
|  |  Motion控制     |  |   IO控制/扫码/MES   |      |
|  +----------------+  +--------------------+      |
|                                                  |
+--------------------------------------------------+

核心思想是:

  1. Qt 程序负责 UI、线程管理、设备连接、状态显示;
  2. C++ 实现实际的工业控制逻辑,例如运动控制、IO、扫码、相机采集;
  3. pybind11 将部分 C++ 类和函数封装成 Python API;
  4. 用户编写 Python 脚本,调用这些 Python API;
  5. Qt 程序加载并执行 Python 脚本;
  6. Python 脚本中的每一个 API 调用最终映射到 C++ 函数执行。

二、技术选型

1. Python Runtime

Python Runtime 用于在 C++ 程序中嵌入 Python 解释器。

例如:

#include <pybind11/embed.h>

namespace py = pybind11;

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    py::scoped_interpreter guard{};

    MainWindow w;
    w.show();

    return app.exec();
}

py::scoped_interpreter 会初始化 Python 解释器,并在对象析构时关闭解释器。


2. pybind11

pybind11 用于在 C++ 和 Python 之间建立绑定关系。

它既可以用于:

  • C++ 调用 Python;
  • Python 调用 C++;
  • 嵌入式 Python 模块;
  • 扩展式 Python 模块。

在上位机中,更常用的是:

C++ 程序内部嵌入 Python,并通过 PYBIND11_EMBEDDED_MODULE 暴露 C++ API 给 Python 脚本。


三、CMake 配置示例

假设项目结构如下:

IndustrialApp/
├── CMakeLists.txt
├── main.cpp
├── MainWindow.cpp
├── ScriptManager.cpp
├── DeviceController.cpp
├── bindings/
│   └── PyApiBindings.cpp
└── scripts/
    └── auto_test.py

CMake 示例:

cmake_minimum_required(VERSION 3.20)

project(IndustrialApp)

set(CMAKE_CXX_STANDARD 17)

find_package(Qt6 REQUIRED COMPONENTS Widgets)
find_package(pybind11 REQUIRED)

add_executable(IndustrialApp
    main.cpp
    MainWindow.cpp
    ScriptManager.cpp
    DeviceController.cpp
    bindings/PyApiBindings.cpp
)

target_link_libraries(IndustrialApp
    PRIVATE
    Qt6::Widgets
    pybind11::embed
)

如果使用 Qt5:

find_package(Qt5 REQUIRED COMPONENTS Widgets)

target_link_libraries(IndustrialApp
    PRIVATE
    Qt5::Widgets
    pybind11::embed
)

四、C++ 设备控制层设计

工业上位机通常会有一层统一的设备控制接口。

例如:

// DeviceController.h
#pragma once

#include <string>
#include <mutex>

class DeviceController
{
public:
    bool connectDevice();
    void disconnectDevice();

    bool moveAxis(const std::string& axis, double position, double speed);
    bool waitMotionDone(const std::string& axis, int timeoutMs);

    bool setOutput(int channel, bool value);
    bool getInput(int channel);

    std::string scanBarcode();

    void logInfo(const std::string& msg);
    void logError(const std::string& msg);

private:
    std::mutex mutex_;
};

实现示例:

// DeviceController.cpp
#include "DeviceController.h"
#include <iostream>
#include <thread>
#include <chrono>

bool DeviceController::connectDevice()
{
    logInfo("Connecting device...");
    return true;
}

void DeviceController::disconnectDevice()
{
    logInfo("Disconnecting device...");
}

bool DeviceController::moveAxis(const std::string& axis, double position, double speed)
{
    std::lock_guard<std::mutex> lock(mutex_);

    std::cout << "[C++] Move axis: " << axis
              << ", position: " << position
              << ", speed: " << speed << std::endl;

    return true;
}

bool DeviceController::waitMotionDone(const std::string& axis, int timeoutMs)
{
    std::cout << "[C++] Wait motion done: " << axis
              << ", timeout: " << timeoutMs << " ms" << std::endl;

    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    return true;
}

bool DeviceController::setOutput(int channel, bool value)
{
    std::lock_guard<std::mutex> lock(mutex_);

    std::cout << "[C++] Set output: " << channel
              << ", value: " << value << std::endl;

    return true;
}

bool DeviceController::getInput(int channel)
{
    std::cout << "[C++] Get input: " << channel << std::endl;
    return true;
}

std::string DeviceController::scanBarcode()
{
    std::cout << "[C++] Scan barcode" << std::endl;
    return "SN123456789";
}

void DeviceController::logInfo(const std::string& msg)
{
    std::cout << "[INFO] " << msg << std::endl;
}

void DeviceController::logError(const std::string& msg)
{
    std::cerr << "[ERROR] " << msg << std::endl;
}

五、设计 Python API

脚本工程师不应该直接接触底层复杂的 C++ 对象。

可以设计一个面向工艺流程的 Python API,例如:

import machine

machine.log_info("开始自动测试")

machine.move_axis("X", 100.0, 50.0)
machine.wait_motion_done("X", 5000)

machine.set_output(1, True)

if machine.get_input(2):
    machine.log_info("检测到到位信号")

sn = machine.scan_barcode()
machine.log_info("扫码结果: " + sn)

这个 machine 模块不是普通 Python 文件,而是由 C++ 使用 pybind11 嵌入注册出来的模块。


六、使用 pybind11 暴露 C++ 接口

1. 全局设备对象

为了让 Python API 能够调用实际设备,可以在 C++ 中维护一个全局或单例设备对象。

例如:

// AppContext.h
#pragma once

#include "DeviceController.h"

class AppContext
{
public:
    static AppContext& instance()
    {
        static AppContext ctx;
        return ctx;
    }

    DeviceController& device()
    {
        return device_;
    }

private:
    DeviceController device_;
};

2. pybind11 模块绑定

// bindings/PyApiBindings.cpp
#include <pybind11/embed.h>
#include "AppContext.h"

namespace py = pybind11;

PYBIND11_EMBEDDED_MODULE(machine, m)
{
    m.doc() = "Industrial machine control API";

    m.def("connect", []() {
        return AppContext::instance().device().connectDevice();
    });

    m.def("disconnect", []() {
        AppContext::instance().device().disconnectDevice();
    });

    m.def("move_axis", [](const std::string& axis, double pos, double speed) {
        return AppContext::instance().device().moveAxis(axis, pos, speed);
    });

    m.def("wait_motion_done", [](const std::string& axis, int timeout_ms) {
        return AppContext::instance().device().waitMotionDone(axis, timeout_ms);
    });

    m.def("set_output", [](int channel, bool value) {
        return AppContext::instance().device().setOutput(channel, value);
    });

    m.def("get_input", [](int channel) {
        return AppContext::instance().device().getInput(channel);
    });

    m.def("scan_barcode", []() {
        return AppContext::instance().device().scanBarcode();
    });

    m.def("log_info", [](const std::string& msg) {
        AppContext::instance().device().logInfo(msg);
    });

    m.def("log_error", [](const std::string& msg) {
        AppContext::instance().device().logError(msg);
    });
}

这样 Python 中就可以:

import machine

machine.move_axis("X", 100, 50)

而这行代码最终会调用:

DeviceController::moveAxis("X", 100, 50)

七、脚本管理器 ScriptManager

Qt 程序中通常会封装一个脚本执行管理器,负责:

  • 初始化 Python 环境;
  • 加载脚本文件;
  • 执行脚本函数;
  • 捕获异常;
  • 向 UI 输出日志;
  • 控制脚本启动、停止、暂停等。

示例:

// ScriptManager.h
#pragma once

#include <QObject>
#include <QString>

class ScriptManager : public QObject
{
    Q_OBJECT

public:
    explicit ScriptManager(QObject* parent = nullptr);

    bool runScriptFile(const QString& filePath);
    bool runScriptFunction(const QString& filePath, const QString& functionName);

signals:
    void scriptStarted();
    void scriptFinished();
    void scriptError(QString message);
    void scriptLog(QString message);
};

实现:

// ScriptManager.cpp
#include "ScriptManager.h"

#include <pybind11/embed.h>
#include <QFileInfo>

namespace py = pybind11;

ScriptManager::ScriptManager(QObject* parent)
    : QObject(parent)
{
}

bool ScriptManager::runScriptFile(const QString& filePath)
{
    emit scriptStarted();

    try
    {
        py::gil_scoped_acquire gil;

        py::object globals = py::dict();
        py::object locals = py::dict();

        py::eval_file(filePath.toStdString(), globals, locals);

        emit scriptFinished();
        return true;
    }
    catch (const py::error_already_set& e)
    {
        emit scriptError(QString::fromStdString(e.what()));
        return false;
    }
    catch (const std::exception& e)
    {
        emit scriptError(QString::fromStdString(e.what()));
        return false;
    }
}

执行整个脚本:

scriptManager->runScriptFile("scripts/auto_test.py");

八、推荐的脚本结构设计

不建议所有逻辑直接写在脚本顶层。

推荐定义统一入口函数,例如 main()setup()run()teardown()

示例:

# scripts/auto_test.py

import machine
import time

def setup():
    machine.log_info("初始化测试流程")
    machine.connect()

def run():
    machine.log_info("开始执行自动流程")

    machine.move_axis("X", 100.0, 50.0)
    machine.wait_motion_done("X", 5000)

    machine.set_output(1, True)
    time.sleep(0.2)
    machine.set_output(1, False)

    if machine.get_input(2):
        machine.log_info("产品到位")
    else:
        machine.log_error("产品未到位")
        return False

    sn = machine.scan_barcode()
    machine.log_info("SN = " + sn)

    return True

def teardown():
    machine.log_info("流程结束")
    machine.disconnect()

def main():
    setup()

    result = False

    try:
        result = run()
    finally:
        teardown()

    return result

然后 C++ 执行指定函数:

bool ScriptManager::runScriptFunction(const QString& filePath, const QString& functionName)
{
    emit scriptStarted();

    try
    {
        py::gil_scoped_acquire gil;

        py::dict globals;
        py::dict locals;

        py::eval_file(filePath.toStdString(), globals, locals);

        py::object func = locals[functionName.toStdString().c_str()];

        py::object result = func();

        bool ok = result.cast<bool>();

        emit scriptFinished();

        return ok;
    }
    catch (const py::error_already_set& e)
    {
        emit scriptError(QString::fromStdString(e.what()));
        return false;
    }
    catch (const std::exception& e)
    {
        emit scriptError(QString::fromStdString(e.what()));
        return false;
    }
}

调用:

scriptManager->runScriptFunction("scripts/auto_test.py", "main");

九、Qt 中避免 UI 卡死:脚本线程

Python 脚本可能执行较长时间,如果直接在 UI 线程执行,会导致界面卡死。

推荐将脚本放到工作线程中执行。

示例:

QThread* thread = new QThread;
ScriptManager* manager = new ScriptManager;

manager->moveToThread(thread);

connect(thread, &QThread::started, [manager]() {
    manager->runScriptFunction("scripts/auto_test.py", "main");
});

connect(manager, &ScriptManager::scriptFinished, thread, &QThread::quit);
connect(manager, &ScriptManager::scriptError, thread, &QThread::quit);

connect(thread, &QThread::finished, manager, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QObject::deleteLater);

thread->start();

注意:

  • Python Runtime 是进程级别的;
  • 多线程调用 Python 时必须处理 GIL;
  • 同一时刻通常只允许一个脚本控制设备;
  • 设备控制接口需要加锁或状态机保护。

十、如何让 Python 日志输出到 Qt 界面

可以在 C++ 中设计一个日志服务,然后暴露给 Python。

例如:

class LogService
{
public:
    std::function<void(const std::string&)> onLog;

    void info(const std::string& msg)
    {
        if (onLog)
            onLog("[INFO] " + msg);
    }

    void error(const std::string& msg)
    {
        if (onLog)
            onLog("[ERROR] " + msg);
    }
};

然后绑定:

m.def("log_info", [](const std::string& msg) {
    AppContext::instance().device().logInfo(msg);
});

实际项目中可以改为:

m.def("log_info", [](const std::string& msg) {
    AppContext::instance().logService().info(msg);
});

Qt 侧通过 signal/slot 更新界面:

connect(scriptManager, &ScriptManager::scriptLog,
        this, &MainWindow::appendLog);

十一、Python 脚本如何转换为 C++ 可执行命令?

这是很多人容易误解的地方。

严格来说:

Python 脚本并不是被转换成 C++ 代码,也不是被编译成 C++ 可执行文件。

实际过程是:

Python脚本
   |
   v
Python解释器读取并执行
   |
   v
执行到 import machine
   |
   v
加载由 pybind11 注册的 C++ 内嵌模块
   |
   v
执行 machine.move_axis("X", 100, 50)
   |
   v
pybind11 将 Python 参数转换为 C++ 参数
   |
   v
调用 C++ lambda 函数
   |
   v
调用 DeviceController::moveAxis()
   |
   v
驱动运动控制卡、PLC、IO模块、相机等硬件

例如 Python 脚本:

machine.move_axis("X", 100.0, 50.0)

运行时会经过以下映射:

Python str "X"       -> std::string axis
Python float 100.0   -> double position
Python float 50.0    -> double speed

然后 pybind11 调用绑定函数:

m.def("move_axis", [](const std::string& axis, double pos, double speed) {
    return AppContext::instance().device().moveAxis(axis, pos, speed);
});

最终执行:

DeviceController::moveAxis(axis, pos, speed);

因此,Python 脚本本质上是在描述自动化流程,而真正执行硬件动作的仍然是 C++ 代码。

可以理解为:

Python脚本 = 工艺流程描述层
pybind11   = 参数转换和函数分发层
C++代码    = 实际设备执行层

十二、命令模式设计:让脚本生成 C++ 命令对象

对于复杂工业软件,建议不要让 Python 直接调用所有设备接口,而是引入“命令模式”。

1. 定义 C++ 命令接口

class ICommand
{
public:
    virtual ~ICommand() = default;
    virtual bool execute() = 0;
};

运动命令:

class MoveAxisCommand : public ICommand
{
public:
    MoveAxisCommand(DeviceController& device,
                    std::string axis,
                    double pos,
                    double speed)
        : device_(device),
          axis_(std::move(axis)),
          pos_(pos),
          speed_(speed)
    {
    }

    bool execute() override
    {
        return device_.moveAxis(axis_, pos_, speed_);
    }

private:
    DeviceController& device_;
    std::string axis_;
    double pos_;
    double speed_;
};

命令调度器:

class CommandExecutor
{
public:
    bool executeMoveAxis(const std::string& axis, double pos, double speed)
    {
        MoveAxisCommand cmd(AppContext::instance().device(), axis, pos, speed);
        return cmd.execute();
    }
};

Python API 绑定:

m.def("move_axis", [](const std::string& axis, double pos, double speed) {
    return AppContext::instance()
        .commandExecutor()
        .executeMoveAxis(axis, pos, speed);
});

这样 Python 调用:

machine.move_axis("X", 100, 50)

实际会被转换成:

MoveAxisCommand cmd(device, "X", 100, 50);
cmd.execute();

这种设计的好处是:

  • 方便记录命令历史;
  • 方便做权限检查;
  • 方便做流程回放;
  • 方便做暂停、恢复、停止;
  • 方便接入状态机;
  • 方便统一异常处理。

十三、脚本停止与安全控制

工业软件必须考虑安全性。

Python 脚本不能随意控制设备,建议增加:

  1. 急停检测;
  2. 设备状态检查;
  3. 运动互锁;
  4. 用户权限;
  5. 脚本超时;
  6. 黑白名单 API;
  7. 运行前语法检查;
  8. 日志追踪;
  9. 异常后安全复位。

例如在 C++ API 中增加状态检查:

m.def("move_axis", [](const std::string& axis, double pos, double speed) {
    auto& ctx = AppContext::instance();

    if (ctx.isEmergencyStop())
    {
        throw std::runtime_error("Emergency stop is active");
    }

    if (!ctx.device().isConnected())
    {
        throw std::runtime_error("Device is not connected");
    }

    return ctx.device().moveAxis(axis, pos, speed);
});

Python 中捕获异常:

try:
    machine.move_axis("X", 100, 50)
except Exception as e:
    machine.log_error("运动失败: " + str(e))

十四、完整执行流程示例

用户点击 Qt 界面的“执行脚本”按钮:

void MainWindow::onRunScriptClicked()
{
    scriptManager->runScriptFunction("scripts/auto_test.py", "main");
}

C++ 加载 Python 脚本:

py::eval_file("scripts/auto_test.py", globals, locals);

Python 调用:

machine.move_axis("X", 100.0, 50.0)

pybind11 转发到 C++:

m.def("move_axis", [](const std::string& axis, double pos, double speed) {
    return AppContext::instance().device().moveAxis(axis, pos, speed);
});

C++ 执行硬件动作:

DeviceController::moveAxis("X", 100.0, 50.0);

设备执行完成后返回结果:

C++ bool -> Python bool -> 脚本继续执行

十五、总结

通过 Python Runtime 和 pybind11,可以很方便地为 C++/Qt 工业上位机程序增加脚本自动化能力。

推荐的架构是:

Qt UI
  |
ScriptManager
  |
Python Runtime
  |
pybind11 API
  |
C++ Command / DeviceController
  |
工业设备

Python 脚本主要负责:

  • 描述工艺流程;
  • 编排动作顺序;
  • 判断流程分支;
  • 调用设备 API;
  • 处理异常和日志。

C++ 程序主要负责:

  • 设备驱动;
  • 运动控制;
  • IO 控制;
  • 线程安全;
  • 权限管理;
  • 安全互锁;
  • 状态机;
  • UI 显示。

需要注意的是,Python 脚本并不是转换成 C++ 代码,而是在 Python Runtime 中解释执行。当脚本调用 machine.xxx() 时,pybind11 会把 Python 函数调用映射到对应的 C++ 函数,从而实现脚本控制 C++ 上位机执行实际任务。

更多推荐