计算机程序的三段人生(二)--编译原理--未完

零. 目录

1. 概述

2.

3.

 

 

一. 概述


 

0. 核心参考博文:

https://blog.csdn.net/qq_36459481/article/details/90937809

1. 语言的历史

计算机只能运行二进制,即她只认识0101,所以最开始的程序是用0101编码出来的,用这些01串去控制CPU进行计算,同时还要手动为程序分配存储空间以及输入和输入。显然,这是反人类的.....

         wxy碎碎念:计算机无非就俩件事,把数据找个地方放着(存储),和计算并得到结果。所以我们再阐述编程语言的时候基本都要关联一下这两方面。

后来,芯片厂商发明了一些助记符,也就是汇编语言。所谓的01串,无非就是让CPU去计算,去移动,去获取等等,于是把这些行为用固定的指令去代替,然后将指令可以很容易的翻译成二进制代码。各家的汇编格式可能不一样,例如gcc的汇编器使用的是AT&T文法,常见的汇编语言可以使用nasm编译。但是,汇编会直接和硬件比如寄存器打交道,只要与硬件相关那么他的可移植性就....

最后,高级语言诞生了,管你什么硬件,我代表的就是一个逻辑(算法),至于硬件怎么去实现我的逻辑?不管!,于是,可移植性大大提升!

 

2. 从高级语言到机器语言的转化

根据语言的历史我们基本上可以判断,转化基本上就是 高级 --->汇编 ---->机器, 即

Higher Level language  -- compiling?-->  assembled code --assembling--> object Program --linking--> executable program

参考链接:  http://www.uobabylon.edu.iq/eprints/publication_1_26931_35.pdf

https://en.wikipedia.org/wiki/Compiler

https://stackoverflow.com/questions/845355/do-programming-language-compilers-first-translate-to-assembly-or-directly-to-mac

0)Compiler, 编译

   解析:广义的编译器"compiler"是指将一种编程语言(源代码)转化成另一种(目标代码)的计算机程序。现在主要专门指将高级编程语言转化成一种可执行的低级的语言(例如,汇编语言,目标代码,或者机器代码)。也就是说,Compliler的结果不是一定要是汇编,或者一定要直接就是机器码,而是根据你的要求生成,但是有一点是明确的,被操作的对象:高级编程语言。

 

     解析:再一次说明编译后得到汇编并不是必要的,可以直接就是机器码,本来汇编和机器码就是一对一关系(注意,不是等同),只不过前者是一种对人类更友好的表现形式。那么为什么需要汇编呢? 

        而在gcc编译工具套件中,为了便于Unix平台之间做移植,所以就将汇编器assembler从compiler中单独分离出来,所以在我们最常用的gcc工具编译中,编译compiler可以理解成狭义的"高级语言-->汇编语言", 而从 “汇编语言 -> 机器语言”则经历了assembler和linked。

       除此之外,比如LLVM和MSVC支持将程序编译成一个库函数,也就是一独立的模块,但并不能直接运行,只有等待被别人调用(对应linked)的时候,才会真正被运行起来。

                   wxy: 到此时不知道你是否有这样的疑问。1)unix平台之间的移植怎么就需要了,我全部编译成机器码就不能在多平台之间移植了么?  2)同理, 作为可被调用的库函数,怎么就需要了?

                           

                              https://en.wikipedia.org/wiki/Relocation_%28computing%29

         https://stackoverflow.com/questions/52901266/does-the-compiler-actually-produce-machine-code

         https://stackoverflow.com/questions/62701333/why-do-c-compilers-translate-source-code-into-assembly-before-creating-machine

         https://stackoverflow.com/questions/2135788/what-do-c-and-assembler-actually-compile-to

   解析:当有多进程的操作系统的出现,以及动态/静态链接库函数的需求,都要求不能向嵌入式编程那般,直接去操作地址,而是使用符号来代表地址。

                      于是,编译得到的汇编程序中的地址都是一堆符号,汇编器使用的也是这些符号表来转化成机器码,知道链接器才开始将地址做真正的映射(注意,因为有操作系统,即使此时提到的地址也不是真正的物理地址).  

 

wxy:关于这部分,我们就需要深入讨论下计算机原理,即程序是怎么运行的,以及操作系统所扮演的角色。参见我另一篇博文:

   另外,关于gcc,之前只是知道这是一个编译器工具套件,但是常常看到xx是基于gcc,那么说明gcc并不是简单的编译,肯定是由他的风格,于是现在,我大概理解gcc的如下特性了。

                  首先,也是最重要的,他是可以根据平台编译出运行在操作系统上的object文件,这种文件不是一般的单片机/嵌入式的可执行文件,而是使用的相对地址来表示

                  然后,你会调用不同的底层操作系统,或者库函数,于是编译器会根据你指定的平台执行对应的接口(一般,见glibc?),

                  最后,为了达到如上的目的,其整个编译一般情况下经过了compiler,assembler,linked的过程。当然,如果你不需要引用一些库文件.so,.o等,则可以直接一下编译得到最后的.

                  

 

