
1. 冯·诺依曼架构与哈佛架构从指令流和数据流走哪条路说起如果你在嵌入式这行干过一段时间一定遇到过这类问题为什么 STM32 上把一个大的查表数组加个const就能塞进 Flash、不加就把 RAM 吃光为什么 Bootloader 擦写 Flash 的时候代码必须搬到 RAM 里跑为什么 DMA 搬一块放在 CCM RAM 里的缓冲区永远搬不动这些问题表面上看是链接脚本、是寄存器配置往下挖一层全都指向同一个底层话题——冯·诺依曼架构和哈佛架构。很多朋友初学嵌入式的时候把这两个名词当成纯背诵的八股考试完就扔了。但我自己的体会是这两个架构决定的是「CPU 在一个机器周期里到底能同时干几件事」它直接决定了你写出来的代码跑多快、内存怎么分、DMA 能不能用、程序在板子上会不会莫名其妙跑飞。不管你是刚点亮第一颗 LED 的新手还是已经在做嵌入式 Linux、做基于 Simulink 自动代码生成的工程化项目这套底层认知都绕不开。这篇东西我打算按从业者的思路来讲先把两种架构的本质差异拆开再落到 51、AVR、STM32、DSP 这些真实芯片上看它们各站哪一边然后讲清楚架构差异是怎么一层层影响到你写的每一行 C 代码的最后给一套可以自己动手复现的验证流程和排错清单。文中涉及的具体参数和实测数据我都会说明来源和前提方便你拿自己的板子对照。1.1 冯·诺依曼架构一条总线走天下代价在哪冯·诺依曼架构的核心思想叫「存储程序」最早在 1945 年那份著名的报告草案里被系统提出。它的关键特征是指令和数据存放在同一个存储器里共用同一套地址总线和数据总线。CPU 要执行一条指令先从内存里把指令取出来取指再去内存里取操作数取数算完了再把结果写回去写回。这几次内存访问走的是同一条路只能排队不能并行。这个「排队」就是后来大家常说的冯·诺依曼瓶颈。打个比方一家只有一扇门的仓库工人要进去拿图纸指令又要进去拿原料数据一次只能进一个人。CPU 主频越跑越高存储器速度跟不上瓶颈就越明显。现代通用处理器靠多级缓存、指令预取、乱序执行去缓解它但逻辑模型上还是冯·诺依曼的那一套。它的好处也很实在。第一结构简单布线少芯片面积和成本低。第二灵活性极高——程序本身可以被当成数据来处理所以动态加载、自修改代码、JIT 编译这些玩法在冯·诺依曼体系里是天然成立的。你在 PC 上双击一个可执行文件操作系统把它当数据读进内存然后跳过去执行这就是「程序即数据」的直接体现。代价则集中在带宽上。取指和取数争抢同一条总线流水线设计里必须处理「结构冒险」一旦发生就只能插入流水线气泡性能打折。对于需要大量数据吞吐的场合这种架构会比较吃力。1.2 哈佛架构指令和数据各走各的路快在哪哈佛架构的名字来自马克一号计算机它的做法很直接指令存储器和数据存储器物理分开各自拥有独立的地址总线和数据总线。这样一来CPU 在一个机器周期里可以一边从程序存储器取指令一边从数据存储器读操作数两件事真正并行不再排队。主流单片机和 DSP 大多是哈佛或它的变体。AVR、PIC、8051、绝大多数 DSP 内核都是这个路子。它带来的收益有几个层面。性能层面最直观。取指和取数不冲突流水线更容易填满指令吞吐量上去了。而且指令字长可以独立设计不必受数据字长约束对定长指令编码很友好。可靠性层面常被忽视但对嵌入式非常重要。程序存储器通常只能通过专门的取指通道读取普通的数据写指令根本改不了它。这意味着跑飞的指针很难把代码段写坏天然的防误写保护。做功能安全、做长期无人值守的设备时这一点值不少钱。第三个层面是总线结构可以做得更宽。DSP 里常见的多总线哈佛结构一个周期内能完成「取一条指令 读两个操作数 写一个结果」配合硬件乘法累加器FIR 滤波、FFT 这类算法的效率能拉开很大差距。它的问题也很清楚。硬件成本上去了两套总线、两块存储器面积和引脚都增加。地址空间不统一访问放在程序存储器里的常量表需要专门指令——AVR 用LPM8051 用MOVCPIC 用TBLRD。写 C 代码时要额外加修饰符比如 AVR 的PROGMEM、8051 的code。至于自修改代码、动态加载这类玩法在纯哈佛结构上基本免谈。1.3 改进型哈佛ARM Cortex-M 实际采用的做法真正有意思的地方来了。ARM Cortex-M 系列既不是纯冯·诺依曼也不是教科书上的纯哈佛而是改进型哈佛从程序员视角看它是一套统一的 4GB 地址空间代码和数据都在里面排布看起来像冯·诺依曼但从总线层面看取指和数据访问走的是不同的通道又带有哈佛的味道。具体来说Cortex-M3/M4 内核对外引出三条总线ICode 总线负责从0x0000_0000到0x1FFF_FFFF区间取指令DCode 总线负责从同一区间取常量数据比如const数组System 总线负责访问0x2000_0000以上的 SRAM 和外设。这三条总线在总线矩阵里交叉指令和数据可以同时奔向不同的目标互不阻塞。但这里有个很关键的细节容易被忽略ICode 和 DCode 虽然逻辑上是两条总线但通常接到同一片 Flash 的物理接口上。如果 CPU 一边从 Flash 取指一边又要从 Flash 读常量表两者最终还是要在这个接口处仲裁、排队。这就是为什么在 Flash 上跑代码、同时频繁读 Flash 里的查表数组性能往往不如把这张表搬到 SRAM 里去读。还有个冷门但实用的知识点Cortex-M0/M0 只有一条 AHB-Lite 总线取指和取数分时复用本质上更接近冯·诺依曼式的结构。而 M3/M4 才是前面说的三条总线。M7 更复杂带 I-Cache、D-Cache、ITCM、DTCM属于典型的哈佛缓存结构。所以如果有人问你「STM32 是不是哈佛架构」正确回答是看具体型号M3/M4 是改进型哈佛M0 的总线结构更接近冯·诺依曼。2. 落到真实芯片上51、AVR、STM32、DSP 各站哪一边光讲概念容易飘我们把镜头拉近看几种大家天天打交道的芯片到底怎么实现的。2.1 8051藏在存储类型关键字里的哈佛结构8051 是很多人入门的第一颗芯片它其实是相当标准的哈佛结构。程序存储器和数据存储器在物理上分开地址都从 0 开始编。这种分离直接体现在 C 语言的存储类型修饰符上这些关键字看着莫名其妙背后全是架构决定的。存储类型所在空间寻址方式典型用途code程序存储器MOVC指令常量表、字库、算法系数data片内 RAM 低 128 字节直接寻址频繁访问的小变量idata片内 RAM 全部 256 字节间址寻址需要间接访问的变量xdata片外数据存储器MOVXDPTR大缓冲区pdata分页片外存储器MOVXR0/R1小容量外扩你看code这个关键字的存在本身就是哈佛架构的证据。写unsigned char code table[]的时候编译器知道要把表放进程序存储器读取时生成MOVC A,ADPTR如果你忘了加code表就落到data或xdata里小芯片上 RAM 瞬间告急。用 Keil C51 编译时经常遇到的DATA SEGMENT TOO LARGE报错本质上就是「片内 RAM 只有 128 字节你还往里面塞大数组」的问题。2.2 ARM7 与 ARM9同一门派的两条技术路线理解 ARM 的历史演进对搞清架构差异特别有帮助。ARM7TDMI 是冯·诺依曼结构一条 32 位数据总线指令和数据共用取指和取数不能同时进行所以在高主频下性能受限。ARM9TDMI 换成了 5 级流水线并且引入分离的指令缓存和数据缓存也就是哈佛缓存结构指令和数据的访问可以并行性能明显提升。这个脉络往下延伸就是今天Cortex-M0 走的是精简的单总线路线成本优先Cortex-M3/M4 用三条总线做改进型哈佛Cortex-M7 加上真正的 I-Cache/D-Cache 和紧耦合内存。我记得早期做项目从 ARM7 平台迁到 Cortex-M3 平台时同样主频下跑同样的算法性能提升相当可观很大一部分就来自取指和数据访问的解耦。面试里这类演进问题出现频率极高光背「冯·诺依曼慢、哈佛快」是不够的能说清楚「ARM7 是冯·诺依曼ARM9 开始用哈佛缓存Cortex-M3/M4 用改进型哈佛」这条线基本就能把面试官稳住了。2.3 DSP 与多总线哈佛架构的极致形态如果把哈佛架构做到极致就是 DSP 的样子。以 TI 的 TMS320C54x 为例它内部有 1 条程序总线和 3 条数据总线一个机器周期内可以取一条指令同时读两个操作数、写一个结果。再配合硬件乘法累加器、循环寻址、位反转寻址这些专门为信号处理设计的硬件做 FIR 滤波和 FFT 的时候效率比通用 MCU 高出一大截。这类架构里还有个值得注意的设计双访问 RAM。CPU 可以在一个周期里对同一块内存做两次访问进一步减少总线冲突。如果你在做音频处理、电机控制里的坐标变换或者用 Simulink 自动生成代码后跑在 DSP 上会明显感觉到这套总线结构带来的差异——同样的算法在 DSP 上跑得又稳又顺在通用 MCU 上就得多花心思优化。3. 架构差异是怎么影响你写的每一行 C 代码的到这一节才是真正的干货区。很多人学架构学完之后感觉跟实际写代码没有关系那是没找对切入点。下面这六个影响全是我在实际项目里踩过或者被卡过的。3.1 const 到底把变量放哪了这是最基础也最容易出问题的一点。以 GCC ARM Cortex-M 为例一个全局变量到底进哪个段取决于你有没有加const加了const的全局数组进.rodata段链接时被分配到Flash。读取它走 DCode 总线。不加const的全局数组进.data段有初值或.bss段初值为零被分配到SRAM。读写走 System 总线。这个规则看着简单实际影响巨大。你在 STM32F103C8 上写代码芯片只有 20KB SRAM 和 128KB Flash。一张 8KB 的汉字字库表不加const直接把 RAM 干掉小一半加const放进 Flash 只占 8KB 空间RAM 一点不占。我见过不止一个新手因为忘了加const在链接阶段报region RAM overflowed然后一脸茫然地来问「我的代码明明很小」。反过来的坑也存在。STM32F4 上有块 64KB 的 CCM RAM只能被 CPU 通过 D 总线访问DMA 完全碰不到。有人为了性能把 DMA 缓冲区塞进 CCM结果 DMA 传输永远不结束查了半天才发现是内存放错了地方。H7 上的 DTCM/ITCM 也是同样的性质。所以「这块内存该放哪」不是一个可以随便拍脑袋的决定得先看它由谁访问。3.2 为什么在 Flash 上跑代码会变慢等待周期和预取Flash 的工艺决定了它有访问速度上限。STM32F103 跑到 72MHz 的时候Flash 取指需要插入2 个等待周期F407 跑到 168MHz 需要5 个等待周期。如果不做任何优化每取一条指令白白等 5 个周期CPU 的实际效能大打折扣。厂商的应对方案就是各种加速器。F4 系列有 ART Accelerator自适应实时加速器内部包含指令缓存、分支预测和指令预取F7/H7 更是上了真正的 I-Cache 和 D-Cache也就是前面说的哈佛缓存。开启这些机制以后代码在 Flash 上跑和搬到 RAM 上跑的差距会明显缩小但在紧密循环、频繁访问常量表这类场景下差距依然存在。这也是为什么很多 Bootloader、算法热点函数会主动搬到 RAM 执行。你可以在链接脚本里开一个.RamFunc段启动时用memcpy把这段代码从 Flash 拷到 RAM运行时跳进去执行。具体有多快后面第 4 节我会给一套自己动手测的办法。3.3 XIP、RAM 函数与 IAP 的硬性约束XIP 就是 Execute In Place代码直接在 Flash 里原地执行不需要搬运省 RAM是绝大多数 MCU 的默认做法。但有一种场合是被逼着必须搬在应用内编程也就是 IAP。道理是这样——你的擦写例程本身也存放在 Flash 里当你要擦除或写入 Flash 的时候如果 CPU 还在同一片 Flash 上取指就会出现「一边读代码一边改代码」的访问冲突结果要么写入失败要么直接跑飞。所以所有成熟的 Bootloader 都会把 Flash 擦写例程放到 RAM 里执行。这个要求在编程手册里通常写得很清楚但初做 Bootloader 的人极易忽略。写这段代码要注意几个细节函数体本身要放进.RamFunc段被这个函数调用的所有子函数也要一起搬否则子函数还在 Flash 里等于白搬搬运时机必须在进入 IAP 之前通常是main里较早的位置还要注意搬运完成后刷新指令流水线ARM 平台一般用__DSB()加__ISB()保证。3.4 DMA 与 CPU 抢总线CCM/TCM 的坑总线分离带来并行也带来仲裁。当 CPU 正在通过 D 总线读写 SRAMDMA 也在通过总线矩阵搬运同一片 SRAM 的数据时两者会在总线矩阵里排队仲裁。设计良好的芯片会尽量让它们错开但争用不可避免。更麻烦的是那些「挂在 CPU 私有总线上的内存」。STM32F4 的 CCM RAM 只连在 CPU 的 D 总线上DMA 的 AHB 主设备根本走不到那里。STM32H7 的 DTCM 和 ITCM 也是同样的性质。这类内存的好处是 CPU 访问延迟极低、不受 DMA 干扰非常适合放堆栈、放算法中间变量坏处就是外设搬不动它。我在一个项目里就吃过这个亏为了把 FFT 中间结果放得快一点把缓冲区定义到了 CCM结果 SPI 的 DMA 读取一直超时。最后排查路线是「怀疑 DMA 配置 → 怀疑时钟 → 怀疑中断优先级 → 最后查链接脚本发现段给错了」绕了一大圈。所以给你一条经验凡是任何外设会碰的内存一律不要放进 CPU 私有内存区。3.5 中断向量表要在跳转时重定位这条和架构的关系稍微隐蔽一点但逻辑很硬。Cortex-M 复位时CPU 从0x0000_0000处读取栈顶地址和复位入口这是通过指令通道完成的初始取指。当你的 Bootloader 要跳转到 App 的时候App 的向量表在自己的 Flash 起始地址上如果还让 CPU 去0x0000_0000找向量中断就会跳到 Bootloader 的向量表里。解决办法是写SCB-VTOR APP_BASE_ADDR;重新告诉硬件「向量表在这儿」。做 OTA 升级、双区备份方案的这一句几乎必写。有时候还会配合__DSB()和__ISB()保证写寄存器的效果在后续取指之前生效。3.6 缓存一致性与 DMAH7 和 Cortex-A 上绕不开一旦引入 D-Cache问题就多了一层。DMA 直接写内存绕过缓存CPU 读的是缓存里的旧值就会出现「数据明明搬过来了CPU 看到的还是老数据」。反过来 CPU 写了数据但还在缓存里没落盘DMA 读到的也是旧值。标准做法是发送 DMA 之前对发送缓冲区做Clean接收完成之后对接收缓冲区做Invalidate。H7 上对应SCB_CleanDCache_by_Addr()和SCB_InvalidateDCache_by_Addr()。更稳妥的做法是把 DMA 缓冲区放到 MPU 配置为不可缓存的内存区域或者 TCM 里从根上避开一致性问题。这也是为什么高端的多核平台里「内存分区策略」是一门专门的学问。4. 动手实测用反汇编、map 文件和 DWT 计数器验证架构差异前面讲了这么多影响光看文字不如自己验证一遍。这一节给一套可以照着做的流程我用的工具链是arm-none-eabi-gcc系列STM32 平台其他平台思路一致。4.1 工具链和最小工程准备需要的工具其实很少编译器套件自带arm-none-eabi-objdump、arm-none-eabi-nm、arm-none-eabi-size、arm-none-eabi-readelf。如果你想在 VSCode 里看反汇编装个 Cortex-Debug 插件加上反汇编视图就够了。先准备一个最小工程里面明确放三种变量/* 放在 Flash 的常量表 */ const uint16_t sin_table[64] { /* ... */ }; /* 放在 SRAM 的普通数组 */ uint16_t work_buf[64]; /* 未初始化的全局缓冲 */ uint8_t rx_buffer[256];编译完成之后重点看编译输出的.map文件和.elf。4.2 从 map 文件里看清代码和数据的落点打开.map文件搜索段名你会看到类似这样的内容数值以实际工程为准.rodata 0x08002a10 0x80 table.o .data 0x20000000 0x10 main.o .bss 0x20000010 0x100 main.o这段信息说明三件事sin_table落在了0x0800开头的 Flash 区域work_buf落在了0x2000开头的 SRAMrx_buffer在 SRAM 里但只占运行时空间、不占 Flash 空间。这就是哈佛结构在工程文件里的直接投影两块内存两个地址区间两条访问通道。如果你把自己的const去掉再编译一次会发现sin_table从.rodata挪到了.dataFlash 占用减少SRAM 占用猛增。这一进一出比任何教科书描述都直观。4.3 反汇编里看两条通道的痕迹用arm-none-eabi-objdump -d your.elf dis.txt导出反汇编然后搜你的函数名。你会看到访问 Flash 常量表和访问 SRAM 变量生成的指令形态是不一样的。访问 Flash 里的常量编译器通常用 PC 相对寻址的加载指令ldr r3, [pc, #16] ; 从 Flash 的常量池加载地址 ldrh r0, [r3, r1, lsl #1]访问 SRAM 变量则是指令里直接给出基址寄存器加偏移ldr r2, [r7, #4] ; 从 SRAM 加载 str r2, [r7, #4] ; 写回 SRAM再看 AVR 平台的反汇编从 Flash 读常量必须用LPM指令和读 SRAM 的LDS是两条完全不同的指令8051 上就是MOVC对MOVX。这些指令层面的差异就是哈佛架构留下来的指纹。看懂这些再回头看那些「PROGMEM 宏是干嘛的」这类问题答案自己就浮出来了。4.4 用 DWT 周期计数器实测 Flash 执行和 RAM 执行Cortex-M3/M4/M7 内核带一个 DWT 单元里面有周期计数器 CYCCNT精度是单周期。用它测耗时比用定时器还准。初始化三步CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk;然后准备两个功能完全一样、只是落点不同的函数__attribute__((section(.RamFunc), noinline)) void hot_loop_ram(uint32_t n) { volatile uint32_t acc 0; for (uint32_t i 0; i n; i) { acc i * 3 1; } } __attribute__((noinline)) void hot_loop_flash(uint32_t n) { volatile uint32_t acc 0; for (uint32_t i 0; i n; i) { acc i * 3 1; } }测的时候前后各读一次DWT-CYCCNT做差就是周期数。顺手再测一下「在 Flash 执行 同时读 Flash 常量表」的组合复现 ICode 和 DCode 在 Flash 接口上的争用。关于结果我自己的板子上观察到的规律是这样的你可以对照自己的实验在 Flash 有等待周期、且没有开启指令缓存的条件下紧密循环放在 RAM 里执行通常能比放在 Flash 里快两到四成循环体里内存访问越密集或者常量表越大这个差距越明显一旦芯片启用了 I-Cache 和分支预测纯计算型循环的差距会大幅收窄但「Flash 取指 Flash 取表」这种双重访问的差距依然存在。这些数字和主频、等待周期配置、优化等级都强相关所以别记死数要记住的是「怎么测」和「看到差距该往哪个方向优化」。4.5 链接脚本里分配段的实操要让.RamFunc真正生效链接脚本必须配合。典型的写法是这样MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K CCMRAM (rwx) : ORIGIN 0x10000000, LENGTH 64K } SECTIONS { .text : { *(.isr_vector) *(.text*) } FLASH .RamFunc : { . ALIGN(4); *(.RamFunc) . ALIGN(4); } RAM AT FLASH .ccmram : { *(.ccmram*) } CCMRAM }关键点是 RAM AT FLASH这句段的运行地址是 RAM加载地址是 Flash。程序启动时C 运行时会把这段从 Flash 拷到 RAM拷完跳过去执行。之后要用的变量定义可以用__attribute__((section(.ccmram)))指定到 CCM前面提醒过放这里的变量一定不能被 DMA 访问。5. 面试高频问题与排错实录到最后一节了把前面所有内容收拢成两块能直接用的东西一张面试速查表一张故障排查表。5.1 那些年被问烂的架构问题与回答要点问题回答要点冯·诺依曼和哈佛架构的核心区别指令和数据是否共用存储器与总线。共用为冯·诺依曼分离为哈佛哈佛可并行取指与取数STM32 属于哪种架构看型号。Cortex-M0/M0 单 AHB-Lite 总线偏冯·诺依曼M3/M4 为改进型哈佛ICode/DCode/System 三条总线M7 带 I-Cache/D-Cache 与 TCM哈佛架构为什么快取指与取数使用独立通道流水线不因结构冒险停顿一个周期可并行完成8051 为什么是哈佛结构程序存储器与数据存储器独立编址附加MOVC专门访问程序空间C 语言里用code修饰符体现哈佛架构的缺点硬件成本高、地址空间不统一、访问程序区常量需要专用指令、不支持自修改代码与动态加载为什么有些代码要搬到 RAM 执行绕过 Flash 等待周期提升速度IAP 擦写 Flash 时避免同片 Flash 取指冲突为什么 DMA 搬不了 CCM RAMCCM 只挂在 CPU 私有 D 总线上DMA 主设备无法通过总线矩阵访问到回答这类问题光说概念容易显得空洞最好带上一句自己的项目经历比如「我在做 OTA 的时候把 Flash 操作函数搬到 RAM否则一擦写就挂」可信度立刻不一样。5.2 常见问题速查表现象可能原因排查与解决链接报 RAM 溢出Flash 还大量空闲大数组、字库、常量表未加const加const让编译器归入.rodata或用section属性显式指定段DMA 传输不完成或数据全为 0缓冲区被分配到 CCM/DTCM/ITCM 等 CPU 私有内存检查链接脚本把 DMA 缓冲区改到普通 SRAM 区加const后程序跑飞常量地址超出链接脚本中 Flash 区域边界或向量表未重定位检查.ld的 MEMORY 长度确认 VTOR 设置正确代码搬 RAM 后执行异常拷贝长度或地址对齐有误被调用的子函数没一起搬用ALIGN(4)保证对齐把所有相关函数放进同一段搬完加__DSB/__ISBFlash 上跑算法明显比 RAM 慢Flash 等待周期、无指令缓存、取指与读常量争用接口开启预取与缓存热点函数搬 RAM常量表视情况搬 SRAM中断跳到了错误的处理函数Bootloader 跳 App 时未重定位向量表写SCB-VTOR APP_BASE_ADDR库函数里的常量访问异常平台相关的修饰符缺失如 AVR 的PROGMEM、8051 的code按平台补上对应修饰符配套使用专用读取函数这张表里的每一条我基本都在不同项目里遇到过至少一次。第一条和第二条出现的频率最高尤其是刚从 PC 端开发转到嵌入式的朋友很容易默认「所有内存都是一样的」。5.3 关于学习路线的几句实话经常有人问学嵌入式要不要先把架构这些底层东西啃透。我的看法是不用一开始就抱着体系结构的教材硬啃但一定要在动手的过程中反复回头理解它。你点灯的时候不需要知道哈佛是什么但你做 DMA 搬运、做 Bootloader、做性能优化的时候不懂它就会一直踩坑。比较舒服的路径是先跑通基础外设建立对 Flash 和 SRAM 的直觉然后回头把这两种架构弄明白同时对照你手上的芯片手册看总线结构再往后做一两个带 DMA、带 Bootloader、带实时性要求的完整项目把认知落到实处。至于工具用 Keil、IAR 还是 VSCode 加开源工具链其实和架构无关。甚至有人问用很老的开发工具能不能做嵌入式硬件编程——能不能编是一回事编出来的东西合不合理是另一回事编译器优化、段分配、栈使用这些东西换什么工具都躲不开。手上有什么就用什么把注意力放在内存布局、总线结构、时序分析上回报率高得多。最后分享一个小习惯每次新建工程我都会先看一眼编译出来的.map文件确认关键缓冲区落在哪个地址区间、RAM 还剩多少、有没有意料之外的段膨胀。养成这个动作很多问题在写代码阶段就能提前发现不用等到板子跑飞了再通宵查。