近来为了深入学习C++浅要学习了计算原理、汇编语言,并阅读了不少相关帖子,现在仅就个人学习思考和心得做一下小结,如有错误之处感谢指正。
1. C++指针
指针是C或C++中很强大的工具,指针赋予了编程人员很大的自由。
(其实各种编程语言都会使用指针,只是有的语言不需要明确定义数据类型,数据类型由编译器自动确定,因而指针的概念被淡化了。像python中的对象其实也是一个指针,如果我们创建一个列表,在调试的时候可以看到它)
从概念上来说,指针是指向一个内存地址的特殊变量,指针是有类型的,可以根据自己的需要定义不同数据类型的指针,但实际上指针只是一个无符号整形变量,它指示的是内存地址位置。 我们写的C++程序定义指针的类型只是为了让编译器理解并生成正确的程序,在CPU内部只有0和1,想要使CPU按我们的意图工作并返回正确结果需要编译器做很多工作,而类型判定就是很重要一步,这直接关系到编译器如何理解你的程序意图。下图展示了计算机的内存地址包含范围。
可以简单的理解计算机把各个内存元件(包括主板的ROM,显卡的显存,等随机存储元件)按某一顺序进行编址,这个地址就是真实的物理地址,一般常用16进制整数表示,如0x00E6F834(注意单位是字节,1Byte=8bit)。
补充一点:主板的ROM(只读存储器)的编址一般位于最高位,比如0xFFFFFFFF(可能不是很准确,大概意思如此),主板ROM上有启动引导记录,在计算机启动时,CPU断电重启后,指令寄存器会置为最高位,于是首先加载该地址的程序执行。随后主板BIOS引导程序将控制权交给操作系统引导程序,操作系统的相关程序开始加载,系统开始启动。
重新回到指针,指针本质上就是记录内存中一个字节(8bit存储单元)位置的编号
(我们程序中的指针指向的实际是操作系统分配的虚拟地址,这些虚拟地址和真实物理地址间的映射关系由操作系统管理,为什么要这么做涉及到操作系统安全和资源分配等很多因素)。
那么如果让我们自己去思考,指针应该有多大呢?我们目前的计算机内存条已经有64GB和128GB的了,显卡的显存也有很多超过10GB,那如果想给每个存储介质的字节编址,指针的最大值应该大于存储介质容量。计算一下:2^10=1KB 2^20=1MB 2^30=1GB 2^32=4GB 2^36=64GB 2^37=128GB 2^40=1TB 2^50=1PB 2^60=1EB 2^64=16EB。
在不考虑CPU内部改变寻址设计情况下,有以下结论:
2个字节的指针能最大指示2^16=64KB,这是一些16位处理器的最大寻址范围。
4个字节的指针能最大指示2^32=4GB,这是一些32位处理器的的最大寻址范围。
8个字节的指针能最大指示2^64=16EB,这是win64(X86_64架构)处理器理论最大寻址范围.
注意上面最大寻址范围与CPU实际最大寻址能力并不一致,这是因为CPU设计的成本原因,CPU寻址需要通过地址总线,这是真实的物理线路,当地址总线有32根时,寻址能力是2^32=4GB,增加总线数量成本是很高的,所以设计64根地址总线不实际,而且也用不上,现在计算机的内存32G内存条已经足够了,甚至4GB内存也能让大部分程序正常运行,毕竟可以在内存不足时把内存中的数据交换到硬盘上(硬盘,包括SSD和机械硬盘,属于外接存储设备,不能被CPU直接寻址,但CPU通过存储控制器能间接访问硬盘)。Linux系统的swap分区就是用来交换内存的。
我查阅了一下Intel官方参数:i9-13900KF处理器最大内存支持128GB。这意味着CPU内至少有37根地址总线可用(2^37=128GB)。但官方文档上显示超过39位的地址才是无效的,因此i9-13900KF地址总线可能有39根(2^39=512GB),但由于某些问题内存最大只支持128GB。当然也有可能只有37根地址总线,Intel在判断地址是否合法时为未来留了冗余。
指针的大小一般根据平台类型,编译器会生成不同长度的指针,如win32应用程序指针长度4个字节,win64应用程序指针长度8字节
win64(x86_64) 可以发现指针前6位都是0,代表前24个bit值都是0,所以8字节指针在我的电脑上实际有效位是40位,其中37-40位不一定都有效,因为最高位 2=(0010) 前面2个0不一定有效,但至少有38位有效地址。(当然这个地址是虚拟地址,操作系统也有可能生成大于有效物理地址的虚拟地址,只要映射关系正确就没问题,这只是个人猜想,未经验证)
win32(x86)
理解了指针是什么,我们在编程的时候就可以更容易理解不同指针,函数指针等抽象描述了。
简单介绍几种特殊指针:
整型指针:int *p 以64位平台为例,int类型4个字节,p 为int 类型数据的首个字节地址
字符串指针:char * p 一个字符占用1个字节,p 指向一个字符串的首个字符地址
数组指针:如整型数组 int p[10]={0} p指向数组第一个整数,输出*p会显示0
函数指针:int (*p)(int a, int b) p为一个指向函数原型为 int func(int a,int b);的指针,实际值为函数代码段的首地址(入口地址)。后面会介绍C++ 源文件如何编译执行的。
总结:任何指针都只是一个整数,值为虚拟内存空间的字节编号,空指针值为0;指针可以指向任何有效地址,虽然指针本质上都是指向一个地址的变量,但编译器需要知道指向的地址是什么东西,从而对指针指向的数据进行正确操作,所以定义指针时需要声明指针的类型(指针指向的数据类型)。
2. C++基础数据类型长度
对于基础数据类型长度曾经困扰了我很久,所以在这里也简单说一下。
基础数据类型是每种语言都会定义的,它包含了我们编程时会经常用到的变量类型,如整型,短整型,长整型,浮点型,字符型等。我认为C语言设计之初就是面向数字的,C语言本身是面向过程的,接近汇编语言,基础数据类型中没有字符串类型,只能使用字符数组表示,像VB中就有字符串类型。
许多语言都有bool,int,short,long,float,double等类型,下表中是C++常见的基础数据类型字节长度,对于32位系统来说是全部正确的,但不同平台的int,long长度会有区别。
16位操作系统:int = 2Byte long = 4Byte
32位操作系统:int = 4Byte long = 4Byte
64位操作系统:int = 4Byte long = 8Byte
这些差异应该是处理器发展遗留问题,当新的处理器诞生,总会需要一些改变来匹配其性能要求,在嵌入式设备中,许多使用16位处理器,因而2字节的int类型能有很高的处理速度。对于32位处理器,同时处理一个32位长度的数据无疑会使其性能得到彰显,所以int类型长度为4字节。那么对于64位处理器, int理应为8个字节才对,可事实却还是4字节。这里我给出两点可能不是很准确的原因,第一,为了向旧的32位程序兼容,无疑当int长度变为8字节,许多旧的程序会无法运行。第二,int和long在64位处理器中处理速度并不一定,许多旧的汇编指令操作数还是32位的,因此编译器若编译的指令集是旧的汇编指令,int是会比long快的,而如果使用操作数为64位的指令,汇编指令长度又会增加,这也会带来额外开销。所以int设为4字节更合适。
补充一点看到的别人的比较中肯的解释:
少量数字用 int 或 int64 都可,因为 x64 架构对 32 位 int 计算效率有优化,对 CPU 而言,两者差不多,可能只有几个 micro op 的差距。大量数字,即需要放到缓存里了,那么越小越好。这时,缓存命中率和内存读取成为主要矛盾,所以选最小的。
参考链接
关于缓存命中稍后会简单介绍。
3. CPU结构与指令执行
典型的CPU是由运算器、控制器、寄存器等器件组成。
运算器与寄存器直接相连,运算器如何工作取决于控制器和相关寄存器中的值。我们的程序实际就是不断的给控制器和相关寄存器设置参数和取出参数。
一个典型的汇编指令如:mov ax,0003H 表示将0x0123地址处的值送入ax寄存器
上图中的CS和IP指明了指令所在的内存地址,通过地址总线寻址,指令通过数据总线输入到指令缓冲器,然后执行。(所谓的执行实际为运算器芯片在某一特定输入下进行的特定输出,输入和输出都只是芯片不同引脚的电位高低)
寄存器
寄存器是程序员可以用汇编指令读写的部件,程序员通过改变各种寄存器中的内容来实现对CPU的控制。不同的CPU寄存器的个数和结构也不同。每个寄存器都有一个名字,有些寄存器还有特定功能(比如指令寄存器CS和IP)。CPU的位数实际指运算器一次最多处理的数据长度,也是寄存器的最大宽度。Intel x64 CPU 有16个64位宽通用寄存器,默认的操作数宽度是32位(注:与32位模式相同,这也是前文中提到的64位处理器的int类型为什么4个字节的原因)
更多信息可以参考 64位和32位的寄存器和汇编的比较
主存
主存即我们常说的内存,或者说是内存条。主存是能被CPU直接寻址的,每一个字节都有唯一的物理地址。下图展示了8086PC机的内存地址编址规则。
任何可执行文件实际上都是数据和一系列2进制指令集,我们的可执行文件一般都在本地硬盘上。当运行时,需将将其加载到内存中,并将CPU的指令寄存器设为程序入口地址,CPU才能执行程序。
可执行文件内在运行时可能还会动态申请内存以存放数据,如C++ new语句开辟的空间。可执行文件还有可能加载一些所需文件数据到内存,因而,内存空间充裕才能充分发挥计算机的性能,当计算机内存空间不足时也不是毫无办法,操作系统会去将内存中近期未访问的数据写入硬盘,然后就内存释放,但这需要以时间为代价。
一般个人计算机内存16G左右是足够的,多多自然益善,Intel i7 i9 系列CPU最大也只支持128G内存,建议内存条使用2根,如8GB2, 16GB2,32GB*2 等。这与CPU内部构造有关,Intel 较新的CPU一般都设有两个内存控制器,也就可以同时读写两个内存条,这也就是常说的双通道,这会让CPU读写内存速度加倍(但整体性能提升不会2倍,因为CPU内部有高速缓存,只有缓存未命中才会从内存中取数据)
内存泄露
在C++编程中使用new语句申请的内存都是需要自己释放的,如果不及时释放就会一直占用内存资源(特别是无意间开辟堆内存却又未释放),直至进程退出,所有可执行文件在执行结束后,操作系统会回收为其分配的所有内存空间。但是在服务器上,许多进程是需要长期运行的,一旦发生内存泄露导致内存用尽,就只有重启(内存中的数据是易失的,断电后数据将不复存在,操作系统重启后重新管理和分配内存)。
三级缓存
CPU的运行速度和内存的访问速度是矛盾的,CPU每秒可以运行上亿次,但一般内存条的访问时间是微秒级的,和CPU的速度差了上万倍,也就是说如果CPU频繁从内存中取数据,性能优势将不复存在。因此现在计算机CPU都设置了三级缓存,来解决CPU与内存之间的速度矛盾。
位于顶层的存储器速度最快,但是相对容量最小,成本非常高。层级结构向下,其访问速度会变慢,但是容量会变大,相对造价也就越便宜。如果将常用数据按使用频次放入一级、二级、三级缓存中,CPU会在缓存中寻找所需数据,一旦找到(缓存命中)就不需要花费很长时间去主存中取。
根据CPU 的局部性原理:
1.时间局部性:如果某个数据被访问,那么在不久的将来它很可能再次被访问
2.空间局部性:如果某个数据被访问,那么与它相邻的数据很快也可能被访问
那么我们只要提高缓存命中率就能够在经济的条件下提升程序的性能。
当CPU内核读到内存加载指令时,它将地址传递给L1高速缓存。L1检查它是否包含相应的缓存行。如果不是则从下一个更深的缓存级,继续检查,如果还没;则从内存中引入整个缓存行。
即任何时候,任何高速缓存级别中存在的所有高速缓存行的内容始终与相应地址的内存中的值相同。也就是说高速缓存行是以实际物理地址为索引的实际数据的副本。
各类存储介质读写速度及时延
更多信息可以参考** :**
CUP 三级缓存L1 L2 L3 cahe详解
内存的速度和CPU缓存速度比较
3. C++编译链接过程
先看一下C++项目是如何组织的
首先区分一下头文件和源文件。头文件以.h为拓展名,源文件以.cpp/.cxx等为拓展名。源文件一般存放函数、类成员函数和常量等定义(即代码实现),头文件一般存放函数的原型声明、内联函数等(C++内联函数允许多次定义,所以能够放入头文件内被多个源文件包含)。C++中,函数(除内联函数)的定义只允许出现一次,否则会报重复定义错误。这是因为C++的函数是共享的,在同一个命名空间内每个函数只允许定义一次,当然可以在不同命名空间定义同名函数。
C/C++的编译系统和其他高级语言存在很大的差异,其他高级语言中,编译单元是整个Module,即Module下所有源码,会在同一个编译任务中执行。而在C/C++中,编译单元是以文件为单位。
C++项目的编译过程按步骤可分为预处理、编译(汇编和机器码)、链接三部分。
(以下不考虑编译器优化)
预处理部分,包含宏定义替换,头文件展开,条件编译展开,删除注释。预处理器将对cpp文件进行宏替换,编译器将会在指定路径下搜索cpp文件内包含的头文件,然后将包含的头文件直接拷贝到源文件的相应位置,这一步很机械,仅仅相当于复制粘贴。
编译部分,编译器先将预处理后的cpp编译成汇编文件(.s),然后将汇编文件编译为目标文件,即二进制机器码(.o / .obj)。编译时,若源文件中存在未声明的函数调用,将会报编译错误,若没有函数定义但存在函数原型声明,编译器将通过编译,没有定义的函数符号将留给链接器处理。
链接部分,链接器将对所有目标文件中的符号进行修改,主要是针对源文件中出现的没有定义的函数调用,链接器会去在全局查找函数的定义,然后将该函数入口地址修改为实际地址。如果链接器查找不到对应函数定义,将会报链接错误。专业解释如下:
当链接器进行链接的时候,首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。然后遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实现地址,最后把所有的目标文件的内容写在各自的位置上,就生成一个可执行文件。
参考链接
汇编文件编译为目标文件过程如下图:
注意:头文件不直接进行编译,但在预处理时用到的头文件会被粘贴到cpp文件内,随cpp文件一起编译。未用到的头文件不会编译。在VS2019中尝试编译一个头文件会提示“请选择有效的启动项”。在Linux下用g++编译C++项目也是仅编译cpp/cxx文件,编译器一般不会去对头文件单独编译。如果你尝试将一个包含main函数的cpp文件重命名为.h文件,并用g++编译它,g++会将其视为需要预编译的头文件,并对其进行预编译生成.pch 文件。虽然Linux可执行程序是不依赖拓展名的,但g++编译器会识别源文件的拓展名,进行不同处理。
补充
预编译:大型项目往往需要包含许多头文件,有些头文件非常大,如果将这些头文件包含到cpp文件内,每次编译项目时,编译器都需要将包含的头文件内容拷贝到cpp文件并进行相应处理,这可能会让你的编译过程非常费时。一种主流的解决方案是将一些不需要改动的大型头文件放入预编译头文件内,然后在cpp文件内包含这个预编译头文件。编译器每次编译前会检查预编译头文件是否有改动,如果没有改动将不会对其重新编译,从而减少编译时间。
程序描述信息:编译后得到的可执行文件开始的若干位是程序描述信息,记载了程序的入口地址(一般为main函数地址)。CPU会读取描述信息中的入口信息,并将指令寄存器修改为该地址,随后执行主函数。
参考书籍:
汇编语言(第三版)王爽著
深入理解计算机系统
版权归原作者 水流向天空 所有, 如有侵权,请联系我们删除。