1)Assembling,汇编

  • 汇编是指将源(source)程序转化成目标(object)程序,其会生成一个.obj格式的中间过程文件或模块。
  • 汇编时,会为数据段(data segment)中每一份数据项(data item),   以及代码段(code segment)中的指令计算地址偏移量。
  • 在汇编期间,会在生成的.obj模块前面创建一个头文件,该文件包含了一份不完整的地址信息。
  • 汇编器(Assembler)会检查语法错误, 如果则不生成object模块。
  • 汇编器会创建.obj .lst和.crf格式的文件,后面两种文件是在运行时创建的,且可选。
  • 对于很短的程序也可以手动完成汇编,程序员可以使用查表的方式将每一个助记符翻译成机器语言(machine language)。
  • 汇编器会逐条读取程序中的汇编指令并作为ASCII字符,然后将其翻译成对应的机器码(machine code)。

汇编器的类型:

有两种类型的汇编器

(1) One pass 汇编器

  • 这种类型的汇编器会一次性扫描所有的汇编程序然后转化成object码。
  • 这个汇编器只有定义前向引用的程序。
  • 跳转指令使用在扫描期间出现在程序后面的那个地址,所以在这种情况下,程序员会在程序组装后定义这些地址。(wxy: 没理解)

(1) Two pass 汇编器

  • 这种类型的汇编器会扫描汇编语言两次.
  • 第一次pass会生成程序中的名称(name)和标签(labels)的符号表,并计算他们的相对地址。
  • 这个表可以在列表文件的末尾看到,这里用户不需要定义任何东西。
  • 第二遍pass使用第一遍构建的表并完成目标代码的创建。
  • 这个汇编器比之前的那个更高效、更容易。

2)Linking,链接

  • 这一步涉及的是将 .OBJ 模块转换为 .EXE(可执行)模块,即可执行机器码。
  • 正是使用上一步汇编器留下的地址完成的。
  • 它会将汇编过的目标文件分别进行组装。
  • 链接会创建 .EXE、.LIB、.MAP 格式的文件,其中最后两个是可选的。

3) Loading and Executing,加载和执行

  • 他会将程序加载到内存中等待执行。
  • 他会解析剩余的地址。
  • 这一步的处理会在程序加载之前创建程序段前缀(PSP)。
  • 最后执行并产生结果。

 

 

 

 

 

 

 

 

汇编编译器assembler编译目标代码二进制文件(nasm -f elf -g -F stabs *.asm),连接器linker(ld -o bin_file *.o)除了把目标代码组合成一个单个的块,还要确保模块以外的函数调用能够指向正确的内存引用(连接器必须建立一个索引,也就是符号表,里面存放的是它连接的每一个目标模块中的每一个已命名项,其中存放着一些关于哪个名字或叫符号指向模块内部哪个位置的信息)。
立即数,内置在机器指令内部,它不是存放在寄存器中,也不是存放在位于某个指令之外的内存中。
1, 寄存器打中括号代表寄存器的内存地址中的内存数据。例:
mov eax,[ebx+16]。
2, 在汇编中,变量名代表的地址,不是数据。例:
Msg: "Hello World"
mov ecx,Msg         #复制Msg地址到ecx寄存器,而不是数据
mov edx,[Msg]       #复制数据,而不是地址
MsgLen: equ $-Msg   #$代表末尾,长度=末尾位置减开始位置

 https://www.zhihu.com/question/315568815/answer/661107210

 https://stackoverflow.com/questions/6287210/assembling-and-linking-steps-for-assembly-language

 

https://www.zhihu.com/question/348237008/answer/843382847

2,

  • 机器语言(CPU可以直接识别的语言)不存在“变量”这个概念,它只能操作内存和寄存器(CPU内部的几个暂存电路,数量很有限)。变量这个概念在实现的时候通常是将之对应于某个寄存器或者某一片内存。
  • CPU提供了一系列指令来方便程序员维护一个叫“栈”的数据结构。栈位于内存当中,栈顶和栈底都保存在特殊的寄存器当中,CPU可以随时将数据压栈或者出栈。这个“数据”的含义实际上比较宽泛,它可以是一个数字、字符,也可以是CPU的运行状态。CPU可以将某一刻的运行状态压栈,然后跳转到其他地方执行一段程序,然后出栈恢复之前的执行状态,这就实现了函数的调用。没错,CPU也不太认识什么叫做“函数”,不过将运行状态压栈以及恢复运行状态都有专门的指令,一般就把它们成为”子程序调用指令“和“返回指令”。
  • 对于“局部变量”,CPU连“变量”都不认识,所以局部变量什么的也不存在了。不过这玩意可以依靠栈来实现,通过压栈就可以随时分配一个内存,出栈就可以认为这个内存区域不再被使用。配合刚才说过的“函数”调用的实现方法,这种依靠压栈分配出的内存一定只能被当前正在执行的函数使用。因为函数调用前,这片内存还没有被分配,返回后这片内存也不再使用了

编译阶段发生了什么,编译阶段编译器会分析源代码,然后把源代码转成一系列的出入栈操作指令,还有调用指令等。比如说foo那一段变成了:

