简介:面向电子测试、自动化测试与仪器控制方向的开发者,这份压缩包围绕通过USB接口控制艾德克斯(ITECH)直流电源展开,涉及电压电流设定、输出状态读取、恒压恒流模式配置以及自动化测试流程搭建,适合具备C#基础、希望将可编程电源接入测试系统的读者参考。包内共14个文件,以7个C#源码为核心,辅以WinForms窗体资源、项目与解决方案配置、应用配置等,压缩后仅19KB,结构轻量便于查阅。从目录与预览可见,ControlItech工程包含界面窗体、程序入口和独立的USB通讯类,读者可据此理解设备连接、指令下发、测量数据回传与过压过流保护监控的实现思路,并在此基础上扩展批量测试脚本或自定义上位机功能。项目采用C# WinForms组织,代码规模较小,便于快速定位通讯封装与界面交互逻辑。已有3947人学习,可作为USB仪器控制与电源自动化测试的入门参考工程。
1. 做老化测试时,我为什么要把电源从面板挪到脚本里
做一轮老化测试,最烦的不是写用例,而是每改一个电压都要伸手去拧电源旋钮,拧完还得拿万用表回读一遍,再手工填进表格。这个项目干的事很直接:一根 USB 线把电脑和艾德克斯电源连起来,用 SCPI 指令把设压、设流、开输出、回读电压电流全搬进上位机,脚本跑完一轮,数据顺手落盘。适合两类人:手上有这台电源、想把它接进自动化流程的测试开发,以及想搞清楚 USB 通讯到底怎么和仪器对话的上位机新手。代码不依赖重型框架,Python 加 VISA 就能跑。
2. 技术选型:USBTMC、VISA 与 SCPI 三条线怎么接
很多人第一次插上仪器,看到设备管理器里多出一个"USB Test and Measurement Device",就以为后面只要open()一个串口那么简单,结果list_resources()返回空列表,直接卡在第一步。问题出在这根线上其实同时跑着三层东西,你得先认清自己手里这台设备走的是哪一层,后面的代码才不会白写。
2.1 通信链路拆解:一根 USB 线里跑的是哪几层
最底下是 USB 物理链路,多数仪器是 USB 2.0 Full Speed,插上后主机侧枚举,按接口类描述符决定加载哪个驱动。仪器类设备通常有两种身份:一种是 USBTMC(USB Test & Measurement Class),属于 USB488 子类,枚举完成后系统把它当"仪器"而不是"串口",数据走 Bulk-OUT 和 Bulk-IN 端点;另一种是 CDC-ACM,也就是虚拟串口,系统给你一个 COMx 或者 /dev/ttyACM0,收发就是普通字节流。
中间层是 VISA。它把不同总线(USB、串口、GPIB、LAN)统一成一套"资源字符串 + 读写/查询"的接口,所以你会看到USB0::0x1234::0x5678::序列号::INSTR这种看起来像乱码的东西。VISA 之上才是 SCPI,也就是真正有语义的 ASCII 文本命令,比如VOLT 5.000、OUTP ON、MEAS:CURR?。
USBTMC 的 Bulk-OUT 消息不是裸文本,前面有个十几字节的消息头,里面带 MsgID、bTag 和传输长度,Bulk-IN 的响应同样带头,尾部还可能补一个零长度包表示结束。这些细节 VISA 帮你处理掉了,但一旦驱动装错、后端选错,你看到的症状就是"能枚举、连不上"或者"第一条能读、第二条超时"。
判断设备身份不用猜,命令行看一眼就清楚:
# Linux 下先确认设备到底以什么身份枚举出来的 lsusb # 看 VID/PID 是否出现在列表里 ls /dev/usbtmc* 2>/dev/null # USBTMC 会被映射成 /dev/usbtmc0 ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null # 虚拟串口则是 ttyACM0 / ttyUSB0 udevadm info -q property -n /dev/usbtmc0 | head -20 # 看 ID_VENDOR_ID / ID_MODEL_ID如果只出现/dev/usbtmc0,说明要走 VISA 的 USB 资源字符串;如果只出现/dev/ttyACM0,那就老老实实用 pyserial,把 SCPI 当普通文本发过去,末尾补\n。
提示:同一台电源的不同系列,USB 实现可能不一样,有的走 USBTMC,有的走 CDC 虚拟串口,甚至有的机型两个都支持、靠菜单切换。先看设备管理器或
lsusb,再决定用哪套传输代码,比翻半天手册快。
2.2 技术栈取舍:NI-VISA、pyvisa-py 与 pyserial 怎么选
Python 侧控制仪器绕不开 pyvisa,但 pyvisa 只是个前端,真正干活的是后端。官方后端是 NI-VISA(或厂商自带的 VISA 运行时),装完几百兆,好处是稳、兼容性最好、厂商仪器基本开箱即用;纯 Python 后端 pyvisa-py 走 libusb,装起来轻,但需要解决驱动占用问题——Windows 上常常要用 Zadig 把设备驱动换成 libusb-win32 或 WinUSB,换完之后厂商自带的上位机可能就认不到设备了,来回切换很折腾。
我一般的判断标准是这样:只在 Windows 上跑、又要和厂商上位机共存,就上 NI-VISA;要在 Linux 服务器或 Docker 里跑、追求部署干净,就 pyvisa-py 加 udev 规则;设备本身就是虚拟串口,直接 pyserial,别为了统一而统一。
| 方案 | 依赖 | 适合场景 | 主要坑点 |
|---|---|---|---|
| NI-VISA + pyvisa | 厂商 VISA 运行时 | Windows 桌面、与厂商软件共存 | 安装包大,多版本运行时可能冲突 |
| pyvisa-py + libusb | pyusb、libusb | Linux、容器、干净部署 | 需替换驱动,权限和 udev 要配 |
| pyserial | 仅 pyserial | 设备是 CDC 虚拟串口 | 波特率/流控要和仪器菜单一致 |
选型的本质不是"哪个更先进",而是"这台设备以什么身份枚举"。选错了,后面所有超时、乱码、读不到数据的现象,追根溯源都是这一层没对上。
2.3 目录结构:把传输层和驱动层拆开
这类小工具最常见的写法是一个文件从头撸到尾,能跑,但一旦要接第二台仪器或者换成串口版本就得大改。我的习惯是至少分三层:传输层只管"发字符串、收字符串",驱动层只管"SCPI 语义和量程保护",应用层只管"业务流程和界面/命令行"。这样换总线只动传输层,换机型只动驱动层。
usb-psu-ctl/ ├── app/ │ ├── cli.py # 命令行入口,参数解析、流程编排 │ └── runner.py # 跑测流程:设压、保持、采样、落盘 ├── transport/ │ ├── base.py # Transport 抽象:write / query / close │ ├── visa_tmc.py # USBTMC 实现,基于 pyvisa │ └── serial_cdc.py # 虚拟串口实现,基于 pyserial ├── driver/ │ └── psu.py # SCPI 封装、量程钳位、输出上下文管理 ├── config/ │ └── device.yaml # 资源字符串、额定电压电流、超时参数 └── tests/ └── test_driver.py # 用假 Transport 做单元测试分层的收益在排查时特别明显:*IDN?能回来说明传输层通了,设了电压但回读不对就是驱动层或者仪器状态的问题,边界一下就清楚了。后面几章就按这个结构往里填。
3. 建立连接:枚举、会话与第一条 *IDN?
连不上仪器是这类项目里最高频的求助点,而九成问题都集中在三个地方:资源字符串拼错、终止符没设、驱动被占。这一章按顺序把这三步走完,跑通之后再去谈设压设流。
3.1 资源字符串怎么拼、怎么枚举
不要手写资源字符串。仪器插上之后让 VISA 自己枚举,拿到什么用什么,序列号那一段每台机器都不一样,抄别人的示例几乎必错。
import pyvisa # "@py" 强制走纯 Python 后端;装了 NI-VISA 就传 "@ivi" 或干脆留空 rm = pyvisa.ResourceManager("@py") for res in rm.list_resources(): # USB 资源形如 USB0::0x1234::0x5678::000000001::INSTR # 串口资源形如 ASRL3::INSTR(Windows)或 ASRL/dev/ttyACM0::INSTR(Linux) print(repr(res))list_resources()返回空列表时别急着怀疑代码,先确认后端是否真的加载成功。在 Linux 上还多一层:普通用户没有/dev/usbtmc0的读写权限,需要写一条 udev 规则,把设备节点的属主改到你当前的用户组或用户,然后重新插拔,否则 pyvisa 枚举得出来但一打开会话就抛权限错误。
拿到的资源字符串建议直接写进config/device.yaml,而不是硬编码在脚本里。换机器、换工位只改配置。
3.2 打开会话:超时、终止符、读块大小
open_resource()只是建立会话,真正决定"能不能稳定读到数据"的是后面几个属性。这几个参数不设,症状往往不是报错,而是随机卡住几十秒然后抛一个看不出原因的 VisaIOError。
inst = rm.open_resource(resource_str, open_timeout=3000) inst.timeout = 3000 # 单条命令的读写超时,单位毫秒,别用默认的 2000 硬扛 inst.write_termination = "\n" # 仪器按行解析 SCPI,缺终止符命令会"粘"在缓冲区不发出去 inst.read_termination = "\n" # 不设的话 read() 只能靠超时判断结束,每次都白等 3 秒 inst.chunk_size = 20480 # 单次 Bulk-IN 的最大字节数,回读长数据时调大 print(inst.query("*IDN?").strip())write_termination和read_termination是最容易被忽略的两个属性。有些仪器对\r\n和\n都认,有些只认其中一个,认错的表现是命令发出去了但仪器没反应,下一次查询才把上一条的回显读回来,现象就是"永远慢一条"。
提示:如果仪器是虚拟串口,除了终止符还要对齐波特率、数据位、停止位和流控,尤其流控设成硬件而线只有三根时,会出现发得出去收不回来的情况。
3.3 握手三步:*IDN? → *RST → 查错误队列
会话建立后不要立刻设压,先做一次标准握手。*IDN?确认通道是对的,*RST把仪器拉回已知状态(否则上一轮脚本留下的输出状态、限流值、OVP 设置全在),SYST:ERR?把错误队列清空并确认干净。
import time def handshake(inst) -> str: idn = inst.query("*IDN?").strip() print("设备身份:", idn) # 厂商,型号,序列号,固件版本,先记下来便于复现问题 inst.write("*RST") # 复位到出厂默认状态,清掉上一轮的残留设置 inst.write("*CLS") # 清状态寄存器和错误队列 time.sleep(0.3) # 部分机型复位后需要一点时间才恢复响应 for _ in range(10): # 错误队列可能有多条,逐条读空 err = inst.query("SYST:ERR?").strip() if err.startswith("0"): # 形如 0,"No error" 才算干净 break print("残留错误:", err) return idn错误队列要循环读,不能只读一条。一次*RST之后可能积压好几条历史错误,只读一条就往下走,后面设压失败时你会以为是新问题,其实是旧账。把*IDN?的返回值连同时间戳写进日志,是排查现场问题最省事的一招——固件版本不同,个别 SCPI 命令的行为确实会有差异。
4. 核心模块:设压设流、输出开关与回读采样
连接通了,接下来才是这个项目真正有用的部分。这一层要解决三件事:命令怎么封才不会被误用、输出怎么保证异常时一定关掉、回读的数据怎么处理才不至于把噪声当真实值。
4.1 SCPI 命令封装与量程保护
最危险的不是命令写错,是命令写对了但参数越界。比如给一台额定 32V 的机器发VOLT 60,有的机型直接进保护、有的默默钳到上限、有的报错但不告诉你。所以在驱动层做一次钳位,成本很低,收益很大。
class PsuDriver: def __init__(self, transport, v_max=32.0, i_max=5.0): self.t = transport self.v_max = v_max # 机型额定电压,从 config 里读,别写死在业务代码里 self.i_max = i_max def set_voltage(self, v: float) -> float: v = max(0.0, min(float(v), self.v_max)) # 越界直接钳位,宁可设小了也别送进保护 self.t.write(f"VOLT {v:.3f}") # 三位小数足够,多了仪器也不一定收 return v def set_current(self, i: float) -> float: i = max(0.0, min(float(i), self.i_max)) self.t.write(f"CURR {i:.3f}") return i def set_ovp(self, v: float) -> None: # 过压保护略高于工作电压,防止误设把被测板烧了 self.t.write(f"VOLT:PROT {min(v * 1.1, self.v_max):.3f}")v_max和i_max一定要来自配置而不是硬编码,因为同一个脚本很可能要跑不同功率段的机型。钳位函数返回实际生效的值,业务层把它记进日志,"我明明设了 60V"这种争论就不用发生了。
另外一套常用写法是SOUR:VOLT/SOUR:CURR这类完整的 SCPI 树形路径。短写VOLT在多数机型上等价,但如果某台仪器对短写支持不全,就换成长写,别在这上面耗时间。
4.2 输出开关:用上下文管理器兜住异常下电
手动OUTP ON之后如果脚本在中间抛异常,输出就会一直挂着,被测板带着电停在那里,第二天早上才发现——这种血泪经验,写一次就记住了。Python 里最省事的解法是上下文管理器。
from contextlib import contextmanager @contextmanager def output_on(psu: PsuDriver): psu.t.write("OUTP ON") try: yield psu except Exception: # 异常时先把输出关掉再往上抛,别让异常处理顺序把下电漏掉 psu.t.write("OUTP OFF") raise finally: psu.t.write("OUTP OFF") psu.t.write("VOLT 0")使用侧就变成:
with output_on(psu): psu.set_voltage(5.0) psu.set_current(1.0) run_test_case() # 中途抛异常也会走到 finally注意except里关一次、finally里再关一次,看起来重复,但except分支是为了在异常传播路径上尽快断电,finally是兜底。至于先设压后开输出还是先开输出后设压,取决于被测板能不能承受上电瞬间的过冲,我一般先设压设流、确认回读为 0 再开输出,稳一点。
4.3 回读采样与滑动平均
MEAS:VOLT?和MEAS:CURR?拿到的是仪器内部 ADC 的读数,单次采样会有几个毫伏到几十毫伏的抖动,尤其是轻载时。直接用单次值做判定,很容易在阈值附近反复横跳。
import time def read_back(psu: PsuDriver, samples=8, interval=0.05): vs, cs = [], [] for _ in range(samples): vs.append(float(psu.t.query("MEAS:VOLT?"))) cs.append(float(psu.t.query("MEAS:CURR?"))) time.sleep(interval) # 采样间隔别太小,小于仪器刷新率只是重复读同一个值 n = len(vs) return sum(vs) / n, sum(cs) / n采样数 8、间隔 50ms 是我在多数场景下用的默认值,一轮下来 400ms 左右,对老化测试完全够。如果要做瞬态分析,就别用轮询了,去看仪器是否支持列表输出或者更高速的采样模式,轮询的节奏受 USB 往返延迟限制,快不到哪里去。
回读值和设定值之间通常有几个毫伏的差,这是正常的线上压降和 ADC 精度,判定阈值要留余量。把每次的回读连同时间戳写进 CSV,出问题时翻数据比翻日志有用得多。
4.4 命令行入口与批量跑测
驱动层写好之后,外面套一层 argparse 就能直接用,也方便塞进 CI 或者被别的脚本调用。
# app/cli.py 片段 import argparse, yaml from transport.visa_tmc import VisaTmc from driver.psu import PsuDriver, output_on def main(): ap = argparse.ArgumentParser() ap.add_argument("--resource", required=True, help="VISA 资源字符串") ap.add_argument("--volt", type=float, default=5.0) ap.add_argument("--curr", type=float, default=1.0) ap.add_argument("--hold", type=float, default=10.0, help="保持时间,秒") args = ap.parse_args() t = VisaTmc(args.resource) psu = PsuDriver(t, v_max=32.0, i_max=5.0) psu.set_voltage(args.volt) psu.set_current(args.curr) with output_on(psu): time.sleep(args.hold) v, c = read_back(psu) print(f"回读: {v:.3f} V / {c:.3f} A")跑起来就是一行:
python -m app.cli --resource "USB0::0x1234::0x5678::000000001::INSTR" --volt 5.0 --curr 1.0 --hold 30如果设备是虚拟串口,把VisaTmc换成SerialCdc就行,其余代码一行不动——这正是前面分层想要的效果:
import serial class SerialCdc: def __init__(self, port, baud=9600): # 波特率、数据位要与仪器菜单里的设置完全一致,否则收到的是乱码 self.ser = serial.Serial(port, baud, timeout=1) def write(self, cmd: str) -> None: self.ser.write((cmd + "\n").encode("ascii")) def query(self, cmd: str) -> str: self.write(cmd) return self.ser.readline().decode("ascii", errors="ignore").strip()5. 避坑排查:设备管理器认得到,脚本却连不上
下面这几条都是我在不同工位上真实撞过的,按"现象 → 原因 → 解决"记下来,遇到时对着排查能省不少时间。
现象一:list_resources()返回空列表,但设备管理器里明明有设备。原因通常是后端没加载成功,或者设备被别的驱动占着。Windows 上如果之前用厂商上位机连过,设备可能被它独占;Linux 上多半是权限不足,普通用户读不了/dev/usbtmc0。 解决:先确认ResourceManager("@py")里后端初始化没报错,再换ResourceManager()试官方后端;Windows 上用设备管理器看驱动是不是"USB Test and Measurement Device",不是就用厂商安装包里的驱动重新指一次;Linux 上加 udev 规则给设备节点授权,重新插拔后ls -l /dev/usbtmc0确认权限。
现象二:第一条*IDN?能读,第二条命令开始超时。原因是前面的查询没把响应读完,残留数据堵在 Bulk-IN 缓冲区里,后面的读操作一直在等一个永远不来的终止符;也可能是read_termination没设,靠超时判断结束。 解决:把read_termination显式设成仪器实际用的行尾,查询前先*CLS;如果还是不行,把timeout临时调大跑一次,看是不是某个命令本身响应慢(比如量程切换),定位到具体命令再单独加延时。
现象三:设了 5V,回读却是 0V 或者明显偏低。原因有三类:输出根本没开;仪器处于远程锁定或面板锁定状态,前面板显示和实际设定不一致;负载电流超过设定值,仪器进入恒流模式把电压拉下来了。 解决:先OUTP?查输出状态,再MEAS:CURR?看实际电流是否顶到设定值,最后SYST:ERR?读错误队列。这三步串起来查,基本能定位到是哪一类。
现象四:跑一段时间后偶发掉线,设备重新枚举。原因是 USB 供电或信号完整性,长线、无源 HUB、和电机/继电器共用一个口都会这样。 解决:换短线直连主机后置 USB 口,避开 HUB;如果被测系统里有大功率开关动作,把电源通讯线和功率线分开走;代码侧加一层重连逻辑,捕获异常后重新枚举、重开会话、重新握手,而不是让整个测试中断。
现象五:脚本跑几十轮之后越来越慢,最后卡死。原因是 VISA 会话没释放,句柄越积越多。 解决:所有会话统一用try/finally或者干脆包成上下文管理器,close()一定要执行;写个循环跑 200 次的小脚本压一压,观察进程句柄数,问题会立刻暴露。
注意:排查顺序永远是"先传输层、再驱动层、最后业务层"。
*IDN?不通就别去调设压逻辑,那是给自己找麻烦。
6. 二次开发:把电源驱动挂进自动化测试框架
真要把这套东西用起来,多数人不会停在命令行。测试框架里可能同时挂着电子负载、万用表、数据采集卡,电源只是其中一个节点。这时候第一件该做的事,是把PsuDriver抽成一个接口,让业务代码只依赖方法名,不依赖具体的传输实现。
| 接口方法 | 作用 | 备注 |
|---|---|---|
connect()/close() | 建会话、释放句柄 | 必须幂等,重复调用不报错 |
set_voltage(v)/set_current(i) | 设定值,带钳位 | 返回实际生效值 |
output(on: bool) | 输出开关 | 建议只暴露布尔,不暴露字符串 |
read_back() | 返回 (V, I) 元组 | 内部做多点平均 |
reset() | 复位并清错误队列 | 每个用例开始前调用 |
有了这层接口,单元测试就可以塞一个假的 Transport 进去,不接真机也能跑。假 Transport 里维护一个字典记录收到过哪些命令,断言set_voltage(5.0)之后确实发出了VOLT 5.000,这样重构驱动层时心里有底。真机验证则分两步:先空载跑,看回读电压是否跟设定值吻合(通常差几毫伏);再接一个已知阻值的假负载,让电流落到恒流区,看电压是否按预期跌落。这两步过了,再往被测板上接。
还有一个容易忽略的点是并发。同一个工位上如果两个脚本同时抢一台电源,表现会很诡异——A 设了 5V,B 设了 3V,回读一直在跳。要么在驱动层加文件锁,要么干脆规定一个工位一个进程,别图省事。
我最早写这类脚本时图快,所有逻辑塞在一个main()里,仪器一报错就得从头插一遍调试。后来养成一个习惯:每加一个新命令,先单独跑一次SYST:ERR?确认干净,再往下写。看着慢,实际比事后追查快得多。这份东西你要是拿去改,建议也从这一条开始。希望帮到你。
本文还有配套的精品资源,点击获取