资讯动态

Python调用周立功USBCAN卡:从环境配置到收发调试完整方案

发布时间:2026/10/20 2:58:35 来源:尧图企业网站定制
工位上调试CAN总线这件事说大不大说小也真能折腾人。ECU没回帧、BMS报文卡死、电机控制器偶发超时这种时候手边最趁手的工具就是一块周立功的CAN卡配上一台装了Python的电脑。用Python控制周立功CAN卡收发数据听起来是个老话题但每次新同事上手还是会在一堆细节上卡住DLL是32位的、波特率要自己填两个寄存器、接收函数返回0就以为总线挂了。这篇文章我按从环境到代码再到排查的顺序把实际在用的完整方案整理出来你可以直接照着抄。1. 先确认你的卡这套组合适合什么场景DeviceType千万别抄错1.1 为什么是Python加CAN卡CAN总线在汽车电子、工业控制、医疗设备里到处都是。调试阶段你需要的不是花哨的图形界面而是快速发包、快速看帧、批量跑测试用例。Python做这件事几乎没有成本不用编译字符串和字节处理方便后面想接pandas、matplotlib做数据分析也顺。周立功的CAN卡是这类场景里的常客USBCAN系列插上就能用官方还提供Windows下的动态库和调试软件CANTest。这也意味着有两条路可以走用官方SDK或者直接调DLL。两条路我都试过会在下面详细说但最终给出的完整代码以直接调DLL为主。原因后面讲。1.2 常见USBCAN型号与DeviceType对照写代码之前第一件事是搞清楚你的卡在代码里叫什么。周立功的API里每个型号对应一个DeviceType常量存在官方头文件里。我常用的几个型号大概是这样常见型号接口典型DeviceTypeUSBCAN-IUSB4USBCAN-IIUSB3USBCAN-2AUSB21CANalyst-IIUSB22PCI9810等PCI卡PCI2注意这个表只能当参考。不同批次、不同版本的库常量可能有细微差别。最稳的做法是打开官方SDK里的头文件搜一下或者直接看CANTest软件装完后自带的头文件。我遇到过有人拿着USBCAN-I抄了个3去打开设备死活打不开换成4就通了就是这种小地方。1.3 驱动和CANTest动手写代码前的20分钟验证代码一行没写之前先把硬件链路确认了。装好官方驱动插上卡在设备管理器里确认设备枚举出来了。然后打开CANTest手动配置500K波特率发几帧试试。这一步的意义在于把问题分层如果CANTest都连不上设备那是驱动或硬件问题先解决再说不用急着写Python。如果你连CANTest都懒得开直接上来写代码后面一旦收不到数据你会在我的代码有问题和硬件有问题之间反复横跳。我见过太多人栽在这上面花一晚上排错最后发现是卡没插紧。2. 官方SDK和ctypes直调DLL我为什么把完整代码押在后者2.1 官方SDK什么时候用省事但有版本坑近两年周立功在PyPI和GitHub上都有官方Python库包名一般叫zlgcan。它最大的优势是封装好了结构体不用自己用ctypes定义还支持CAN FD等新功能。如果你的卡在官方支持列表里Python版本又匹配直接用它是很舒服的。官方SDK的用法大致是这样的思路注意不同版本的方法名差异很大务必以你pip到的版本的官方demo为准# 伪代码别直接抄zlgcan各版本API差异较大 from zlgcan import ZCAN can ZCAN() # 1) 打开设备并拿到设备句柄 # 2) 初始化通道传入波特率和滤波配置 # 3) 启动通道 # 4) 调用发送/接收接口处理返回的报文对象但这里有个现实问题版本迭代快网上抄来的老代码经常对不上而且部分经典老型号不在新库的支持范围内。已经停产但还在用的老卡往往是SDK更新最不积极的。2.2 直调DLL的好处可控、通用、不随SDK漂移ControlCAN.dll是周立功老牌卡共用的动态库API十几年没大变过VCI_OpenDevice、VCI_InitCAN、VCI_Transmit、VCI_Receive这一套。用ctypes直接调用不依赖第三方包不受SDK版本影响只要卡能被CANTest识别代码就能跑。我个人的体会是这种底层直调的方式特别适合工具型代码。你可以完全掌控每一帧发出去的状态码发没发出去、缓冲区满没满返回值清清楚楚。SDK封装得再好出问题的时候你还得回到这一层去查。干脆一开始就用这层省得来回倒腾。2.3 32位Python问题编译环境决定成败这是经典的坑值得单独说。经典ControlCAN.dll是32位的你用64位Python去加载直接报错OSError: [WinError 193] %1 不是有效的 Win32 应用程序我第一次遇到的时候还以为是DLL文件坏了折腾半天。解决方案很简单装一个32位的Python环境专门跑这类脚本。我现在的习惯是平时开发用64位Python但固定保留一个32位的Python 3.8/3.9专门处理CAN相关的工具用虚拟环境隔离互不干扰。2.4 python-can库能不能用顺带说一句python-can这个第三方库也有周立功部分型号的backend写法更优雅。但它对型号的覆盖有限而且你拿不到底层状态码来做细粒度判断。自己写个demo玩玩可以做产线或者实验室的长期工具我还是倾向直接控DLL少一层封装少一层鬼。3. 报文结构与波特率映射不搞懂这两个收发代码写了也是白写3.1 VCI_CAN_OBJ一帧报文的结构拆解CAN报文在周立功的API里就是一个结构体VCI_CAN_OBJ最关键的几个字段ID帧ID。标准帧是11位扩展帧是29位所谓标准帧ID 0x123就是这个值。ExternFlag0表示标准帧1表示扩展帧。这个标志决定ID解释成11位还是29位。RemoteFlag0表示数据帧1表示远程帧。远程帧不带数据是向对方请求数据。SendType这个只对发送有意义0表示正常发送有错误自动重发1表示单次发送发一次失败不重试。调试阶段我一般用1。DataLen数据长度经典CAN最多8字节。Data数据本体一个8字节的数组。这个结构体顺序很重要因为后面ctypes定义要按同样的字段顺序和类型对齐错一个字节整个报文解析就乱了。3.2 VCI_INIT_CONFIG里最容易配错的滤波和模式初始化通道时要用VCI_INIT_CONFIG这个结构体里面有AccCode、AccMask、Filter、Timing0、Timing1、Mode这些字段。先说Filter也就是滤波方式。新手最容易在这里配错以为设了Filter1就能收到所有帧结果AccMask掩码没算对啥也收不到。最简单的做法是Filter0表示接收所有帧AccCode和AccMask都不用管。我提供给大部分测试场景的方案就是Filter0先保证能收到再做精细化过滤。再说Mode0是正常模式1是只听模式。只监听不想发送干扰总线的时候用1。如果只是调试工具正常模式就行。3.3 Timing0/Timing1波特率表与计算公式波特率不是直接填一个数字而是填两个字节Timing0和Timing1。这套表是基于16MHz晶振、SJA1000兼容控制器的经典值绝大多数USBCAN经典卡适用波特率Timing0Timing11000K0x000x14800K0x000x16500K0x000x1C250K0x010x1C125K0x030x1C100K0x040x1C50K0x090x1C20K0x180x1C10K0x310x1C如果想知道这套值怎么来的可以这样算Timing0的低6位是BRP预分频值Timing1的低4位是TSEG1高3位是TSEG2晶振按16MHz计。波特率约等于16MHz除以2乘BRP1再乘TSEG1TSEG23。以500K为例Timing10x1CTSEG112TSEG21BRP0算下来正好是500K。新款的CAN FD控制器不一定沿用这套寄存器直接用CANTest工具生成配置更靠谱。这也是我为什么强调一开始先开CANTest它能帮你验证波特率参数是不是对的。3.4 ctypes结构体定义字段对齐和sizeof校验用ctypes定义结构体时字段类型不能错。VCI_CAN_OBJ里ID和TimeStamp都是32位无符号整数后面一连串标志位是单字节最后Data是8个字节数组加3个保留字节。VCI_INIT_CONFIG则是三个32位整数加四个单字节。定义完别急着往下写先打印一下结构体大小验证from ctypes import * print(sizeof(VCI_CAN_OBJ)) # 正常应该是24 print(sizeof(VCI_INIT_CONFIG)) # 正常应该是16如果大小不对说明字段定义和C头文件有出入后面所有收发都会解析错。这个验证只要写错一次就知道有多值钱。4. 完整代码用ctypes封装ControlCAN.dll收发一条龙4.1 DLL加载与函数签名声明先把结构体和常量定义好。我把DLL路径和脚本放同一目录用os.path拼绝对路径省得手工拷贝DLL。加载用ctypes.WinDLL因为ControlCAN.dll是stdcall调用约定。import os import ctypes from ctypes import * class VCI_CAN_OBJ(Structure): _fields_ [ (ID, c_uint), (TimeStamp, c_uint), (TimeFlag, c_ubyte), (SendType, c_ubyte), (RemoteFlag, c_ubyte), (ExternFlag, c_ubyte), (DataLen, c_ubyte), (Data, c_ubyte * 8), (Reserved, c_ubyte * 3), ] class VCI_INIT_CONFIG(Structure): _fields_ [ (AccCode, c_uint), (AccMask, c_uint), (Reserved, c_uint), (Filter, c_ubyte), (Timing0, c_ubyte), (Timing1, c_ubyte), (Mode, c_ubyte), ] VCI_USBCAN1 4 VCI_USBCAN2 3 BAUD_TABLE { 1000: (0x00, 0x14), 800: (0x00, 0x16), 500: (0x00, 0x1C), 250: (0x01, 0x1C), 125: (0x03, 0x1C), 100: (0x04, 0x1C), 50: (0x09, 0x1C), 20: (0x18, 0x1C), 10: (0x31, 0x1C), }接下来封装一个ZLGCAN类声明每个API的参数类型和返回值类型。这一步很多人省略但argtypes一定要写。不写的话在64位Python上传递指针参数时可能被截断报错都很诡异。写了argtypes之后参数类型不对会立刻暴露省很多事。class ZLGCAN: def __init__(self, dll_pathControlCAN.dll): self.dll ctypes.WinDLL(dll_path) self._bind() def _bind(self): d self.dll d.VCI_OpenDevice.argtypes [c_uint, c_uint, c_uint] d.VCI_OpenDevice.restype c_int d.VCI_CloseDevice.argtypes [c_uint, c_uint] d.VCI_CloseDevice.restype c_int d.VCI_InitCAN.argtypes [c_uint, c_uint, c_uint, POINTER(VCI_INIT_CONFIG)] d.VCI_InitCAN.restype c_int d.VCI_StartCAN.argtypes [c_uint, c_uint, c_uint] d.VCI_StartCAN.restype c_int d.VCI_ResetCAN.argtypes [c_uint, c_uint, c_uint] d.VCI_ResetCAN.restype c_int d.VCI_ClearBuffer.argtypes [c_uint, c_uint, c_uint] d.VCI_ClearBuffer.restype c_int d.VCI_Transmit.argtypes [c_uint, c_uint, c_uint, POINTER(VCI_CAN_OBJ), c_uint] d.VCI_Transmit.restype c_int d.VCI_Receive.argtypes [c_uint, c_uint, c_uint, POINTER(VCI_CAN_OBJ), c_uint, c_int] d.VCI_Receive.restype c_int d.VCI_GetReceiveNum.argtypes [c_uint, c_uint, c_uint] d.VCI_GetReceiveNum.restype c_int4.2 打开-初始化-启动三步曲设备的生命周期很固定打开设备初始化某个通道启动通道清空接收缓冲然后才谈收发。每一步的返回值都要检查1表示成功0表示失败。失败就抛异常不要在日志里云里雾里地飘。def open_device(self, dev_type, dev_index0): if self.dll.VCI_OpenDevice(dev_type, dev_index, 0) ! 1: raise RuntimeError(打开设备失败检查驱动、占用和DeviceType) def close_device(self, dev_type, dev_index0): self.dll.VCI_CloseDevice(dev_type, dev_index) def init_can(self, dev_type, dev_index, chan, bitrate500, mode0): if bitrate not in BAUD_TABLE: raise ValueError(f未内置该波特率: {bitrate}) t0, t1 BAUD_TABLE[bitrate] cfg VCI_INIT_CONFIG(0, 0xFFFFFFFF, 0, 0, t0, t1, mode) if self.dll.VCI_InitCAN(dev_type, dev_index, chan, byref(cfg)) ! 1: raise RuntimeError(初始化CAN通道失败) def start_can(self, dev_type, dev_index, chan): if self.dll.VCI_StartCAN(dev_type, dev_index, chan) ! 1: raise RuntimeError(启动CAN通道失败) def reset_can(self, dev_type, dev_index, chan): self.dll.VCI_ResetCAN(dev_type, dev_index, chan) def clear_buffer(self, dev_type, dev_index, chan): self.dll.VCI_ClearBuffer(dev_type, dev_index, chan)初始化里Filter填0接收所有帧AccMask填0xFFFFFFFF。这是最不容易出错的组合。4.3 发送函数注意SendType和返回值发送的核心是构造VCI_CAN_OBJ然后调用VCI_Transmit。VCI_Transmit的返回值是实际进入发送缓冲区的帧数。你传1帧进去返回1才是真成功了返回0说明发送缓冲区满或者总线状态有问题。调试场景我把SendType设为1单次发送不自动重发。原因很简单如果总线上没有其他节点应答正常发送模式会一直重试表现就像卡死了一样特别吓人。单次发送至少能干净地暴露问题。def send(self, dev_type, dev_index, chan, frame_id, data, remoteFalse, extendedFalse, singleTrue): frame VCI_CAN_OBJ() frame.ID frame_id 0x1FFFFFFF frame.SendType 1 if single else 0 frame.RemoteFlag 1 if remote else 0 frame.ExternFlag 1 if extended else 0 frame.DataLen len(data) if not remote: if len(data) 8: raise ValueError(经典CAN一帧最多8字节) for i, b in enumerate(data): frame.Data[i] b 0xFF n self.dll.VCI_Transmit(dev_type, dev_index, chan, byref(frame), 1) if n ! 1: raise RuntimeError(发送失败: 发送缓冲区满或总线无应答)4.4 接收函数阻塞等待、批量取帧与线程化接收用VCI_Receive。这个函数的最后一个参数是等待时间单位毫秒0表示立即返回正数表示最多等这么久-1表示无限等。我用100到200毫秒比较合适别用-1否则程序退出时不好收拾。更关键的一点VCI_Receive一次可以取多帧。我习惯一次传入16帧的数组把缓冲区的积压一次捞干净避免持续溢出丢帧。把每帧解析成字典返回后面处理起来方便。def recv(self, dev_type, dev_index, chan, wait_ms100, max_frames16): frames (VCI_CAN_OBJ * max_frames)() n self.dll.VCI_Receive(dev_type, dev_index, chan, frames, max_frames, wait_ms) if n 0: return [] out [] for i in range(n): f frames[i] out.append({ id: f.ID, ts: f.TimeStamp if f.TimeFlag else None, remote: bool(f.RemoteFlag), extended: bool(f.ExternFlag), dlc: f.DataLen, data: bytes(f.Data[:f.DataLen]), }) return out def recv_ready(self, dev_type, dev_index, chan): return self.dll.VCI_GetReceiveNum(dev_type, dev_index, chan)如果要长期监听开一个后台线程循环接收回调到主逻辑里就行。注意接收线程里不要做耗时操作解析完就丢给队列或回调否则下一轮接收会滞后。import threading class CanMonitor(threading.Thread): def __init__(self, can, dev_type, dev_index, chan, callback, wait_ms50): super().__init__(daemonTrue) self._can can self._args (dev_type, dev_index, chan) self._cb callback self._wait_ms wait_ms self._running True def stop(self): self._running False def run(self): while self._running: frames self._can.recv(*self._args, wait_msself._wait_ms) for f in frames: self._cb(f)4.5 直接可跑的main双通道自测接线法有了上面的封装主程序就很简单了。这里有个非常重要的自测技巧如果你用的是USBCAN-II这种双通道卡把通道0和通道1的CAN_H对接、CAN_L对接再共地那么通道0发出去的帧通道1就能收到。这是最便宜的自环验证方式不用第二块卡也不用额外设备。def main(): DEV_TYPE VCI_USBCAN2 # 双通道USB卡按你的型号改 DEV_INDEX 0 CH 0 BITRATE 500 can ZLGCAN() can.open_device(DEV_TYPE, DEV_INDEX) can.init_can(DEV_TYPE, DEV_INDEX, CH, BITRATE) can.start_can(DEV_TYPE, DEV_INDEX, CH) can.clear_buffer(DEV_TYPE, DEV_INDEX, CH) # 发送一帧然后接收看看 can.send(DEV_TYPE, DEV_INDEX, CH, 0x123, [1, 2, 3, 4]) for _ in range(5): frames can.recv(DEV_TYPE, DEV_INDEX, CH, wait_ms200) for f in frames: print(fID{f[id]:03X} DLC{f[dlc]} DATA{f[data].hex( )}) can.reset_can(DEV_TYPE, DEV_INDEX, CH) can.close_device(DEV_TYPE, DEV_INDEX) if __name__ __main__: main()如果你只有单通道卡又没有任何别的CAN节点那自测就得借助CANTest作为对端节点CANTest里开接收你的Python脚本只管发能在CANTest里看到帧就说明发送链路是通的。5. 收不到数据按这个顺序排查从驱动到接线的完整链路5.1 设备打不开先别怀疑代码打开设备失败不外乎几个原因。先看驱动装没装设备管理器里有没有未知设备。再看你的CANTest是不是还占着设备周立功的驱动同一时刻一般只允许一个进程持有设备CANTest没关的话Python这边VCI_OpenDevice就会失败。然后是32位Python的问题我前面专门提过。最后才是DeviceType抄错。按这个顺序查基本都能解决。5.2 发不出去ACK应答和单节点总线这个隐藏前提这是很多人忽略的底层知识。CAN总线上发出去的每一帧都需要至少一个其他节点在ACK槽位给出应答发送方才会认为这一帧成功。如果总线上只有你一块卡没有别的节点不管SendType是0还是1这帧实际上都发不成功要么一直重试要么直接报错。所以如果你是单卡单节点测试别怀疑代码了物理上就缺一个节点。解决办法我前面说了双通道卡两个通道对接或者用CANTest当节点或者再接一块别的CAN设备。另外提醒一句总线两端最好各接一个120欧姆终端电阻。短距离桌面调试不接也能通但遇到偶发错帧先查终端。5.3 收不到波特率、滤波、接线一个都不能少收不到数据先核对两边的波特率。500K对250K那是真的谁也听不见谁。用CANTest一边发一边收能确认波特率是不是一致。再就是滤波。我们的Filter0接收所有帧基本不会出错。如果你改过Filter或AccMask仔细核对掩码逻辑掩码算错了符合条件之外的帧全被滤掉。最后是接线。CAN_H对CAN_HCAN_L对CAN_L地线共地。H和L接反了或没共地总线就是废的。看起来都是常识但越急越容易错。5.4 丢帧清缓冲、批量接收、查GetReceiveNumCAN总线的报文是突发性的数据量大时PC端接收稍慢一点设备端缓冲区就可能溢出。我的处理是启动后先调用ClearBuffer把旧帧清掉接收时用批量接口一次取多帧轮询时用VCI_GetReceiveNum看看缓冲区里积压了多少。pending can.recv_ready(DEV_TYPE, DEV_INDEX, CH) print(f缓冲区还有 {pending} 帧)如果pending数量持续上涨说明你的接收循环太慢要么减少单次处理的耗时要么把wait_ms调小要么用线程接收。长期跑的时候我还会定时打印pending一旦异常增长立刻定位。5.5 时间戳TimeFlag为1才有效单位看手册VCI_CAN_OBJ里的TimeStamp字段只有在TimeFlag为1时才有意义。TimeFlag为0说明硬件没给这个帧打时间戳读出来的值不可信。我的代码里用TimeFlag判断为1才返回时间戳否则返回None。时间戳的单位因设备而异常见的是0.1毫秒。做精确时序分析之前一定去手册里查清楚你的卡的时间戳单位我踩过把0.1ms当1ms用导致时序结论全错的坑。6. 进阶玩法远程帧、CAN FD和把脚本做成能长期跑的工具6.1 远程帧请求一帧数据而不是填数据远程帧在调试里不算频繁但偶尔要用。比如你想让对方设备把某个ID的数据发出来就发送一个RemoteFlag1、DataLen对应期望长度的远程帧。发送方收到后通常会回一帧数据。# 请求ID0x321的设备回一帧8字节数据 can.send(DEV_TYPE, DEV_INDEX, CH, 0x321, [], remoteTrue)注意远程帧里的DataLen是请求的数据长度不是真的带数据。我的send函数里已经处理了这个分支传remoteTrue时就不校验数据长度了。6.2 CAN FD超过8字节就得换设备换API经典CAN一帧最多8字节CAN FD是为了解决带宽问题出现的一帧最多64字节数据段波特率还可以和仲裁段不一样。但这个支持是硬件级的USBCAN老型号没法靠刷固件支持FD。如果你要玩CAN FD建议直接用USBCANFD这类设备和官方SDK或者新版内核动态库。经典CAN的VCI_CAN_OBJ结构体装不下64字节的FD帧硬套的话数据会被截断这是结构决定的事不是改个参数能绕过去的。我自己在USBCANFD上跑经典CAN帧用的是本文章这套代码跑FD帧还是走官方SDK。6.3 工程化CSV落盘、参数化与长跑稳定性脚本一旦要长期跑就不能只是main里print一下。我的习惯是把配置提到命令行参数里用argparse传波特率、通道、设备类型避免每次改代码。数据落盘用csv模块一帧一行import csv import time with open(can_log.csv, w, newline) as f: writer csv.writer(f) writer.writerow([time, id, dlc, data]) # 在接收回调里写行 # writer.writerow([time.time(), f[id], f[dlc], f[data].hex( )])长跑稳定性上主要注意两点。第一接收线程的异常要捕获并记录下来不能让线程静默死掉。第二周期性检查recv_ready一旦发现积压帧数持续超过某个阈值就说明PC处理不过来该降波特率或者优化处理逻辑了。这套代码和思路在我手头USBCAN-II和USBCANFD上跑过经典CAN帧收发最早是给一个电机控制器项目做老化测试用的连续跑过几天。最后留个建议拿到卡先插上开CANTest发一帧收一帧把硬件链路确认了再写Python。这个顺序别反。出了问题拿这份脚本当底案从已知能跑的地方开始改你会少走很多弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