简介:这份面向周立功CAN设备二次开发的C#上位机例程包,适合使用PC104-CAN、ISA-9620、PCI-5110/5121/9810/9820/9840、USBCAN系列及DNP9810、PEC9920、PCIE9220等硬件的工程师与学生参考,重点解决多型号CAN卡在统一上位机中的调用与调试问题。压缩包共53个文件、约822KB,包含22个dll、6个exe、6个cs源码,以及resx/resources界面资源、ini/txt配置说明、sln/csproj工程与pdb调试文件,既有可运行示例,也保留VS2005工程结构,便于对照阅读。已有5596人学习下载。读者可从中了解设备初始化、报文收发、界面交互等C#实现思路,借助DLL与示例工程快速搭建测试程序,并为后续二次开发和排错提供参考。
1. 换一块同型号 CAN 卡就跑不起来,问题多半不在协议
接手一个产线检测工位改造时,我拿到的第一份东西不是需求文档,而是一台装着周立功CAN 通用上位机例程(C#)的旧工控机。屏幕上收发计数跳得挺欢,可换一块同型号的 CAN 卡插上去,程序就再也叫不醒设备了。这类毛病很少出在 CAN 协议本身,基本卡在三个地方:通道号对不上、波特率寄存器填错、接收线程和界面线程抢资源。这份例程真正值钱的地方不是界面,是它把 VCI 那套 DLL 调用、初始化结构体、收发循环和错误码判断完整摊在源码里,改一改就能当二次开发的骨架。它适合三类人:给自家设备做调试工具的嵌入式工程师、需要把报文转成业务数据的上位机开发者、接手别人遗留工控软件的维护者。下面按跑通、看懂、改得动的顺序拆。
2. 环境与设备枚举:从 ControlCAN.dll 到第一帧报文
2.1 为什么这个场景死磕 C# 而不是脚本
现场工控机清一色 Windows,运维人员只认「双击 exe 就能用」,这一点就排掉了大半方案。C# 的 WinForm 拖控件快,串口、CAN、数据库、MES 接口在同一套进程里不折腾;更关键的是 P/Invoke 调ControlCAN.dll的成本极低,把头文件里的结构体原样翻译成 C# struct 就行,不用写中间层。
代价是内存布局必须自己盯。C# 的 struct 默认会做对齐填充,传给非托管函数时如果少了[StructLayout(LayoutKind.Sequential)],DLL 读到的就是错位的内存,表现出来往往是「发送返回成功但总线上什么都没有」。这种 bug 不报异常、不打日志,纯黑匣子,我第一次踩的时候排查了整整一个下午。
另一条路是用 C++ 直接吃 SDK 头文件,稳但开发慢;用 Python 加 ctypes 也行,只是打包给现场时依赖一堆运行库,运维一升级环境就翻车。所以例程选 C# 是务实的,不是技术偏好。
2.2 驱动与 DLL 的落位,先解决位数问题
装完驱动,设备管理器里能看到对应节点,只说明系统认了硬件,不代表你的进程能调通它。真正决定成败的是位数匹配。
# 用 VS 自带的 dumpbin 看清 DLL 是 32 位还是 64 位 dumpbin /headers ControlCAN.dll | findstr "machine" # 输出 14C machine (x86) -> 只能被 32 位进程加载 # 输出 8664 machine (x64) -> 只能被 64 位进程加载工程里同步锁死平台目标,别用 AnyCPU:
<PropertyGroup> <PlatformTarget>x86</PlatformTarget> <Prefer32Bit>true</Prefer32Bit> </PropertyGroup> <ItemGroup> <!-- 把运行库跟着 exe 走,现场换机器不用再找 DLL --> <None Include="sdk\ControlCAN.dll"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>PlatformTarget设成 x86 之后,即使跑在 64 位 Windows 上,进程也是 32 位的,能正常加载 32 位的 DLL。这一条看着啰嗦,但现场 90% 的「BadImageFormatException」都是它引起的。DLL 跟着输出目录走则是为了部署,我曾经遇到过拷了 exe 没拷 DLL、结果程序在客户那边一启动就崩的情况。
2.3 把 VCI 接口翻译成 C#,结构体一个字节都不能错
先定义结构体,注意Data和Reserved是定长数组,必须加ByValArray:
using System; using System.Runtime.InteropServices; [StructLayout(LayoutKind.Sequential)] public struct VCI_BOARD_INFO { public ushort hw_Version; public ushort fw_Version; public ushort dr_Version; public ushort in_Version; public ushort irq_Num; public byte can_Num; // 这块卡有几个 CAN 通道 [MarshalAs(UnmanagedType.ByValArray, SizeConst = 20)] public byte[] str_Serial_Num; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)] public byte[] str_hw_Type; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public byte[] Reserved; } [StructLayout(LayoutKind.Sequential)] public struct VCI_INIT_CONFIG { public uint AccCode; // 验收码 public uint AccMask; // 屏蔽码 public uint Reserved; public byte Filter; // 0 单滤波,1 双滤波 public byte Timing0; // BTR0 public byte Timing1; // BTR1 public byte Mode; // 0 正常,1 只听 } [StructLayout(LayoutKind.Sequential)] public struct VCI_CAN_OBJ { public uint ID; public uint TimeStamp; public byte TimeFlag; public byte SendType; public byte RemoteFlag; public byte ExternFlag; public byte DataLen; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)] public byte[] Data; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 3)] public byte[] Reserved; } public static class Vci { [DllImport("ControlCAN.dll")] public static extern uint VCI_OpenDevice(uint devType, uint devIndex, uint reserved); [DllImport("ControlCAN.dll")] public static extern uint VCI_CloseDevice(uint devType, uint devIndex); [DllImport("ControlCAN.dll")] public static extern uint VCI_ReadBoardInfo(uint devType, uint devIndex, ref VCI_BOARD_INFO info); [DllImport("ControlCAN.dll")] public static extern uint VCI_InitCAN(uint devType, uint devIndex, uint canIndex, ref VCI_INIT_CONFIG cfg); [DllImport("ControlCAN.dll")] public static extern uint VCI_StartCAN(uint devType, uint devIndex, uint canIndex); [DllImport("ControlCAN.dll")] public static extern uint VCI_ResetCAN(uint devType, uint devIndex, uint canIndex); [DllImport("ControlCAN.dll")] public static extern uint VCI_ClearBuffer(uint devType, uint devIndex, uint canIndex); [DllImport("ControlCAN.dll")] public static extern uint VCI_GetReceiveNum(uint devType, uint devIndex, uint canIndex); [DllImport("ControlCAN.dll")] public static extern uint VCI_Transmit(uint devType, uint devIndex, uint canIndex, ref VCI_CAN_OBJ obj, uint count); [DllImport("ControlCAN.dll")] public static extern uint VCI_Receive(uint devType, uint devIndex, uint canIndex, [Out] VCI_CAN_OBJ[] objs, uint count, int waitMs); }几个参数必须记死。devType决定用哪套底层协议:USBCAN-I 是 3,USBCAN-II 是 4,E-U 系列是 20,2E-U 是 21。填错不会报错,只会返回 0。devIndex是从 0 开始的多卡索引,接两块卡时它是区分设备的唯一手段,别跟通道号搞混——canIndex才是一块卡内部的通道编号,双通道卡是 0 和 1。
VCI_CAN_OBJ的大小是 24 字节,VCI_INIT_CONFIG是 16 字节,这两个数字可以用Marshal.SizeOf打出来核对一下,跟 SDK 头文件里的sizeof对不上,就说明布局错了。
2.4 打开设备并跑通第一帧
const uint DEV_USBCAN2 = 4; const uint DEV_INDEX = 0; public static void OpenAndProbe() { if (Vci.VCI_OpenDevice(DEV_USBCAN2, DEV_INDEX, 0) != 1) { Console.WriteLine("打开设备失败:先查位数、驱动占用、设备类型"); return; } try { var info = new VCI_BOARD_INFO { str_Serial_Num = new byte[20], str_hw_Type = new byte[40], Reserved = new byte[4] }; if (Vci.VCI_ReadBoardInfo(DEV_USBCAN2, DEV_INDEX, ref info) == 1) Console.WriteLine($"硬件类型 {(info.can_Num)} 通道,固件版本 {info.fw_Version:X4}"); for (uint ch = 0; ch < info.can_Num; ch++) { var cfg = new VCI_INIT_CONFIG { AccCode = 0x00000000, AccMask = 0xFFFFFFFF, // 全通过,先打通再说 Filter = 0, Timing0 = 0x00, // 500 kbps Timing1 = 0x1C, Mode = 0 }; if (Vci.VCI_InitCAN(DEV_USBCAN2, DEV_INDEX, ch, ref cfg) != 1) continue; Vci.VCI_ClearBuffer(DEV_USBCAN2, DEV_INDEX, ch); // 清掉上电前的残留 if (Vci.VCI_StartCAN(DEV_USBCAN2, DEV_INDEX, ch) != 1) continue; Console.WriteLine($"通道 {ch} 已启动"); } } finally { Vci.VCI_CloseDevice(DEV_USBCAN2, DEV_INDEX); // 必须放 finally } }VCI_ReadBoardInfo的用处经常被忽略:can_Num能告诉你这块卡到底几个通道,写死循环 0 到 1 的双通道代码插到单通道卡上,第二个通道的VCI_InitCAN会直接失败,但很多人看都不看返回值,后面接收永远为 0,还以为是接线问题。VCI_ClearBuffer放在StartCAN之前,是清掉控制器里上一轮遗留的报文,工控机上程序重启频繁,不清就容易读到几分钟前的旧数据。
3. 收发闭环:波特率寄存器、发送锁与接收线程
3.1 波特率填的不是数字,是两个寄存器字节
例程里最常见的一段是下拉框选波特率、然后给Timing0/Timing1赋值。这两个字节直接写进 CAN 控制器的 BTR0/BTR1,含义是分频系数和位时间段划分,不是「波特率编号」。
| 波特率 | Timing0 (BTR0) | Timing1 (BTR1) | 典型用途 |
|---|---|---|---|
| 1000 kbps | 0x00 | 0x14 | 台架短距离、刷写 |
| 500 kbps | 0x00 | 0x1C | 产线、整车主流 |
| 250 kbps | 0x01 | 0x1C | J1939 常见速率 |
| 125 kbps | 0x03 | 0x1C | 工业设备 |
| 100 kbps | 0x04 | 0x1C | 老设备、长线 |
| 50 kbps | 0x09 | 0x1C | 长距离低速 |
| 20 kbps | 0x18 | 0x1C | 特殊传感器 |
| 10 kbps | 0x31 | 0x1C | 极长线 |
按这套取值反推一下:BTR0 的低 6 位是 BRP,BTR1 的低 4 位是 TSEG1、高 3 位是 TSEG2,采样点等于 (1 + TSEG1) / (1 + TSEG1 + TSEG2)。500 kbps 那组算出来是 16 个时间份额、采样点 87.5%,正好落在常规推荐区间;1 Mbps 那组是 8 个份额、采样点 75%,短线上没问题,线一长就容易出错误帧。
所以别自己瞎改这两个数。要非标准波特率(比如某些设备用 83.3 kbps),得按控制器时钟重新算 BRP 和 TSEG,算完一定要用分析仪测一遍实际采样点,光看「能通信」是不够的——采样点偏差 5%,实验室没事,装到产线上跑两小时就开始偶发丢帧,这种玄学问题最难查。
3.2 发送:一帧报文从按钮到总线
private static readonly object _txLock = new object(); public static bool SendFrame(uint devType, uint devIndex, uint ch, uint id, byte[] data, bool extended = false, bool remote = false, byte sendType = 0) { if (data != null && data.Length > 8) throw new ArgumentException("经典 CAN 单帧数据段最多 8 字节"); var obj = new VCI_CAN_OBJ { ID = id, SendType = sendType, RemoteFlag = (byte)(remote ? 1 : 0), ExternFlag = (byte)(extended ? 1 : 0), DataLen = (byte)(data?.Length ?? 0), Data = new byte[8], Reserved = new byte[3] }; if (data != null) Array.Copy(data, obj.Data, data.Length); lock (_txLock) // DLL 内部不是线程安全的 { return Vci.VCI_Transmit(devType, devIndex, ch, ref obj, 1) == 1; } }SendType三个值要分清:0 是正常发送,失败会自动重发;1 是单次发送,只发一次不管结果;2 是自发自收,也就是回环模式。回环模式是最省事的自测手段——不接任何从站,发出去自己收回来,能验证整条链路(初始化、波特率、发送、接收、解析)是不是通的。我调试新板子时第一件事就是切到回环跑一遍,跑通了再去碰真实总线。
ExternFlag设 1 时ID是 29 位扩展帧,别把 0x18FF0000 这种扩展 ID 当标准帧发出去,控制器会直接截断成 11 位,对面收到的 ID 完全不对,而且不报错。
3.3 接收:VCI_Receive必须离开 UI 线程
新手最容易写出来的代码是「开个 100ms 的 WinForm Timer,里面调VCI_Receive拿一帧刷界面」。这在低速总线上能跑,一旦总线负载上来,界面就开始一顿一顿,再久一点直接假死。原因很简单:VCI_Receive是阻塞调用,界面线程被它占住,重绘和消息泵全停了。
正确做法是独立线程 + 批量读 + 有界队列:
private static volatile bool _running; private static readonly BlockingCollection<VCI_CAN_OBJ> _rxQueue = new BlockingCollection<VCI_CAN_OBJ>(20000); private static long _dropped; private static void ReceiveLoop(uint devType, uint devIndex, uint ch) { var buffer = new VCI_CAN_OBJ[500]; for (int i = 0; i < buffer.Length; i++) { buffer[i].Data = new byte[8]; buffer[i].Reserved = new byte[3]; } while (_running) { uint pending = Vci.VCI_GetReceiveNum(devType, devIndex, ch); if (pending == 0) { Thread.Sleep(5); // 轮询间隔,5ms 对 500k 总线足够 continue; } uint want = Math.Min(pending, (uint)buffer.Length); uint got = Vci.VCI_Receive(devType, devIndex, ch, buffer, want, 50); if (got == 0 || got == 0xFFFFFFFF) continue; for (int i = 0; i < got; i++) { if (!_rxQueue.TryAdd(buffer[i])) // 队列满了主动丢,别让内存爬 Interlocked.Increment(ref _dropped); } } }三个参数值得说:缓冲区开到 500 是为了减少系统调用次数,一次拿一批比一帧一帧拿快一个量级;Thread.Sleep(5)是轮询间隔,太小空转吃 CPU,太大队列容易积压,500 kbps 下 5ms 最多也就几十帧,够用;waitMs传 50 是超时,别传 0,传 0 在没数据时会立刻返回,变成忙等。
队列用BlockingCollection并设上限 20000,是给内存上保险。真实场景里如果界面线程因为弹窗阻塞了几秒,队列会一直涨,无界队列能把内存吃干净;有界队列满了丢帧并计数,反而是可控的失败。
界面侧则用 Timer 每 100ms 把队列里的数据TryTake出来一次性刷到DataGridView,用VirtualMode或者直接SuspendLayout,别一帧一帧Rows.Add。
3.4 滤波:屏蔽位是 1 表示「不关心」
AccCode是验收码、AccMask是屏蔽码,规则是屏蔽位为 1 的比特不参与比较、为 0 的比特必须和验收码一致。所以AccMask = 0xFFFFFFFF就是全通过,AccMask = 0x00000000就是必须完全等于AccCode。
11 位标准帧在 32 位验收寄存器里的对齐偏移,不同固件版本的处理不完全一致,网上流传的左移位数照抄很容易翻车。我一般的做法是:硬件滤波只做粗筛甚至干脆全通过,把精确的 ID 过滤放到应用层的字典里做。软件过滤的成本对现代 CPU 来说可以忽略,换来的是不用为了一个寄存器偏移去猜。
Mode字段还有个实用值:设成 1 是只听模式,控制器只接收不发送、不回 ACK。接在别人正在运行的总线上做监听时,这是唯一安全的姿势——正常模式下你的节点会给每一帧回 ACK,一旦你的波特率或者时序有问题,会把整条总线拖进错误帧风暴。
4. 数据落地:帧 ID 规划、位域解析与周期发送
4.1 先把 CAN ID 当表结构设计
拿到例程之后很多人直接改界面,把 ID 和字节偏移硬编码在按钮事件里,项目一超过五个信号就开始失控。我的习惯是先定一张表,把 ID 当成「主题」而不是「设备地址」来规划。
| ID | 方向 | 周期 | 内容 | 备注 |
|---|---|---|---|---|
| 0x180 | 上位机 → 设备 | 100 ms | 控制字、目标值 | 周期帧 |
| 0x181 | 上位机 → 设备 | 事件 | 参数下发 | 收到应答才发下一包 |
| 0x280 | 设备 → 上位机 | 50 ms | 状态、实际值 | 丢失 3 帧判离线 |
| 0x281 | 设备 → 上位机 | 事件 | 故障码 | 需缓存最近 20 条 |
规划的核心是区分周期帧和事件帧。周期帧用固定 ID、固定周期,接收端只要超过 3 个周期没收到就判离线,这个判断非常可靠;事件帧必须有应答机制,否则下发了参数但不知道设备收没收到,产线上就是一笔糊涂账。
另外别把 8 个字节塞满不同含义的高低位混排信号,DBC 里看着紧凑,手写解析代码时全是位运算嵌套,改一次错一次。宁可多用两个 ID。
4.2 位域解析:把原始字节变成工程量
DBC 文件里每个信号都有起始位、长度、字节序、因子、偏移、是否有符号。工程上不需要完整的 DBC 解析器,一段通用取位函数就够:
public static class CanSignal { /// <summary>按 Intel(小端)字节序取位,startBit 是信号最低位所在的绝对位号</summary> public static ulong GetBits(byte[] data, int startBit, int length) { ulong raw = 0; for (int i = 0; i < length; i++) { int bit = startBit + i; int byteIdx = bit >> 3; int bitIdx = bit & 7; if ((data[byteIdx] & (1 << bitIdx)) != 0) raw |= 1UL << i; } return raw; } /// <summary>有符号信号做补码扩展</summary> public static long ToSigned(ulong raw, int length) { ulong signMask = 1UL << (length - 1); return (raw & signMask) != 0 ? (long)(raw | ~((1UL << length) - 1)) : (long)raw; } /// <summary>物理量 = 原始值 * 因子 + 偏移</summary> public static double ToPhysical(byte[] data, int startBit, int length, double factor, double offset, bool signed) { ulong raw = GetBits(data, startBit, length); double v = signed ? ToSigned(raw, length) : raw; return v * factor + offset; } }startBit用的是 DBC 里的绝对位号,第 0 字节的最低位是 0,第 1 字节的最低位是 8,依次往后。函数按 Intel 字节序处理,也就是低位在前;Motorola 大端信号不能直接套这段代码,得按 DBC 的锯齿状编号规则单独换算,这是新手最容易出错的地方——把大端信号当小端解析,得到的结果看着「差不多」,其实每个字节都是反的。
ToSigned里的补码扩展别省。12 位有符号信号如果不做符号位判断,负温度会被解析成 4000 多,界面上显示「4200 摄氏度」,第一次看到会以为是传感器炸了。
4.3 周期发送:不要用界面 Timer 直接发
WinForm 的Timer精度在 15 ms 左右,而且一旦界面线程被阻塞它就停摆。用System.Timers.Timer稍好,但回调在线程池里跑,两次触发间隔会累积漂移,跑一上午周期就从 100 ms 漂到 130 ms。总线上其他节点如果按周期做超时判断,就开始报你掉线。
我的写法是独立线程加Stopwatch,每次在上一拍的基准上加周期,而不是从现在开始加:
private static void PeriodicLoop(uint devType, uint devIndex, uint ch) { const long period = 100; // ms var sw = Stopwatch.StartNew(); long next = 0; while (_running) { long now = sw.ElapsedMilliseconds; if (now < next) { Thread.Sleep(1); continue; } next += period; if (next < now - period * 5) // 落后超过 5 拍就重新对齐,避免补发风暴 next = now + period; SendFrame(devType, devIndex, ch, 0x180, BuildControlPayload()); } }next += period是关键,它让长期平均周期严格等于 100 ms,不累积误差;next = now + period的重置分支是后悔药,程序被系统挂起或者断点调试之后,next会严重落后,此时如果按差额连续补发,会在几毫秒内往总线上灌几十帧,直接把人家的接收缓冲打爆。
单次发送的最坏耗时也要有数:一次VCI_Transmit在总线繁忙时会阻塞几毫秒,所以周期任务里别做任何 IO 和界面操作,只负责组包和发。
4.4 用设备时间戳判断丢帧
VCI_CAN_OBJ里的TimeFlag和TimeStamp经常被空着不用。把TimeFlag置 1 之后,设备会给每帧打时间戳,单位通常是 0.1 ms。拿相邻两帧的时间戳差值跟自己记录的周期一对比,就能判断是「设备没发」还是「你没收」——这个区分在排查现场问题时价值极高,能直接把锅甩给对的一端。
5. 避坑与排查:五条在产线上反复出现的故障记录
5.1 打开设备返回 0,但设备管理器里一切正常
现象:程序启动后VCI_OpenDevice返回 0,日志里只有一句「打开失败」,重插 USB 也没用,重启电脑偶尔能好。
原因排下来通常是三个。第一是位数不匹配,工程用 AnyCPU 编出来的进程在 64 位系统上是 64 位的,加载 32 位 DLL 直接失败。第二是上一个进程没退干净,socket或者句柄还占着设备,尤其调试时按了停止按钮但进程还在后台。第三是多卡场景下devIndex填错,实际接的是第 2 块卡却传了 0。
解决:平台目标锁 x86,VCI_CloseDevice无条件放finally,启动时先枚举devIndex0 到 3 逐个试VCI_OpenDevice。还有一个兜底习惯——把VCI_ReadBoardInfo的返回值和str_hw_Type打进日志,能不能读到硬件类型,是判断「设备到底通没通」最直接的证据。
5.2 能发不能收,或者收到一串全 0 的帧
现象:发送返回值是 1,示波器或者分析仪能看到报文确实上总线了,但自己的接收计数一直是 0;另一种情况是能收到,但 ID 和数据全是 0。
原因:接收为 0 大多是没调VCI_StartCAN,或者VCI_InitCAN失败了但代码没检查返回值继续往下跑。收到全 0 的帧则是典型的物理层问题——总线两端缺 120 Ω 终端电阻,或者波特率不匹配导致控制器把错误帧当数据收了。
解决:初始化三步(Init、ClearBuffer、Start)每一步都检查返回值并写日志,任何一步不等于 1 就中止并提示具体是哪一步。物理层上,两端各一个 120 Ω,中间节点不接;500 kbps 下支线长度控制在 0.3 m 以内,双绞线别拆开走平行线。用回环模式(SendType = 2)自测一遍,能排除掉一大半软件问题。
5.3 通信时好时坏,短报文通、长报文必错
现象:实验室一切正常,装到现场跑几个小时开始偶发错误,重启程序又好了;发 8 字节的报文失败率明显高于 2 字节的。
原因:波特率虽然「一样」,但采样点不一致。两个节点一个 87.5% 一个 75%,短线看不出差别,线一长、节点一多,边沿抖动累积到采样时刻就采错位。另一个常见原因是多节点共地不良,CAN_H/CAN_L 的共模电压漂了。
解决:所有节点统一用同一组Timing0/Timing1,别一个用 500k 的标准值、一个自己算的近似值。有条件就用分析仪测实际采样点和错误帧计数,VCI_GetReceiveNum涨得慢但错误帧多,基本就是时序问题。共地这条别偷懒,屏蔽层的接地方式要统一,现场因为接地差异导致的玄学故障,我见过不止一次。
5.4 跑几小时后界面卡死、内存一路涨
现象:刚启动很流畅,连续跑四五个小时之后界面刷新明显变慢,任务管理器里内存从 80 MB
本文还有配套的精品资源,点击获取