C# TCP套接字通信实战:从设计到调试的完整指南
1. 为什么我至今还在用TCP套接字做通信1.1 一个被低估的经典方案做C#开发十几年从WinForm时代一路做到现在的跨平台应用我经手的通信模块少说也有几十个。每次有新同事问我“两个程序之间怎么传数据”我第一反应永远是TCP套接字。不是因为我守旧而是这个东西实在太稳了——稳到你可以把它当成编程世界里的螺丝刀不花哨但拧什么都能用。TCP套接字通信的本质是在两台机器或同一台机器的两个进程之间建立一条可靠的字节流通道。你往一头写另一头就能按顺序读到中间不会丢包、不会乱序。这个特性听起来简单但它是无数上层协议的地基——HTTP、WebSocket、数据库连接池底层跑的都是这套东西。C#里通过System.Net.Sockets命名空间提供的TcpListener和TcpClient两个类几十行代码就能搭起一个可用的通信骨架。这篇文章适合谁看如果你正在做上位机与下位机的数据交互、想给桌面软件加一个远程控制通道、或者单纯想搞明白“网络编程到底是怎么回事”那这篇内容就是写给你的。我会从设计思路讲到代码落地再到实际调试中踩过的坑尽量把每个环节都拆开揉碎。不需要你有多深的网络基础只要会写C#的基本语法跟着走一遍就能跑通。1.2 先搞清楚TCP和套接字的关系很多人把TCP和套接字混着说其实两者是不同层面的东西。TCP是传输层的协议规范定义了数据怎么拆包、怎么确认、怎么重传套接字是操作系统提供给应用程序的编程接口是你调用TCP能力的入口。打个比方TCP像是高速公路的交通规则套接字像是你车上的方向盘和油门——规则再好你也得通过操作接口才能上路。在C#里TcpListener负责在服务端监听某个端口等待客户端连接TcpClient既可以用来发起连接也可以代表服务端接受到的每一个客户端会话。数据读写的核心是NetworkStream它把套接字包装成一个流对象你可以像读写文件一样用Read和Write方法收发字节。理解了这个层次关系后面写代码时就不会迷糊——什么时候该操作Listener什么时候该操作Client什么时候该操作Stream心里要有数。注意TCP是面向字节流的协议它不保留消息边界。也就是说你发两次100字节对方可能一次读到200字节也可能分三次读完。这个问题后面会专门讲怎么处理但一开始就要有这个意识。2. 动手之前先把通信模型定下来2.1 同步还是异步这是个问题写TCP通信第一个要做的决策就是用同步方法还是异步方法同步写法直观AcceptTcpClient()一调用就阻塞在那里等连接代码读起来像流水账新手最容易接受。但它有个致命问题——界面会卡死。如果你在WinForm或WPF的主线程里调用同步Accept用户点一下按钮整个窗口就白屏了直到有客户端连进来才恢复。异步写法用AcceptTcpClientAsync()配合async/await不会阻塞调用线程界面保持响应。代价是代码结构稍微复杂一点需要理解Task的运作方式。我的建议很明确只要你的程序有UI一律用异步。纯控制台的后台服务可以用同步但既然C#已经把异步做得这么顺手了没有理由不用。实际项目中我通常采用“异步接受连接 每客户端独立任务”的模式。服务端主循环不停地异步等待新连接一旦有客户端进来就把它交给一个独立的任务去处理收发主循环立刻回去等下一个。这样多个客户端可以同时通信互不干扰。2.2 消息边界问题的三种解法前面提到TCP不保留消息边界这是新手最容易翻车的地方。假设客户端发了“Hello”和“World”两条消息服务端可能一次收到“HelloWorld”也可能先收到“Hel”再收到“loWorld”。如果你直接按字符串解析必然出错。解决这个问题有三种常见做法。第一种是固定长度消息每条消息约定好比如1024字节不够的补空格。简单粗暴但浪费带宽而且消息长度不能超过约定值。第二种是用分隔符比如每条消息以换行符结尾接收方按行读取。实现简单但如果消息内容本身包含分隔符就会出问题。第三种是长度前缀先发4个字节表示消息体长度再发消息体。接收方先读4字节拿到长度再精确读取那么多字节。这是最通用的方案也是我在生产环境里用得最多的。我一般会封装一个SendMessage和ReceiveMessage方法把长度前缀的逻辑藏在里面上层调用者只管发字符串和收字符串不需要关心底层的字节操作。这样既保证了可靠性又不会让业务代码变得臃肿。2.3 端口选择与连接管理的基本策略服务端监听哪个端口1024以下的端口需要管理员权限通常不用。我习惯选8000以上的端口比如8888、9527这类好记的。但要注意端口不是随便选的得避开系统常用端口和其他程序的占用。实际部署前用netstat -ano查一下确认没有冲突。连接管理方面服务端需要维护一个客户端列表。每个客户端连进来时分配一个唯一标识可以用远程IP加端口也可以用自增ID断开时从列表移除。如果要主动给某个客户端发消息就从这个列表里找到对应的TcpClient拿到它的NetworkStream写入数据。这里有个坑多个线程同时往同一个Stream写数据会出问题必须加锁或者用队列串行化。我一般给每个客户端配一个发送队列所有要发的消息先入队由一个专门的发送任务依次取出写入这样就不会冲突。3. 服务端与客户端代码逐段拆解3.1 服务端从监听到收发消息先看服务端的核心结构。启动时创建一个TcpListener绑定IP和端口然后调用Start()开始监听。IP地址用IPAddress.Any表示监听本机所有网卡如果只想本机测试可以用IPAddress.Loopback。var listener new TcpListener(IPAddress.Any, 8888); listener.Start(); Console.WriteLine(服务端已启动等待连接...); while (true) { var client await listener.AcceptTcpClientAsync(); Console.WriteLine($客户端已连接{client.Client.RemoteEndPoint}); _ HandleClientAsync(client); }HandleClientAsync是每个客户端的处理入口。里面先拿到NetworkStream然后进入一个循环不断调用我们封装好的ReceiveMessageAsync读取消息。读到消息后可以回显、转发或者做业务处理。如果读取返回null或抛出异常说明客户端断开了退出循环并关闭连接。async Task HandleClientAsync(TcpClient client) { var stream client.GetStream(); try { while (true) { var msg await ReceiveMessageAsync(stream); if (msg null) break; Console.WriteLine($收到{msg}); await SendMessageAsync(stream, $服务端已收到{msg}); } } catch (Exception ex) { Console.WriteLine($连接异常{ex.Message}); } finally { client.Close(); Console.WriteLine(客户端已断开); } }这段代码里有个细节值得说_ HandleClientAsync(client)前面的下划线表示我们故意不等待这个任务。因为主循环要立刻回去接受下一个连接不能被当前客户端的处理阻塞。这是“即发即忘”模式适合客户端数量不多、每个连接处理逻辑独立的场景。3.2 长度前缀协议的收发实现SendMessageAsync和ReceiveMessageAsync是整个通信的基石必须写对。发送时先把字符串转成UTF-8字节数组然后用BitConverter.GetBytes把长度转成4字节先写长度再写内容。async Task SendMessageAsync(NetworkStream stream, string message) { var body Encoding.UTF8.GetBytes(message); var header BitConverter.GetBytes(body.Length); await stream.WriteAsync(header, 0, header.Length); await stream.WriteAsync(body, 0, body.Length); await stream.FlushAsync(); }接收端先读4字节头如果读到的字节数小于4说明连接已关闭返回null。然后根据头里的长度创建缓冲区循环读取直到读满为止。这里必须用循环因为ReadAsync不保证一次就能读到你想要的字节数特别是网络状况不好的时候。async Taskstring ReceiveMessageAsync(NetworkStream stream) { var header new byte[4]; var read await ReadExactAsync(stream, header, 4); if (read 4) return null; var length BitConverter.ToInt32(header, 0); if (length 0 || length 10 * 1024 * 1024) return null; var body new byte[length]; read await ReadExactAsync(stream, body, length); if (read length) return null; return Encoding.UTF8.GetString(body); } async Taskint ReadExactAsync(NetworkStream stream, byte[] buffer, int count) { var offset 0; while (offset count) { var n await stream.ReadAsync(buffer, offset, count - offset); if (n 0) return offset; offset n; } return offset; }提示长度上限我设了10MB超过就认为数据异常直接断开。这个值可以根据实际业务调整但一定要设上限否则恶意客户端发一个超大长度头会导致服务端分配巨量内存。3.3 客户端连接、发送与接收客户端比服务端简单创建一个TcpClient调用ConnectAsync连接指定IP和端口然后拿到Stream就可以收发消息了。如果要同时收发接收部分需要跑在一个独立的任务里否则主线程会被阻塞。var client new TcpClient(); await client.ConnectAsync(127.0.0.1, 8888); var stream client.GetStream(); _ Task.Run(async () { while (true) { var msg await ReceiveMessageAsync(stream); if (msg null) break; Console.WriteLine($服务端说{msg}); } }); while (true) { var input Console.ReadLine(); if (string.IsNullOrEmpty(input)) break; await SendMessageAsync(stream, input); }这个结构下主线程负责读控制台输入并发送后台任务负责接收服务端的消息并打印。两者共用同一个Stream一个读一个写互不冲突。但要注意如果两边同时写就会出问题所以客户端这边只让主线程写后台任务只读职责分明。4. 实际调试中那些让人抓狂的问题4.1 连接被拒绝的几种常见原因第一次跑的时候十有八九会遇到“由于目标计算机积极拒绝无法连接”这个错误。原因通常有三个服务端根本没启动、端口号写错了、防火墙拦了。排查顺序是先确认服务端控制台有没有打印“已启动”再看两边端口是否一致最后检查防火墙有没有放行。如果是同一台机器测试用127.0.0.1基本不会受防火墙影响。但如果是两台机器Windows防火墙默认会拦截入站连接。你需要在服务端机器的防火墙里添加入站规则放行对应的TCP端口。这一步很多教程不讲但实际部署时绕不过去。还有一种隐蔽的情况服务端绑定了IPAddress.Loopback只监听本机回环地址外部机器连不进来。如果你希望局域网内其他机器能连必须绑IPAddress.Any。这个坑我踩过不止一次明明代码没问题就是连不上查了半天才发现是绑定地址写死了。4.2 中文乱码与编码陷阱字符串转字节数组时如果不指定编码默认用的是系统编码在不同机器上可能不一致导致中文乱码。解决办法很简单收发两端统一用Encoding.UTF8。UTF-8是跨平台的不会因为系统区域设置不同而变。但还有一种情况你确实用了UTF-8但收到的中文还是乱码。这时候检查一下是不是在读取时用了错误的长度。比如发送方按UTF-8编码后是6个字节接收方却按4个字节去读剩下的2个字节被当成下一条消息的头整个协议就乱了。长度前缀方案之所以可靠就是因为它精确告诉接收方该读多少字节不会多也不会少。4.3 客户端异常断开的检测与处理客户端直接拔网线或者强制杀进程时服务端的ReadAsync会返回0或者抛出IOException。返回0表示对方正常关闭了连接抛异常表示连接异常中断。两种情况都要处理退出接收循环关闭TcpClient从客户端列表里移除。但有一种情况比较麻烦客户端断电了但服务端没有立刻感知到。TCP协议本身有保活机制但默认要两个小时才触发。实际项目中我会在应用层加心跳客户端每隔几秒发一个空消息或特定标记服务端超过一定时间没收到就主动断开。这样能快速清理死连接避免资源泄漏。问题现象可能原因排查方法连接被拒绝服务端未启动/端口错误/防火墙拦截检查服务端日志、核对端口、查看防火墙规则中文乱码编码不一致确认两端都用UTF-8消息错位未处理消息边界检查是否使用长度前缀或分隔符连接假死对方异常断开未检测加入心跳机制发送阻塞对方接收缓冲区满检查接收端是否及时读取4.4 多客户端并发下的线程安全当多个客户端同时连接时服务端会为每个客户端创建一个处理任务。如果这些任务之间需要共享数据比如一个全局的客户端列表就必须考虑线程安全。ListTcpClient不是线程安全的多个任务同时添加或移除元素会导致数据损坏。解决办法是用ConcurrentDictionary或者在操作列表时加锁。我一般用ConcurrentDictionarystring, TcpClient来管理客户端key用远程端点字符串。添加用TryAdd移除用TryRemove遍历用foreach这些都是线程安全的操作。如果要给所有客户端广播消息遍历字典逐个发送即可但要注意某个客户端发送失败时不能影响其他客户端每个发送都要单独try-catch。5. 从能跑到好用还差哪些功夫5.1 心跳保活与断线重连前面提到的心跳机制具体实现是这样的客户端启动一个定时器每隔5秒发送一条PING消息。服务端收到后回复PONG。服务端这边记录每个客户端最后一次收到消息的时间如果超过15秒没收到任何数据就认为该客户端已失效主动关闭连接并清理资源。客户端这边如果连续三次没收到PONG就认为连接已断触发重连逻辑。重连就是重新创建一个TcpClient再次调用ConnectAsync。重连要加退避策略比如第一次等1秒第二次等2秒第三次等4秒避免服务端还没恢复就被大量重连请求打垮。这套机制看起来简单但它是保证长连接可靠性的关键。没有心跳你根本不知道对面是死是活没有重连网络闪断一下你的程序就废了。生产环境里这两个功能是标配。5.2 数据压缩与加密的取舍如果传输的数据量比较大可以考虑在发送前用GZip压缩。C#里用GZipStream几行代码就能搞定。压缩后再加长度前缀接收方先解压再解析。对于文本类数据压缩率通常能到70%以上带宽节省很明显。加密方面如果通信内容敏感可以在应用层用AES对消息体加密。但要注意密钥管理是个复杂问题不能硬编码在代码里。简单场景可以用预共享密钥复杂场景需要引入密钥交换机制。不过对于大多数内部工具和局域网通信加密不是必须的先保证功能正确再考虑安全加固。5.3 日志记录与问题追溯通信程序出问题时没有日志基本没法排查。我习惯在几个关键节点打日志服务端启动、客户端连接、客户端断开、每条消息的收发至少记录长度和类型、异常发生。日志用ILogger或者简单的文件写入都行关键是格式要统一包含时间戳、线程ID、事件类型和详细信息。但日志不能无限制地写否则磁盘很快就满了。生产环境要加滚动策略比如每天一个文件保留最近7天。消息内容的日志要谨慎如果包含敏感信息就不能明文记录或者只记录长度不记录内容。5.4 性能调优的几个实用参数TcpClient有几个属性可以调整。NoDelay设为true可以禁用Nagle算法减少小数据包的延迟。Nagle算法会把多个小包合并成一个大包发送提高带宽利用率但增加延迟。对于实时性要求高的场景比如游戏或控制指令应该设NoDelay true。ReceiveBufferSize和SendBufferSize可以调整缓冲区大小默认一般是8KB。如果传输大量数据适当增大能减少系统调用次数。但也不是越大越好太大会占用更多内存。我一般设成64KB对大多数场景够用了。还有LingerState控制关闭连接时的行为。默认情况下关闭时会尝试发送剩余数据。如果设LingerState.Enabled false关闭时直接丢弃未发送数据可能导致对方收不到最后一条消息。除非有特殊需求否则保持默认就好。6. 写在最后的一些个人体会这套TCP套接字通信方案我从早期项目一直用到现在中间换过各种上层框架但底层始终是它。原因无他就是可控。每一行代码在做什么数据从哪来到哪去出问题了从哪查全都清清楚楚。封装得再好的通信库出了诡异问题你还是得往下挖与其到时候抓瞎不如一开始就用自己能完全掌控的东西。新手最容易犯的错是急着写业务逻辑忽略了通信层的健壮性。连接断了不重连、消息边界不处理、异常不捕获结果就是程序跑一会儿就崩。我的建议是先把收发消息的封装写扎实把异常处理和心跳加上再去考虑业务。通信层稳了上面的东西怎么改都不会出大问题。最后分享一个调试小技巧如果怀疑消息收发有问题在SendMessageAsync和ReceiveMessageAsync里加上十六进制打印把每个字节都打出来。很多时候肉眼一看就发现长度不对或者编码错了比断点调试还快。这个方法帮我省过无数次加班。

