最近整理了手头两年多的 C# 代码和排错记录,整理完回头看,发现一个规律:真正让我卡壳的往往不是某个高深框架,而是像数组和集合到底怎么选、字符串中间空格怎么去、不带 BOM 的文本怎么识别编码这类基础坑,以及上位机通信里的 OPC UA 证书、TCP 多客户端管理、Modbus 读写这种实战细节。这些问题的搜索热度一直很高,说明大家踩的坑都差不多。这份笔记我就按"基础语法高频疑问、工业通信实战、数据访问、桌面与 Web 交叉、工程化工具链、图像与 CAD 处理"这条线来整理,把我实际运行验证过的代码和结论写出来,给正在学 C# 或者做 C# 上位机/后端开发的朋友做个参考。
1. 这份笔记的由来:C# 开发者到底在搜什么
先说这份笔记的定位。它不是教科书式的语法大全,更像是一份"问题答案合集"。我在整理过程中去翻了一下搜索记录和社区高频问题,发现 C# 开发者的日常大概集中在三条线:传统 Web 后端(ASP.NET Core、Web API、ORM)、桌面与工具类应用(WPF、WinForms、CEFSharp)、工业自动化上位机(串口、CAN、Modbus、OPC UA、TCP)。
这三条线看起来差异很大,但底层都是同一套 C# 语言能力。很多人学了基础语法之后直接跳到框架,结果遇到具体问题时才发现基础理解有漏洞。比如数组和集合的使用区别,比如委托和事件到底什么时候该用哪个,比如为什么 RestClient 调接口会报"远程主机强迫关闭了一个现有的连接"。这些问题在热搜里反复出现,说明它们确实是一线开发者的真实痛点。
我写这份笔记还有一个私心:把散落在各个项目里的代码片段统一做一次沉淀。很多代码当时是通过搜索拼出来的,能用但不知道为什么能用。这次把所有关键点重新过了一遍,把"为什么这样写"也补全了。对于读者来说,这份笔记最大的价值不是某个代码段可以直接复制,而是你能顺着我的思路理解 C# 在真实项目里是怎么被用起来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础语法层的"一问就卡壳":数组、集合、字符串与委托
2.1 数组和集合的定义方式与选择逻辑
C# 里数组和集合是两套东西,但很多人一开始分不清。数组用 int[] arr = new int[10] 定义,长度固定,创建之后不能增删。集合里最常用的是 List<T>,用 List<int> list = new List<int>() 定义,可以动态添加和移除元素。这个"固定长度"和"动态长度"是两者最本质的差别。
实际项目里什么时候用数组、什么时候用集合,我的选择标准很简单:数据量在编译期就确定、后续不会增删的情况下用数组,比如读取固定长度的字节缓冲、颜色表的 RGB 值、一年 12 个月这种。凡是需要动态增减、需要 LINQ 查询、需要按条件过滤的,一律用 List<T>。数组的遍历性能比 List 略好,但这个差距在绝大多数业务场景里可以忽略,不需要为了性能强行用数组。
还有一个容易忽略的点:数组是协变的,string[] 可以赋值给 object[],这在某些场景下会带来隐蔽的运行时异常。集合里的泛型 List<T> 没有这个问题。如果你在做 API 设计,接收参数建议用 IEnumerable<T> 或 IReadOnlyList<T> 而不是具体实现,这样调用方传数组还是传 List 都不影响。另外,如果需要频繁在中间位置插入或删除元素,List<T> 的底层是数组,插入操作是 O(n) 的,数据量大时考虑 LinkedList<T>,但这种场景在实际业务里非常少见。
2.2 字符串截取与去空格:边界条件和特殊字符处理
字符串操作是 C# 里最容易被低估的领域。面试时问 Substring,很多人能写出来,但一到实际场景就出问题。
先看截取。Substring(startIndex) 和 Substring(startIndex, length) 是两种重载,难点在于边界控制。比如要从 "2024-05-18 14:30:00" 里取日期部分,直接 Substring(0, 10) 没问题。但如果字符串长度不固定,前面有可变前缀,直接写死索引就会崩。我的习惯是先判断 Length,或者用 IndexOf 找到分隔符的位置再截取:
csharp复制string raw = "prefix_2024-05-18_suffix";
int start = raw.IndexOf("_") + 1;
int end = raw.LastIndexOf("_");
string datePart = raw.Substring(start, end - start);
从右侧截取是另一个高频场景。C# 没有 VB 那种 Right() 函数,需要先算 Length - count 再作为起始位置。C# 8.0 以后可以用范围操作符,str[^5..] 表示取最后 5 个字符,简洁很多,但要注意 ^ 索引的语义是从 1 开始数的。
去空格也没有想象中简单。Trim() 只能去掉首尾空格,去掉字符串中间的所有空格要用 string.Concat(str.Where(c => !char.IsWhiteSpace(c))) 或者 str.Replace(" ", "")。但 Replace 只能处理半角空格,如果混入了全角空格、制表符、换行符,就要用 char.IsWhiteSpace 配合 Split/Join:
csharp复制string clean = string.Join("", raw.Split(default(string[]), StringSplitOptions.RemoveEmptyEntries));
还有一种很常见的情况:用户输入的电话号码、车牌号里混入了不可见字符(比如零宽空格),肉眼看不出来,但 Trim 去不掉。排查办法是把字符串的每个字符转成 Unicode 码点看一遍:
csharp复制foreach (char c in raw)
{
Console.WriteLine($"0x{(int)c:X4}");
}
能看到实际编码之后再去处理,就不会瞎猜了。
2.3 委托和事件:从语法到设计意图
委托和事件是 C# 里被问得最多、也最容易被搞混的一对概念。语法层面,委托就是一个方法类型,像 delegate void MyHandler(string msg) 定义了一个"接收 string 不返回值的方法类型"。有了这个类型,你可以把方法当作参数传递。现代 C# 开发里,自定义 delegate 用得很少,直接用 Action<T> 和 Func<T> 就够了:Action<int> 表示接收 int 不返回值,Func<int, string> 表示接收 int 返回 string。
有了委托,回调函数就好写了。比如一个上位机程序收到串口数据后要通知 UI 更新,最直接的做法就是传一个回调委托进去:
csharp复制public void ReadData(Action<string> onReceived)
{
string data = ReadFromPort();
onReceived?.Invoke(data);
}
事件和委托的关系是:事件是受限制的委托字段。event EventHandler DataReceived; 声明之后,类外部可以用 += 注册和 -= 注销,但不能直接 DataReceived?.Invoke(),只有声明它的类内部才能触发。这是刻意的设计:防止外部代码随意触发你的事件,也防止外部直接把它当普通字段覆盖。
实际项目里怎么选?只要涉及"某个事情发生了,需要通知多个订阅者",用事件。只是单个回调、一次性调用,用委托(Action/Func)就够。我自己踩过的坑是:事件订阅后忘记注销导致内存泄漏。比如 WPF 窗口订阅了某个静态服务的事件,窗口关闭了但事件还挂在服务上,服务持有窗口引用,GC 就无法回收窗口。保险做法是在 Closed 事件里把所有 -= 注销一遍,或者用弱事件模式。新手经常忽略这个,等内存一直涨才开始找问题。
2.4 文本编码识别:不带 BOM 的文件怎么判断
编码问题在工业数据采集场景里特别烦。很多老设备输出的文本文件没有 BOM,内容可能是 UTF-8,也可能是 GB2312/GBK,还可能是 ASCII 超集。直接按固定编码读,中文就会乱码。
判断思路分两步。第一步,检查文件头 BOM。UTF-8 BOM 是 EF BB BF,UTF-16 LE 是 FF FE,UTF-16 BE 是 FE FF,有 BOM 直接按对应编码读就行。第二步,对没有 BOM 的文件做启发式判断。最实用的方法是先用 Encoding.UTF8 的严格解码模式尝试读取,如果遇到非法字节就说明不是纯 UTF-8,再退回 GB2312:
csharp复制byte[] bytes = File.ReadAllBytes(path);
try
{
var utf8 = new UTF8Encoding(false, true);
string content = utf8.GetString(bytes);
// 没抛异常,大概率是 UTF-8
}
catch (DecoderFallbackException)
{
// 不是合法 UTF-8,按 GB2312 读
string content = Encoding.GetEncoding("GB2312").GetString(bytes);
}
这个方案能覆盖绝大多数情况。要注意的是 UTF8Encoding(false, true) 的两个参数:第一个 false 表示不输出 BOM,第二个 true 表示遇到非法字节抛异常。默认的 Encoding.UTF8 容错模式会把非法字节替换成 ?,无法用来做判断。
还有一个补充技巧:如果文件里既有中文又有 ASCII,但 UTF-8 严格解码成功了,也不能 100% 保证是 UTF-8,因为 GBK 编码的某些双字节序列恰好可能是合法的 UTF-8 序列。这时候可以统计解码后的"可读性"——中文字符占比高、控制字符少、替换符 \uFFFD 没有,就倾向 UTF-8。工程上这个启发式已经够用,不需要上机器学习。
3. 工业通信开发:串口、CAN、Modbus、OPC UA 到 TCP 的实战笔记
3.1 串口编程的线程模型和粘包处理
C# 串口编程的核心是 SerialPort 类。配置波特率、数据位、校验位、停止位之外,最容易出问题的是 DataReceived 事件的工作线程。这个事件在后台线程触发,事件里直接操作 UI 控件会抛跨线程异常。用 Invoke 或者 BeginInvoke 封送到 UI 线程是常规做法,但要注意别在事件里做耗时解析,否则会阻塞后续数据的读取。
csharp复制port.DataReceived += (s, e) =>
{
int n = port.BytesToRead;
byte[] buffer = new byte[n];
port.Read(buffer, 0, n);
// 用队列存数据,另一个线程负责解析
_queue.Enqueue(buffer);
};
串口通信的另一个经典问题是粘包和半包。设备发来的数据可能一次没读完,也可能一次发来好几帧。我的处理习惯是:所有收到的数据先进队列,解析线程按帧头、帧长、校验来拆帧。比如某个协议帧头是 0xAA 0x55,第 3 个字节是数据长度,那么解析时先找帧头,再判断缓冲区长度是否够一帧,不够就等下一包数据来再拼。缓冲区用 List<byte> 或 MemoryStream 维护,解析完了把已消费的部分移除。
3.2 CAN 通讯的基础套路
C# 做 CAN 通讯通常不直接操作硬件,而是通过厂商提供的 SDK,比如周立功的 ZLG CAN、创芯等,都是 C++ 动态库加 C# 封装的方式。给你一个通用的接入思路:打开设备、初始化通道、设置波特率、启动、接收/发送帧、关闭设备。这跟串口的流程本质一样,只是 API 不同。
做 CAN 通讯最容易忽略的是帧 ID 过滤和波特率匹配。设备上电后第一件事就是确认波特率和总线上的设备一致,否则根本收不到数据。还有 CAN 帧分标准帧(11 位 ID)和扩展帧(29 位 ID),收发双方的帧格式必须匹配。在 C# 代码里,我习惯把 CAN 设备封装成一个服务类,暴露 Open/Close/Send/Receive 接口,跟具体厂商 SDK 解耦,这样换硬件平台时只改底层实现,上层业务代码不用动。
如果手边没有真实硬件,可以用 PCAN 的虚拟总线或者 ZLG 的虚拟设备做调试,但要注意虚拟环境和真实总线的时序差异,波特率配置错误在虚拟环境里不一定能暴露出来。
3.3 EasyModbus 使用要点
EasyModbus 是一个轻量的 C# Modbus 库,支持 TCP、RTU、ASCII。用起来确实方便,几行代码就能读写寄存器:
csharp复制var client = new EasyModbus.ModbusClient("192.168.1.10", 502);
client.Connect();
int[] values = client.ReadHoldingRegisters(0, 10);
client.WriteSingleRegister(20, 1234);
client.Disconnect();
但有三个坑要注意。第一个坑是超时和重连。设备断电重启或者网线松动后,已建立的连接不会自动恢复,必须在调用读写前检查 Connected 状态,异常时手动重连。第二个坑是功能码的映射。ReadHoldingRegisters 对应功能码 0x03,ReadInputRegisters 对应 0x04,如果把设备文档里的寄存器地址当功能码用,读出来全是错值或直接超时。第三个坑是大小端。Modbus 寄存器是 16 位,32 位浮点数或整数跨两个寄存器时,高低字顺序由设备厂商决定,不同品牌可能相反,需要用 BitConverter 配合 Array.Reverse 调整。
我封装了一个统一的读写方法,内部处理重连和异常,业务层只传寄存器地址和数量,代码干净很多。
3.4 OPC UA 连接中的证书问题
OPC UA 比 Modbus 复杂得多,最大区别是安全机制。很多人在用 OPCFoundation 的 UA Helper 或 Opc.Ua.Client 连接服务器时,会碰到 ApplicationCertificate cannot be found 这个报错。
这个报错的根因是:OPC UA 客户端默认需要一张应用证书来建立安全通道,但 SDK 在默认路径下没找到证书。解决思路不是关掉安全,而是让客户端正确生成并信任证书。
我当时的处理分三步:
第一步,确保证书存储目录可写。SDK 默认会往 %CommonApplicationData%\OPC Foundation\CertificateStores\MachineDefault 写入证书,如果程序以服务方式运行,权限不足就会失败。
第二步,如果自动生成失败,手动预生成证书。可以用 OPC Foundation 提供的 CertificateGenerator 工具,生成 PFX 证书后放入 MachineDefault 对应的 private 目录,再导入公钥到 trusted 目录。
第三步,如果你只是本机开发调试,不打算上生产,可以先配置成 SecurityMode.None 跳过安全机制:
csharp复制var config = new ApplicationConfiguration();
config.SecurityConfiguration.ApplicationCertificate = new CertificateIdentifier();
// 开发环境临时使用
var endpoint = CoreClientUtils.SelectEndpoint(url, useSecurity: false);
但生产环境强烈不建议关闭安全,OPC UA 的价值就在于加密和证书体系。我踩过一次生产事故就是关了安全裸连,结果现场总线被一个误配置的节点干扰,排查了半天才发现是安全策略降级导致的问题。
3.5 TCPListener 多客户端接入管理
TCP 服务端比 TCP 客户端复杂在连接管理。TcpListener 只负责监听,AcceptTcpClientAsync 每接受一个客户端就创建一个会话。网上很多例子是单客户端,实际项目里经常要多台设备同时连服务器。
我的设计是按连接 ID 维护一个 ConcurrentDictionary<string, TcpClient>,每个客户端建一个接收循环。摘掉死连接的关键是:接收循环里读取返回 0 或者抛异常时,从字典里移除这个客户端并关闭资源。
csharp复制private async Task HandleClientAsync(string clientId, TcpClient client)
{
var buffer = new byte[4096];
try
{
while (client.Connected)
{
int n = await client.GetStream().ReadAsync(buffer, 0, buffer.Length);
if (n == 0) break; // 对端正常关闭
ProcessReceivedData(clientId, buffer, n);
}
}
catch (Exception ex)
{
// 对端异常断开
}
finally
{
_clients.TryRemove(clientId, out _);
client.Close();
}
}
还有一个容易忽略的点:TCP 本身没有"连接存活"的强保证。设备断电、网线断开时,两端不会立刻感知,服务端要定期做心跳检测,比如每 30 秒向客户端发一个心跳包,超时没回应就强制断开。很多"客户端还在连,但数据死活不更新"的问题,都是因为服务端没有积累死连接,还在往一个事实上已经不存在的 socket 上写数据。
4. 数据访问的常规操作:Dapper、多条 SQL 与 CSV 加密
4.1 Dapper 从入门到进阶的实际用法
Dapper 的定位是"轻量 ORM",由 Stack Overflow 团队开源,核心就一个 IDbConnection 的扩展方法。它不像 EF Core 那样有完整的实体跟踪,但胜在性能高、写法贴近 SQL。
基础用法就三种:Query<T> 查询、Execute 执行增删改、QuerySingle/QueryFirst 取单条。关键是参数化,不要用字符串拼接:
csharp复制var user = connection.QueryFirstOrDefault<User>(
"SELECT * FROM Users WHERE Id = @Id",
new { Id = 1001 });
new { Id = 1001 } 里的属性和 SQL 参数名对应,Dapper 会自动映射,既防 SQL 注入又免去逐个 AddWithValue 的繁琐。
进阶用法有几个点值得单独讲。
第一个是事务。多个写操作需要保证原子性时,用 connection.BeginTransaction(),执行完 Commit,异常时 Rollback。Dapper 的 Execute 方法有 transaction 参数,每个操作都要显式传进去,漏了就会在同一个连接上无事务执行。
第二个是多表映射。Query<User, Order, User> 这种方式可以用 splitOn 参数指定拆分的列,适合一对多查询。但实际用过之后我建议别太依赖这个,SQL 复杂到一定程度,直接写一个专门的 DTO(数据传输对象)接 Query<OrderWithUser> 更直白,团队可维护性更高。
第三个是动态过滤条件。搜索功能经常要根据不同条件拼 SQL,但又不想用 ORM 的表达式树。我的方案是 SqlBuilder 或者直接 StringBuilder 加参数,Dapper 支持匿名对象传参,把可选条件拼出来就行。
csharp复制var sql = "SELECT * FROM Products WHERE 1 = 1";
var parameters = new DynamicParameters();
if (!string.IsNullOrEmpty(name))
{
sql += " AND Name LIKE @name";
parameters.Add("name", $"%{name}%");
}
if (minPrice > 0)
{
sql += " AND Price >= @price";
parameters.Add("price", minPrice);
}
var list = connection.Query<Product>(sql, parameters);
这里 1 = 1 是业界常见的"占位写法",避免每个条件都去判断是否是第一个条件。
4.2 一键执行多条 SQL 语句的正确姿势
C# 里执行多条 SQL 不止一种方式,但不同方式的语义差别很大。
最简单的做法是 SqlCommand.CommandText 里直接写多条语句,用分号分隔:
csharp复制using var cmd = new SqlCommand(
"UPDATE T SET Status = 1 WHERE Id = 10; UPDATE T SET Status = 2 WHERE Id = 20;",
conn);
cmd.ExecuteNonQuery();
这种方式 SQL Server 是支持的,执行顺序从上到下。但要特别注意:如果第一条就报错,后面不会执行;如果想让它们作为同一个事务单元,必须显式开启事务:
csharp复制using var tx = conn.BeginTransaction();
try
{
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
tx.Commit();
}
catch
{
tx.Rollback();
throw;
}
Dapper 里没有专门的"执行多条"方法,你可以直接 Execute 多条 SQL 到一个连接上,先后调用即可,同样要配合事务。
还有人会问能不能用 SQL 脚本一次提交几十条 INSERT 提升性能。这个确实有效,但也有整洁度问题。我的建议是:如果是向同一个表插入数据,用表值参数(TVP)或者 SqlBulkCopy 性能最好,几千条数据毫秒级搞定;如果是不同的操作混在一起,就老老实实走事务。
4.3 CSV 文件的写入与加密
CSV 本质就是逗号分隔的纯文本,所谓"写完加密"其实就是对文本文件做加密。很多人在网上搜"CSV 加密",是因为业务要求把导出的数据文件保护起来,不能让人直接打开看内容。
方案分三个层次。第一个层次是最简单的,导出后立即用 AES 加密成二进制文件:
csharp复制using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
using var fs = File.Create("data.csv.enc");
using var cs = new CryptoStream(fs, aes.CreateEncryptor(), CryptoStreamMode.Write);
using var sw = new StreamWriter(cs);
sw.Write(csvContent);
解密时反过来用 CreateDecryptor。这个方案能挡住绝大多数非技术用户,但密钥要保护好,不要硬编码在代码里。第二个层次是对 CSV 中的敏感字段单独加密,比如手机号、身份证号,AES 加密后转 Base64 放进 CSV。好处是文件整体仍可阅读,敏感列是密文。第三个层次是压缩后加密,先 GZipStream 压缩再 AES 加密,文件更小但需要配合 CS 端或 Java 端解密时先解压再读。
密码学上有个常被忽略的点:加密时要考虑完整性校验。AES 只保证机密性,不保证密文没有被篡改。如果数据用于对账或审计,建议再加上 HMAC 或数字签名。CSV 写入时还有一个经验:如果字段里包含逗号、引号、换行符,一定要按 RFC 4180 规范用双引号包裹,否则 Excel 打开会错列。
5. 桌面与 Web 交界处:CEFSharp、HTTP 服务、ActionResult 与 RestClient 异常
5.1 WPF 中嵌入 CEFSharp 的关键配置
CEFSharp 是在 WPF/WinForms 里嵌入 Chromium 的库,适合"要把 Web 页面嵌进桌面程序"的场景。它解决的核心问题很简单:WebBrowser 控件是基于老 IE 内核的,CSS3、ES6、WebGL 全都跟不上,而 CEFSharp 把整套 Chromium 塞进桌面程序,渲染效果和 Chrome 一致。
接入的几个关键点。第一,CEFSharp 不支持 AnyCPU,项目必须显式配置成 x64 或 x86,否则运行时会报初始化失败。第二,初始化只需要一次,在程序启动时调用:
csharp复制var settings = new CefSettings();
Cef.Initialize(settings);
Cef.Initialize 最好放在 App.xaml.cs 的 OnStartup 里,而且要在任何浏览器控件创建之前调用。第三,默认情况下 CEFSharp 是进程隔离的,子进程崩溃不至于带崩整个主程序,但内存占用比较高,需要监控进程数量。如果程序里大量加载不同页面,建议使用 Cef.Shutdown() 前把所有浏览器控件销毁干净,释放缓存。
还有一个实操技巧:如果页面里需要和 C# 交互(比如页面按钮触发 C# 方法),CEFSharp 提供了 JavascriptObjectRepository 注册 .NET 对象,页面里直接调用注册对象的方法。这里要注意注册对象的方法参数类型必须是简单的 CLR 类型(int、string、数组等),复杂类型传递会出问题。
5.2 用 HttpListener 搭一个轻量级 HTTP 服务
有些桌面工具需要在本地提供一个 HTTP 接口,让其他程序来调用或取数据,但又不想引入 ASP.NET Core 这种重量级框架。HttpListener 是最合适的方案,一个类就能搞定。
csharp复制var listener = new HttpListener();
listener.Prefixes.Add("http://localhost:8080/");
listener.Start();
while (listener.IsListening)
{
var context = listener.GetContext();
var response = context.Response;
string result = "{\"status\":\"ok\"}";
byte[] buffer = Encoding.UTF8.GetBytes(result);
response.ContentType = "application/json; charset=utf-8";
response.OutputStream.Write(buffer, 0, buffer.Length);
response.Close();
}
这段代码能跑,但只用在了本机调试。实际使用中两个坑要注意。第一个是 URL ACL 权限。非管理员启动时监听 http://localhost:8080/ 可能报"拒绝访问",需要管理员执行一次:
code复制netsh http add urlacl url=http://+:8080/ user=Everyone
第二个是并发。上面的写法是单线程循环,一个请求没处理完,后续请求就排队。要支持并发,把请求处理放到 Task.Run 里。还有 HttpListener 是底层的",,处理 POST body、querystring、路由都要自己写,超过两三个接口就建议用 ASP.NET Core Minimal API 了。我的分界线是:接口少于 5 个、只供本机或局域网调用,用 HttpListener;接口多、需要参数校验甚至鉴权,直接上 ASP.NET Core。
5.3 ActionResult 的核心设计思想
ActionResult(更准确地说是 IActionResult)在 ASP.NET Core 里代表"控制器方法返回给调用方的响应类型"。它的作用不是返回数据本身,而是描述"响应的方式"。Ok(user) 表示 200 且 JSON 序列化 user,BadRequest("参数错误") 表示 400,NotFound() 表示 404,File(bytes, "text/csv", "data.csv") 表示文件下载。
这个设计解决了什么问题?它把"业务结果"和"HTTP 响应语义"解耦了。业务方法不需要关心状态码,返回 ActionResult,框架负责映射。这样也方便做统一过滤器,比如在异常中间件里统一处理 500 响应。
实际项目里我建议不要每个 action 都返回具体的 Ok()/BadRequest() 混着写,而是定一个结果包装类:
csharp复制public class ApiResult<T>
{
public bool Success { get; set; }
public string Message { get; set; }
public T Data { get; set; }
}
然后写一个扩展方法把 ApiResult<T> 转成统一 JSON。前端只用判断 Success 字段,不用每个接口都去解析不同的错误结构。这个模式在团队协作时特别值,前端和后端的接口文档只需要定义一次错误格式。
5.4 RestClient 报"远程主机强迫关闭了一个现有的连接"排查链路
这个报错是 HTTP 客户端调用远端接口时最烦人的问题之一。表面上"远程主机强迫关闭了一个现有的连接",但真实原因可能有很多种。我自己排查时按这条链路走,基本能定位。
第一步是看 TSL/SSL 版本。远程服务器如果只支持 TLS 1.2/1.3,而客户端默认还在用 TLS 1.0/1.1(老 .NET Framework 项目常见),握手阶段就会被强制断开。解决方式是显式设置协议:
csharp复制ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls13;
第二步是看 KeepAlive 和连接复用。底层 Socket 被服务器主动回收后,如果连接池里还留着旧连接,下一次请求就会失败。RestSharp 里出现这个问题的概率高于 HttpClient,因为 HttpClient 自己管理连接池。解决方式是设置合理的超时时间,缩短 ConnectionLeaseTimeout,或者对失败请求做一次重试。
第三步是看代理。公司内网环境经常有代理拦截,转发到外网时把长连接掐断。检查系统代理设置,必要时 HttpClientHandler.Proxy = null。第四步是看请求体大小。上传大数据量时,服务器因为防线或超时断开,也会报这个错,一般是服务端的 maxAllowedContentLength 或 maxRequestLength 限制。最后一步是让同事抓包看 TCP RST,看是谁先发的断开包,一眼就能定位问题出在客户端还是服务端。
6. 工程化工具链:NLog、文档注释、WMI 与 AI 辅助开发
6.1 NLog 的正确配置姿势
日志是 C# 项目里"平时没用,出事救命"的东西。NLog 是 .NET 生态用得最广的日志库之一,支持文件、数据库同、EventLog、HTTP 等多种目标。
先看最简单的配置。NuGet 安装 NLog 后,写一个 NLog.config:
xml复制<nlog>
<targets>
<target name="file" xsi:type="File"
fileName="${basedir}/logs/app-${shortdate}.log"
layout="${longdate}|${level:uppercase=true}|${logger}|${message}${exception:format=tostring}" />
</targets>
<rules>
<logger name="*" minlevel="Info" writeTo="file" />
</rules>
</nlog>
关键在于两点。第一,${shortdate} 让日志文件按天分文件,避免单个日志文件撑爆磁盘。第二,${exception:format=tostring} 一定要有,否则 logger.Error(ex, "msg") 只记录消息不记录堆栈,排查问题等于瞎猜。
NLog 的异步配置我强烈建议开启。在 <target> 上加 async="true",或者在 targets 里用 AsyncWrapper 包一层,让日志写入在后台线程完成,避免日志写入拖慢业务流程。但注意:进程崩溃时异步日志可能丢失最后几条,所以关键业务关键节点可以单独同步写一条。
还有一个大型项目里的经验:日志级别要定规范。Debug 是详细调试、Info 是业务流程关键节点、Warn 是可恢复异常、Error 是当前请求处理失败、Fatal 是进程无法继续。不要把所有信息都打成 Info,否则真正出问题的时候日志文件里全是噪音。
6.2 文档注释:不只是给 IDE 看的
C# 的文档注释以 /// 开头,可以标记 summary、param、returns、exception 等 XML 标签。它最直接的作用是 IDE 里鼠标悬浮就能看到方法说明,这能让团队协作顺畅很多——调用别人写的方法不用去看源码。
csharp复制/// <summary>
/// 根据订单号查找订单,找不到时返回 null。
/// </summary>
/// <param name="orderNo">订单号,必填。</param>
/// <returns>订单实体或 null。</returns>
public Order GetOrder(string orderNo) { ... }
要输出成 XML 文档文件,在项目文件里开启 GenerateDocumentationFile,也可以顺便接入 Swagger 等接口文档工具。但文档注释有个反面坑:代码重构后忘记更新注释,注释和实际逻辑不一致,比没有注释更坑。我给自己定了一条规矩:注释只写"为什么",不写"是什么"。命名已经能说明 GetOrderByOrderNo 是什么意思,注释就不要重复"获取订单"了,而是写"为什么这里要从 Redis 先查,查不到再走数据库"。
6.3 用 WMI 获取 CPU ID 等硬件信息
WMI(Windows Management Instrumentation)是 Windows 自带的管理接口。C# 里通过 System.Management 命名空间访问。最常用的场景是获取 CPU 序列号、主板序列号、MAC 地址,用来做软件授权绑定。
csharp复制var searcher = new ManagementObjectSearcher("SELECT ProcessorId FROM Win32_Processor");
foreach (ManagementObject obj in searcher.Get())
{
string cpuId = obj["ProcessorId"]?.ToString();
}
但 WMI 有两个坑。第一个,System.Management 在 .NET Core/.NET 5+ 里默认不包含,要单独装 NuGet 包 System.Management。第二个,WMI 查询在部分机器上可能很慢,首次查询可能要几秒,所以不要放在 UI 主线程。另外要注意 ProcessorId 在虚拟机和部分新硬件上可能取不到,做授权系统时不能只依赖这一项,要组合 CPU、主板、磁盘序列号一起算指纹。
6.4 AI 辅助开发 C# 工具和 C# Dev Kit
这两年 AI 辅助开发渗透很快,C# 场景也不例外。目前在用的工具基本分三类:第一类是 IDE 内补全(GitHub Copilot、通义灵码等),适合自动生成样板代码和单元测试;第二类是对话式助手(ChatGPT、Claude 等),适合解释异常堆栈、生成某种协议解析代码;第三类是 .NET 官方和社区的配套(C# Dev Kit 就是微软为 VS Code 出的一套 C# 开发套件)。
C# Dev Kit 的核心价值是把 VS Code 从一个"轻量编辑器"变成"能跑大型 .NET 项目的 IDE"。它包含 C# 扩展、Solution Explorer、调试器、测试管理器,还集成了 MSBuild 支持。如果你受不了 Visual Studio 的启动速度,又需要写 .NET 项目,C# Dev Kit 是首选。
用 AI 辅助写 C# 的注意事项:AI 生成的代码语法通常没问题,但容易忽略 .NET 版本的 API 差异。比如它可能生成 .NET 8 才有的 API,而你的项目还在 .NET 6。所以 AI 代码必须经过编译和单元测试,不能直接合入。我的习惯是让 AI 写"一次性脚本"或"协议解析纯函数",这类代码边界清晰、容易验证;不让 AI 直接写涉及并发、事务、安全策略的代码,这段还是自己盯比较稳。
7. 图像与 CAD 处理的刁钻需求:位图拷贝、OCR 与 DWG 线段读取
7.1 两个 BitmapData 对象之间做类似 memcpy 的全量拷贝
处理图像时经常需要把一张位图的像素快速拷贝到另一张。逐像素 GetPixel/SetPixel 是最简单的方法,但性能极差,一张 1920x1080 的图几十万次调用能卡几秒。正确做法是用 Bitmap.LockBits 锁定内存,然后用 Marshal.Copy 直接拷贝内存块,类似 C 语言的 memcpy。
csharp复制Bitmap src = ...;
Bitmap dst = new Bitmap(src.Width, src.Height, src.PixelFormat);
var srcRect = new Rectangle(0, 0, src.Width, src.Height);
var dstRect = new Rectangle(0, 0, dst.Width, dst.Height);
var srcData = src.LockBits(srcRect, ImageLockMode.ReadOnly, src.PixelFormat);
var dstData = dst.LockBits(dstRect, ImageLockMode.WriteOnly, dst.PixelFormat);
int bytesPerPixel = Image.GetPixelFormatSize(src.PixelFormat) / 8;
int byteCount = srcData.Stride * src.Height;
byte[] buffer = new byte[byteCount];
Marshal.Copy(srcData.Scan0, buffer, 0, byteCount);
// 如果需要跨格式拷贝,可以在这里对 buffer 做像素格式转换
Marshal.Copy(buffer, 0, dstData.Scan0, byteCount);
src.UnlockBits(srcData);
dst.UnlockBits(dstData);
这里最关键的坑是 Stride。Stride 是每行像素在内存中占用的字节数,因为要对齐,它可能大于 Width * bytesPerPixel。按 Width * Height * bytesPerPixel 分配缓冲区会导致错行。必须用 Stride * Height。如果两张图的 PixelFormat 不一样,不能直接拷贝,要逐行做格式转换。另外 LockBits 之后一定要在 finally 里 UnlockBits,否则位图对象一直处于锁定状态,后续保存、绘制都会异常。
还有一个提高性能的细节:如果要频繁处理同一张图,尽量复用 byte[] 缓冲区,不要每次 LockBits 都 new 一个几 MB 的数组,GC 压力大。
7.2 用 Tesseract 做 OCR:识别中文的预处理比引擎更重要
Tesseract 是一个开源 OCR 引擎,C# 里用 Tesseract NuGet 包封装。识别中文需要下载中文语言包 chi_sim.traineddata,放进项目的 tessdata 目录。
csharp复制using var engine = new TesseractEngine("./tessdata", "chi_sim", EngineMode.Default);
using var img = Pix.LoadFromFile("capture.png");
using var page = engine.Process(img);
string text = page.GetText();
实际项目中识别准确率往往不理想,问题大多不在 Tesseract 本身,而是图像预处理不够。Tesseract 对干净的二值图识别率远高于带噪声的原图。我的预处理流程是:先转灰度,再做高斯模糊去噪,再用阈值二值化(或者自适应阈值),必要时做倾斜校正和边框去除。
提个醒:Tesseract 对复杂的表格、手写体、特殊字体识别效果有限。如果业务场景是识别手机屏幕截图、验证码或者票据,深度学习 OCR(PaddleOCR、RapidOCR)的效果会好得多,只是部署成本也高。我的建议是:先拿 Tesseract 跑通流程,确认业务可行后,再按准确率需求决定是否换深度学习引擎。
7.3 读取 AutoCAD DWG 文件的线段信息
C# 直接读取 DWG 格式的二进制是很困难的事,DWG 是封闭格式,内部结构随 AutoCAD 版本变化。要读取 DWG(不是 DXF),常见做法有三种。
第一种是通过 AutoCAD 的 COM API,就是装 AutoCAD 软件,然后在 C# 里引用其 COM 组件。这种方式功能最全,但要求目标机器装有 AutoCAD,而且调用速度慢。
第二种是用 ODA(Open Design Alliance)的 Teigha 库,这是商业库。第三种是找国产/开源的解析库。如果文件是 DXF 格式,开源库 netDXF 是免费好用的选择:
csharp复制var doc = DxfDocument.Load("drawing.dxf");
foreach (var line in doc.Lines)
{
Console.WriteLine($"Start: {line.StartPoint}, End: {line.EndPoint}");
}
netDXF 读取 DXF 中的 Line、Polyline、Circle 等实体都很方便,我也多次用它从 DXF 文件里提取线段坐标去生成加工路径。
但注意 DXF 和 DWG 不是一个格式。如果客户只给 DWG,我建议业务侧让他们"另存为 DXF",或者用 CAD 脚本批量转换。DWG 解析的开源方案很少且不稳定,生产项目里不要依赖一个长期不维护的库去赌它支持你想到的 DWG 版本。
8. 最后分享几条实践下来的心得
整理这份笔记的过程,让我重新审视了自己写代码的一些习惯。第一个体会是:C# 的坑大多不在语法本身,而在异步边界、编码、协议细节、资源释放这些"不显眼"的地方。写上位机程序尤其明显,串口数据乱码、Modbus 寄存器解析错误、TCP 断线重连,每一个都是"看起来没报错,但数据不对"的隐性 bug。解决这类问题的唯一办法,就是把数据流和状态机的每一步都打日志,先还原现场,再谈修复。
第二个体会是:工程上的东西,比如日志、文档注释、统一的响应格式,看起来不产生直接业务价值,但能在一周后的排查里帮你省下三个小时。NLog 日志级别规范、API 返回结构统一、事务边界明确,这些"约定"比任何高级特性都值得制度化。
第三个体会是:新技术和 AI 工具要大胆用,但边界要清楚。C# Dev Kit 极大改善了 VS Code 写 .NET 的体验,AI 助手能快速生成协议解析、图像处理的样板代码,但这些工具生成的东西,最终要由自己理解后确认。我今天花了很多时间验证代码里的边界条件和资源释放,目的就是让这份笔记里的每一个片段,在真实项目里拿出来都是能直接用的状态。如果你在照着实践时发现某个坑和我写得不太一样,欢迎按你自己的现场排查逻辑去补一条笔记,这个领域的细节远比一篇文章承载的多。
