精华内容
下载资源
问答
  • 2021-08-15 10:16:22

    1. 结构体struct和类class占用内存大小解析

    今天面试遇到一个比较有意思的问题, 这里安排一下
    空结构体和空类占内存大小是多少?

    答案:
    1. C++指定空结构体和空类所占内存大小为1,
    2. C 的空类和空结构体大小为0

    为何c++会有这样的规定呢?
    no object shall have the same address in memory as any other variable
    如果允许C++对象大小为0, 那么这里的运算将产生两个问题:

    1. 不能通过指针区分不同的数组对象,
    2. sizeof(S1)为0, 导致非法除零操作
    3. 这样一来就需要更复杂的代码处理异常

    示例代码

    #include<iostream>
    #include<string>
    using namespace std;
    
    struct S1{
    
    }; // 内存大小是1字节
    S1 s1,s2; //对象大小s1=1, s2=1, 对象地址 s1=1556095296, s2=1556095297
    
    class C1{
    
    }; // 1字节
    
    class C2{
        C2(){};
        ~C2(){};
    }; // 1字节  // 证明析构函数和构造函数不占空间
    
    
    
    int main(){
    	printf("空结构体大小=%d, \n对象大小s1=%d, s2=%d, \n对象地址 s1=%d, s2=%d\n",sizeof(S1), sizeof(s1), sizeof(s2), &s1, &s2);
    	cout<<"空类大小" <<sizeof(C1) <<endl;
        cout<<"C1类大小" <<sizeof(C2) <<endl;
    	return 0;
    }
    
    /*
    空结构体大小=1, 
    对象大小s1=1, s2=1, 
    对象地址 s1=-2084564687, s2=-2084564686
    空类大小1
    C1类大小1
    */
    

    2. C++中各类数据所占内存字节数

    1. 1字节=8位 [ 00000001 ] 2 [00000001]_2 [00000001]2
    2. sizeof(void*) 32位操作系统中4字节, 64位操作系统8字节
    3. int 4字节
    4. char 1字节
    5. float 4字节
    6. double 8字节
    7. 空类class, 空结构体struct 1字节

    3. C++字节对齐规则

    3.1 什么是字节对齐

    现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

    3.2 字节对齐的原因和作用

    各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些架构的CPU在访问 一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐.其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对 数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那 么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数 据。显然在读取效率上下降很多。

    3.3 字节对齐三个准则 这太磨叽了, 直接看下面的几个示例就明白了

    1. 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
    2. 结构体每个成员相对于结构体首地址的偏移量都是当前成员大小的整数倍,如有需要编译器会在成员之间加上填充字节;
    3. 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。// 比如结构体最大的成员double8字节, 那么整个内存大小应该是8的整数倍, 不够的补

    3.4 字节对齐的示例, 看不懂你来打我

    #include <iostream>
    #include <iostream>
    using namespace std;
    struct S1{
    }; // 内存大小是1字节
    S1 s1,s2; //对象大小s1=1, s2=1, 对象地址 s1=1556095296, s2=1556095297
    
    // 准则1, 首地址必须能被double 8整除, 
    // 准则2, 首地址偏移量都是当前大小的整数倍, 即char=1需要补全到4才能是int=4的整数倍, int4就不用补全了, 因为当前偏移量是4+4是double的整数倍
    struct S2{ 
        char a; // 1 偏移1, 不是int的整数倍, 偏移量+3
        int b;  // 4 偏移4+4=8, 是double的整数倍, 偏移量+0
        double c;  // 8 偏移8+8=16字节
    }; // 16字节
    S2 s3,s4; //对象大小s3=16, s4=16, 对象地址 s3=1556095312, s4=1556095328
    
    
    struct S3{ // 首地址8的整数倍
        char a;  // 1 a偏移1=1, 是e的整数倍, 偏移量+0
        char e;  // 1 1偏移1=2 不是c的整数倍, 偏移量+6=8
        double c; // 8 8偏移8 = 16, 是int b的整数倍, 偏移量+0
        int b;  // 4 16偏移4=20, 不是成员最大字节整数倍, 偏移量补全+4, 最后20+4=24字节
    };  // 24字节
    
    struct S4{ // 首地址8的整数倍
        char a;  // a偏移1=1, 是e的整数倍, 偏移量+0 = 1
        char e;  // e偏移1=2, 不是c的整数倍, 偏移量+6 = 8
        double c; // c偏移8=16, 是b的整数倍, 偏移量+0 = 16
        int b;  // b偏移4=20, 是f的整数倍, 偏移量+0 = 20
        char f; // f偏移1=21, 最后不是8的整数倍, 补全, 偏移量+3, 最后24字节
    };
    
    int a; // 4字节
    char b; // 1字节
    double c; // 8字节
    
    void struct_test(){
        cout<<"指针大小" << sizeof(void *)<<endl;  // 32位机指针大小是4字节, 64位是8字节
        cout<<"int型大小" << sizeof(a)<<endl; // 4
        cout<<"char型大小" << sizeof(b)<<endl;   // 1
        cout<<"double型大小" << sizeof(c)<<endl;   // 8
        printf("空结构体大小=%d, \n对象大小s1=%d, s2=%d, \n对象地址 s1=%d, s2=%d\n",sizeof(S1), sizeof(s1), sizeof(s2), &s1, &s2);
        printf("S2结构体大小=%d, \n对象大小s3=%d, s4=%d, \n对象地址 s3=%d, s4=%d\n",sizeof(S2), sizeof(s3), sizeof(s4), &s3, &s4);
        cout<< "S3内存大小" << sizeof(S3)<<endl;  //24
        cout<< "S4内存大小" << sizeof(S4)<<endl;  //24
    }
    
    class C1{
    
    }; // 1字节
    
    class C2{
        C2(){};
        ~C2(){};
    }; // 1字节  // 证明析构函数和构造函数不占空间
    
    class C3{
    public:
        C3(int a){a=a;};
        int a; // 4
        char b; // 1 + 4是2的倍数, =5
        char e; // 5+1不是8的倍数, 偏移2=8
        double c;  // 8+8 = 16字节
    }; // 16字节
    
    class C4{
    public:
        C4(int a){a=a;};
        int a; // 4
        char b; // 1 + 4是2的倍数, =5
        char e; // 5+1不是8的倍数, 偏移2=8
        double c;  // 8+8 = 16字节
        int sum3(int f, int m, int n){
            return f+m+n;
        }
    }; // 16字节
    
    void class_test(){
        cout<<"空类大小" <<sizeof(C1) <<endl;
        cout<<"C1类大小" <<sizeof(C2) <<endl;
        cout<<"C2类大小" <<sizeof(C3) <<endl;
        cout<<"C3类大小" <<sizeof(C3) <<endl;
        cout<<"C4类大小" <<sizeof(C3) <<endl;
        // 综上所述, class和struct异曲同工
    }
    
    int main()
    {   
        struct_test();
        class_test();
        return 0;
    }
    

    到此, 这段代码也算是解释和证明了了上面所有的问题

    4. 内存存储

    4.1 为何使用数据/代码分存储

    1. 一个类去定义对象, 系统会为每个对象分配存储空间

    2. 一个类包含数据, 函数

    3. 理论上讲: 一个类定义10个对象, 需要分配10个数据和代码存储单元, 如图1
      图1

    4. 能否只用一段空间来存放共同的函数代码段, 在调用个对象的函数时, 直接去调用公共函数代码, 如图2
      图2

    5. 显然后者能够大大解决存储空间; 因此C++编译系统就是这样做的
      每个对象所占用的存储空间只是该对象的数据部分(虚函数指针和抽象类指针也属于数据部分) 所占用的存储空间都不包含函数代码所占的存储空间(这句话在上面的类C4中既已证明)

    4.2 分类存储实际操作

    C++程序的内存格局有四个区: 全局数据区, 代码区, 栈区, 堆区
    在这里插入图片描述

    加粗样式
    在这里插入图片描述
    在这里插入图片描述

    总的来讲

    分区分配存放的东西管理
    全局数据区static全局变量, 常量, 静态数据操作系统管理
    代码区 function类的成员函数和非成员函数操作系统管理
    堆区 heap程序员指定,malloc/new 动态分配的对象操作系统管理
    栈区 stack局部变量、函数参数、返回数据、返回地址

    静态区,代码区,堆区,栈区 存储地址依次下降

    //main.cpp  
    int a = 0; //全局初始化区   
    char *p1; //全局未初始化区   
    main()   
    {   
    	int b;// 栈   
    	char s[] = "abc"; //栈   
    	char *p2; //栈   
    	char *p3 = "123456"; 123456/0";//在常量区,p3在栈上。   
    	static int c =0//全局(静态)初始化区   
    	p1 = (char *)malloc(10);   
    	p2 = (char *)malloc(20);   //分配得来得10和20字节的区域就在堆区。   
    	strcpy(p1, "123456"); //123456/0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。   
    }
    

    注意:

    1. 类成员函数和非成员函数代码存放在代码区;
    2. 静态成员函数和非静态成员函数都是在类的定义时放在内存的代码区
    3. 类的非静态类成员函数其实都内含了一个指向类对象的指针型参数(即this指针),因而只有类对象才能调用(此时this指针有实值)
    4. 成员函数的代码段都不占用对象的存储空间
    class C5 
    {  
    public:  
        void printA()      {  
            cout<<"printA"<<endl;  
        }  
        virtual void printB() { // 基类里定义的虚函数
            cout<<"printB"<<endl;  
        }  
    }; 
    int main(){   
    	C5 *d=NULL;
    	d->printA(); // printA
    	d->printB(); // Segmentation fault (core dumped)
    	// 这是因为printB包含指向类对象的指针, 而d不是个对象, 因此程序出错
    

    5. C++中哪些函数不能定义为虚函数*

    1. 不能被继承的函数
    2. 不能被重写的函数

    1. 普通函数:
      不属于成员函数,是不能被继承的。普通函数只能被重载,不能被重写,因此声明为虚函数没有意义。因为编译器会在编译时绑定函数。
    2. 友元函数: 不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
    3. 构造函数:
      首先说下什么是构造函数,构造函数是用来初始化对象的。假如子类可以继承基类构造函数,那么子类对象的构造将使用基类的构造函数,而基类构造函数并不知道子类的有什么成员,显然是不符合语义的。从另外一个角度来讲,多态是通过基类指针指向子类对象来实现多态的,在对象构造之前并没有对象产生,因此无法使用多态特性,这是矛盾的。因此构造函数不允许继承。
    4. 内联成员函数
      我们需要知道内联函数就是为了在代码中直接展开,减少函数调用花费的代价。也就是说内联函数是在编译时展开的。而虚函数是为了实现多态,是在运行时绑定的。因此显然内联函数和多态的特性相违背。
    5. 静态成员函数
      首先静态成员函数理论是可继承的。但是静态成员函数是编译时确定的,无法动态绑定,不支持多态,因此不能被重写,也就不能被声明为虚函数。
    // 友元函数示例
    #include <iostream>
    #include <string>
    using namespace std;
    
    class Box{
    	double width;  // C++默认为private
    public:
    	// 成员函数声明 
    	friend void printWidth(Box box);  // 定义友元函数, 可以访问这里面的所有成员, 但不属于该类的成员
    	void setWidth(double wid);
    };
    
    // 成员函数定义
    void Box::setWidth(double wid){
    	width = wid;
    }
    
    // 请注意:printWidth() 不是任何类的成员函数
    void printWidth(Box box){
    	/* 因为 printWidth() 是 Box 的友元,它可以直接访问该类的任何成员 */
    	cout << "Width of box : " << box.width << endl;
    }
    
    // 程序的主函数
    int main(){
    	Box box;
    	// 使用成员函数设置宽度
    	box.setWidth(10.0);
    	// 使用友元函数输出宽度
    	printWidth(box); // Width of box : 10
    	return 0;
    }
    

    6. 虚函数与纯虚函数

    6.1 虚函数和纯虚函数

    虚函数: C++中用于实现多态的机制, 核心理念是通过基类访问派生类定义的函数, 是C++中多态的一个重要体现; 利用基类指针访问派生类中的虚函数, 这种情况采用的是动态绑定技术;

    纯虚函数: 基类声明的虚函数, 基类无定义, 要求任何派生类都需要定义自己的实现方法, 在基类中实现纯虚函数的方法是在函数原型后面加 =0 纯虚函数不能实例化对象; 带有纯虚函数的类也叫做抽象类

    下面几个重要的概念关于虚函数和纯虚函数

    1. 定义一个函数为虚函数,不代表函数为不被实现的函数。
    2. 定义他为虚函数是为了允许用基类的指针来调用子类的这个函数。
    3. 定义一个函数为纯虚函数,才代表函数没有被实现。
    4. 定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。

    6.2 虚函数的使用

    #include <iostream>
    using namespace std;
    
    class A{
    public:
        virtual void foo(){ // 定义虚函数并实现了
            cout<<"A::foo() is called"<<endl;
        }
    };
    
    class B:public A{
    public:
        void foo() { // 重现了虚函数
            cout<<"B::foo() is called"<<endl;
        }
    };
    
    int main(void)
    {
        A *a = new B();
        // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的
        // 所以返回的结果是 B::foo() is called
        // 同时说明, 一个类函数的调用并不是编译时刻被确定的, 而是运行时刻
        a->foo();   
        return 0;
    }
    

    6.3 纯虚函数的使用

    定义: 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加 =0: 如 virtual void funtion1()=0

    引入原因:

    1. 为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
    2. 在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

    为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数,则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
    声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。

    抽象类的作用: 是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。

    7. 多态底层实现

    7.1 虚函数表

    class B {
        virtual int f1 (void);  // 0
        virtual void f2 (int);  // 1
        virtual int f3 (int);   // 2
    };
    
    // 虚函数表
    vptr -> [B::f1, B::f2, B::f3]
              0      1      2
    

    首先对于包含虚函数的类, 编译器会为每个包含虚函数的类生成一张虚函数表,即存放每个虚函数地址的函数指针的数组,简称虚表(vtbl),每个虚函数对应一个虚函数表中的下标。

    除了为包含虚函数的类生成虚函数表以外,编译器还会为该类增加一个隐式成员变量,通常在该类实例化对象的起始位置,用于存放虚函数表的首地址,该变量被称为虚函数表指针,简称虚指针(vptr)。例如:

    B* pb = new B;
    pb->f3 (12);
    // 被编译为
    pb->vptr[2] (pb, 12); // B::f3       参数pb是this指针
    
    // 注意:虚表是一个类一张,而不是一个对象一张,同一个类的多个对象,通过各自的虚指针,共享同一张虚表。
    vptr-> | vptr1  |   vptr2 |   vptr3 |
    

    7.2 多态的工作原理

    // 继承自B的子类
    class D : public B {
        int f1 (void); 
        int f3 (int);  
        virtual void f4 (void);
    };
    
    // 虚函数表
    // 子类覆盖了基类的f1和f3,继承了基类的f2,增加了自己的f4,编译器同样会为子类生成一张专属于它的虚表。
    vptr(子类)-> D::f1, B::f2, D::f3, D::f4
                 0       1     2      3
    
    // 指向子类虚表的虚指针就存放在子类对象的基类子对象中。例如:
    B* pb2 = new D;  // 父类指向子类, 调用子类的方法
    pb2->f3 (12);
    // 被编译为
    pb2->vptr(子类)[2] (pb2, 12); // D::f3
    

    派生类定义对象时, 程序运行会自动调用构造函数, 在构造函数中创建虚函数表并对虚表初始化; 在构造子类对象时, 先调用父类构造函数, 此时, 编译器只"看到了"父类, 并为父类对象初始化虚表指针, 令他指向父类虚表, 当调用子类的构造函数时, 为子类对象初始化虚表指针, 令他指向子类虚表;
    请添加图片描述
    动态绑定- 多态的灵魂
    当编译器“看到”通过指针或者引用调用基类中的虚函数时,并不急于生成有关函数调用的指令,相反它会用一段代码替代该调用语句,这段代码在运行时被执行,
    完成如下操作:

    1. 根据调用指针或引用的目标对象找到其内部的虚表指针;
    2. 根据虚表指针找到其所指向的虚函数表;
    3. 根据虚函数名和函数指针在虚函数表中的索引,找到所调用虚函数的入口地址;
    4. 在完成函数调用的准备工作以后,直接跳转到虚函数入口地址处顺序执行函数体指令序列,直到从函数中返回。

    动态绑定对性能的影响

    1. 虚函数表和虚指针的存在势必要增加内存空间的开销。
    2. 和普通函数调用相比,虚函数调用要多出一个步骤,增加运行时间的开销。
    3. 动态绑定会妨碍编译器通过内联优化代码,虚函数不能内联。
    更多相关内容
  • 我们在写程序时,既有程序的逻辑代码,也有在程序中定义的变量等数据,那么当我们的程序进行时,我们的代码数据究竟是存放在哪里的呢?下面就来总结一下。 一、程序运行时的内存空间情况 其实在程序...
    我们在写程序时,既有程序的逻辑代码,也有在程序中定义的变量等数据,那么当我们的程序进行时,我们的代码和数据究竟是存放在哪里的呢?下面就来总结一下。

    一、程序运行时的内存空间情况

    其实在程序运行时,由于内存的管理方式是以页为单位的,而且程序使用的地址都是虚拟地址,当程序要使用内存时,操作系统再把虚拟地址映射到真实的物理内存的地址上。所以在程序中,以虚拟地址来看,数据或代码是一块块地存在于内存中的,通常我们称其为一个段。而且代码和数据是分开存放的,即不储存于同于一个段中,而且各种数据也是分开存放在不同的段中的。

    下面以一个简单的程序来看一下在Linux下的程序运行空间情况,代码文件名为space.c
    1. #include <unistd.h>  
    2. #include <stdio.h>  
    3.   
    4. int main()  
    5. {  
    6.     printf("%d\n", getpid());  
    7.     while(1);  
    8.     return 0;  
    9. }  

    这个程序非常简单,输出当前进程的进程号,然后进入一个死循环,这个死循环的目的只是让程序不退出。而在Linux下有一个目录/proc/$(pid),这个目录保存了进程号为pid的进程运行时的所有信息,其中有一个文件maps,它记录了程序执行过程中的内存空间的情况。编译运行上面的代码,其运行结果如图1所示:


    从上面的图中,我们可以看到这样一个简单的程序,在执行时,需要哪些库和哪些空间。上面的图的各列的意思,不一一详述,只对重要的进行说明。
    第一列的是一个段的起始地址和结束地址,第二列这个段的权限,第三列段的段内相对偏移量,第六列是这个段所存放的内容所对应的文件。从上图可以看到我们的程序进行首先要加载系统的两个共享库,然后再加载我们写的程序的代码。

    对于第二列的权限,r:表示可读,w:表示可写,x:表示可执行,p:表示受保护(即只对本进程有效,不共享),与之相对的是s,意是就是共享。

    从上图我们可以非常形象地看到一个程序进行时的内存分布情况。下面我们将会结合上图,进行更加深入的对内存中的数据段的解说。

    二、程序运行时内存的各种数据段

    1.bss段
    该段用来存放没有被初始化或初始化为0的全局变量,因为是全局变量,所以在程序运行的整个生命周期内都存在于内存中。有趣的是这个段中的变量只占用程序运行时的内存空间,而不占用程序文件的储存空间。可以用以下程序来说明这点,文件名为bss.c
    1. #include <stdio.h>  
    2.   
    3. int bss_data[1024 * 1024];  
    4.   
    5. int main()  
    6. {  
    7.     return 0;  
    8. }  
    这个程序非常简单,定义一个4M的全局变量,然后返回。编译成可执行文件bss,并查看可执行文件的文件属性如图2所示:


    从可执行文件的大小4774B可以看出,bss数据段(4M)并不占用程序文件的储存空间,在下面的data段中,我们可以看到data段的数据是占用可执行文件的储存空间的。

    在图1中,有文件名且属性为rw-p的内存区间,就是bss段。

    2.data段
    初始化过的全局变量数据段,该段用来保存初始化了的非0的全局变量,如果全局变量初始化为0,则编译有时会出于优化的考虑,将其放在bss段中。因为也是全局变量,所以在程序运行的整个生命周期内都存在于内存中。与bss段不同的是,data段中的变量既占程序运行时的内存空间,也占程序文件的储存空间。可以用下面的程序来说明,文件名为data.c:
    1. #include <stdio.h>  
    2.   
    3. int data_data[1024 * 1024] = {1};  
    4.   
    5. int main()  
    6. {  
    7.     return 0;  
    8. }  
    这个程序与上面的bss唯一的不同就是全局变量int型数组data_data为每个元素指定了一个初始值1.编译可执行文件data,并查看可执行文件的文件属性如图3所示:


    从可执行文件的大小来看,data段数据(data_data数组的大小,4M)占用程序文件的储存空间。

    在图1中,有文件名且属性为rw-p的内存区间,就是data段,它与bss段在内存中是共用一段内存的,不同的是,bss段数据不占用文件,而data段数据占用文件储存空间。

    3.rodata段
    该段是常量数据段,用于存放常量数据,ro就是Read Only之意。但是注意并不是所有的常量都是放在常量数据段的,其特殊情况如下:
    1)有些立即数与指令编译在一起直接放在代码段(text段,下面会讲到)中。
    2)对于字符串常量,编译器会去掉重复的常量,让程序的每个字符串常量只有一份。
    3)有些系统中rodata段是多个进程共享的,目的是为了提高空间的利用率。

    在图1中,有文件名的属性为r--p的内存区间就是rodata段。可见他是受保护的,只能被读取,从而提高程序的稳定性。

    4.text段
    text段就是代码段,用来存放程序的代码(如函数)和部分整数常量。它与rodata段的主要不同是,text段是可以执行的,而且不被不同的进程共享。

    在图1中,有文件名且属性为r-xp的内存区间就是text段。就如我们所知道的那样,代码段是不能被写的。

    5.stack段
    该段就是栈段,用来保存临时变量和函数参数。程序中的函数调用就是以栈的方式来实现的,通常栈是向下(即向低地址)增长的,当向栈中push一个元素,栈顶指针就会向低地址移动,当从栈中pop一个元素,栈顶指针就会向高地址移动。栈中的数据只在当前函数或下一层函数中有效,当函数返回时,这些数据自动被释放,如果继续对这些数据进行访问,将发生未知的错误。通常我们在程序中定义的不是用malloc系统函数或new出来的变量,都是存放在栈中的。例如,如下函数:
    void func()
    {
        int a = 0;
        int *n_ptr = malloc(sizeof(int));
        char *c_ptr = new char;
    }

    整型变量a,整型指针变量n_ptr和char型指针变量c_ptr,都存放在栈段中,而n_ptr和c_ptr指向的变量,由于是malloc或new出来的,所以存放在堆中。当函数func返回时,a、n_ptr、c_ptr都会被释放,但是n_ptr和c_ptr指向的内存却不会释放。因为它们是存在于堆中的数据。

    在图1中,文件名为stack的内存区间即为栈段。

    6.heap段
    heap(堆)是最自由的一种内存,它完全由程序来负责内存的管理,包括什么时候申请,什么时候释放,而且对它的使用也没有什么大小的限制。在C/C++中,用alloc系统函数和new申请的内存都存在于heap段中。

    以上面的程序为例,它向堆申请了一个int和一个char的内存,因为没有调用free或delete,所以当函数返回时,堆中的int和char变量并没有释放,造成了内存泄漏。

    由于在图1所对应的代码中没有使用alloc系统函数或new来申请内存,所以heap段并没有在图1中显示出来,所以以下面的程序来说明heap段的位置,代码文件为heap.c,代码如下:
    1. #include <unistd.h>  
    2. #include <stdlib.h>  
    3. #include <stdio.h>  
    4.   
    5. int main()  
    6. {  
    7.     int *n_ptr = malloc(sizeof(int));  
    8.     printf("%d\n", getpid());  
    9.     while(1);  
    10.     free(n_ptr);  
    11.     return 0;  
    12. }  
    查看其运行时内存空间分布如下:


    可以看到文件名为heap的内存区间就是heap段。从上图,也可以看出,虽然我们只申请4个字节(sizeof(int))的空间,但是在操作系统中,内存是以页的方式进行管理的,所以在分配heap内存时,还是一次分配就为我们分配了一个页的内存。注:无论是图1,还是上图,都有一些没有文件名的内存区间,其实没用文件名的内存区间表示使用mmap映射的匿名空间。
    展开全文
  • int数据存放时第一位是高地址还是低地址,就是比如我存放1是按00000001还是01000000
  • 内存管理:程序是如何被优雅的装载到内存中的

    千次阅读 多人点赞 2021-11-04 09:26:35
    我们知道我们写的代码最终是要从磁盘被加载到内存中的,然后再被CPU执行,不知道你没有想过,为什么一些大型游戏大到10几G,却可以在只有8G内存的电脑上运行?甚至在玩游戏期间,我们还可以聊微信、听音乐...,...

    内存作为计算机中一项比较重要的资源,它的主要作用就是解决CPU和磁盘之间速度的鸿沟,但是由于内存条是需要插入到主板上的,因此对于一台计算机来说,由于物理限制,它的内存不可能无限大的。我们知道我们写的代码最终是要从磁盘被加载到内存中的,然后再被CPU执行,不知道你有没有想过,为什么一些大型游戏大到10几G,却可以在只有8G内存的电脑上运行?甚至在玩游戏期间,我们还可以聊微信、听音乐...,这么多进程看着同时在运行,它们在内存中是如何被管理的?带着这些疑问我们来看看计算系统内存管理那些事。

    内存的交换技术

    如果我们的内存可以无限大,那么我们担忧的问题就不会存在,但是实际情况是往往我们的机器上会同时运行多个进程,这些进程小到需要几十兆内存,大到可能需要上百兆内存,当许许多多这些进程想要同时加载到内存的时候是不可能的,但是从我们用户的角度来看,似乎这些进程确实都在运行呀,这是怎么回事?

    这就引入要说的交换技术了,从字面的意思来看,我想你应该猜到了,它会把某个内存中的进程交换出去。当我们的进程空闲的时候,其他的进程又需要被运行,然而很不幸,此时没有足够的内存空间了,这时候怎么办呢?似乎刚刚那个空闲的进程有种占着茅坑不拉屎的感觉,于是可以把这个空闲的进程从内存中交换到磁盘上去,这时候就会空出多余的空间来让这个新的进程运行,当这个换出去的空闲进程又需要被运行的时候,那么它就会被再次交换进内存中。通过这种技术,可以让有限的内存空间运行更多的进程,进程之间不停来回交换,看着好像都可以运行。 

     如图所示,一开始进程A被换入内存中,所幸还剩余的内存空间比较多,然后进程B也被换入内存中,但是剩余的空间比较少了,这时候进程C想要被换入到内存中,但是发现空间不够了,这时候会把已经运行一段时间的进程A换到磁盘中去,然后调入进程C。 

    内存碎片

    通过这种交换技术,交替的换入和换出进程可以达到小内存可以运行更多的进程,但是这似乎也产生了一些问题,不知道你发现了没有,在进程C换入进来之后,在进程B和进程C之间有段较小的内存空间,并且进程B之上也有段较小的内存空间,说实话,这些小空间可能永远没法装载对应大小的程序,那么它们就浪费了,在某些情况下,可能会产生更多这种内存碎片。

     如果想要节约内存,那么就得用到内存紧凑的技术了,即把所有的进程都向下移动,这样所有的碎片就会连接在一起变成一段更大的连续内存空间了。 

     但是这个移动的开销基本和当前内存中的活跃进程成正比,据统计,一台16G内存的计算机可以每8ns复制8个字节,它紧凑全部的内存大概需要16s,所以通常不会进行紧凑这个操作,因为它耗费的CPU时间还是比较大的。

    动态增长

    其实上面说的进程装载算是比较理想的了,正常来说,一个进程被创建或者被换入的时候,它占用多大的空间就分配多大的内存,但是如果我们的进程需要的空间是动态增长的,那就麻烦了,比如我们的程序在运行期间的for循环可能会利用到某个临时变量来存放目标数据(例如以下变量a,随着程序的运行是会越来越大的):

    var a []int64
    for i:= 0;i <= 1000000;i++{
      if i%2 == 0{
       a = append(a,i) //a是不断增大的
      }
    }
    

    当需要增长的时候:

    1. 如果进程的邻居是空闲区那还好,可以把该空闲区分配给进程
    2. 如果进程的邻居是另一个进程,那么解决的办法只能把增长的进程移动到一个更大的空闲内存中,但是万一没有更大的内存空间,那么就要触发换出,把一个或者多个进程换出去来提供更多的内存空间,很明显这个开销不小。

    为了解决进程空间动态增长的问题,我们可以提前多给一些空间,比如进程本身需要10M,我们多给2M,这样如果进程发生增长的时候,可以利用这2M空间,当然前提是这2M空间够用,如果不够用还是得触发同样的移动、换出逻辑。

    空闲的内存如何管理

    前面我们说到内存的交换技术,交换技术的目的是腾出空闲内存来,那么我们是如何知道一块内存是被使用了,还是空闲的?因此需要一套机制来区分出空闲内存和已使用内存,一般操作系统对内存管理的方式有两种:位图法链表法

    位图法

    先说位图法,没错,位图法采用比特位的方式来管理我们的内存,每块内存都有位置,我们用一个比特位来表示:

    1. 如果某块内存被使用了,那么比特位为1
    2. 如果某块内存是空闲的,那么比特位为0

    这里的某块内存具体是多大得看操作系统是如何管理的,它可能是一个字节、几个字节甚至几千个字节,但是这些不是重点,重点是我们要知道内存被这样分割了。

     位图法的优点就是清晰明确,某个内存块的状态可以通过位图快速的知道,因为它的时间复杂度是O(1),当然它的缺点也很明显,就是需要占用太多的空间,尤其是管理的内存块越小的时候。更糟糕的是,进程分配的空间不一定是内存块的整数倍,那么最后一个内存块中一定是有浪费的。

    如图,进程A和进程B都占用的最后一个内存块的一部分,那么对于最后一个内存块,它的另一部分一定是浪费的。

    链表法

    相比位图法,链表法对空间的利用更加合理,我相信你应该已经猜到了,链表法简单理解就是把使用的和空闲的内存用链表的方式连接起来,那么对于每个链表的元素节点来说,他应该具备以下特点:

    1. 应该知道每个节点是空闲的还是被使用的
    2. 每个节点都应该知道当前节点的内存的开始地址和结束地址

    针对这些特点,最终内存对应的链表节点大概是这样的:

     p代表这个节点对应的内存空间是被使用的,H代表这个节点对应的内存空间是空闲的,start代表这块内存空间的开始地址,length代表的是这块内存的长度,最后还有指向邻居节点的pre和next指针

    因此对于一个进程来说,它与邻居的组合有四种:

    1. 它的前后节点都不是空闲的
    2. 它的前一个节点是空闲的,它的后一个节点也不是空闲的
    3. 它的前一个节点不是空闲的,它的后一个节点是空闲的
    4. 它的前后节点都是空闲的

    当一个内存节点被换出或者说进程结束后,那么它对应的内存就是空闲的,此时如果它的邻居也是空闲的,就会发生合并,即两块空闲的内存块合并成一个大的空闲内存块。

    ok,通过链表的方式把我们的内存给管理起来了,接下来就是当创建一个进程或者从磁盘换入一个进程的时候,如何从链表中找到一块合适的内存空间?

    首次适应算法

    其实想要找到空闲内存空间最简单的办法就是顺着链表找到第一个满足需要内存大小的节点,如果找到的第一个空闲内存块和我们需要的内存空间是一样大小的,那么就直接利用,但是这太理想了,现实情况大部分可能是找到的第一个目标内存块要比我们的需要的内存空间要大一些,这时候呢,会把这个空闲内存空间分成两块,一块正好使用,一块继续充当空闲内存块。

     一个需要3M内存的进程,会把4M的空间拆分成3M和1M。

    下次适配算法

    和首次适应算法很相似,在找到目标内存块后,会记录下位置,这样下次需要再次查找内存块的时候,会从这个位置开始找,而不用从链表的头节点开始寻找,这个算法存在的问题就是,如果标记的位置之前有合适的内存块,那么就会被跳过。

     一个需要2M内存的进程,在5这个位置找到了合适的空间,下次如果需要这1M的内存会从5这个位置开始,然后会在7这个位置找到合适的空间,但是会跳过1这个位置。

    最佳适配算法

    相比首次适应算法,最佳适配算法的区别就是:不是找到第一个合适的内存块就停止,而是会继续向后找,并且每次都可能要检索到链表的尾部,因为它要找到最合适那个内存块,什么是最合适的内存块呢?如果刚好大小一致,则一定是最合适的,如果没有大小一致的,那么能容得下进程的那个最小的内存块就是最合适的,可以看出最佳适配算法的平均检索时间相对是要慢的,同时可能会造成很多小的碎片。

     假设现在进程需要2M的内存,那么最佳适配算法会在检索到3号位置(3M)后,继续向后检索,最终会选择5号位置的空闲内存块。

    最差适配算法

    我们知道最佳适配算法中最佳的意思是找到一个最贴近真实大小的空闲内存块,但是这会造成很多细小的碎片,这些细小的碎片一般情况下,如果没有进行内存紧凑,那么大概率是浪费的,为了避免这种情况,就出现了这个最差适配算法,这个算法它和最佳适配算法是反着来的,它每次尝试分配最大的可用空闲区,因为这样的话,理论上剩余的空闲区也是比较大的,内存碎片不会那么小,还能得到重复利用。

     一个需要1.5M的进程,在最差适配算法情况下,不会选择3号(2M)内存空闲块,而是会选择更大的5号(3M)内存空闲块。

    快速适配算法

    上面的几种算法都有一个共同的特点:空闲内存块和已使用内存块是共用的一个链表,这会有什么问题呢?正常来说,我要查找一个空闲块,我并不需要检索已经被使用的内存块,所以如果能把已使用的和未使用的分开,然后用两个链表分别维护,那么上面的算法无论哪种,速度都将得到提升,并且节点也不需要P和M来标记状态了。但是分开也有缺点,如果进程终止或者被换出,那么对应的内存块需要从已使用的链表中删掉然后加入到未使用的链表中,这个开销是要稍微大点的。当然对于未使用的链表如果是排序的,那么首次适应算法和最佳适应算法是一样快的。

    快速适配算法就是利用了这个特点,这个算法会为那些常用大小的空闲块维护单独的链表,比如有4K的空闲链表、8K的空闲链表...,如果要分配一个7K的内存空间,那么既可以选择两个4K的,也可以选择一个8K的。

     它的优点很明显,在查找一个指定大小的空闲区会很快速,但是一个进程终止或被换出时,会寻找它的相邻块查看是否可以合并,这个过程相对较慢,如果不合并的话,那么同样也会产生很多的小空闲区,它们可能无法被利用,造成浪费。

    虚拟内存:小内存运行大程序

    可能你看到小内存运行大程序比较诧异,因为上面不是说到了吗?只要把空闲的进程换出去,把需要运行的进程再换进来不就行了吗?内存交换技术似乎解决了,这里需要注意的是,首先内存交换技术在空间不够的情况下需要把进程换出到磁盘上,然后从磁盘上换入新进程,看到磁盘你可能明白了,很慢。其次,你发现没,换入换出的是整个进程,我们知道进程也是由一块一块代码组成的,也就是许许多多的机器指令,对于内存交换技术来说,一个进程下的所有指令要么全部进内存,要么全部不进内存。看到这里你可能觉得这不是正常吗?好的,别急,我们接着往下看。

    后来出现了更牛逼的技术:虚拟内存。它的基本思想就是,每个程序拥有自己的地址空间,尤其注意后面的自己的地址空间,然后这个空间可以被分割成多个块,每一个块我们称之为(page)或者叫页面,对于这些页来说,它们的地址是连续的,同时它们的地址是虚拟的,并不是真正的物理内存地址,那怎么办?程序运行需要读到真正的物理内存地址,别跟我玩虚的,这就需要一套映射机制,然后MMU出现了,MMU全称叫做:Memory Managment Unit,即内存管理单元,正常来说,CPU读某个内存地址数据的时候,会把对应的地址发到内存总线上,但是在虚拟内存的情况下,直接发到内存总线上肯定是找不到对应的内存地址的,这时候CPU会把虚拟地址告诉MMU,让MMU帮我们找到对应的内存地址,没错,MMU就是一个地址转换的中转站。

    程序地址分页的好处是:

    1. 对于程序来说,不需要像内存交换那样把所有的指令都加载到内存中才能运行,可以单独运行某一页的指令
    2. 当进程的某一页不在内存中的时候,CPU会在这个页加载到内存的过程中去执行其他的进程。

    当然虚拟内存会分页,那么对应的物理内存其实也会分页,只不过物理内存对应的单元我们叫页框。页面和页框通常是一样大的。我们来看个例子,假设此时页面和页框的大小都是4K,那么对于64K的虚拟地址空间可以得到64/4=16个虚拟页面,而对于32K的物理地址空间可以得到32/4=8个页框,很明显此时的页框是不够的,总有些虚拟页面找不到对应的页框。

    我们先来看看虚拟地址为20500对应物理地址如何被找到的:

    1. 首先虚拟地址20500对应5号页面(20480-24575)
    2. 5号页面的起始地址20480向后查找20个字节,就是虚拟地址的位置
    3. 5号页面对应3号物理页框
    4. 3号物理页框的起始地址是12288,12288+20=12308,即12308就是我们实际的目标物理地址。

    但是对于虚拟地址而言,图中还有红色的区域,上面我们也说到了,总有些虚拟地址没有对应的页框,也就是这部分虚拟地址是没有对应的物理地址,当程序访问到一个未被映射的虚拟地址(红色区域)的时候,那么就会发生缺页中断,然后操作系统会找到一个最近很少使用的页框把它的内容换到磁盘上去,再把刚刚发生缺页中断的页面从磁盘读到刚刚回收的页框中去,最后修改虚拟地址到页框的映射,然后重启引起中断的指令。

    最后可以发现分页机制使我们的程序更加细腻了,运行的粒度是页而不是整个进程,大大提高了效率。

    页表

    上面说到虚拟内存到物理内存有个映射,这个映射我们知道是MMU做的,但是它是如何实现的?最简单的办法就是需要有一张类似hash表的结构来查看,比如页面1对应的页框是10,那么就记录成hash[1]=10,但是这仅仅是定位到了页框,具体的位置还没定位到,也就是类似偏移量的数据没有。不猜了,我们直接来看看MMU是如何做到的,以一个16位的虚拟地址,并且页面和页框都是4K的情况来说,MMU会把前4位当作是索引,也就是定位到页框的序号,后12位作为偏移量,这里为什么是12位,很巧妙,因为2^12=4K,正好给每个页框里的数据上了个标号。因此我们只需要根据前4位找到对应的页框即可,然后偏移量就是后12位。找页框就是去我们即将要说的页表里去找,页表除了有页面对应的页框后,还有个标志位来表示对应的页面是否有映射到对应的页框,缺页中断就是根据这个标志位来的。

    可以看出页表非常关键,不仅仅要知道页框、以及是否缺页,其实页表还有保护位修改位访问位高速缓存禁止位

    • 保护位:指的是一个页允许什么类型的访问,常见的是用三个比特位分别表示执行
    • 修改位:有时候也称为脏位,由硬件自动设置,当一个页被修改后,也就是和磁盘的数据不一致了,那么这个位就会被标记为1,下次在页框置换的时候,需要把脏页刷回磁盘,如果这个页的标记为0,说明没有被修改,那么不需要刷回磁盘,直接把数据丢弃就行了。
    • 访问位:当一个页面不论是发生读还是发生写,该页面的访问位都会设置成1,表示正在被访问,它的作用就是在发生缺页中断时,根据这个标志位优先在那些没有被访问的页面中选择淘汰其中的一个或者多个页框。
    • 高速缓存禁止位:对于那些映射到设备寄存器而不是常规内存的页面而言,这个特性很重要,加入操作系统正在紧张的循环等待某个IO设备对它刚发出的指令做出响应,保证这个设备读的不是被高速缓存的副本非常重要。

    TLB快表加速访问

    通过页表我们可以很好的实现虚拟地址到物理地址的转换,然而现代计算机至少是32位的虚拟地址,以4K为一页来说,那么对于32位的虚拟地址,它的页表项就有2^20=1048576个,无论是页表本身的大小还是检索速度,这个数字其实算是有点大了。如果是64位虚拟的地址,按照这种方式的话,页表项将大到超乎想象,更何况最重要的是每个进程都会有一个这样的页表

    我们知道如果每次都要在庞大的页表里面检索页框的话,效率一定不是很高。而且计算机的设计者们观察到这样一种现象:大多数程序总是对少量的页进行多次访问,如果能为这些经常被访问的页单独建立一个查询页表,那么速度就会大大提升,这就是快表,快表只会包含少量的页表项,通常不会超过256个,当我们要查找一个虚拟地址的时候。首先会在快表中查找,如果能找到那么就可以直接返回对应的页框,如果找不到才会去页表中查找,然后从快表中淘汰一个表项,用新找到的页替代它。

    总体来说,TLB类似一个体积更小的页表缓存,它存放的都是最近被访问的页,而不是所有的页。

    多级页表

    TLB虽然一定程度上可以解决转换速度的问题,但是没有解决页表本身占用太大空间的问题。其实我们可以想想,大部分程序会使用到所有的页面吗?其实不会。一个进程在内存中的地址空间一般分为程序段、数据段和堆栈段,堆栈段在内存的结构上是从高地址向低地址增长的,其他两个是从低地址向高地址增长的。

    可以发现中间部分是空的,也就是这部分地址是用不到的,那我们完全不需要把中间没有被使用的内存地址也引入页表呀,这就是多级页表的思想。以32位地址为例,后12位是偏移量,前20位可以拆成两个10位,我们暂且叫做顶级页表和二级页表,每10位可以表示2^10=1024个表项,因此它的结构大致如下:

     对于顶级页表来说,中间灰色的部分就是没有被使用的内存空间。顶级页表就像我们身份证号前面几个数字,可以定位到我们是哪个城市或者县的,二级页表就像身份证中间的数字,可以定位到我们是哪个街道或者哪个村的,最后的偏移量就像我们的门牌号和姓名,通过这样的分段可以大大减少空间,我们来看个简单的例子:

    如果我们不拆出顶级页表和二级页表,那么所需要的页表项就是2^20个,如果我们拆分,那么就是1个顶级页表+2^10个二级页表,两者的存储差距明显可以看出拆分后更加节省空间,这就是多级页表的好处。

    当然我们的二级也可以拆成三级、四级甚至更多级,级数越多灵活性越大,但是级数越多,检索越慢,这一点是需要注意的。


    最后

    为了便于大家理解,本文画了20张图,肝了将近7000多字,创作不易,各位的三连就是对作者最大的支持,也是作者最大的创作动力。

    微信搜一搜【假装懂编程】,加入我们,与作者共同学习,共同进步。

     

    往期精彩:

    展开全文
  • 数据的存储前言 ...就是将10这个数据存放进变量a中,而变量a,就是我们在内存中申请开辟的一块空间。 在内存中如何开辟空间给变量的问题博主已经在函数栈帧里用反汇编的方式将其原理剖析了,具体可看 ...

    🌹前言

    我们在敲代码的时候总是会定义各种变量,对各种数据进行存储,比如int a = 10;就是将10这个数据存放进变量a中,而变量a,就是我们在内存中申请开辟的一块空间。
    在内存中如何开辟空间给变量的问题博主已经在函数栈帧里用反汇编的方式将其原理剖析了,具体可看图解函数栈帧 - 函数栈帧的创建及销毁
    本文将进一步剖析在已经开辟好存储单元的情况下,各种数据是如何存储的。


    在了解数据如何存储之前,应该先了解我们常见的数据类型。

    ✨数据类型汇总

    在C99标准中,我们可将数据类型划分为以下几大类。

    1. 整型家族
    2. 浮点型家族(实型家族)
    3. 自定义类型(构造类型)
    4. 指针类型
    5. 空类型

    下面一一介绍这五种类型的基本情况。

    🎁整型家族

    char
    		unsigned char
    		signed char
    short
    		unsigned short [int]
    		signed short [int]
    int
    		unsigned int
    		signed int
    long
    		unsigned long [int]
    		signed long [int]
    

    注:在C99之后的标准规定,将char类型数据划分为整型家族,因为字符在内存中会将其转化为ASCII码值进行存储。

    如上所示,所有的整型家族都被分为有符号整型和无符号整型,并且signed都是可以被省略的,换言之,signed int完全等价于int,其他以此类推,但其中有一个例外: char类型和signed char并不等价,只写一个char ch = 0;我们将无法分辨这个ch变量到底是有符号字符型还是无符号字符型,他完全取决于编译器,但经博主测试,大部分编译器下char类型都被编译器翻译为有符号的char类型。

    在C99中还引入了long long - 长长整型,用法和long类型一致,但C语言语法规定,sizeof(long)<= sizeof(long long),而long类型所占内存大小为4/8字节,所以long long类型所占内存空间大小一定为8个字节。

    🙈浮点型家族

    float
    double
    

    浮点型家族只有float和double这两种类型,float类型所占空间大小为4byte,double类型所占空间大小为8byte。

    他们之间的区别除了所占空间大小不同之外还有精度的区别,float称为单精度浮点型,有效精度为小数点后6位,而double类型称为双精度浮点型,精确到小数点后15位,但其有效数字只有11位左右。

    🦝自定义类型

    > 数组类型
    > 结构体类型 struct
    > 枚举类型 enum
    > 联合类型 union
    

    这里可能会有很多人无法李姐为什么数组类型也被划分为自定义类型,这里稍微做一些解释。

    我们知道数组类型的变量定义形式:数据类型+数组名+[数组大小];

    如:

    int arr[10] = { 0 };
    

    这里可能会让很多人产生误区,认为arr数组的类型是int类型,也就把这条语句理解为是int类型的、数组名为arr的数组大小为10的数组,其实不然,这个数组的数组名确实是arr,但其数据类型是int [10],这里可能让大部分人无法接受,

    举个简单的例子即可解释:

    我们知道,sizeof操作符是用来计算所占内存空间大小的,其操作数既可以是变量名,也可以是变量类型。

    #define _CRT_SECURE_NO_WARNINGS 1
    
    #include <stdio.h>
    
    int main()
    {
    	int a = 10;
    
    	printf("%d\n", sizeof(a));
    	printf("%d\n", sizeof(int));
    
    	return 0;
    }
    

    这两种写法都正确,打印结果为:

    打印结果

    而对于数组,操作数也同样可以是数组名或者数组类型:

    #define _CRT_SECURE_NO_WARNINGS 1
    
    #include <stdio.h>
    
    int main()
    {
    	/*int a = 10;
    
    	printf("%d\n", sizeof(a));
    	printf("%d\n", sizeof(int));*/
    
    	int arr[10] = { 0 };
    
    	printf("%d\n", sizeof(arr));
    	printf("%d\n", sizeof(int[10]));
    
    	return 0;
    }
    

    其打印结果为:

    打印结果
    这么一来,就验证了int [10]是数组类型。

    知道了这点,解释为什么数组类型是自定义类型就更清晰了,用上面解释的结论就可以知道,int arr[10]和int arr[9]的数组类型不同,并不都是int类型的,数组大小是我们程序员人为规定的,所以可以把他划分为自定义类型。

    其他的自定义类型比较明显,这里就不一一解释。

    🐱‍🏍指针类型。

    指针类型很特殊。

    我们常说的指针有两个含义:

    1. 某一个变量的地址,也就是其在内存中的编号,我们可称其为指针。
    2. 用于存放地址(编号)的变量,我们称其为指针变量,常简称指针。

    指针类型的定义方式为:

    数据类型+*(用于标识指针类型)+指针变量名

    常见的指针类型有:

    int* pi;
    char* pc;
    float* pf;
    void* pv;
    

    这里着重介绍一点,指针变量赋值大部分都是取出某变量地址存放进指针变量,如int pc = &c;

    但有一个例外:

    int main()
    {
    	char* pc = "hello world";
    
    	printf("%c\n", *pc);
    
    	return 0;
    }
    

    这里之间将一个字符串常量赋值给指针变量pc,我们知道,字符串常量时放在常量区的,他的值不可修改,并且这里的字符串加上隐藏的’\0’总共是12个字节,而我们的指针变量根据平台的不同只能是4/8个字节,怎么都不可能放的下这个字符串常量,所以这么理解是错误的。

    我们将其打印看看结果:

    打印结果
    打印结果为单字母h,这么一来其实就解释的通了,将整个常量字符串赋值给指针变量,其实并不会把整个字符串放进去,而是把整个字符串的首地址赋给指针变量,比较指针存放的就是地址,这和将字符数组名赋值给指针变量类似,存放的都是首元素地址。

    🐥空类型

    void 用于表示空类型(无类型)
    通常应用于函数的返回类型、函数的参数、指针类型。

    下面举几空类型的例子帮助理解:

    • 返回类型:
    void test(int x)
    {
    	printf("%d\n", x);
    }
    
    int main()
    {
    	int a = 10;
    	test(a);
    
    	return 0;
    }
    

    打印结果

    这里test函数的返回类型就是void。

    • 函数的参数:
    int test(void)
    {
    	return 1;
    }
    
    int main()
    {
    	int ret = test();
    
    	printf("%d\n", ret);
    
    	return 0;
    }
    

    这个代码就是将函数的参数置为空,表示不允许主调函数传参,如果非要传参,编译器将给出警告。

    int test(void)
    {
    	return 1;
    }
    
    int main()
    {
    	int a = 10;
    	int ret = test(a);
    
    	printf("%d\n", ret);
    
    	return 0;
    }
    

    警告

    • 指针类型:
    void* pc;
    

    表示定义一个指针pc,但他什么都不指向,作为一个空指针存在。


    🕸大小端字节序说明

    我们知道不管是什么样的数据,最终都会被编译器编译为二进制机器码进行存储,并且我们的内存是以字节为最小存储单元划分而进行存储的,那么就存在了一个问题,数据以字节为单位进行存储的时候,是以怎样的顺序进行存储的呢?这就引出了大小端字节序的概念。

    🧠出现大小端字节序的原因

    为什么会有大小端字节序模式之分呢?这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着一个字节,一个字节为8bit位。但是在C语言中除了8bit的char类型之外,还有16bit的short类型,32bit的long类型(要看具体的编译器,64位平台long类型为64位),另外,对于位数大于8位的处理器,例如16位或者32位的处理器,由于寄存器的宽度大于一个字节,那么必然存在着一个如何将多个字节安排的问题。因此就导致了大端存储模式和小端存储模式。

    例如:一个16bit位的short类型变量x ,在内存中的地址为0x0010,变量x 的值为0x1122 ,那么0x11为高字节,0x22为低字节。对于大端模式,就将 0x11放在低地址中,即0x0010中,0x22 放在高地址中,即0x0011中。小端模式,刚好相反。我们常用的X86(32位平台)结构是小端模式,而KEILC51则为大端模式。很多的ARM,DSP都为小端模式。有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。

    🐉字节序的概念

    字节序,即字节顺序,又称端序或尾序,在计算机科学领域中,指「存储器」中或者「数字通信链路」中,组成多字节的字节排列顺序 。在几乎所有的机器上,多字节对象都被存储为连续的字节序列 。例如在C语言中,一个 int类型的变量x地址为0x100,那么其对应的地址表达式&x的值为0x100 且 x 的4个字节将被存储在存储器的0x100, 0x101, 0x102, 0x103位置。字节的排列方式有2个通用规则。

    1. 顺序排列 - 大端字节序
    2. 逆序排列 - 小端字节序

    上面的文字描述也许过于抽象,接下来用较为容易理解的方式分别简单的介绍大端字节序和小端字节序的概念。

    ✋大小端字节序

    所谓大小端字节序,就是将多字节数据中的高低字节位按不同顺序存放在内存中的高低地址处,相当于顺(逆)序存放。接下来博主将把上述抽象概念划分逐一介绍:

    1. 首先理解什么叫做多字节数据。

    我们知道一个数据根据大小不同被划分为不同的数据类型,各数据类型所占字节数不同,我们也就据此根据数据字节大小来将其存放于不同的数据类型中。

    比如字符类型 - 其扩展之后的ASCII码值为0~255,我们知道一个字节是8位,按照无符号字符型的理解也就是从00000000 ~ 11111111,刚好是0 ~ 255,所以字符类型被称为单字符类型数据。

    而十六进制数,如:0x11223344则为多字节数据,其中有4个字节,分别是0x11、0x22、0x33、0x44,像这样的数据则被称为多字节数据。


    1. 理解什么叫做多字节数据的高字节位。

    在一个二进制序列中,

    如:01010110101001011010100101101001

    我们把前方高亮部分的0101称为高字节位,把后端加删除线的1001 部分称为低字节位,以此区分。

    其实很好理解,因为最后一个1的的权重为20,也就是2的0次方,而第一个0的权重为231,也就是2的31次方,以此来区分高低字节位也是很不错的选择。


    接下来介绍大小端字节序的存储方式:

    大端字节序

    所谓大端字节序,就是将处于高字节位的数据存放在内存的低地址处,将处于低字节位的数据存放在内存的高地址处

    如今给一数据:0x11223344

    在内存中的存放形式为:

    大端存储模式
    以这样的形式存放的模式,就称为大端存储模式,这样的存放顺序,也就被称为大端字节序。

    小端字节序

    所谓小端字节序,就是将处于高字节位的数据存放在内存的高地址处,将处于低字节位的数据存放在内存的低地址处

    今给一数据:0x11223344

    在内存中的存放形式为:

    小端存储模式
    以这样的形式存放的模式,就称为小端存储模式,这样的存放顺序,也就被称为小端字节序。

    在博主使用的VS2019编译器上,采用的就是小端字节序:

    例:

    int main()
    {
    	int a = 0x0000ff40;
    
    	return 0;
    }
    

    调试 - 内存窗口(&a):

    内存中存放情况
    0x001DFEFC就是该代码中a变量的地址,存放情况为40 ff 00 00。

    也就是小端存储模式。

    👩‍🍳百度系统工程师笔试题(通过编程判断该编译器为大端存储还是小端存储)

    百度2015年系统工程师笔试题:

    请简述大端字节序和小端字节序的概念,设计一个小程序来判断当前机器的字节序。(10分)

    该题前半部分在上文其实已经解决了,这里博主将分析问题,并实现代码。

    🧣问题分析

    要判断编译系统到底是大端存储还是小端存储,其实并不复杂。

    如0x11223344

    如果是在大端存储模式下:
    存储方式为:11 22 33 44

    如果是在小端存储模式下:
    存储方式为:44 33 22 11

    所以其实只需要知道第一个字节的内容到底是11还是44就可以判断了。
    但这样的数据太过于复杂,不如换简单一点的数字,比如1。

    1的高字节位就是00,低字节位就是01,比较好判断。

    🎒代码演示

    int check_sys(int x)
    {
    	return *(char*)&x;
    }
    
    int main()
    {
    	int a = 1;
    
    	//约定:
    	//如果是大端,返回0
    	//如果是小端,返回1
    	int ret = check_sys(a);
    	if (ret)
    	{
    		printf("是小端存储模式\n");
    	}
    	else
    	{
    		printf("是大端存储模式\n");
    	}
    
    	return 0;
    }
    

    运行结果:
    运行结果
    之前也分析了,我的编译器VS2019是小端存储模式,所以代码的结果正确,下面分析代码。

    🎮代码分析

    1. 想要在4个字节中拿到第一个字节,只需要在取地址时将整型强制类型转换为字符型即可,拿到存放第一个字节的地址后对其解引用便可拿到第一个字节数据。

    2. 如果拿到的是01,说明存储方式是01 00 00 00,也就是小端存储模式,反之则为大端存储模式。

    这里如果有没有讲清楚的地方,欢迎评论区留言或者私信博主解决嗷。


    🧶整型数据在内存中的存储

    数据在内存中的存储遵循一定的法则,而整型数据和浮点型数据在内存中所遵循的法则是不同的,这里我们先介绍整型数据在内存中是如何存储的。

    介绍整型数据的存储需要先引进一个概念:原反补码。

    💣原码、反码、补码

    计算机中的有符号数有三种表示方法,即原码、反码和补码。三种表示方法均有符号位和数值位(或称有效位)两部分,符号位都是用0表示“正”,用1表示“负”,而数值位,三种表示方法各不相同。在计算机系统中,数值一律用补码来表示和存储。原因在于:使用补码,可以将符号位和数值域统一处理;同时,加法和减法也可以统一处理。
    而补码其实是针对负数存储设定的,对于无符号数来说,其反码和补码都和原码相等。

    原码:

    所谓原码,就是将数据直接翻译为二进制序列。

    拿32位平台举例,最高位作为符号位,正数的符号位为0,负数的符号位为1,后面的31位称为有效位,以不同的权重计算出不同的数字,最低位的权重为20,其次为21,以此类推。

    如:

    13的原码为:00000000000000000000000000001101
    
    -3的原码为:10000000000000000000000000000011
    

    反码:

    反码,顾名思义,就是将原码的二进制序列按位取反,但这里需要注意,并不是将所有的二进制位都按位取反,符号位是特殊独立出来的,他表示一个数的正负,随意取反可能会遭遇意想不到的结果。

    所以反码应该通过原码除符号位,其他位按位取反获得。
    (注:正数的反码和原码相等。)

    如:

    13的反码为:00000000000000000000000000001101
    
    -3的反码为:11111111111111111111111111111100
    

    补码:

    整数在内存中的存储存的都是补码,所以要通过上面的反码求出补码,补码的获取规则是原码按位取反(除符号位)再加一。
    (注:正数的补码和原码相等。)

    如:

    13的补码为:00000000000000000000000000001101
    
    -3的补码为:11111111111111111111111111111101
    

    因为整数在内存中的存储形式是补码,所以引出原反补的意义就是求出补码,而补码的计算公式为:补码 = 原码按位取反(除符号位)再加一

    这里我们通过VS2019编译器进行验证内存中存储的是数据的补码:

    int main()
    {
    	int a = 13;
    	//原码:00000000 00000000 00000000 00001101
    	//反码:01111111 11111111 11111111 11110010
    	//补码:01111111 11111111 11111111 11110011
    
    	int b = -3;
    	//原码:10000000 00000000 00000000 00000011
    	//反码:11111111 11111111 11111111 11111100
    	//补码:11111111 11111111 11111111 11111101
    
    	return 0;
    }
    

    编译器下调试 - 内存 - &a:

    &a的结果
    内存中存储的是:0d 00 00 00

    为小端存储模式,00001101转换为十六进制就是0d。

    编译器下调试 - 内存 - &b:

    &b的结果
    内存中存储的是:fd ff ff ff

    为小端存储模式,1111 1111转换为十六进制就是ff,1111 1101转换为十六进制就是fd。

    如此说来,在内存中真的存放的就是补码,所以为了弄清楚整型数据在内存中的存储,必须牢牢掌握原反补的概念。


    🔨截断与整型提升

    我们知道int类型的变量所占空间大小是4个字节32个bit位(32位平台下),而char类型的变量所占空间大小是1个字节8个bit位,那我要怎么将一个整型的数据存放在一个char类型的变量里呢?这里教大家一个很有用的办法,那就是没办法,32个比特位是不可能放进8个小格子里的,所以就会发生所谓的截断

    我们知道,一个char类型只能存放8个比特位,那如果我要将char类型的数据以%d的形式打印,也就是看做32位数据将其打印,那有要怎么做呢?再教大家一个办法,那依然是没办法,所以编译器只能对char类型的数据进行整型提升

    接下来简单讲解截断和整型提升的原理。

    截断

    假设我有一个32位二进制序列:
    01010011001000110001000100100011

    这是一个非常大的数字:

    计算器计算

    有一个char类型的空间:
    char类型的空间

    在把32位数字往里放的时候会发现放不下,便会发生截断,只保留低八位的数字,其他24位数字直接舍弃,

    截断过程

    最终存放的结果为:

    计算器存放结果

    这就是截断的过程。

    整型提升

    当我要将char类型的数据以%d的形式打印时,我们知道,%d是打印有符号整型,打印的是32位0/1序列的最终结果,但我们的char类型里只存放了8位,这个时候就会发生整型提升。

    整型提升规则:

    1. 如果对无符号数进行整型提升,则在前面补24位0。
    2. 如果对有符号数进行整型提升,则判断该数在当前的二进制0/1序列的首元素,相当于符号位。
      - 如果是0,则全补0
      - 如果是1,则全补1

    如:

    今有一8位无符号数。

    unsigned char a = 148;
    

    首先我们写出该数的二进制序列。

    10010100 - 148

    由于变量a是无符号类型的,所以不管该二进制序列首元素是0还是1,都将全部补0

    获得:

    00000000000000000000000010010100

    最终打印的结果就是148

    🎉整型数据存储练习

    对以下代码分析输出结果:

    1.
    //输出什么?
    int main()
    {
    	char a = -1;
    	signed char b = -1;
    	
    	unsigned char c = -1;
    
    	printf("a=%d b=%d c=%d\n", a, b, c);
    
    	return 0;
    }
    

    首先VS2019编译器对char类型的处理为默认认为是有符号的char,所以变量a和变量b属于同一类型。

    先计算出-1的补码。

    int main()
    {
    	//-1
    	//原码:10000000000000000000000000000001
    	//反码:11111111111111111111111111111110
    	//补码:11111111111111111111111111111111
    	
    	char a = -1;
    	signed char b = -1;
    	
    
    	unsigned char c = -1;
    	
    
    	printf("a=%d b=%d c=%d\n", a, b, c);
    
    	return 0;
    }
    

    三个变量都是char类型,所以存储时都将发生截断

    int main()
    {
    	//-1
    	//原码:10000000000000000000000000000001
    	//反码:11111111111111111111111111111110
    	//补码:11111111111111111111111111111111
    
    	char a = -1;
    	//存储的补码:11111111
    	signed char b = -1;
    	//存储的补码:11111111
    
    	unsigned char c = -1;
    	//存储的补码:11111111
    
    	printf("a=%d b=%d c=%d\n", a, b, c);
    
    	return 0;
    }
    

    现在要将三个变量以%d形式打印,则会发生整型提升

    • 而对于变量a和变量b来说,存放的是有符号的char,根据第一个二进制位决定提升的数为1,所以

    变量a和变量b整型提升后的结果为:

    11111111111111111111111111111111
    
    • 而对于变量c来说,它是无符号的char,直接全部补0,所以

    变量c整型提升后的结果为:

    00000000000000000000000011111111
    

    因为提升后的c符号位是0,所以原反补码均相等。

    而按%d形式打印需要将补码转化为原码后转化为十进制进行打印,

    所以:

    int main()
    {
    	//-1
    	//原码:10000000000000000000000000000001
    	//反码:11111111111111111111111111111110
    	//补码:11111111111111111111111111111111
    
    	char a = -1;
    	//存储的补码:11111111
    	//提升后的补码:11111111111111111111111111111111
    	//提升后的反码:10000000000000000000000000000000
    	//提升后的原码:10000000000000000000000000000001
    	signed char b = -1;
    	//存储的补码:11111111
    	//提升后的补码:11111111111111111111111111111111
    	//提升后的反码:10000000000000000000000000000000
    	//提升后的原码:10000000000000000000000000000001
    
    	unsigned char c = -1;
    	//存储的补码:11111111
    	//提升后的补码:00000000000000000000000011111111
    	//提升后的反码:00000000000000000000000011111111
    	//提升后的原码:00000000000000000000000011111111
    
    	printf("a=%d b=%d c=%d\n", a, b, c);
    
    	return 0;
    }
    

    这么一来,打印的结果就应该是-1 -1 255

    打印结果:

    打印结果

    1. 下面程序输出什么?
    2.
    int main()
    {
    	char a = -128;
    
    	printf("%u\n", a);
    
    	return 0;
    }
    

    这道题的变量a是有符号的char类型的。

    首先计算出-128的原反补码。

    int main()
    {
    	char a = -128;
    	//-128
    	//原码:10000000000000000000000010000000
    	//反码:11111111111111111111111101111111
    	//补码:11111111111111111111111110000000
    
    	printf("%u\n", a);
    
    	return 0;
    }
    

    将01111111111111111111111110000000这样一个二进制序列存放进a中将会发生截断

    截断之后a中存放的结果为:10000000

    这时以%u的形式打印,也就是以无符号整型的形式打印,要进行整型提升,而变量a是一个有符号的char类型,第一个元素是1,所以整型提升24个1。

    int main()
    {
    	char a = -128;
    	//-128
    	//原码:10000000000000000000000010000000
    	//反码:11111111111111111111111101111111
    	//补码:11111111111111111111111110000000
    
    	//截断的结果:10000000
    	//整型提升后的结果:11111111111111111111111110000000
    
    	printf("%u\n", a);
    
    	return 0;
    }
    

    这时要将提升之后的补码转换为原码后以十进制的形式进行打印。

    而%u的形式将把补码中的符号位看做是有效位,所以其原反补都是一样的。

    int main()
    {
    	char a = -128;
    	//-128
    	//原码:10000000000000000000000010000000
    	//反码:11111111111111111111111101111111
    	//补码:11111111111111111111111110000000
    
    	//截断的结果:10000000
    	//整型提升后的结果:11111111111111111111111110000000
    	
    	//补码:11111111111111111111111110000000
    	//反码:11111111111111111111111110000000
    	//原码:11111111111111111111111110000000
    
    	printf("%u\n", a);
    
    	return 0;
    }
    

    而11111111111111111111111110000000的值应该是4,294,967,168

    计算器计算

    所以输出结果:

    输出结果

    3.
    int main()
    {
    	char a = 128;
    
    	printf("%u\n", a);
    
    	return 0;
    }
    

    还是一样,先求出128的补码,由于128是正数,所以其原反补都是相同的为:

    00000000000000000000000010000000
    

    存放进变量a中将发生整型截断:

    10000000
    

    而变量a为有符号的char类型,所以整型提升为

    11111111111111111111111110000000
    

    变量a以%u形式打印,则把符号位看成有效位,则此时原码反码补码相同,直接进行计算,11111111111111111111111110000000的十进制形式为4,294,967,168

    计算器

    所以打印结果为:打印结果

    4.
    int mian()
    {
    	int i = -20;
    	unsigned int j = 10;
    
    	//按照补码的形式进行运算,最后格式化成为有符号整数
    	printf("%d\n", i + j);
    	
    	return 0;
    }
    

    还是先把-20和10的补码计算出来,但是这里的i和j都是整型变量,所以不会发生截断和整型提升。

    int mian()
    {
    	int i = -20;
    	//-20
    	//原码:10000000000000000000000000010100
    	//反码:11111111111111111111111111101011
    	//补码:11111111111111111111111111101100
    	unsigned int j = 10;
    	//10
    	//补码:00000000000000000000000000001010
    
    
    	//按照补码的形式进行运算,最后格式化成为有符号整数
    	printf("%d\n", i + j);
    	
    	return 0;
    }
    

    数据的计算是按照二进制补码的形式进行计算的,最后的结果再根据打印要求或者存储要求进行调整更改。

    计算的结果

    int mian()
    {
    	int i = -20;
    	//-20
    	//原码:10000000000000000000000000010100
    	//反码:11111111111111111111111111101011
    	//补码:11111111111111111111111111101100
    	unsigned int j = 10;
    	//10
    	//补码:00000000000000000000000000001010
    
    	//计算:
    	//11111111111111111111111111101100
    	//00000000000000000000000000001010
    	//11111111111111111111111111110110 - 补码相加的结果
    
    	//按照补码的形式进行运算,最后格式化成为有符号整数
    	printf("%d\n", i + j);
    	
    	return 0;
    }
    

    要求按%d的形式打印,则将计算的结果转化为原码以有符号十进制数打印。

    补码:11111111111111111111111111110110
    反码:10000000000000000000000000001001
    原码:10000000000000000000000000001010
    

    计算结果为-10

    打印结果

    int main()
    {
    	unsigned int i;
    	for (i = 9; i >= 0; i--)
    	{
    		printf("%u\n", i);
    	}
    
    	return 0;
    }
    

    程序分析:

    变量i从9开始自减到0时,都可以正常进入程序打印的值就是

    9 8 7 6 5 4 3 2 1 0
    

    在打印完0之后,变量i再自减1,变成-1,按道理来说应该跳出循环,但我们注意,这里的变量i为无符号整型,而-1的补码为11111111111111111111111111111111,所以会被解析为一个特别大的正整数:4294967295。

    计算器计算结果
    那么他也符合循环控制条件(i >= 0),所以循环会继续4294967295次,而一直自减到0的时候,再次自减又变成-1,有被解析为4294967295,所以该程序将无限循环下去。

    这里博主随便截两张打印结果的图供大家参考。
    打印结果

    打印结果

    6.
    #include <string.h>
    
    int main()
    {
    	char a[1000];
    	int i;
    	for (i = 0; i < 1000; i++)
    	{
    		a[i] = -1 - i;
    	}
    	printf("%d", strlen(a));
    
    	return 0;
    }
    

    程序分析:

    根据代码可知数组中第一个存放的数应该是-1,第二个是-2,以此类推。

    内存中存放形式

    但是这个数组是char类型的,我们知道char类型可存放的数据范围是-128~127,所以这些数据一直自减到-128之后,如果再自减就会放不下了,但是这里有一个小知识点。

    通过画图给大家讲解。

    char类型补码计算

    1. 这个图中放的是char类型补码对应十进制的全部情况,二进制位从0开始补码加1,即十进制从0开始加1计算,最终计算到127。

    2. 11111111为-1的补码,往上减1计算得到-2,再减1就是-3,以此类推可计算到-127。

    3. 而10000000这个二进制序列是无法计算的,所以系统直接将其赋为-128。

    综合以上三点可知,char类型的补码其实是以从-1,-2,…,-127,-128,127,126,…,2,1这样的方式连续的。

    画成图的形式为:

    char类型的补码连续
    由以上两个图可知,内存中的数据存放为:

    内存中的数据

    其实这又是一个无限循环的存放,一直存放满1000个数据为止。

    而打印的是字符串长度,使用的是strlen函数,strlen函数遇到\0就停止计算,所以计算的结果应该为128 + 127 = 255。

    运行结果

    7.
    unsigned char i = 0;
    
    int main()
    {
    	for (i = 0; i <= 255; i++)
    	{
    		printf("hello world\n");
    	}
    
    	return 0;
    }
    

    程序分析:

    首先定义了一个全局变量:无符号整型i。

    无符号的char类型范围是0~255,所以代码前面会打印255个"hello world\n",这一点肯定没错。

    而255作为无符号数在内存中的补码是:

    00000000000000000000000011111111
    

    自增1之后的结果是:

    00000000000000000000000100000000
    

    将这个数存放于变量i中必然是存不下,所以会发生截断

    只保留低八位存储,所以变量i现在存储的是00000000,也就是0,是一个无符号数,原反补相同,并且符合循环条件,所以循环又开始了。

    经过上述分析,该代码的结果应该是一个无限打印的死循环。

    运行结果


    🏆浮点型数据在内存中的存储

    首先我们先见一下常见的浮点型数据有哪些?

    3.14159
    1E10

    浮点型数据类型:

    1. float
    2. double
    3. long double

    long double是在C99标准中引入的,比较老旧的编译器都不支持这种写法。

    浮点数表示的范围:在"float.h"文件中可以查看。

    打开文档方式

    float.h文档

    在此文档中即可查看浮点型数据的范围大小。

    接下来介绍浮点型数据在内存中的存储方式。

    🎨证明整数和浮点数的存取方式不同

    浮点数存储的例子:

    int main()
    {
    	int n = 9;
    	float* pFloat = (float*)&n;
    
    	printf("n的值为:%d\n", n);
    	printf("*pFloat的值为:%f\n", *pFloat);
    
    	*pFloat = 9.0;
    
    	printf("num的值为:%d\n", n);
    	printf("*pFloat的值为:%f\n", *pFloat);
    
    	return 0;
    }
    

    打印结果为:

    打印结果

    • 将9存放进int类型的变量i中,所以第一个打印是9,这很容易理解,而用float类型的指针对其解引用得到的值却是0;

    • 用float指针类型将内存中的值覆盖为9.0,以整型的方式打印出来是我们不知道的值,而用float类型指针解引用得到了9.0。

    以上例子证明了整型数据和浮点型数据的存储方式是截然不同的,接下来就
    开始研究浮点型数据在内存中到底是以怎样的形式进行存储的。


    🍦IEEE标准形式

    根据国际标准IEEE(电气和电子工程协会)754,任意一个二进制浮点数V可以表示为下面的形式:

    • (-1)S ✖ M ✖ 2E
    • (-1)s表示符号位,当s = 0时,V为正数;当s = 1时,V为负数。
    • M表示有效数字,M必须大于等于1,且小于2。
    • 2E表示指数位。

    举两个例子:

    1. 十进制数3.75,先将其转化为二进制数011.11

    二进制数011就是十进制数3,小数点后面的第一个1表示1.0 / 21,第二个1表示1.0 / 22
    转换为IEEE标准形式为(-1)0 ✖ 1.111 ✖ 21
    此时S = 0,M = 1.111,E = 1

    1. 十进制数-0.5,现将其转化为二进制数-0.1

    二进制数0就是十进制数0,小数点后面的1表示1.0 / 21
    转换为IEEE标准形式为(-1)1 ✖ 1.0 ✖2-1
    此时S = 1,M = 1.0,E = -1

    ==注意:==小数点后面的数都是按照1.0 / 2n的形式相加得到的,所以很多数其实是得不到准确值的。

    🍚IEEE存储标准规定

    1. IEEE 754规定:
    1. 对于32位的浮点数,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M。
    2. 对于64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。

    画图说明

    1. 对于单精度浮点数:

    单精度浮点数存储模式

    1. 对于双精度浮点数:

    双精度浮点数存储模式

    1. IEEE 754对有效数字M和指数E,还有一些特别规定。

    对于M(有效数字)的规定:

    前面说过,1 ≤ M < 2 ,也就是说,M可以写成1.xxxxxx的形式,其中xxxxxx表示小数部分。
    IEEE 754规定,在计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,只保存后面的xxxxxx部分。比如保存1.01的时候,只保存01,等到读取的时候,再自动把第一位的1给加上去。这样做的目的是节省1位有效数字,以增加M的精度。
    以32位浮点数为例,留给M的空间只有23位,将第一位的1舍去以后,等于可以保存24位有效数字。

    对于E(指数部分)的规定:

    至于指数E,情况就比较复杂。

    首先,E为一个无符号整数(unsigned int),这意味着,如果E为8位,它的取值范围为0 ~ 255;如果E为11位,它的取值范围为0~2047。但是,我们知道,科学计数法中的E是可以出现负数的,所以IEEE 754规定,存入内存时E的真实值必须再加上一个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023。比如,210的E是10,所以保存为32位浮点数时,必须保存为10 + 127 = 137,即10001001。

    加上127或者1023进行存储的原因是取出该数的时候就需要减去127或者1023,这样E就可以得到负数的情况。


    🥛IEEE读取标准规定

    指数E从内存中取出还可以再分成三种情况:

    1. E不全为0或不全为1
      这时,浮点数就采用下面的规则表示:
      即指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第一位的1。
      比如:
      十进制数0.5的二进制形式为0.1,由于规定整数部分必须为1,即将小数点右移1位,则为(-1)0 ✖ 1.0 ✖ 2(-1),其阶码(指数部分)为-1 + 127 = 126,表示为01111110,而有效位部分1.0去掉整数部分为0,补齐0到23
      位00000000000000000000000,则其二进制表示形式为:
      0 01111110 00000000000000000000000
    1. E全为0
      可以理解为E为全0时,该数被解析为0。
      因为,当E为全0时,说明以IEEE标准形式写出的式子的指数部分是-127或者-1023,也就是说符号位和有效位要乘以1.0 / 2127或者乘以1.0 / 21023的数,而这个数非常小,近乎为0,所以在内存中取出该数时通过一些办法直接将其翻译为0。
    1. E全为1
      这时,表示±无穷大(正负取决于符号位S);
      原因是,如果E全为1,则指数位计算的是128,2128次方是一个非常大的数字,所以这里我们可以认为他是正负无穷大。

    举个简单的例子,如十进制数-12.75,转换为二进制数为:-1100.11,转换为IEEE标准形式为(-1)1 ✖ 1.10011 ✖ 2 3,此时的S = -1, M = 1.10011,E = 3

    以单精度浮点型为例,将其存入内存的方式为:把S放在第一位作为符号位,E加上127,即3 + 127 = 130转化为二进制数10000010,把M的整数部分去掉,将小数部分存储,E和M不够的位全部补0。

    1 10000010 10011000000000000000000
    

    在VS2019编译器上测试:

    int main()
    {
    	float f = -12.75;
    
    	return 0;
    }
    

    调试 - 内存 - &f:

    调试结果

    编译器的形式为十六进制

    将其翻译为二进制为:

    00000000 00000000 01001100 11000001
    

    而我们刚才的计算结果是:

    11000001 01001100 00000000 00000000
    

    可以发现,和我们写的正号相反,这说明浮点型数据在内存中存储也遵循大小端字节序规则,且这里遵循的是小端字节序。

    最后,我们在来看最开始给出的那道例题:

    int main()
    {
    	int n = 9;
    	float* pFloat = (float*)&n;
    
    	printf("n的值为:%d\n", n);
    	printf("*pFloat的值为:%f\n", *pFloat);
    
    	*pFloat = 9.0;
    
    	printf("num的值为:%d\n", n);
    	printf("*pFloat的值为:%f\n", *pFloat);
    
    	return 0;
    }
    

    程序分析:

    1. 第一次赋值时,将9赋值给n,属于整型数据存储。

    其二进制序列为:

    000000000000000000000000000001001
    

    第一次打印为整型打印,输出为9
    第二次打印为浮点型打印,就要以浮点型数据的方式取出:

    0 00000000 000000000000000000001001
    

    第一部分为S(符号位),第二部分为E(指数位(需要减去127/1023)),第三部分为M(有效位(小数部分))

    符号位为0,说明是正数,指数位为全0,减去127后得到-127,放在指数部分是2-127,即1.0 / 2127,是一个非常小的数,无论M(有效位)为多少,这里都将翻译为0,所以第二次打印结果输出为0.0。

    1. 第二次赋值时,是以浮点型存储方式进行赋值。

    十进制数9.0,转换为二进制数为1001.0,转换为IEEE标准格式为(-1)0 ✖ 1.001 ✖ 23
    其中S = 0,M = 1.001,E = 3
    进行二进制存储时,第一位放符号位,后8位放E+127的二进制序列,其余位放M的小数部分。

    0 10000010 00100000000000000000000
    

    第三次打印结果为将这个二进制数翻译为十进制。

    即1091567616‬
    计算器计算

    所以,第三次打印结果为1091567616‬

    第四次打印为按浮点数打印,即按浮点数形式取出数据,所以第四次从打印结果为9.0。

    打印结果:

    打印结果

    🍃总结

    本文内容较多,首先介绍了各个数据类型,又介绍了编译器中的大小端存储模式,接着讲解了2015年百度系统工程师的一道笔试题,在整型数据内存存储中介绍了原反补、截断和整型提升的概念,并进行了7道题目的训练,最后根据IEEE协会讲述了浮点型数据在内存中的数据,可谓干货慢慢,建议大家收藏下来慢慢看。

    最后我是Aaron,希望今天的博文对各位有帮助,别忘了三连支持哇~

    👍点赞👍 + 👀关注👀 + ✔收藏✔

    如果以上内容有任何不懂的地方欢迎评论区留言或者私信博主哦~

    展开全文
  • 数据类型 【知识点】 数据类型 整型 字符型 实数(浮点型)型 【内容】 一、数据类型 ...C语言要求在定义所有的变量时都要指定变量的类型。...在数学中,数值是不分...而在计算机中,数据存放在存储单元中的,它是...
  • ArrayList中每个数据占用的内存大小

    千次阅读 2020-12-23 03:08:29
    不懂C#, 但略懂Java, 勉强回答一下.对象开销C#和Java相同, 会进行自动内存回收, 但这并不存在什么特别的魔法.... 如, 单属性int的对象, 必然会占用超过4字节(32位系统中存储int值需要的内存数).除了要存储...
  • C++在程序执行的时候,内存划分为4个区域 ... ①代码存放CPU执行的机器指令既程序汇编而成的二进制代码。 ②代码区是共享的,对于频繁执行的程序内存中只需要保存一份即可。 ③代码区是只读的,只读的原...
  • 如何深入理解Lua数据结构和内存占用? ** 导语 Lua底层数据结构的内存动态分配,一般情况下不太好估算内存占用。腾讯游戏学院专家Ross在本文剖析lua常见数据结构string和table的底层实现原理,并从中找到一般性的...
  • 程序运行时内存的各种数据

    千次阅读 2017-11-19 21:12:18
    该段用来存放没有被初始化或初始化为0的全局变量,因为是全局变量,所以在程序运行的整个生命周期内都存在于内存中。有趣的是这个段中的变量只占用程序运行时的内存空间,而不占用程序文件的储存空间。可以用以下...
  • 没有内存,怎么还能跑程序

    万次阅读 多人点赞 2020-02-26 10:33:00
    正如帕金森定律说的那样:不管存储器多大,但是程序大小的增长速度比内存容量的增长速度要快的多。下面我们就来探讨一下操作系统是如何创建内存并管理他们的。 经过多年的探讨,人们提出了一种 分层存储器体系...
  • C语言进阶 ~ 内存四区(栈、堆、全局、代码区)

    千次阅读 多人点赞 2020-03-05 20:16:42
    特别声明:该部分是... 程序中使用的所有数据都必定属于某一种数据类型 1.1.2 数据类型的本质 数据类型可理解为创建变量的模具:是固定内存大小的别名。 数据类型的作用:编译器预算对象(变量)分配的内存空...
  • 深度分析数据内存中的存储形式

    万次阅读 多人点赞 2021-08-09 16:48:04
    文章目录一、数据的基本类型介绍二、整型在内存中的存储形式1.原码、反码、补码2.大小端介绍3.浮点型在内存中的存储 一、数据的基本类型介绍 char//字符数据类型 short//短整型 int//整形 long//长整型 long long ...
  • LINUX程序(进程)在内存中的布局

    千次阅读 2018-03-04 23:50:20
    http://blog.csdn.net/mumumuwudi/article/details/47141291翻译自: ... 内存管理是操作系统的核心; 是编程和系统管理的关键部分,在接下来的几篇文章中会从...
  • 程序运行时的内存空间分布

    万次阅读 多人点赞 2014-03-21 01:33:18
    我们在写程序时,既有程序的逻辑代码,也有在程序中定义的变量等数据,那么当我们的程序进行时,我们的代码数据究竟是存放在哪里的呢?下面就来总结一下。 一、程序运行时的内存空间情况 其实在程序运行时,...
  • 程序是怎么装载到内存并被运行的

    千次阅读 2021-03-17 17:10:42
    装载器会把对应的指令和数据加载到内存里面来,让 CPU 去执行,而程序,包括操作系统就是一堆指令和数据的集合。 下面开始套娃,BIOS硬件初始化并开始加载主引导扇区(多系统需要选择启动哪个系统的原因),将操作...
  • 1.函数代码存放代码段。声明的类如果从未使用,则在编译时,会优化掉,其成员函数不占代码段...BSS段:BSS段(bss segment)通常是指用来存放程序中未初始化的全局变量的一块内存区域。 BSS是英文Block Started by
  • Java 内存区域-运行时数据区域

    千次阅读 2018-08-19 20:46:08
    一开始看的比较快,对JVM 运行时数据区域只有一个模糊的概念,不太清楚不同内存区域里面到底存放了那些数据,所以在此记录。   我们都知道Java 与C、C++ 最大的区别就是内存管理领域(Java 有内存动态分配和垃圾...
  • 程序白屏问题和内存研究

    千次阅读 2020-12-21 11:27:41
    在开发小程序应用中,QA发现过几次页面白屏的情况,苦于难易...三端的脚本执行环境以及用于渲染非原生组件的环境是各不相同的[1]:在 iOS 上,小程序逻辑层的 javascript 代码运行在 JavaScriptCore 中,视图层是...
  • 上一篇文章学习了以下内容: 用一种不同的分段方法,从另一个不同的的角度理解处理器的分段内存访问机制 ...学习操作系统加载应用程序的过程,演示段的重定位方法,最终彻底理解8086的分段内存管理机...
  • java中栈内存与堆内存(JVM内存模型)Java中堆内存和栈内存详解1 和 Java中堆内存和栈内存详解2 都粗略讲解了栈内存和堆内存的区别,以及代码中哪些变量存储在堆中、哪些存储在栈中。内存中的堆和栈到底是什么 详细...
  • 鉴于笔者最近恶补了java基础,在这儿给大家总结了一些java代码内存分析的经验,希望可以对有所帮助。 在分析内存之前呢,通过这个图让大家明白计算机是怎么处理一个程序的。 简单总结一下:1.计算机首先从硬盘拿...
  • 12.程序运行为啥需要内存

    千次阅读 2016-03-13 10:50:02
    12.1.计算机程序的本质 12.2.冯诺依曼结构和哈佛结构 12.3.为啥需要内存 12.4.操作系统如何管理内存 12.5.编程语言如何管理内存
  • C语言程序设计 利用文件保存数据

    千次阅读 多人点赞 2021-03-06 10:17:40
    程序运行时读入内存数据。 文件指存储在外部介质上数据的集合。 输入输出是数据传送的过程,输入输出形象地称为流,即输入输出流。流表示了信息从源到目的的流动。 1.2 文件名 文件标识包括三部分: (1)文件路径...
  • 我们写代码,离不开处理各种数据,我们靠数据储存各种信息。你没有想过,那么为什么要规定那么多种数据类型?它们在内存中的储存方式一样吗?本文将详细介绍数据内存中的储存,将重点介绍以下几个方面:数据类型...
  • 深入理解Java虚拟机-Java内存区域与内存溢出异常

    万次阅读 多人点赞 2020-01-03 21:42:24
    文章目录概述运行时数据区域程序计数器(线程私有)Java虚拟机栈(线程私有)局部变量表操作数栈动态链接方法返回地址小结本地方法栈(线程私有)Java堆(全局共享)方法区(全局共享)运行时常量池直接内存HotSpot...
  • JVM的内存形式:   (1)方法区:存放了要加载的类的信息(名称,修饰符等)、类中的静态变量、类中定义为final的变量、类中Field信息、类中的方法信息,当开发人员通过Class对象的getName、isInterface方法...
  • 本文是参考两篇CSDN和自己做的修改笔记整理: 作者:lm_y 原文:... 作者:dadalan ... 一、内存 内存就是RAM! RAM和ROM是相对的,RAM在断电后会丢失其中的信息,而...
  • 由于现在 ORM 框架的成熟运用,很多小伙伴对于 JDBC 的概念有些薄弱,ORM 框架底层其实是通过 JDBC 操作的 DB ...文章大数据量操作核心围绕 JDBC 展开,目录结构如下: MySQL JDBC 大数据量操作.
  • flink内存模型

    千次阅读 2022-03-29 09:26:55
    flink内存管理模型,参数配置

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 129,968
精华内容 51,987
热门标签
关键字:

内存存放的既有程序代码又有数据

友情链接: PANATERM-V3-7.zip