ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

51单片机存储结构详解:主存、外部内存与地址空间全解析

51单片机存储结构详解:主存、外部内存与地址空间全解析 说实话做嵌入式这么多年我见过太多新手在单片机的存储结构上栽跟头。程序跑飞了、变量被莫名改写了、明明代码量不大却报内存溢出十有八九都是没把存储结构这件事搞明白。单片机的存储体系跟PC上那套冯·诺依曼结构完全不一样主存、外部内存、地址空间这些概念如果不先理清楚后面写驱动、调bug、做项目移植会一直吃亏。所以这篇文章我不打算讲虚的就围绕51单片机这个最经典的入门平台把存储结构从头到尾拆一遍包括主存内部是怎么布局的、外部内存怎么扩展、地址空间那套让人头疼的映射关系到底是怎么回事最后再结合Keil开发环境讲讲程序超出内存这类问题的定位方法。无论是刚入门的在校生还是准备转嵌入式开发的自学者只要你想把这行干明白这篇都值得认真读一读。1. 为什么存储结构是嵌入式入门的第一道坎1.1 从一次程序跑飞说起先讲个我早年带新人时遇到的真实案例。有个学生照着网上的例程写了个电子钟程序逻辑看着都对但一上电数码管显示的数字每隔几秒就乱跳一次有时候干脆全灭。他怀疑是中断配置问题折腾了两天也没找到原因。后来我打开他的工程看了一眼发现他在主函数里定义了一个很大的局部数组用来存放待显示的数据用的还是默认的small内存模型。在Keil C51里局部变量默认放在内部RAM里而51单片机的内部RAM总共就128字节标准8051或者256字节8052那个数组加上其他变量早就把内部RAM挤爆了栈指针直接冲进了工作寄存器区程序不乱跑才怪。这件事给我的触动挺大的。它说明一个问题在单片机上写程序不能只盯着逻辑对不对你还得时刻知道自己写的每一行代码、声明的每一个变量最终会被塞进硬件的哪个角落。单片机不像PC有操作系统帮你管理内存你new一个对象出来如果内存不够了系统还能报个错。单片机没有这个机制内存溢出、地址越界表现出来的往往是各种灵异现象而这些现象的根源几乎都指向存储结构。1.2 哈佛结构与冯·诺依曼结构单片机存储设计的分水岭在聊具体的存储布局之前有必要先弄明白一个底层差异单片机为什么要把存储分得这么细。PC用的是冯·诺依曼结构指令和数据存在同一块内存里通过同一条总线去访问优点是灵活性高但取指令和数据访问不能同时进行。而绝大多数单片机包括51和ARM Cortex-M系列采用的是哈佛结构或者改进型哈佛结构程序存储器和数据存储器是物理分开的各有各的地址空间、各有各的总线。分开之后有什么好处最直观的一点取指令和读写数据可以在同一个时钟周期内并行进行执行效率更高。第二个好处是可靠性程序固件存放在Flash或ROM里正常情况下运行时不会被修改程序不容易自己把自己写坏。第三个好处其实对于入门的人最实用——因为程序和数据分开编址你完全可以把程序存储空间设得很大把数据存储空间另算一套互不影响。这一点跟后面要讲的地址空间解析直接相关也是理解为什么51单片机一个地址编号能对应多个物理存储区域的关键。1.3 内部存储与外部存储是怎么分工协作的搞清楚结构类型之后下一个问题就是既然单片机内部已经有存储了为什么还要分内部和外部答案很简单——芯片内部的空间有限。芯片的制造成本、引脚数量、功耗都限制了片内存储器的容量51系列标准的片内程序存储空间只有4KB或8KB数据RAM只有128B或256B这点容量在今天做一个小项目都不一定够用更别说跑复杂的算法或者存大量的采集数据了。所以单片机的设计者给了两条路一是用好片内这有限的主存二是通过总线接口外接存储芯片把存储空间扩出去。这里的主存概念需要单独拎出来说一下。在很多嵌入式资料里主存通常指CPU可以直接通过总线访问的存储区域单片机里的主存就是片内的程序Flash、片内RAM和特殊功能寄存器而外部扩展的RAM/ROM则算是外存或扩展内存。但严格来说单片机的主存并不是一个硬件概念它更像是一个面向软件的资源概念——你写的代码、定义的变量最终都会被编译器分配到主存的某个区域里。理解了这一点再去看那些主存不够外部内存扩展的说法就能对得上号了。2. 主存详解单片机片内的代码仓库和数据中转站2.1 程序存储器Flash代码真正的家很多人刚学单片机时会有一个误区觉得代码是跑在芯片里的说不清代码到底存在哪。实际上代码编译后生成的机器指令会被烧录到芯片内部的Flash里程序一上电CPU从Flash里一条条把指令取出来执行这就是取指-译码-执行的循环。51单片机的程序存储器空间编址是0x0000到0xFFFF一共64KB但这64KB是理论可用上限。拿经典的STC89C52RC来说片内Flash实际只有8KB地址从0x0000到0x1FFF如果你的代码超过了8KB但芯片不支持外扩程序存储器那编译出来的固件连烧都烧不进去。程序存储器有几个非常关键的特性。首先是掉电不丢失因为Flash是非易失性的这也是固件能长期保存的前提。其次是只读性在正常运行阶段程序存储器里的内容不能被指令随意改写如果要修改固件只能通过ISP、IAP等专门的编程流程来操作。第三就是它的地址空间有特殊用途——单片机复位后PC指针固定指向0x0000也就是说程序必须从0x0000开始执行而各个中断服务程序的入口地址也被固定在程序存储器的低端区域比如外部中断0的入口在0x0003定时器0的入口在0x000B。很多人在程序里跳转异常、中断进不去往往就是忽略了这些入口地址的分区规则。2.2 内部RAM你每天都在用却未必了解的三个区内部RAM是程序运行时的数据中转站变量、堆栈、中间计算结果都在这里。标准8051的内部RAM只有128字节地址从0x00到0x7F但这128字节内部还划成了三个区域0x00到0x1F是工作寄存器区一共4组每组8个寄存器R0到R70x20到0x2F是位寻址区16个字节共128个可位寻址的位每个位都有自己的位地址0x30到0x7F才是普通的数据RAM区可以随意存放变量和堆栈。到了8052以及STC89C52这类增强型芯片内部RAM扩展到了256字节其中0x80到0xFF的高128字节区域和特殊功能寄存器区地址重叠了但通过不同的寻址方式区分直接寻址访问的是SFR间接寻址访问的是高128字节RAM。这个设计特别容易让人绕晕笔试面试也常考。说句掏心窝的话我见过不少工作一两年的工程师也未必能立刻说清楚data和idata的区别。所以这个知识点别看它基础含金量相当高。在实际写代码时内部RAM的管理基本由编译器代劳但你要有意识地知道变量被分配到了哪里。Keil C51里用data关键字声明的变量放在低128字节可以用直接寻址快速访问用idata声明的变量放在整个256字节内部RAM区域访问要靠间接寻址速度略慢一点但空间更多。默认情况下编译器会优先把变量放在data区data区满了才会放idata区。而位寻址区专门给位变量用比如你用bit声明一个标志位它就会占用0x20到0x2F这一片区域。由于内部RAM速度最快、访问指令最简洁省着用内部RAM永远是51程序优化的核心思路之一。2.3 SFR特殊功能寄存器寄存器也是存储空间的一部分很多初学者会问一个问题为什么单片机程序里P0、TMOD、TCON这些寄存器看起来就像在访问某个地址答案很简单因为它们本来就在存储空间里。在51单片机的地址分配中SFR特殊功能寄存器区占用了内部数据存储空间0x80到0xFF这128个地址每个寄存器都有一个固定的地址。比如P0端口寄存器地址是0x80TMOD定时器模式寄存器地址是0x89。你写P0 0x0F本质上就是向0x80这个地址写一个字节。把寄存器映射到存储空间的好处是CPU就可以用现有的存储器访问指令来操作硬件外设不需要单独设计一套I/O指令集。这也是统一编址思想在嵌入式领域的典型应用。不过SFR区不是所有地址都有对应的寄存器空着的地址是不能随便访问的。最坑的一点是SFR区里很多寄存器支持位寻址比如P0.0到P0.7这些位地址跟前面提到的位寻址区是两码事SFR位地址范围是0x80到0xFF跟它们的字节地址数值上完全一致。用Keil的时候你可以直接写P0_01这背后就是通过位寻址指令实现的但如果你不清楚这一层遇到位操作相关的诡异bug时往往就只能干瞪眼。3. 外部内存详解当片内小仓库不够时的扩展方案3.1 什么情况下才需要外部内存片内RAM只有256字节我做过的一个数据采集项目光一组512字节的FIFO缓冲区就放不下了这种时候就必须把目光投向外部内存。在51单片机体系里外部数据存储器的标准编址空间是0x0000到0xFFFF一共64KB通过MOVX指令访问。注意这个64KB和程序存储器的64KB虽然地址编号一样但是物理上完全分开的两块空间访问指令也完全不同。这也就是我在开头说的一份地址编号多套内存地图。具体什么场景需要外部RAM简单说就是片内RAM不够用或者需要大块缓冲区、采集数组、通信协议栈缓冲区时。常见的片子有62648KB SRAM、6225632KB SRAM再大的还有628128128KB SRAM但51的MOVX寻址能力上限是64KB超过就要靠分页或者额外的控制线扩展。另外如果需要保存大量掉电不丢失的数据也可以外接EEPROM或者SPI Flash但那些算是另一类存储外设跟这里讲的总线扩展RAM不完全一样先不混在一起说。3.2 三总线扩展P0口的分时复用到底是怎么回事外部RAM扩展的基础是把单片机的引脚变成总线来用。总线分三路地址总线、数据总线和控制总线。51系列是8位数据总线需要有16根地址线才能寻址64KB。问题来了芯片引脚有限地址线加数据线一共24根根本不够用。于是设计者想出一个办法P0口分时复用先输出低8位地址再切换为8位数据总线用ALE信号来控制这个切换的节奏。具体的工作流程是这样的CPU执行MOVX A,DPTR指令时DPTR里的16位地址会被分成两部分低8位送到P0口高8位送到P2口。此时的P0口输出的是地址信号不是数据。紧接着ALE引脚输出一个下降沿外部锁存器在这个时刻把P0口上的低8位地址锁存保存下来P0口随后切换成数据总线模式开始传输数据。这就是教科书上说的地址锁存74HC373、74HC573这类锁存器就是干这个活儿的。P2口在整个过程中一直输出高8位地址不需要锁存。控制总线方面读外部RAM时RD引脚输出有效低电平写外部RAM时WR引脚输出有效低电平这两个信号分别接到SRAM芯片的OE和WE引脚上。3.3 经典外部RAM扩展电路74HC373 62256 全流程分析咱们来一个可以直接照着画的经典方案用74HC373作为地址锁存器外扩一片62256 SRAM。62256是32KB容量的静态RAM有15根地址线A0到A148根数据线D0到D7加上片选CE、输出使能OE、写使能WE三个控制引脚。由于62256有15根地址线刚好需要P0口锁存出来的8位地址加上P2口的7位地址P2.0到P2.6一共15根接在A0到A14上。连接方式如下P0口接74HC373的D端74HC373的Q端接SRAM的A0到A774HC373的LE引脚接单片机的ALEP2.0到P2.6接SRAM的A8到A14P0口同时也接SRAM的D0到D7单片机的RD接SRAM的OEWR接SRAM的WE片选CE接地使其始终有效这样62256占用的地址空间就是0x0000到0x7FFF。如果你想扩满64KB那就再加一片62256然后通过P2.7引脚加反相器做片选当P2.7为0时选通第一片为1时选通第二片这也就是常说的线选法虽然简单但片选逻辑容易浪费地址空间因为P2.7每切换一次前后各有一段地址被映射到了同一片芯片上。我当年第一次焊这个电路时犯过一个特别低级的错误把74HC373的方向引脚或者锁存使能接错了导致地址一直锁不住读写数据全乱。后来学聪明了先用示波器看ALE引脚有没有正常的下降沿再看锁存器输出端能不能稳定保持地址信号一步一步排查。这个排查思路在做任何总线扩展时都适用。4. 地址空间全解析一份地址编号三套内存地图4.1 程序存储器地址空间从0x0000开始的启动旅程程序存储器的地址编了很多层咱们一条一条捋。对于标准51来说程序存储器是一个独立的64KB地址空间起始地址固定是0x0000。芯片复位后CPU从0x0000开始取指令所以这张内存地图的开头通常放一条跳转指令跳过中断向量区直接跳到主程序入口。中断向量区从0x0003开始每8个字节为一个中断源比如外部中断0对应0x0003定时器0对应0x000B串口中断对应0x0023。这些地址是硬件规定的不是你想改就能改的。在Keil C51里你新建工程后它会自动帮你生成启动文件内部已经处理好了这些细节但如果你用的是汇编或者自己对链接脚本做了修改就得特别小心别把中断向量区覆盖掉否则中断一触发程序就跳到乱七八糟的地方去了。关于内部程序存储器和外部程序存储器的切换需要提到EA引脚。EA引脚接高电平时CPU优先访问片内程序存储器当PC值超过片内Flash容量时自动转向外部程序存储器读取。EA引脚接低电平时CPU完全忽略片内的程序存储器所有取指操作都从外部进行。这个设计在几十年前很有用因为当时很多8051是片内无ROM的版本比如8031程序必须放在外部扩展的EPROM里。现在的单片机片内Flash容量都够用了外部程序存储器的扩展已经很少见但EA这个引脚在很多芯片上依然存在使用的时候注意按数据手册的要求接好悬空的话程序可能没法正常启动。4.2 数据存储器地址空间DATA / IDATA / XDATA 三个区域的区别数据存储器这边的地图要复杂得多因为51把数据存储空间分成了好几个看似重叠、实际独立的部分。用C51开发时接触最多的三个关键词是data、idata和xdata。先明确一点data和idata都属于片内RAM的范畴xdata则对应外部数据存储空间。data区域对应片内RAM的低128字节0x00到0x7F编译器生成的直接寻址指令访问它速度最快。idata区域对应整个片内RAM0x00到0xFF在8052里就是256字节访问靠间接寻址速度比data稍微慢一点。xdata区域就是外部RAM空间编址也是0x0000到0xFFFF共64KB通过MOVX指令访问速度最慢因为要先送地址再读写数据还要等总线时序。如果你用过增强型51比如STC89C52RC这类芯片它内部可能还内置了一小片扩展RAM用起来和xdata类似但物理上还是在片内只不过通过MOVX方式访问这类细节就要以具体芯片的数据手册为准了。给个直观对比data访问是单周期指令效率最高idata访问要多一个间址步骤略慢xdata访问至少需要两个机器周期以上还要考虑总线时序。所以同样的代码变量存储区域不同性能差异可能非常明显。你在做实时性要求高的项目时把核心变量放在data里把大缓冲区扔到xdata里是基本功里的基本功。4.3 地址重叠的真相同一个地址为何能访问不同硬件现在可以正面回答为什么0x80这个地址既能访问SFR又能访问高128字节RAM这个问题了。在8052中内部RAM的高128字节地址是0x80到0xFFSFR区的地址也是0x80到0xFF两边的地址编号完全一样。如果硬件上用一套地址来区分不就冲突了吗51的解决方案是靠指令类型来区分直接寻址的指令比如MOV 0x90, #0x01访问的是SFR区间接寻址的指令比如MOV R0, #0x01且R0等于0x90访问的是高128字节RAM。这种同号不同房的机制在嵌入式世界里非常常见本质上是一种地址译码策略。CPU在译码阶段看到地址线和控制信号硬件电路根据不同的控制信号决定激活哪一块存储区域。你不需要亲自去操作这些底层逻辑编译器会帮你选对指令但你要明白这个原理否则遇到为什么我这个变量改了值寄存器也跟着变这种看起来诡异的问题时会一头雾水。还有一个容易踩坑的地方位寻址区。0x20到0x2F这16个字节有128个可位寻址的位位地址是0x00到0x7F。SFR区有另一套位寻址机制位地址是0x80到0xFF。这两套位地址也重叠了。你用C51的bit变量时编译器会根据目标地址自动映射但你要是用汇编或者用指针手段强行访问位地址就会出大问题。我见过有人把位变量地址算错一位结果改了P1口的某个引脚状态自己还不知道排查半天。5. 实操程序超出内存时的判断方法与排查实录5.1 Keil编译器的报错信息究竟在说什么程序超出内存是嵌入式新人最常见、也最迷茫的编译错误之一。Keil C51编译时报错信息对新手非常不友好上来就是一堆类似*** ERROR L107: ADDRESS SPACE OVERFLOW的英文很多人看一眼就懵了。实际上这几行信息里藏了关键线索。来看一个真实的报错例子*** ERROR L107: ADDRESS SPACE OVERFLOW SPACE: DATA SEGMENT: ?DT?MAIN LENGTH: 0021H这个报错翻译过来就是DATA空间溢出发生问题的段是?DT?MAIN长度是0x21字节。?DT?表示这是一个DATA段MAIN是所在的源文件名或模块名。也就是说你的main.c文件里定义的变量加起来大小超过了DATA区可用的空间。如果报错里写的是XDATA那问题的范围就在外部RAM区域。还有类似的C249错误会直接告诉你某个段太大。看到L107这一类报错后先别想着改逻辑第一件事是打开你的变量列表数一数哪些变量占用了data区哪些占用了xdata区。如果编译只是给了个警告比如*** WARNING L16: UNCALLED SEGMENT意思是某个段没有被调用通常是某个函数没有被引用虽然不至于导致运行失败但也会白白占用存储空间能清就清。5.2 MAP文件在手内存占用一清二楚要真正搞清楚自己的程序占了多少内存、每个变量躺在哪个区域最直接的办法是打开编译生成的MAP文件。在Keil中默认勾选了Generate Map File时工程目录下会生成一个.M51后缀的文件这就是内存分配地图。用记事本打开它你会看到类似这样的内容TYPE BASE LENGTH RELOCATION ---- ------ ------ ---------- CODE 0000H 0800H UNIT DATA 0008H 0030H UNIT IDATA 0040H 0010H UNIT XDATA 0000H 0200H UNIT START STOP LENGTH NAME ------ ------ ------ ------- 0008H 0037H 0030H ?DT?MAIN怎么看这段信息TYPE列说明存储区域CODE是程序存储器DATA是片内直接寻址RAMIDATA是片内间接寻址RAMXDATA是外部RAM。BASE是起始地址LENGTH是长度。后面的START、STOP、NAME列表把每个源文件里每一个段的分配情况列得明明白白。你只要重点关注CODE这一块的总长度不能超过单片机Flash容量DATA和IDATA的占用不能超过256字节XDATA占用不能超过64KB心里就有数了。我自己的习惯是每次工程编译完成后哪怕没有报错也会顺手打开MAP文件扫一眼。特别是项目后期不断加功能的时候很多内存过度占用问题不是一步到位的而是慢慢积累起来的你隔几天看一次MAP文件就能察觉到内存的消耗趋势在问题爆发之前就提前处理掉。5.3 代码和内存都吃紧时的优化手段真遇到内存不够了该怎么优化先说数据内存。第一优先级是把不适合放在data区的大数组和缓冲区挪到xdata或idata。一个512字节的数组放在data区域直接就把255字节的内部RAM挤爆了但只要在定义时加个xdata关键字立刻就不占内部宝贵资源了。第二是尽量少用大类型比如能用unsigned char表达的数值范围就不要用int一个char占1字节一个int占2字节积少成多差异很大。第三是合理使用位变量能用一个bit表示的状态标志绝不用char。再说程序空间。如果提示的是Flash容量不够也就是CODE段超了先看看代码里有没有大量的重复函数、冗余的库引用把未用到的库和死代码删掉。其次大段的常量字符串和查表数据可以放到code区不占RAM。第三合理的优化等级也能明显减小代码体积Keil里调整优化级别从Level 0改到Level 8体积能差出不少不过要注意优化等级高了之后调试难度也会变大这个要看项目阶段取舍。顺带说一个很多人忽略的点如果你用的是STC的单片机烧录软件在下载固件时会直接显示程序占用大小并且会跟芯片容量比对超出容量的话压根下不进去。有时候编译没问题但下载失败去软件里看提示往往就是CODE超过容量了。这个提示比编译报错更直观下载前养成看一眼的习惯很有用。6. 常见问题与排查技巧实录6.1 程序一启动就乱跑、变量被莫名改写这类问题十有八九指向存储空间配置不当。有一次我帮一个网友看程序他的现象是程序运行几分钟后一个全局变量被莫名其妙地改成了0xFF。我让他把MAP文件发过来一看发现他的工程里同时用了data和xdata的变量但某个外部RAM地址译码有问题读写操作时地址总线上的毛刺导致写到了错误地址恰好那个地址又映射到了片内SRAM。这种问题光看代码是看不出来的必须结合硬件原理图和数据手册逐位核对地址译码逻辑。另外程序乱跑也经常是因为堆栈区被破坏。51的堆栈默认设在内部RAM的高端如果data区的变量太多数组越界写入先遭殃的往往就是堆栈。栈指针一乱函数返回地址就被改了程序自然就跑飞了。排查思路很明确先检查有没有数组越界特别是data和idata区域的数组然后把用不到的调试代码和未初始化变量清理干净实在不行可以在关键变量周围加一些哨兵值运行一段时间后检查哨兵值是否被篡改用二分法定位越界写入的源头。6.2 外部RAM读写总是不出数据接好外部SRAM后读写没反应这个坑基本每个人都踩过。常见原因按出现频率排序大致有这么几种第一种是锁存器电路问题74HC373的LE引脚没接对或者锁存器本身引脚焊错了导致低8位地址根本锁不住第二种是片选信号没拉对CE引脚悬空或者接了不该接的电平芯片压根没有被选通第三种是RD和WE接反了读变成了写写变成了读数据的时序全部错位第四种是地址线或数据线的顺序接反了比如A0接到了A1上这种情况下读取的数据看起来错位但又不是完全乱码特别容易迷惑人。排查的时候我建议按信号链路的顺序走一遍先确认VCC和GND正常再用逻辑分析仪或示波器看ALE引脚在访问xdata时是否产生下降沿然后检查锁存器输出端有没有保持正确的地址值再检查SRAM的CE、OE、WE信号在读写瞬间是否按预期动作最后看数据线上的波形。很多人一上来就怀疑芯片坏了其实芯片坏的概率极低绝大多数都是接线和时序问题。另外无论什么时候扩展外部RAM都要记得在C51里对xdata区域的变量正确声明因为编译器不会因为你外接了SRAM就自动把变量放过去。6.3 堆栈溢出和存储器模型配置不当堆栈在51单片机里是一个很容易被忽视的隐形存储区。51的堆栈位于内部RAM里由SP寄存器指示栈顶位置复位后SP默认值为0x07也就是堆栈从0x08开始往上生长。如果程序里用了递归或者嵌套很深的中断调用堆栈会迅速向RAM高端扩张一旦越过RAM顶端就会覆盖到其他变量的存储区引发各种莫名其妙的故障。关于堆栈和存储器的关系我还有一个实际经验要分享。在Keil C51里默认的存储器模型是Small也就是变量默认放在data区。当你把模型改成Compact或Large之后大量变量会被挪到xdata或pdata区域这确实释放了内部RAM的压力但同时访问速度变慢了代码体积也会增加。我有一次为了省data空间直接改成了Large模型结果程序体积暴涨Flash差点放不下。所以存储器模型的选择一定需要综合考虑内部RAM的容量、外部RAM的速度、代码体积的需求不能只盯着某一个指标。稳妥的做法是先按默认模型写代码把确实需要快速访问的小变量显式指定为data把大数组和缓冲区显式指定为xdata最后再看整体占用情况而不是简单地在编译选项里改一刀切。踩过的坑多了之后我养成了一个习惯每次在单片机上跑一个新工程第一件事就是打开MAP文件把DATA、IDATA、XDATA、CODE四类空间的占用量记下来保存一个baseline。后面每次改动代码都会顺手对比一下。这个习惯看起来笨但真的能帮你在问题出现之前就把它摁死。等你哪天自己写完一个完整的小项目回过头来再翻这篇关于存储结构的文章应该就能体会到这些看似枯燥的地址编号和存储区划分其实才是真正决定一个嵌入式程序能不能稳定跑起来的底牌。
返回列表