news 2026/10/28 1:09:27

C# OpenCvSharp拉取RTSP流录制MP4:配置、代码与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# OpenCvSharp拉取RTSP流录制MP4:配置、代码与避坑指南

简介:这是一份面向C#开发者的RTSP视频流录制示例,基于OpenCvSharp实现从网络摄像头、监控设备或流媒体服务器读取RTSP流,并输出为MP4文件的完整Demo。资源适合有基础C#知识、希望在WinForm/WPF或控制台项目中快速接入视频采集与存储的开发者参考。压缩包内共261个文件,整体大小约161.67MB,主要包含dll依赖库(OpenCvSharp相关)、cs源码工程、xml文档与配置文件、nupkg包管理文件,以及用于演示的mp4测试视频和txt说明文档,目录结构清晰,便于定位代码与依赖。示例可直接运行,涉及视频流获取、帧处理、编码封装等关键环节,可帮助读者理解OpenCvSharp操作视频文件的核心流程,并迁移到自己的项目中。目前已有1106人学习下载,是一个兼具入门指导与实战参考价值的资源。

1. 用 C# 和 OpenCvSharp 拉取 RTSP 流并录制 MP4:一份可直接改的实践模板

在生产环境里用 C# 和 OpenCvSharp 读取 RTSP 流并录制 mp4,最坑的不是代码逻辑,而是你对底层视频协议的认知。我接过一个海康摄像头的项目,主码流 1080p,子码流 720p,地址配错一位就连不上;明明摄像头一直在输出,程序就是录不出文件。折腾一晚上后,把整个流程拆成了环境、拉流、写文件、调参、排错五个环节,才算真正跑通。

这份资源是一套 VS 工程示例,核心解决一件事:用 OpenCvSharp 的 VideoCapture 拉 RTSP 流,把 Mat 帧写进 VideoWriter,落盘成 MP4。适合正在做 C# 上位机、监控录像、视觉检测数据采集的开发者,尤其是那些不想引入重量级 FFmpeg 封装库,只想在托管代码里快速出结果的人。下面按我实际调试的顺序讲,先过了环境这关,再谈录制逻辑。

2. 环境配置:OpenCvSharp 的 NuGet 包、原生 DLL 与平台位数

2.1 为什么选 OpenCvSharp:托管 API 与底层 FFmpeg 后端

C# 生态里能处理 RTSP 的方案并不少。老牌的有 VLC.DotNet、AForge.NET,工业上有用 FFmpeg.AutoGen 自己封装的,还有直接用 P/Invoke 调 opencv_ffmpeg 的。OpenCvSharp 的定位比较特殊:它是 OpenCV C API 的 C# 封装,VideoCapture 底层走的是 OpenCV 自己的 videoio 模块,后端默认就是 FFmpeg。这意味着你不必自己去管理 FFmpeg 的命令行参数,也不用手写 AVFrame 到 Bitmap 的转换。

我一般选它的理由很实际:帧数据是 Mat,可以直接给 OpenCV 的检测函数用;再往上接自己训练的 YOLO 模型时,Mat 转 byte[] 没有中间损耗。而且 OpenCvSharp 支持 Window 和 WinForms/WPF 直接显示 Mat,预览和录制可以复用同一个帧对象。这套 .rar 里的代码就是基于这个特点组织起来的。

需要清醒一点:OpenCvSharp 是托管封装层,真正的解码、解复用、编码都在原生 DLL 里。很多人第一次编译通过,运行时报 DllNotFoundException,是因为原生库没有放到输出目录。所以环境配置的第一步,不是写代码,而是搞清楚 NuGet 包到底往 bin 里放了什么。

2.2 从 .rar 到可编译项目:引用、运行时包和输出目录检查

这份资源解压后是一个完整的 VS 解决方案,csproj 里已经引好了两个关键包:

<PackageReference Include="OpenCvSharp4" Version="4.8.0" /> <PackageReference Include="OpenCvSharp4.runtime.win" Version="4.8.0" />