总结:
 
 
 
 
说到这里不得不说一些CPU的结构,奶奶的,又要跑题
CPU:
中央处理器(CPU,Central Processing Unit)是一块超大规模的集成电路,是一台计算机的运算核心(Core)和控制核心(Control Unit)。它的功能主要是解释计算机指令以及处理计算机软件中的数据。
中央处理器主要包括运算器(算术逻辑运算单元,ALU,Arithmetic Logic Unit),译码器,寄存器组成,和高速缓冲存储器(Cache),以及实现它们之间联系的数据(Data),控制及状态的总线(Bus)一起协同工作。
CPU与内部存储器(Memory)和输入/输出(I/O)设备合称为电子计算机三大核心部件。
针对这一段"阅读理解"一下
1)CPU主要两件事:运算和控制。运算谁?运算数据!数据在那里?在存储中,包括寄存器,缓存和内存!怎样控制这种行为?通过总线,包括数据总线和地址总线!
2)关于三个存储器件:寄存器,缓存和内存
   寄存器,缓存:属于CPU内步的;  缓存和内存:同属于RAM类型介质; 
 
 
现在我们从外一层一层理解
 
C语言等高级语言肯定是不能直接执行的,需要进行编译,得到最终的机器语言,然后交给CPU执行机器码
1,第一步:编译
    目标都是机器码,可以直接编译得到机器码;
                               可以先得到汇编(编译),再从汇编得到机器码(汇编);
                               如果涉及到库,编译/编译+汇编 之后,再经过链接才能得到可执行文件,也就是一堆机器码,详细的原因可以参考
    另外,需要说明的是一般的博文常常会直接拿汇编解释CPU的行为,因为汇编几乎可以认为和机器码一一对应,只不过汇编使用了人类可理解的符号去表示机器码(后来学了个词叫"助记符",嗯,很好!)
              而高级语言就不同了,高级语言是"一句话包含多个含义",即一条语句可能被翻译成多条汇编
              即,汇编语言就是机器语言,表示不同罢了
                     高级语言属于另一个层级的概念,需要"分解"一下才能对应成汇编
 
2,第二步:程序被加载到存储中
   如果是无操作系统的,比如单片机,则需要提前将编译得到HEX格式文件烧到flash种,或者说是ROM中
   如果是有操作系统的,则首先会执行一段叫做BIOS的程序,由BIOS加载操作系统到xxx,而这个BIOS是CPU出厂就写入到ROM中的,即BIOS ROM
                    详细可以参看我的系列博客“Linux内存管理-预备篇2(寻址与内存映射) ”
  总的来说,CPU工作的最最开始都是先和ROM中的指令(机器码)打交道,之后指令被???取到Cache?在取到
 
3,第三步:解析机器码
   从我们的角度来看,CPU认识机器码(010101.....),每一条机器码代表着一个操作,比如将xx移动到寄存器中,那么对于数字电路该怎么去实现这个操作呢?
这里就又出现了我们熟悉的概念:指令集比如精简指令集(RISC) 和 复杂指令集(CISC)
我其实是有些疑惑的,所谓指令集是用在制定汇编到机器码对应关系的,还是用在CPU拿着机器码如何控制电子电路工作的
说法1:从汇编到机器码的对应关系,就是指令集指定的
说法2:CPU拿着的机器码尽管已经是01码了,但并不是直接控制电路的高低电平,还需要一层CPU内部的翻译,也叫作微码(Micro-Ops)
             Micro-Ops作为可以执行的最小单位,可以被调度入Pipeline中去执行(这就是下一步的工作了)。
             至于机器码可以划分成几条微码,则根据该机器码(指令)的复杂程度。
             参见博文https://www.zhihu.com/question/348237008/answer/847081068中老狼的回答
 
另外,何为精简指令集,何为复杂指令集
精简指令集:RISC(Reduced Instruction Set Computer),比如存,取,加这类指令
复杂指令集:CISC(Complex Instruction Set Computer),更复杂的指令比如POP等。
学过电路原理的知道,几个晶体管就能组成一个加法器,如果要得到更高级的操作有两种方案:
1)拆分成多条精简指令,多次执行。
 2)就得搭建更复杂的电路,真正执行的时候使用的是微码的技术,即需要Decode更小的计算单元
 
更多的参考:
 
小结:
  总结了各方的说法,初步可以得到如下的结论
  1)指令集指的是汇编和和机器码之间的映射关系
  2)计算机架构也叫微架构, 有一种说法是"CPU是某个特定指令集在硬件上的实现",另一句话"指令集决定微结构的一部分硬件逻辑设计"需要好好理解下,因为架构可以支持一种,两种指令集,并不是一对一关系。另外,实际上实现指令集是微架构的重点但不是全部,这个架构海决定了怎么取指令,解码,分指出流水线等,也就是光有对照表肯定不幸,要让电路实际工作起来才是终极目的。
  3)上面说到解码,结合推荐博文中有这样的后记:"而这个decode的过程,让曾经泾渭分明的RISC和CISC两种CPU架构的界限变得模糊了起来....",我的理解是
  所谓精简指令集,精简嘛,可以认为是一个机器码对应一种电路操作
  复杂指令集,一个机器码实际包含了多种操作,经过Decode才变成了单一的电路操作,也就是微码
  但是微码和精简指令不是一回事,只是类似的都属于单一的执行单元
 
 
还有个就是编译器:其实就是将高级语言编译成汇编,再查指令集翻译成机器码
具体来说:
1)高级语言 ---> 汇编语言:编译
   高级语言你诞生了,那肯定就是有自己的一套规则,你想干啥编译器就把你的逻辑梳理成汇编语言
   一种高级语言具备自己的编译器
2)汇编语言 ---> 机器语言:汇编
   这个就是通用了,根据指令集,对应成机器码
3)一般使用的gcc编译器,把常用的编程语言都涵盖进来了,所以编译谁都行
4)一般一个语言的诞生,都会附带一个用该语言编写的编译器,这种叫做"自举"。那怎么得到这个编译器呢(怎么编译出来呢):
    最最开始肯定是汇编,   用汇编写一个就叫做base的编译器,这个编译器就负责解析你用新语言写的编译器.....好绕....
 
 
 
   比如,让ALU干什么,让BUS干什么,所以CPU还需要拿着机器码进行decode成高低电平,于是
 
 4,第四步:调度
 
5,第五步:被执行
------------------------------------------------------------
关于函数(汇编)是如何被执行的,用一个例子来说明
global main

flag1:
        add eax, 1
        ret

main:
        mov eax, 0
        call flag1
        ret
(gdb) disas main0x080483f4 <+0>:    mov    $0x0,%eax
=> 0x080483f9 <+5>:    call   0x80483f0 <flag1>      //在此处打断点,此时程序还未执行call语句
   0x080483fe <+10>:    ret    
   0x080483ff <+11>:    nop
(gdb) info register eip
eip            0x80483f9    0x80483f9 <main+5>       //所以eip中是接下来要执行语句的地址,即call语句
(gdb) info register esp
esp            0xffffd16c    0xffffd16c
(gdb) stepi
0x080483f0 in flag1 ()                              //此时进入flag1函数,到达该函数的第一条语句待执行

 (gdb) disas
  => 0x080483f0 <+0>: add $0x1,%eax
     0x080483f3 <+3>: ret 

(gdb) info register eip
eip            0x80483f0    0x80483f0 <flag1>     //此时eip的内容就是即将执行语句的地址,即flag1函数的第一条
(gdb) info register esp                           //桟指针上移(地址负增长)一条指令的长度,即4字节,内容是call指令的下一条,即ret指令 
esp            0xffffd168    0xffffd168             
(gdb) p/x *(unsigned int*)$esp    
$1 = 0x80483fe
小小结:
CPU接下来该执行哪一条? 根据寄存器EIP存放的地址。EIP存放的是接下来要执行的。wxy:CPU取完指令后EIP++
CPU如果临时跑到别的地址,怎么回来?根据寄存器ESP存放的地址。ESP存放的是从别处回来后接下来要执行的指令地址。
       ESP实际是堆栈的桟顶元素的内容,不同于EIP存放的是一个孤立的地址,但是刚好桟顶元素就是接下来要执行的第一条
        wxy:貌似是压桟之前ESP的先上移
 
 
这个堆栈不是我们谁用代码写出来的堆栈,而是在一块内存中开辟出来的硬件堆栈,这个堆栈除了用来存放每次离开的节点,还用于局部变量,比如一段C语言代码经过编译的时候时这样的
 
我一直有个疑惑,代码中声明了一个变量,之后又操作这个变量,那么在编译的时候(目前就先只看编译成汇编),这个变量是以什么形式存在的呢,难道就是记录一下这个符号?
汇编语言中要么操作寄存器,要么操作立即数,要么操作内存,就没见到操作符号的!
有的文章就说局部变量是在桟上分配内存,但我总是觉得没有融会贯通,到底谁给分配的内存呢(此时不要考虑操作系统)?比如声明了一个变量a和一个变量b,分别放哪有什么讲究。
开始我还以为编译的时候会分配一个虚拟的存储给变量,在汇编语言中,就直接操作这个假的临时地址...
比如这个同学,和我有相同的疑惑,当然也可以看看下面的解答
其中有的回答是我一直以来也是这么认为的,但是又隐隐觉得不对劲的一句话:"编译器分配4个字节内存...."
程序还没运行呢,编译器怎么就有能力分配内存呢?分配的是虚拟内存么?这个虚拟内存是操作系统那个虚拟内存一样么?
答:
 
 
但实际貌似不是这样的,当c语言中声明了一个变量后,编译成汇编就是一条push到堆栈中的语句,使用完这个变量就pop出来
也就是说一个push指令相当于分配存储的指令,真正执行的时候自然push到堆栈中,自然就是占用了一块内存
当然,这个是针对局部变量的,对于全局变量和static变量,则另外说....
 
关于为什么引导程序是汇编,就不能是C?反正C经过编译就是汇编啦?
答:根据上面的解释可以知道,C语言需要堆栈,在堆栈可用之前的这些代码需要使用汇编,用汇编构建好堆栈之后才能执行C程序
       所以启动OS之前,需要又BIOS和bootloader
 
BIOS会首先进行自我检查(POST),然后寻找可引导设备。一般来说,操作系统都是从硬盘上引导的,这时可引导设备就是硬盘。BIOS只会把可引导设备的第一个扇区(512字节)读入内存0x7c000的位置,并跳转(jmp)到0x7c000的位置开始执行其中的代码。512字节显然装不下一个OS,因此大多数OS都在这512字节内存放引导程序(bootloader),引导程序的作用就是加载完整的OS到内存,并开始执行OS代码。

局部变量都是桟,那么全局变量和静态变量呢?
首先,相同点,
在编译阶段:编译器看待他们是一样的,即他们再编译的时候都是编译器就分配好了地址,为什么说是地址呢,具体原理是:
 
