0%

一次对内存的探索

一段程序

故事发生在某天深夜,已经熄灯的宿舍,黯淡的屏幕闪烁出一条消息

“睡了没”

“木呢。”

“来看段代码”

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <stdlib.h>
int main()
{
int i= 17970;
char *q = &i;
printf("%d\n", *q);
printf("%d\n", *(q + 1));
return 0;
}

“……,这啥操作?”

“输出是 50 70。”

简单总结下这段代码奇怪的地方

  1. 使用一个 char 指针指向了一个 int 类型的整数;
  2. char 指针所指内容强制转化为 int 类型输出;

和2扯不断的联系

“一个突破口是

\(17970\mod256=50\)

\(17970/256=70\) 。”

\(256\)是个很眼熟的数字,对于程序员来说,最直接联想到的就是\(2^8 = 256\)。这个\(2\)很是让人在意,计算机总是离不开二进制,在这里,一个涉及指针的程序突然出现了\(2\),这就不得不让人联想到使用二进制进行进一步探索。

使用 union

union是 C 语言里一个比较奇特的关键字,其使用方式和struct很是相似。区别在于struct中各个元素是相互独立的,互相之间不会有影响,而union则只分配一块内存,各个成员共用同一块内存空间。

比如说,对于以下代码:

1
2
3
4
5
6
7
8
9
10
union UN
{
int a;
char ch[4];
}
struct Temp
{
int a;
char ch[4];
}
  • union
  • struct

那么使用 union 就可以模仿上述代码中用 char 的方式访问 int

首先用下述程序验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

typedef union UN
{
int a;
char ch[4];
} UN;

int main()
{
UN un;
un.a = 17970;
int i = 0;
printf("%d\n", un.a);
for (;i<4;i++)
{
printf("%d ", un.ch[i]);
}
printf("\n");
return 0;
}

结果和预想一致:
17970
50 70 0 0

结合二进制

那么将上述程序稍加修改,以二进制形式输出呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

typedef union UN
{
int a;
char ch[4];
} UN;

int main()
{
UN un;
un.a = 17970;
int i = 0;
printf("%d\n", un.a);
for (;i<4;i++)
{
printf("%d ", un.ch[i]);
}
printf("\n");
return 0;
}

输出结果为: 00000000000000000100011000110010 00110010 01000110 00000000 00000000 稍加思索 (/ ̄. ̄\)

整理下形式
00000000 00000000 01000110 00110010
00110010 01000110 00000000 00000000
≖‿≖✧

可以看到,如果将一个 int 放大到内存去看的话,四个字节的排列方式是低权位在前,高权位在后,这点颠覆了我过往的认知。

其次,在这里我突然明白了为什么\(2^8=256\)里这个\(8\)的含义,每个字节占\(8\)位,\(2\)\(8\)的结合产生出了\(256\)这个神奇的数字。

答案

“也就是说,char*的作用是将 int 的一部分拿出来,再强制转化为 int 进行操作。”

“而一个 int 占四个字节,char 占一个字节,刚好能拿出四分之一”

“一个字节占 8 位,这就是 256 的原因。”

“最为关键的是,int 在内存中的存放方式是以一个字节为单位,低权位在后,高权位在前,这就是为什么是 50 和 70 而不是两个 0。”

“完美。”

“睡觉。”

加戏

如果说一个可以用 char 来逐字节的读 int 的话,那么换成其他的类型应该也是没问题的吧。

或者从原理上讲,从 int 的首地址出发,使用 char 将其向后 sizeof(int) 的内存中的内容读了出来。

那么,如果已知一个地址,使用类似的思路,是不是就能将其之后指定大小的内存中的内容读出来了?

按着这个想法对之前的函数稍加修改,于是就得到了一个查看内存内容的工具。 (•̀ᴗ•́)و ̑̑

两个参数分别是地址以及大小,返回的是以 C 风格的字符串储存的该内存中的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
char *ToBinary(const char *source, const unsigned int size)
{
char *_source = (char *)malloc(sizeof(char) * size);
memcpy(_source, source, size); //使用_source代替source

static char *ret = NULL; //返回ret
if (ret)
{
free(ret);
}
unsigned ret_length = size * (CHAR_BIT + 1);
ret = (char *)malloc(sizeof(char) * ret_length + 1);
ret[sizeof(char) * ret_length] = 0; //最后一个是0

for (int index = 0; index < size; index++)
{
short char_source = *(_source + index);
char *temp = ret + index * (CHAR_BIT + 1);
memset(temp, '0', sizeof(char) * CHAR_BIT);
temp[CHAR_BIT] = ' ';

for (int j = CHAR_BIT - 1; j >= 0; j--)
{
temp[j] = (char_source & 1) + '0';
char_source >>= 1;
}
}
return ret;
}

有意思的事情要发生了。

左移与右移

