欲看图文版pdf,请用PC浏览器下载附件。欲索取工程源码,请电邮14518918@qq.com。
一、环境建立
VS 2026社区版官方下载链接:https://visualstudio.microsoft.com/zh-hans/downloads/,浏览器在后台将自动下载在线安装包VisualStudioSetup.exe,双击它,开始安装。在"工作负载"页面,勾选 ".NET 桌面开发",它包含了C#开发所需的全部核心组件(如.NET SDK、WinForms、WPF 等)。如果想改变安装盘位,请点击位置的"更改...",切换到你要的工作盘。
然后等待安装完成。
二、尝试简单应用
启动后,用微软账号登录。完了创建一个新项目。
选C#的控制台应用。
然后得到一个纯净的控制台工程。
菜单"调试---开始执行",弹出控制台窗口(Console Window),也常被称为 "命令提示符窗口" 或 "CMD 窗口",上书著名的hello world
创建一个C#的.net(不是.net Framework)窗体工程
跑起来是一个空窗。
菜单"试图---工具箱",呼出控件栏,拖拽一个Button到面板。修改其控件名为BUT_Clickme,修改其Text为"点击我试试",并适当加宽不然"点击我试试"显示不全。
切换到闪电页,修改其事件处理方法名为"on_BUT_ClickMe"。
在面板上双击BUT_Clickme按键,将自动跳转到Form1.cs的该按键的事件处理方法,填入代码,
// 1. 修改按钮本身的文字(演示控件属性修改)
But_ClickMe.Text = "我被点击啦!";
// 2. 弹出提示框(最简单的交互)
MessageBox.Show("你点击了按钮!🎉", "提示",
MessageBoxButtons.OK, MessageBoxIcon.Information);
再运行,点击按钮,就变成这样:
C#确实容易上手,比Qt容易多了。在VS IDE中,控件及其事件处理方法,可以像在CVI IDE中的那样,控件和回调函数可直接相互跳转,如下图所示。但Qt就必须手写一些代码,才能把控件所触发的信号,与其槽函数,connect起来,而且还无法实现控件到槽函数的双向跳转。
三、挑战C#的DataGripView表格
关于表格,我的感受是,CVI的表格,最简单。Qt稍难一点,但还好没有那么多弯弯绕绕。C#的表格,为了一个行头(就是每一行的序号0...7),我折腾了几个小时,最后是把行头隐藏起来,用数据区的第一列(添加属性不可编辑)做行头,才好了的。如果采用原生行头,就要了老命了,它会随着光标激活某一行单元格,而消失。如果要原生行头保持显示,就必须写回调函数来高刷行头而很是占用CPU资源,于是你会看到你仅仅改变了某一个单元格数值,行头从0~7都刷新了一遍,就很是反直觉。这是C#的WinForms DataGridView 的经典坑,原因在于:
- 原生行头的渲染逻辑:DataGridView 的 RowHeader 不是独立控件,而是和单元格共享渲染上下文,当单元格获得焦点 / 数值变化时,控件会触发局部重绘,若行头的绘制逻辑未做缓存 / 判断,就会被 "覆盖 / 清空";
- 刷新机制反直觉:默认情况下,行头属于行级元素,单个单元格数值变化可能触发整行的RowPostPaint事件,若在该事件中重绘行头,就会出现 "改一个单元格,所有行头都刷新" 的现象,这是控件设计层面的历史遗留问题;
- 新手友好性差:WinForms 为了 "易用性" 做了大量封装,但定制化时会暴露底层的耦合问题,原生行头的定制需要理解RowPostPaint/CellPainting等事件的触发时机,对新手极不友好。
所以我在折腾了几个小时接近放弃的时候,终于在豆包推荐下选择了 "隐藏原生行头 + 用第一列做只读行头"的替代方案,豆包说这是 C# WinForms 里解决行头问题的最优实践------ 既避开了原生行头的渲染坑,又能完全掌控行头的显示 / 交互逻辑,这也是绝大多数 WinForms 开发者的选择。
喏,就是上图这个表格,我搞了十个小时。
第一个坑还不是行头,而是表格只能在控件编辑器中新增和编辑列,不能新增行,新增行要写代码。第二个坑是新增完行之后,表格底部有一条挥之不去的灰色横幅,很是扎眼,据说是横向滚动条,所以得精确计算表格得纵向和横向像素值并填入控件编辑器中,以免太短而出现滚动条。第三个坑才是来有踪去无影需要频繁刷新才能保持原样的原生行头,说多了都是泪。
C#中创建一个表格,是先拖一个dataGridView1控件到面板,控件名就用默认的dataGridView1,然后右键"编辑列",第一列不要列头HeaderText为空,因为这一列我们将用来替代行头,ReadOnly=True,列宽Width=20;然后添加第2列到第17列,都是数据列,列头HeaderText从0~F,列宽Width=32太窄了放不下两个大写字母的16进制数。完了得到这样的空白表格,实际上都不能称之为表格,就一个表头而已。比较反直觉的是,该控件属性框里没有添加行的操作,必须自己写代码,就离谱。
在豆包的调教下,这是初始化8*17的表格的函数。要知道CVI和Qt根本不用写这么长的代码就能控件编辑器中搞定,但C#必须手写代码呢。
private void InitDataGridView()
{
// 禁止用户手动新增行(固定8行,避免多余空行)
dataGridView1.AllowUserToAddRows = false;
// 隐藏所有滚动条(无需滚动,适配精确宽高)
dataGridView1.ScrollBars = ScrollBars.None;
// 隐藏原生行头(改用第0列替代行头显示行号)
dataGridView1.RowHeadersVisible = false;
// 设置所有数据单元格内容水平+垂直居中显示
dataGridView1.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter;
// 强制关闭自动行高
dataGridView1.RowTemplate.Height = 20; // 每行固定20px,可根据需求调整
dataGridView1.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None;
// ========== 业务核心:初始化8行数据,用第0列替代行头 ==========
// 清空表格原有行数据(避免初始化时残留旧数据)
dataGridView1.Rows.Clear();
// 循环新增8行,并给第0列赋值行号(0~7)
for (int row = 0; row < 8; row++)
{
// 新增一行,返回该行的索引
int rowIndex = dataGridView1.Rows.Add();
// 给新增行的第0列(替代行头列)赋值当前行号(字符串格式)
dataGridView1.Rows[rowIndex].Cells[0].Value = row.ToString();
}
// 精准计算表格宽度(核心新增逻辑)
// 禁用自动列宽,避免冲突
dataGridView1.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None;
// 计算所有可见列的宽度总和
int totalColumnWidth = 0;
foreach (DataGridViewColumn col in dataGridView1.Columns)
{
if (col.Visible) totalColumnWidth += col.Width;
}
// 第三步:加边框补偿(2px),设置精准宽度
int exactWidth = totalColumnWidth + 2; // 2px边框补偿
dataGridView1.Width = exactWidth;
// 核心修复:计算表格高度时加8px余量(避免编辑最后一行的单元格时最后一行整体上抬看不到第一行)
int baseHeight = dataGridView1.ColumnHeadersHeight +
(dataGridView1.RowCount * dataGridView1.RowTemplate.Height) + 2;
dataGridView1.Height = baseHeight + 2; // 2px余量足够抵消所有编辑偏移
// 设置当前激活单元格为:第0行、第1列(跳过替代行头的第0列)
dataGridView1.CurrentCell = dataGridView1.Rows[0].Cells[1];
}
我们约定,当用户改变单元格值,程序将自动把新单元格值写入I2C从机寄存器,然后等待T_Wait秒,等I2C从机把数据可靠写入FLASH。那么,就需要定义一个事件处理方法来承载,请在CellEndEdit属性后面填入函数名DataGridView1_CellEndEdit。注意这里填了函数名,就不用手动在public Form1()的构造函数里面,再用dataGridView1.CellEndEdit += DataGridView1_CellEndEdit来手工绑定该单元格编辑结束事件了,否则会造成该函数的重复调用。
如下是用户改变单元格值后触发的函数:
// 表格的单元格编辑结束(回车/失去焦点)触发事件
private void DataGridView1_CellEndEdit(object sender, DataGridViewCellEventArgs e)
{
// 1. 过滤行头/列头/无效单元格
if (e.RowIndex < 0 || e.ColumnIndex < 0) return;
// 2. 获取当前编辑的单元格
DataGridViewCell cell = dataGridView1.Rows[e.RowIndex].Cells[e.ColumnIndex];
// 3. 获取用户输入的原始值
string hexStr = cell.Value?.ToString() ?? "";
// 4. 校验并格式化16进制(无0x前缀,非法则返回00)
;
// 5. 自动修正单元格值(合法则大写黑色,非法则改为00红色)
bool isValid = CheckByteHexStr(hexStr, out byte validHex);
cell.Value = isValid ? hexStr.Trim().ToUpper() : "00";
cell.Style.ForeColor = isValid ? Color.Black : Color.Red;
// 1. 解析选中的CH341索引(从CMB_USBHost获取)
if (!int.TryParse(CMB_USBHost.SelectedItem?.ToString().Replace("0x", ""),
System.Globalization.NumberStyles.HexNumber, null, out int CH341Index))
{
MessageBox.Show("请先选择有效的CH341设备索引", "提示");
BUT_SearchI2CSlave.Enabled = true;
return;
}
// 2. 解析选中的I2C从机地址(从CMB_I2CSlave获取)
if (!int.TryParse(CMB_I2CSlave.SelectedItem?.ToString().Replace("0x", ""),
System.Globalization.NumberStyles.HexNumber, null, out int I2CSlaveAdd))
{
MessageBox.Show("请先选择有效的I2C从机地址", "提示");
BUT_SearchI2CSlave.Enabled = true;
return;
}
// 3. 解析单元格值并写入
byte[] romValueArr = new byte[300];
int RegAdd = e.RowIndex * 16 + e.ColumnIndex - 1;
romValueArr[RegAdd + 0] = validHex;
float T_wait = float.Parse(TXB_Twait.Text.Trim());
int error = I2cRandomWrite(CH341Index, I2CSlaveAdd, RegAdd, 1, romValueArr, T_wait);
}
三、调用CH341DLLA64.dll实现I2C访问
C#调用DLL,采用一种叫做 P/Invoke (Platform Invocation Services) 的机制,有点类似于动态调用,不用lib,但需要手动声明 DLL 函数的签名(Signature),包括函数名、返回值类型、参数列表及其类型。
首先用户需要添加 DLL 文件到项目,请将 CH341DLLA64.dll 文件复制到项目根目录(与 .csproj 同级)
在VS2026右侧的解决方案资源管理器 中,右键项目 → "添加" → "现有项",选择 CH341DLLA64.dll,然后选中该文件 → 在属性面板中设置:"生成操作" = 内容,"复制到输出目录" = 如果较新则复制。完了等项目编译后,该DLL 会自动出现在 bin子目录下,这样编译生成的exe程序才能找到该DLL。
做如下申明的DLL函数,就基本上可以应付常规I2C的随机读写操作了。
//动态调用DLL函数声明
DllImport("CH341DLLA64.dll", CallingConvention = CallingConvention.StdCall)
private static extern ulong CH341GetVersion();
DllImport("CH341DLLA64.dll", CallingConvention = CallingConvention.StdCall)
private static extern IntPtr CH341OpenDevice(uint iIndex);
DllImport("CH341DLLA64.dll", CallingConvention = CallingConvention.StdCall)
private static extern void CH341CloseDevice(uint iIndex);
DllImport("CH341DLLA64.dll", CallingConvention = CallingConvention.StdCall)
private static extern bool CH341WriteData(uint iIndex, byte[] pBuffer, ref uint ioLength);
DllImport("CH341DLLA64.dll", CallingConvention = CallingConvention.StdCall)
private static extern bool CH341WriteRead(
uint iIndex, // CH341设备索引
uint iWriteLength, // 要写入的字节数
byte[] pWriteBuffer, // 写入缓冲区
uint iReadLength, // 最大读取字节数
uint iReadLength2, // 实际读取长度(和Qt中的iReadLength对应)
ref uint oReadLength, // 输出:实际读取的字节数
byte[] pReadBuffer // 读取缓冲区
);
DllImport("CH341DLLA64.dll", CallingConvention = CallingConvention.Cdecl)
private static extern int CH341StreamI2C(
int iIndex, // 设备索引(对应m_deviceIndex)
int iWriteLength, // 写入长度
byte[] pWriteBuffer, // 写入缓冲区
int iReadLength, // 读取长度
byte[] pReadBuffer // 读取缓冲区
);
调用DLL函数非常简单,就像在CVI里面静态调用DLL函数一样,以CH341OpenDevice()为例:
IntPtr deviceHandle = CH341OpenDevice(index);
if ((deviceHandle != IntPtr.Zero) && (deviceHandle != new IntPtr(-1)))
{
// 打开成功:添加索引到ComboBox
CMB_USBHost.Items.Add($"0x{index:X2}");
validDeviceCount++;
// 关闭句柄,释放资源
//CH341CloseDevice(index);
}
注意,我们约定,从0~15的Index都会去尝试打开CH341设备,而且重复打开CH341设备并不会有什么不良影响。而且打开之后不再立即关闭,关闭CH341设备的代码,是放在面板的析构函数中的:
//面板关闭前执行善后工作,关闭已打开的CH341设备
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
// 核心:关闭CH341句柄
for (uint index = 0; index < mCH341_MAX_NUMBER; index++)
{
CH341CloseDevice(index);
}
}
所以,还需要绑定面板的FormClosing事件对应的方法Form1_FromClosing,才能在关闭面板时关闭已经打开的CH341设备。
完了整一些常规的Button、Lable、TextBox、ComboBox等控件到面板上,就可以开始发起对I2C从机的访问了:
比如:
0,回读CH341DLLA64.dll版本号
1,搜索USB主机:
2,搜索I2C从机
3,读从机的[00...7F]寄存器并显示到表格
4,I2C随机写4Byte
注意,界面上左下角有个T_wait(s)参数,单位秒,是主机发起I2C写时序之后的贤者时间,本质上是等待I2C从机把收到的数据成功存入FLASH的非请勿扰时间。
5,I2C随机读4Byte
Tips
1,在Debug模式下,观察一个数组变量的值,需要右键点击数组名,点击"查看",选择IEnumerable Visualizer,默认是10进制的。如果先看16进制数值,需要编辑表达式:
readBuf.Select(b => b.ToString("X2")),再刷新,显示的就是补齐了2个字符的16进制数值了。
工程源码在此: