ESP32-S3 硬件缓存一致性问题的解决

本文从 ESP32-S3 双核无硬件缓存一致性的硬件缺陷出发,结合 Cache Line、Store Buffer、失效队列等底层机制,深入剖析了标准 C 库原子操作与 ESP-IDF 自旋锁如何通过内存屏障解决多核数据同步问题,并给出了两种方案的选择建议


一、ESP32-S3 的双核数据缓存机制简介

  • ESP32-S3 内部数据缓存机制框图
博客园图片

(一)L1 Cache 和 Cache Line

  • L1 Cache,一级缓存
    • 每个核心私有的、高速的小容量存储器
    • 用于暂存最近使用的数据和指令,以加速访问
  • Cache Line,缓存行
    • CPU 从主存(DRAM)加载数据到 L1 Cache 的最小单位
    • 在 ESP32-S3 上,一个 Cache Line 是 32 字节

(二)数据从内存到 L1 Cache:独立加载

  • 时机:上电第一次运行
  • 工作内容:Core 0 和 Core 1 会各自独立地从主存 DRAM 中按需加载包含数据的整个 Cache Line(32bytes)到自己的 L1 Cache 中,所有常规变量(全局变量、静态变量、局部变量)都可以加载

(三)数据在 L1 Cache 内部:独立操作

  • 时机:独立加载之后
  • 工作内容
    • 每个核心只读写自己 L1 Cache 里面的那份数据副本,不会主动去管另一个核心干了什么,也不会通知另一个核心自己修改了这个变量
    • 写操作:当核心修改数据时,不会直接修改 L1 Cache 中的数据副本,而是先把新值放入存储缓冲器,后续存储缓冲区会将新值写入 L1 Cache,同时将对应的 Cache Line 标记为“脏”
    • 读操作:当核心要读取数据时,会先去存储缓冲区中查询,若未查询到再去 L1 Cache 中查询

(四)数据从 L1 Cache 到内存:写回

  • 时机:独立操作之后
  • 工作内容:被标记为“脏”的 Cache Line 会在特定时机,如该 Cache Line 被其它数据挤占、系统空闲时,才真正写回到主存中

二、ESP32-S3 的双核数据缓存机制缺陷

(一)没有硬件通知

  • 内容:ESP32-S3 没有自动监听和同步机制两个核心的缓存,一个核心修改了数据,另一个核心的缓存不会收到任何“你手里的数据过期了”的通知
  • 例如:Core 0 修改了自己 L1 Cache 里的变量后,Core 1 对此一无所知,依然在使用自己 L1 Cache 中的旧数据

(二)写回策略的局限

  • 内容:只解决“L1 Cache 与主存”之间的同步问题,无法解决“Core 0 的 L1 Cache 与 Core1 的 L1 Cache”之间的同步问题
  • 例如
    • Core 0 把新数据写回了主存,但 Core 1可能根本不知道主存里的数据已经变了,仍然在使用自己 L1 Cache 里的旧数据副本
    • 反过来,如果 Core 1一直没去主存读新数据,它也就无法获知 Core 0 的修改

(三)Cache Line 的副作用

  • 内容:数据从内存加载到 L1 Cache,虽然是按需加载,即用到某个变量就加载某个变量,但是不等于只加载这个变量,因为 Cache Line 是 32 字节,CPU 加载数据时是整行整行地搬
  • 例如
// Core 0 只操作 val_A
uint8_t val_A = 0; // 地址 0x4000_0000
// Core 1 只操作 val_B
uint8_t val_B = 0; // 地址 0x4000_0001 (紧紧挨着 A)

// val_A 和 val_B 位于同一个 Cache Line(0x4000_0000 ~ 0x4000_001F)内
  • 独立加载
    • Core 0 读取 val_A,硬件会把包含 A 和 B 的整行 32 字节全部加载进 Core0 的 L1 Cache
    • Core 1 读取 val_B,硬件会把同样的整行 32 字节全部加载进 Core 1 的 L1 Cache
    • 此时,两个核心的 Cache 里都有这一整行数据(A=0,B=0)
  • 独立操作
    • Core 0 修改 val_A = 1,此时 Cache Line(A=1,B=0),为“脏”
    • Core 1 修改 val_B = 2,此时 Cache Line(A=0,B=2),为“脏”
  • 写回:假设 Core 0 先写回
    • Core 0 将Cache Line(A=1,B=0)写回主存,此时主存(A=1,B=0)
    • Core 1 将Cache Line(A=0,B=2)写回主存,此时主存(A=0,B=2)
  • 结果
    • Core 0 的 val_A 修改在主存被覆盖中,永久丢失
    • 不论时 Core0 还是 Core 1 写写回,都会导致其中的一个修改永久丢失

三、解决方案①:标准 C 库原子操作

  • 这是 C11 标准引入的官方解决方案,也是处理单个基础变量共享的首选方案

(一)使用方法

#include <stdatomic.h>

atomic_uint8_t shared_val = 0; // 声明一个原子类型的 uint8_t 变量

// 在 Core 0 中写入
void writer_task()
{
	atomic_store(&shared_val,1);  // 原子写入
}

// 在 Core 1 中读取
void reader_task()
{
	uint8_t val = atomic_load(&shared_val); // 原子读取
	
	// ......
}

(二)内部原理

1. 原子操作

  • 硬件支持(无锁, \(\leq8\) 字节)
    • 使用场景:uint8_t 到 uint64_t 的所有基础类型
    • 编译器会直接利用 Xtensa LX7 架构提供的原子读写指令,由硬件来保证执行过程中不会被中断,且对内存的访问是原子的
  • 软件模拟(带锁,\(>8\) 字节)
    • 适用场景:大小大于 8 字节的大结构体等
    • 硬件没有对应的单条指令可以利用,编译器会退而使用软件模拟,这通常意味着在底层调用libatomic 库,通过锁来保证操作的原子性,有额外的开销和潜在死锁风险

2. 内存屏障

  • 内存屏障(Memory Barrier)是一个统称,用于防止指令重排序和保证数据可见性
  • 屏障类型有如下:
    • 写屏障
      • 强制 CPU 将存储缓冲器中的数据刷新到 L1 Cache
      • 间接触发缓存一致性协议向其它核心广播“该 Cache Line 已失效”,其它核心会接收到广播后,会将此失效请求放入失效队列等待处理
    • 读屏障
      • 强制 CPU 立刻暂停后续所有读操作的执行,先处理失效队列,直到失效队列被彻底清空为止
    • 全屏障
      • 写屏障+读屏障

失效队列只用来接收和处理来着其它核心的失效请求,本地核心的写操作不会触发失效请求和失效广播;广播不是屏障指令直接发出的,而是数据提交到 L1 Cache 后,由缓存一致性协议自动触发的硬件行为

  • 编译器会根据你指定的内存顺序(如默认的 memory_order_seq_cst)插入内存屏障指令,不同的内存顺序对应着不同的屏障类型
内存顺序 对应的屏障类型 典型用途
memory_order_relaxed 无屏障 仅保证原子性,不保证顺序,用于纯计数(如统计次数)
memory_order_acquire 读屏障 用于读操作,确保后续读能看到其他核心的写入
memory_order_release 写屏障 用于写操作,确保之前写入对其他核心可见
memory_order_acq_rel 全屏障 用于“读-改-写”操作
memory_order_seq_cst 最强的全屏障 默认顺序,提供全局统一顺序,代价最大

3. L1 Cache同步

  • 标准 C 库原子操作指令在执行时,其总线事务会主动让其它核心的对应 Cache Line 失效,并直接从发起方的 L1 Cache 中获取最新数据,而不依赖于 DRAM 是否已更新

四、解决方案②:ESP-IDF 自旋锁

  • 当需要保护一段复杂的临界区代码,或操作大于 8 字节的结构体时,自旋锁是更合适的选择。

(一)什么是自旋锁

  • 自旋锁是所有多核架构(ARM、RISC-V、Xtensa 等)都支持的通用同步机制
  • 核心机制:忙等待(Busy-Wait)
    • 当一个核心试图获取已被占用的锁时,它会持续循环(“自旋”)检查锁的状态,直到成功获取为止
    • 与互斥锁不同,拿锁失败还是会继续运行(虽然是持续循环),而不是阻塞
  • 硬件基础
    • 自旋锁的实现依赖于 CPU 提供的原子操作指令
    • 例如:
      • ARM:ldrex / strex
      • Xtensa(ESP32-S3):s32c1i
      • RISC-V:lr / sc

(二)使用方法

1. 静态分配

#include "esp_spinlock.h"

// 静态分配并初始化自旋锁
static portMUX_TYPE my_spinlock = portMUX_INITIALIZER_UNLOCKED;

void some_function(void)
{
    portENTER_CRITICAL(&my_spinlock);   // 进入临界区
    // 在此处安全地访问共享资源
    // ......
    portEXIT_CRITICAL(&my_spinlock);    // 退出临界区
}

2. 动态分配

// 动态分配自旋锁
portMUX_TYPE *my_spinlock = malloc(sizeof(portMUX_TYPE));
// 动态初始化自旋锁
portMUX_INITIALIZE(my_spinlock);

/* 使用 */
portENTER_CRITICAL(my_spinlock);
// 访问共享资源
// ......
portEXIT_CRITICAL(my_spinlock);

3. 在中断服务函数(ISR)中使用

void IRAM_ATTR my_isr(void)
{
    portENTER_CRITICAL_ISR(&my_spinlock);
    // 在中断上下文中安全地访问共享资源
    // ......
    portEXIT_CRITICAL_ISR(&my_spinlock);
}

注意portENTER_CRITICAL()portENTER_CRITICAL_ISR() 在 ESP-IDF 中的实现是相同的,这使得编写既可用于任务又可用于中断的代码变得容易

(三)内部原理

与标准 C 库原子操作类似

1. 原子拿锁

  • 自旋锁会使用以下硬件提供的原子指令来尝试拿锁:
    • 独占访问:L32AI,在总线上设置一个“独占标记”,监控该地址是否被其它核心修改
    • 条件存储:S32C1I,如果该地址未被其他核心修改,则写入成功;否则写入失败并重试
  • 而原子指令在执行时,其总线事务会触发以下硬件行为:
    • 强制将当前核心的存储缓冲器中的待定写操作排空并提交到 L1 Cache
    • 提交完成后,间接触发缓存一致性协议,向其他核心广播“该 Cache Line 已失效”
    • 其他核心收到广播后,将对应的 Cache Line 标记为 Invalid(无效)

2. 内存屏障

  • 拿锁
    • 插入读屏障,强制当前核心处理(清空)失效队列,确保之后的所有读操作都能看到其他核心的最新写入
  • 放锁
    • 插入写屏障,强制将当前核心的存储缓冲器全部排空,数据被刷入 L1 Cache 并触发广播

3. 临界区保护

  • 将要修改的所有变量,都放在同一个临界区内保护
  • 保证同一时刻只有一个核心能进入临近区
  • 写回操作变成串行的,后写回的那个会把前一个的修改包含在内,不会覆盖丢失

4. 关中断

  • 拿锁时,还会关闭当前核心的中断,防止在中断里面尝试拿同一把自旋锁的死锁场景

本文只讨论如何解决硬件一致性的问题,所以关于自旋锁的一些使用限制就不多解释了,这里只提一点:自旋锁的临界区必须极短(微秒级),严禁在其中执行 printfvTaskDelay、大量循环等耗时操作,否则会导致其他核心长时间空转、系统响应变慢甚至看门狗超时复位


五、总结

  • 如果只是只是小部分保护变量(1~8 字节),建议使用标准 C 库原子操作,性能开销更低,代码简洁,对系统中断的影响几乎没有
  • 在使用复杂数据结构时,使用自旋锁
posted @ 2026-06-22 17:30  临祁  阅读(14)  评论(0)    收藏  举报