C 语言中的 << 与 >> 是两个比较神奇的操作符,能将一个数从内存中左移或者右移。本来潜意识里我认为就是直接移就好了。但是之前也有发现,int 在内存中的储存方式并不是一个连续的状态,而是每八个字节为一个单位,低权位在前,高权位在后。那左移和右移究竟是如何进行的呢?用上之前的函数,写个程序看看:

1
2
3
4
5
6
7
8
9
10
int main()
{
int x = 1;
while(x>0)
{
printf("%s\n", ToBinary((char *)&x, sizeof(x)));
x <<= 1;
}
return 0;
}

结果如下:

00000001 00000000 00000000 00000000
00000010 00000000 00000000 00000000
00000100 00000000 00000000 00000000
00001000 00000000 00000000 00000000
00010000 00000000 00000000 00000000
00100000 00000000 00000000 00000000
01000000 00000000 00000000 00000000
10000000 00000000 00000000 00000000
00000000 00000001 00000000 00000000
00000000 00000010 00000000 00000000
00000000 00000100 00000000 00000000
00000000 00001000 00000000 00000000
00000000 00010000 00000000 00000000
00000000 00100000 00000000 00000000
00000000 01000000 00000000 00000000
00000000 10000000 00000000 00000000
00000000 00000000 00000001 00000000
00000000 00000000 00000010 00000000
00000000 00000000 00000100 00000000
00000000 00000000 00001000 00000000
00000000 00000000 00010000 00000000
00000000 00000000 00100000 00000000
00000000 00000000 01000000 00000000
00000000 00000000 10000000 00000000
00000000 00000000 00000000 00000001
00000000 00000000 00000000 00000010
00000000 00000000 00000000 00000100
00000000 00000000 00000000 00001000
00000000 00000000 00000000 00010000
00000000 00000000 00000000 00100000
00000000 00000000 00000000 01000000
可以看到,在一个字节中,左移 的确是不断向左移动,但是字节与字节之间来看,则是有些奇怪的样子。

看样子说成左移右移更多的是考虑到的是易于理解,而实际上并不是简单的左移和右移。虽然并不是很理解为什么要这样设计内存的使用方式就是了。

2021年1月6日补充

实际上是因为在我测试的计算机中,采用小端的方式分配位置,就表现出低字节在低位,高字节在高位,而一个字节是\(8\)位,就导致了这种看似有点反常理的表现。

结构(struct)

之前有次时间探寻了一下结构的size的问题,发现了一个有些神奇的规律。

设结构内各个元素的 size 之和为x,结构内最大的元素 size 为y,结构的 size 为z,则满足z >= x && z % y == 0。老师解释多的那部分是记录了结构的一些东西。

但是,果然还是自己看看才放心啊。<( ̄︶ ̄)>

实验程序如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
typedef struct
{
char a;
} A;

typedef struct
{
char a;
int b;
} B;
typedef struct
{
char a;
int b;
long long c;
} C;
typedef struct
{
char a;
long long c;
} D;

int main()
{
A a;
B b;
C c;
D d;
d.a = c.a = b.a = a.a = CHAR_MAX;
c.b = b.b = INT_MAX;
d.c = c.c = LONG_LONG_MAX;
printf("%s\n", ToBinary((char *)&a, sizeof(a)));
printf("%s\n", ToBinary((char *)&b, sizeof(b)));
printf("%s\n", ToBinary((char *)&c, sizeof(c)));
printf("%s\n", ToBinary((char *)&d, sizeof(d)));
return 0;
}

输出如下:

01111111

01111111 00000000 00000000 00000000 11111111 11111111 11111111 01111111

01111111 00000000 00000000 00000000 11111111 11111111 11111111 01111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111 01111111

01111111 00000000 00000000 00000000 00000000 00000000 00000000 00000000 11111111 11111111 11111111 11111111 11111111 11111111 11111111 01111111

所以多出来的那一部分里面什么都没有(°ο°)

好吧,还是很好奇为什么要设计成这个样子。

2021年1月6日补充

实际上也很简单,字节在分配时采用了对齐的策略,这样的好处是读取速度加快了,但是相应的占据空间更多。

结语

在 struct 之后,我又分别测试了 union 和 class 都和想象的差不多,就不说了。但是最后我又发现了个好玩的东西。

我记得,函数入口是个指针吧。

那么最后这个程序呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void fun1()
{
}

void fun2()
{
int a = 0;
}

void fun3()
{
}

int main()
{
printf("%s\n", ToBinary((char *)fun1, (char *)fun2 - (char *)fun1));
printf("%s\n", ToBinary((char *)fun2, (char *)fun3 - (char *)fun2));
return 0;
}

反正是超出了我的知识范围 ╮(╯▽╰)╭

滚回去复习期末考试~(~o ̄▽ ̄)~o

2021年1月6日补充

emm,这个讲起来略微复杂一些,函数声明是在代码区,然后带上一些控制信息,就是代码里展示出来的地址之间存在内容。