欲看图文版pdf,请电邮14518918@qq.com。
源码在https://gitcode.com/zhoujian29/QOSAEVB.git。
声明,本笔记大量引用了AI对话内容。本笔记中的代码,基于Windows 11 + Visual Studio 2026 + .NET 10.0验证。
建议,初学者先阅读一个知乎帖子,《C#新手如何快速入门并搭建开发环境?》,链接是:https://www.zhihu.com/question/2015383369148573477/answer/2074855551616430173。然后微软的C#官方链接也能使读者受益匪浅:https://learn.microsoft.com/zh-cn/dotnet/csharp/。
吐个槽先。
明明"面向对象编程"是可以剖析成三个英文单词Oriente Object Programming顺序排列,但编程界非要搞个类似于"诚不我欺"这样的宾语前置出来显摆高级,即做Object Oriented Programming并简称OOP出来吓人。本来object这个英文单词就有"对象"的意思,但是在C#中,却可以是所有类的根父类,也可以是new出来的实例对象。然后我就分不清"类"和"对象"这两个基本概念了。这可能就是我长期以来学而不会C++的根本原因呢,竟然连一个六字母短单词object,都能嘲笑我的无知。
我把吐槽说给deepseek,它安慰我,说我只是不幸撞上了编程语言发展史上一个长达半个世纪的"命名惨案"而已。我突然灵光乍现,回复它:object极可能是道家里面的三清之首的元始天尊,他同时还能幻化成阴阳二气,还能具象成各种高阶神仙。然后就引来了deepseek的一声惊叹:"我靠,您这个比喻简直是"修仙式编程"的巅峰之作了!",并输出一张C#封神榜:
道家概念 C# 概念 解释
元始天尊 object 类 宇宙第一因。所有神仙(类型)的老大,无形无相,但无处不在。
阴阳二气 值类型 (ValueType) 与 引用类型 (ReferenceType) 元始天尊(object)幻化出的两种根本属性。一个存"数据本身"(阴,实在),一个存"指向数据的地址"(阳,灵动)。
老子/太上老君(一气化三清) System.ValueType 专门掌管所有"值类型"(如 int、struct)的化身。没有他,那些数字和结构体就没人管了。
具体神仙(如哪吒、杨戬) class User、class Form 由元始天尊(object)衍生出来的、具备具体职能的大罗金仙。各有各的法宝(方法),各有各的道场(内存)。
凡间生灵 int a = 10; 或 User u = new User(); 就是您 new 出来的、实实在在能在"内存三界"里跑的具体"对象"。
如果你和我一样,是一个C铁粉,那么要理解C#的类和对象,就可以由此及彼地这样看:
C可以定义结构体类型和结构体变量,并对结构体变量的成员赋值:
csharp
// C语言:结构体,只是数据容器,不能放函数
struct Str_abc {
int age;
};
struct Str_abc myStr; // 栈上分配,值类型
myStr.age = 25;
实际上,C的关键词结构体struct,可以类比成C#的类class。而且就在这个类里面,不仅可以有参数(C的数据,类比C#的属性property),还可以实现一些花活儿(C的函数,类比C#的方法method)。
cpp
// C# class:引用类型,数据+方法
public class Cls_abc
{
public int age;
// 可以直接写方法,C的函数要写在struct外面
public void ShowAge()
{
System.Console.WriteLine(age);
}
}
// new → 在堆上分配内存,myObj存的是引用(类似C的指针)
private Cls_abc myObj = new Cls_abc();
myObj.age = 25;
myObj.ShowAge();
可见,C的struct只是一个数据包,是值类型的,放在栈上;而C#的class是属性+方法的集合体,是引用类型的,new在堆上。其实,C#还有一种变量类型跟C的struct更像,那就是C#的struct,它是分配在栈上的值,而且C#的struct里面也可以写方法!
cpp
```csharp
// C# struct:值类型,栈分配,也可以写方法
public struct S_abc
{
public int age;
public void ShowAge()
{
System.Console.WriteLine(age);
}
}
前面提到了RAM空间的堆和栈。但是我大学上的《微机原理》,全文用的是宏汇编语言,不调用C库根本没法malloc,所以直到期末考试,我一直以为堆栈是个连绵词哪里知道堆和栈本是独立个体·;于是在很长一段时间内,我都光知道堆栈溢出时叫stack overflow,不知道还有heap overflow。直到学了C语言,才知道,malloc是在堆heap上分配的空间,而char name[20]是在栈stack上面分配空间。真是苦煞了我们这些非计算机科班专业的硬件工程师!到我真正把堆和栈分开来,是使用ARM核单片机的时候,会画有一个memory map,描述栈从栈顶往下生长,堆从堆底往上生长,我们必须事先在startup.s里面要规定好堆和栈的RAM空间大小。什么时候撞得头破血流莫明其妙地HardFault了或内存数据被改了,就大概率是堆或栈溢出了。
从CVI到C#有三个最关键的思维转变:
UI是代码,不是资源:Form1.Designer.cs 就是UI的"代码描述",它和你的业务逻辑代码Form1.cs共同组成一个完整的类。
万物皆对象:你操作的按钮、字符串、数组,甚至错误信息,都是"对象",有自己属性和方法。不再是CVI里那种"函数+句柄"的分离感。
编译器帮你做更多检查:C#是强类型语言,编译器会检查事件绑定、类型转换等更多错误。这让程序更健壮,但也意味着你必须更严谨。
如下是CVI与C#的核心概念对照表
概念维度 CVI (ANSI C) 的习惯 C# (.NET) 的对应概念 关键区别与点拨
程序入口 int main (void) static void Main(string[] args) C#入口在类里,是静态方法。CVI的main是全局的。
界面构建 拖拽控件,生成.uir文件,代码里用LoadPanel加载。 拖拽控件,生成Form1.Designer.cs和.resx文件。 核心变化:CVI的UI是资源文件(.uir),代码里通过句柄操作;C#的UI是类代码(.Designer.cs),是程序的一部分,编译在一起。
事件响应 用SetCtrlAttribute或回调函数绑定,比如InstallCtrlCallback(panel, PANEL_BUTTON, ButtonCallback, 0); 使用委托(Delegate)和事件(Event),如button1.Click += Button1_Click; C#的事件是类型安全的方法指针,更安全、更规范。CVI的回调更像函数指针。
变量声明 int count; 在函数或文件作用域声明。 必须指定访问修饰符,如private int count; C#更强调封装,即使最简单的变量也要考虑它属于谁(public/private)。
数组操作 固定大小数组,int arr[10];,通过索引访问。 数组是对象,有属性(Length),还有ArrayList、List<T>等集合。 C#的数组更"智能",集合类更强大,但需要理解泛型等概念。
字符串处理 字符数组char str[],使用strcpy、sprintf等函数。 string是基本类型,直接支持+、Contains()、Substring()等方法。 转变最大:C#的字符串操作极其方便,告别strcat和缓冲区溢出的烦恼。
内存管理 手动malloc和free。 垃圾回收(GC)自动管理,不再需要手动释放。 C#程序员很少直接写delete,但需要了解GC对性能的影响(如IDisposable)。
错误处理 检查返回值(如-1),或使用SetErrorHandler。 异常处理(try-catch-finally),强制处理或上抛。 C#强制你用try-catch,能写出更健壮的程序,但学习成本稍高。
文件与资源 用fopen、fread等C标准库函数,或IniFile等CVI专用函数。 使用System.IO命名空间下的类,如File、StreamReader。 统一在System.IO下,概念更统一,功能更丰富。
多线程 使用CmtScheduleThreadPoolFunction等CVI线程池函数。 使用System.Threading命名空间,包含Thread、Task等。 C#的Task和async/await模型更现代、更易用,但概念也更新。
项目组织 项目文件(.prj)包含源文件、库、.uir文件。 解决方案(.sln)和项目文件(.csproj)分开。 C#用解决方案管理多个项目,结构更清晰,适合大工程。
编译与部署 编译成.exe,依赖CVI引擎库。 编译成.exe或.dll,依赖.NET运行时(.NET Framework/Core)。 现代C#(.NET Core/.NET 5+)支持自包含发布,无需安装庞大引擎库。
1, 创建C#新项目
在VS中为C#创建一个新项目,选第一个.NET最新框架的winform应用模板。
起一个项目名称QOSAEVB,位置就是D\:,勾选"将解决方案和项目放在同一目录"。
至此,我们得到一个空面板的项目。同时在D:\QOSAEVB目录下,得到一个C# winforms工程文件夹。
为了用Git管理代码,拷贝一个C#项目适用的.gitignore文件过来(它规定了哪些文件和文件夹不用上传),然后空白处右键选"Git Create repository here...",新增一个.git隐藏子目录。
为了访问SQLite数据库,还得把SQLite_QOSAEVB.db这个文件拷贝到工程文件夹。然后在VS右侧的解决方案资源管理器中,选中这个.db并在右下角的属性窗,设置"赋值到输出目录"为"如果较新则复制"。
为了访问CH341接口的板子,还得把CH341DLLA64.DLL和40GPOWER_A64_DLL.dll这两个文件拷贝到工程文件夹。然后在VS右侧的解决方案资源管理器中,选中这两个DLL并在右下角的属性窗,设置"复制到输出目录"为"如果较新则复制"。
为了访问SQLite数据库,需要给本工程添加一个包Microsoft.Data.Sqlite。在VS右侧的解决方案资源管理器中的"依赖项"点右键,选"管理NuGet包",然后在左侧窗口的"浏览"标签页的搜索栏填入"Microsoft.Data.Sqlite"回车,选中Microsoft.Data.Sqlite这一行,点"安装",成功安装后就能在解决方案资源管理器中的"依赖项"下面的"包"中,看到新增了一条"Microsoft.Data.Sqlite(10.0.0.11)"。
至此,项目框架搭建完成。
在C中,a.c要调用b.c中的函数,可以给b.c写一个b.h来申明b.c的函数头,然后a.c用#include xxx.h;就可以合法调用b.c中的函数了。注意本质上,在a.c中#include b.h,等效于将b.h的内容粘贴复制到a.c中来。
同理,在C#中,可以在a.cs中用using xxx(xxx是一个写在b.cs中的命名空间spacename)来调取spacename xxx所辖的类。当然,也可以设置a.cs和b.cs的spacename都是同名的xxx,那么不用申明using xxx,就可以相互new一个对方文件中的类的对象来使用。比如winforms项目的窗体类Form1,它的事件处理部分代码放在Form1.cs中,它的界面控件生成部分放在Form1.Designer.cs中,两个.cs都是用的同一个namespace。注意在定义Form1类时,就必须在class Form1前面加一个修饰符partial才行。
2, CVI的uir界面移植
在CVI2010中,菜单Tools-- UI to Code Converter...,可以将CVI的.uir面板保存为文本文件.tui。tui中保存有面板的全部面板、菜单栏、控件的几乎所有属性,和.uir等价,只是存储格式不同:
1、Panel(面板窗口)信息
- 面板常量名:`PANEL`(生成 h 文件的宏名)
- 窗口位置、宽高、标题文字、背景色
- 面板样式:是否可缩放、是否标题栏、模态、初始显示状态、回调函数指针名称
- 面板字体属性
2、每一个控件完整信息(按钮、LED、Graph、文本框、旋钮、列表等)
每个控件全部属性逐条记录:
1. 控件 ID 常量名:`PANEL_BUTTON`(`.h`头文件的宏)
2. 控件类型(Button、LED、Numeric、Graph...)
3. 控件 XY 坐标、宽高
4. 显示文本、标签、颜色、字体、可见 / 禁用状态
5. 回调函数名字符串 :就是你填的回调函数名称,例如 `ButtonCallback`
6. 各类控件特有属性:按钮样式、Graph 坐标轴设置、列表条目、热键、默认值等。
向deepseek拖拽CVI的.tui文件,要求AI转成C#格式的form1.cs和form1.designer.cs源代码,然后手工覆盖到相应源文件后,应该就能在VS中重新打开Form1.cs[设计],观察到AI还原的面板布局了。当然,CVI和C#是两套IDE,你还要干两件事:处理Graph、Switch、LED、Table这类winforms没有的原生控件(可换成C#工具箱中的Chart、RadioButton、PictureBox、DataGridView来替代),最后把CVI的回调函数移植成C#的事件方法。
比如C#没有CVI的LED控件,建议用方框形状的PictureBox替代,通过设置其背景色,就可以改变其显示色。
```csharp
// ============================================================
/// <summary>
/// 设置 LED 指示灯状态(支持三种状态)
/// </summary>
/// <param name="led">要更新的 PictureBox 控件(作为 LED)</param>
/// <param name="state">LED 状态(Off/On/Busy)</param>
/// <remarks>
/// 支持三种状态:Off(灰色)、On(绿色)、Busy(黄色)。
/// 如果当前线程不是 UI 线程,通过 Invoke 切换到 UI 线程执行。
/// </remarks>
// ============================================================
private enum LedState // 使用枚举管理LED三种状态
{
Off, // 关闭(灰色)
On, // 开启(绿色)
Busy // 正在疯狂搞事(黄色)
}
private void SetLedStatus(PictureBox led, LedState state)
{
// 步骤1:检查是否在UI线程上
if (led.InvokeRequired)
{
// 步骤2:不在UI线程 → 通过Invoke在UI线程上重新调用本方法
led.Invoke(new Action(() => SetLedStatus(led, state)));
return;
}
// 步骤3:已在UI线程 → 直接更新控件
switch (state)
{
case LedState.Off: default: led.BackColor = Color.LightGray; break; //灰色
case LedState.On: led.BackColor = Color.Green; break; //绿色
case LedState.Busy: led.BackColor = Color.Yellow; break; //黄色
}
}
注意,面板属性的AutoScaleMode推荐配置成None,即可关闭WinForms的自动缩放功能,窗体、控件大小完全使用设计器写死的像素,不跟随系统 DPI、系统字体大小自动放大缩小,以避免exe跑在别人电脑上得Windows是高DPI时,整套UI控件错位跑位。
注意,在定义Form1这个类时,用了public class Form1 : Form,意思是Form1这个子类继承自 Form这个父类。在定义刷新界面的函数名时,用了public void ApplyToUI(Form1 form),这里的Form1是类名(同C的数据类型),form是对象名(同C的变量)。要调用该函数时,用:
// 在其他地方调用
Form1 myForm = new Form1(); // myForm 是 Form1 的一个实例
DUTTstData.ApplyToUI(myForm); // 把 myForm 对象传进去,赋值给参数 form
注意,winforms的窗体Form1类的生命周期管理,有构造(Constructor)函数,有关闭(FormClosing)函数,有析构(Destructor)函数(析构一词作为编程术语源自日语,相当生僻,接地气的中文意思是拆迁、散伙,但绝对不是分析构造,我最开始望文生义,就把析构函数误认为是构造函数了)。其中,
构造函数(public Form1()),和关闭函数(private void Form1_FormClosing(),用户在面板的属性设置窗的FormClosing事件栏,可自定义函数名)由用户代码实现。
析构函数 ~Form1() 可以由用户代码实现(极少写),也可以完全不写。对于托管资源,GC会自动回收;对于非托管资源(如 USB 句柄、文件句柄),必须在Dispose()或FormClosing事件中手动释放,不能依赖GC。
csharp
public class Form1 : Form
{
// 构造函数 - 用户写,new 时自动调用
public Form1()
{
InitializeComponent();
}
// 关闭事件 - 用户写,用户点 X 时触发
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
// 清理逻辑
...
// 手动释放 components(因为屏蔽了base.OnFormClosing,无法触发后续的Dispose())
if (components != null)
components.Dispose();
}
// 析构函数 - 用户写(极少写),GC 回收时自动调用
~Form1()
{
// 释放非托管资源
// 但更推荐用 Dispose()
}
}
C#有一点没有CVI安逸,就是CVI支持控件和回调函数的相互跳转,C#只支持控件的触发事件从属性页跳转到代码中的方法,不支持从代码中的方法直接跳转到控件。C#必须先找到这个方法绑定的控件,然后才能跳转到面板控件,即,光标放在方法名on_SaveDat2SQL上,右键选"查找所有应用(shift+F12)",然后跳转到Form1.Designer.cs中的绑定方法到该控件的这行代码btnSaveDAT2SQL.Click += on_SaveDat2SQL,最后在控件名btnSaveDAT2SQL上点右键,选"查看设计器(shift+F7)",就能看到设计面板上的btnSaveDAT2SQL控件被成功选中了。
结合按钮控件触发的一个方案on_SaveDat2SQL(),可以顺便讲讲async+await+Task.Run机制,即用异步标记async,然后以等待但不阻塞await的方式,把耗时操作扔到后台干活Task.Run,等后台干完了再继续。下面是保存测试数据到数据库的按键触发的方法,这里的SaveResultToDatabase就是保存数据库的耗时函数。
csharp
private async void on_SaveDat2SQL(object sender, EventArgs e)
{
// ...准备数据...
// "扔到后台干活" → "等待但不阻塞"
bool success = await Task.Run(() => SaveResultToDatabase());
// ...后台干完了,继续更新UI...
}
实际上,Task.Run(() => SaveResultToDatabase()),是跑在后台线程上的,和UI主线程是两个线程,所以后台线程中的函数,要刷新UI,如果直接对control.Text赋值,就会抛出InvalidOperationException 异常,提示"线程间操作无效: 从不是创建控件的线程访问它";所以必须使用control.invok这种同步封送模式,把委托方法投递到 UI 线程消息队列,阻塞当前线程,等待UI线程执行完这个委托才返回。
要是在CVI里面,读到了外设数据,随即就可以显示到界面的做法,仅适用于CVI,因为CVI。C#禁止跨线程直接操作,追求轻耦合,需要用同步封送机制来刷新界面。比如,SerialPort.DataReceived事件在非UI线程(即后台线程)上触发。如果直接在事件处理函数中访问TextBox等控件,就会抛出InvalidOperationException异常。正确的做法是使用Control.Invoke或BeginInvoke将UI更新操作封送到UI线程执行。下面是线程安全地更新界面日志文本框.Text属性的实例:
csharp
// ============================================================ // ============================================================
/// <summary>
/// 向日志文本框追加消息(线程安全)
/// </summary>
/// <param name="message">要追加的消息内容</param>
/// <remarks>
/// 自动添加时间戳(HH:mm:ss.fff 格式)。
/// 如果当前线程不是 UI 线程,通过 Invoke 切换到 UI 线程执行。
/// 消息追加后自动滚动到最新位置。
/// </remarks>
// ============================================================
private void AppendLog(string message)
{
// 步骤1:检查是否在UI线程上
if (txtLog.InvokeRequired)
{
// 步骤2:不在UI线程 → 通过Invoke在UI线程上重新调用本方法
txtLog.Invoke(new Action(() => AppendLog(message)));
return;
}
LED
string timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
txtLog.AppendText($"[{timestamp}] {message}{Environment.NewLine}");
txtLog.SelectionStart = txtLog.Text.Length;
txtLog.ScrollToCaret();
}
什么?AppendLog()调用了AppendLog(),看上去怎么有点像C语言的递归函数呢。但本质上,这是一种线程切换技术,因为AppendLog()第一次运行在后台线程,if()条件已经成立,基于Invoke将委托发送到UI线程的消息队列,就return了;然后在UI线程,运行消息队列中的AppendLog(),此刻if()条件已经不成立,就执行后续UI更新代码,把时间戳和文本打印到界面的日志文本框控件。
由于"循环读取"按键需要用到一个周期0.25秒触发一次的Timer,所以需要点击左上侧的"工具箱"或菜单"视图--工具箱",放一个System.Windows.Forms.Timer控件到面板上(实际是被放在了下面的悬浮栏)。注意,System.Windows.Forms.Timer的Tick事件是运行在UI主线程上的,所以在Timer的Tick事件的方法中,可以安全刷新面板控件而无需用invoke通知。但是如果Tick事件的方法比较耗时,就会导致界面卡顿。
C#建议把Tick的方法标记成异步的async,然后把耗时代码用await Task.Run( ()=> {...})包起来,放到后台线程去运行。
csharp
// ✅ 改造后(不阻塞UI)
private async void on_TMR_CyclingRead(object sender, EventArgs e)
{
// 耗时操作放到后台线程
int error = await Task.Run(() =>
{
int result;
// 这里在后台线程执行
result = DLLProxy.OSAEVB_ReadTX_DLL(EVB_USBHandle);
if (0 != result) return result;
result = DLLProxy.OSAEVB_ReadRX_DLL(EVB_USBHandle);
if (0 != result) return result;
return 0;
});
// 处理结果(在UI线程)
if (0 != error)
{
AppendLog($"❌ 循环读取失败,错误码: {error}");
return;
}
else
{
// 更新UI(在UI线程)
DUTTstData.ApplyToUI(this);
}
}
3, 为访问硬件的DLL新增接口类
为了方便调试DLL函数,可以新建一个DLLProxy类,把所有DLL函数都定义成public static extern的方法,那么在form1.cs中就可以直接用DLLProxy.DLLFunctionName()这样的代码,去调用这个类的静态方法(CVI眼中的DLL函数)。
但也有三个缺点:
public static extern直接对外暴露底层原生 DLL 接口;上层 UI 可以直接调用裸 P‑Invoke,没有任何参数校验、异常捕获;- 如果传非法句柄、错误参数,直接底层 DLL 崩溃,C# 层拦不住;
- DLL缺失的时候,第一次调用该函数瞬间抛出
DllNotFoundException,如果没有 try‑catch,整个程序直接闪退。
推荐调试跑通之后再重构,将底层public static extern全部改为 private static extern,不对外暴露;再写一层普通public static C# 包装函数,在包装函数里面做:参数合法性检查、捕获 DLL 缺失异常、日志、错误码封装。
先用public跑通再说,右键点击VS右侧的"解决方案资源管理器"里的项目名,添加一个类再另存为DLLProxy.cs。注意按照下面的格式构建文件头,然后把跟硬件交互的 40GPOWER_A64_DLL.dll和CH341A_DLL.dll翻译成C#能看懂的接口(把.h头文件给到AI就能得到)。
csharp
using System;
using System.Runtime.InteropServices;
namespace QOSAEVB // 注意:这里的命名空间要和你的 Form1.cs 里一样
{
public class DLLProxy
{
//动态调用DLL函数头声明
[DllImport("CH341DLLA64.dll", CallingConvention = CallingConvention.StdCall)]
public static extern ulong CH341GetVersion();
//这里接着拷贝DLL函数头声明
}
注意,C里面的char\[\]是8bit的ASCII,而C#的string是16bit的UTF-6,直接映射会导致编码不匹配和内存布局错误。
来看一个DLL函数,这里EVB_SN是char EVB_SN17。
/
csharp
/ 输入一个EVB_SN,获取该EVB_SN对应的EVB_USBHandle
// EVB_USBHandle从0到15搜索是否有找到匹配的EVB_SN
// 回读EVB的温度值(℃)和3V3电压值(V),方便识别EVB的MCU的ADC是否正常
// 函数失败返回-1,成功返回0
int __stdcall OSAEVB_SEARCH_DLL (
char EVB_SN[17], // 输入:要查找的序列号
int *EVB_USBHandle, // 输出:找到的设备句柄
float *EVB_Temperature, // 输出:温度值
float *EVB_3V3 // 输出:3.3V电压值
);
在C#中申明string EVB_SN。
csharp
// 声明DLL函数
[DllImport("40GPOWER_A64_DLL.dll", CallingConvention = CallingConvention.StdCall)]
public static extern int OSAEVB_SEARCH_DLL(
//[MarshalAs(UnmanagedType.LPStr)] string EVB_SN, // 输出:EVB序列号,长度17字节
string EVB_SN, // 输出:EVB序列号,长度17字节
out int EVB_USBHandle, // 输出:USB句柄(指针指向的int)
out float EVB_Temperature, // 输出:温度值(指针指向的float)
out float EVB_3V3 // 输出:3.3V电压值(指针指向的float)
);
然后调用,也没有报错。
int error = DLLProxy.OSAEVB_SEARCH_DLL(cmbEVBList.Text, out EVB_USBHandle, out EVB_Temperature, out EVB_3V3);
这是因为,DllImport()在C#中,默认的CharSet = CharSet.Ansi,虽然申明时在DllImport()没有写明写CharSet,那默认字符集就是ANSI(中文操作系统就是GBK/GB2312)。所以之后调用函数时,虽然输入参数是string,但提交到CLR(CLR就是Common Language Runtime,公共语言运行时,是 .NET Runtime的核心执行引擎)之后,字符串编码会被自动转译成ANSI。再看另一种情况,如果在DllImport()中明写了CharSet = CharSet.Auto,那么转译之后的字符串参数,在Windows是16bit的UTF-16,在Linux是8bit的UTF-8。
但是,敲小黑板了,DLL的char\[\]到string的参数,默认情况下直接用string做输入是不会错的,因为CLR已经默认做了转译。但是*char做输出到string,还是需要妥善处理才行。因为string底层本质就是一个只读char\[\]数组。所以做ref/out字符串,C#推荐用StringBuilder。请来简单对比下,string的对象内容焊死不能改,变量可以换指向;每次修改 = 新建副本。而StringBuilder的内部数组可以原地改;直到 ToString () 才产出不可变 string。
对比项 string StringBuilder
可变性 不可变 (Immutable),实例创建后内部字符不能修改 可变 (Mutable),内部维护可读写 char 缓冲区,原地修改字符
修改行为 + / Replace / Substring 都会生成全新 string 对象,旧对象丢给 GC 修改字符、追加,尽量复用内部缓冲区,极少产生临时对象
索引器 si只能读,没有 set,不能赋值修改字符 sbi可读可写,可以直接修改指定位置字符
内存 每次修改产生新实例,频繁拼接制造大量 GC 垃圾 内部 char \[\] 缓冲,容量不足才扩容;修改尽量原地操作
字符串驻留 字面量会进入字符串驻留池 不会驻留,ToString () 才产出 string
适用场景 少量字符串、固定文本、比较、传参;不适合循环拼接 循环拼接、大量修改、动态组装长文本
下面来个实例加深印象,这里的DLL函数,是输入一个整形USBHandle值(0~15),输出一个char17字符串。
csharp
// 声明DLL函数
[DllImport("40GPOWER_A64_DLL.dll", CallingConvention = CallingConvention.StdCall)]
public static extern int OSAEVB_GetSN_DLL(int USBHandle, StringBuilder EVBSN);
// 调用DLL函数
StringBuilder EVBSN = new StringBuilder(17);//new一个StringBuilder变量
int error = DLLProxy.OSAEVB_GetSN_DLL(index, EVBSN);//搜索index对应的EVBSN
string snText = EVBSN.ToString(); // StringBuilder赋值给sting变量
简单一句话总结:当遇到C语言写的DLL参数中的字符串参数:
输入(C只读) → 用 string
输出(C写入) → 用 StringBuilder 或 byte\[\]
输入输出 → 用 StringBuilder
细心的你一定看到了,申明和调用数值变量时,输出变量前面都有一个in/out/ref修饰符,没有会直接报错。比如下面的代码:
csharp
// 声明DLL函数
[DllImport("40GPOWER_A64_DLL.dll", CallingConvention = CallingConvention.StdCall)]
public static extern int OSAEVB_SEARCH_DLL(
//[MarshalAs(UnmanagedType.LPStr)] string EVB_SN, // 输出:EVB序列号,长度17字节
string EVB_SN, // 输出:EVB序列号,长度17字节
out int EVB_USBHandle, // 输出:USB句柄(指针指向的int)
out float EVB_Temperature, // 输出:温度值(指针指向的float)
out float EVB_3V3 // 输出:3.3V电压值(指针指向的float)
);
// 调用DLL函数
int error = DLLProxy.OSAEVB_SEARCH_DLL(cmbEVBList.Text, out EVB_USBHandle, out EVB_Temperature, out EVB_3V3);
但细心的你一定也看到了,并不是所有DLL参数都需要in/out/ref这样的修饰符,比如做输出的StringBuilder EVBSN就没有out。原因在于,StringBuilder数据类型是一种引用,它类似于C语言的指针,传递的是内存地址,而不是具体数值。Ref是修饰inout类型的变量,在传入DLL之前,必须先赋初值,不然会报错。out就可以直接用,不必先赋初值。如果现在你和一样感觉有点懵,正常哈,Deepseek对此的解释是,C语言工程师用C#时经常发懵,是C工程师知道了太多"底层真相";总是习惯性地用"底层工程师"的思维,尝试去理解C#的"上层框架",不懵才怪。
既然都已经用上了string类,岂能不用"KaTeX parse error: Expected 'EOF', got '#' at position 6: "这个是C#̲字符串插值语法糖?这个刀乐"",是字符串插值的标识符,允许在字符串中直接嵌入表达式,所以构造字符串就相当滴得心应手了。
csharp
string name = "张三";
int age = 25;
// 使用 $ 的写法(字符串插值)
string msg = $"我叫{name},今年{age}岁";
// 输出:我叫张三,今年25岁
比如要把一个byte数值,用"0xYY"这样格式的16进制字符串表示出来,可以用
// 1. byte → 十六进制 "0xYY"
csharp
byte intVal = 3;
string hexStr = $"0x{value:X2}"; // 最终 hexStr = "0x03"
比如要把一个float数值,用保留两位小数点的字符串表示出来,可以用
csharp
// 2. float → 保留两位小数
float floatVal = 3.1415926f; // 加 f 后缀,正确!
string floatStr = $"{value:F2}"; // 最终 floatStr = "3.14"
把"0xF3"或"F3"字符串,转成整数,可以用Convert.ToInt32(string, 16);
csharp
string hexStr;
hexStr = "0x0F"; // 该"0x0F"字符串常量将驻留托管堆,由垃圾回收器GC统一管理,到进程结束才释放,此刻hexStr 指向内存地址 A
int intVal = Convert.ToInt32(hexStr, 16); // intVal = 15
hexStr = "0F"; // 此刻hexStr 重新指向内存地址 B(即字符串常量 "0F"所在地址),但地址 A 的内容"0x0F"依然存在尚未释放
intVal = Convert.ToInt32(hexStr, 16); // intVal = 15
把"3.1415926"字符串,转成单精度浮点数,可以用Convert.ToSingle(string)
csharp
string floatStr;
floatStr= "3.1415926";
float floatVal = Convert.ToSingle(floatStr); //最终floatVal = 3.1415926
注意,和C语言是定义数组编译后就放在栈(Stack)上的特性大相径庭的是,C#的数组,是必须new出来放在堆(heap)上的,于是我们能看到,遍历USBHandle、尝试回读EVBSN并判断SN合法性及反推USBHandl有效性的代码如下:
// 遍历0~15所有可能的CH341设备索引,回读EVBSN并判断合法性
for (int index = 0; index < 16; index++)
{
// 尝试打开设备,验证索引有效性
IntPtr deviceHandle = DLLProxy.CH341OpenDevice((uint)index);
if ((deviceHandle != IntPtr.Zero) && (deviceHandle != new IntPtr(-1)))
{
//获取EVBSN
StringBuilder EVBSN = new StringBuilder(17);
int error = DLLProxy.OSAEVB_GetSN_DLL(index, EVBSN);
string snText = EVBSN.ToString(); // 转成string
//判断获取到的EVBSN是否合法
bool isMatch = snText.StartsWith("OLOSAEVB");
if (isMatch == true) //合法就添加信息到combobox列表中去
{
cmbUSBList.Items.Add($"0x{index:X2}"); // 添加索引到cmbUSBList
cmbEVBList.Items.Add(snText); // 添加序列号到cmbEVBList
}
}
}
既然刚才提到了在C#中非常好用的combobox控件,它即可以支持键盘输入也可以从下拉列表中选择已有条目。ComboBox 有两种工作模式,这决定了用户怎么取操作它。默认属性是DropDownStyle = ComboBoxStyle.DropDown,即可以点选,也可以键盘输入新内容,下拉框要点一下才呈现出下拉菜单。跟它相似的还有DropDownStyle = ComboBoxStyle.Simple,但输入框和列表框是固定呈现的。另外一种是属性DropDownStyle = ComboBoxStyle.DropDownList,即只能点下拉框选,不能键盘输入。
向 ComboBox 添加数据,有两种模式,一种是只添加key,每一项的索引值是从0开始顺序递增的,比如:
cmbEVBList.Items.Add("SN001");
cmbEVBList.Items.Add("SN002");
// 手动选中第一项,或者键盘输入一个字符串
cmbEVBList.SelectedIndex = 0;
// cmbEVBList.Text是combobox控件当前显示的字符串,即使为空,也是""空字符串,不会是null
if (!string.IsNullOrEmpty(cmbEVBList.Text))
string text = cmbEVBList.Text;
还有一种是添加键值对keyvaluepair,value就可以绑定到key,而且显示出来就是"key : value"这种集合的样子。读取键或值之前,需要先拿到已经存在的键值对对象的引用,后续才好分离key和value,并且每一项的依然是从0开始顺序递增的。
// 1. 添加键值对的项
cmbEVBList.Items.Add(new KeyValuePair<string, int>("APD", 1));
cmbEVBList.Items.Add(new KeyValuePair<string, int>("PIN", 2));
// 2. 手动选中第一项
cmbEVBList.SelectedIndex = 0; // 或者
cmbEVBList.SelectedItem = cmbEVBList.Items0;
// 现在可以安全读取
if (cmbEVBList.SelectedItem != null)
{
// pair是已经存在的键值对对象的引用
var pair = (KeyValuePair<string, int>)cmbEVBList.SelectedItem;
string key = pair.Key; // 取出key="APD"
int value = pair.Value; // 去除value=1
}
combobox操作大全
操作目的 代码
清空列表 cmbPNList.Items.Clear();
添加一项 cmbPNList.Items.Add("ABC");
删除选中项 cmbPNList.Items.Remove(cmbPNList.SelectedItem);
删除指定索引项 cmbPNList.Items.RemoveAt(0);
获取总项数 int count = cmbPNList.Items.Count;
查找文本(模糊) int idx = cmbPNList.FindString("APD");
查找文本(精确) int idx = cmbPNList.FindStringExact("APD");
默认选中第一项 cmbPNList.SelectedIndex = 0;
选中最后一项 cmbPNList.SelectedIndex = cmbPNList.Items.Count - 1;
取消选中(清空选择) cmbPNList.SelectedIndex = -1;
当用户从combobox的下拉菜单中选中某一项,或者代码清空列表Items.Clear()时electedIndex会变成-1,就会触发SelectedIndexChanged事件;
当用户输入的内容的前半截,是列表中已经有的,系统会自动补全,用户一回车就会触发SelectedIndexChanged事件;
当用户输入的内容是新内容,一回车就触发KeyDown事件。不推荐用TextChanged事件,因为每输一个字母都会触发一次。
下面的例程,将在 SelectedIndexChanged 和 KeyDown 里同时加日志,而且在 KeyDown 里还能根据 SelectedIndex 判断用户是"选择了已有项"还是"输入了新内容",并执行对应的业务逻辑。
private void cmbPNList_SelectedIndexChanged(object sender, EventArgs e)
{
AppendLog($"【SelectedIndexChanged】索引:{cmbPNList.SelectedIndex},文本:{cmbPNList.Text}");
}
private void cmbPNList_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter)
{
e.SuppressKeyPress = true;
AppendLog($"【KeyDown】回车触发,文本:{cmbPNList.Text}");
// 根据 SelectedIndex 判断
if (cmbPNList.SelectedIndex == -1)
{
// 新内容:处理新增逻辑
AppendLog($"✅ 检测到新内容:{cmbPNList.Text}");
}
else
{
// 已有项:处理选择逻辑
var item = (KeyValuePair<string, int>)cmbPNList.SelectedItem;
AppendLog($"✅ 选中已有项:{item.Key}");
}
}
}
如果函数的某个输出参数,后面用不到,被调用时就可以用"out _"这样的丢弃符做替代去占位,就不用再定义变量名去填充到函数参数表中了,很是方便。
全文原本是:
float EVB_Temperature = 0, EVB_3V3 = 0;
int error = DLLProxy.OSAEVB_SEARCH_DLL(cmbEVBList.Text, out EVB_USBHandle,
out EVB_Temperature, out EVB_3V3);
用了丢弃符是:
int error = DLLProxy.OSAEVB_SEARCH_DLL(cmbEVBList.Text, out EVB_USBHandle,
out _, out _); //用_做丢弃符
4, 为访问数据库的SQLite新增接口类
为了方便管理SQLite数据库的读写,还有数据跟面板控件的交互,需要新开一个mySQLite.cs文件,新建一个ConfigData类,对应数据库config表单即配置数据,和TestData类,对应数据库result表单即测试数据。
using System;
using Microsoft.Data.Sqlite;
namespace QOSAEVB // 注意:这里的命名空间要和你的 Form1.cs 里一样
{
public class ConfigData
{
//这里填写属性和方法
}
public class TestData
{
//这里填写属性和方法
}
}
注意,在C++中,成员变量是类的属性(property),成员函数是类的方法(Method)。但是在C#中,成员变量是类的字段(feild),成员变量+{ get; set; }才是类的属性(property)。而且微软、.NET 官方文档、所有最佳实践都推荐:公开的数据成员用属性,不要用字段;这是因为,在C#里,属性(Property)是一种专门设计的、用来替代"裸字段(Public Field)"的更安全、更灵活的编码模式。属性不是一个简单的数据盒子,而是一个可以装"额外行为"的数据盒子,比如在赋值或读取时,可以加逻辑顺便做点别的事;而字段就缺乏这种灵活性了。
所以我们在C#中定义数据库config表对应字段的成员变量时,就应该在成员变量名后面加上{ get; set; }从而构成C#眼中的属性。这之后,编译器会自动生成一个隐藏的私有字段,并实现标准的 get/set 访问器。这行代码既保留了字段的"数据存储"功能,又具备了属性(Property)的"封装控制"能力,也即所谓的自动属性 (Auto-Implemented Properties)
public class ConfigData
{
// ========== 字符串字段 ==========
public string UserName { get; set; } = "";
}
注意,Microsoft.Data.Sqlite 在多线程环境下,同一个连接不能同时被多个线程使用。建议所有数据库操作通过静态方法统一管理,内部加锁。
// 定义一个类级别的唯一锁
private static readonly object _dbLock = new object();
private bool SaveConfigToDatabase()
{
lock (_dbLock) // 获取数据库锁(阻塞等待,直到别人释放)
{
// ... 数据库操作 ...
} // 编译器生成 finally,自动释放锁
}
早就耳闻著名的"SQL注入风险",所以各大AI都不推荐用sprintf()来构造SQL指令。这里举个栗子,用户登录时程序总是要拿账号名去数据库取出对应的密钥,来和用户界面输入的密码生成的密钥做对比。这里,取数据库密钥,就一般会用到一条select指令:
//从界面账号控件读取用户输入的账号名
string account = cmbAccount.Text;
//"注入式"构造sql指令,搜索账号名对应的保存在数据库中的密钥
string sql = $"SELECT passwordStr FROM users WHERE Account = '{account}' ;";
但如果黑客往界面的账号控件cmbAccount输入了 "SB'; DROP TABLE users; --",那么最终这条指令就变成了:
sql = "SELECT passwordStr FROM uses WHERE Account = 'SB'; DROP TABLE users; --' ;"
在SQL Server看来,就是下图这样的正常语句,因为SQL Server默认支持通过分号执行多条语句,那就真的能删库跑路!但是像SQLite或mySQL数据库,大都默认不支持一次执行多条语句,还能苟住。
故一般推荐采用参数化查询(Parameterized Query),这是最有效的防SQL注入风险的手段。先定义sql字符串,用了@account 作为参数占位符:
string sql = "SELECT passwordStr FROM users WHERE Account = @account";
然后,绑定参数值到占位符
cmd.Parameters.AddWithValue("@account", account);
这样,即使用户在界面控件输入了"SB'; DROP TABLE users; --",都只会被当作纯数据处理,不会被当成有效的SQL指令被SQL Server执行。C#参数化查询SQLite数据库的这部分代码如下:
using System.Data.SQLite;
string account = cmbAccount.Text;
string sql = "SELECT passwordStr FROM users WHERE Account = @account";
using (SqliteConnection conn = new SqliteConnection("Data Source=mydb.db"))
{
conn.Open();
using (SQLiteCommand cmd = new SQLiteCommand(sql, conn))
{
// 添加参数
cmd.Parameters.AddWithValue("@account", account);
using (SQLiteDataReader reader = cmd.ExecuteReader())
{
if (reader.Read())
{
string passwordStr = reader.GetString(0);
// 验证密码...
}
}
}
}
但参数化查询,会有个问题,就是SQL指令如果构造错了,程序员因为看不到拼接出来的SQL指令字符串,无法拿到navicat中去验证,会不知道哪里错了。所以,这里AI给了一个方法,string fullSql = GetFullSql(),让fullSql变量在 try块内被赋值,一旦 ExecuteNonQuery() 抛出异常,在catch中将fullSql打印出来用于调试。
我们来看一个保存测试配置数据到数据库的示例代码。这里,GetFullSql()必须在执行sql指令之前被执行,否则如果放在执行sql指令之后而执行sql指令时发生异常,就会跳至catch块而无法调用该函数了。还有,如果想在VS处于debug状态下,拷贝到sql指令,可以调用System.Diagnostics.Debug.WriteLine(fullSql),就能在输出窗口看到打印信息了。
public static int SaveConfig(string _connectionString, ConfigData DUTCfgData, out string message)
{
message = "";
if (string.IsNullOrEmpty(_connectionString))
{
message = "❌ 连接字符串为空";
return -1;
}
lock (_dbLock)//尝试拿锁并独占
{
string fullSql="";
try
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();//连接数据库
using (var transaction = conn.BeginTransaction())
{
using (var cmd = DUTCfgData.BuildInsertCommand(conn))//构造sql指令
{
cmd.Transaction = transaction;//开始事务
#if DEBUG // 这段代码只在 Debug 模式下编译
fullSql = GetFullSql(cmd); //获取sql字符串
#endif
cmd.ExecuteNonQuery(); //执行sql指令
}
transaction.Commit(); //提交事务
}
message = "✅ 数据库配置保存成功";
return 0;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(fullSql); // 输出到输出窗口
message = ex.Message;
return -2;
}
}//释放锁
}
以上代码,只有VS运行在debug模式下(注意#if DEBUG这段话),执行sql指令时报异常,才会进入catch块把sql指令打印到输出窗口。如果想在sql指令没报异常时也能拷贝到sql指令,需要这样操作:首先在VS处于debug模式下,断点打在fullSql = GetFullSql(cmd)之后。当运行完这句话之后停下来,菜单"调试 → 窗口 → 即时",呼出即时窗口,在即时窗口中输入"System.Diagnostics.Debug.WriteLine(fullSql)"代码,注意不要有结尾的分号,回车之后,就能看到打印出来的sql指令了:
注意这里数据库连接字符串DB_CONNECTION_STRING,是在窗体构造函数中一次性构造好了的:
public Form1()
{
// 组装SQLite数据库文件完整路径,在构造函数中可以对readonly string赋值
DB_FILE_PATH = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, DB_FILE_NAME);
// 构造SQLite连接字符串,在构造函数中可以对readonly string赋值
DB_CONNECTION_STRING = $"Data Source={DB_FILE_PATH};";
InitializeComponent();
InitializeCustomComponents();
}
这里,还用到了SQL的事务,事务是一组数据库操作的"打包",要么全部成功,要么全部失败,不会出现"一半成功一半失败"的情况。就像银行转账:从A账户扣钱(操作1),往B账户加钱(操作2)。这两个操作必须同时成功或同时失败。
using var conn = new SqliteConnection(DB_CONNECTION_STRING);
conn.Open();
// ⭐ 1. 开始事务
using (var transaction = conn.BeginTransaction())
{
// ⭐ 2. 创建命令,关联事务
using (cmd = DUTTstData.BuildInsertCommand(conn))
{
cmd.Transaction = transaction; // ← 关键!命令必须绑定事务
fullSql = GetFullSql(cmd);
cmd.ExecuteNonQuery(); // ← 执行插入
}
// ⭐ 3. 提交事务(所有操作成功)
transaction.Commit();
}
那如果不幸出异常了呢?C#不需要像CVI那样手动回滚Rollback,因为C#中using了Microsoft.Data.Sqlite的,SqliteTransaction的 Dispose()方法会检查事务是否已提交,如果未提交则执行 Rollback()。所以即使C#中未显式调用 Rollback(),using块退出时也会自动回滚,然后在catch块打印完整的sql指令:
try
{
using (var transaction = conn.BeginTransaction())
{
using (cmd = DUTTstData.BuildInsertCommand(conn))
{
cmd.Transaction = transaction;
fullSql = GetFullSql(cmd); //获取完整sql指令
cmd.ExecuteNonQuery(); // ← 如果这里出错
}
transaction.Commit(); // ← 这行不会执行
} // ← using 块检测到未提交,自动 Rollback ✅
catch (Exception ex)
{
if (!string.IsNullOrEmpty(fullSql))
AppendLog($"SQL: {fullSql}"); //打印fullSql
}
至此,框架文件全部就位。再细心打磨打磨,就成功移植CVI2010+MySQL+CH341DLL到C#+SQLite+CH341DLLA64了。
如下是V1.0.0.0运行起来的软件界面:
以下是Deepseek给的Program.cs结构树:
Program.cs
│
└── Program (静态类 - 应用程序入口)
│
├── DllImport("user32.dll") SetProcessDPIAware() - 禁用自动缩放
│
└── Main() (应用程序主入口)
├── SetProcessDPIAware() - 关闭系统DPI自动缩放
├── Application.EnableVisualStyles() - 启用视觉样式
├── Application.SetCompatibleTextRenderingDefault(false) - 文本渲染兼容性
└── Application.Run(new Form1()) - 启动主窗体 (消息循环)
以下是Deepseek给的Form1.cs构造树:
Form1.cs
│
├── 字段
│ ├── DUTCfgData (ConfigData) - 配置数据对象
│ ├── DUTTstData (ResultData) - 测试数据对象
│ ├── DB_FILE_PATH / DB_CONNECTION_STRING - 数据库路径和连接串(仅UI层配置用)
│ └── EVB_USBHandle / _isConnected - USB句柄和连接状态
│
├── 构造函数
│ └── Form1() → 初始化数据库路 → InitializeComponent() → InitializeCustomComponents()
│
├── 生命周期
│ └── Form1_FormClosing() - 关闭定时器、释放CH341、释放components
│
├── UI辅助 (线程安全)
│ ├── AppendLog() - 追加日志(带时间戳)
│ ├── SetLedStatus(LedState) - 更新LED状态(Off/On/Busy)
│ └── ResetAllLeds() - 重置所有LED为灰色
│
├── 枚举 (LED状态)
│ └── LedState { Off, On, Busy }
│
├── 硬件操作 (CH341)
│ ├── on_Search_QOSAEVB() - 搜索EVB设备
│ ├── on_ConnectEVB() - 连接EVB(含句柄校验)
│ ├── on_setIbias() - 设置4路偏置电流
│ ├── on_SetVapd() - 设置4路APD电压
│ └── on_ScanVbr1minus3V() - 扫描雪崩电压
│
├── 定时器操作
│ ├── on_CyclingRead() - 启动/停止循环读取
│ └── on_TMR_CyclingRead() - Tick事件(async,DLL读取放在后台线程)
│
├── 数据库操作 (全部委托给DataService)
│ ├── on_sqlite_connect() - 连接按钮 → DataService.TestConnection() + LoadPNList()
│ ├── on_SaveCfg2SQL() - 保存配置按钮 → DataService.SaveConfig()
│ ├── on_SaveDat2SQL() - 保存数据按钮 → DataService.SaveResult()
│ ├── cmbPNList_SelectedIndexChanged() - PN选择 → DataService.LoadConfigbyPN()
│ └── UpdatePNListUI() - 更新PN下拉列表(UI层纯显示)
│
└── UI事件 (其他)
├── on_PN() - PN选择(调用上面的事件)
├── on_DUTType() - APD/PIN切换
├── cmbEVBList_SelectedIndexChanged() - EVB切换(释放旧连接)
└── on_btnConfig_Click() - 一键配置(使能 + Ibias + Vapd)
以下是Deepseek给的mySQLite.cs构造树:
mySQLite.cs
│
├── DataService (静态类 - 数据库底层操作)
│ ├── _dbLock - 唯一锁
│ ├── TestConnection() - 测试连接
│ ├── LoadPNList() - 加载PN列表
│ ├── LoadConfigbyPN() - 根据PN加载配置
│ ├── SaveConfig() - 保存配置 (含调试SQL)
│ ├── SaveResult() - 保存结果 (含调试SQL)
│ └── GetFullSql() - 调试工具:生成完整SQL
│
├── ConfigData (数据模型 - 配置表)
│ ├── 属性: UserName, PN, WS, EVB_SN, DUTType, Ibias\[\], Vapd\[\], 等
│ ├── LoadFromUI() - 界面 → 对象
│ ├── ApplyToUI() - 对象 → 界面
│ ├── BuildInsertCommand() - 生成 INSERT 命令
│ └── LoadFromDatabase() - 数据库 → 对象 (带锁)
│
└── ResultData (数据模型 - 结果表)
├── 属性: QOSA_SN, PN, WS, WO, Ibias\[\], Vapd\[\], BIASDAC\[\], Vbr\[\], 等
├── LoadFromUI() - 界面 → 对象
├── ApplyToUI() - 对象 → 界面
└── BuildInsertCommand() - 生成 INSERT 命令
以下是Deepseek给的DLLProxy.cs构造树:
DLLProxy.cs
│
├── CH341DLLA64.dll (USB通信底层)
│ ├── CH341GetVersion() - 获取CH341版本号
│ ├── CH341OpenDevice() - 打开CH341设备 (返回句柄)
│ ├── CH341CloseDevice() - 关闭CH341设备
│ ├── CH341WriteData() - 写数据到CH341
│ ├── CH341WriteRead() - 写+读 (复合操作)
│ ├── CH341StreamI2C() - I2C流式传输
│ └── CH341SetStream() - 设置流模式
│
└── 40GPOWER_A64_DLL.dll (EVB业务层)
├── OSAEVB_GetSN_DLL() - 读取EVB序列号
├── OSAEVB_SEARCH_DLL() - 搜索并连接EVB (返回句柄+温度+电压)
├── OSAEVB_ENABLE_DLL() - 使能/禁止 TX/RX 输出
├── OSAEVB_Set4Ibias_DLL() - 设置4路偏置电流
├── OSAEVB_Set4Vapd_DLL() - 设置4路APD电压
├── OSAEVB_ReadTX_DLL() - 回读发端状态 (BIASDAC/ADC/VF/阻抗)
├── OSAEVB_ReadRX_DLL() - 回没收端状态 (APDDAC/RSSI)
└── OSAEVB_Search4VbrSetVbrMinus3V_DLL() - 搜索雪崩电压并设置Vbr-3V
附件是C#源码: