2、硬件平台感知:CPU架构(ARM/Cortex-M/RISC-V)、缓存层次结构、内存子系统

做嵌入式性能调优,第一件事是什么?

不是看代码,不是调参数。是搞清楚你的芯片到底长什么样。

我见过太多工程师,拿着一个Cortex-M0的片子,硬要跑出M4的效果。那怎么可能呢?硬件底子摆在那里,你优化到死也突破不了物理极限。

所以这一章,咱们就聊聊硬件平台感知。说白了,就是让你学会「看人下菜碟」。不同的CPU架构,不同的缓存设计,不同的内存子系统,你的优化策略完全不一样。

2.1 CPU架构:ARM、Cortex-M、RISC-V

先说说这三兄弟的区别。

2.1.1 ARM Cortex-A 系列

这是应用处理器,跑Linux、Android用的。比如手机里的骁龙、麒麟,还有树莓派的BCM2837。

特点是什么?

  • 乱序执行:CPU自己会调整指令顺序,尽量让流水线不空转。
  • 分支预测:猜你下一步要跳转到哪里,猜对了就快,猜错了就慢。
  • 多级缓存:L1、L2、甚至L3,一级比一级大,一级比一级慢。

我在项目中遇到过一个问题:一个图像处理算法,在Cortex-A72上跑得飞快,换到A53上就卡成PPT。为什么?因为A72的乱序执行能力更强,分支预测更准。算法里那些if-else判断,在A72上几乎不花时间,在A53上就成了瓶颈。

核心要点:对于Cortex-A系列,优化重点是减少分支误预测,提高缓存命中率。

2.1.2 Cortex-M 系列

这是微控制器,跑裸机或者RTOS用的。比如STM32、NXP的LPC系列。

特点是什么?

  • 顺序执行:指令一条一条来,不乱序。
  • 无分支预测:或者只有最简单的静态预测。
  • 无缓存:或者只有很小的TCM(紧耦合内存)。

嗯,这里要注意。很多人把Cortex-M当成小号的Cortex-A来用,这是大错特错的。

我曾经在一个项目中,把一段在A53上优化得很好的代码,直接移植到M4上。结果性能反而下降了30%。后来一查,原来那段代码用了很多「软件流水线」的技巧,在A53上能充分利用乱序执行,在M4上却因为指令依赖导致流水线频繁停顿。

避坑指南:在Cortex-M上,不要过度依赖编译器优化。我建议你手动展开循环,减少函数调用开销。因为M系列没有分支预测,函数调用的压栈出栈成本很高。

2.1.3 RISC-V

这是开源指令集架构,最近几年火得不行。国内很多公司都在做RISC-V的芯片。

特点是什么?

  • 模块化:你可以选配乘除法、原子操作、压缩指令等扩展。
  • 可定制:有些RISC-V核允许你添加自定义指令。
  • 生态还在建设中:编译器、调试工具不如ARM成熟。

我个人习惯,在做RISC-V优化时,会先确认芯片支持哪些指令集扩展。比如,如果支持「乘除法扩展(M扩展)」,那除法运算就不要用软件模拟了,直接用硬件指令。

小技巧:RISC-V的压缩指令集(C扩展)能显著减小代码体积。如果你的Flash空间紧张,记得在编译时加上 -march=rv32imc 这样的选项。

2.2 缓存层次结构

缓存这东西,说白了就是「用空间换时间」。CPU跑得太快,内存跟不上,就在中间加一层SRAM做缓冲。

2.2.1 缓存的基本原理

缓存的工作原理,可以用三个词概括:时间局部性空间局部性缓存行

  • 时间局部性:刚访问过的数据,很可能再次被访问。比如循环里的变量。
  • 空间局部性:刚访问过的数据附近的数据,很可能被访问。比如数组的连续元素。
  • 缓存行:缓存的最小单位,通常是64字节。一次加载一整行。

你想想看,如果你的数据访问模式符合这两个局部性,缓存命中率就高,性能就好。反之,如果到处乱跳,缓存就频繁失效,性能就崩。

2.2.2 缓存命中与失效