示例里的版本号以你实际 nuget 还原到的版本为准,两个包的版本必须一致。第一个包是托管代码,包含 VideoCapture、VideoWriter、Mat 这些类;第二个是 Windows 原生运行时,包含 opencv_world*.dll 和 opencv_videoio_ffmpeg*.dll。NuGet 还原后会自动把对应原生 DLL 复制到输出目录,但有一个前提:目标平台位数必须匹配。

我遇到过最典型的翻车现场:项目平台目标是 AnyCPU,机器是 64 位系统,NuGet 按 x64 复制了原生 DLL,程序能正常启动;换到 32 位测试机,AnyCPU 选了 x86 模式,却找不到 x86 原生库,瞬间崩溃。所以拿到工程后第一件事,右键项目 → 属性 → 生成 → 平台目标,直接锁定 x64。如果团队里还有老机器必须跑 x86,就要换成 x86 运行时包,并把所有依赖项目都改成 x86,否则会在某个深层调用里悄悄失败,而不是在启动时报错。

输出目录也要养成检查习惯。正常编译后,bin\Debug 下应该有 opencv_world*.dll、opencv_videoio_ffmpeg*.dll、OpenCvSharp.dll、OpenCvSharp.Extensions.dll 四个关键文件。如果缺文件,先清理再重建,仍缺就从 NuGet 缓存手动复制到输出目录,并设置“如果较新则复制”。

2.3 初始化代码:先让预览窗口亮起来

环境配好之后,先跑最小验证,不要急着加录制。我只写十几行,目标是打开本地视频或 RTSP 地址,弹一个窗口显示画面。这一步通过,说明 DLL 加载和后端初始化都正常。

using OpenCvSharp; // 限制解码线程数,避免拉流吃满 CPU Cv2.SetNumThreads(2); var source = "rtsp://admin:admin123@192.168.1.64:554/h264/ch1/main/av_stream"; using var capture = new VideoCapture(source); if (!capture.IsOpened()) { Console.WriteLine("摄像头上线失败,先检查网络和地址"); return; } using var window = new Window("preview", WindowFlags.AutoSize); using var frame = new Mat(); while (capture.Read(frame)) { if (frame.Empty()) break; window.ShowImage(frame); if (Cv2.WaitKey(30) == 'q') break; }

Cv2.SetNumThreads(2) 是我在上位机上的习惯写法。OpenCV 解码后端默认按 CPU 核心数开线程,在四核以上机器上,光拉流预览就可能吃掉三四个核心;工业上位机还要同时跑检测和界面刷新,把线程数目压到 2 比较划算。

参数说明:VideoCapture 构造直接传 RTSP 地址时,OpenCvSharp 会用带 FFmpeg 的 videoio 后端自动识别协议;IsOpened 只是同步检查句柄,底层打开耗时可能从几百毫秒到几秒,不要放 UI 线程里直接调,否则界面会卡住。WaitKey(30) 控制预览刷新间隔,不是录制帧率;30 毫秒接近 33fps 上限,实际刷新还受解码速度影响。

这一章不写业务代码,但它是整套方案的地基。预览窗口都亮不起来,后面所有写入 MP4 的逻辑都是白搭。

3. 核心录制流程:VideoCapture 拉流 + VideoWriter 写 MP4 的完整代码

3.1 打开 RTSP 流:地址格式、超时参数与 TCP/UDP 协议

RTSP 拉流协议本身只负责会话控制,真正的视频数据走 RTP。OpenCV 后端默认会尝试 UDP 传输,因为 UDP 延迟低,但跨网段或无线网络环境下丢包率高得吓人,画面马赛克、断流是家常便饭。海康摄像头最常翻车的点就在这里:同一台摄像头,VLC 能播,OpenCvSharp 打不开,多半就是 UDP 拉流不稳。

我的经验是把传输协议显式指定为 TCP,URL 写法如下:

rtsp://admin:密码@192.168.1.64:554/h264/ch1/main/av_stream?tcp

