C语言遐想(瞎想)

导读

本文为笔者对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
  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是一段连续的内存空间。
  2. 由于字符串以\0作为结束符,所以b占用5+1,即6byte。而a和c是以单个字符形式定义的字符数组,所以a和b仅占用数组个数的字节数,分别为7byte和5byte。
  3. 最后一个问题,为啥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!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,884评论 6 513
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,212评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 167,351评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,412评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,438评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,127评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,714评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,636评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,173评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,264评论 3 339
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,402评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,073评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,763评论 3 332
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,253评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,382评论 1 271
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,749评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,403评论 2 358

推荐阅读更多精彩内容

  • 指针是C语言中广泛使用的一种数据类型。 运用指针编程是C语言最主要的风格之一。利用指针变量可以表示各种数据结构; ...
    朱森阅读 3,449评论 3 44
  • 第十章 指针 1. 地址指针的基本概念: 在计算机中,所有的数据都是存放在存储器中的。一般把存储器中的一个字节称为...
    坚持到底v2阅读 1,073评论 2 3
  • 1. 变量 不同类型的变量在内存中占据不同的字节空间。 内存中存储数据的最小基本单位是字节,每一个字节都有一个内存...
    C语言学习阅读 1,283评论 0 4
  • 版权声明:本文为 gfson 原创文章,转载请注明出处。注:作者水平有限,文中如有不恰当之处,请予以指正,万分感谢...
    gfson阅读 3,024评论 0 6
  • 欢迎来到不可能的世界! 本期编辑:叶子露Lucya 1.编者的话 感谢大家关注奇思妙想! 奇思的编辑们仍然在兢兢业...
    叶子露Lucya阅读 416评论 14 5