OV7670+SDRAM+VGA:FPGA实时图像链路设计与避坑指南
简介这是一套面向FPGA与数字图像处理学习者的完整工程实践资料围绕OV7670 CMOS摄像头、SDRAM缓存与VGA显示构建实时视频采集与显示链路并集成中值滤波算法改善画质。项目覆盖IIC寄存器配置、SDRAM控制器读写、VGA时序驱动以及灰度图像去噪等关键环节适合已具备Verilog或VHDL基础、希望深入理解FPGA图像处理系统架构的开发者与在校学生参考。压缩包共576个文件约11.74MB以cdb、hdb等Quartus工程数据库文件为主配合v、vhd源码、tdf与qip工程配置、dat与mif初始化数据、rpt报告及txt说明文档完整保留了综合、布局布线与仿真流程痕迹。目前已有397人学习下载。通过研究该工程读者可掌握摄像头数据流到SDRAM再到VGA显示的完整通路设计理解中值滤波在FPGA上的实现思路并借鉴SDRAM多端口控制与跨时钟域处理的工程经验为自建图像采集与显示系统提供可复用的参考框架。1. OV7670 SDRAM VGA一条被低估的实时图像链路很多人第一次接触图像采集都是从 OV7670 这颗摄像头模组开始的。它便宜、资料多、时序相对规整但真正把它跑成一条「摄像头采集 → SDRAM 缓存 → VGA 实时显示」的完整链路翻车的人远比想象中多。原因不在某一块而在三块拼在一起时的时序配合OV7670 输出的是连续像素流VGA 扫描是另一套独立的时序两者频率不同、节拍不同中间必须靠一块 SDRAM 做缓冲否则屏幕上要么撕裂要么花屏要么干脆一片黑。这套方案解决的核心问题就一个让摄像头看到的画面稳定地出现在 VGA 显示器上不依赖任何上位机、不依赖操作系统纯硬件逻辑跑通。它适合正在学 FPGA 图像处理、想搞懂跨时钟域数据搬运、或者需要一个低成本实时显示验证平台的从业者。下面我按自己实际调通的顺序把选型、时序、缓存、显示和踩坑一条条讲清楚。2. 三块硬件的角色分工与时钟账怎么算2.1 OV7670 输出的是什么别被「摄像头」三个字骗了OV7670 不是那种「给个命令就吐一帧图」的器件。它上电后需要配置寄存器配置完就按自己的节奏持续输出像素PCLK 是像素时钟VSYNC 是帧同步HREF 是行有效D0~D7 是 8 位并行数据。它默认输出 RGB565一个像素两个字节分两次在 PCLK 上升沿给出高字节和低字节。关键点在于OV7670 的 PCLK 频率由内部 PLL 和寄存器决定常见配置下大约在 12MHz 到 24MHz 之间。这个时钟和 VGA 的像素时钟25.175MHz 或 25MHz不是同源也不是整数倍关系。所以你不能把 OV7670 的 PCLK 直接当 VGA 时钟用必须做跨时钟域处理。我一般会先把 OV7670 配成 QVGA320×240输出RGB565这样一帧数据量是 320×240×2 153600 字节约 150KB。这个量级对 SDRAM 来说很轻松但对 FPGA 内部 RAM 来说就偏大了所以外挂 SDRAM 是合理选择。2.2 SDRAM 为什么是这块板子的核心而不是可选件有人会问能不能用 FPGA 内部 Block RAM 做缓存可以但容量不够。一块常见的中低端 FPGA内部 RAM 也就几十到一百多 KB存一帧 QVGA RGB565 刚好卡在边缘存两帧做乒乓就完全不够。而 SDRAM 便宜、容量大一颗 16MB 的 SDRAM 能存上百帧 QVGA 数据做双缓冲甚至多缓冲都绰绰有余。但 SDRAM 的麻烦在于它是动态存储需要定期刷新而且读写命令、行激活、列地址、预充电这一套时序必须严格按手册来。你不能像读写 SRAM 那样直接给地址就出数据。所以中间必须有一个 SDRAM 控制器把「我要写这个地址」翻译成 SDRAM 能听懂的命令序列。常见做法是写侧用 OV7670 的 PCLK 驱动读侧用 VGA 的像素时钟驱动两边通过 SDRAM 控制器做异步 FIFO 加仲裁。这样摄像头写它的VGA 读它的互不阻塞。2.3 时钟账三个时钟域怎么划分这套系统里至少有三个时钟域时钟域来源典型频率用途摄像头域OV7670 PCLK12~24MHz采集像素、写 SDRAMSDRAM 域外部晶振经 PLL100MHzSDRAM 控制器主时钟VGA 域外部晶振经 PLL25.175MHz读出像素、生成行场同步三个时钟之间都要做跨时钟域处理。摄像头域到 SDRAM 域用异步 FIFOSDRAM 域到 VGA 域也用异步 FIFO。FIFO 的深度不用太大16 到 32 深就够了因为 SDRAM 的读写带宽远高于摄像头和 VGA 的需求。注意异步 FIFO 的读写指针比较一定要用格雷码否则多比特跨时钟域会出亚稳态表现就是偶尔花屏、偶尔丢行这种玄学问题最难查。3. 从 OV7670 到 SDRAM采集与写入的落地步骤3.1 OV7670 寄存器配置SCCB 写序列OV7670 通过 SCCB 总线配置SCCB 和 I2C 很像但细节有差异。下面是一段典型的初始化写序列用 Verilog 任务封装// SCCB 写一个寄存器 task sccb_write; input [7:0] reg_addr; input [7:0] reg_data; begin sccb_start(); sccb_send_byte(8h42); // 器件写地址 sccb_check_ack(); sccb_send_byte(reg_addr); // 寄存器地址 sccb_check_ack(); sccb_send_byte(reg_data); // 寄存器数据 sccb_check_ack(); sccb_stop(); end endtask // 关键寄存器配置片段 initial begin sccb_write(8h12, 8h80); // 复位所有寄存器 #1000; sccb_write(8h12, 8h00); // 解除复位 sccb_write(8h0C, 8h00); // 关闭缩放 sccb_write(8h3E, 8h00); // 关闭 PLL sccb_write(8h40, 8hD0); // 设置 RGB565 输出 sccb_write(8h12, 8h14); // QVGA 格式 sccb_write(8h11, 8h80); // 时钟分频PCLK 约 12MHz sccb_write(8h0D, 8h40); // 开启 PLL sccb_write(8h13, 8hC0); // 开启自动增益 end这段代码的逻辑是先软复位再逐项配置输出格式、分辨率、时钟和增益。参数说明上0x12写0x14表示 QVGA写0x00表示 VGA0x40写0xD0表示 RGB565写0x10表示 YUV。PCLK 频率由0x11的分频值和0x0D的 PLL 倍频共同决定实际调的时候先用示波器量 PCLK确认在 12~24MHz 之间再往下走。3.2 采集状态机VSYNC、HREF、PCLK 三者怎么配合OV7670 的时序是VSYNC 下降沿表示一帧开始之后 HREF 拉高表示一行有效HREF 高电平期间每个 PCLK 上升沿输出一个字节。RGB565 一个像素两个字节所以每两个 PCLK 组成一个像素。采集状态机我一般写成三段式// 采集状态机核心逻辑 always (posedge pclk or negedge rst_n) begin if (!rst_n) begin state IDLE; wr_en 0; end else begin case (state) IDLE: begin if (!vsync) state FRAME_WAIT; // 检测到帧同步 end FRAME_WAIT: begin if (href) state LINE_CAPTURE; // 行有效开始 end LINE_CAPTURE: begin if (href) begin byte_cnt byte_cnt 1; if (byte_cnt[0]) begin // 第二个字节拼成 16 位像素 pixel_data {first_byte, d_in}; wr_en 1; end else begin first_byte d_in; wr_en 0; end end else begin state FRAME_WAIT; byte_cnt 0; end end endcase end end逻辑说明IDLE 等 VSYNC 下降沿FRAME_WAIT 等 HREF 拉高LINE_CAPTURE 在 HREF 高期间按字节计数奇数字节暂存偶数字节拼成 16 位像素并拉高写使能。参数上byte_cnt[0]用来区分高低字节这个写法比用模 2 计数器更省资源。写使能只持续一个 PCLK 周期后面接异步 FIFO 的写端口。3.3 写入 SDRAM地址怎么算、突发怎么设SDRAM 写入用突发模式效率最高。常见做法是异步 FIFO 攒够一定数量比如 16 个像素后SDRAM 控制器发起一次突发写。地址计算上QVGA 一帧 320×240每个像素 2 字节所以一行 640 字节一帧 153600 字节。// SDRAM 写地址生成 always (posedge sdram_clk) begin if (frame_start) begin wr_addr_base 0; // 第一帧从 0 开始 end if (burst_done) begin wr_addr wr_addr_base pixel_cnt * 2; end end参数说明wr_addr_base用来做双缓冲切换第一帧写 0 地址第二帧写 153600 地址第三帧再回 0这样 VGA 读的时候不会读到正在写的帧。突发长度一般设 8 或 16太长会占满总线影响读侧太短则效率低。我一般设 8配合 100MHz 的 SDRAM 时钟写带宽足够。4. 从 SDRAM 到 VGA读出与显示的落地步骤4.1 VGA 时序生成行场同步的参数怎么定VGA 显示 QVGA 分辨率需要把 320×240 的画面放大到 640×480 或直接居中显示。我一般用 640×48060Hz像素时钟 25.175MHz行同步和场同步参数如下参数行像素场行有效区640480前肩1610同步脉冲962后肩4833总计800525行同步和场同步都是低电平有效。生成逻辑就是一个计数器数到有效区就输出像素数到同步区就拉低同步信号。// 行场同步生成 always (posedge vga_clk) begin if (h_cnt 800) h_cnt h_cnt 1; else begin h_cnt 0; if (v_cnt 525) v_cnt v_cnt 1; else v_cnt 0; end end assign hsync (h_cnt 64016 h_cnt 6401696) ? 0 : 1; assign vsync (v_cnt 48010 v_cnt 480102) ? 0 : 1; assign de (h_cnt 640 v_cnt 480) ? 1 : 0;参数说明de是数据有效信号只有它为高时RGB 输出才被显示器采样。行计数 800、场计数 525 是 640×48060Hz 的标准值改分辨率时这几个数都要重算。4.2 读出 SDRAM读地址怎么和 VGA 扫描对齐VGA 扫描到第 x 行第 y 列时需要从 SDRAM 里读出对应像素。但 QVGA 是 320×240VGA 是 640×480所以要做 2 倍放大VGA 的 (x, y) 对应 QVGA 的 (x/2, y/2)。// 读地址计算 always (posedge vga_clk) begin if (de) begin qvga_x h_cnt 1; // 除以 2 qvga_y v_cnt 1; rd_addr rd_addr_base (qvga_y * 320 qvga_x) * 2; end end逻辑说明h_cnt 1和v_cnt 1实现 2 倍放大每个 QVGA 像素在 VGA 上显示成 2×2 的块。rd_addr_base和写侧的wr_addr_base相反写第一帧时读第二帧写第二帧时读第一帧这就是双缓冲。参数上320是 QVGA 行像素数*2是因为每个像素 2 字节。如果改成 VGA 分辨率采集这里就是640和*2但 SDRAM 容量要够。4.3 双缓冲切换什么时候切、怎么保证不撕裂双缓冲的核心是写侧写满一帧后发一个切换信号读侧从下一帧开始读新地址。切换时机必须在帧消隐期否则会撕裂。// 双缓冲切换 always (posedge vsync) begin if (frame_written) begin buf_sel ~buf_sel; // 切换缓冲 frame_written 0; end end assign wr_addr_base buf_sel ? 153600 : 0; assign rd_addr_base buf_sel ? 0 : 153600;逻辑说明vsync下降沿时判断上一帧是否写完写完就切换buf_sel。wr_addr_base和rd_addr_base始终相反保证读的不是正在写的。参数上153600是一帧 QVGA RGB565 的字节数改分辨率时这个值要重算。注意切换信号一定要同步到读侧时钟域再使用否则读侧可能采到亚稳态表现就是切换瞬间闪一下或者花几行。5. 避坑与排查这套链路最容易翻车的五个地方5.1 现象屏幕全黑但摄像头和 SDRAM 都在跑原因VGA 的 DE 信号极性搞反了或者行场同步的极性不对。有些显示器要求同步信号高有效有些要求低有效OV7670 和 VGA 的默认极性不一定一致。解决先用示波器量 hsync 和 vsync确认在有效区之外有正确的脉冲。如果显示器不支持自动识别手动改极性再试。DE 信号如果反了像素数据会被当成消隐区丢掉。5.2 现象画面撕裂上下半屏内容不一致原因双缓冲切换时机不对在帧中间切了。或者读写地址基址算错读侧读到了正在写的区域。解决切换信号必须锁在 vsync 消隐期而且要用读侧时钟同步一次。地址基址用参数化写法别手算容易错一个字节就整体偏移。5.3 现象画面有规律的花屏每隔几行出现噪点原因SDRAM 刷新和读写冲突。SDRAM 需要定期刷新如果读写请求太密集刷新被推迟数据就会丢。解决在 SDRAM 控制器里给刷新请求最高优先级读写请求做仲裁。如果还是花降低突发长度给刷新留更多窗口。另外检查 SDRAM 时钟是否稳定100MHz 下布线不好也会导致偶发错误。5.4 现象颜色不对偏红或偏绿原因RGB565 的高低字节顺序搞反了。OV7670 先输出高字节再输出低字节如果拼接时顺序反了颜色就错。解决在采集状态机里确认{first_byte, d_in}的顺序。如果颜色整体偏检查 RGB565 到 VGA 的映射R 在高 5 位、G 在中间 6 位、B 在低 5 位别接错。5.5 现象画面能显示但帧率很低明显卡顿原因SDRAM 读写带宽不够或者 FIFO 深度太小导致频繁等待。解决算一下带宽账。QVGA RGB565 一帧 150KB60fps 就是 9MB/sSDRAM 100MHz 下带宽远大于此所以瓶颈一般在仲裁逻辑。把读写请求做成突发减少命令开销。FIFO 深度加到 32 或 64让突发更连续。6. 进阶技巧用参数化写法让这套链路能换分辨率这套链路调通之后最值钱的地方不是「能显示」而是「能换分辨率」。我后来把采集、缓存、显示三部分的参数全部抽出来做成一个参数化模块改几个数字就能从 QVGA 切到 VGA或者从 RGB565 切到灰度。// 参数化配置 parameter H_ACTIVE 640; // 行有效像素 parameter V_ACTIVE 480; // 场有效行 parameter PIXEL_BYTES 2; // 每像素字节数 parameter BUF_DEPTH 153600; // 一帧字节数 // 地址计算自动适配 assign frame_size H_ACTIVE * V_ACTIVE * PIXEL_BYTES; assign wr_addr_base buf_sel ? frame_size : 0; assign rd_addr_base buf_sel ? 0 : frame_size;这样改分辨率时只需要改H_ACTIVE、V_ACTIVE和PIXEL_BYTES地址和缓冲切换逻辑自动跟着变。我一般还会加一个缩放因子参数让 QVGA 源在 VGA 屏上按整数倍放大避免插值带来的额外逻辑。验证方法上我习惯先用一个固定的测试图案比如彩条写入 SDRAM不接摄像头直接看 VGA 显示是否正常。彩条对了说明 SDRAM 读写和 VGA 时序没问题再接摄像头如果花屏问题就在采集侧。这种分段验证能省很多时间别一上来就全链路联调出了问题根本不知道是哪一段。血泪经验是SDRAM 的引脚分配一定要按手册来特别是时钟和片选走线等长、远离干扰源。我见过因为 SDRAM 时钟走线太长导致偶发丢数据的查了两天才发现是布线问题。另外异步 FIFO 的格雷码转换别忘了这个后悔药不好吃。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

