3天基于VS2026的C#编程入门及动态调用CH341DLLA64读写I2C从机

欲看图文版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 的经典坑,原因在于:

  1. 原生行头的渲染逻辑:DataGridView 的 RowHeader 不是独立控件,而是和单元格共享渲染上下文,当单元格获得焦点 / 数值变化时,控件会触发局部重绘,若行头的绘制逻辑未做缓存 / 判断,就会被 "覆盖 / 清空";
  2. 刷新机制反直觉:默认情况下,行头属于行级元素,单个单元格数值变化可能触发整行的RowPostPaint事件,若在该事件中重绘行头,就会出现 "改一个单元格,所有行头都刷新" 的现象,这是控件设计层面的历史遗留问题;
  3. 新手友好性差: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进制数值了。

工程源码在此:

相关推荐
Tony Bai2 小时前
Go 1.26 :go mod init 默认行为的变化与 Go 版本管理的哲学思辨
开发语言·后端·golang
xyq20242 小时前
WebForms SortedList 深度解析
开发语言
Hx_Ma162 小时前
测试题(三)
java·开发语言·后端
CHANG_THE_WORLD3 小时前
深入理解C语言指针:从源码到汇编的彻底剖析
c语言·开发语言·汇编
星火开发设计3 小时前
序列式容器:deque 双端队列的适用场景
java·开发语言·jvm·c++·知识
码农葫芦侠3 小时前
Rust学习教程2:基本语法
开发语言·学习·rust
键盘鼓手苏苏4 小时前
Flutter for OpenHarmony 实战:Envied — 环境变量与私钥安全守护者
开发语言·安全·flutter·华为·rust·harmonyos
特种加菲猫4 小时前
C++核心语法入门:从命名空间到nullptr的全面解析
开发语言·c++
坚持就完事了4 小时前
Java泛型
java·开发语言