那些年,C给我们留下的思考——换一种角度重释C语言
探索环境及工具
环境:VC6
工具:VC++6.0
探索内容
变量篇
全局变量和局部变量到底是什么,为什么全局变量可以不用初始化,而局部变量必须初始化
首先,我们需要回顾一下,什么是全局变量,什么是局部变量:
紧接着就是我们的探究部分,首先按照定义定义出全局变量和局部变量:
#include <stdio.h>
int x; //全局变量x
int add(){
int y; //局部变量y
y=0;
return 0;
}
int main(){
add();
getchar();
}
然后在add()和getchar()处下断点调试如下:
查看汇编如下:
add()函数未调用前,如图中汇编视图可知,此时全局变量已有初值,由此可知全局变量在编译时,编译器就已经在全局变量区分配好了内存空间,且编译器也为其完成了相应初始化,而y由于函数add()还未被调用,未在栈空间分配内存,故显示not found:
add()函数调用之后,定义了局部变量y,此时编译器为变量y划分一个栈空间,而y的值为栈空间里的一个随机数据,未被初始化,不能使用(虽然vc6使用未被初始化的局部变量不会暴错,但使用无意义)
函数内执行到初始化语句后,y被成功初始化为0,数据初始化完成。
add函数执行结束后,编译器回收其对应栈空间,局部变量y,也重新回到not found 状态:
由上述的汇编探究,我们应该能更加深切的理解什么是局部变量,什么是全局变量,已经为什么我们可以不用初始化全局变量,而对于局部变量,必须初始化之后,才能使用,答案都在我们的探究过程中,总结如下:
无符号和有符号到底是怎么辨别
在我接触计算机语言以来,无符号和有符号的使用一直挺模糊的,但是对于它的定义,我确记得很清楚,就是有符号数最高位就是符号位,为1就代表负数,反之代表正数,而对于无符号数,就没有符号位之说,数为多大就表示多大,这就是我之前对有符号数和无符号数的理解。
学而不思则罔,在学习的路上,我们都有各种不明白和不理解,而对于有符号和无符号,我也曾有过一个问题,既然有无符号数,也有有符号数,那么计算机到底怎么区分一个无符号数和有符号数,到底怎么存储无符号数和有符号数的呢?
下面就让我们从汇编的世界,来一探究竟,计算机的世界里有符号数和无符号数到底有什么不一样。
我们就以图中的值-1来探究,众所周知,计算机存储数是按照补码的方式来存储的,而-1的补码全为1,而int类型长度为4个字节,也就是32位,转为16进制表示就为0xFFFF_FFFF
#include <stdio.h>
int main(){
int x = -1; //默认 signed int
unsigned int y = -1;
printf("有符号:%d \n无符号:%u\n",x,y);
printf("<-------------------------->\n");
int a = 0xFFFFFFFF; //默认 signed int
unsigned int b = 0xFFFFFFFF;
printf("有符号:%d \n无符号:%u\n",a,b);
}
打印结果如下:
通过汇编查看如下:
由上述汇编代码部分可知,有符号数和无符号数的存贮方式几乎一模一样,从汇编上看没有差异,若真如此,那我们换一种打印方式来验证一下,将有符号数,按照无符号数的方式打印,将无符号数,按照有符号数打印,其结果如下:
如图可知,果如我们猜测的那样,不管是有符号数,还是无符号数,都是我们人为的一种规定,计算机只知道存贮,实际上,计算机基本不区分有符号数和无符号数,这些都是人为的规定,数都是按照补码的形式存储在计算机中,判断一个数是有符号数还是无符号数,完全是人的主观行为,计算机本身并不区分,它只将数的补码存储,怎么读取,按照有符号的方式,还是无符号的方式,都是取决于使用它的人怎么看待。
但是无符号数是不是就和有符号数真的没有区别呢?在存储上确实如此,但是在类型转换和比较时,是有区别的,实例代码如下:
- 类型转换时,有符号数需要根据符号位补0或者1,而无符号数直接补0即可
比较时,示例如下:
上述总结如下:
同样都是4个字节,为什么float比int存储的范围大那么多
在我们学习C语言数据类型的时候,我相信很多人都会有疑问,为什么int和float都是4个字节,存储数据的范围却差距那么大。这是为什么呢?
我们都知道,整数是按照补码的形式存储在计算机中,那么浮点型呢?
通过愈来愈深入的学习,我们知道浮点型数据和整数类型的数据是由于存储的方式不同,才导致产生如此的差别。
那么浮点型数据到底采用什么样的存储方式呢?浮点型数据在存储方式上都是遵从IEEE编码规范的,存储方式如图所示:
那具体是如何存储的呢?
首先,我们需要先将需要存储的十进制数转为二进制,整数转二进制很简单,是学计算机的基本功,我们平时用的很多,转的方式也有很多,最经典的就是除二取余法,但是小数转二进制,用的不是很多,笔者就示例一下小数转二进制的方式及一个现象。
例如小数0.25和0.4,转为二进制过程如下:
由上述转化过程可知,小数不一定能完整的转化为二进制,如0.4,这也就意味着,浮点型数据描述小数时,不是百分之百准确,是有一定的精确位数,也就是精度。
那将一个十进制数转化为二进制数后,又怎样按照IEEE编码规范存储呢?我以浮点数8.25,示例如下:
如上图所示,浮点数8.25按照IEEE编码规范存储值为0x41010000,通过汇编验证如图所示:
由上述汇编可知,浮点型数据确如我们所描述的那样,采用IEEE编码规范存储,接下来,我们再看看浮点数的精度:
最后再来讨论我们的问题,也就是浮点数存储的值的范围,由于浮点数采用IEEE编码规范存储,故其存储值的范围取决于其指数范围,float的指数部分除去符号位为7位,也就是2^7,指数范围为-127~+128,故其存储范围为-3.40E+38 ~ +3.40E+38,而doubel指数范围为 -2^1023 ~ +2^1024,也即-1.79E+308 ~+1.79E+308。
C语言中的枚举类型是什么,为什么要有枚举类型
C语言中为什么要有一种枚举类型,直接使用宏定义不行么?
#define命令虽然能解决问题,但也带来了不小的副作用,导致宏名过多,代码松散,看起来总有点不舒服。C语言提供了一种枚举(Enum)类型, 枚举类型是预处理指令#define的替代,枚举与宏非常相似。 宏在预处理阶段用相应的值替换名称,枚举在编译阶段用相应的值替换名称。不仅可以解决宏定义带来的弊端,而且枚举类型还可以用来限制用户的输入,在很多开发场景中,我们都会用到枚举类型来进行限制。
探究如下:
通过汇编查看如下:
如上述所示,确如查阅资料所述,枚举在编译阶段用相应的值替换名称。
在使用C语言枚举类型时需要注意以下几点:
函数篇
函数的参数到底是怎么传递的,值传递和地址传递到底是怎么一回事
首先,我们回顾一下,值传递和地址传递是怎么一回事.
之前,我们都是只是知道值传递和地址传递,理解和使用,并没有真正的通过汇编的角度来认识到底什么是值传递和地址传递,那么笔者从一个简单的交换示例,来从汇编的角度来真正理解何为值传递和地址传递。
示例如下:
#include <stdio.h>
void swap_value(int a ,int b){ //值传递
printf("交换前(swap_value函数中):\n a=%d \n b=%d\n",a,b);
int tmp;
tmp = a;
a = b;
b = tmp;
printf("交换后(swap_value函数中):\n a=%d \n b=%d\n",a,b);
}
void swap_addr(int* a ,int* b){ //地址传递
printf("交换前(swap_addr函数中):\n a=%d \n b=%d\n",*a,*b);
int tmp;
tmp = *a;
*a = *b;
*b = tmp;
printf("交换后(swap_addr函数中):\n a=%d \n b=%d\n",*a,*b);
}
int main(){
int a=5;
int b=10;
printf("交换前(main函数中):\n a=%d \n b=%d\n",a,b);
swap_value(a,b);
printf("swap_value交换后(main函数中):\n a=%d \n b=%d\n",a,b);
swap_addr(&a,&b);
printf("swap_addr交换后(main函数中):\n a=%d \n b=%d\n",a,b);
getchar();
}
运行结果如下:
从C语言代码执行结果我们也可以清晰的看出,值传递并不会影响真正的实参值,而地址传递传递,形参的变化会使实参也发生相应的变化。
那么接下来让我们从汇编的角度来一探究竟:
从汇编指令可以看出,值传递的时候使用的是move指令,直接将参数的值压入栈中传递,而地址传递的时候,是使用lea指令,将参数的地址压入栈中进行传递,故而,形参在取得实参地址之后,与实参共同拥有一段内存空间,形参的变化也就是实参的变化。
通过上述探究,总结如下:
函数中涉及的堆、栈到底又是什么
谈及堆栈,我们就会想到C语言中到底有多少内存模型,通过查阅资料,简单了解如下:
接下来就让我们从汇编的世界,加深一下对堆栈的理解:
#include <stdio.h>
#include <malloc.h>
int add(int a ,int b){
return a+b;
}
int main(){
add(5,10);
int* p=(int*)malloc(sizeof(int));
if(p)
printf("Request Merroy Error!");
else
printf("Memory Allocated at: %x/n",p);
free(p);
getchar();
}
(栈)汇编视角如下:
(堆)视角如下:
调用malloc从堆中申请内存:
堆中申请内存完成:
总结上述操作如下:
分支语句篇
同样是分支语句,为什么条件多的时候使用Switch比使用if-else效率更高
if-else和switch是我们都很熟悉的分支语句,那么为什么条件很多时候,使用switch的效率更高呢,让我们从汇编的角度来深入理解一下:
#include <stdio.h>
void My_If(int x){
if(x==1)
printf("A\n");
else if(x==2)
printf("B\n");
else
printf("S\n");
}
void My_Switch(int x){
switch(x){
case 1: printf("a\n");break;
case 2: printf("b\n");break;
case 3: printf("c\n");break;
default:printf("s\n");
}
}
int main(){
My_If(1);
My_Switch(2);
getchar();
}
(条件少)if-else汇编部分:
(条件多)if-else汇编部分:
(条件少)switch汇编部分:
(条件多)switch汇编部分:
通过上述汇编代码可知:
循环语句篇
while do、do while和for的汇编有什么不一样
while-do、do-while、for是我们C语言种使用最多的循环语句,让我用一共简单的C程序来开启汇编的探索之旅:
#include <stdio.h>
void My_While_Do(int x){ //while-do循环
while(x--){
printf("While_do: \n x=%d\n",x);
}
}
void My_Do_While(int x){ //do-while循环
do{
printf("Do_While: \n x=%d\n",x);
}while(x--);
}
void My_For(int x){ //for循环
for(;x>0;x--){
printf("For: \n x=%d\n",x);
}
}
int main(){
My_While_Do(3);
My_Do_While(3);
My_For(3);
getchar();
}
汇编视角如下:
-
while-do:
-
do-while:
-
for:
巧妙探究for循环的执行流程
对于for循环,是我们比较喜欢使用的一种循环,但是关于它的执行流程,一开始学的时候有点懵,现在,让我们通过一种巧妙的方式来证实我们那些年熟记的for循环执行顺序:
#include <stdio.h>
void R1(){
printf("R1 \n");
}
int R2(){
printf("R2 \n");
return -1;
}
void R3(){
printf("R3 \n");
}
void My_For_Route(){
for(R1();R2();R3()){
printf("R4 \n");
}
}
int main(){
My_For_Route();
getchar();
}
数组篇
数组和指针的区别与联系
我们都知道,数组有时候使用起来和指针很相似,数组名就代表数组首地址,编译器把数组名标识为首地址元素地址的别名。
查阅资料可知,指针与数组最本质的区别就是:
既然数组名是常量,那么就会有以下四种区别:
-
赋值问题
-
自增自减操作
-
内存分配问题
汇编角度:
-
锯齿数组问题
汇编视角:
指针数组和数组指针汇编的理解
指针数组和数组指针,是我们很容易混淆的两个概念,从根本上来剖析容易混淆的原因,就是符号的优先级不熟悉,记不住"*“和”[]“的优先级哪一个更高,因而就对数组指针和指针数组的概念模糊不清。
知道了”*“和”[]"的优先级,理解数组指针和指针数组就很简单了,下面让我们从简单的示例以汇编的角度来进一步剖析:
#include <stdio.h>
int main(){
int arr[6] = {1,2,3,4,5,6};
int *p1[6]={ //因为"[]"的优先级比"*"高,故p1先与"[]"构成数组,即为指针数组
&arr[0],&arr[1],&arr[2],&arr[3],&arr[4],&arr[5]
};
int (*p2)[6]=&arr; //"()"的优先级比"[]"高,故p2先与"()"构成指针,即为数组指针
getchar();
}
汇编视角:
由上述分析探究可知:
结构体篇
为什么结构体作为参数传递的时候,通常都是选择传递结构体指针
为什么我们开发编程的时候,一使用到结构体作为参数传递,为什么几乎哦都是使用其对应的结构体指针,不能像使用数组一样,直接传递结构体名字么?让我们用一个简单的示例来一探究竟。
#include <stdio.h>
struct Student{
int id;
char gender;
double score;
}s1;
void Student_Print_Value(Student s){
printf("Student信息如下:\n %d\n %c \n %f\n",s.id,s.gender,s.score);
}
void Student_Print_Addr(Student* s){
printf("Student信息如下:\n %d\n %c \n %f\n",s->id,s->gender,s->score);
}
int main(){
s1.id=1;
s1.gender='m';
s1.score=520;
Student_Print_Value(s1);
Student_Print_Addr(&s1);
getchar();
}
汇编角度:
从上述汇编角度,我们可以清晰的发现:
结构体的大小为什么老是和我想得不一样
结构体的大小为什么老是和我们想的不一样,学习结构体的时候,使用sizeof()函数输出结构体大小的时候,总是和我们预估的大小对不上,这又是怎么一回事呢?
通过深入的学习和查阅资料,我们知道了编译器在优化时,都会默认采用一种字节对齐的方式来进行优化,是一种以空间换时间的优化方法。关于字节对齐,网上有很多参考资料,笔者就以最通俗易懂的概括如下:
那么结构体的成员也遵守字节对齐么?
让我们通过C与汇编结合的方式去探究。
#include <stdio.h>
struct Student{
int id; //int 4字节
char gender; //char 1字节
}s1; //sizeof(Student)=4+1=5字节?
struct Teacher{
_int64 id; //_int64 8字节
char gender; //char 1字节
}t1; //sizeof(Teacher)=8+1=9字节?
int main(){
s1.gender=100;
s1.gender='m';
t1.id=101;
t1.gender='m';
printf("sizeof student:\n %d %d\n",sizeof(Student),sizeof(s1));
printf("sizeof Teacher:\n %d %d\n",sizeof(Teacher),sizeof(t1));
getchar();
}
输出结果如下:
汇编视角:
如上述汇编所示:
那这种字节对齐的方式,我们可以改变么?就是不想采用默认的以空间换取时间的字节对齐方式
当然可以,但我们对空间要求较高的时候,可以通过#pragma pack(n)来改变结构体成员的对齐方式,示例如下:
汇编视角:
我们可以发现:
指针篇
指针到底神奇好用在哪里
谈到C语言,最灵活,最复杂的莫过于指针了,它是C语言种最重要、最灵活的一种结构。
那么指针到底有何神奇之处,让所有学C的人为之着迷。
查阅资料总结如下:
其它网上相关使用指针可以带来如下的好处:
指针和字符串之间的关联探究
字符串也是C语言中使用较多的一种类型,那字符串和指针之间又有什么联系呢?常量区又与字符串之间又是怎么一回事呢?
让我们从汇编的角度来探究这些问题
#include <stdio.h>
int main(){
char str[]={'A','B','C','D','\0'};
char str1[] = "ABCD";
char* str2 = "ABCD";
printf(" %s\n %s\n %s\n",str,str1,str2);
getchar();
}
汇编视角:
从上述汇编,我们可以看出:
那么这样是不是意味这,我们在使用指针方式的时候,由于直接指向的是常量区的指定字符,故只能进行读操作,因为常量区默认是不能修改的,故下面进一步验证我们的判断。
上述使用汇编探究的时候,出现了内存访问错误,那这确实如我们所分析的那样,指针存储的是常量区的地址,故只能读,不能直接进行修改操作。
指针取值的两种方式
关于指针取值的方式,可以采用’*()‘和’[]',那么两者使用的时候有什么差异和联系呢?
让我们从汇编的角度来深入剖析理解一下。
#include <stdio.h>
int main(){
int x = 5;
int* p =&x;
int** p1 = &p;
int*** p2 =&p1;
printf(" %d\n %d\n %d\n %d\n %d\n %d\n",p[0],*p,p1[0][0],**p1,p2[1][2][3],*(*(*(p2+1)+2)+3));
//p1[0][0]<==>*(*(p+0)+0)==**p
getchar(); //p2[1][2][3]和*(*(*(p2+1)+2)+3)只是为了测试从汇编观察
}
汇编视角如下:
从上述汇编视角可以看出:
调用约定是什么
首先,我们需要了解调用约定指的是什么,调用约定就是告诉编译器,怎么传递参数,怎么传递返回值,以及怎么平衡堆栈。
常用的几种调用约定:
调用约定 | 参数压栈顺序 | 平衡堆栈 |
---|---|---|
_cdecl | 从右至左入栈 | 调用者清理栈 |
_stdcall | 从右至左入栈 | 自身清理栈 |
_fastcall | ECX/EDX传送前两个参数,其余从右至左入栈 | 自身清理栈 |
查看VC6.0默认采用的是第一种调用约定方式:
以下用简单的示例,以汇编的角度来深入理解剖析调用约定:
#include <stdio.h>
int add(int x, int y){
return x+y;
}
int main(){
int r = add(5,10);
getchar();
}
(默认_cdecl方式)汇编视角:
(_stdcall)视角:
(_stdcall)汇编视角:
(_fastcall)视角:
(_fastcall)汇编视角:
通过上述汇编过程探究,与查阅的资料所述一致,除了上述常见的三种调用约定,还有一些其他的调用约定,可以自行探究。
结语
C语言是我们很多大学生接触的第一门语言学科,那时的我们,学起来懵懵懂懂,很多东西理解不了,都是靠着记忆的方式记下来,但是,随着我们学习的深入,我们开始可以探究C语言种每条规则背后的奥秘,解开原来学习时留下的疑惑,这何尝不是一种深刻而又美妙的进步呢。