导读
本文为笔者对C语言的一点思考,内容较杂,难免出错。如果阅读过程中发现什么问题,望不惜赐教。推荐顺序阅读,否则可能出现断片现象。
全文共包含以下几部分:
- 变量与地址
- 内存空间的申请
- 变量类型
- 编码方式
- 字符型数组与字符串
-
\0
作为字符串结束符的必要性 - 数组、指针、数组指针及指针数组
变量与地址
先说下变量与变量名,这两者的区别可以简单理解为:定义的时候叫变量名,使用的时候叫变量。
int a = 1; // 变量名
a = 2; // 变量
printf("%d\n", a); // 变量
如上所示,可以对变量a进行读写操作,那么变量a的数据存储在哪里?计算机如何找到a存储的位置?
一个程序的运行需要加载到内存中,而程序就是函数与变量的集合,计算机会为每个加载到内存中的函数与变量分配内存,被分配的内存所在的位置就是内存地址。计算机通过对变量a寻址就可以找到a存储数据的位置,读取地址存储的内容就可以获取变量a存储的数据。
C语言通过&
符号对变量寻址,通过*
符号对指针变量读址
int *p = &a; // 获取变量a的地址并赋值给指针变量p
*p = 3; // 读取指针变量p所存储的地址,并对这个地址所存储的内容赋值
printf("%d\n", a); // 此时a的值为3
可见变量的读写实际是对内存的读写,而变量名可以辅助我们找到存储变量所在内存的地址,内存地址可以辅助我们操作内存。试想一下,如果没有变量名,只能通过地址操作内存,每次要写一堆的0x123456789abc之类的代码这太难受了。
通过变量名找到内存地址,通过内存地址操作内存,这条线可以简单的类比为:通过姓名找到身份证号,通过身份证号找到具体个人。
有了内存地址就可以正确的读写数据吗?最直接的问题,除非计算机只读取当前地址所在字节存储的内容,但是当存储内容大于1 byte时,计算机并不知道读到什么位置结束。所以当我们为变量申请内存空间时,需指明要申请空间的大小。
那么如何为变量申请内存空间?
内存空间的申请
内存空间的申请分为两种:静态申请与动态申请。
两者的区别在于:
静态申请内存空间,变量存储在栈上,当变量生命周期结束时,内存空间自动释放。动态申请内存空间(除alloca外,使用alloca函数申请的内存空间在栈区,无需手动释放),变量存储在堆上,当变量生命周期结束时,内存需手动释放,否则内存泄漏。
如上文中的int a = 1
就属于静态申请内存空间。C语言中可以通过,malloc、calloc、realloc等函数动态申请内存空间。
void test1() {
printf("----%s----\n", __func__);
int a = 1;
int *p = &a;
printf("a的地址:%p\n", &a);
printf("p的值:%lx\n", (u_long)p);
printf("p的地址%p\n", &p);
}
void test2() {
printf("----%s----\n", __func__);
int *p = (int *)malloc(sizeof(int));
*p = 1;
int a = *p;
printf("a的地址:%p\n", &a);
printf("p的值:%lx\n", (u_long)p);
printf("p的地址:%p\n", &p);
int *q = (int *)malloc(sizeof(int));
printf("q的地址:%p\n", &q);
free(p);
free(q);
}
int main() {
test1();
test2();
return 0;
}
内存空间的申请-1
test1中,变量a与指针变量p都属于静态申请内存空间,两者都存储在栈区,可自动释放。由于p晚于a申请内存空间,所以p位于栈顶,a位于栈底,p的地址小于a的地址(栈顶地址小于栈底地址)。此时将指针变量p的值与变量a的地址相同,因为p本来就存储着a的地址。
test2中,指针变量p与q属于动态申请内存空间,存储在堆区,需手动释放内存。由于q晚于p申请内存,所以堆区中q的地址大于p的地址。变量a属于静态申请内存空间,位于栈区,可以自动释放,所以a的地址小于p的地址(栈区地址小于堆区地址)。
test2中,指针变量p的值为存储着数据1的内存地址,可以通过下面的例子来验证:
u_long test3() {
printf("----%s----\n", __func__);
int *p = (int *)malloc(sizeof(int));
*p = 99;
return (u_long)p;
}
int main() {
u_long t = test3();
int *p = (int *)t;
printf("%d\n", *p); // 99
free((void *)t);
return 0;
}
注意,这里之所以将指针强转成数值,是为了更好的理解指针内部存储的本来就是数值,只不过这个数值是某个变量的地址。显然,test3中的指针变量p未释放,所以函数体外仍然可以获取指针。
变量类型
所谓变量类型其实就是对变量进行种类划分,如同狗分为藏獒、阿拉斯加、哈奇士等不同品种。C语言中除掉存在char、int、float、double等基本数据类型外,还存在enum、struct、union、指针、数组等类型。如果强转变量类型,可能会造成数据丢失、错误甚至crash。
int main() {
short *p = (short *)malloc(sizeof(int));
*p = 999;
char *q = (char *)p;
printf("%d\n", *p); // 999
printf("%d\n", *q); // -25
return 0;
}
999的二进制数据为0000_0011_1110_0111,-25的二进制数据为1001_1001。由于计算机以补码存储数据,所以计算机中存储的数据为1110_0111,这与999二进制数据的后8位相同。显然,小端模式下将存储着999数据的short *
类型指针转成char *
类型指针后,读取的数值为-25。
同一串二进制数据切割后,以不同的方式读取(数据类型),所读出的数据显然是不同的。其实,编码方式也是对二进制数据的切割(填补)。
编码方式
int main() {
char a[] = {-28, -67, -96, -27, -91, -67};
printf("%s\n", a); // 你好
char c = 'x';
printf("%c\n", c); // x
printf("%d\n", c); // 120
return 0;
}
对于变量c为什么以字符形式输出是x
,以数字形式输出是120
,想必都能答出ASCII码
。但是变量a为什么以字符串形式输出的是你好
?
再来看另一个例子:
int main() {
char *s = "你好";
for (size_t i = 0; i < strlen(s); i++) {
printf("%d\n", *(s + i));
}
}
编码方式-1
可见,指针s指向地址的连续空间内,存储的数据就是第一个例子a数组中存储的数据。但是为什么这组数据可以表示你好
?这又要回到上文提到的二进制数据切割了,也就是这一小节的主题编码
。
你
对应{-28, -67, -96}
这组数字,转成二进制为{1001_1100, 1100_0011, 1110_0000},计算机中存储的补码为{1110_0100, 1011_1101, 1010_0000}。也就是说,最终计算机中存储的这组补码数据对应着汉字你
。
我们知道,汉字对应着utf8
编码(也存在utf16、utf32等编码方式,但是并不常用)
你
对应的Unicode为\u4f60
,对应着0800~FFFF这个范围,所以占3个字节。转成二进制后变为:0100_1111_0110_0000,套用utf8格式1110_xxxx_10xx_xxxx_10xx_xxxx
,从低到高用刚才的二进制填补x
,二进制不足位用0填补x
,最后得到utf8编码后的数据1110_0100_1011_1101_1010_0000
,这与{-28, -67, -96}这组数据计算机中存储的转成二进制后的补码相同。
所谓的编码方式就是将字符串转成Unicode码后,以某种格式重新填补形成的新的二进制数据。
字符型数组与字符串
既然说到字符串,顺便说说字符型数组与字符串:
int main() {
char a[] = {'0', '1', '_', 'J', 'a', 'c', 'k'};
printf("----------------\n");
printf("%p\n", a);
printf("%lu\n", sizeof(a));
printf("%s\n", a);
char b[] = "world";
printf("----------------\n");
printf("%p\n", b);
printf("%lu\n", sizeof(b));
printf("%s\n", b);
char c[] = {'h', 'e', 'l', 'l', 'o'};
printf("----------------\n");
printf("%p\n", c);
printf("%lu\n", sizeof(c));
printf("%s\n", c);
return 0;
}
字符型数组与字符串-1
- 首先以静态方式申请了数组变量a、b、c的内存,他们存储在栈区,因为c晚于b晚于a申请内存,所以c位于栈顶,a位于栈底,c的地址小于b的地址小于a的地址(栈顶地址小于栈底),且a、b、c各占7、6、5个字节,c的地址加5后就是b的地址,b的地址加6就是a的地址。可见,a、b、a是一段连续的内存空间。
- 由于字符串以
\0
作为结束符,所以b占用5+1,即6byte。而a和c是以单个字符形式定义的字符数组,所以a和b仅占用数组个数的字节数,分别为7byte和5byte。 - 最后一个问题,为啥a、b输出的是赋值数据,而c输出的是
helloworld
?c的赋值明明是{'h', 'e', 'l', 'l', 'o'}
这个数组。答案其实就是上边1、2两小点的结合,由于c与b是一段连续的内存空间,且c不包含\0
字符串结束符,当把b按字符串格式输出时,会在以b地址为起始的连续内存空间寻找\0
,所以b输出helloworld
。再回头看a数组,因为a处于栈底,当没有\0
字符串结束符时,仅输出数组内定义的数组,不会额外增加一个字符用于结束当前数组。
再来看个例子:
int main() {
char a[] = {'t', 'e', '\0', 's', 't'};
printf("----------------\n");
printf("%lu\n", sizeof(a));
printf("%s\n", a);
char b[] = "te\0st";
printf("----------------\n");
printf("%p\n", b);
printf("%lu\n", sizeof(b));
printf("%s\n", b);
return 0;
}
字符型数组与字符串-2
虽然a仍然占用定义的数组个数5byte,但是并未输出全部数组内容,而是到\0
字符结束,b同样到\0
字符结束,两者均输出te
。由于b本身定义为字符串,所以末尾会多出1byte用于添加\0
作为字符串结束符,占用6byte。
\0
作为字符串结束符的必要性
由于C语言没有专门的字符串类型,当我们通过字符型数组或者字符型指针来定义字符串时,很容易数组或者指针越界,以\0
作为字符串结束符虽然会增加1byte的存储量,但是此时可以通过判断当前数组或者指针内容是否为\0
来甄别字符串是否结束,从而避免越界情况的发生。
但是,为什么是以\0
而不是其他字符作为字符串结束符?\0
的ASCII码为0,作为字符输出是空白,此时添加到字符串结尾并不影响字符串本身的显示与使用。同时用\0
作为占位符,占位符的副作用相对较小,因为通常我们并不需要输出或显示一个空白字符。但是如上文例子所示,当一个字符串中间包含\0
时,字符串输出会在\0
除被截断,无法输出完整的原字符串。
如果想输出\0
这种字符串,可以这样写\\0
:
int main() {
char a[] = "te\\0st";
printf("%s\n", a); // te\0st
return 0;
}
数组、指针、数组指针及指针数组
-
一维数组与一级指针
一维数组与一级指针其实没什么好说的,数组名就是数组的起始地址,并且是直接寻址,而指针则是通过*
间接寻址。由于数组名就是数组的起始地址,也可以用*
与数组名
的组合方式,进行间接寻址(与指针使用相同)。除此之外,数组是数组,指针是指针,两者是不同的类型,仅仅是由于数组名就是数组的起始地址导致的部分使用方式重叠。 -
一维数组名与一维数组名取地址
int main() {
int a[3];
printf("a:%p\n", a);
printf("a+1:%p\n\n", a + 1);
printf("&a:%p\n", &a);
printf("&a+1:%p\n", &a + 1);
return 0;
}
数组、指针、数组指针及指针数组-1
虽然a与&a的地址相同,但是a+1与&a+1的结果并不同,根本原因在于两者不是同一种类型。
a相当于&a[0],他的类型是int *
,a+1表示首地址+sizeof(int),所以a+1在首地址的基础上向后移动4个字节。
&a的类型为int (*)[3]
,是个数组指针,他指向包含3个int元素的一维数组,&a+1表示首地址+sizeof(a),所以&a+1相当于在首地址的基础上向后移动12个字节
- 二维数组与数组指针
如果真正理解了一维数组与一级指针间的关系,二维数组与数组指针间的关系不在话下:
int main() {
int a[2][3] = {1, 2, 3, 4, 5, 6};
int (*p)[3] = a;
printf("%d\n", (*p)[0]);
printf("%d\n", (*p)[1]);
printf("%d\n", (*p)[2]);
printf("%d\n", (*(p + 1))[0]);
printf("%d\n", (*(p + 1))[1]);
printf("%d\n", (*(p + 1))[2]);
return 0;
}
数组、指针、数组指针及指针数组-2
例子中的p是个数组指针,他指向包含3个int元素的一维数组。当把二维数组a的首地址赋值给数组指针p时,显然(*p)[0]到(*p)[2]对应访问的是a[0][0]到a[0][2],由于变量p的类型为int (*)[3]
,本质是个数组指针,所以p+1相当于一维数组中的&数组名+1
。因此p+1移动sizeof(int*3)个数,从而*(p+1)[0]到*(p+1)[2]指向a[1][0]到a[1][2]
- 数组指针与指针数组
两者的区别很简单:数组指针是个指针,他指向一个数组;指针数组是个数组,他内部存储着一组相同类型的指针;
这两句话慢慢意会吧☺
你可能会有这种疑问,这样不能描述数组指针吗?
数组、指针、数组指针及指针数组-3很遗憾,看起来好像是一个指针指向了一个数组,然而int *
只能表示指针的特性,编译器无法得知指针指向的是数组(反而告诉编译器指向的是int),所以数组指针的正确表达方式只能是类型 (*变量名)[数组个数]
这种格式
- 二维数组与二级指针(真没什么关系)
为了避免混淆,最后再来说一下二维数组与二级指针:
int main() {
int a[2][3] = {1, 2, 3, 4, 5, 6};
int (*p)[3] = a;
printf("%d\n", **p); // a[0][0]
printf("%d\n", *(*p+1)); // a[0][1]
printf("%d\n", *(*(p+1))); // a[1][0]
printf("%d\n", *(*(p+1)+1)); // a[1][1]
}
数组、指针、数组指针及指针数组-4
如你所见,仅此而已。这并不表示二维数组对应着二级指针,显然我们定义的p是个数组指针而非二级指针。之所能通过**p
这种形式来访问二维数组,可以通过如下伪代码来表示:
*p == int b[3]
int b[3] == a[1][0]
*p+1 == b+1
b+1 == a[0][1]
*(p+1) == &b+1
&b+1 == a[1][0]
*(p+1)+1 == &b+1+1
&b+1+1 == a[1][1]
最根本原因就在伪代码的第一行,*p
指向包含3个int元素的一维数组,而数组名又可以当做地址进一步访问。虽然如此,二维数组与二级指针仍然没有一毛钱关系。
Have fun!
网友评论