4. DSP 内存架构:哈佛架构与冯诺依曼架构、片内 RAM/片外 RAM、DMA 传输、Cache 一致性
做语音算法移植,说白了就是在跟内存打交道。你算法写得再漂亮,跑在 DSP 上如果内存没安排好,照样卡成狗。我见过太多工程师,PC 上仿真跑得飞起,一上 DSP 就崩,十有八九是内存踩坑了。
这一章,咱们把 DSP 内存架构的几个核心概念掰开揉碎。搞懂了这些,你写代码的时候心里就有底了。
4.1 哈佛架构 vs 冯诺依曼架构
先问个问题:你的程序指令和数据,是放在同一个地方,还是分开存放?
这就是两种架构的根本区别。
- 冯诺依曼架构:指令和数据共用一套总线、一个存储空间。CPU 取指令和读写数据不能同时进行,得排队。这叫“冯诺依曼瓶颈”。
- 哈佛架构:指令总线和数据总线是分开的,程序存储器和数据存储器也是独立的。CPU 可以一边取指令,一边读写数据,并行操作。
大部分 DSP 都采用哈佛架构或其改进型。为什么?因为 DSP 的核心运算——乘加操作(MAC),需要在一个时钟周期内同时读取指令、两个操作数,还要写回结果。冯诺依曼根本扛不住。
核心差异一句话:冯诺依曼是单车道,哈佛架构是双车道。DSP 要跑得快,必须上双车道。
我刚开始用 TI C55x 系列时,习惯性地把数据和代码混放在一个段里,结果程序跑起来比预期慢了一半。查了半天才发现,是链接脚本没配好,数据和指令抢了同一套总线。改成哈佛模式后,性能直接翻倍。
现在很多高端 DSP 用的是改进型哈佛架构——数据和指令总线物理上分开,但允许数据总线去读程序存储器里的常数表。这个特性在语音算法里特别有用,比如查正弦表、窗函数系数,可以直接走数据总线,不用绕路。
4.2 片内 RAM vs 片外 RAM
DSP 的存储系统,通常分两层:片内(On-Chip)和片外(Off-Chip)。
| 特性 | 片内 RAM | 片外 RAM |
|---|---|---|
| 访问速度 | 1~2 个时钟周期 | 几十到上百个时钟周期 |
| 容量 | 几十 KB ~ 几 MB | 几十 MB ~ 几 GB |
| 功耗 | 低 | 高 |
| 成本 | 高(每 bit 贵) | 低 |
| 典型用途 | 关键代码、高频数据、堆栈 | 大缓冲区、离线数据、日志 |
做语音算法移植时,我有个原则:能放片内,绝不放片外。语音信号处理是实时系统,每一帧数据都有严格的 deadline。片外 RAM 的延迟,足以让你的音频出现“咔咔”的爆音。
举个例子,一个 16kHz 采样率的 AEC(回声消除)算法,帧长 10ms,也就是 160 个采样点。如果参考信号和近端信号的缓冲区都放在片外,每次读写多花 50 个周期,一帧下来可能多出几千个周期的额外开销。在低功耗 DSP 上,这直接导致处理不完一帧数据。
我的习惯:先把算法里最频繁访问的数组——比如自适应滤波器的系数、环形缓冲区——用关键字或链接脚本强制放到片内 RAM。剩下的低频数据,比如统计信息、调试日志,才放到片外。
但片内 RAM 就那么点,不够用怎么办?两个思路:
- 分时复用:不同处理阶段共用同一块片内缓冲区。比如 AEC 处理完,这块内存马上给 NR(降噪)用。
- DMA 搬运:把片外数据用 DMA 批量搬到片内,处理完再搬回去。这就是下一节要聊的。
4.3 DMA 传输:让 CPU 喘口气
DMA(Direct Memory Access),直译就是“直接内存访问”。它的作用很简单:在不需要 CPU 干预的情况下,在内存和外设之间、或者内存和内存之间搬数据。
你想想看,语音算法里最耗时的操作之一就是搬数据。比如麦克风采集进来的 160 个样点,要从 I2S 接口的 FIFO 搬到内存里。如果每次都用 CPU 去搬,那 CPU 啥也别干了,光搬砖了。
DMA 就是干这个的。你配好源地址、目的地址、数据长度,启动它,它就自己在那搬。搬完了给你发个中断,告诉你“活干完了”。
我做过一个项目,双麦克风波束成形,每通道 48kHz 采样。一开始用 CPU 轮询搬数据,CPU 占用率直接飙到 60%。后来改成 DMA 乒乓缓冲,CPU 占用率降到 15%,剩下的算力全用来做算法处理。
DMA 配置要点:
- 传输粒度:8-bit、16-bit、32-bit,要和数据类型对齐。语音数据一般是 16-bit,用半字传输最省带宽。
- 传输模式:单次传输、连续传输、链表传输。语音帧处理用连续传输+乒乓缓冲最常用。
- 优先级:DMA 通道有优先级,音频数据流通常设最高优先级,别被其他低优先级 DMA 堵住。
下面是一个典型的 DMA 乒乓缓冲配置伪代码:
// 定义两个缓冲区
int16_t ping_buffer[160];
int16_t pong_buffer[160];
// 配置 DMA 通道 0
DMA_Config config;
config.src_addr = (uint32_t)&I2S_RX_FIFO; // 源:I2S 接收 FIFO
config.dst_addr = (uint32_t)ping_buffer; // 目的:Ping 缓冲区
config.transfer_size = 160; // 160 个半字
config.transfer_mode = DMA_MODE_PINGPONG; // 乒乓模式
config.alternate_dst = (uint32_t)pong_buffer; // 交替缓冲区
config.interrupt_enable = TRUE; // 传输完成产生中断
DMA_init(0, &config);
DMA_start(0);
// 中断服务函数
void DMA_ISR() {
if (DMA_get_status(0) == DMA_PING_COMPLETE) {
process_audio_frame(ping_buffer); // 处理 Ping 缓冲区
} else {
process_audio_frame(pong_buffer); // 处理 Pong 缓冲区
}
}
这个模式的好处是:DMA 在后台往一个缓冲区写数据,CPU 同时处理另一个缓冲区里的数据。两者互不干扰,流水线拉满。
我曾经踩过的坑:DMA 传输完成中断里,千万别做耗时操作。中断服务函数应该只设置一个标志位,或者切换一下缓冲区指针。真正的算法处理,放到主循环或者任务里去干。否则中断嵌套一多,音频就断了。
4.4 Cache 一致性:看不见的坑
很多高性能 DSP 引入了 Cache,目的是弥合 CPU 和片外 RAM 之间的速度差距。但 Cache 有个臭名昭著的问题——一致性问题。
什么叫 Cache 一致性?简单说:CPU 在 Cache 里改了数据,但片外 RAM 里还是旧值。或者 DMA 往片外 RAM 写了新数据,但 Cache 里还是老数据。两边对不上了。
为什么会这样?因为 Cache 和 DMA 是两个独立的硬件模块,它们互相不知道对方在干什么。CPU 读写 Cache,DMA 直接读写片外 RAM,两者没有自动同步机制。
语音算法里最容易出问题的场景:
- DMA 把新数据写入片外 RAM,CPU 从 Cache 读数据:CPU 读到的是 Cache 里的旧数据,音频就变成“重复播放上一帧”。
- CPU 把处理结果写到 Cache,DMA 从片外 RAM 搬数据:DMA 搬走的是旧数据,输出音频就“丢帧”。
解决方案就三个字:刷、无、配。
- 刷(Flush/Invalidate):手动刷 Cache。CPU 写完数据后,执行 Cache Flush,把 Cache 内容写回片外 RAM。CPU 读数据前,执行 Cache Invalidate,让 Cache 失效,强制从片外 RAM 重新加载。
- 无(Non-cacheable):把 DMA 要访问的缓冲区配置为不可缓存。这样 CPU 和 DMA 都直接操作片外 RAM,没有一致性问题。代价是 CPU 访问变慢。
- 配(MPU 配置):通过内存保护单元(MPU)把共享数据区域配成“共享”属性,硬件自动维护一致性。这个最省心,但不是所有 DSP 都支持。
我个人的习惯是:音频数据缓冲区一律配成不可缓存。因为语音算法对实时性要求高,缓冲区访问模式是流式的——写一次,读一次,不会反复读写同一个位置。Cache 对这种流式访问几乎没有加速效果,反而引入一致性问题,得不偿失。
而算法内部的系数表、查找表,这些是反复读的,可以放心地放进 Cache,加速效果明显。
一个小技巧:如果你的 DSP 支持 Cache 锁定(Cache Lock),可以把最常用的代码段或数据表锁定在 Cache 里,防止被其他数据踢出去。我在做实时 FFT 时,就把旋转因子表锁在了 Cache 里,每次 FFT 计算省了从片外 RAM 加载的时间。
4.5 本章知识体系总览
下面这张图,把本章的核心知识点串起来了。你可以把它当作内存架构设计的检查清单。
这张图把四个知识点串成了一个整体。你从上往下看:先选架构,再定存储层级,然后用 DMA 做搬运,最后用 Cache 策略兜底。每一步都有对应的工程决策。