从8k到1M上下文的通用化:利用Qwen-Agent的创新实践

从8k到1M上下文的通用化:利用Qwen-Agent的创新实践

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

2026/10/15 12:37:05 阅读更多 →
Java实现抖音代码跳舞:音频节拍驱动字符动画实战

Java实现抖音代码跳舞:音频节拍驱动字符动画实战

简介:一份面向Java开发者的抖音代码跳舞动画源码包,适用于希望掌握JavaFX或Swing图形编程、动画控制及文本可视化技术的学习者。该资源将Java代码文本以动态形式渲染到图形界面,并结合时间轴控制实现类似抖音的跳舞节奏效果。 压缩包共包含1…

2026/10/15 12:37:05 阅读更多 →
Floodlight实战指南:从OpenFlow控制器到静态流表路径调度

Floodlight实战指南:从OpenFlow控制器到静态流表路径调度

简介:面向SDN初学者与技术爱好者,这份基于Java的开源控制器Floodlight安装部署资料,重点解决“如何在Linux环境中快速搭建控制器”这一实际问题。内容从概述、安装前准备切入,特别提醒非专业人士建议在虚拟机上操作,并…

2026/10/15 12:37:05 阅读更多 →

最新新闻

MapLibre GL JS:高性能Web地图开发指南