缓存命中,皆大欢喜。缓存失效,那就麻烦了。

常见的失效原因有三种:

  1. 强制失效:第一次访问某个数据,缓存里没有,必须从内存加载。
  2. 容量失效:缓存太小,装不下所有需要的数据,旧的被踢出去,新的进来。
  3. 冲突失效:多个数据映射到同一个缓存行,互相打架。

我在项目中遇到过最头疼的问题,就是冲突失效。一个音频处理算法,用了两个大数组,偏偏它们映射到了同一个缓存组。结果每次访问都要来回踢,性能直接腰斩。

解决方案:把数组的起始地址错开,比如在定义时加一个 padding 数组,或者用 __attribute__((aligned(64))) 强制对齐到缓存行边界。

2.2.3 缓存一致性

多核系统里,每个核都有自己的L1缓存。如果一个核修改了数据,另一个核的缓存里还是旧数据,那就出问题了。

这就是缓存一致性问题。ARM用MESI协议(或者它的变种)来解决。

嗯,这里要注意。在Cortex-A系列的多核系统中,如果你用共享内存做核间通信,一定要加内存屏障指令(如DMB、DSB)。否则,你这边写完了,那边读到的可能是旧数据。

避坑指南:我曾经在一个项目中,两个核通过共享内存传递数据,没有加内存屏障。结果调试了整整三天,才发现是缓存一致性问题。从那以后,我只要涉及多核共享数据,必加内存屏障。

2.3 内存子系统

内存子系统,包括SRAM、DRAM、Flash,还有各种总线。

2.3.1 内存类型与特性

内存类型 速度 容量 成本 典型用途
SRAM 最快(几ns) 小(KB~MB) CPU缓存、TCM
DRAM 较快(几十ns) 大(GB) 主内存
Flash 慢(几百ns~几us) 大(MB~GB) 代码存储、数据持久化

说白了,速度越快、容量越小、成本越高。这就是内存的「不可能三角」。

2.3.2 内存访问模式对性能的影响

不同的访问模式,性能差异巨大。我举个例子:

// 模式1:顺序访问(好)
for (int i = 0; i < N; i++) {
    sum += array[i];
}

// 模式2:跳跃访问(差)
for (int i = 0; i < N; i++) {
    sum += array[random_index[i]];
}

模式1的访问是连续的,符合空间局部性,缓存命中率高。模式2到处乱跳,缓存几乎每次都失效。

我在项目中做过测试:同样的数据量,模式1比模式2快了将近10倍。所以,尽量让你的数据访问是连续的,这是最基础、最有效的优化手段。

2.3.3 内存对齐

很多CPU要求数据按自然边界对齐。比如,一个4字节的int,地址必须是4的倍数。如果不对齐,轻则性能下降,重则直接触发异常。

为什么会这样?因为内存总线通常是一次传输4字节或8字节。如果数据跨了两个传输边界,CPU就得读两次,再拼接起来。这多花了一倍的时间。

小技巧:在C语言中,可以用 __attribute__((aligned(4))) 来强制对齐。在结构体里,注意成员的排列顺序,把大的成员放在前面,小的放在后面,可以减少padding浪费。

2.3.4 内存带宽与延迟

带宽是「能传多少数据」,延迟是「传一次数据要等多久」。这两个概念经常被混淆。

举个例子:DDR4-3200的带宽是25.6GB/s,但延迟是几十纳秒。如果你的算法需要频繁随机访问小数据,带宽再高也没用,延迟才是瓶颈。

我个人习惯,在做性能分析时,先用性能计数器(如ARM的PMU)看看缓存缺失率和内存访问延迟。如果缺失率很高,那就说明问题出在缓存或内存子系统上,而不是CPU计算能力不够。

核心要点:性能调优,不是盲目优化代码。先搞清楚瓶颈在哪里。是CPU不够快?是缓存命中率低?还是内存带宽不够?对症下药,才能药到病除。

好了,这一章的内容就到这里。下一章,咱们聊聊「编译器优化与代码生成」,看看怎么让编译器帮你写出更快的代码。