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
- ARM:
(二)使用方法
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. 关中断
- 拿锁时,还会关闭当前核心的中断,防止在中断里面尝试拿同一把自旋锁的死锁场景
本文只讨论如何解决硬件一致性的问题,所以关于自旋锁的一些使用限制就不多解释了,这里只提一点:自旋锁的临界区必须极短(微秒级),严禁在其中执行
printf、vTaskDelay、大量循环等耗时操作,否则会导致其他核心长时间空转、系统响应变慢甚至看门狗超时复位
五、总结
- 如果只是只是小部分保护变量(1~8 字节),建议使用标准 C 库原子操作,性能开销更低,代码简洁,对系统中断的影响几乎没有
- 在使用复杂数据结构时,使用自旋锁

浙公网安备 33010602011771号