news 2026/10/16 1:24:24

ZYNQ 7000 AMP双裸核实战:共享内存交互与Cache一致性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ZYNQ 7000 AMP双裸核实战:共享内存交互与Cache一致性

1. 为什么要在 ZYNQ 7000 上折腾 AMP 双裸核

ZYNQ 7000 系列最让人又爱又恨的地方,就是它那颗双核 Cortex-A9。爱的是两个核都能跑到 666MHz 甚至 766MHz,算力在嵌入式领域相当能打;恨的是大多数项目里,第二个核要么被 Linux 晾在一边,要么干脆被关掉省电。我接触过的不少工业控制和数据采集项目,CPU0 跑 Linux 做网络通信和文件系统,CPU1 却闲得发慌,而同时 FPGA 侧的实时数据又需要低延迟处理,Linux 的调度抖动根本扛不住微秒级的响应要求。

这时候 AMP(Asymmetric Multiprocessing,非对称多处理)模式就派上用场了。简单说,就是两个核各跑各的裸机程序,互不干扰,通过共享内存交换数据。CPU0 可以继续跑 Linux 或者裸核负责管理,CPU1 专职跑实时任务,两边通过一块约定的内存区域来传递命令和数据。这种架构在电机控制、高速数据采集、协议转换网关里非常常见,核心诉求就一个:实时性要硬,延迟要可预测。

但 AMP 不是配置一下就能跑起来的。我踩过的坑包括:CPU1 启动后直接跑飞、共享内存数据出现撕裂、Cache 一致性问题导致读到旧数据、中断无法正确路由到 CPU1。这些问题在单核开发里根本遇不到,一旦碰上,调试起来相当折磨人。这篇文章就把我从零搭建 ZYNQ 7000 AMP 双裸核共享内存交互的完整过程拆开讲,包括启动流程、内存规划、Cache 处理、中断路由和实际调试技巧,适合已经有一定 ZYNQ 裸机开发基础、想往多核方向走的工程师参考。

2. AMP 架构整体设计与内存规划

2.1 为什么选 AMP 而不是 SMP

ZYNQ 7000 的 Cortex-A9 双核支持 SMP(对称多处理),Linux 下默认就是 SMP 模式,两个核一起跑内核调度。但 SMP 的问题在于,Linux 的调度器不会保证你的实时任务一定在某个核上执行,中断也可能被任意核处理,上下文切换带来的延迟抖动可能达到几百微秒甚至毫秒级。对于需要稳定在 10 微秒以内响应的场景,SMP 基本没法用。

AMP 的思路完全不同:两个核在物理上隔离,各自有独立的代码段、数据段和栈空间,CPU0 跑一个裸机程序(或者 Linux),CPU1 跑另一个裸机程序。两边通过共享内存通信,通信协议自己定义。这样做的好处是实时性完全可控,CPU1 的代码不会被任何其他任务打断,中断响应延迟只取决于代码本身的执行时间。

代价也很明显:两个核之间没有操作系统层面的同步机制,所有同步都要自己实现;内存需要手动划分,不能重叠;Cache 一致性需要手动维护。但这些代价换来的是确定性的实时性能,在工业场景里这笔账算得过来。

2.2 内存地图怎么划分

ZYNQ 7000 的 DDR 通常从 0x0000_0000 开始,大小取决于具体型号和板卡设计,常见的有 512MB 和 1GB。AMP 模式下,两个核必须使用不同的内存区域,绝对不能重叠,否则一个核写数据可能覆盖另一个核的代码或栈。

我的划分方案是这样的:

内存区域起始地址大小用途
CPU0 代码与数据0x0010_0000256MBCPU0 裸机程序或 Linux
CPU1 代码与数据0x1010_0000128MBCPU1 裸机程序
共享内存区0x1810_00001MB双核通信缓冲区
保留区0x1820_0000剩余其他用途

这里有几个关键点需要说明。CPU0 的起始地址设为 0x0010_0000 而不是 0x0000_0000,是因为 ZYNQ 的 OCM(On-Chip Memory)和部分外设映射在低地址区域,避开这些区域可以防止意外访问。CPU1 的代码起始地址 0x1010_0000 是相对于 DDR 基址的偏移,实际使用时需要根据 DDR 大小调整。

共享内存区选 1MB 是经验值。太小了不够用,太大了浪费。1MB 可以放 256 个 4KB 的数据块,或者做双缓冲加命令队列绰绰有余。地址 0x1810_0000 选在 CPU1 区域之后,确保不会和任何一方的代码段冲突。

注意:内存划分必须在链接脚本和 FSBL(First Stage Boot Loader)里同时体现,任何一处不一致都会导致核间访问异常。

2.3 启动流程设计

ZYNQ 的启动流程是:BootROM 加载 FSBL,FSBL 初始化 DDR 和部分外设,然后加载 CPU0 的应用程序。CPU1 默认处于 WFE(Wait For Event)状态,需要 CPU0 主动唤醒。

AMP 模式下,CPU1 的启动有两种方式。第一种是 CPU0 通过 SLCR(System Level Control Register)寄存器直接释放 CPU1 的复位,然后 CPU1 从约定的地址开始执行。第二种是使用 Xilinx 提供的XFsbl_StartAppCpu1接口,在 FSBL 阶段就把 CPU1 的应用程序加载到指定地址并启动。

我推荐第二种方式,因为 FSBL 已经完成了 DDR 初始化,CPU1 启动时可以直接访问 DDR,不需要重复初始化。具体做法是在 FSBL 的main.c里调用XFsbl_StartAppCpu1,传入 CPU1 应用程序的入口地址。这个地址就是链接脚本里 CPU1 代码段的起始地址。

CPU0 这边需要在启动后等待 CPU1 完成初始化。我的做法是在共享内存里放一个握手标志,CPU1 初始化完成后把标志置为 0x55AA55AA,CPU0 轮询这个标志,确认 CPU1 就绪后再开始发送命令。

3. 双裸核工程搭建与关键配置

3.1 两个裸机工程的创建与链接脚本

在 Xilinx SDK 或者 Vitis 里创建两个独立的 Application Project,分别命名为cpu0_app和cpu1_app。两个工程都选择Empty Application模板,不要用Hello World,因为模板里包含的打印代码会引入不必要的依赖。

链接脚本是 AMP 配置的核心。CPU0 的链接脚本里,代码段起始地址设为 0x0010_0000,堆栈大小根据实际需求调整,我一般给 0x10000(64KB)的栈空间。CPU1 的链接脚本里,代码段起始地址设为 0x1010_0000,栈空间同样给 64KB。

这里有个容易忽略的细节:两个工程的.bss段和.data段必须完全分开,不能有任何重叠。我见过有人把两个工程的链接脚本写成一样的,结果 CPU1 启动后直接覆盖了 CPU0 的数据,系统跑几分钟就崩了。检查方法是编译完成后查看.map文件,确认两个工程的地址范围没有交集。

共享内存区不需要在链接脚本里分配,直接在代码里用指针访问固定地址即可。但要注意,共享内存区的地址必须在两个工程里保持一致,建议定义一个公共头文件,两边都包含。

3.2 CPU1 启动代码的关键细节

CPU1 的启动代码和普通裸机程序不太一样。CPU1 被唤醒后,首先需要设置自己的栈指针,然后跳转到main函数。这段代码通常用汇编写,放在 CPU1 工程的启动文件里。

关键步骤包括:关闭 CPU1 的 Cache 和 MMU(如果不需要),设置 VBAR(Vector Base Address Register)指向 CPU1 自己的中断向量表,初始化栈指针,然后调用main。这里最容易出错的是 VBAR 的设置,如果 VBAR 指向了 CPU0 的向量表,CPU1 一旦发生中断就会跳到 CPU0 的代码里执行,后果不可预测。

CPU1 的main函数里,第一件事是初始化共享内存的握手标志,然后进入主循环等待 CPU0 的命令。主循环里不要用while(1)空转,最好加一个wfe指令,让 CPU1 在等待命令时进入低功耗状态,收到事件后再唤醒。这样既能省电,又能减少总线争用。

3.3 共享内存数据结构设计

共享内存的数据结构设计直接决定了通信效率和可靠性。我的方案是采用环形缓冲区加命令队列的方式。

环形缓冲区用于批量数据传输,比如 CPU1 采集的 ADC 数据要传给 CPU0 处理。缓冲区大小设为 512KB,分成 128 个 4KB 的块,每个块有一个状态标志(空、满、正在写、正在读)。CPU1 写数据时先检查块状态,确认是空才写入,写完把状态改为满。CPU0 读取时先检查状态,确认是满才读取,读完把状态改为空。

命令队列用于控制指令传递,比如 CPU0 通知 CPU1 开始采集、停止采集、修改采样率等。队列用数组实现,每个元素是一个结构体,包含命令码、参数和校验字段。队列的头尾指针也放在共享内存里,两边通过原子操作更新。

typedef struct { volatile uint32_t head; volatile uint32_t tail; volatile uint32_t count; uint32_t reserved[5]; } ring_buffer_ctrl_t; typedef struct { volatile uint32_t status; uint32_t length; uint8_t data[4088]; } shared_block_t; typedef struct { ring_buffer_ctrl_t ctrl; shared_block_t blocks[128]; } shared_memory_t;

这个结构体里,volatile关键字非常重要,它告诉编译器这些变量可能被外部修改,不要做优化。reserved字段用于对齐,确保结构体大小是 64 字节的整数倍,方便 Cache 操作。

4. Cache 一致性与中断路由实战

4.1 Cache 问题为什么这么坑

Cortex-A9 每个核都有自己的 L1 Cache 和共享的 L2 Cache。在 AMP 模式下,CPU0 写共享内存时,数据可能只写到了 CPU0 的 L1 Cache 里,还没有同步到 DDR。CPU1 读同一块内存时,如果 CPU1 的 L1 Cache 里恰好有旧数据的缓存行,它就会读到旧数据,而不是 CPU0 刚写的新数据。

这个问题在单核开发里根本不存在,因为单核自己写自己读,Cache 是一致的。但双核各写各读,Cache 就不一致了。解决方法是手动维护 Cache 一致性,具体操作包括:写共享内存后执行 Clean 操作,把 Cache 里的数据刷到 DDR;读共享内存前执行 Invalidate 操作,把 Cache 里的旧数据标记为无效,强制从 DDR 重新读取。

Xilinx 提供了Xil_DCacheFlushRange和Xil_DCacheInvalidateRange函数,分别对应 Clean 和 Invalidate 操作。使用时传入共享内存的起始地址和长度即可。

// CPU0 写共享内存后 Xil_DCacheFlushRange((u32)shared_mem, sizeof(shared_memory_t)); // CPU1 读共享内存前 Xil_DCacheInvalidateRange((u32)shared_mem, sizeof(shared_memory_t));

注意:Flush 和 Invalidate 操作的开销不小,不要每次读写几个字节就调用一次。我的做法是把共享内存按 4KB 分块,每次操作一整块,减少 Cache 维护次数。

4.2 中断如何正确路由到 CPU1

ZYNQ 的中断控制器(GIC)支持将中断路由到指定的 CPU。默认情况下,所有中断都路由到 CPU0。要让 CPU1 处理某些中断,需要在 GIC 的配置寄存器里设置目标 CPU。

具体操作是修改 GIC 的ICDIPTR寄存器,把对应中断号的目标 CPU 设为 CPU1。比如,如果 CPU1 要处理 PL 侧的中断,就需要把该中断号的目标 CPU 改为 CPU1。这个配置通常在 CPU0 的初始化代码里完成,因为 GIC 的全局配置只能由 CPU0 修改。

配置完成后,CPU1 还需要使能自己的中断控制器接口,也就是 GIC 的 CPU Interface。这包括设置ICCICR寄存器使能中断,设置ICCPMR寄存器设置优先级掩码。这些操作在 CPU1 的启动代码里完成。

我踩过的一个坑是:CPU1 的中断向量表没有正确设置,导致中断触发后跳到了错误地址。后来发现是 VBAR 寄存器在 CPU1 启动时被设置成了 CPU0 的向量表地址。解决方法是在 CPU1 的启动汇编里显式设置 VBAR,指向 CPU1 自己的向量表。

4.3 共享内存通信协议实现

通信协议的核心是状态机和握手信号。我设计的协议包含以下几个状态:空闲、CPU0 发送命令、CPU1 执行命令、CPU1 返回结果、CPU0 读取结果。

CPU0 发送命令时,先把命令写入命令队列,然后更新队列的 tail 指针,最后触发一个软件中断(SGI)通知 CPU1。CPU1 收到中断后,读取命令队列,执行命令,把结果写入结果队列,然后触发 SGI 通知 CPU0。CPU0 收到中断后读取结果。

这里的关键是 SGI 的使用。Cortex-A9 支持 16 个软件中断,编号 0 到 15。我一般用 SGI 0 作为 CPU0 通知 CPU1 的信号,SGI 1 作为 CPU1 通知 CPU0 的信号。SGI 的触发通过写 GIC 的ICDSGIR寄存器实现,指定目标 CPU 和中断号即可。

// CPU0 触发 SGI 0 通知 CPU1 XScuGic_SoftwareIntr(&intc, 0, XSCUGIC_SPI_TARGET_CPU1); // CPU1 触发 SGI 1 通知 CPU0 XScuGic_SoftwareIntr(&intc, 1, XSCUGIC_SPI_TARGET_CPU0);

提示:SGI 的优先级要设得足够高,否则可能被其他中断阻塞,导致通信延迟增大。

5. 调试过程中遇到的典型问题与排查

5.1 CPU1 启动后跑飞

这是最常见的问题,表现是 CPU1 启动后没有任何输出,或者输出乱码。原因通常有三个:链接脚本地址错误、栈指针未初始化、向量表未设置。

排查方法是先用调试器连接 CPU1,查看 PC 指针停在哪里。如果 PC 停在 0x0000_0000 附近,说明向量表没设置对。如果 PC 停在某个非法地址,说明链接脚本里的代码段地址和实际加载地址不一致。如果 PC 正常但栈操作崩溃,说明栈指针指向了非法区域。

我的经验是,CPU1 的启动汇编里,栈指针设置要在调用任何 C 函数之前完成。因为 C 函数的局部变量和函数调用都依赖栈,栈没设好,一调用就崩。

5.2 共享内存数据撕裂

数据撕裂的表现是 CPU0 读到的数据一半是新的一半是旧的,或者长度字段和实际数据不匹配。原因是 CPU0 在 CPU1 写数据的过程中读取了共享内存。

解决方法是用双缓冲或者状态标志。双缓冲的思路是准备两块缓冲区,CPU1 写 A 块时 CPU0 读 B 块,写完后交换。状态标志的思路是每块数据加一个状态字段,CPU1 写之前把状态设为"正在写",写完后设为"写完成",CPU0 只在状态为"写完成"时才读取。

我推荐状态标志方案,实现简单,可靠性高。状态字段要用volatile修饰,并且读写顺序要正确:先写数据,再写状态;先读状态,再读数据。

5.3 Cache 一致性导致的偶发错误

Cache 问题最恶心的地方在于它是偶发的。可能跑几个小时才出现一次,而且每次出错的位置都不一样。我遇到过一次,CPU1 采集的数据偶尔会出现几个字节的偏差,查了两天才发现是 Cache 没维护。

排查 Cache 问题的方法是:暂时关闭两个核的 Cache,看问题是否消失。如果关闭 Cache 后问题消失,基本可以确定是 Cache 一致性导致的。然后逐步开启 Cache,定位是哪段代码缺少 Flush 或 Invalidate 操作。

注意:关闭 Cache 会严重影响性能,只适合调试阶段使用。正式代码里必须正确维护 Cache,不能靠关闭 Cache 来规避问题。

5.4 中断丢失或重复触发

中断丢失的表现是 CPU1 偶尔收不到 CPU0 的命令,或者 CPU0 偶尔收不到 CPU1 的响应。原因可能是 SGI 触发太快,前一个还没处理完后一个就来了,导致中断被合并。

解决方法是在 SGI 处理函数里先清除中断标志,再处理业务逻辑。如果业务逻辑执行时间较长,可以在处理完后检查是否还有未处理的命令,有的话手动再触发一次 SGI。

中断重复触发的表现是 CPU1 对同一条命令执行了两次。原因可能是中断标志没有正确清除,或者命令队列的指针更新有竞态。解决方法是在中断处理函数里加锁,或者用原子操作更新队列指针。

问题现象可能原因排查方法解决方案
CPU1 无输出链接地址错误查看 map 文件修正链接脚本
数据偶发偏差Cache 不一致关闭 Cache 测试加 Flush/Invalidate
命令丢失SGI 合并查看中断计数处理完后检查队列
命令重复执行指针竞态加日志打印原子操作更新指针
系统跑几分钟崩溃内存重叠检查两个 map重新划分内存

6. 性能优化与稳定性提升经验

6.1 减少 Cache 维护开销

Cache 维护是 AMP 模式下最大的性能开销来源。每次 Flush 或 Invalidate 都要操作整个 Cache 行,如果共享内存区域很大,开销相当可观。我的优化方法是把共享内存按 4KB 分块,每次只维护当前操作的那一块,而不是整个共享内存区。

另外,可以把共享内存区配置为 Non-Cacheable 属性。这样就不需要手动维护 Cache 了,但代价是访问速度变慢。对于数据量不大、实时性要求极高的场景,Non-Cacheable 反而是更好的选择。配置方法是在 MMU 页表里把共享内存区的属性设为 Device 或 Strongly Ordered。

6.2 用中断替代轮询

早期我的方案是 CPU0 轮询共享内存的标志位,看 CPU1 有没有返回结果。这种方式实现简单,但 CPU0 会一直占用总线,而且响应延迟取决于轮询周期。后来改成中断方式,CPU1 写完结果后触发 SGI,CPU0 在中断里读取结果,响应延迟从毫秒级降到了微秒级。

中断方式的另一个好处是 CPU0 可以在等待期间处理其他任务,不需要空转。对于 CPU0 跑 Linux 的场景,中断方式更是必须的,因为 Linux 下没法在用户态轮询物理地址。

6.3 共享内存的原子操作

多核环境下,对共享内存的读写必须是原子的。Cortex-A9 提供了 LDREX 和 STREX 指令,用于实现原子操作。Xilinx 的 BSP 里也提供了Xil_AtomicAdd等原子操作函数。

我的做法是:对于简单的计数器,用原子操作;对于复杂的数据结构,用自旋锁保护。自旋锁的实现可以用 LDREX/STREX 指令,也可以用 GIC 的硬件锁。不过 ZYNQ 的 GIC 硬件锁资源有限,我一般用软件自旋锁。

static inline int spin_lock(volatile uint32_t *lock) { uint32_t tmp; do { __asm__ volatile( "ldrex %0, [%1]\n" "cmp %0, #0\n" "strexeq %0, %2, [%1]\n" : "=&r"(tmp) : "r"(lock), "r"(1) : "cc" ); } while (tmp); return 0; }

提示:自旋锁在单核上没意义,但在双核 AMP 下是必需的。加锁范围要尽量小,只保护共享数据的读写,不要保护整个业务逻辑。

6.4 实测性能数据

在我的测试板上(ZYNQ 7020,DDR3 533MHz),AMP 双裸核共享内存通信的实测数据如下:

测试项数据
SGI 中断响应延迟约 1.2 微秒
4KB 数据块传输时间约 8 微秒
命令往返延迟约 15 微秒
连续传输带宽约 380 MB/s
连续运行 72 小时错误数0

这个性能对于大多数工业控制场景已经足够了。如果需要更低的延迟,可以考虑把共享内存放到 OCM 里,OCM 的访问延迟比 DDR 低得多,但容量只有 256KB,需要精心规划。

