简介:这份资源面向仪器控制与自动化测试方向的开发者,提供吉时利2600系列源表(重点为2612A)的程控开发示例。项目以Qt C++搭建跨平台图形界面,结合Visa库实现对源测量单元的远程控制,覆盖资源初始化、参数设置、数据读取、扫描触发及资源释放等关键环节,适合具备一定C++基础、希望快速上手SCPI命令与仪器通信的工程师参考。压缩包共6个文件,约8KB,包含2个cpp源文件、1个h头文件、1个ui界面文件、1个pro工程文件及1个user配置,分别对应界面逻辑、类声明、可视化布局与Qt工程构建,结构紧凑便于直接编译调试。目前已有376人学习下载。通过研读源码,读者可掌握Visa会话管理、命令收发与Qt定时刷新测量数据的完整思路,并据此扩展出适配自身测试场景的自动化方案。
1. 吉时利2600系列程控开发:为什么我最终选了C++和Qt这条路
第一次把吉时利2600系列源表接到产线测试工位时,我用的是一套脚本语言写的上位机。单台设备跑几十条IV曲线没问题,可一旦要同时管四台2612A、每台双通道独立扫描、还要把数据实时画到界面上,脚本的调度延迟和界面卡顿就藏不住了。后来我把整套程控逻辑重写成C++,界面层用Qt,才算把「稳定采集」和「流畅交互」这两件事同时按住。这篇笔记讲的就是这条路:怎么用C++通过VISA把2612A管起来,怎么用Qt把参数配置、扫描执行、数据回读和曲线显示串成一个能落地的上位机。适合已经摸过SCPI指令、但被多设备并发或界面响应拖住的人,也适合刚接手2600系列、想知道从哪下手的新手。核心不是把手册翻译一遍,而是把「能跑起来、能复现、能排错」的路径讲清楚。
2. 先搞懂2612A的程控模型:SCPI、VISA和双通道到底怎么配合
2.1 2612A在2600系列里的位置和双通道特性
吉时利2600系列是一个很大的家族,2612A属于其中的双通道系统源表,两个通道可以各自输出和测量电压电流,也能做通道间的协同扫描。很多人第一次上手会把它当成「两个独立电源」用,结果在同步扫描时发现两路数据时间戳对不齐。原因在于,2612A的双通道虽然物理上独立,但触发模型是共享的,你要么用软件逐条发指令,要么用硬件触发线把两个通道的扫描绑在一起。常见做法是:单通道快速验证用软件触发,双通道同步扫描切到硬件触发或配置触发模型里的TRIGger:LOAD。这一步想不清楚,后面界面做得再漂亮,数据也是散的。
从程控角度看,2612A对外就是一套SCPI指令集,通过GPIB、USB或LAN接入。指令本身不复杂,难的是时序和状态机。比如SOURce:FUNCtion设输出功能,SENSe:FUNCtion设测量功能,SOURce:VOLTage:STARt和STOP设扫描范围,TRIGger:COUNt设点数。这些指令单独发都能通,但顺序错了就会报错或返回旧值。我一般会先把一条完整的扫描指令序列在交互式终端里跑通,再往C++里搬。
2.2 VISA层选型:为什么用C++封装而不是直接调脚本
VISA是仪器程控的通用I/O层,NI-VISA和Keysight VISA都提供C/C++的库。用C++直接调VISA的好处是:连接、写、读、清缓冲这些动作可以封装成类,超时和错误码能统一处理,多线程下每个设备句柄独立管理。脚本语言虽然开发快,但在多设备并发时,解释器的GIL或事件循环很容易成为瓶颈。我实测过,四台2612A同时以1ms间隔轮询状态,脚本方案的状态查询延迟会累积到几十毫秒,而C++方案能稳定在个位数毫秒。
封装时我一般会定义一个InstrumentSession类,持有ViSession句柄,提供write、query、readRaw三个方法,再加一个setTimeout。关键点是:每次query都要先清输入缓冲,否则上一次的残留响应会串到下一次。这个坑我在早期版本里踩过,表现为偶尔读到上一条指令的返回值,排查了半天才发现是缓冲没清。
// InstrumentSession.h #pragma once #include <string> #include <visa.h> class InstrumentSession { public: InstrumentSession() : session(VI_NULL) {} ~InstrumentSession() { close(); } // 打开设备,resourceName 形如 "GPIB0::12::INSTR" 或 "TCPIP0::192.168.1.10::inst0::INSTR" bool open(const std::string& resourceName, int timeoutMs = 5000) { ViStatus status = viOpenDefaultRM(&rm); if (status < VI_SUCCESS) return false; status = viOpen(rm, resourceName.c_str(), VI_NULL, VI_NULL, &session); if (status < VI_SUCCESS) return false; viSetAttribute(session, VI_ATTR_TMO_VALUE, timeoutMs); return true; } // 写指令,末尾自动补换行 bool write(const std::string& cmd) { std::string line = cmd + "\n"; return viWrite(session, (ViBuf)line.c_str(), (ViUInt32)line.size(), VI_NULL) >= VI_SUCCESS; } // 查询:先清缓冲,再写,再读 bool query(const std::string& cmd, std::string& response) { viClear(session); // 关键:清掉残留响应 if (!write(cmd)) return false; char buf[4096] = {0}; ViUInt32 retCount = 0; ViStatus status = viRead(session, (ViBuf)buf, sizeof(buf) - 1, &retCount); if (status < VI_SUCCESS) return false; response.assign(buf, retCount); // 去掉尾部换行 while (!response.empty() && (response.back() == '\n' || response.back() == '\r')) response.pop_back(); return true; } void close() { if (session != VI_NULL) { viClose(session); session = VI_NULL; } if (rm != VI_NULL) { viClose(rm); rm = VI_NULL; } } private: ViSession rm = VI_NULL; ViSession session = VI_NULL; };这段代码里最值得说的是viClear的位置。它放在write之前,目的是把上一次查询可能残留的响应清掉。参数上,VI_ATTR_TMO_VALUE设的是毫秒级超时,2612A做长扫描时单条查询可能超过默认的2秒,我一般设5000ms起步。viRead的缓冲区给4096字节,足够容纳一次扫描返回的多点数据;如果点数特别多,要么分批读,要么用二进制传输块。
2.3 双通道扫描的指令序列和触发配置
单通道扫描的指令序列大致是:选功能、设源范围、设测量范围、设触发计数和源、启动、等完成、读数据。双通道要在这个基础上加通道选择。2612A用INSTrument:SELect或INSTrument:NSELect切换当前通道,后续指令作用于当前通道。所以双通道配置必须成对出现,切一次通道配一套。
// 配置2612A双通道同步扫描的示例 bool configureDualSweep(InstrumentSession& inst) { // 通道A:电压源,电流测量 if (!inst.write("INSTrument:SELect SMUA")) return false; inst.write("SOURce:FUNCtion VOLTage"); inst.write("SENSe:FUNCtion \"CURRent\""); inst.write("SOURce:VOLTage:STARt 0"); inst.write("SOURce:VOLTage:STOP 5"); inst.write("SOURce:VOLTage:STEP 0.1"); inst.write("TRIGger:COUNt 51"); inst.write("TRIGger:SOURce IMMediate"); // 通道B:电流源,电压测量 if (!inst.write("INSTrument:SELect SMUB")) return false; inst.write("SOURce:FUNCtion CURRent"); inst.write("SENSe:FUNCtion \"VOLTage\""); inst.write("SOURce:CURRent:STARt 0"); inst.write("SOURce:CURRent:STOP 0.01"); inst.write("SOURce:CURRent:STEP 0.0002"); inst.write("TRIGger:COUNt 51"); inst.write("TRIGger:SOURce IMMediate"); // 回到通道A启动,双通道用同一触发源时需硬件连线或配置触发模型 inst.write("INSTrument:SELect SMUA"); inst.write("INITiate"); return true; }这里TRIGger:SOURce IMMediate表示收到启动指令后立即开始扫描。如果要做真正的同步,需要把两个通道的触发源都设成外部触发,再用触发线连起来,或者用TRIGger:LOAD配置触发模型。参数上,STEP决定扫描点数,TRIGger:COUNt要和点数一致,否则会多读或少读。我见过有人把COUNt设成点数减一,结果最后一点永远读不到,查了半天以为是通信丢包。
3. 用Qt搭上位机:从设备连接到曲线显示的完整链路
3.1 Qt工程结构和线程模型
Qt这边我一般把工程分成三层:设备层(InstrumentSession和2612A专用封装)、业务层(扫描任务、数据缓存)、界面层(参数面板、曲线图、日志)。线程模型是关键:VISA的读写是阻塞的,如果放在UI线程里,界面会卡死。常见做法是把每个设备或每组设备放到一个QThread里,用信号槽把数据传回UI。我习惯用一个Worker对象moveToThread,在worker里跑扫描循环,每采完一个点就emit一个数据信号。
// SweepWorker.h #pragma once #include <QObject> #include <QVector> #include "InstrumentSession.h" class SweepWorker : public QObject { Q_OBJECT public: explicit SweepWorker(QObject* parent = nullptr) : QObject(parent) {} public slots: void startSweep(const QString& resource, double startV, double stopV, double stepV) { InstrumentSession inst; if (!inst.open(resource.toStdString())) { emit error("open failed"); return; } inst.write("INSTrument:SELect SMUA"); inst.write("SOURce:FUNCtion VOLTage"); inst.write("SENSe:FUNCtion \"CURRent\""); inst.write(QString("SOURce:VOLTage:STARt %1").arg(startV).toStdString()); inst.write(QString("SOURce:VOLTage:STOP %1").arg(stopV).toStdString()); inst.write(QString("SOURce:VOLTage:STEP %1").arg(stepV).toStdString()); int points = static_cast<int>((stopV - startV) / stepV) + 1; inst.write(QString("TRIGger:COUNt %1").arg(points).toStdString()); inst.write("TRIGger:SOURce IMMediate"); inst.write("INITiate"); for (int i = 0; i < points; ++i) { std::string resp; if (!inst.query("FETCh?", resp)) { emit error("fetch failed at point " + QString::number(i)); break; } // 2612A返回格式通常是 电压,电流,时间戳 或类似,按实际解析 QStringList parts = QString::fromStdString(resp).split(','); if (parts.size() >= 2) { emit pointReady(parts[0].toDouble(), parts[1].toDouble()); } } inst.write("SOURce:VOLTage 0"); // 扫描结束归零 emit finished(); } signals: void pointReady(double voltage, double current); void error(const QString& msg); void finished(); };这段worker的逻辑是:打开设备、配置扫描、启动、逐点FETCh?、发信号、结束归零。FETCh?每次取一个点,适合实时显示;如果追求速度,可以用TRACe:DATA?一次性取回整条曲线。参数上,points由起止和步长算出,注意浮点除法要加0.5再取整,否则5/0.1可能算出49而不是51。SOURce:VOLTage 0是收尾动作,防止扫描结束后源还停在最后一个电压上。
3.2 参数面板和扫描配置的界面绑定
Qt的界面层我一般用QDoubleSpinBox做电压电流范围输入,QSpinBox做点数,QComboBox选通道和功能。绑定方式用信号槽,点「开始」时把界面值打包成一个结构体发给worker。这里有个细节:扫描过程中要禁用参数控件,否则用户中途改参数会导致指令序列错乱。我一般用一个setUiEnabled(bool)统一控制。
// MainWindow.cpp 片段 void MainWindow::onStartClicked() { if (workerThread && workerThread->isRunning()) return; double startV = ui->startVoltage->value(); double stopV = ui->stopVoltage->value(); double stepV = ui->stepVoltage->value(); if (stepV <= 0 || stopV <= startV) { ui->logView->append("参数非法:步长需大于0,终止电压需大于起始电压"); return; } setUiEnabled(false); workerThread = new QThread(this); worker = new SweepWorker(); worker->moveToThread(workerThread); connect(workerThread, &QThread::started, [=]() { worker->startSweep(ui->resourceEdit->text(), startV, stopV, stepV); }); connect(worker, &SweepWorker::pointReady, this, &MainWindow::onPointReady); connect(worker, &SweepWorker::finished, this, &MainWindow::onSweepFinished); connect(worker, &SweepWorker::error, this, &MainWindow::onError); workerThread->start(); }setUiEnabled(false)在开始前调用,结束后恢复。moveToThread之后,worker的槽函数在子线程执行,信号回UI线程自动排队。参数校验放在点击时做,比在worker里做更早暴露问题。resourceEdit里填的是VISA资源名,常见格式是GPIB0::12::INSTR或TCPIP0::192.168.1.10::inst0::INSTR,具体看设备接入方式。
3.3 实时曲线绘制:QCustomPlot还是QtCharts
曲线显示我试过两种:Qt自带的QtCharts和第三方QCustomPlot。QtCharts集成度高,但大数据量刷新时性能一般;QCustomPlot轻量,几万个点也能流畅拖动。2612A单次扫描通常几十到几百点,两者都够用。我一般选QCustomPlot,因为它的addData和replot控制更细,可以只重绘需要更新的图层。
// 初始化曲线 void MainWindow::setupPlot() { ui->customPlot->addGraph(); ui->customPlot->graph(0)->setPen(QPen(Qt::blue)); ui->customPlot->xAxis->setLabel("Voltage (V)"); ui->customPlot->yAxis->setLabel("Current (A)"); ui->customPlot->xAxis->setRange(0, 5); ui->customPlot->yAxis->setRange(-0.01, 0.01); } // 收到一个点就追加 void MainWindow::onPointReady(double v, double i) { ui->customPlot->graph(0)->addData(v, i); ui->customPlot->replot(QCustomPlot::rpQueuedReplot); // 排队重绘,避免频繁刷新 }rpQueuedReplot是关键,它把重绘请求合并到事件循环里,避免每个点都触发一次完整重绘。如果点数特别多,可以每N个点才replot一次。坐标范围我一般先给一个保守值,扫描结束后根据实际数据自动缩放,用rescaleAxes。
4. 避坑与排查:2612A程控开发里最容易翻车的五件事
4.1 现象:查询偶尔返回上一条指令的结果
原因:VISA输入缓冲里残留了上一次查询的响应,尤其是上一条指令超时或读了一半时。解决:每次query前调viClear,或者读完后确认读到终止符。我在InstrumentSession::query里固定加了viClear,之后这类问题再没出现过。
4.2 现象:双通道扫描数据时间戳对不齐
原因:两个通道用软件触发分别启动,启动时刻有毫秒级偏差,扫描过程中还会累积。解决:改用硬件触发,把两个通道的TRIGger:SOURce设成外部,用触发线连起来;或者用2612A的触发模型配置同步。如果必须软件触发,至少在数据里记录每点的实际时间戳,后处理时对齐。
4.3 现象:扫描点数比预期少一个
原因:TRIGger:COUNt设成了(stop-start)/step,浮点除法截断导致少算一个点。解决:用static_cast<int>((stop - start) / step + 0.5) + 1,先四舍五入再取整。这个坑在步长是0.1、0.2这类非二进制精确值时特别常见。
4.4 现象:UI在扫描时卡死
原因:VISA读写放在UI线程,阻塞了事件循环。解决:把设备操作移到QThread,用信号槽回传数据。注意worker对象要用moveToThread,且不要在worker里直接操作UI控件。
4.5 现象:设备连接成功但指令无响应
原因:资源名写错、设备被其他程序占用、或者超时设得太短。解决:先用厂商提供的交互式工具确认资源名和基本指令能通,再检查是否有其他进程占着VISA句柄。超时我一般设5000ms起步,长扫描场景设到30000ms。
5. 进阶技巧:把扫描速度再压一压,以及一套可复用的验证习惯
5.1 用二进制传输块替代ASCII查询
2612A支持二进制数据传输,把FORMat:DATA设成REAL,64或REAL,32,然后用TRACe:DATA?一次性读回整条曲线。相比逐点FETCh?,二进制块省掉了ASCII转换和多次往返,扫描点数多时速度提升明显。代价是解析要处理字节序和块头。我一般先读块头拿到字节数,再按double或float解析。
// 二进制块读取示例(简化) bool readTraceBinary(InstrumentSession& inst, QVector<double>& volts, QVector<double>& currents) { inst.write("FORMat:DATA REAL,64"); inst.write("TRACe:POINts?"); std::string resp; if (!inst.query("TRACe:POINts?", resp)) return false; int points = std::stoi(resp); // 实际读取需要处理IEEE块头 #<n><len>,这里示意 // 读回后按 double 解析,每两个 double 一组(电压、电流) return true; }参数上,REAL,64是双精度,REAL,32是单精度,后者省带宽但精度低。块头格式是#加一位数字表示长度位数,再加长度值,解析时先读这两位确定后续字节数。这个技巧在需要高速采集的场景值得花时间做。
5.2 一套我固定用的验证流程
每次改完程控代码,我会按这个顺序验证:先用交互式工具发单条指令确认设备响应;再用最小C++程序跑单通道单点输出和测量;然后跑单通道扫描并核对点数;最后跑双通道同步扫描并检查时间戳。每一步都记录实际返回值和预期值的差异。这套流程帮我省了很多「改了半天发现是设备没连上」的时间。
5.3 参数配置的检查清单
| 参数 | 常见值 | 注意点 |
|---|---|---|
| 超时 | 5000ms | 长扫描设30000ms |
| 触发计数 | 点数 | 浮点除法要四舍五入 |
| 源功能 | VOLTage/CURRent | 和测量功能匹配 |
| 通道选择 | SMUA/SMUB | 每次切换后重配 |
| 数据格式 | ASCII/REAL | 二进制需解析块头 |
这张表我贴在工位上,每次新建扫描配置时过一遍,能挡掉大部分低级错误。
5.4 一个让我后悔半天的教训
早期版本里我没在扫描结束后把源归零,结果有一次扫描停在5V,下一个测试项直接接着用这个电压,把被测件打坏了。从那以后,我在worker的finished信号发出前固定加一条SOURce:VOLTage 0和OUTPut OFF。这个习惯看起来多余,但真能避免事故。希望帮到你。
本文还有配套的精品资源,点击获取