MapLibre GL JS:高性能Web地图开发指南

1. 项目概述:MapLibre GL JS 是什么?MapLibre GL JS 是一个开源的 JavaScript 库,用于在网页中嵌入高性能、可定制的交互式地图。它基于 WebGL 技术构建,能够流畅渲染大量地理数据,支持矢量切片、3D 地形等现代地图功能…

2026/10/15 13:22:53 阅读更多 →
电商评论分析系统实战:从零搭建分层处理架构与情感判断流程

电商评论分析系统实战:从零搭建分层处理架构与情感判断流程

1. 从零搭建商品评论分析系统:一个电商数据人的实战复盘做电商数据分析这几年,我经手过不少评论相关的需求。从最早的Excel手工筛选,到后来写Python脚本跑词频,再到现在搭建一套相对完整的评论分析流程,踩过的坑比想象…

2026/10/15 13:22:53 阅读更多 →
Superpowers 实战指南:用可插拔技能包重构开发者工作流

Superpowers 实战指南:用可插拔技能包重构开发者工作流

做开发这些年,我最大的痛点不是写不出代码,而是每次换电脑、换团队,那些好不容易攒出来的效率工具和脚本就散架了。有段时间我沉迷收集各种 CLI 工具,但一来互相之间没关联,二来团队其他人根本不知道怎么用&#xff0c…