然后,再区别:
静态局部变量:作用域为其定义的函数,地址是在编译阶段就定了,并且做了初始化,之后每次调用该函数,该变量都是原本的那个,所以再值方面,会呈现"有记忆"的特点。
普通全局变量:作用域为整个项目?。在本编译单元(源文件)中,所有函数共享,在其他源文件中,用extern关键字引用后使用
静态全局变量:作用域是一个编译单元(源文件),其他源文件不可以引用。范围反倒缩小了,要这么想。因为是全局的,所以文件中的所有函数都可以用,但是因为静态的,即地址固定了,再给别的文件用有点不合适,因为拿不走了。作用:保护代码模块化,封闭外部接口。
                        
 
 
 
 
 
BIOS和Bootloader的关系
答:BIOS是硬件家的,即CPU出场就有的,他的作用除了自检,就是去固定位置找操作系统
       BootLoader是操作系统家的,当收到BIOS的通知后,我才真正开始让我家的操作系统登上台面
     为什么BIOS不直接加载操作系统?
       答:操作系统大小,存放的位置(硬盘的哪里)等都是不固定的,怎么加载我BIOS才不管呢,我就负责通知下,然后操作系统自己负责搞一个程序专门用于加载。
 
 
 Bootloader:标准的如GRUB,如果自己实现,那么就得遵循和bios之间的约定,例如将程序静态链接到起始地址0x7c00;如果依赖GRUB,就得遵循multiboot spec
 
 
----------------------------------------------------------------------------
 
说明,其中三四步在推荐博文中,老狼的回答克很好的看出一条指令经历了什么,即 Decode ---> Scheduler  ---> Pipeline  --->计算单元ALU,累加器等电路
 -----------------------------------------------------------------------------------------------------------------------------
编译器到底干嘛了
编译器可以分为前端和后端两部分。
  • 前端负责:词法分析 -> 语法分析 -> 语义分析 -> 中间代码生成
  • 后端负责:平台无关优化 -> 指令选择 -> 平台相关优化 -> 指令调度 -> 寄存器分配 -> 目标代码生成
 1.我们常说的符号表是前端的概念,后端不需要。
    何为符号表:就是真正的汇编+二进制和高级代码的映射关系,如果后端生成的可执行代码需要debug,则就也需要符号表,因为我们在debug模式下,肯定要操作函数啦,变量啦,这些高级语言层面的东西
2,编译器IR
3,寄存器分配器是什么东西?
    答:之前说了给局部变量分配存储都是一条汇编命令 push a,相当于在桟中获取了自己的一席之地,但实际上既然有那么多寄存器,为啥不用寄存器承载这些变量呢,即在汇编语言层面用寄存器代替局部变量
           因为寄存器是有限的,编译器在编译的时候根据变量的生存器给他分配不同的寄存器,寄存器不够就在桟上分配(push 桟),这个也叫做寄存器溢出,Register spilling
           具体的算法可以参考:https://www.zhihu.com/question/26161033/answer/43447161
           反正就是寄存器是用来作"中转"的,这个变量可能占用这个寄存器,但当他声明期结束了,寄存器又可以给别人用了
 
        目前就先认为局部变量是靠push语句分配内存的吧
 
 4,全局变量,静态变量
 
 
 
 -------------------------------------------------------------------------------------------------------
 
附1:关于内存,外存,硬盘,RAM,ROM概念区分
wxy:感觉工作之后都是在还债,好多基本概念上学时都是似是而非,很多人说RAM是内存,ROM是外存/硬盘,包括我也一直这么大概认为着,
         但实际这是错误的,当然产生这种错误是有历史原因,下面我们梳理一下。
1.主要参考链接
 
2.基本概念
首先,说说这些存储器
 RAM:随机存取存储器(random access memory),半导体存储器,存储单元的内容可按需随意取出或存入,存取的速度与存储单元的位置无关的存储器。
          虽然断电会丢失数据,但是有电期间也不是高枕无忧,所以更细划分有如下两种类型RAM:
1)SRAM:静态随机存储器(Static RAM),不需要刷新电路即能保存它内部存储的数据。存储单元是0是1只要上了电,除非重新赋值,否则不会改变。SRAM速度非常快,是目前最快的存储设备,但是造价昂贵。
2)DRAM:动态随机存储器(Dynamic RAM),每隔一段时间,要刷新充电一次,否则内部的数据即会消失。比SRAM速度慢但造价低,当然比ROM是快很多的。
   DRAM更更细分的话还有很多种,但是这里不再展开。
 
 
 ROM:只读存储器(Read-Only Memory),属于半导体存储器,在系统停止供电的时候仍然可以保持数据。在早期,只能读出信息不能写入,也就是说在出厂的时候就"定死"了。随着技术的发展,ROM已经不再是严格的只能读出不能写入了,随着技术的进步衍生出如下几种:
