(二)羽夏看C语言——容器

写在前面

  此系列是本人一个字一个字码出来的,包括示例和实验截图。本人非计算机专业,可能对本教程涉及的事物没有了解的足够深入,如有错误,欢迎批评指正。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我

你如果是从中间插过来看的,请仔细阅读 (一)羽夏看C语言——简述 ,从而方便学习本教程。

容器指的是什么

  说到容器,你可能会联想到日常生活中家里的用喝水的杯子;如果接触过计算机的人,可能会想到高大上的东西,比如应用广泛的虚拟化容器Docker。无论联想到的是什么,它们具有同一个属性——装东西。
  计算机运作的数据需要容器,比如内存或者硬盘。在编程语言层面,它具有的容器就形式各样:变量常量数组结构体共用体等等。在汇编层面,它具有的容器有寄存器内存(这个内存和计算机的内存不是一个东西,通常来说计算机的内存指内存条,此处含义为内存地址空间,请自行科普)。此篇将C语言层面的那些能够存储数据的常见容器汇编逐一联系起来。

变量

  In computer programming, a variable or scalar is a storage location (identified by a memory address) paired with an associated symbolic name, which contains some known or unknown quantity of information referred to as a value; or in easy terms, a variable is a container for a particular type of data (like integer, float, String and etc...). ——《维基百科》

  英文看不懂,那就翻译一下:在计算机编程中,变量或标量是一个存储位置(由内存地址标识),与一个相关符号名配对,其中包含一些已知或未知数量的信息,称为值;或者简单地说:

变量是特定类型数据(如整数、浮点、字符串等)的容器

  变量是什么,对于编程语言来说,变量就是一个特定类型数据的容器,正如我在文本章开篇说明的。对于CPU来说,目前它具有以下容器:

容器名称 大小
BYTE 1个字节
WORD 2个字节
DWORD 4个字节
FWORD 6个字节
QWORD 8个字节

  然而对于32位CPU,它就没有比DWORD还大的容器了,具体原因请到汇编进行学习查看(需要额外声明,当你用x32dbg调试器随便打开一个程序,发现有大于等于8个字节的容器,那是因为有IntelCPU集成了FPU,它是专门用于处理浮点运算的寄存器,不特殊说明仅指普通CPU寄存器)。

  对于C语言来说,它具有以下容器:

容器名称 大小
char 1个字节
short 2个字节
int 4个字节
long 4个字节
float 4个字节
double 8个字节

  学习C语言,很多初学者学完可能都会有的误区:认为char类型是用来存储字符的,short是用来存储短整数类型 诸如此类的印象。如果这样认识变量,就太肤浅了,你就没学会C语言,变量的本质是容器,是用来组织数据的方式char类型不是字符类型,而是字节类型(能够装1个字节数据的容器,字节类型是我的说法)。其他的基础变量类型以此类推。

C语言有byte类型,前提包含"Windows.h"头文件,你可以查看它的定义,你会发现如下代码:

typedef unsigned char byte;

可以下一条结论:字节类型就是无符号的char类型,它也是字节类型。

  既然说到符号和无符号,它们到底有什么不同。前面说charunsigned char都是我所谓的字节类型。从内存或者寄存器存储的视角来看,它只是显示方式的不同。举个例子,如果一个数,在一个字节大小的容器中,存储0xF4这一个数(这个是16进制的写法,如果不会,可参考我的下一篇文章)。如果用有符号显示,它是-12,如果用无符号的显示,它是244

  铺垫了这么多,是时候打开VS,来说明变量和汇编的关系。我们新建一个控制台工程,输入以下代码以供测试:

#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h

int main()
{
    char ch = 1;
    short s = 2;
    int i = 3;
    int unsigned ui=0;
    long l = 4;
    float f = 5;
    double d = 6;
    system("pause");
    return 0;
}

  我们在main函数头部下一个断点,开始调试,切换到汇编模式,你会看到如下图所示的结果:

  图中汇编代码看不懂的同志,请自行补缺。

