简介:这是一套基于VC++ 6.0与MFC框架开发的CAN总线通信测试源代码,面向汽车电子、工业自动化及嵌入式领域的开发者,尤其适合需要与周立功CAN接口卡(如USBCAN、PCI系列)联调的技术人员。程序封装了CAN接口初始化、报文收发、数据解析与结果显示等核心功能,可帮助读者理解CAN ID仲裁、数据长度编码、CRC错误检测与位填充等协议要点,并在MFC对话框结构中完成消息循环与事件处理。压缩包共47个文件,约3.62MB,包含cpp与h源码、dll动态库、lib导入库、obj与sbr编译中间文件、rc资源脚本及txt说明文档,另附周立功各型号驱动库,便于直接编译运行与二次开发。目前已有850人学习下载,适合作为CAN通信入门与工程调试的参考范例。
1. 从一次产线调试翻车说起:这套 VC MFC CAN 通信程序到底能干什么
去年帮一个做非标自动化设备的朋友排查问题,他们的工控机跑着一套 VC6.0 编译的 MFC 上位机,通过周立功 USBCAN-II 盒子跟三台伺服驱动器通信。现象很邪门:单步调试一切正常,全速跑起来偶尔丢帧,重启软件又能撑几个小时。最后定位到是接收线程里直接操作了 MFC 控件,跨线程访问导致消息队列阻塞,CAN 缓冲区溢出。这件事让我意识到,很多做 VC CAN 上位机的同行,卡的不是 CAN 协议本身,而是 MFC 的消息机制和 CAN 收发线程怎么配合。
这套 VC MFC CAN 通信程序支持周立功,说白了就是一份能直接编译运行的 VC6.0 或 VS 工程,封装了周立功 CAN 盒子的动态库调用,用 MFC 做界面,实现 CAN 帧的收发、解析和显示。它解决的是从零搭框架的问题——你不用再去啃 ZLGCAN.h 里那几十个 API,也不用纠结 VCI_InitCAN 的参数怎么填。适合两类人:一是刚接手工控上位机维护的开发者,需要快速看懂现有代码;二是要做设备通信模块但不想从底层写起的工程师。下面我按实际拆包和复现的顺序,把这份资源讲透。
2. 周立功 CAN 二次开发接口拆解:从 VCI_OpenDevice 到帧结构体
2.1 动态库调用链与设备初始化参数
周立功的 CAN 盒子,不管是 USBCAN-I、USBCAN-II 还是 CANET 系列,上位机开发都绕不开它的动态库。常见做法是拿到ControlCAN.dll、ControlCAN.lib和ControlCAN.h三个文件,把 lib 加到工程链接器里,h 文件 include 进代码。这套资源里已经把这些都配好了,你打开工程属性就能看到附加依赖项里躺着ControlCAN.lib。
初始化的调用顺序是固定的,顺序错了后面全白搭:
// 1. 打开设备,参数是设备类型和索引号 // VCI_USBCAN2 是设备类型,0 是设备索引(第一块盒子) DWORD dwRet = VCI_OpenDevice(VCI_USBCAN2, 0, 0); if (dwRet != STATUS_OK) { AfxMessageBox(_T("打开设备失败,检查驱动是否装好")); return FALSE; } // 2. 初始化 CAN 通道,先填结构体再调用 VCI_INIT_CONFIG initConfig; initConfig.AccCode = 0x00000000; // 验收码,全 0 表示接收所有帧 initConfig.AccMask = 0xFFFFFFFF; // 屏蔽码,全 F 表示不屏蔽任何位 initConfig.Filter = 0; // 滤波方式,0 是单滤波 initConfig.Timing0 = 0x00; // 波特率参数,配合 Timing1 使用 initConfig.Timing1 = 0x1C; // 0x00 0x1C 对应 500Kbps initConfig.Mode = 0; // 正常模式,1 是只听模式 dwRet = VCI_InitCAN(VCI_USBCAN2, 0, 0, &initConfig); if (dwRet != STATUS_OK) { AfxMessageBox(_T("初始化 CAN 通道失败")); VCI_CloseDevice(VCI_USBCAN2, 0); return FALSE; } // 3. 启动通道,这一步之后才能收发 dwRet = VCI_StartCAN(VCI_USBCAN2, 0, 0);这里有几个参数是新手最容易填错的。Timing0和Timing1不是随便写的,它们对应的是 CAN 控制器的位定时寄存器。500Kbps 常用0x00 0x1C,250Kbps 用0x01 0x1C,1Mbps 用0x00 0x14。如果你不确定,周立功的文档里有张表,但很多人懒得翻,结果通信不上就怀疑硬件坏了。AccCode和AccMask配合决定接收哪些 ID 的帧,全 0 全 F 就是全收,调试阶段建议这么设,等稳定了再收紧过滤规则。
2.2 报文结构体与收发缓冲区管理
周立功的帧结构体叫VCI_CAN_OBJ,定义在头文件里,核心字段就几个:
typedef struct _VCI_CAN_OBJ { UINT ID; // 报文 ID,标准帧 11 位,扩展帧 29 位 UINT TimeStamp; // 时间戳,设备内部计时,单位 0.1ms BYTE TimeFlag; // 是否使用时间戳 BYTE SendType; // 发送类型,0 正常发送,1 单次发送,2 自发自收 BYTE RemoteFlag; // 远程帧标志,0 数据帧,1 远程帧 BYTE ExternFlag; // 扩展帧标志,0 标准帧,1 扩展帧 BYTE DataLen; // 数据长度,0~8 BYTE Data[8]; // 数据区 BYTE Reserved[3]; // 保留 } VCI_CAN_OBJ;发送的时候,你填好 ID、DataLen、Data,然后调VCI_Transmit。接收用VCI_Receive,它从设备的接收缓冲区里往外读。这里有个血泪经验:VCI_Receive的第四个参数是你要读的帧数,第五个参数是缓冲区大小,但实际返回的是读到的帧数。如果你一次读 100 帧,但缓冲区里只有 3 帧,它返回 3,不会阻塞等你。所以接收线程里通常要循环调用,配合 Sleep 或者 WaitForSingleObject 控制节奏。
缓冲区溢出是丢帧的头号原因。USBCAN-II 的硬件接收缓冲区大概能存 2000 帧左右,如果你上位机处理慢了,新来的帧就会覆盖旧的。我一般会在接收线程里加一个计数器,每次读到帧就累加,如果发现某次读到的帧数突然变少,或者时间戳不连续,就要警惕了。这套资源里在接收线程用了PeekMessage做消息泵,避免 MFC 界面卡死,这个设计值得借鉴。
3. MFC 对话框工程落地:线程、定时器和控件绑定的实操
3.1 接收线程与界面刷新解耦
MFC 的界面控件不是线程安全的,你在工作线程里直接SetWindowText或者InsertItem,轻则界面卡顿,重则直接崩。常见做法是工作线程只负责收数据,收到后通过PostMessage把数据指针传给主线程,主线程的消息处理函数里再更新控件。
// 自定义消息,定义在头文件里 #define WM_CAN_RECV_MSG (WM_USER + 100) // 接收线程函数 UINT CCanDemoDlg::RecvThreadProc(LPVOID pParam) { CCanDemoDlg* pDlg = (CCanDemoDlg*)pParam; VCI_CAN_OBJ frame[50]; int nCount = 0; while (pDlg->m_bThreadRunning) { // 一次最多读 50 帧,返回实际读到的数量 nCount = VCI_Receive(VCI_USBCAN2, 0, 0, frame, 50, 100); if (nCount > 0) { // 把帧数据拷贝到堆上,通过消息传给主线程 VCI_CAN_OBJ* pFrame = new VCI_CAN_OBJ[nCount]; memcpy(pFrame, frame, sizeof(VCI_CAN_OBJ) * nCount); pDlg->PostMessage(WM_CAN_RECV_MSG, (WPARAM)nCount, (LPARAM)pFrame); } Sleep(10); // 别设太小,给系统留点调度时间 } return 0; } // 消息处理函数,在主线程执行 LRESULT CCanDemoDlg::OnCanRecvMsg(WPARAM wParam, LPARAM lParam) { int nCount = (int)wParam; VCI_CAN_OBJ* pFrame = (VCI_CAN_OBJ*)lParam; for (int i = 0; i < nCount; i++) { CString strId, strData; strId.Format(_T("0x%03X"), pFrame[i].ID); // 把 8 字节数据拼成字符串 for (int j = 0; j < pFrame[i].DataLen; j++) { CString strByte; strByte.Format(_T("%02X "), pFrame[i].Data[j]); strData += strByte; } int nIndex = m_listRecv.InsertItem(m_listRecv.GetItemCount(), strId); m_listRecv.SetItemText(nIndex, 1, strData); // 限制列表长度,防止内存无限增长 if (m_listRecv.GetItemCount() > 1000) { m_listRecv.DeleteItem(0); } } delete[] pFrame; // 别忘了释放 return 0; }这段代码的关键点在于new和delete的配对。PostMessage是异步的,消息发出去之后线程继续跑,所以数据必须放在堆上。主线程处理完再释放。如果你用SendMessage,那是同步的,数据可以放栈上,但界面会卡。我一般用PostMessage,配合列表长度限制,跑一整天也不会内存暴涨。
3.2 发送逻辑与定时发送的实现
发送比接收简单,但有个坑:VCI_Transmit的返回值是实际发送的帧数,不是布尔值。你发一帧,它返回 1 表示成功,返回 0 表示失败。失败的原因可能是设备没打开、通道没启动、或者发送缓冲区满了。
// 手动发送按钮的响应函数 void CCanDemoDlg::OnBnClickedBtnSend() { VCI_CAN_OBJ frame; memset(&frame, 0, sizeof(frame)); // 从界面控件读取 ID 和数据 CString strId; m_editSendId.GetWindowText(strId); frame.ID = _tcstoul(strId, NULL, 16); // 按十六进制解析 frame.ExternFlag = 0; // 标准帧 frame.RemoteFlag = 0; // 数据帧 frame.SendType = 0; // 正常发送 frame.DataLen = 8; // 解析数据区,假设用户输入 "01 02 03 04 05 06 07 08" CString strData; m_editSendData.GetWindowText(strData); // 这里省略解析代码,按空格拆分再转字节 DWORD dwSent = VCI_Transmit(VCI_USBCAN2, 0, 0, &frame, 1); if (dwSent != 1) { AfxMessageBox(_T("发送失败,检查设备状态")); } }定时发送用SetTimer就行,但周期别设太小。Windows 的定时器精度大概在 15ms 左右,你设 1ms 实际可能 15ms 才触发一次。如果要精确周期发送,得用多媒体定时器或者自己算时间差。这套资源里用的是SetTimer,周期 100ms,对于大多数工控场景够用了。如果你要发心跳帧,周期 500ms 或 1s,那更没问题。
4. 避坑与排查:CAN 通信调试中那些让人抓狂的瞬间
4.1 现象:VCI_OpenDevice 返回失败,设备管理器里能看到盒子
原因:驱动装了但版本不对,或者设备被其他进程占用了。周立功的 USBCAN 盒子同一时间只能被一个进程打开,如果你之前调试的程序没退干净,后台还挂着,新的就打不开。
解决:先在任务管理器里把之前残留的进程杀掉,然后确认驱动版本和盒子型号匹配。USBCAN-II 和 USBCAN-II+ 的驱动不一样,装错了设备管理器里显示正常,但 API 调用会失败。我一般会准备一个最小化的测试程序,只调VCI_OpenDevice和VCI_CloseDevice,用来快速判断是驱动问题还是代码问题。
4.2 现象:能发送但收不到任何帧,示波器量 CAN_H 和 CAN_L 有波形
原因:验收码和屏蔽码设错了,或者波特率不匹配。很多人以为波特率设了 500K 就是 500K,但Timing0和Timing1的组合如果不对,实际波特率可能是 250K 或者 1M。另外,如果总线上有其他节点,它们的波特率必须一致,否则谁都收不到谁。
解决:先用周立功自带的 CANTest 软件试一下,那个软件里波特率是下拉选的,能通说明硬件和接线没问题。然后对照文档检查Timing0和Timing1的值。500K 用0x00 0x1C,250K 用0x01 0x1C,125K 用0x03 0x1C。验收码和屏蔽码调试阶段全 0 全 F,确保能收到所有帧。
4.3 现象:程序跑一段时间后界面卡死,CAN 数据不再更新
原因:接收线程里直接操作了界面控件,或者PostMessage的消息队列积压太多。MFC 的消息队列有上限,默认大概 10000 条,如果你每收到一帧就PostMessage一次,高速通信时很快就会塞满。
解决:接收线程里攒一批帧再发消息,比如每 10ms 或者每 50 帧发一次。另外,界面刷新频率要控制,列表控件插入 1000 条和插入 10 条的性能差很多。我一般会在消息处理函数里判断,如果列表已经超过 500 条,就批量删除前面的,而不是一条一条删。
4.4 现象:发送成功但对方设备没反应,用 CANTest 发同样的数据对方就动
原因:SendType设成了 1(单次发送),这种模式下如果总线忙或者仲裁失败,帧不会重发,直接丢弃。而 CANTest 默认用的是正常发送,失败会自动重发。
解决:把SendType改成 0(正常发送)。另外检查RemoteFlag和ExternFlag是否和对方设备匹配。有些设备只认扩展帧,你发标准帧它不理你。还有DataLen,如果对方期望 8 字节,你只发了 4 字节,有些设备会直接忽略。
4.5 现象:编译报错 LNK2019,找不到 VCI_xxx 符号
原因:ControlCAN.lib没加到链接器里,或者平台不对。VC6.0 和 VS2010 以上的工程配置方式不一样,VC6 在 Project Settings 里加,VS 在属性页的链接器输入里加。另外,周立功的库分 32 位和 64 位,你的工程是 Win32 就得用 32 位的 lib。
解决:确认工程是 Win32 还是 x64,然后选对应的 lib。如果还不行,用dumpbin /exports ControlCAN.dll看看导出符号名,有时候是调用约定不匹配,需要在函数声明前加__stdcall。
5. 进阶技巧:用队列和状态机把 CAN 通信做成可复用的模块
5.1 把收发逻辑封装成独立类
这套资源里的代码是直接写在对话框类里的,调试方便,但复用性差。如果你下一个项目还要用,我建议抽成一个CCanComm类,把设备打开、通道初始化、收发线程、消息回调都封进去。对话框只负责调Open、Send、SetRecvCallback这几个接口。
class CCanComm { public: BOOL Open(int nDeviceType, int nDeviceIndex, int nChannel, int nBaudrate); void Close(); BOOL SendFrame(UINT nId, BYTE* pData, int nLen, BOOL bExt = FALSE); void SetRecvCallback(void (*pfn)(VCI_CAN_OBJ*, int, void*), void* pUser); private: static UINT RecvThread(LPVOID pParam); // ... 其他成员 };这样封装之后,换一个项目只需要改设备类型和波特率参数,业务逻辑不用动。回调函数里处理协议解析,跟界面完全解耦。
5.2 用环形队列缓冲突发数据
高速通信时,接收线程读到的帧可能瞬间很多,如果每次都new一块内存传给主线程,内存分配和释放的开销不小。常见做法是用一个环形队列,接收线程往里写,主线程从里读,队列满了就覆盖最旧的数据。
// 简化的环形队列,固定大小,避免动态分配 #define QUEUE_SIZE 4096 VCI_CAN_OBJ g_queue[QUEUE_SIZE]; volatile int g_nHead = 0; // 写指针 volatile int g_nTail = 0; // 读指针 // 接收线程写入 void PushFrame(VCI_CAN_OBJ* pFrame) { int nNext = (g_nHead + 1) % QUEUE_SIZE; if (nNext != g_nTail) { // 队列没满 g_queue[g_nHead] = *pFrame; g_nHead = nNext; } // 满了就丢弃,或者覆盖 g_nTail 指向的最旧数据 } // 主线程读取 BOOL PopFrame(VCI_CAN_OBJ* pFrame) { if (g_nTail == g_nHead) return FALSE; // 空 *pFrame = g_queue[g_nTail]; g_nTail = (g_nTail + 1) % QUEUE_SIZE; return TRUE; }这个队列的读写指针用volatile修饰,防止编译器优化导致死循环。实际项目中,我还会加一个临界区保护,虽然单读单写的情况下不加也能跑,但加了更稳。
5.3 协议解析的状态机写法
CAN 帧本身只传 8 字节,复杂协议都是多帧拼起来的。比如某设备用第一帧传长度,后续帧传数据,最后一帧传校验。这种用状态机解析最清晰:
| 状态 | 触发条件 | 动作 | 下一状态 |
|---|---|---|---|
| 空闲 | 收到帧 ID 匹配且首字节为 0xAA | 记录总长度,清空缓冲区 | 接收中 |
| 接收中 | 收到后续帧 | 追加数据,检查是否收满 | 接收中/校验 |
| 校验 | 数据收满 | 计算校验和,比对 | 空闲/错误 |
| 错误 | 校验失败或超时 | 丢弃缓冲区,记录日志 | 空闲 |
状态机的好处是逻辑清晰,加新协议只需要改状态转移表。我一般会把状态机写成一个独立的函数,输入是帧,输出是解析结果,跟通信层完全分开。
5.4 一个验证通信稳定性的小技巧
程序写完之后,别急着上产线。先做压力测试:用两个 CAN 盒子,一个发一个收,发的那边用定时器每 1ms 发一帧,连续跑 24 小时。收的那边统计丢帧率和时间戳连续性。如果 24 小时丢帧率低于 0.01%,基本可以放心了。这个测试能暴露很多问题,比如缓冲区溢出、线程调度延迟、内存泄漏。我现在的习惯是,任何 CAN 通信模块交付前,都强制跑一遍这个 24 小时测试,翻车过太多次,不敢省这一步。
希望帮到你。
本文还有配套的精品资源,点击获取