Python玩转ZLG CAN卡:二次开发环境与实战解析
2026/9/7 13:18:19 网站建设 项目流程

简介:面向Python硬件二次开发者的ZLG示例包,围绕DTU 200UWGR及CAN-DTU系列无线数据传输单元,提供经官方工程师修复后可直接运行的Python二次开发程序。资源定位于物联网与工业控制场景,适合需要定制DTU通信功能、扩展协议适配或排查官方示例问题的Python工程师。压缩包内含124个文件,以XML数据与配置文件、DLL底层驱动库、INI参数文件为主,另附一个Python脚本及编译后的PYC文件,整体仅1.86MB,体积精简但覆盖核心调用接口;XML用于定义通信参数,DLL封装各型号设备底层接口,INI保存默认运行参数。已有1161人学习下载。通过这套demo,开发者可快速理解DTU连接建立、CAN总线数据收发、无线链路状态管理等关键环节,避免重复踩坑,并能复用其中代码段构建自身业务逻辑,从设备初始化到数据上报形成完整开发参考,大幅缩短基于ZLG硬件的项目开发周期。

1. 这块板卡到底能干什么,以及为什么我用Python来做二次开发

做嵌入式或者车载总线相关的朋友,对ZLG(致远电子)应该不陌生。它的USBCAN系列、CANalyst-II分析仪,以及各类串口服务器、采集模块,几乎是实验室和现场调试的常备工具。以前我最早接触ZLG的时候,用的还是它自带的厂商上位机软件,功能确实全,但一旦遇到“要把采集的数据接进自己的业务系统”这种需求,厂商软件就完全使不上劲了。

这时候就需要做二次开发。传统做法是用C++或者C#去调用厂家提供的DLL接口,但对于我们这种以测试和数据处理为主、又不常写桌面程序的团队来说,C++的门槛和开发周期都不太划算。后来我发现ZLG官方其实有Python的demo,而且接口封装得比较干净,调用逻辑和C接口是一一对应的,只是换成了ctypes或者官方提供的Python扩展库来实现。用Python做这件事的好处非常直接:写起来快,调试方便,能直接和数据分析、报表生成、数据库对接这些活无缝衔接。

这篇东西适合谁看?如果你手里有ZLG的CAN卡或者串口设备,想用Python快速把数据读回来、把控制指令发出去,但不想从头去啃DLL文档;或者你已经在用别的语言做二次开发,想看看Python这条路能不能提高效率,那这篇文章就是写给你的。我尽量把从环境搭建到实际跑通的细节都交代清楚,包括我在现场踩过的坑。

2. 环境准备:别急着写代码,先把依赖理清楚

2.1 Python版本和系统架构的选择

先说结论:我测试用的是Python 3.8到3.10之间的版本,64位系统,Windows 10/11都跑过,Linux环境下用官方提供的Linux库也能跑通。网上有些人说Python 3.10以上会有兼容问题,我实测下来并没有碰到大问题,但如果你用的是旧版ZLG库,建议还是保守一点,用Python 3.8。

这里有一个很多人容易忽略的点:ZLG提供的动态库是有32位和64位之分的,你的Python解释器架构必须和动态库架构一致,否则加载DLL的时候会直接报“模块找不到”或者“不是有效的Win32应用程序”。我自己就犯过这个错,电脑上装了64位Python,结果从旧项目里拷了一个32位的DLL,折腾了一下午才发现是架构不匹配。

安装Python的时候,记得把“Add Python to PATH”勾上,这个不勾的话,后续命令行执行pip会非常痛苦。装完以后建议顺手把pip源换成国内镜像,不然下载依赖动不动就超时。

2.2 ZLG驱动与动态库文件

ZLG的板卡安装好之后,一般会带一个驱动光盘或者官网下载的驱动包。驱动装好之后,你可以把它自带的库文件找出来。不同系列的设备库文件名不太一样,常见的有:

设备系列库文件名主要用途
USBCAN-I/IIControlCAN.dllCAN接口卡标准接口
CANalyst-IIControlCAN.dll同样走ControlCAN接口
串口设备zlg_serial.dll 或系统串口串口通信
以太网设备ZLGCAN.dll网络型CAN设备

这里最常用的是ControlCAN.dll,几乎所有的CAN分析仪二次开发都是从这一套接口入手的。如果你下载的demo包里有Python示例,大概率它也是直接加载这个DLL,然后用ctypes去调用里面的导出函数。

2.3 demo项目目录和文件清单

我拿到的官方Python demo解压之后大概是这个结构:

zlg_python_demo/ ├── demo/ │ ├── test_can.py │ ├── test_serial.py │ └── common.py ├── lib/ │ ├── ControlCAN.dll │ └── kernel.dll └── README.txt