7. 几个容易被忽略的细节

7.1 FSBL 里的 CPU1 启动地址

FSBL 启动 CPU1 时,传入的地址必须是 CPU1 应用程序的入口地址,而不是链接脚本里的代码段起始地址。这两个地址通常是一样的,但如果链接脚本里有.boot段或者对齐填充,入口地址可能会偏移。最保险的做法是查看 CPU1 工程的.map文件,找到_start符号的地址,把这个地址传给 FSBL。

7.2 两个工程的 BSP 配置要一致

CPU0 和 CPU1 的 BSP 里,有些配置必须一致,比如 DDR 的时序参数、UART 的波特率、GIC 的基地址。如果两边配置不一致,可能出现 CPU0 能正常打印但 CPU1 打印乱码的情况。我的做法是让两个工程共用同一个 BSP 配置,只修改必要的部分。

7.3 调试器的使用技巧

调试 AMP 双核程序时,普通的单核调试器不够用。需要支持双核调试的调试器,比如 Xilinx 的 System Debugger 或者 Lauterbach 的 Trace32。调试时可以先暂停 CPU0,单独调试 CPU1,确认 CPU1 的代码能正常运行后再让两个核一起跑。

另外,可以在共享内存里放一个调试日志区,两个核都把关键信息写进去,然后通过 CPU0 的串口打印出来。这样即使 CPU1 没有串口输出,也能看到它的运行状态。

7.4 上电顺序与复位处理

ZYNQ 上电后,CPU0 先启动,CPU1 处于复位状态。如果 CPU0 的代码里没有释放 CPU1 的复位,CPU1 就永远不会启动。释放 CPU1 复位的操作是写 SLCR 的A9_CPU_RST_CTRL寄存器,清除 CPU1 的复位位。

另外,如果系统需要支持软复位,两个核的复位处理要协调好。我的做法是 CPU0 负责整个系统的复位,CPU1 收到复位命令后先保存必要数据到共享内存,然后进入 WFE 等待 CPU0 重新启动它。

8. 这套方案还能怎么扩展

AMP 双裸核共享内存的架构搭好之后,扩展方向很多。比如可以在 CPU1 上跑一个简单的实时操作系统(RTOS),把任务调度、信号量这些机制用起来,代码结构会更清晰。也可以把共享内存通信封装成一套 RPC 框架,CPU0 调用 CPU1 的函数就像调用本地函数一样,开发效率会高很多。

另一个方向是和 PL 侧的配合。CPU1 可以通过 AXI 总线直接访问 PL 的寄存器,实现硬件加速和软件控制的结合。比如 CPU1 负责配置 DMA,PL 负责数据搬运,搬完后 PL 触发中断通知 CPU1,CPU1 再通过共享内存通知 CPU0。这种软硬件协同的架构在高速数据采集和图像处理里非常有用。

我在实际项目里还遇到过需要三个核协同的场景,ZYNQ 7000 只有两个 A9 核,但可以加上 MicroBlaze 软核,形成 A9 + A9 + MicroBlaze 的三核架构。MicroBlaze 跑在 PL 里,通过 AXI 和 A9 通信,共享内存的机制同样适用。不过 MicroBlaze 的字节序和 A9 可能不同,通信协议里需要处理字节序转换。

最后分享一个小技巧:共享内存的起始地址最好选在 1MB 对齐的边界上,这样 MMU 页表配置更简单,Cache 维护的粒度也更整齐。我一般选 0x1810_0000 这种地址,低 20 位全是 0,操作起来不容易出错。

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

人工智能学院考研备考指南:初试科目与复试实战全解析

/* 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 1:23:17

Hive启动日志Logging initialized详解:从log4j2到故障排查

/* 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 1:21:58

双电池串联主动均衡设计:MP2642GR-Z与PIC18F4515协同实现

/* 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 1:21:58

DCS分布式控制系统:1970–1990过程工业的控制革命

/* 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 1:21:49

邻域依赖非线性图像增强:可解释、可控的色彩增强方法

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

作者头像 李华