需要图文并茂的pdf请电邮14518918qq.com一、概述第一次用串口助手软件会觉得很神奇。每次插入一个新的串口设备左上角的串口设备列表就会多一个串口号出来反之拔掉一个就会少一个。据说这背后的机制是串口助手软件会监听系统设备热插拔消息WM_DEVICECHANGE(0x0219)然后调用SerialPort.GetPortNames()重新枚举全部COM口并刷新串口设备列表框。CH341/CH347是USB转I2C或/和UART的接口芯片原厂提供了VC的访问例程CH347Demo.exe也能实现串口助手软件那样的实时侦听CH347插拔事件并显示信息到界面的设备列表的功能。这个例程走了关键两步在初始化阶段调用CH347DLLA64.dll的CH347SetDeviceNotify()函数注册UsbDevPnpNotify() / Uart_UsbDevPnpNotify() / Jtag_UsbDevPnpNotify()这样的回调函数在DLL内部会新建一条后台线程专门等待Windows PnP即插即用事件当匹配VID/PID的CH347设备有插拔时就在这条DLL后台线程上运行之前注册的回调函数。注意这些回调函数不执行繁琐业务、不操作UI就只做两件事1. 判断事件类型是CH347_DEVICE_ARRIVAL插入还是CH347_DEVICE_REMOVE拔出2. 调用PostMessage()把事件类型投递到窗体的消息队列并立刻返回。故刷新面板上CH347设备列表的工作其实是由UI线程上的消息队列所绑定的函数来执行的。实际上VC例程注册回调函数来响应CH347设备插拔事件的这套操作如果你做过单片机编程就应该觉得咋这么眼熟怎么感觉都像是中断处理机制。比如有中断源类比VC的CH347设备插拔事件有中断使能类比VC的注册回调函数后有发生中断时触发PC指针跳转到的中断服务函数类比VC会跳转到运行在DLL后台线程的C回调函数、有在中断服务函数中简单处理之后就退回到while(1)主循环去做进一步处理类比VC的UI线程接收消息队列并做事务处理。到了C#就比C或C有些许繁琐DLL后台线程收到热插拔 → 执行C#委托回调DLL线程禁止访问UI控件回调里面调用BeginInvoke((){ ... })等价 C的PostMessage()把委托任务丢到窗体的UI消息队列回调函数马上返回。WinForm消息循环UI线程取出任务执行lambda代码比如调用ScanCH347Devices()刷新comboBox设备列表。二、C实现我们先从最简单的C开个头这里用CVI创建一个工程CVI_Call_CH341A64DLL。面板左边放一个TextBox用于记录日志右边放一个Ring用于显示当前所有EVBSN极其对应的USBIndex。这是运行界面如上图过程讲解如下① 初始化阶段用CH341SetDeviceNotify()注册一个回调函数MyNotifyRoutine()再主动执行一次Search_EVB()去扫描并显示当前已经挂上来的所有EVBSN及其对应的USBIndex;可见当前EVBcnt2且右侧Ring显示如下两条记录EVBSNOLOSAEVB_PIN0005, Index0EVBSNOLOSAEVB_APD0002, Index1② 拔出原先“EVBSNOLOSAEVB_PIN0005, Index0”的USB设备可见当前EVBcnt1且右侧Ring显示如下一条记录EVBSNOLOSAEVB_APD0002, Index0③ 插入刚才拔出的USB设备可见当前EVBcnt2且右侧Ring显示如下两条记录EVBSNOLOSAEVB_PIN0005, Index0EVBSNOLOSAEVB_APD0002, Index1附上源码文件夹如附件代码分解如下。1main函数执行开篇初始化和收尾注销工作在LoadPanel()之后调用CH341SetDeviceNotify()注册了一个回调函数MyNotifyRoutine()再主动执行一次Search_EVB()去扫描USBIndex从0到15并显示当前已经挂上来的所有EVBSN及其对应的USBIndex。然后在退出UI循环的RunUserInterface()之后又调用了一次CH341SetDeviceNotify()注销回调。int main (int argc, char *argv[]){int error 0;/* initialize and load resources */ nullChk (InitCVIRTE (0, argv, 0)); errChk (panelHandle LoadPanel (0, CVI_Call_CH341A64DLL.uir, PANEL)); // 1. 注册回调第三个参数直接传函数名它会自动退化为函数指针 if (CH341SetDeviceNotify(0, NULL, MyNotifyRoutine)) { SetCtrlVal (panelHandle, PANEL_TXB_Log, CH341SetDeviceNotify已成功注册\n); Search_EVB(); //扫描USBIndex从0到15并显示当前已经挂上来的所有EVBSN及其对应的USBIndex } else { // 注册失败处理错误 return -1; } /* display the panel and run the user interface */ errChk (DisplayPanel (panelHandle)); errChk (RunUserInterface ());Error:// 2. 退出前注销回调避免 DLL 持有悬空指针CH341SetDeviceNotify(0, NULL, NULL);/* clean up */DiscardPanel (panelHandle);return 0;}2USB发生插拔则DLL内部线程调用回调函数MyNotifyRoutine(iEventStatus)MyNotifyRoutine这个回调函数是运行在DLL的后台线程上的。它只负责把事件消息iEventStatus用PostDeferredCall()向UI消息队列插入一条待执行任务func地址即OnDeviceEventDeferredcallbackData即打包后的iEventStatus就立刻返回则DLL后台线程就释放回去继续监听 PnP事件。void CALLBACK MyNotifyRoutine(ULONG iEventStatus){// 注意这里只投递不碰任何 UI把事件状态作为参数传过去PostDeferredCall(OnDeviceEventDeferred, (void *)(uintptr_t)iEventStatus);}那iEventStatus都有哪些事件类型呢这就得看dll头文件定义了#define CH341_DEVICE_ARRIVAL 3 // 设备插入事件,已经插入#define CH341_DEVICE_REMOVE_PEND 1 // 设备将要拔出#define CH341_DEVICE_REMOVE 0 // 设备拔出事件,已经拔出3UI 主线程RunUserInterface后续调度CVI主线程轮询消息从消息队列中取出这条任务然后在UI主线程内执行OnDeviceEventDeferred(callbackData)这里的callbackData就是之前由PostDeferredCall()投递的(void *)(uintptr_t)iEventStatus。void CVICALLBACK OnDeviceEventDeferred(voidcallbackData){ULONG eventStatus (ULONG)(uintptr_t)callbackData;char str[1024];/static unsigned int lastEventTime 0; //记录上一次事件发生的时间unsigned int now GetTickCount();// 300ms内相同事件直接丢弃过滤DLL多次回调 if ((now - lastEventTime) 300) { return; } lastEventTime now;*/switch (eventStatus) { case CH341_DEVICE_ARRIVAL: sprintf(str, \n有CH341插入\n); break; case CH341_DEVICE_REMOVE: sprintf(str, \n有CH341拔出\n); break; default: return; } SetCtrlVal(panelHandle, PANEL_TXB_Log, str); Search_EVB(); //扫描USBIndex从0到15并显示当前已经挂上来的所有EVBSN及其对应的USBIndex}这个回调函数的核心函数是Search_EVB()用于扫描USBIndex从0到15并显示当前已经挂上来的所有EVBSN及其对应的USBIndex 。//扫描USBIndex从0到15并显示当前已经挂上来的所有EVBSN及其对应的USBIndexvoid Search_EVB(void){int error, index;char str[1024];EVBcnt 0; memset(EVBSNList, 0x00, sizeof(EVBSNList)); DeleteListItem (panelHandle, PANEL_LSB_QOSAEVBSN, 0, -1); //Delay(0.01); //如果这里加了delay则第一次收到Arrival消息都能正确回读到EVBSN //扫描USBIndex从0到15尝试回读已经挂上来的所有EVBSN for (index0; index16; index) { error OSAEVB_GetSN_DLL(index, EVBSNList[index]); if ((error) || (NULL strstr(EVBSNList[index], OLOSAEVB))) ; else EVBcnt; } if (EVBcnt_old ! EVBcnt) { EVBcnt_old EVBcnt; sprintf(str, EVB接入数量有变当前EVBcnt%d\n, EVBcnt); SetCtrlVal (panelHandle, PANEL_TXB_Log, str); } else { sprintf(str, EVB接入数量未变当前EVBcnt%d\n, EVBcnt); SetCtrlVal (panelHandle, PANEL_TXB_Log, str); EVBcnt_old EVBcnt; } //显示当前已经挂上来的所有EVBSN及其对应的USBIndex到Textbox和Ring for (index0; index16; index) { if (NULL ! strstr(EVBSNList[index], OLOSAEVB)) { sprintf(str, EVBSN%s, Index%d\n, EVBSNList[index], index); SetCtrlVal (panelHandle, PANEL_TXB_Log, str); InsertListItem (panelHandle, PANEL_LSB_QOSAEVBSN, -1, str, index); } }}三、C实现VC不熟略。如下文字其实是deepseek总结的然后我润色成我自己能够理解的句子。这个原厂例程也能实现串口助手软件那样的实时侦听CH347插拔事件的功能其核心代码分两步在初始化阶段调用CH347DLLA64.dll的CH347SetDeviceNotify()函数注册UsbDevPnpNotify() / Uart_UsbDevPnpNotify() / Jtag_UsbDevPnpNotify()这样的回调函数在DLL内部会新建一条后台线程专门等待Windows PnP即插即用事件当匹配VID/PID的CH347设备有插拔时就在这条DLL后台线程上运行之前注册的回调函数。注意这些回调函数不执行繁琐业务、不操作UI就只做两件事1. 判断事件类型是CH347_DEVICE_ARRIVAL插入还是CH347_DEVICE_REMOVE拔出2. 调用PostMessage()把事件类型投递到窗体的消息队列并立刻返回。故刷新面板上CH347设备列表的工作其实是由UI线程上的消息队列所绑定的函数来执行的。附上原厂提供的源码工程文件夹四、C#实现对应 CVI 里的CH341SetDeviceNotify MyNotifyRoutine回调机制C#也有一套托管程序把CH341的USB插拔PnP回调接入C#主线程。这是运行界面。在子面板的USBHdl列表框会自动显示出所有能被上位机成功CH341OpenDevice(USBIndex)的USBIndex然后根据当前USBHdl值去显示它出下面挂的I2CSlv从机地址。整体链路CH341 热插拔PnP发生CH341DLLA64 内部线程执行回调跳板调用已注册的C#委托DLLProxy.OnDeviceEventDLL 的临时线程必须快速返回OnDeviceEvent 立刻调度Task.Run把工作丢给.NET线程池DLL线程返回归还DLL.NET线程池线程执行 lambdalambda 内部执行DeviceChanged?.Invoke(iEventStatus)DeviceChanged遍历所有订阅调用 Form1 的OnCH341DeviceChangedForm1 方法检测InvokeRequired用BeginInvoke切 UI 主线程执行业务扫描。完整时间轴如下程序启动Form1构造函数├─ DLLProxy.DeviceChanged OnCH341DeviceChanged; //订阅托管事件└─ DLLProxy.RegisterNotify(); //调用DLL把静态委托NotifyRoutine传给CH341DLLA64用户插上CH341 USB设备└─ Windows PnP驱动检测USB插入└─ CH341DLLA64.dll 内部触发回调调用你传入的 NotifyRoutine驱动PnP线程└─ DLLProxy.OnDeviceEvent() 执行├─ Task.Run( () DeviceChanged?.Invoke() ); //立刻扔给线程池驱动线程返回释放└─ 驱动线程返回给Windows系统完成。.NET线程池线程执行 DeviceChanged事件└─ 触发Form1的 OnCH341DeviceChanged此时在线程池不能碰控件└─ BeginInvoke( ()OnCH341DeviceChanged(iEventStatus) ); //投递UI消息队列线程池线程结束Windows消息循环轮到UI主线程执行OnCH341DeviceChanged└─ InvokeRequiredfalse安全更新txtLog日志└─ 调用 _subForm1.on_BUT_SearchUSBHost(null,null); 刷新USB设备下拉列表用户拔下CH341流程同上事件参数为 CH341_DEVICE_REMOVEUI刷新列表用户关闭窗口├─ 取消事件订阅DLLProxy.DeviceChanged - OnCH341DeviceChanged;├─ DLLProxy.UnregisterNotify(); //DLL不再调用回调└─ 释放CH341设备句柄退出。第一步DLLProxy.cs 内部实现代理层隔离原生 DLL1.1 定义回调委托和C语言稍不一样C#没有“函数指针”必须使用委托 (delegate) 来映射非托管函数指针。其中CH341NotifyRoutine是托管委托类型对应C语言的函数指针类型。委托对象的本质是托管对象里面封装了要执行的方法对象引用。// 回调委托等价C语言的mPCH341_NOTIFY_ROUTINE函数指针类型[UnmanagedFunctionPointer(CallingConvention.Winapi)] //这个委托是要传给非托管DLL调用约定Winapi(__stdcall)public delegate void CH341NotifyRoutine(uint iEventStatus); //用委托(delegate)来映射非托管函数指针这里为什么必须static readonly呢C#把委托传给DLL之后DLL内部只保存一个函数指针裸地址CLR的GC完全不知道DLL还在用这个委托对象。如果委托是局部变量/实例成员GC随时可以把它回收。当设备插拔DLL去调用一个已经被回收的委托对象程序直接随机崩溃。static readonly这个委托对象在程序整个生命周期都不会被GC回收DLL手里的函数指针永远有效。1.2 注册/注销API封装首先P/Invoke导入DLL函数。注意这里第三个参数CH341NotifyRoutine是前面定义的一种C#独有的托管委托类型。[DllImport(“CH341DLLA64.DLL”, CallingConvention CallingConvention.Winapi)]public static extern uint CH341SetDeviceNotify(uint iIndex,string iDeviceID,CH341NotifyRoutine iNotifyRoutine // ←重点);然后定义注册或注销// 指定索引/设备ID注册iIndex0广播模式监听本机全部CH341设备// 支持RegisterNotify()这种简化调用public static int RegisterNotify(uint index 0, string deviceId null){return CH341SetDeviceNotify(index, deviceId, NotifyRoutine);}// 指定索引/设备ID注销和RegisterNotify()成对调用// 支持UnregisterNotify()这种简化调用public static int UnregisterNotify(uint index 0, string deviceId null){return CH341SetDeviceNotify(index, deviceId, null);}这里index0广播模式监听本机全部CH341设备不用预先知道设备索引号适合热插拔监控。注销的时候第三个参数传nullDLL就不再触发回调。1.3驱动回调入口函数 OnDeviceEvent运行在DLL后台线程它是一个轻量转发器——本身不干重活只负责快速识别热插拔事件、安全地把活儿转交给线程池和上层订阅者并防止异常击穿驱动线程。设计上的两个亮点值得借鉴① 异步投递 快速返回符合驱动回调不阻塞的铁律② 在回调边界用 try/catch 隔离订阅者异常避免外部代码拖垮宿主进程。// 驱动回调入口函数运行在DLL后台线程必须快速返回// 禁止在此函数内调用任何CH341OpenDevice / I2C读写等硬件操作public static void OnDeviceEvent(uint iEventStatus){// 使用Task.Run把业务投递到.NET线程池立刻归还驱动线程_ Task.Run(() {try{DeviceChanged?.Invoke(iEventStatus);}catch (Exception ex){// 捕获订阅者异常避免驱动线程上未处理异常直接造成进程崩溃Debug.WriteLine($“DeviceChanged订阅处理异常{ex.Message}”);}});// 调试打印轻量操作允许留在回调线程 switch (iEventStatus) { case CH341_DEVICE_ARRIVAL: Debug.WriteLine(CH341 已插入); break; case CH341_DEVICE_REMOVE_PEND: Debug.WriteLine(CH341 将要拔出); break; case CH341_DEVICE_REMOVE: Debug.WriteLine(CH341 已拔出); break; default: Debug.WriteLine($CH341 未知事件: {iEventStatus}); break; }}注意看OnDeviceEvent()有一句DeviceChanged?.Invoke(iEventStatus)干了三件事1把 DLL 底层传来的插拔事件数据 iEventStatus向外转发出去。2通知所有订阅了DeviceChanged的外部代码比如 Form1在构造函数就做了订阅DLLProxy.DeviceChanged OnCH341DeviceChanged;3?.是空条件运算符保护没有任何对象订阅事件的时候不会抛空引用异常。 如果外面没有任何代码执行 订阅DeviceChanged 为null?.Invoke() 什么都不做安全放过。1.4 对外公开事件这是普通C#托管事件上层Form1来订阅它。事件触发的时候运行在DLL后台线程所以窗体收到事件之后还得做一次线程切换。// 对外事件Form层订阅接收插拔消息事件运行在线程池不是UI线程相当于一个受保护的全局函数指针链表// 只有 DLLProxy 类内部收到 CH341 底层插拔消息时才主动去调用所有注册上来的回调函数public static event Action DeviceChanged;第二步Form1.cs主窗体订阅插拔事件2.1 构造函数注册监听先订阅C#事件DeviceChanged再调用RegisterNotify()即调用DLL函数CH341SetDeviceNotify()把静态委托指针交给驱动DLL。//注册事件订阅DLLProxy.DeviceChanged OnCH341DeviceChanged;int ok DLLProxy.RegisterNotify();AppendLog((1ok) ? “✅ CH341 插拔监视已开启” : “❌ CH341 插拔监视注册失败”);2.2 实现Form1的事件处理函数OnCH341DeviceChanged如下代码当InvokeRequiredtrue代表当前不是 UI 线程调用BeginInvoke把这个函数重新投递到UI主线程BeginInvoke本身不会阻塞线程池线程经过 BeginInvoke 投送后代码再次进入本函数此时InvokeRequiredfalse代表当前已经在UI线程可以写日志、调用子窗体扫描USB并更新列表。private void OnCH341DeviceChanged(uint iEventStatus){// 当前.NET线程池线程不是UI线程if (InvokeRequired){// 切回UI线程执行自己BeginInvoke非阻塞BeginInvoke(new Action(() OnCH341DeviceChanged(iEventStatus)));return;}//到这里才真正来到UI主线程可以安全访问窗体控件、日志、子窗体switch (iEventStatus){case DLLProxy.CH341_DEVICE_ARRIVAL:AppendLog(“ CH341 已插入”);_subForm1.on_BUT_SearchUSBHost(null, null);break;case DLLProxy.CH341_DEVICE_REMOVE_PEND:AppendLog(“⚠️ CH341 将要拔出”);break;case DLLProxy.CH341_DEVICE_REMOVE:AppendLog(“ CH341 已拔出”);if (_isConnected){_isConnected false;}_subForm1.on_BUT_SearchUSBHost(null, null);break;}}2.3 窗体关闭取消订阅注销DLL通知成对主要就是这句DLLProxy.UnregisterNotify()private void Form1_FormClosing(object sender, FormClosingEventArgs e){//第一步取消C#事件订阅解绑DLLProxy.DeviceChanged - OnCH341DeviceChanged;//第二步告诉DLL不要再调用回调委托传入nullDLLProxy.UnregisterNotify();AppendLog(“ CH341 插拔监视已取消”);// 释放_subForm1 if (_subForm1 ! null !_subForm1.IsDisposed) { _subForm1.Close(); _subForm1.Dispose(); _subForm1 null; } // 无脑释放所有CH341的USBHandle if (_isConnected) { _isConnected false; for (uint i0; i16; i) { try { DLLProxy.CH341CloseDevice(i); AppendLog($ CH341句柄 {i} 已释放); } catch (Exception ex) { Debug.WriteLine(ex); } } AppendLog( CH341设备已释放); } //这里不屏蔽base.OnFormClosing就会递归触发死循环 //base.OnFormClosing(e); //手动释放 components因为前面屏蔽了base.OnFormClosing函数无法触发后续的Dispose() if (components ! null) { components.Dispose(); AppendLog( components资源已释放); }}附上C#源码工程文件夹