2026/10/15 13:22:53 阅读更多 →
LSTM股票时间序列预测实战:从数据准备到模型调参的完整指南

LSTM股票时间序列预测实战:从数据准备到模型调参的完整指南

1. 为什么用LSTM做股票时间序列预测股票价格预测这件事,圈外人觉得是玄学,圈内人知道它是个典型的时间序列回归问题。你手头有一串按时间排列的价格数据,想根据过去N天的走势推测明天大概会落在什么区间。传统方法比如ARIMA、GARCH这些统计模…

2026/10/15 13:22:53 阅读更多 →
FFmpeg Qt播放器稳定化:线程模型、seek与内存泄漏修复实战

FFmpeg Qt播放器稳定化:线程模型、seek与内存泄漏修复实战

简介:基于FFmpeg与Qt的播放器最终完善版工程,针对从零开始学习音视频编程的开发者,适合已在Qt中编写播放器、希望处理异常场景的读者。代码将底层播放器与Qt界面拆分为两个模块,底层用纯C编写,便于移植;支持…

2026/10/15 13:22:53 阅读更多 →
Spring Boot 部署实践:jar、war、Docker与Compose

Spring Boot 部署实践:jar、war、Docker与Compose

部署这事,看起来简单,实际坑不少。刚学 Spring Boot 的时候,我都是本地mvn spring-boot:run跑起来就完事,直到有一次要把一个“某订单服务”项目丢到测试服务器上给同事联调,才真正体会到“跑起来”和“部署好”是两码…

2026/10/15 13:21:53 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 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/15 0:14:36 阅读更多 →
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/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练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/15 9:49:14 阅读更多 →