1)PROM:可编程ROM(Programmable ROM),用户可以用专用的编程器将自己的资料数据写入,只有一次机会,一旦写入在也无法修改。缺点是成本高速度满所以应用场景少。
2)EPROM:可擦出可编程ROM(Erasable Programmable ROM),顾名思义,在PROM的基础上有了可重复擦除和写入的特性。缺点是需要借助专用工具且写内容时要求加一定的编程电压。
3)EEPROM:电可擦除可编程ROM(Programmable ROM),不需要借助设备,用电子信号就可以修改其内容,说到电子信号,不就是CPU执行机器码干的事情么?所以说,用程序就可以控制写入了。另外,EEPROM是以Byte为最小的修改单位。
4)FLASH :闪存,有的资料上说是在EEPROM上发展出来的,有的说是EPROM上的,不管怎么说他都是ROM的高级品,同样掉电不易失,同样电可擦除,但不是按字节为单位的擦除,而是可以按块(block)甚至整个擦除,效率更高一些,更加细分还分为:
    (1)NOR FLASH:数据线和地址线分开,可以像RAM一样随即寻址,可以读取任何一个字节,但擦除还是按块来
   (2)NAND FLASH:数据线和地址线复用,不能随机寻址,只能按页读取,同样擦除还是按块。擦除和写入速度比NOR快,读比NOR慢。
 
 
 
3.与实际硬件的关系
市面上一般有两种说法:RAM就是内存,ROM就是硬盘;RAM和ROM都是内存,硬盘是磁盘(非半导体存储器,另一种存储介质)。
经过整合各方材料,我认为以下的说法是准确的:
内存:RAM和ROM都是内存,我们常说的内存条属于RAM(严格说是DRAM),或者叫主存Main Memory; 还有一块内存用于存储BIOS信息,属于出厂就在的,属于ROM
          在研究系统启动原理的时候,我们常常会看到这样的描述:"ROM或者硬盘上寻找引导程序...”,那时候还疑惑,有硬盘啥事阿?
缓存:数据交换的缓冲区(称作Cache),当某CPU要读取数据时,会首先从缓存中查找需要的数据,如果找到了则直接执行,找不到的话则从内存中找。
          由于缓存的运行速度比内存快得多,故缓存的作用就是帮助CPU更快地运行。Cache在CPU中容量很小,一般会分为一级缓存(L1)和二机缓存(L2)。
          描述到这里,是不是猜到使用的是那种存储媒介了?没错,就是SRAM。
 
寄存器:Register,属于CPU内部的部件,可以用FF(电路里的边沿触发器flip-flop)实现,比较大的寄存器组也可以用具有多读写端口的SRAM做,具体选哪个取决于微架构的设计和取舍。
 
 
硬盘分为两种,一种是机械硬盘(即磁盘HDD),一种是固态硬盘(SSD)
  磁盘:也就是我们通常说的硬盘,HDD字眼是不是很熟悉?是不是在很多硬盘上都能看到这三个字母?这类硬盘属于磁性原理存储,的和ROM没什么关系,
  固态硬盘:也算是前几年火起来,我弟弟就曾经跟我说,给你电脑加块固态硬盘,速度会快些,现在想起来,若有所思了。
                  固态硬盘和磁盘不同,固态硬盘用到的颗粒是基于NAND FLASH技术,和u盘以及手机存储有点相似,此时的硬盘,可以认为是一种ROM
 
另:关于以上关键存储的速度,可以参考,主要说了Cache和寄存器
 
wxy:
RAM:属于内存;可读可写,访问速度快,断电会丢失数据; 分SRAM和DRAM两种,分别作为CPU的Cache和内存条。
ROM(基础版):属于内存;可读断电不丢失数据;用于存储CPU的固化信息比如BIOS
ROM(进阶版) :属于外存中硬盘的一个种类:固态硬盘。至于机械硬盘,已经是另外一种材质了....
 
 
==============================================
编译过程问题集锦
1,使用gcc进行分步编译的时候,链接报错
#gcc -S helloc.c                   //编译得到汇编语言文件helloc.s
#gcc -C helloc.s -o helloc.o      //汇编得到二进制文件helloc.o
#gcc -o helloc helloc.o           //链接代码中引用的库文件,得到最终的目标文件