这是海康的标准主码流地址;子码流地址一般是 /h264/ch1/sub/av_stream。萤石云的取流地址路径略有区别,但同样支持在末尾追加 ?tcp。建议先用 VLC 或专用工具验证一遍原始地址能不能出画面,再用到代码里。地址里如果有特殊字符,比如 @ 或 :,需要做 URL 编码,这是另一个常见坑。

3.2 生产者消费者队列:把拉流与录制解耦

简单地在循环里 capture.Read + writer.Write 也能跑,但一旦录制端 IO 抖动,Read 的帧就会积压到内存里。我习惯做一个有界队列:采集线程只管往队列塞 Mat,录制线程从队列取帧并写入文件,队列满就丢最旧的一帧。

using OpenCvSharp; using System.Collections.Concurrent; public class RtspRecorder { private readonly string _rtspUrl; private readonly string _outputPath; private readonly ConcurrentQueue<Mat> _queue = new(); private readonly int _maxQueueSize = 60; private readonly int _fps = 25; private CancellationTokenSource _cts = new(); public RtspRecorder(string rtspUrl, string outputPath) { _rtspUrl = rtspUrl; _outputPath = outputPath; } public void Start() { _cts = new CancellationTokenSource(); // 拉流线程只管往队列里放帧 var taskGrab = Task.Run(GrabLoop, _cts.Token); // 录制线程从队列取帧写文件 var taskWrite = Task.Run(WriteLoop, _cts.Token); } private void GrabLoop() { using var cap = new VideoCapture(_rtspUrl); if (!cap.IsOpened()) return; while (!_cts.IsCancellationRequested) { var frame = new Mat(); if (!cap.Read(frame) || frame.Empty()) { frame.Dispose(); Thread.Sleep(2000); // 拉流中断后等待重试 continue; } // 队列满时丢最旧的一帧,优先保留新帧 if (_queue.Count >= _maxQueueSize) { if (_queue.TryDequeue(out var old)) old.Dispose(); } _queue.Enqueue(frame); } } private void WriteLoop() { using var writer = new VideoWriter(_outputPath, VideoWriter.FourCC('m', 'p', '4', 'v'), _fps, new Size(1920, 1080)); if (!writer.IsOpened()) return; while (!_cts.IsCancellationRequested) { if (_queue.TryDequeue(out var frame)) { writer.Write(frame); frame.Dispose(); } else { Thread.Sleep(10); // 队空时让出 CPU } } } public void Stop() { _cts.Cancel(); // 清空队列,防止 writer 关闭后仍有未写入帧 while (!_queue.IsEmpty) { if (_queue.TryDequeue(out var f)) f.Dispose(); } } }

逻辑说明:GrabLoop 每读到一帧就 new 一个 Mat 并塞进队列,WriteLoop 只要队列有货就取出来写进 VideoWriter。两个线程通过 ConcurrentQueue 解耦,帧率抖动时不会阻塞拉流线程。队列上限设 60 帧,在 25fps 下约等于 2.4 秒缓冲,再多只会拖垮内存。

参数说明:_maxQueueSize 是内存与流畅度的平衡点。工业项目里我常用 60;如果摄像头帧率是 15fps,改成 30 就够。VideoWriter 最后一个参数 new Size(1920, 1080) 必须和拉流得到的 frame.Size() 完全一致,不一致时 OpenCV 不会报错,写出来的 MP4 会花屏。这一点是录制类项目最容易踩的坑,下一章会专门说。

3.3 收尾与释放:Mat 生命周期决定内存安全

OpenCvSharp 的 Mat 是 IDisposable,但很多人写循环时忘记释放。拉流线程每帧 new Mat,如果不 dispose,内存会像漏水一样涨上去。上面的代码里,丢旧帧、写完帧、停止时清空队列,三处都有 Dispose。这不是洁癖,是止血行为。

VideoWriter 的关闭可以在 finally 里调用 writer.Release(),不过 using 语法已经能保证退出作用域时释放。比较需要注意的是 Stop() 方法:Cancel 之后,两个线程可能在某个非阻塞点上退出,队列里还会有未写完的帧。一定要把队列清空的逻辑放在 Stop 里,否则最后一个 MP4 文件会缺末尾几秒。

4. 录制参数调优:帧率、分辨率和编码器怎么组合才不翻车

4.1 FourCC 编码器对照:mp4v、avc1 与 MJPG 的差异

OpenCV VideoWriter 用 FourCC 指定编码器,常见的是 mp4v 和 avc1。mp4v 对应 MPEG-4 Part 2,兼容性最好,几乎所有播放器都能开,但压缩率一般,同样画质下文件偏大。avc1 对应 H.264,体积小、画质好,但 OpenCV 官方包不一定带 H.264 编码器,写完 writer.IsOpened() 可能返回 false。

FourCC编码标准文件体积兼容性CPU 开销适用场景
mp4vMPEG-4 Part 2大高中本地存档、快速验证
avc1H.264小中高平台对接、长期存储
MJPGJPEG 帧内压缩很大高低测试流、低分辨率录制

我的判断是:第一次先把 mp4v 跑通,文件大点没关系,稳定优先。项目对体积有硬要求再切 avc1,切完必须在本机实测写 10 秒片段,确认能播。MJPG 适合做测试流,帧内压缩对写盘速度要求低,但要长期录像不建议,一小时能占好几 GB 空间。

4.2 帧率匹配:源 15 帧写入 25 帧会发生什么

VideoWriter 构造里的 fps 参数决定 MP4 容器时间基准。把 15fps 输入当 25fps 写入,OpenCV 不会补帧,它只是按帧计数封包,最后文件时长被压缩到原来的 60%。反过来把 25fps 源写成 15fps,文件时长拉长,但帧间时间戳错位,播放器进度条和画面内容对不上。这两种错误都不报异常,不开播放器根本看不出来,属于隐蔽的翻车。

正确做法是实测帧率。先读 capture.Get(VideoCaptureProperties.Fps),再采样前 50 帧,用 Stopwatch 统计真实间隔:

var probeSw = Stopwatch.StartNew(); int probeFrames = 0; using var probeFrame = new Mat(); while (probeFrames < 50 && capture.Read(probeFrame)) { probeFrames++; } probeSw.Stop(); double realFps = probeFrames * 1000.0 / probeSw.ElapsedMilliseconds; Console.WriteLine($"实测帧率: {realFps:F2}");

这里 realFps 用于初始化 VideoWriter。50 帧在 1080p 下大约耗时 2 秒,对工业采集可以接受。如果 RTSP 源是海康摄像机,主码流和子码流的帧率配置可以不同——主码流可能限制 25 帧,子码流可能只有 10 帧,换一路码流就必须重新采样一次,我一般把这一逻辑做成公共函数,避免每次手敲。

提示:不要永远依赖标称帧率。摄像头在夜间会因自动降低码率导致实际帧率掉到标称值以下,录久了时长偏差会累积到无法忽略。

4.3 文件时长不对:用帧序号代替系统时钟

另一个隐蔽问题是时间戳。许多开发者以为 VideoWriter 会自动打 PTS,其实它只记录帧计数,播放时间由容器帧率和总帧数推算。如果你用 DateTime.Now 来做“写不写这一帧”的判断,文件时长会受线程调度和系统休眠影响,出现每小时差两三秒的漂移。

常见做法是维护单调递增的帧计数,用帧数除以实测帧率得到 elapsed 时间:

long frameIndex = 0; double realFps = 25.0; TimeSpan lastSliceTime = TimeSpan.Zero; while (capture.Read(frame)) { frameIndex++; var elapsed = TimeSpan.FromSeconds(frameIndex / realFps); // 示例:按分钟切换录像文件 if (elapsed - lastSliceTime > TimeSpan.FromMinutes(1)) { writer.Dispose(); writer = CreateNewWriter(elapsed); lastSliceTime = elapsed; } writer.Write(frame); }

elapsed 只用于切片和显示判断,不需要回写 VideoWriter 本身。这样切出来的片段时长相加等于总时长,不会因为 DateTime.Now 被 NTP 校准而对不上。这个习惯后来救了我一次:客户要求按小时归档录像,起初用 DateTime.Now 算切片点,每隔几天就会多出或者少了几秒,换成帧序号之后连续跑了一个星期没有对不上的问题。

5. 避坑排查:五个高频问题与现场解决记录

5.1 问题一:找不到 opencv_world 原生 DLL

现象:程序编译通过,点运行时弹出 System.DllNotFoundException,异常消息明确写着“无法加载 DLL ‘opencv_world450.dll’”,调用栈指到 VideoCapture 构造函数。代码刚从仓库拉下来,换了一台机器就翻车。

原因:bin 目录里没有原生 DLL。常见原因有三个:项目只引用了 OpenCvSharp4,漏了 OpenCvSharp4.runtime.win;NuGet 还原不完整,原生 DLL 没有复制出来;平台目标位数为 AnyCPU,运行时选择了和原生 DLL 不匹配的 CPU 模式。

解决:打开项目属性,把平台目标锁定为 x64 或 x86,然后彻底清理并重新生成。检查 bin 目录里是否出现 opencv_world*.dll 和 opencv_videoio_ffmpeg*.dll。如果还没有,打开 NuGet 包管理器确认 runtime.win 包已安装,版本号和 OpenCvSharp4 一致;实在不行就从 NuGet 缓存把 DLL 手动复制到输出目录,并设置“如果较新则复制”。

5.2 问题二:RTSP 打开缓慢或直接失败

现象:capture.IsOpened() 返回 false,或者打开过程要 5 秒以上;同一地址用 VLC 播放器试却正常。日志里没有任何报错,只有“打不开”这个结果。

原因:OpenCV videoio 默认优先走 UDP/RTP,在跨网段或无线网络下丢包严重,花屏继而断流。另一个常见原因是摄像头码流数量达到上限,比如主码流同时被两台电脑占住,第三个连接被拒。地址路径写错也会这样,海康的主码流路径是 /h264/ch1/main/av_stream,子码流是 /h264/ch1/sub/av_stream,把两者记混了就会打不开。

解决:在 URL 末尾追加 ?tcp 强制走 TCP。标准地址改成 rtsp://admin:密码@IP:554/h264/ch1/main/av_stream?tcp。其次确认摄像头在线用户数没有超限,预留一个给程序用的账号,不要和测试人员共用。最后,如果仍然打不开,先用 VLC 验证原始地址,再检查特殊字符是否做过 URL 编码,密码里带 @ 或 : 时尤其容易踩。

5.3 问题三:MP4 文件花屏、时长异常

现象:录制结束,MP4 文件拖进播放器,花屏、马赛克,或者时长只有实际采集时间的一半。文件大小看起来正常,但播放进度一跳一跳。

原因:VideoWriter 初始化时的 Size 和 fps 与真实帧不一致。尺寸不一致时编码器不知道画面边界,会胡涂;帧率不一致时容器时间轴对不上;还有一种是 FourCC 写了 avc1 而本机没有 H.264 编码器,VideoWriter 默默降级。

解决:初始化 writer 时不要手写数字,从 capture 对象上读属性:capture.Get(VideoCaptureProperties.FrameWidth)、capture.Get(VideoCaptureProperties.FrameHeight)、capture.Get(VideoCaptureProperties.Fps)。先写 mp4v 做验证,等稳定了再考虑 avc1。写入前加一个 if (frame.Empty()) continue,把空帧挡在编码器外面。

5.4 问题四:长时间运行内存暴涨

现象:内存曲线持续上升,从启动时的 200MB 涨到 6 小时后的数 GB,最后程序卡死。任务管理器里看到句柄数也在涨。

原因:每帧 new Mat 没有 Dispose。拉流线程一秒钟产生 25 个新 Mat,不及时释放就成了内存泄漏。另一方面,生产者消费者队列没有设置上限,写盘速度跟不上时,队列无限积压,内存自然起飞。

解决:给所有 Mat 加上 using 或手动 Dispose,特别是从队列里丢弃的老帧。队列加上限,比如 60 帧,满了就丢最旧帧保新帧。用性能计数器监控 OpenCvSharp 的 Mat 句柄数量,连续两小时未见回调,说明释放路径是通的。

5.5 问题五:CPU 占用居高不下

现象:录 1080p 码流时 CPU 占用 90% 以上,界面拖动卡顿,风扇转得像起飞。

原因:循环里用了 WaitKey(1),预览线程空转,解码和绘制都在跑;MJPG 编码在 CPU 上软压 1080p 非常吃力;解码线程数没有被限制,四核八线程机器会被拉满。

解决:预览和录制分开,预览走低分辨率,比如把 Mat resize 到 640×360 再显示,录制仍写原始帧。WaitKey 从 1 改成 30,限制预览刷新率。全局加 Cv2.SetNumThreads(2),把解码线程数压下来。如果还是高,检查你的 FourCC 是不是 MJPG,换 mp4v 再看。

6. 断线重连与录制验证:把示例改成 7×24 小时稳定运行

6.1 断线自动重连:失败计数与重建 VideoCapture

摄像头断网、NVR 重启都会导致拉流中断,简单跳出循环是录不长久的。我常用的模式是内层循环读帧,连续失败 20 次就重建 VideoCapture,重建前停 2 秒。

int failCount = 0; while (!_cts.IsCancellationRequested) { using var capture = new VideoCapture(url); if (!capture.IsOpened()) { Thread.Sleep(2000); continue; } failCount = 0; using var frame = new Mat(); while (capture.Read(frame)) { if (frame.Empty()) break; writer.Write(frame); failCount = 0; } if (++failCount >= 20) { Thread.Sleep(2000); failCount = 0; } }

重连时务必用 using 释放旧 capture,再 new 新实例;在同一实例上反复 Open,容易让 FFmpeg 句柄卡死。重连后帧率和分辨率可能变化,writer 也要按新参数重建,否则继续花屏。

6.2 验证 MP4 完整性:文件大小、首帧非零和播放器实测

录制逻辑跑通后,最可靠的验证不是看代码,是直接打开 MP4。我用 ffprobe 查看时长和总帧数,用播放器快进到中段看是否花屏,顺便确认有没有音轨。RTSP 源如果只有视频,写出来的 MP4 没有音轨是正常的,别当成损坏。时长误差超过 1%,回查 VideoWriter 的 fps 参数。

6.3 进阶方向:把帧推给 FFmpeg 编码或接入显示控件

录制稳定后想压缩体积或切片,可以把 Mat 帧交给 FFmpeg 编码器。OpenCvSharp 拿到的是 BGR 排列,推给 FFmpeg 前用 Cv2.CvtColor 转成 RGB/YUV,开销很小。只做监控展示的话,把解码帧直接绑定到控件背景,比每次都转 Bitmap 高效。

这份 .rar 里的示例正是按这套流程组织的,解压后打开方案,把 RTSP 地址和输出路径改成本地值就能跑通。第一次做摄像头录制项目时,我以为会写窗口、把帧塞进 VideoWriter 就完工了,结果被 DLL 加载、TCP 传输、帧率漂移、断线重连轮番教育。从那以后,每次搭录制服务,我都强制走一遍四个验证:地址能不能在播放器出画、平台位数和原生 DLL 是否匹配、VideoWriter 参数是否从 capture 读取、断网后能否自动恢复。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 1:09:26

嵌入式校招岗位需求全解析:从日报看技术路线与面试准备

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

作者头像 李华
网站建设 2026/10/28 1:08:59

RV1126B边缘NPU实战:8大图像分类模型RKNN部署与量化对比

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

作者头像 李华
网站建设 2026/10/28 1:08:42

多差速无人车编队控制:ROS/C++源码实现与调参实战

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

作者头像 李华
网站建设 2026/10/28 1:08:42

国产SiC MOSFET通过AEC-Q101与HV-H3TRB车规认证意味着什么

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

作者头像 李华
网站建设 2026/10/28 1:08:15

STM32F030C8T6+MRT311额温枪方案:硬件设计、软件流程与标定避坑

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

作者头像 李华
网站建设 2026/10/28 1:08:13

嵌入式AI协同开发实战:用Codex类工具提效重复编码

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

作者头像 李华