
目录前言程序漏洞1. while 循环无优化-O0开启优化-O2或-O32. buffer 赋值3. 并发问题volatile 关键字1. while 循环2. buffer 赋值3. 并发问题cache 优缺点1. while 循环2. buffer 赋值3. 并发问题总结前言首先声明一点volatile 和 CPU Cache 没有直接的关系volatile 是一种内存屏障用于防止编译器对内存的优化。但是这两个概念在嵌入式开发中往往成对出现。考虑如下场景某芯片 SoC 带 CPU 和若干 IP寄存器和内存统一编址一起挂在 AXI 总线上AXI 总线又通过桥接器和外部特殊的 SPI 接口相连SPI 可以直接访问芯片内部寄存器和内存。AXI BusCPUAXI Bus寄存器内存桥接器SPI InterfaceSOC 运行的时候包含如下一段程序该程序逻辑是等待0x01地址的寄存器值大于 0然后从0x10000000地址内存区域开始改写 1024 字节数据每个地址的数据自增 1。char*ctrl(char*)0x01;char*buffer(char*)0x10000000;while(*ctrl0);for(inti0;i1024;i){buffer[i];}程序运行的同时外部会操作 SPI 接口从0x10000000地址开始改写 1024 字节数据也是每个地址的数据自增 1。期望的结果是这两个操作之后每个地址的数据都自增 2。外部的操作SPI 读写我们无法把控但是 SOC 程序我们可以完善。程序漏洞思考如下几个问题程序中 while 循环会被编译器优化吗程序中对 buffer 的赋值操作会被编译器优化吗如果外部 SPI 正在读写0x10000000地址程序恰好进入 for 循环会怎样1. while 循环下面分优化等级说明编译器会如何翻译这段代码以 AArch64 为例无优化-O0编译器会逐句翻译每次判断都从内存读取*ctrl但因为缓存的存在如果0x01地址是可缓存的CPU 仍然可能读到旧值但这不是编译器的问题。生成的汇编大致如下; char *ctrl (char *)0x01; mov x0, #1 ; char *buffer (char *)0x10000000; mov x1, #0x10000000 ; while (*ctrl 0); loop: ldrb w2, [x0] ; 每次循环都从地址0x01读取一个字节 cmp w2, #0 b.eq loop ; 如果为0继续循环 ; for循环... ...这里提到了 CPU Cache 的概念简单说明一下什么是 cache。cache 是一种高速缓存用于存储常用的数据以减少对主内存的访问次数。缓存的工作原理是当 CPU 需要访问某个内存地址时会先检查缓存中是否有该地址的数据。如果有就直接从缓存中读取如果没有就从主内存中读取并将其存储到缓存中。缓存的大小和速度都要比主内存快得多所以缓存的存在可以显著提高程序的运行速度。我们平时组装电脑对 CPU 进行选型时常常会遇到这个概念L1 / L2 / L3 Cache 分别对应不同的缓存层级L1 Cache 是最快的但是最小L2 Cache 是比 L1 慢、比 L1 大L3 Cache 又比 L2 慢、比 L2 大。总体来说这三个缓存越大越好。从汇编来看每次都会读取0x01这段地址的寄存器/内存编译器至少没有省略读操作所以从编译结果来看能正常工作。然而如果地址0x01被配置为可缓存CPU 可能一直从缓存中读取旧值永远看不到硬件更新0x01这段地址的数据。也就是说地址是否为可缓存也会影响结果不过大多数芯片的寄存器都是不可缓存的只有内存才会有缓存可以参考芯片手册确认。以下表格可以从多个角度直观感受程序是否正常角度0x01是寄存器0x01是内存编译器符合期望符合期望CPU符合期望不符合期望整体来看符合期望不符合期望开启优化-O2或-O3编译器会进行以下优化*ctrl的值提升到循环外因为编译器认为*ctrl在循环中没有被修改没有代码写它所以它只读一次如果这个值是 0会无限循环在这个值上。后面的 for 循环可能被完全删除因为编译器认为 while 是死循环后面的代码永远不可达。典型生成的汇编mov x0, #1 ldrb w1, [x0] ; 只读一次 *ctrl cmp w1, #0 b.ne after_while ; 如果读到0进入死循环 dead_loop: b dead_loop ; 永不退出 after_while: ; 后面的for循环代码被优化掉因为永远到不了这里后续无论硬件如何改变0x01地址的值程序都卡死在dead_loop或者直接跳过循环。另外如果地址0x01是可缓存CPU 还会一直从缓存中读取旧值。继续从多个角度感受程序期望值角度0x01是寄存器0x01是内存编译器不符合期望不符合期望CPU符合期望不符合期望整体来看不符合期望不符合期望2. buffer 赋值发行版release的 bin 档都要进行编译优化以减少 flash 占用降低硬件成本我们直接看开启-O2优化的情况。编译器可能会进行如下优化使用 SIMD 指令如 ldnp / stnp 或 ldr q0一次性处理 16 个字节。将数组加载到寄存器中修改再写回大大减少*buffer访问次数。也可能做循环展开甚至推测出整个循环的结果是每个字节加 1但仍然用向量化实现。char*buffer(char*)0x10000000;for(inti0;i1024;i){buffer[i];}生成的汇编mov x0, #0x10000000 add x1, x0, #1024 ; 地址结束 loop: ldr q0, [x0] ; 加载 16 字节到 SIMD 寄存器 add v0.16b, v0.16b, #1 ; 所有 16 个字节同时加 1 str q0, [x0] ; 写回 16 字节 add x0, x0, #16 ; 指向下一个 16 字节 cmp x0, x1 ; 比较是否到了结束地址 b.lt loop这里对*buffer的读写次数大幅减少1024/16 64 次读写而不是 1024 次。这么看确实是正优化。但是对于有 CPU Cache 的系统来说这段操作细分来看还是存在一定的问题ldr q0, [x0]加载 16 字节到 SIMD 寄存器 q0。CPU 首先检查这 16 字节是否已经在 L1 缓存中。如果缺失cache miss会从内存或下一级缓存读取整个缓存行64 字节到 L1 中然后从中提取 16 字节给 q0。add v0.16b, v0.16b, #1在 SIMD 寄存器内部完成加法不涉及缓存更不涉及内存。str q0, [x0]将 16 字节写回缓存并标记为脏数据该缓存行将在被替换/驱逐eviction时写回内存。如果不在 L1 中可能会先触发缓存行填充写分配策略然后修改其中的 16 字节。继续在 cache 中处理下一组 16 字节数据cache hit。问题来了内存中的值并不会在这段程序执行完毕后立即更新而是在 cache 刷新时才会更新。cache 刷新到内存需要满足以下条件之一当缓存需要腾出空间、驱逐evict当前脏缓存行时。当缓存一致性协议多核场景触发写回时。当程序显式调用 flush 或 invalidate 指令时。于是我们可以得出如下期望值角度buffer指向寄存器buffer指向内存编译器符合期望符合期望CPU符合期望可能不符合期望内存数值更新的时机不确定整体来看符合期望可能不符合期望内存数值更新的时机不确定3. 并发问题一旦有了外部 SPI 访问的这种可能buffer 的值就不能依赖“cache 被动刷新机制”进行同步因为被动刷新的时机是不可控的。可能恰好在 SPI 读写的时候进行了 cache flush。volatile 关键字如果一个变量的值可以被当前执行线程以外的其他东西异步读取或修改则称该变量为易失的volatile。volatile 关键字会抑制编译器的优化不删除 volatile 变量的读/写操作不新增 volatile 变量的读/写操作不对 volatile 变量的读/写操作进行重排序。参考 https://en.wikipedia.org/wiki/Volatile_(computer_programming)参考 https://www.gnu.org/software/c-intro-and-ref/manual/html_node/volatile.html我们结合以上三个问题进行具体分析1. while 循环如果对 ctrl 变量增加 volatile 修饰如下volatilechar*ctrl(char*)0x01;...则对于-O2优化级别编译器通常翻译如下mov x0, #1 ; ctrl 地址 loop: ldrb w1, [x0] ; 每次循环都从该地址加载字节volatile 强制 cbz w1, loop ; 若为0则继续循环那么我们可以得出如下期望值角度0x01是寄存器0x01是内存编译器符合期望符合期望CPU符合期望不符合期望整体来看符合期望不符合期望由于前言中我们场景描述过0x01是寄存器因此单纯给*ctrl增加 volatile 修饰可以保证程序正常运行。2. buffer 赋值接下来看一下给*buffer增加 volatile 修饰的情况。volatilechar*buffer(char*)0x10000000;...对于-O2优化级别编译器翻译如下mov x0, #0x10000000 mov x1, #0 ; i 0 add x2, x0, #1024 ; 结束地址 loop: ldrb w3, [x0, x1] ; 1. 读 buffer[i] (volatile) add w3, w3, #1 ; 2. 加 1 strb w3, [x0, x1] ; 3. 写回 buffer[i] (volatile) add x1, x1, #1 ; i cmp x1, #1024 b.lt loop我们可以更新期望值如下角度buffer指向寄存器buffer指向内存编译器符合期望符合期望CPU符合期望可能不符合期望内存数值更新的时机不确定整体来看符合期望可能不符合期望内存数值更新的时机不确定整体期望值与增加 volatile 修饰之前没有本质区别甚至整体效率还会下降因为每次都要从内存读取最新值每次也会写回最新值。3. 并发问题增加 volatile 修饰只是解决编译器优化问题对于外部并发访问 buffer 的问题仍然无效。cache 优缺点优点毋庸置疑可以大幅提升数据访问速度具体数据如下存储层级访问延迟相对比例特点L1 Cache 命中1–4 个时钟周期≈ 0.5–1 ns 3GHz1×速度最快离CPU核心最近每个核心私有并分为指令缓存 (I-Cache) 和数据缓存 (D-Cache)L2 Cache 命中10–20 个周期≈ 3–7 ns5–10× 慢于 L1速度其次每个核心私有或共享L3 Cache 命中30–50 个周期≈ 10–15 ns15–40× 慢于 L1速度再次由所有CPU核心共享主内存 (DRAM)200–300 个周期≈ 60–100 ns80–200× 慢于 L1容量大但速度远慢于各级缓存注实际数字取决于 CPU 型号、内存频率、访问模式等。这里取典型值。参考 https://www.aida64.com/user-manual/benchmarks/cache-memory-benchmark参考 https://blog.logicalincrements.com/data-transfer-rates-bandwidth-cpu-ram-pcie-m-2-sata-usb-hdmi缺点就是会造成缓存一致性问题比如多核协商比如本文讨论的并发问题我们更希望 cache 失效invalidate。这么说来只要 cache 能及时写回内存就不会有并发问题。cache 中的脏数据又在何时写回a) 缓存行被驱逐Eviction当 CPU 需要加载新的内存数据而当前缓存组已满、必须牺牲一个已有缓存行时如果该行是脏的就会先写回主存再填充新行。b) 缓存一致性协议多核环境如果另一个 CPU 核心也要访问 0x10000000 所在的地址它会通过总线发送“读取所有权”RFO等一致性消息。当前核心监听到该消息后会将对应的脏缓存行写回主存或直接转发给请求核心并把自己缓存中的该行标记为共享或无效。但在单核系统或没有其他核访问该地址时不会发生。c) 显式缓存维护指令程序中若调用__builtin___clear_cache、cacheflush、msync或内嵌汇编的缓存清除/清理指令如 ARM 的DC CVAC、x86 的CLFLUSH会强制写回。d) 系统正常休眠/关机系统进入休眠/关机前会将所有 CPU 缓存中的脏数据写回内存以确保唤醒/启动后数据一致。回头分析前言中我们提到的场景1. while 循环因为0x01地址指向的是不可缓存的寄存器volatile 可以保证程序绝对安全没有必要增加 cache 相关的逻辑保障。2. buffer 赋值让脏数据刷回内存只需要显式调用 flush 指令该指令和处理器架构有关没有统一的 C 语言接口确保内存中的值与 cache 中的值一致...for(inti0;i1024;i){buffer[i];}mb();// 建议增加内存屏障防止编译器 / CPU 乱序重排FLUSH_CACHE();// 执行清 CacheClean Invalidate刷回物理 RAM更新期望值如下角度buffer指向寄存器buffer指向内存编译器符合期望符合期望CPU符合期望符合期望内存数值立即更新整体来看符合期望符合期望内存数值立即更新3. 并发问题关于并发问题还要继续分析虽然 buffer 地址范围的值会在程序结束后刷回内存但是 SPI 对该地址的访问还可能在 flush 之前进行。因此还需要在赋值前禁用 SPI 外设访问关中断或其他方式赋值并 flush 之后恢复 SPI 访问。...DISABLE_EXTERNAL_INTERFACE();for(inti0;i1024;i){buffer[i];}mb();FLUSH_CACHE();mb();// 推荐ENABLE_EXTERNAL_INTERFACE();总结综上我们可以得出最终的程序如下volatilechar*ctrl(char*)0x01;char*buffer(char*)0x10000000;while(*ctrl0);DISABLE_EXTERNAL_INTERFACE();for(inti0;i1024;i){buffer[i];}mb();FLUSH_CACHE();mb();// 推荐ENABLE_EXTERNAL_INTERFACE();这样来看遇到数据一致性问题不能一味地加 volatile要具体问题具体分析。如果是寄存器直接用 volatile简单、高效、无脑安全。如果是内存不能用 volatile因为会导致大片内存的读写失去优化性能跌入谷底。在本文场景中针对内存 buffer 的处理我们最终 不加 volatile、加内存屏障、加清 Cache、加禁用接口。这才是兼顾性能与绝对安全的终极方案。