看起来非常简单,但实际运行之前你需要确认几件事:第一,lib目录下的DLL是不是和你板卡型号匹配;第二,Python能不能找得到这个DLL的路径;第三,有没有缺失的节点或者依赖,比如某些demo会用到numpy或者pandas来做数据处理,这就得先pip install。官方README里写的是“安装缺失的节点”,其实翻译成人话就是:运行前把需要的第三方库装齐。

如果你拿到的是一个残缺的demo包,里面只有Python代码没有DLL,那也正常,因为DLL通常在驱动安装目录里,比如C:\Program Files\ZLG\...。你需要把DLL路径加入系统的PATH环境变量,或者在Python代码里用绝对路径加载。

我个人建议是不要改动官方库文件的位置,而是把DLL复制到你的项目lib目录下,然后代码里通过相对路径去加载。这样换电脑、交给同事跑的时候都不会出问题。

3. 核心API解析:看懂ControlCAN这几个函数,demo基本就懂了一半

3.1 接口初始化和设备打开

ZLG的CAN接口设计思路是很统一的。不管你是用官方上位机还是自己写代码,第一步永远是打开设备,第二步是初始化,第三步是启动CAN通道。这个顺序不能乱,就像你开机得先按电源键,不能直接去按显示器开关。

Python里通过ctypes加载DLL的方式如下:

import ctypes import os # 加载DLL,注意路径 dll_path = os.path.join(os.path.dirname(__file__), 'lib', 'ControlCAN.dll') can = ctypes.WinDLL(dll_path) # Windows环境用WinDLL

加载之后,调用打开设备的接口:

# VCI_OpenDevice(设备类型, 设备索引, 保留参数) # 设备类型:比如USBCAN-I对应4,USBCAN-II对应4,具体按官方头文件定义 device_type = 4 device_index = 0 reserved = 0 result = can.VCI_OpenDevice(device_type, device_index, reserved) if result == 1: print("设备打开成功") else: print("设备打开失败,错误码:", result)

这里的返回值特别容易让人困惑。C接口里返回1表示成功,0表示失败,但有些老版本的库在异常情况下返回的是负值,比如-1。所以判断条件最好是result == 1,而不是result != 0,否则可能把错误值当作成功处理。

3.2 初始化CAN通道和启动

打开设备只是第一步,接下来要初始化通道参数,比如波特率、工作模式。ZLG的接口里,这个参数通常通过一个结构体VCI_INIT_CONFIG传入,用Python的ctypes定义它:

class VCI_INIT_CONFIG(ctypes.Structure): _fields_ = [ ("AccCode", ctypes.c_uint32), # 验收码 ("AccMask", ctypes.c_uint32), # 屏蔽码 ("Reserved", ctypes.c_uint32), # 保留 ("Filter", ctypes.c_ubyte), # 滤波器方式 ("Timing0", ctypes.c_ubyte), # 波特率参数0 ("Timing1", ctypes.c_ubyte), # 波特率参数1 ("Mode", ctypes.c_ubyte), # 工作模式,0正常,1只听 ]

这个结构体里的很多参数,比如验收码、屏蔽码,如果你只是做基础收发,直接用默认值就行。最容易出问题的是波特率参数,因为ZLG用的不是直接填波特率数值,而是通过Timing0Timing1两个字节来确定。这是从CAN控制器底层寄存器映射过来的。

比如常用的几个波特率:

波特率Timing0Timing1
125Kbps0x030x1C
250Kbps0x010x1C
500Kbps0x000x1C
1Mbps0x000x14

这些数值在官方头文件里都有,千万别自己乱改。如果你发现总线那边设备波特率是250K,而你这里默认填了500K,那结果是收不到任何数据,而且不会报错,排查起来非常头疼。

初始化函数和启动函数的调用顺序:

config = VCI_INIT_CONFIG() config.AccCode = 0x00000000 config.AccMask = 0xFFFFFFFF config.Filter = 0 # 不滤波,接收所有帧 config.Timing0 = 0x01 config.Timing1 = 0x1C # 250Kbps config.Mode = 0 # 正常模式 can.VCI_InitCAN(device_type, device_index, 0, ctypes.byref(config)) # 启动CAN通道,0 表示CAN0通道 can.VCI_StartCAN(device_type, device_index, 0)

这里有一个小细节:如果你用的是双通道设备,启动另一个通道时把最后一个参数改成1就行。但初始化的时候,每个通道都要单独调用一次VCI_InitCAN

3.3 数据接收:轮询和中断式怎么选

ZLG的CAN卡接收数据有两种常见方式:一种是开一个线程定时去读取缓冲区数据,另一种是依赖驱动底层的中断回调(在Python里实现起来比较绕)。官方demo里通常用的是第一种,也就是轮询。

接收接口的定义和发送类似,但它需要额外定义一个接收帧的结构体:

class VCI_CAN_OBJ(ctypes.Structure): _fields_ = [ ("ID", ctypes.c_uint32), # 帧ID ("TimeStamp", ctypes.c_uint32), # 时间戳 ("TimeFlag", ctypes.c_ubyte), # 是否使用时间戳 ("SendType", ctypes.c_ubyte), # 发送类型 ("RemoteFlag", ctypes.c_ubyte), # 远程帧标志 ("ExternFlag", ctypes.c_ubyte), # 扩展帧标志 ("DataLen", ctypes.c_ubyte), # 数据长度 ("Data", ctypes.c_ubyte * 8), # 数据 ("Reserved", ctypes.c_ubyte * 3), ]

读取数据时,先创建一个数组用来存放多帧数据,然后调用接收函数:

recv_buff = (VCI_CAN_OBJ * 100)() while True: count = can.VCI_GetReceiveNum(device_type, device_index, 0) if count > 0: actual = can.VCI_Receive(device_type, device_index, 0, recv_buff, count, 100) for i in range(actual): frame = recv_buff[i] print(f"ID={hex(frame.ID)}, Data={bytes(frame.Data[:frame.DataLen]).hex()}") time.sleep(0.01)

这里注意两个点:VCI_GetReceiveNum返回缓冲区里有多少帧,VCI_Receive的第三个参数是等待超时时间(毫秒),如果你传0,那就变成了非阻塞模式,缓冲区没数据时直接返回0。实际开发时我习惯先用GetReceiveNum查数量,再按数量去收,这样不会丢帧。

3.4 数据发送:最简单的报文发送

发送一个CAN报文的核心逻辑,就是填充一个VCI_CAN_OBJ结构体,然后调用发送函数:

frame = VCI_CAN_OBJ() frame.ID = 0x123 frame.SendType = 0 # 正常发送,1表示单次发送 frame.RemoteFlag = 0 # 数据帧 frame.ExternFlag = 0 # 标准帧 frame.DataLen = 8 frame.Data[0] = 0x01 frame.Data[1] = 0x02 send_result = can.VCI_Transmit(device_type, device_index, 0, ctypes.byref(frame), 1) if send_result == 1: print("发送成功") else: print("发送失败")

发送这里有一个比较容易踩的坑:VCI_Transmit的第四个参数是pSend,它指向一个VCI_CAN_OBJ结构体数组,而第五个参数是发送的帧数。如果你一次只发一帧,传1没问题;但如果你想一次发多帧,不能重复调用Transmit,而是要构造一个结构体数组,一次传进去。频繁单帧调用会明显拉低发送效率,尤其是在高负载测试的时候。

4. 实操过程:从demo到完整小工具,我的一次完整复现记录

4.1 先把官方demo跑起来

我这次拿到的demo里,test_can.py是最核心的示例。它的大致逻辑是:打开设备、初始化通道、启动CAN、循环接收数据并打印、然后发送一帧测试报文。我把路径切换到项目目录,直接运行:

python test_can.py

结果第一遍运行就报错了,提示AttributeError: function 'VCI_OpenDevice' not found。这个报错的意思是DLL加载成功了,但里面找不到对应的导出函数。我马上意识到可能是DLL不对,于是用一个小工具查看了DLL的导出表,发现这个版本的ControlCAN.dll导出的是OpenDevice而不是VCI_OpenDevice

原来ZLG早期版本的库函数命名和后来不一样,后面为兼容老客户,新版本库才同时保留了两种名称。解决方案很简单:把代码里所有VCI_前缀的函数名映射成标准名称。如果你拿到的是老库,可以用getattr去做一层名字兼容:

open_func = getattr(can, "VCI_OpenDevice", None) if open_func is None: open_func = getattr(can, "OpenDevice")

跑通之后,我又遇到了第二个问题:设备打开成功,初始化也OK,但一直收不到总线上别的节点发来的数据。排查过程放到下一节细说,这里先继续讲代码层面。

4.2 数据解析现场:把收到的原始帧变成结构化数据

demo只能让你看到数据长什么样,但实际项目中,你需要把CAN帧里的裸数据解析成真实的物理量。比如某型号的BMS会在ID为0x351的报文里通过字节0和字节1发送电池总电压,字节3发送温度,这种解析逻辑就必须自己写。

我这里做了一个简单的解析函数,以字典的方式返回结果:

def parse_bms_frame(frame): if frame.ID != 0x351: return None data = bytes(frame.Data[:frame.DataLen]) voltage = (data[0] | (data[1] << 8)) / 10.0 # 单位0.1V temperature = data[3] - 40 # 偏移量40 return { "voltage": voltage, "temperature": temperature, }

这种解析脚本写起来很简单,但要注意字节序和偏移量,每个厂商的定义都不一样,一定要对着协议文档来。我就见过有人把高低字节搞反,解析出来的电压直接翻了几十倍,还在现场找了半天原因。

4.3 整个demo的完整代码架构

我最终完成的这个小工具,结构大概是这样的:

can_tool/ ├── main.py ├── zlg_driver.py ├── parser.py └── config.yaml

zlg_driver.py封装了设备打开、关闭、发送、接收所有底层操作,对上层只暴露四个方法:open()close()send(frame_dict)receive(timeout)parser.py处理协议解析。config.yaml存设备类型、波特率、通道号这些配置。这样做的好处是,换设备型号或者换波特率的时候,只需要改配置文件,完全不用动代码。

一个小建议:接收线程里不要直接做数据处理,最好把原始帧塞进队列里,由另一个线程去消费。这样可以保证接收速率稳定,不会被解析逻辑拖慢。

5. 常见问题与排查技巧实录

5.1 问题速查表,先对着看

我在不同电脑、不同设备上跑ZLG Python demo,前前后后遇到过不少问题。下面这个表是我整理出来频率最高的几类:

现象可能原因解决办法
导入DLL失败,提示找不到模块DLL路径不对或Python位数与DLL位数不一致用绝对路径加载,确认Python是32位还是64位
函数调用报错function not foundDLL版本太老,函数名不同getattr做名称兼容,或者换新版DLL
设备打开失败,返回0驱动未安装、USB线没插好、设备被占用先检查设备管理器,再试重新插拔USB
CAN通道初始化失败波特率参数填错对照官方头文件或者上面给的波特率表
能打开但收不到数据波特率不匹配、滤波配置不对、总线没有终端电阻检查总线上的波特率,确认所有节点的滤波设置
发送失败返回-1通道未启动或总线关闭确认调用了VCI_StartCAN,检查总线是否短路
程序退出后设备仍被占用没调用CloseDevice写代码时用try...finally确保关闭设备

5.2 现场排查记录:为什么设备打开正常却收不到数据

有一次我在台架上调试,换了一个新的ZLG设备,demo跑起来设备打开、初始化全部正常,但就是收不到数据。第一反应是波特率问题,查了一遍发现配置没改。然后又检查了总线的终端电阻,也正常。

最后用厂商上位机软件打开一看,发现设备因为之前被别的程序占用过,驱动状态异常,通道没有彻底释放。解决办法是把设备从USB口拔掉,重新插上,并且在代码里关闭设备后加一个短暂的time.sleep(0.5),给驱动一点释放时间。

从那次以后,我在代码里都会加上设备重试机制,打开失败之后延时1秒重试三次,这在实际环境中非常管用,因为USB设备在系统睡眠唤醒或热插拔之后,经常需要一点时间重新枚举。

5.3 几个别人踩过、你也容易踩的坑

第一,不要在高频接收的时候用print每一帧。Python的print是同步阻塞的,帧率一高,缓冲区很快就满了,丢帧丢到怀疑人生。我把数据改成批量写入CSV之后,测试5000帧/秒完全没压力。

第二,多线程里调用DLL函数时要小心。如果两个线程同时调用发送函数,有可能会发生资源竞争。我建议给发送操作加一个锁,或者统一由一个线程负责所有发出操作。

第三,DLL的加载路径不要写死成C:\Windows\System32,也不要依赖系统PATH。最好放在项目目录下,用os.path.join(os.path.dirname(__file__), ...)去定位。这样项目拷给别人时,DLL自带,不会因为每台电脑的环境变量不同而跑不起来。

6. 最后的建议与扩展方向

从我个人的实际使用来看,ZLG的Python二次开发接口并没有想象中那么神秘,它本质上就是对一套C接口的ctypes封装。难点不在于接口本身,而在于你对自己要解决的应用场景是否有足够清晰的规划。你是要做总线监控,还是要做自动化测试,或者是要做产线数据采集?这三种场景下的代码架构是完全不同的。

如果你只是做监控类工具,把demo改一改就够了;如果是做自动化测试,我强烈建议把设备操作封装成一个类,再结合pytest写用例,这样可以在CI里自动跑总线回归测试。

还有一个很实用的扩展方向是配合数据分析。CAN数据录下来之后,用pandas做离线分析,再配合matplotlib画曲线,可以非常快地定位到总线上偶发异常的时间点。这一步很多纯嵌入式工程师不熟悉,但正是Python这个生态能带给你的额外价值。

最后,在使用过程中,记得保留好本来的C语言头文件对照着看,毕竟Python这一层封装再漂亮,最终调用的还是底层那套函数,遇到问题翻头文件往往比翻demo代码更有效。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询