拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于QT与ZLG USBCAN的上位机开发:CAN/串口通信与波形显示实战

简介面向汽车电子与工业自动化领域的Qt/C开发者这份资源围绕ZLG CAN卡与串口通信的集成开发提供了一套可用于UDS诊断和CAN数据收发的完整工程。包体共26个文件主要包含10个头文件、7个C源文件、3个DLL动态库及lib、pro、ui、ini等配置与界面文件压缩包约831KB结构紧凑便于直接导入Qt工程对照使用。已有5677人学习下载。资源不仅实现了ControlCAN.dll到ZLG驱动的兼容替换还封装了CAN消息收发、串口读写、UI交互与日志处理等模块通过信号槽机制完成实时通信并在多线程环境下避免界面阻塞适合需要快速搭建诊断工具或理解Qt下CAN/串口协议栈的开发者参考。 搞嵌入式上位机这几年ZLG的USBCAN系列基本是绕不开的工具配合QT做上位机界面又是国内工业软件最常见的组合。这篇东西我拖了很久才写因为涉及的点确实多——CAN通信、串口通信、实时波形显示、时域频域转换每一个单独拿出来都能写一篇但实际项目里它们往往是拧在一起的。这篇文章我会从整体架构讲起把CAN和串口这两条数据通道的搭建、QT界面集成的关键细节、以及用QCustomPlot做时域和频域波形显示的方法都过一遍最后把我踩过的坑和排查思路整理出来。适合正在做设备调试工具、产测软件、或者数据采集分析的开发者如果你是刚接触ZLG库或者QT串口/CAN编程的新手也能从这里找到可直接落地的代码和配置方法。1. 整体架构设计为什么是ZLG QT数据流怎么走1.1 方案选型背后的考量先说选型。ZLG致远电子在工业总线领域地位不用多介绍USBCAN系列设备比如USBCAN-I、USBCAN-II、USBCANFD等是很多人接触CAN总线时的首选调试工具。它最大的优势是把复杂的CAN控制器逻辑封装成了统一的DLL动态库ControlCAN.dll你的QT程序只需要加载这个库调用几个VCI开头的API函数就能完成打开设备、初始化CAN通道、收发报文的操作完全不用关心底层USB驱动怎么和CAN控制器打交道。QT这边Qt Widgets做传统桌面工具的成熟度不用怀疑QCustomPlot在实时曲线绘制领域又是轻量级首选不需要引入QWT那种重量级依赖。组合起来的开发效率很高界面代码和通信逻辑可以完全分离这对于需要频繁迭代的调试工具来说是刚需。这套架构的核心思路是界面线程负责显示和交互通信线程负责数据收发两者通过信号槽和缓冲区解耦。这样设计的好处很直接——收发数据的线程绝对不会卡界面哪怕CAN总线上报文风暴每秒几千帧界面依然能流畅拖动缩放波形。1.2 数据流与模块划分从数据流向来看整个系统分两层下位机STM32或其他MCU --CAN总线/串口-- ZLG USBCAN/串口设备 --USB/串口-- 上位机QT程序上位机程序内部再分三个模块通信层封装CAN收发调用ControlCAN库和串口收发用Qt自带的QSerialPort对外提供统一的打开、关闭、发送、接收回调接口。数据处理层对收到的原始帧做解析——CAN要解析ID、DLC、数据字节串口要按协议帧格式帧头、长度、校验组包拆包。解析完的数据会存成可绘图的数组需要做频谱分析时还会在这里调用KissFFT库做时域到频域的转换。显示层QCustomPlot负责时域波形和频域频谱的绘制表格控件负责报文列表展示状态栏显示设备连接状态和总线负载。这个分层是我试过很多次后觉得最舒服的结构。如果直接把VCI_Receive的调用和UI更新写在同一个槽函数里初期开发很爽但后面一旦要加缓存、过滤、统计功能代码会迅速腐化成一团乱麻。提示虽然ZLG官方也提供了测试软件比如CANTest但作为开发者把通信能力嵌入自己的工具里是必须的因为产线测试、自动化脚本、数据分析这些场景下没有人会手动点一个软件。2. CAN通信核心细节ControlCAN库的关键参数与帧收发2.1 设备初始化AccCode/AccMask过滤与波特率计算先看初始化这块这是第一个容易出问题的点。ZLG的ControlCAN库核心初始化流程是打开设备 - 初始化CAN通道 - 启动CAN通道对应三个API// 打开设备0表示设备索引0表示USBCAN-I类型具体看设备型号 VCI_OpenDevice(VCI_USBCAN2, 0, 0); // 初始化CAN1通道 VCI_INIT_CONFIG config; config.AccCode 0x00000000; // 验收码配合验收掩码一起用 config.AccMask 0xFFFFFFFF; // 全1表示不过滤接收所有帧 config.Filter 0; // 0表示单滤波1表示双滤波 config.Timing0 0x03; // 波特率相关参数 config.Timing1 0x1C; // 波特率相关参数 config.Mode 0; // 0正常模式1只听模式2自测模式 VCI_InitCAN(VCI_USBCAN2, 0, 0, config); // 启动CAN1通道 VCI_StartCAN(VCI_USBCAN2, 0, 0);这里的AccCode和AccMask很多新手搞不明白。简单说CAN控制器有个硬件验收过滤器只有当收到的报文ID满足“(ID AccMask) (AccCode AccMask)”时才会进入接收缓冲区。把AccMask设为0xFFFFFFFF意思就是“每条报文都符合过滤条件”全部接收。如果你只关心某个特定ID比如0x123那就设AccCode0x123AccMask0xFFFFFFF0低4位不参与匹配这样只有高28位匹配0x123的帧才会被接收。这对降低CPU占用很有帮助尤其是总线上报文量很大的时候。2.2 Timing0/Timing1与SJW同步跳跃宽度Timing0和Timing1这两个参数是BTR0和BTR1寄存器直接决定CAN波特率。这两个字节的位分配是Timing0低4位是BRP波特率预分频值-1高两位是SJW同步跳跃宽度-1Timing1低4位是TSEG1相位缓冲段1的Tq数-1高3位是TSEG2相位缓冲段2的Tq数-1最高位SAM采样次数很多人在STM32上见过SJW到ZLG这边就懵了。其实一回事。SJW的作用是补偿总线上的相位误差在总线节点时钟频率有偏差时这个值决定了每个位周期内采样点能往前或往后最多跳动多少个Tq时间份额。SJW设置太大会增加误采样风险太小则抵抗时钟漂移能力弱。一般的做法是总线波特率较低比如125kbps时SJW设1-2个Tq波特率较高1Mbps时设1个Tq不要超过TSEG2的值。我习惯直接背常用波特率的配置值波特率Timing0Timing1说明1000kbps0x000x14常用高速500kbps0x000x1C工业最常见250kbps0x010x1C中等速率125kbps0x030x1C低速远距离100kbps0x040x1C低速场景这里有个小技巧如果你不确定设备上的波特率是多少可以先用ZLG的CANTest工具软件会自动扫描常见波特率。实际项目里我遇到过几次波特率不匹配的情况波形上看不出来但表现为“所有节点都收不到数据”或者“偶发错误帧”。排查方法是看ZLG设备的LED状态灯有错误时它闪的频率和正常时明显不同。2.3 VCI_CAN_OBJ帧结构发送与接收的完整代码ZLG收发报文用的数据结构是VCI_CAN_OBJ成员包括帧ID、帧格式标准帧/扩展帧、帧类型数据帧/远程帧、数据长度和8字节数据。手动填充字段很容易出错我习惯封装一个发送函数bool CanManager::sendFrame(uint32_t id, const QByteArray data, bool isExtended) { VCI_CAN_OBJ frame; memset(frame, 0, sizeof(VCI_CAN_OBJ)); frame.ID id; frame.SendType 0; // 0自发送1单次发送 frame.RemoteFlag 0; // 0数据帧1远程帧 frame.ExternFlag isExtended ? 1 : 0; // 0标准帧1扩展帧 frame.DataLen data.size() 8 ? data.size() : 8; memcpy(frame.Data, data.constData(), frame.DataLen); // 发送到CAN1通道0为超时时间毫秒 int ret VCI_Transmit(VCI_USBCAN2, 0, 0, frame, 1); if (ret ! 1) { qWarning() CAN发送失败错误码: ret; return false; } return true; }接收呢两种方式。一种是在通信线程里循环调用VCI_Receive设一个合适的超时时间比如10ms有数据就处理没数据继续循环另一种是开一个定时器轮询。我测试下来独立线程阻塞接收是效率和CPU占用率兼顾得最好的方案。代码结构大致如下void CanManager::receiveLoop() { VCI_CAN_OBJ frames[256]; // 一次最多取256帧 while (m_running) { int count VCI_Receive(VCI_USBCAN2, 0, 0, frames, 256, 10); for (int i 0; i count; i) { // 解析frames[i]通过信号发给界面 emit frameReceived(frames[i].ID, QByteArray((char*)frames[i].Data, frames[i].DataLen), frames[i].ExternFlag); } } }这个写法有两点要注意第一VCI_Receive的返回含义是“实际读取到的帧数量”不是错误码所以判断条件是count0而不是ret1第二接收缓冲区数组大小决定了一次调用能取多少帧CAN波特率500kbps满载时每秒约4000帧256的大小可以承受。2.4 总线终端电阻和硬件排查经验软件写对了但通信不稳定十有八九是硬件问题。CAN总线两端必须接120欧姆终端电阻这是初中课本就有的知识但实际项目里真有人会漏。上了终端电阻、波特率也一致但通信还是间歇性失败这时候可以量一下总线电压——CAN_H和CAN_L之间的静默电压应约为2.5V显性状态时CAN_H拉到约3.5V、CAN_L拉到约1.5V。如果电压不对优先检查是不是总线短路、引脚接反、或者某个节点供电异常。波形判断这里多说一句。用示波器看CAN收发器比如TJA1050输出端的RX/TX脚正常波形应该是CAN_H和CAN_L对称的差分方波边沿陡峭。如果看到边沿明显圆滑、幅值不足说明总线负载过重节点太多、分支线太长或终端电阻有问题。这个话题很多论坛都在问“如何通过can总线波形判断通信的好坏”其实核心就三点幅值、边沿陡峭度、以及波形是否出现毛刺/回勾。幅值不够会直接导致接收端识别失败。3. 串口通信模块QSerialPort的正确打开方式3.1 串口配置与数据接收的坑CAN讲完了来说串口。QT的串口通信比CAN简单因为QSerialPort已经封装好了你只需要三步设置串口名、设置参数、open。常见的坑集中在参数设置和接收方式上。QSerialPort *serial new QSerialPort(this); serial-setPortName(COM3); // Windows下注意COM10以上的命名区别 serial-setBaudRate(115200); // 波特率 serial-setDataBits(QSerialPort::Data8); // 8位数据位 serial-setParity(QSerialPort::NoParity); // 无校验 serial-setStopBits(QSerialPort::OneStop); // 1位停止位 serial-setFlowControl(QSerialPort::NoFlowControl); // 无流控 if (!serial-open(QIODevice::ReadWrite)) { qWarning() 串口打开失败: serial-errorString(); return; } connect(serial, QSerialPort::readyRead, this, SerialManager::onDataReady);看着简单实际坑不少。流控位是新手最常忽略的——有些USB转串口模块默认开了RTS/CTS流控但线和对方设备没接对应引脚结果就是能发不能收或者收一帧丢一帧。工业上大多数场景都用NoFlowControl除非对方明确要求硬件流控。3.2 跨平台串口枚举和Linux权限问题Windows下枚举串口可以用QSerialPortInfo::availablePorts()这个不用多说。但跨平台时要注意Linux下串口设备名是/dev/ttyUSB0、/dev/ttyS0这样的USB转串口通常是ttyUSB开头板载串口是ttyS开头。在Linux下打开串口还有一个经典权限问题操作/dev/ttyUSB0需要dialout组权限或者root权限。如果程序在普通用户下启动并报“Permission denied”先执行sudo usermod -a -G dialout $USER然后重新登录生效。这条命令我每次搭新环境都会用到写在这里省得大家再搜。另一个与串口相关的常见现象是“9600波特率能通信4800波特率收不到数据”。我遇到过好几次实际原因不是波特率本身而是对端设备在4800下根本没发数据或者线虚接在低速时更容易暴露。排查方法用示波器或逻辑分析仪抓TX脚波形数一下实际波特率是否对得上或者干脆换一个USB转串口模块试试。少数情况是晶振误差太大比如劣质USB转串口芯片用非标晶振导致波特率偏差超过3%高速率下直接乱码低速率下勉强能通。ZLG的USBCAN设备一般没这个问题但便宜的CH340模块遇到过。3.3 组包拆包串口数据必定分帧串口通信最重要的一条经验永远不要假设一次readyRead就是一整帧。串口数据在底层是按字节流到达的应用层帧的边界需要自己维护。一个典型帧格式可能是这样的帧头(0xAA 0x55) 数据长度(1字节) 命令字(1字节) 数据(N字节) 校验和(1字节)我的做法是用QByteArray做接收缓存每来一段数据就追加进去然后循环查找帧头、判断长度、校验、截取完整帧void SerialManager::onDataReady() { m_buffer.append(serial-readAll()); while (true) { if (m_buffer.size() 2) return; if ((uchar)m_buffer[0] ! 0xAA || (uchar)m_buffer[1] ! 0x55) { m_buffer.remove(0, 1); // 逐字节丢弃直到找到帧头 continue; } int len (uchar)m_buffer[2]; // 数据长度 int totalLen 2 1 1 len 1; // 帧头长度命令数据校验 if (m_buffer.size() totalLen) return; // 数据还不够等下一波 QByteArray frame m_buffer.left(totalLen); if (checkSum(frame)) { emit frameParsed(frame); } m_buffer.remove(0, totalLen); // 处理完一帧继续找下一帧 } }这个逐字节丢弃找帧头的方法虽然暴力但对不丢数据的要求来说最稳妥。重点在于调用readAll()把底层缓冲区全部读出来别用read(1)或read(n)——那样容易残留数据在系统缓冲区里下个readyRead信号到来时顺序就乱了。4. 波形显示与时域到频域转换QCustomPlot KissFFT实战4.1 QCustomPlot实时时域波形刷新策略做调试工具不做波形显示等于耍流氓——数据放列表里看毫无感觉绘图才能肉眼判断有无周期、噪声、干扰。QCustomPlot是这里的最佳选择它轻量且绘图性能足够。但直接往graph里append数据然后调用replot()在数据量大时界面会卡成PPT。需要控制刷新频率。我用的模式是通信线程把解析后的数据存到QVector作为环形缓冲界面用一个QTimer定时器20-30ms刷新一次对应约30-50FPS每次刷新把最近N个点塞到graph里并重绘。这样既保证实时性又不会把CPU烧在绘图上。// 绘图定时器槽函数 void PlotWidget::refreshPlot() { QVectordouble xData, yData; int n m_buffer.size(); int pointsPerUpdate 500; // 每次最多画500点 for (int i qMax(0, n - pointsPerUpdate); i n; i) { xData.append(m_x[i]); yData.append(m_y[i]); } m_graph-setData(xData, yData); ui-plot-xAxis-setRange(m_x[qMax(0, n - pointsPerUpdate)], m_x[qMax(0, n-1)] 0.1); ui-plot-replot(QCustomPlot::rpQueuedRepaint); // 异步重绘 }4.2 KissFFT做时域到频域转换的完整调用时域波形要变频域最常见的做法是FFT。QCustomPlot本身不做FFT官方示例里推荐的配合方案是KissFFT——一个轻量级的C语言FFT库无依赖几行代码就能接入。这也是热词里“qt qcustomplot kissfft时域到频域波形”这个组合的由来。引入KissFFT后核心调用很简洁#include kiss_fft.h QVectordouble performFft(const QVectordouble timeData) { int n timeData.size(); // 找到不小于n的最小的2的幂 int fftSize 1; while (fftSize n) fftSize 1; kiss_fft_cfg cfg kiss_fft_alloc(fftSize, 0, nullptr, nullptr); QVectorkiss_fft_cpx in(fftSize), out(fftSize); for (int i 0; i fftSize; i) { in[i].r i n ? timeData[i] : 0; // 不足部分补零 in[i].i 0; } kiss_fft(cfg, in.data(), out.data()); free(cfg); QVectordouble magnitude(fftSize / 2); for (int i 0; i fftSize / 2; i) { magnitude[i] sqrt(out[i].r * out[i].r out[i].i * out[i].i) / (fftSize / 2); } return magnitude; }用FFT有几个参数要心里有数。采样点数N决定了频率分辨率分辨率 采样率 / N。比如采样率是1kHz做1024点FFT那么每个频谱条代表的宽度是约0.98Hz。如果你的目标是要分辨0.1Hz级别的低频信号至少要采4096、8192点才有意义。采样率本身受限于奈奎斯特采样定理只能分析到采样率一半的频率再高就是混叠出来的频域图像狗啃一样全是假峰。还有一个容易被忽视的地方窗函数。如果直接对截断信号做FFT频谱会因矩形窗导致严重的频谱泄漏本来很干净的单频信号旁边也会拖出一堆旁瓣。我一般用汉宁窗或汉明窗只需在FFT之前把时域数据逐点乘以窗函数系数for (int i 0; i n; i) { double win 0.5 * (1 - cos(2 * M_PI * i / (n - 1))); // Hanning窗 timeData[i] * win; }4.3 频域坐标映射与界面联调FFT出来的结果在频域的横轴是“频率索引”要显示成实际频率值需要知道采样率fs。频率点i对应的实际频率是i * fs / N。示波器一类的工具里X轴单位都是HzY轴单位常用dBV或者线性幅值。我习惯显示幅度谱线性值如果信号动态范围很大再切换成对数坐标。还有一个GUI设计上的细节——时域图和频域图不要放在同一个plot里两个独立控件分开画不然纵轴量纲和物理含义完全不同显示在一起会很别扭。从时域到频域的切换交互上我做成一个按钮或者下拉框实时波形窗口默认显示时域用户点“频谱分析”后暂停实时刷新取当前缓冲区的数据做FFT然后显示频域图。这样CPU开销也可以控制不用每帧都算一遍FFT。5. 常见问题与排查技巧实录5.1 “no Qt platform plugin could be initialized”与打包问题很多人在自己电脑上跑得好好的QT程序拷到别的机器就打不开报“no Qt platform plugin could be initialized. reinstalling the application may fix this problem.”。这个错误几乎100%是部署问题——你缺少了Qt的platform插件qwindows.dll或者插件目录不对。正确做法是用官方自带的windeployqt工具在编译好的exe目录下执行windeployqt your_app.exe它会把必要的DLL、插件、QML目录等自动复制到exe旁边。部署时注意整个exe文件夹要一起拷贝不能只拿exe文件因为plugins目录platforms、styles、imageformats等对Qt程序是必需的。如果你用的是动态编译的MinGW版Qt别忘了带上libgcc_s_seh-1.dll、libstdc-6.dll、libwinpthread-1.dll这些运行库缺少任何一个都会导致程序启动即崩溃。要在没有装Qt的Windows机器上运行最省心的方案是加一个static版本的Qt库编译或者用windeployqt之后再手工裁剪没用的插件。有个经验platforms目录下只需要qwindows.dllimageformats可以只留qjpeg和qgif能省不少体积。5.2 CAN设备和串口打不开的排查顺序CAN设备打开失败按这个顺序排查驱动装没装ZLG的设备管理器里能看到设备不算驱动要装好- 设备索引是否正确多个设备插入时索引会变我遇到过设备插拔顺序改变导致代码里写死的索引0失效的情况- 是否被其他软件占用CANTest或者另一个实例已经OpenDevice你的程序就抢不过来了。串口打不开也是在同样的思路上排查QSerialPortInfo::availablePorts()里有没有列出这个端口——没列出来是驱动/硬件问题列出来了但open失败优先怀疑被占用。Windows下有个坑用虚拟串口软件VSPD等创建的虚拟串口对QT程序用起来正常但有些USB转串口的驱动实现有兼容问题表现为打开成功但数据收发不完整。这种时候先换一个串口调试助手确认硬件链路没问题再回头看代码。5.3 CAN接收不到数据滤波、波特率、回环模式、错误帧CAN收不到数据这个问题排在热词搜索前列说明确实困扰了不少人。我的排查路径写在这里先自测ZLG的CANTest里有个自环模式打开后设备自发自收如果自环都不通那就是设备硬件或驱动问题。确认波特率双方波特率必须精确一致。500kbps配成499kbps长期运行也会攒下大量错误帧最终导致通信瘫痪。检查滤波初始化AccMask是不是0xFFFFFFFF如果不是看ID是否真的匹配。看设备状态灯正常通信时灯会规律闪动。错误帧频繁出现时灯闪得又快又乱。确认总线有负载接上示波器看总线上实际有没有波形防止是对方节点压根没发。终极手段用CANTest简单收发一下如果CANTest也不通硬件链路问题的可能性大于软件问题。5.4 绘制卡顿和数据丢失的处理思路最后还有一个我经常见到的现象程序跑起来后界面卡顿严重但把收发数据量降下来就正常了。这通常是两个原因一是UI线程里做了大量耗时操作比如把每个数据包都直接往tableWidget里插行——十万条数据能把Qt表格控件卡到怀疑人生二是没有控制刷新频率每次来数据就replot。解决思路表格显示只保留最近500条或1000条超过就滚动删除绘图通过定时器聚合更新。本质上就是“数据归数据、显示归显示”千万别让原始数据流速直接等于界面刷新频率。数据丢失又是另一回事。如果VCI_Receive明明返回了count但界面表格里却少帧那问题出在接收端处理太慢缓冲区溢出丢帧。ZLG设备自带硬件FIFO一般能存几千帧但如果应用层不及时读取照样会丢。所以通信线程里收到数据后只做压入缓冲区发信号不要在里面做任何涉及字符串、表格、文件写入的重活。要记录文件时单独开一个日志线程通信线程把数据包塞给它就完事。写在最后的几点体会做这种工具类项目最大的感悟是先把“能跑通”做出来再谈优雅。早期我总想一次性把架构设计得完美无瑕结果写了两天还在抽象类层次里绕。后来改了习惯先用最直接的方式打通CAN通道、串口通道把数据在界面上显示出来确立了完整的端到端链路之后再回头重构通信层和显示层的分离。这样每一步都有可验证的成果心态也稳。另一个习惯是把所有配置项做成可修改的波特率、帧ID、串口号、显示颜色都做成界面可调或者配置文件可读。调试工具这东西你不知道明天要面对什么总线速率、什么协议格式的硬件要是每次换个设备都要重新编译一次效率实在太低。最后再分享一个调试技巧把CAN和串口的收发日志都加上时间戳并支持导出很多“偶尔丢一帧”的问题靠的就是离线日志里那一帧数据的时间间隔分析比现场盯屏可靠得多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门