指针

  C语言的精髓是指针,我相信不少人会有所耳闻。很多初学者把指针神话了,甚至僵硬化使用,就是因为对变量是容器的本质没有理解到位。指针也是变量,只不过有一点点特殊,通常用来存放地址编号罢了。
  下面我会给出一段代码,请回答注释当中的问题,看看你学的指针到底怎么样,也看看你对我所述的学习情况。

#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h

int main()
{
    int i = 65534;
    int* pi = (int *)5;//这样对吗?
    unsigned char* pch = (unsigned char*)&i;
    printf_s("pch指向的地址存储的值:%d\n", *pch);//这样对吗?*pch的值到底是多少?
    system("pause");
    return 0;
}

  先别着急检验,我先科普一下小端存储再继续:

The order of digits in a computer that is the opposite of how humans view numbers. The digits on the left are less in value than the digits on the right. ——《维基百科》

  举个例子,一个用十六进制表示的32位数据:0x12345678,存放在存储字长是32位的存储单元中,按低字节到高字节的存储顺序为0x78、0x56、0x34和0x12,通常的CPU采用小端存储,但也有大端存储的,请自行搜索。

  答案将在下一篇文章进行揭晓。

  以上只是拿一维指针进行介绍,以下拿多维指针继续介绍:

#include <iostream>
using namespace std;

//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
//并删除 using namespace std;
//将 cout 改为 printf_s(可以 printf,但微软编译器编译会报错,自行科普)

int main()
{
    int* pi1;
    int** pi2;
    char* pch1;
    char** pch2;
    float*** pf3;

    cout << "以上指针的大小:" << endl
        << sizeof(pi1) << endl
        << sizeof(pi2) << endl
        << sizeof(pch1) << endl
        << sizeof(pch2) << endl
        << sizeof(pf3) << endl;

    cout << "以上指针取值一次的大小:" << endl
        << sizeof(*pi1) << endl
        << sizeof(*pi2) << endl
        << sizeof(*pch1) << endl
        << sizeof(*pch2) << endl
        << sizeof(*pf3) << endl;

    cout << "对能再取值的指针进一步取值的大小:" << endl
        << sizeof(**pch2) << endl
        << sizeof(**pf3) << endl;

    system("pause");
    return 0;
}

  这次不必看汇编代码了,看看结果:

以上指针的大小:
4
4
4
4
4
以上指针取值一次的大小:
4
4
1
4
4
对能再取值的指针进一步取值的大小:
1
4
请按任意键继续. . .

  我们可以下如下结论:所有的指针都是一个大小,为4个字节(32位)。

常量

In computer programming, a constant is a value that should not be altered by the program during normal execution, i.e., the value is constant.When associated with an identifier, a constant is said to be "named", although the terms "constant" and "named constant" are often used interchangeably. This is contrasted with a variable, which is an identifier with a value that can be changed during normal execution. ——《维基百科》

  在计算机编程中,常量是程序在正常执行期间不应更改的值,即该值为常量当与标识符关联时,常量被称为“命名”,尽管术语“常量”和“命名常量”经常互换使用。这与变量不同,变量是一个标识符,其值可以在正常执行期间更改。

  如上说明了常量是什么和与变量的区别。然而不幸的是,在汇编层面,它们本质是一个东西。我们将用相同的方式用如下代码进行验证:

#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h

int main()
{
    int a = 5;
    const int b = 5;

    system("pause");
    return 0;
}

  如下就是验证结果:

  既然常量和变量本质是一样的,常量也是可以被修改的,那么我们用以下代码进行验证:

#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h

int main()
{
    int a = 5;
    const int b = 5;

    printf_s("b的旧值:%d\n", b);

    int* pb = (int*)&b;
    *pb = 10;

    printf_s("b的新值:%d\n", b);

    system("pause");
    return 0;
}

  结果运行后你发现,与预想的根本不一致:

b的旧值:5
b的新值:5
请按任意键继续. . .

  你到此可能怀疑我说的是有问题的,认为常量是无法改变的,其实它已经被改了,请看一下局部变量b的值,它已经是10了,如下图。

  那么,是什么原因导致更改后的值仍然是5呢,看汇编你就会明白了,这都是编译器的把戏,如下图:

  你可以看到,编译器编译好后调用此函数时压根就没有把变量b的地址的内容放入堆栈中,而是直接将5压入堆栈,所以导致以上“奇怪”的问题。

局部变量与全局变量

  学过C语言的同志,应该都知道局部变量和全局变量的区别和作用域。但是,为什么全局变量每个函数都可以到处用,而局部变量不行呢?让我们从汇编层面来看看是为什么。
  先准备如下代码以供实验:

#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h

int quanju = 10;

int main()
{
    int jubu = 0;
    jubu = quanju + 8;
    system("pause");
    return 0;
}

  然后我们看一下汇编,你会看到如下图所示结果(关键部分):

  从汇编代码我们很容易看出,局部变量被翻译为堆栈中的一个“临时地址”,这个是由于ebp寻址提栈提供的缓冲区,函数结束后会被平栈,通过普通方式无法使用该值(如果不懂的话,后面将会有一篇文章用来讲述函数的)。而全局变量直接是一个写死的地址,编译完一个程序后,该地址不会发生变化,这就是所谓的全局变量每个函数随意使用,而局部变量不行的原因。

公共变量和私有变量 ❗

  公共变量和私有变量是什么定义我就不详细描述了,就是字面意思。我们做一个实验进行验证一下:

#include <iostream>

struct MyStruct
{
private:
    int hide = 10;
public:
    int show = 20;
};

int main()
{
    MyStruct stru;
    int h = stru.hide;
    int s = stru.show;
    system("pause");
    return 0;
}

  有些眼尖的朋友一眼就能看出,这个代码是编译不过去的,因为hideMyStruct的私有全局变量,不能访问。但既然是全局变量,就是一个地址,难道就不能访问吗,答案是能够访问,需要一点手段——指针。我们来看下代码:

#include <iostream>

struct MyStruct
{
private:
    int hide = 10;
public:
    int show = 20;
};

int main()
{
    MyStruct stru;
    int* ph = (int*)&stru;
    int s = stru.show;
    printf_s("hide: %d\nshow: %d\n", *ph, s);
    system("pause");
    return 0;
}

  你将会得到如下图所示结果:

  这个是不是巧合呢,让我们看一看汇编代码:

  如果你不看后面的文章,你可能不能完全弄明白,我简单说明一下。第一个图的lea ecx,[ebp-10h]汇编指令就是传说的this指针,指向该结构体的地址。下一句的call就是调用构造函数,虽然我没有写构造函数,但它默认会有一个无参的构造函数(你可能会犯嘀咕,这明显不是类的特征吗?是的,没错,在C语言中,类和结构体是一个东西,没有任何区别,但是通常会把只有数据的称之为结构体,还有功能函数的称之为类)。说了这些,第二张图片也就能看明白了。如果不明白可以在讨论区留言。

数组

  最经常用的数组,当属字符串了,数组是最常用的数据组织方式之一。先从最简单的一维数组来看看数组和汇编的关系。我们先用如下代码进行实验:

#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h

int main()
{
    int a[] = { 1,2,3,4,5,6 };
    int a0 = a[0];
    int a5 = a[5];

    system("pause");
    return 0;
}

  然后查看汇编,得到的结果如下:

  你可能没见过imul指令,这个是有符号乘法:imul ecx,eax,0用数学表达式来写的话就是ecx = eax * 0,以此类推。其他的关于一维数组我就不过多介绍了。

  接下来我们看看更高维数的数组,先以最简单的二维数组试刀,简单修改一下原来的代码:

#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h

int main()
{
    int a[][2] = { 1,2,3, 4,5,6 };
    int a0 = a[0][0];
    int a5 = a[1][1];

    system("pause");
    return 0;
}

  然后看一下反汇编:

  很多初学者学习二维数组的时候,都是用画表格的形式:

索引 0 1
0 1 2
1 3 4
2 5 6

  这样的方式虽然直观好理解,但会带来一个误区,仿佛内存也是这样存储二维数组的。但是,如果是三维数组甚至更高维数的呢?在计算机中,数组是沿着线性地址顺序存储的,无论是多少维。 上面图示的汇编代码就体现出这个特性,本人不多论述。

  二维数组都试了试,再来个三维的加深印象:

#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h

int main()
{
    int a[][3][2] = { 1,2,3,4,5,6,7,8,9,10,11,12 };
    int ia = a[1][1][1];

    system("pause");
    return 0;
}

  汇编代码如下图所示:

  正确的内存存储示意图:

  有了这个示意图,是不是更好理解了多维数组。

数组与指针

  说到指针和数组的关系,我们看一下代码:

#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h

int main()
{
    int a[] = { 1,2,3,4,5,6 };
    int* p = a;
    printf_s("%d,%d\n", a[2], p[2]);
    system("pause");
    return 0;
}

  汇编代码如下:

  仔细观察发现,它们取值方式几乎差不多。虽然可以说数组和指针获取值本质上几乎差不多,但数组一旦定义,在程序的生命周期就不能随意改变。指针是随意的,想指哪就指哪。

结构体 ❓

  因为这篇文章只是介绍容器,故我们接下来继续介绍C的结构体。为什么这么说呢,是因为C不支持带函数的结构体,而C++可以。

  对于此类的结构体的讨论,请用以下的代码做实验,在做这个实验之前,请思考一下它的输出结果是多少:

#include <iostream>
using namespace std;
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
//将 cout 改为 printf_s(可以 printf,但微软编译器编译会报错,自行科普)

struct MyStruct
{
    int i;
    char ch;
    char ch1;
    int i1;
    double d;
};

int main()
{
    cout << sizeof(MyStruct) << endl;
    system("pause");
    return 0;
}

  你可能会惊奇的发现,输出的结果为24,到底是为什么呢,不应该是18个字节吗?这就是因为字节对齐的缘故。

  对于32位的CPU,它最擅长一次操作4个字节的容器。为了提高性能,就必须牺牲一些东西,那就是空间,即所谓的拿空间换时间的操作,不满4个字节按4个字节计算。

  当然,你也可以强制它一个字节接一个字节的对齐,在定义的结构体之前使用##pragma pack(1)就可实现,你可以添加后重新编译查看结果。

共用体

  共用体,简单来说。就是一个地址多个别名,举个简单的例子就能明白:

#include <iostream>
using namespace std;
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
//将 cout 改为 printf_s(可以 printf,但微软编译器编译会报错,自行科普)

union MyUnion
{
    int a;
    int b;
    int c;
    unsigned char ch;
};

int main()
{
    cout << sizeof(MyUnion) << endl;
    MyUnion test; //如果是C,请在本行前添加 union 关键字
    test.a = 0xfffe;
    printf_s("a:%d,b:%d,c:%d,ch:%d\n", test.a, test.b, test.c, test.ch);
    system("pause");
    return 0;
}

  反汇编结果:

  输出结果:

4
a:65534,b:65534,c:65534,ch:254
请按任意键继续. . .

  由此可看出:共用体的所有变量(更合理的说是别名),都共用一个地址。
  编译器是这么做的,但是实际上C/C++编译器都有一套标准,这里会导致未定义行为。我在这里描述一下:如果共用体有多个成员,从一个成员写入,紧接着从另一个成员读出这个是未定义的,只明确说明了匿名共用体的所有成员的首地址一定相等。

下一篇

  (二)羽夏看C语言——进制

posted @ 2021-09-02 14:37  寂静的羽夏  阅读(4242)  评论(1编辑  收藏  举报