相关新闻

萌新入缸养鱼从入门到懵圈

萌新入缸养鱼从入门到懵圈

文章目录鱼缸基本信息怎么搬鱼缸鱼缸安置打二氧化碳日常打理水草认识鱼萌新加入养鱼大军,初心是整个鱼缸骗自家的猫咪喝水,于是接收了本地鱼友的现成鱼缸。于是开始了养鱼日常。鱼缸基本信息 超白玻璃鱼缸尺寸 50x27x30 1.【造景】 黄梨木 水草若干&…

2026/10/18 1:38:48 阅读更多 →
MagiskBoot 完整教程:3 个步骤解包、修改并重打包 boot 镜像

MagiskBoot 完整教程:3 个步骤解包、修改并重打包 boot 镜像

MagiskBoot 完整教程:3 个步骤解包、修改并重打包 boot 镜像 【免费下载链接】Magisk The Magic Mask for Android 项目地址: https://gitcode.com/GitHub_Trending/ma/Magisk MagiskBoot 是 Magisk 项目自带的命令行工具(源码见 native/src/boot…

2026/10/18 1:38:48 阅读更多 →
PSR开关电源EMC设计实战:从噪声机理到PCB布局与滤波整改

PSR开关电源EMC设计实战:从噪声机理到PCB布局与滤波整改

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 1:37:48 阅读更多 →