错误信息: helloc.o:在函数‘_fini’中: (.fini
+0x0): multiple definition of `_fini' /usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/crti.o:(.fini+0x0):第一次在此定义 helloc.o:在函数‘data_start’中: (.data+0x0): multiple definition of `__data_start' /usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/crt1.o:(.data+0x0):第一次在此定义 helloc.o:(.rodata+0x8): multiple definition of `__dso_handle' /usr/lib/gcc/x86_64-redhat-linux/4.8.5/crtbegin.o:(.rodata+0x0):第一次在此定义 helloc.o:(.rodata+0x0): multiple definition of `_IO_stdin_used' /usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/crt1.o:(.rodata.cst4+0x0):第一次在此定义 helloc.o:在函数‘_start’中: (.text+0x0): multiple definition of `_start' /usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/crt1.o:(.text+0x0):第一次在此定义 helloc.o:在函数‘_init’中: (.init+0x0): multiple definition of `_init' /usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/crti.o:(.init+0x0):第一次在此定义 /usr/lib/gcc/x86_64-redhat-linux/4.8.5/crtend.o:(.tm_clone_table+0x0): multiple definition of `__TMC_END__' helloc.o:(.data+0x8):第一次在此定义 /usr/bin/ld: error in helloc.o(.eh_frame); no .eh_frame_hdr table will be created. collect2: 错误:ld 返回 1

定位:
根据错误信息大概看出来是有重定义了,那么就说名在链接之前有些工作已经做了,也就是说前两步执行有问题?
仔细看了下前两条命令,突然觉得-C好奇怪,另外发现执行完第二步生成的.o文件就是绿色的(表示可执行)
于是,将第二改成如下,OK。

#gcc -c helloc.s -o helloc.o

 

2,系统异常崩溃,通过如下两方面可以定位之

 反汇编用法:

#objdump  -SCl Test_agent > test.txt

 当系统日至出错如下:

Nov 25 21:47:02 localhost kernel: Test_agent[12558]: segfault at 1a4 ip 0000000000447c3a sp 00007fab166f9aa0 error 4 in Test_agent[400000+d0000]

则可结合得到的汇编代码进行定位,判断是哪里出现的问题

 
 
 
 
==========关于桟的原理明确理论===========================================
0,程序调用碎碎念
在最初始的汇编时,那真的是完全按照人类的思路一直走下去,做做加加减减运算,物理空间可以直接访问,寄存器直接用,所以没什么可疑惑的。
进化1:但是当有了子函数的出现时,就不再时一个main函数一条路走下去,而是有了旁路,然后还得回来,为什么有旁路呢?因为想要复用旁路啊,即子函数可以被多人调用啊。
旁路是一条独立的路,过去的时候需要带着数据过去,回来需要带着结果回来,回来后还要接着上次离开的地方继续,所以怎么实现呢。
方式1,高级语言虽然是有子函数,但是我汇编之后所谓调用,就直接整块复制过来,所以在汇编看来,压根没有子函数的逻辑。但是,如果有调用来调用去的情况,那么这种方式会让整个可执行代码变得无比臃肿。
方式2,调用方函数M(比如main函数)和被调用方(子函数A)都是独立的一块,从调用方离开的时候用寄存器记录一下执行到哪条指令了,以及想要带过去的入参的值,然后去执行子函数,等子函数执行完了读取寄存器找到当初的记录再继续执行下去。
             但是,这个结果也放在寄存器里。但是寄存器会出现不够用的情况,因为会有子函数A调用子函数B再子函数C这种,所以需要记录的就太多了...
方式3,在方式2的基础上我们使用一块内存,以后进先出的方式来做记录。
            思路:M在调用A之前把M的下一条指令地址存入这块内存; 然后把要传过去的入参逆序依次放进去;最后开始执行A的逻辑,再然后在调用B的时候把A的下一条指令的地址存入这块内存;
            待B执行完,继续A,待A执行完,继续M,于是达到了LIFO;
            具体如何实施呢?
            答:首先,要求你编写代码时用我的两条汇编指令叫做call; 然后要求我CPU会提前准备这块专用内存,并用一个专门的寄存器(桟基址寄存器,EBP)指向这块内存的最高地址位即桟底,同时还用(桟寄存器ESP)也指向他。
                   于是,我按照规则编写代码(汇编码或者汇编得到的汇编码),如果涉及调用则先执行call,
                             在程序真正运行的时候,CPU遇到call指令就自动将下一条命令的地址(PC寄存器中的内容)存入桟寄存器中保存的位置,然后桟寄存器ESP中的地址上移一个地址位,这叫做桟顶。
                   但是,调用子函数涉及到入参和返回值,这该怎么办?于是就进入进化2!
              
            注,对于桟寄存器EBP和ESP中的值,在汇编代码中,用桟指针rbp(Stack Base Pointer)和rsp(Stack Pointer)来代表。
 
进化2:在远古的时候,做个加加减减用个寄存器就存放下数据就可以了,但是明显到了后来是不够用的,况且出来了子函数,即有入参和返回值,以及调用方用来接收返回值的变量,以及函数内部会动态获得值的变量,
            所有这些在高级语言中称为局部变量的东西,所谓局部"变量",重点在"变",即不是那些1234的常量,该怎么记录这些变量,然后随着程序的运行给他赋值呢?答:寄存器和桟的原理。
            思路:
                      于是,就继续利用内存即桟
                      首先,利用寄存器,你说你需要个局部变量a,无非就是想用一个中转站嘛,那么你就可以用寄存器ax来代表a呀,运行的时候有了具体的值就给ax啊,但是寄存器的个数是不够用的,
 
1,首先从函数调用说起
 
 
,当然是SS寄存器指向的那片区域,在汇编语言中用rbp(Frame Pointer桟帧指针)来表示
 
 
 
 入栈操作Push和出栈Pop指令的执行,会自动调整esp的值。
 
#include <stdio.h>

int static add(int a, int b, int n1, int n2, int n3, int n4, int n5){
   0:    55                       push   %rbp                             //同样,压桟的是上一个函数的信息,即上一桟帧的
   1:    48 89 e5                 mov    %rsp,%rbp
   4:    89 7d fc                 mov    %edi,-0x4(%rbp)
   7:    89 75 f8                 mov    %esi,-0x8(%rbp)
   a:    89 55 f4                 mov    %edx,-0xc(%rbp)
   d:    89 4d f0                 mov    %ecx,-0x10(%rbp)
  10:    44 89 45 ec              mov    %r8d,-0x14(%rbp)
  14:    44 89 4d e8              mov    %r9d,-0x18(%rbp)
    return a + b;
  18:    8b 45 f8                 mov    -0x8(%rbp),%eax
  1b:    8b 55 fc                 mov    -0x4(%rbp),%edx
  1e:    01 d0                    add    %edx,%eax
}
  20:    5d                       pop    %rbp
  21:    c3                       retq   

0000000000000022 <main>:


int main(){
  22:    55                       push   %rbp            //main函数也是被调用的,是被_start函数调用的,所以也需要压桟,就是把上一桟帧的桟底的位置放入桟中
  23:    48 89 e5                 mov    %rsp,%rbp       //将当前栈顶的值给栈底,相当于原来的栈顶从此,作为本函数栈帧的栈底了
  26:    48 83 ec 28              sub    $0x28,%rsp      //栈顶上移16*2 + 8 == 40个字节
    int x1,x2,x3,x4,x5,x6,x7,x8,x9;
    int x=5;
  2a:    c7 45 fc 05 00 00 00     movl   $0x5,-0x4(%rbp) //从基值开始向下的4个字节赋值为5,相当于这块是x的位置,  movl代表移动4字节,累计占用4字节
    int y=6;
  31:    c7 45 f8 06 00 00 00     movl   $0x6,-0x8(%rbp) //接下来4字节是y的, 累计占用了8字节
    x8=10;
  38:    c7 45 f4 0a 00 00 00     movl   $0xa,-0xc(%rbp)  //接下来的4字节是x8,,累计占用了12字节
    x9=11;
  3f:    c7 45 f0 0b 00 00 00     movl   $0xb,-0x10(%rbp)  //接下来的4字节是x9, 累计占用了16字节
    int u=add(x,y,1,1,1,1,x8);
  46:    8b 75 f8                 mov    -0x8(%rbp),%esi   //把y的值给si
  49:    8b 45 fc                 mov    -0x4(%rbp),%eax   //把x的值给ax
  4c:    8b 55 f4                 mov    -0xc(%rbp),%edx   //把x8的值给dx
  4f:    89 14 24                 mov    %edx,(%rsp)       //把x8的值入栈,纯入栈,栈指针不动,将占用4字节,累计占用20字节
  52:    41 b9 01 00 00 00        mov    $0x1,%r9d         //把常量入参给r9
  58:    41 b8 01 00 00 00        mov    $0x1,%r8d         //   ...    r8
  5e:    b9 01 00 00 00           mov    $0x1,%ecx         //   ...    cx
  63:    ba 01 00 00 00           mov    $0x1,%edx         //   ...    dx
  68:    89 c7                    mov    %eax,%edi         //把x的值给di
  6a:    e8 91 ff ff ff           callq  0 <add>           //跳转.......,这里面隐藏了一个压栈操作,即会将call的下一指令的地址(64位?8字节?)压栈,栈顶指针是否偏移?累计28个字节?
  6f:    89 45 ec                 mov    %eax,-0x14(%rbp)  //用来取得函数调用得到的结果到ax寄存器中?
}
  72:    c9                       leaveq 
  73:    c3                       retq   
[root@localhost testCompile]# 

 

 
 1,每一个函数的入口都有一个栈帧的顶和底的交换,具体做哪些事情呢?
      1)当前rbp的值(代表上一栈帧的栈底)先存到栈里
      2)当前rsp的值(代表上一栈帧的栈顶)作为当前栈帧的栈底
 
2,关于局域变量,如果只是生命,则不会有任何操作
                               如果有赋值操作,则将赋予的立即数压入栈中,可以用mov等操作,反正就是把值放到栈中,当然如果不是push操做(其栈顶会自动上移)要记得本次访问下次要放下一个位置,相当于栈顶上移,但是栈顶指针没变。
    另外,为什么有mov还有movl?网上各种说法,说是不同的指令集,可是在同一个汇编文件中怎么还出现了两种??
 
3,call指令包含的操作有:将call的下一条指令(即PC寄存器中的值)push到栈中(栈顶指针自动上移),然后将PC中的内容赋值为子函数的第一条指令;
 
 
 
 4,栈虽然是向下增长,即从高地址向地址分配内存,但是寻址时永远都是从低地址开始,即给你4个字节存放int,代表这个int类型的地址(首地址)是低字节那里!!!
 
5,在调用之间传递函数(Caller和Callerr?)之间,在64位操作系统中,用di,si,dx,cx,r8,r9这6个寄存器,如果传参超过6个则还是使用栈来做。返回值用rax来存放。
      例子中是64位的,32位的暂时没有过试验。但是根据查找到的资料是说都是通过压栈来传参的。
      注意,这6个寄存器的顺序是固定的,即和入参的顺序一致,在上面的例子中变量的值可能在寄存器之间move,但是最终传参的顺序和要求的6个寄存器的顺序是一致的,
 
 n,碎碎念:我们要明晰一个事情,所谓的栈其实是不存在的,其实就是你自己编写的代码按照栈的规则存放以及取用,而且千万不要有个误区就是栈里面的数据都是LIFO
                       只不过在栈帧之间是LIFO,而栈帧内部压栈是按照栈的思路来,但不代表栈中的读取或者写入都是“压在我上面的不走我就没法翻身”,而是随便!

 

posted @ 2021-07-26 14:33  水鬼子  阅读(716)  评论(0编辑  收藏  举报