学习C有一阵子了,走了不少弯路,把太多的精力集中在语法上,感觉语法就是基础,语法学了半年,到最后还是不会编程,而语法也随着时间的流逝忘得差不多啦,后来才知道,语法是最次要的,重要的是编程的思想和对机制的理解,实践方能出真知。最近感觉小有成就,所以自己也想拽一下。因为涉及到了操作系统底层的工作机制,以及汇编语言的知识,所以如果里面的错误惹怒了哪位前辈,还请批评指正,包涵一下我这个胆大包天,舞文弄墨,不知好歹的伪高手。
好的,言归正传。今天讨论的话题是C语言的数据处理和指针。
大家对卫生纸肯定不陌生,第一印象是什么,擦屁股,呵呵,反映还挺快,但不可否认的是现在人们用它擦嘴也非常流行!这就对啦,其实计算机的本质无非就是从内存中取出数据,说白了就是把那些让人“兴奋”的二进制数取出来,然后为你所用,你想干什么就干什么,你想让它是什么它就是什么,就看你对这些怎么操作啦,好像白面一样,弄圆了,蒸锅里,出来就是馒头,弄得又长又细,放水里,出来就是面条。
那我们就从熟悉的printf()函数说起吧,探讨一下它的工作机制。
一个非常简单的例子:
printf(“%c %d %c %d”,‘L’,79,86,69);
1,这个函数和C有什么关系?
2,这个函数有几个参数?
3,这些参数放在了哪里?
4,找到之后如何输出他们?
好,我们依次解决。
1, 很多人认为printf()是C特有的特性,是C的一部分,错啦,其实printf()和C没什么
关系,你可以这么认为,它就是为了方便你和程序通信而使用的一个工具,就是一个函数,这个函数是由程序员按照C标准编写的,是额外编写的,是额外提供给你的函数库的一部分,就像是C++的类库一样,这些函数为你开发程序提供了方便,当然如果你是高手的话,在你开发程序时完全可以不用这些函数库,什么细节都自己处理,自己编写。如果你感觉这个printf()使用起来非常不方便,用户体验极差,好啦,你可以编写适合自己的I/O函数,封装起来,为你所用。
2, 对于大多数初学者来说,会认为它有4个参数,为什么呢?因为我们的目的是要在屏幕
上输出4个数字。其实不然,当我们找到这些参数时,真正对这些数据如何处理关键还要看第一个参数——字符串参数,因为它决定了这些数据以什么形式输出,所以说它有5个参数。
3, 那么这些参数放在哪了?我们知道这些程序计算机是不认识的,需要一个编译连接的过
程,最终生成一个.exe的二进制文件,然后我们用window下command提供的debug –u 对该文件进行反汇编(具体细节操作不再说,可以研究一下王爽老师《汇编语言》综合研究,找到代码在什么地方),查看一下它的汇编代码,发现开始的几条指令是将参数列表中的所有参数从右至左依次入栈(系统提供的栈机制,PUSH AX),以保存数据,注意第5个参数并不是将字符串入栈,而是将指向字符串的指针入栈,占用四个字节,字符串放在了一个叫做堆的内存空间中,暂时不讨论堆,否则就跑题啦。
4, 知道数据所在的位置啦,如何对栈内的数据进行操作呢?在反汇编时,你肯定会注意到
这两行代码, PUSH BP MOV BP ,SP 大家知道SP是指向栈顶的,现在bp也指向了栈顶,我们就可以利用bp对栈中的数据进行操作啦,*(int *)(_BP + 2 +4); 这样就取到了‘L’这个数(bp的值入栈占用2个字节,我们还要跳过指针占用的4个字节,所以是_BP + 2 + 4,这只是一个地址,前面的(int *)是将它转换成指向一个字空间的指针,最前边的*是取其里面的内容);我们要从屏幕上输出,所以我们找一个显存空间输出它即可,
*(char far *)(0x0b8000000 + 160 * 10 + 40 * 2) = *(int *)(_BP + 2 +4),这时候屏幕第10行第40列就会出现一个大写的‘L’。
前面几条都是铺垫,下面我们就进入真正的主题,就拿刚才的‘L’说事!为什么打印的是L呢?因为我们都默认%c就是要输出一个字符,而我刚才的操作确实也是这么做的,让它直接在显示器上显示,既然我们是从内存中将它读取了出来,那么它在内存中式怎么存的呢?很容易知道,‘L’对应着十进制的76,二进制的01001100,它就是以二进制的01001100在内存中存放的。开头我们已经说过了,数据在内存中,就看你怎么用,把它当什么用。还是这个‘L’,还是这个01001100,取出它之后,我们现在不想让它在屏幕上显示‘L’,想把它对应的76输出,那么我们需要做的工作就是,读取数据,通过一定的算法,将它转换为字符7和6,然后依次在屏幕的合适位置输出,同样的道理,把它对应的二进制数原封不动的输出也是轻而易举的。
整体的思路是:用这个指向字符串的指针,扫描到%后,根据后面的字符判断应该以怎样的格式输出,从栈中读取数据,通过指定的算法将其输出。
好的,让我们挑战一下权威。假设为我们提供的printf()函数使用起来非常不方便,用户体验超差,那么我们是否应该写一个输出函数取而代之呢?必须的!!什么%c,%d,%s,%x,\n,\t,见鬼去吧!
行,试试吧。读到这里大家不要异想天开,因为我们是在用C编写,所以基本语法规则还是要遵守的,如果你还想打破C对你的束缚,那么你可以自己尝试开发一个编译器,规定新的语法,这时候新的编程语言就诞生啦,开个玩笑,我想很少有人这么做。下面是我写的一个简单的例子,没什么创新,大家可以发挥想象,DIY一个属于自己的输入输出函数。
/*封装的print函数*/
#ifndef PRINT_H_
#define PRINT_H_
void print(char *,...);
void change(int); /*递归函数,实现将数据转换成十进制,依次输出每一位*/
static int charnum = 0; /*定义输出的字符个数*/
static int stacknum = 0; /*定义从栈中取数据的次数*/
void print(char * s,...)
{
int result;
char ch;
while(*s != 0)
{
if(*s == ' ')
{
*(char far *)(0xb8000000 + 160*10 + 80 + 2*charnum) =' ';
*(char far *)(0xb8000000 + 160*10 + 81 + 2*charnum) = 0;
charnum++;
}
if(*s =='%')
{
if(*(s+1) == 'c')
{
*(char far *)(0xb8000000 + 160*10 + 80 + 2*charnum) = *(int *)(_BP + 6 + 2*stacknum);
/*加6是因为要跳过字符指针和BP*/
*(char far *)(0xb8000000 + 160*10 + 81 + 2*charnum) = 2;
/*乘以2是因为一个字符在显存中,要占两个字节,低位字节存放字符,高位存放属性,将2赋值给它,是要让它显示为绿色*/
stacknum++;
charnum++;
}
else if(*(s+1) == 'd')
{
result = *(int *)(_BP + 6 + 2*stacknum);
change(result);
stacknum++;
}
}
s++;
}
}
void change(int result)
{
int k;
if(result != 0)
{
k = result % 10 + 0x0030;;
change(result/10);
*(char far *)(0xb8000000 + 160*10 + 80 + charnum + charnum) = k;
*(char far *)(0xb8000000 + 160*10 + 81 + charnum + charnum) = 2;
charnum++;
}
}
#endif
一开始我们就说,内存中都是数据,就看我们怎么用啦,而前面一大段所讲的只不过是数据输出格式的转换,显然没有体现C的灵活性,下面我们就对C语言中的一个数据赋予不同的意义,我相信这就是C的本质。
C语言中有int ,float,char ,unsigned char 等等,还有两个非常重要的就是结构体类型和指针类型。但是内存是不区分这些的,你需要做的就是找到合适的内存空间,取出自己认为合适的字节数,进行自己认为合理的操作。始终认为你处理的就是数据。
0X2000281
对,一个再平常不过的16进制数据,在内存中存放,占用了四个字节。它有什么意义呢?没意义,但是我们现在就要利用它,使它有意义。
1, 我们要认为他是int类型的,好的,太方便啦,定义两个变量int a,b;取第一个字节
给了a,取第二个字节给了b,a为 0X200,b 为0X2F0。当然现在你可以以任何格式打印出来。
即使你当初存储的时候明明是存储了占用了四个字节空间的float,那有怎样,关键是你现在处于某种目的,想把她按照int类型读取出来,以实现你的目的。
2, 假如你想把它当做是四个字符型的数据,并且想输出他们,行,每次读取一个字节的
数据,注意X80在我们经常使用的编译器中是没有对应的字符的,所以我们可以输出它对应的十进制数,注意了,它的最高位是1,如果当做是有符号数的话,我们就输出一个负数,如果当做无符号数的话,我们就输出类似于unsigned char 的数字。还有一种情况,就是我们已经取出了0X281,但是我们不想把它当做一个int型的数据,而是一个char型的,怎么办,很容易的,(char)0X281,C编译器会自动转换,但是存在数据的丢失,就把高位舍去,只留地位,如果想把一个int型的数据转换成两个char型的话,我们就可以通过C的位运算,先保存高位,在依次处理就可以啦,其他类型之间的转换一是如此,在这就不多说了。
3, 我们还可以把它当做一个结构体,分别存储了一个指针变量0X200和一个int类型的数据0X281。道理是一样的。
终于过渡到指针啦,那么我们怎么将0X200当做指针来用呢?在C里面指针也是有类型的,对,看它指向的是什么类型的数据,现在我们使它指向int类型的,(int *)0X200,这样我们就把一般的数据转换成了指向int类型数据的指针变量,指向0X200这个空间,*(int *)0X200,就代表了该空间里面的int型数据,*(int *)0X200 = 1;就是该空间的内容赋值为1。int * p = (int *) 0X200,指针变量p的值就是0X200,指向了0X200这个空间,p++,因为指针加1是指向下一个该类型数据的单元,这时p的值是0X202。类推,(char *)0X200,*(char *)0X200,(float *)0X200,(struct stu *)0X200都一样。定义一个指针p来指向他们的话,指向的都是该类型数据第一个字节单元,读取时根据需要读取。(这里介绍的底层的机制,其实我们真正读取结构体成员时p ->data就行,编译器会帮你处理一些细节。有人肯定会问,既然是这样我们为什么还要关注这些细节呢?C有陷阱也有缺陷,你的编程之路不会一帆风顺,你会经常遇到根本看不出问题,却无法正确执行的程序,那就要看你的程序到底对什么数据,做了什么处理,这种调试方法是非常令人恶心的,这是你进阶为高手的必经之路。注:我是个菜鸟)。
为了有一个深刻的印象和理解。我们实现一个这样的程序,很简单,在屏幕上输出8个a字符。
main()
{
int n;
for(n = 0;n < 8;n++)
printf("a ");
}
这个程序,别说计算机专业的啦,让城建院那些老土们写,用脚趾头思考一下,马上就能实现,太简单啦。但是,刚才已经说了,我们要对数据进行灵活的应用,这里编译器却为我们做了我们不想让它管的事,定义了一个n,又调用了一个函数。我们希望在一个非常简陋的开发环境里,自己处理细节,掀开层层面纱,从本质上解决问题。好的,看下面一段代码:
main()
{
说明一下,下面的程序的编译环境是TC2.0,因为ANSI C摒弃了far关键字。
for(*(char far *)0x200 = 0;*(char far *)0x200 < 8;(*(char far *)0x200)++)
*(int far *)(0xb8000000 + 160 * 10 + 40 * 2 + *(char far *)0x200 * 2) = 'a' + 0x200;
}
解释一下上面的代码,(char far *)0x200是在内存0x200出开辟了一个空间,相当于上一个程序的n,存放循环次数,*(int far *)(0xb8000000 + 160 * 10 + 40 * 2 + *(char far *)0x200 * 2)是在显存中找到了一块地方(显示出来就是屏幕的中间位置),把数据写进去。'a' + 0x200,低位存储字符的ASCII码,高位存属性,0x200是16进制数,相当于把2存储在了高位,在这里是绿色的意思。在这里我们没有使用任何变量,数据是我们定义的,内存空间是我们开辟的,仔细类比一下,猛然醒悟,这就是C的本质之一。
为了方便理解,我们将其演变一下,也是为了方便阅读,注意,前提是你一定要读懂上面的代码。
#define n (*(char far *)0x200)
#define p (int far *)(0xb8000000 + 160 * 10 + 40 * 2 )
main()
{
for( n= 0;n < 8;n++)
*(p + n) = 'a' + 0x200;
}
如果挡住两个预定义,是不是似曾相识?必须的!
下面来探讨一下数组、指针和函数指针的关系。
int a[20],a就代表一个指向开始数据的指针,但是它又不同于一般的指针,因为a是一个指针常量,不能进行a++这样的操作,可以这样定义int * p;p = a;声明一个指向函数的指针也是如此,void(* f)(int,int);如果f有值的话就指向了一段代码(也就是数据),我们先不管该代码是否可以正确运行,为了让指针和函数指针以及他们之间的转换联系起来,下面我们为数组a初始化一段特殊的数据,
int a[20] = {0xB8,0x00,0xB8,0x8E,0xD8,0xBE,0x90,0x06,
0xC7,0x04,0x61,0x02,0xB8,0x00,0x4C,0xCD,0x21};
先提前说明一下,其实这段数据当做机器码来用的话,它的意义是在屏幕的中间输出绿色的字符a,获取它很简单,我们只要写一段汇编程序,然后反汇编就可以查看它的机器码啦,好的,下面我们写一段C程序,
char a[20] = {0xB8,0x00,0xB8,0x8E,0xD8,0xBE,0x90,0x06,
0xC7,0x04,0x61,0x02,0xB8,0x00,0x4C,0xCD,0x21};
main()
{
int n;
void (far * p)(); /*声明一个函数指针p*/
for(n = 0;n < 20;n++) /*这个for循环是将数组里面的数据拷贝到从0X200开始处*/
((char far *)0x200)[n] = a[n]; /*far 就是远指针的意思,将0X200转换成含有段地址和偏移地址
的指针*/
p = (void(far *)())0x200; /*将0X200转换成指向函数的指针类型,然后赋值给p,p就指向了这段数据*/
p(); /*执行这段代码*/
}
我们接着对其进行演变,既然我们知道了这段代码的位置那么我们为什么还要将它的地址赋值给p呢?直接执行不就行啦!好的,看下面的代码
char a[20] = {0xB8,0x00,0xB8,0x8E,0xD8,0xBE,0x90,0x06,
0xC7,0x04,0x61,0x02,0xB8,0x00,0x4C,0xCD,0x21};
main()
{
int n;
for(n = 0;n < 20;n++)
((char far *)0x200)[n] = a[n];
((void(far *)())0x200)(); /*直接将0x200转换成指向函数的指针类型,然后类比上面的函数代码,直接调用,注意最后一定要加上(),这是函数的标志*/
}
编译连接,OK,成功。
我们接着演变,上面的程序是我们将这段代码复制到了特定位置,即0X200处,再执行。我们知道,数组名就是一个地址,毋庸置疑,那么我们能不能不复制代码,直接将a当做函数指针直接运行,可以吗?我们要做的工作就是将a转换成函数指针类型,但是a是两个字节的地址,我们要转换成四个字节的(far 型的),好的,让我们试一下,下面的代码
char a[20] = {0xB8,0x00,0xB8,0x8E,0xD8,0xBE,0x90,0x06,
0xC7,0x04,0x61,0x02,0xB8,0x00,0x4C,0xCD,0x21};
main()
{
((void(far *)())(long)a)();
}
编译连接,令人兴奋的事情发生了,它成功啦!
它还能更简单吗?如果你学过王爽《汇编语言》的综合研究的话,你会知道程序在编译连接的时候都干了些什么,其中和我们讨论话题有关的一条是系统会找到main()作为函数的入口,然后调用它,那么我们直接将数组名改为main,会发生什么呢?试试:
char main[20] = {0xB8,0x00,0xB8,0x8E,0xD8,0xBE,0x90
,0x06,0xC7,0x04,0x61,0x02,0xB8,0x00,0x4C,0xCD,0x21};
然后再编译连接,成功啦!!
留一个思考问题,我始终认为这段代码属于歪门邪道的功夫,大家可以试试在内存中找到这段代码。(提示:一般的main()都在CS:01FA处)
其实写这些都是为了使学C的人不要被一些表象所蒙蔽。C语言有很多数据类型,它的存在使我们将更多的注意力集中在程序设计上,编译器为你做细节处理,这样就提高了编程效率。不可否认,C语言是有陷阱和缺陷的,当你写非常复杂的C程序时,情况就不再那么简单,如果你想写出高效的程序,了解本质是很重要的,如果你想搞底层或者是内核或嵌入式研究,这些更是必须的。
C语言很深奥,这里只是介绍了它的冰山一角,但是我感觉对有一定的C语言基础的人是一个比较不错的提升。
掀开面纱,了解本质,记住一点,你要处理的是数据。
知识零碎,以后会进一步完善。
以上知识并非自己发明创造,站在巨人的肩膀上学习,研究,总结。
杨鹏飞
10.05.13
河北农业大学信息院实验楼
浙公网安备 33010602011771号