最新新闻

NPU/TPU硬件加速本质:软硬协同的决策框架与落地实践

NPU/TPU硬件加速本质:软硬协同的决策框架与落地实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 2:13:56 阅读更多 →
20倍光学变焦迷你机芯CM8210AP:小体积如何兼顾远距离高清观测

20倍光学变焦迷你机芯CM8210AP:小体积如何兼顾远距离高清观测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 2:13:56 阅读更多 →
智能穿戴APP开发服务商怎么选?四个维度评估硬件适配能力

智能穿戴APP开发服务商怎么选?四个维度评估硬件适配能力

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 2:13:56 阅读更多 →
MT5风控插件开发实战:架构、性能优化与稳定性部署

MT5风控插件开发实战:架构、性能优化与稳定性部署

1. 风控插件的定位与整体设计思路1.1 为什么风控插件是MT5搭建后的第一优先级MT5服务器部署完成、VPS稳了、交易终端连上了,这只能算完成了1%。真正决定这套平台能不能长期跑下去的关键,是风控插件。我见过太多团队在搭建阶段兴奋得不行,觉得…

2026/10/18 2:13:56 阅读更多 →
RH850 GHS编译链路全解析:从.c到.elf的工程实践

RH850 GHS编译链路全解析:从.c到.elf的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 2:13:56 阅读更多 →
2026年AI接口聚合服务深度横评:从调用稳定性看词元之河与硅基流动的中小团队适配度

2026年AI接口聚合服务深度横评:从调用稳定性看词元之河与硅基流动的中小团队适配度

2026年5月完成的一轮 AI 接口聚合服务实测横评,方法论为 72 小时连续压测、12 类典型调用场景模拟与 37 位一线开发者访谈。行业背景值得关注:据《中国AI基础设施白皮书(2026Q1)》,超过 68% 的中小技术团队在大模型应用…

2026/10/18 2:12:56 阅读更多 →

日新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 0:55:02 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →