1. 流的概念
输入输出I/O与流:I/O是内存RAM与外设之间的一个交换。
I/O是相对于内存RAM来说的
流与缓冲区:RAM与逻辑设备之间有一个缓冲区,比如逻辑设备是一个键盘,
RAM与键盘设备之间有一个缓冲区,RAM是去缓冲区里面取数据,不是到键盘里面取数据。比如scanf()函数。
注意:函数getch是直接去键盘里面取数据。
本来我们是通过操作系统的read/write函数去把具体的外设抽象化为普通的I/O设备,这是一种读写方法。
C语言再对普通的I/O设备进行一个抽象,把它们看成逻辑设备,定义为标准的输入输出设备,直接使用
这时候我们就没必要使用read/write这些函数了,我们就可以对它们进行读写了,更加方便了
RAM与这些逻辑设备之间有一个缓冲区,比如去读取键盘的数据,RAM不是直接去键盘这个逻辑设备中读取,
而是到RAM与键盘的缓冲区中去读取。
C语言帮我们定义了标准输入输出
-标准输入流(设备):stdin:与键盘相连(默认情况与键盘相连,也可以重定向到其他设备,比如重定义到手写板)
-标准输出流(设备):stdout:与显示器相连(默认情况与显示器相连,也可以重定向到其他设备,比如重定义到手写板)
-标准错误流(设备):stderr:与显示器设备相连
#include <stdio.h>
int main()
{
int a, b, c;
puts("input a, b"); //puts()默认连接到显示器上,可以重定向到其他设备,比如打印机
scanf("%d %d", &a, &b); //用空格间隔输入
printf("a = %d, b = %d\n", a, b);
return 0;
}
#include <stdio.h>
int main()
{
int a, b, c;
fprintf(stdout, "input a b:\n"); //把字符串输入到标准输出设备stdout里面,stdout目前是绑定在显示器上
scanf("%d %d", &a, &b); //scanf()函数是从输入缓冲区中读取数据
printf("a = %d, b = %d\n", a, b);
puts("input c");
scanf("%d", &c); //如果你在上面的scanf()的一次性输入多余两个以上数据
printf("c=%d\n", c); //那么下面的这个scanf()函数会继续从缓冲区中读取
return 0;
}
#include <stdio.h>
int main()
{
int a, b, c;
fprintf(stdout, "input a b:\n");
scanf("%d %d", &a, &b); //以空格做分隔符
printf("a = %d, b = %d\n", a, b);
fflush(stdin); //清空缓冲区
puts("input c");
scanf("%d", &c);
printf("c=%d\n", c);
// getch(); //这个函数是直接从键盘里面读取,不是从缓冲区读取,比较特殊
return 0;
}
2. 文件
什么是文件:文件是一组数据的有序集合,数据集的名称叫做文件名。
文件一般存储在磁盘等外部介质上,使用时读取到内存。
文件的分类:
(1)-用户的角度:普通文件和设备文件
在Linux下它会把所有的设备看做一个文件,我们叫设备名,可以直接对它进行读写,比如一个U盘,
一个网卡,一个显示器全部看做一个文件,然后可以对他打开读写的操作。在/dev目录下,文件floppy代表软盘
cdrom代表光驱,input鼠标键盘,midi mixer声卡(你往这个文件写入数据,声卡就会唱歌了)
(2)-编码方式:文本文件和二进制文件
文件缓冲区:内存与文件交互是通过文件缓冲区来完成的,不是直接操作文件。
内存读写速度很快,I/O操作速度很慢,中间加个缓冲区提升操作效率。
3. 文件指针
指向一个文件的指针变量称为文件指针。通过该指针可以对文件进行各种操作。
指向谁叫什么指针,指向数组就叫数组指针,指向结构体就叫结构体指针,指向文件就叫
文件指针,
FILE *fp; //FILE是stdio.h中定义的一种派生类型
范例:
#include <stdio.h>
int main()
{
FILE *fp; //打开文件之前先定义一个指向文件的一个指针
fp = fopen("test.c", "wt"); //如果test.c没有就新建 指针fp指向文件test.c
fclose(fp);
return 0;
}
3. 文件的打开与关闭
fopen()函数 文件名 打开模式
原型为:FILE *fopen(const char *fname, const char *mode);
如果文件打开成功,函数fopen()返回的是指向这个文件的指针(FILE *类型的指针),
失败的话返回的是NULL(空指针,也是在stdio.h中定义的),如果fp为NULL,程序将退出。磁盘已满、文件名非法、存取权限不够或者硬件问题都会导致fopen()执行失败。
(1)常见的文件打开方式
-只读:r(以r打开的文件必须已经存在,否则出错)打开一个用于读取的文本文件
#include <stdio.h>
int main()
{
FILE *fp;
fp = fopen("test.bin", "rb"); //文件不存在,用只读的方式打开操作失败
if(fp == NULL) //先判断文件是否打开成功,然后再往下操作,打开失败函数返回的是NULL值,即0值
{
printf("fopen failed!\n");
return -1; //直接退出
} //没有失败继续后面的操作
fclose(fp); //操作完毕记得关闭文件
return 0;
}
-只写:w(以w打开的文件不存在时会自动创建),里面的内容会被冲掉
创建一个用于写入的文本文件
#include <stdio.h>
int main()
{
FILE *fp;
fp = fopen("test.bin", "wb"); //二进制的方式来写的话,写进去的是对应字符的ASCII码
if(fp == NULL)
{
printf("fopen failed!\n");
return -1;
}
fclose(fp);
return 0;
}
-读和写方式:+
-追加:a 原先的内容保留,附加到一个二进制文件
-文本文件:t(默认打开方式,可省略不写)
-二进制文件:b
-"rb"打开一个用于读取的二进制文件
-"wb"创建一个用于写入的二进制文件
-"ab"附加到一个二进制文件
-"r+"打开一个用于读/写的文本文件
-"w+"创建一个用于读/写的文本文件
-"a+"打开一个用于读/写的文本文件
-"rb+"打开一个用于读/写的二进制文件
-"wb+"创建一个用于读/写的二进制文件
-"ab+"打开一个用于读/写的二进制文件
(2)路径
#include <stdio.h>
int main()
{
FILE *fp;
//D:\\c++\\011\\test.bin 注意这里要加转义字符
fp = fopen("D:\\c++\\011\\test.bin", "wb"); //绝对路径
if(fp == NULL)
{
printf("fopen failed!\n");
return -1;
}
fclose(fp);
return 0;
}
4.文件读写函数
字符读写函数:fgetc()/fputc() //只能一个字符一个字符的读写,效率比较低
#include <stdio.h>
int main()
{
FILE *fp;
if(NULL == (fp = fopen("test.c","wt")))
{
printf("fopen failed!\n");
return -1;
}
fputc('f',fp); //向文件写一个字符,fp是指向文件的指针,直接把里面的内容冲掉
fclose(fp);
return 0;
}
#include <stdio.h>
int main()
{
FILE *fp;
if(NULL == (fp = fopen("test.c","wt")))
{
printf("fopen failed!\n");
return -1;
}
fputc('f',fp);
fclose(fp);
int ch;
if(NULL == (fp=fopen("test.c","rt")))
{
printf("fopen failed!\n");
return -1;
}
ch = fgetc(fp);
while(EOF != ch) //条件是当文件没有空的时候,即没有到文件的结尾,stdio.h中定义为-1 #define EOF (-1)
{
putchar(ch); //打印出来
ch = fgetc(fp); //继续从文件里面读取一个字符,直到读到文件的结尾
}
fclose(fp);
return 0;
}
#include <stdio.h>
int main()
{
FILE *fp;
if(NULL == (fp = fopen("test.c","at"))) //追加的方式
{
printf("fopen failed!\n");
return -1;
}
fputc('p',fp); //追加
fclose(fp);
int ch;
if(NULL == (fp=fopen("test.c","rt")))
{
printf("fopen failed!\n");
return -1;
}
ch = fgetc(fp);
while(EOF != ch)
{
putchar(ch);
ch = fgetc(fp);
}
fclose(fp);
return 0;
}
字符串读写函数:fgets()/fputs()
#include <stdio.h>
int main()
{
FILE *fp;
if(NULL == (fp = fopen("string.c","wt")))
{
printf("fopen failed!\n");
return -1;
}
fputs("#include <stdio.h>", fp);
fclose(fp);
char str[50];
if(NULL == fopen("string.c","rt+"))
{
printf("fopen failed!\n");
return -1;
}
fgets(str,10,fp); //读10个字符
printf("%s\n", str);
fclose(fp);
return 0;
}
数据块读写函数:fread()/fwrite()
一般用来处理二进制数据比较方便一些
格式化读写函数:fscanf()/fprintf()
scanf()默认从stdin里面去读
printf()默认从stdout里面去读
前面加个f
fscanf()/fprintf()意思可以指定读的设备
1. 数组
为了解决大量同类型问题的存储。如果没有数组,定义成千上万个变量不把你累死才怪。
优点:存取速度快
缺点:需要一个连续的很大的内存,内存中去找连续的大块内存比较困难
插入和删除元素的效率很低,因为数组元素连续,插入或者删除元素后,元素要移动。
2. 链表
(1)第一个元素有第二个元素的地址,一个元素分两部分,数据和地址。通过第一个元素可以找到第二个元素。
最后一个元素的地址是0,不用指向。
(2)不需要一块很大的空间了,只需要一小块一小块的内存,分开存放的。内存上有很多缝隙,链表可以见缝插针。
(3)与数组不一样,删除的元素后不需要移动,只需要改变地址指向就行。
优点:插入删除元素效率高
不需要一个连续的很大的内存
缺点:查找某个位置的元素效率低
3. 结构体
如果要表示一个复杂的事物,基本的类型就无法满足要求,这个复杂的事物有好多不同的特征。比如一个学生,学生包括的特性有年龄、分数、姓名等。
这个复杂的事物是由基本类型组合在一起。
struct Student st ={80, 66.6, 'F'}; /*定义了数据类型的变量并赋值,struct Student是数据类型,st是变量*/
这个新的数据类型struct Student是由三个基本数据类型组合而成
利用结构体这个东西,可以造出新的数据类型。把这些基本类型组合到一起
就可以模拟现实中一切复杂的事物。
为什么需要结构体
为了表示一些复杂的事物,而普通的基本类型无法满足实际的要求
什么叫结构体
把一些基本的数据类型组合在一起形成的一个新的复合数据类型,这个叫做结构体
定义结构体的第一种方式:推荐
struct Student /*这里是定义了一个数据类型,数据类型里面有三个成员*/
{ /后面的标记Student可以自由定义,
int age;
float score;
char sex;
};
定义结构体的第二种方式:不推荐
struct Student2 /*这里是定义了一个数据类型,数据类型里面有三个成员*/
{ //后面的标记Student可以自由定义,
int age;
float score;
char sex;
}st2; //定义的同时直接写变量名,这种不好,只能定义一次。
定义结构体的第三种方式:不推荐
struct //没有标记,都不知道什么类型
{
int age;
float score;
char sex;
}st3; //定义的同时直接写变量名,
4. typedef
为已有的数据类型定义新的类型名,取别名。方便简洁。
需要注意的是,typedef也是有作用域,在函数内部定义,作用域就在函数内部
在函数外定义,作用域就全局的。
(1)typedef unsigned int uint;
uint i;
(2)typedef unsigned char uchar;
uchar ch;
(3)typedef struct
{
char name[20];
int age;
}student;//把这个结构体取了别名student;
student stu = {"jim",20};
(4)typedef int int_array_10[10];
int_array_10 a;//int a[10]
(5)typedef char * charp; //字符指针
charp name = "hello world";
(6)typedef int(*myfunc)(int a,int b);//函数指针
int func(int a,int b);
myfunc p;
p = func;
5.内存
听起来似乎Jave等语言比C/C++有优势,但是其实他这个虚拟机回收内存是需要付出一定的代价的
,所以说语言没有好坏之分,只有适应与不适宜。当我们程序对性能非常在乎的时候(如操作系统内核)
就会用C/C++语言,当我们对开发程序的速度非常在乎的时候,就会用Jave/C#等语言(上层应用)。与他们
管理内存的方式相关。
6. 位、字节、半字、字的概念和内存位宽
(1)SRAM
(2)SDRAM(SDRAM DDR1 DDR2 DDR3 DDR4 LPDDR)
(3)从硬件角度理解内存:内存实际上是电脑的一个配件(一般叫内存条)。根据不同的硬件实现原理还可以把内存分成
SRAM和DRAM(DRAM又有好多代,譬如最早的SDRAM,后来的DDR1,DDR2,DDR3,DDR4、LPDDR),SRAM是不需要初始化
就可以直接使用,DRAM(动态RAM)要先初始化才可以使用。
(4)从逻辑角度:内存是这样一种东西,它可以随机访问(随机访问的意思就是只要给一个地址,就可以
访问这个内存地址)、并且可以读写(当然了逻辑上也可以限制其为只读或者只写),内存在编程中天然
是用来存放变量的(就是因为有了内存,C语言才能定义变量,C语言中的一个变量实际就对应内存中
的一个单元)。内存从逻辑的角度来说是无尽的,可以一直数下去。从逻辑的角度来讲,内存实际上是由
无限多个内存单元格组成的,每个单元格有一个固定的地址叫内存地址,这个内存地址和这个内存单元格
唯一对应且永久绑定。
(5)以大楼来类比内存是最合适的,逻辑上的内存就好像是一栋无限大的大楼,内存的单元格就好像大楼
中的一个个房间。每个内存单元格的地址就好像每个小房间的房间号,内存中内容就好像住在房间中的人。
逻辑上来说,内存可以有无限大(因为数学上编号永远可以增加,无尽头)。但是现实中实际的内存大小是
有限的,譬如32位系统内存限制为4G(32位系统指的是32位数据线,但是一般配的地址线也就是32位的,
这个地址线32位决定了内存地址只能有32位二级制,所以逻辑上的大小为2的32次方)内存限制就为4Gbit
实际上32位的系统中可用的内存是小于等于4G的(譬如32位CPU装32位windows,但实际电脑只有512M内存)
内存一般分为32位内存、16位内存、8位内存。
32位内存
___________________
| B_ | A_ | 9_ | 8_ |
|_7 | 6_ |5 _ |4 _ |
| 3 | 2 | 1 | 0 | 对于int 类型,在32位内存里面,0~3就是一个Int
16位内存
___________
| 6_ | 5_ |
|4 _ |3 _ |
| 1 | 0 | 对于int 类型,在16位内存里面,0~3就是一个Int
8位内存
_____
| 2_ |
|1 _ |
| 0 |
| |
对于int 类型,在8位内存里面,0~3就是一个Int
现在遇到的大多是32位内存,颗粒可以是16位的,两个16位的颗粒并联成一个32位的内存,这种也属于32位
的内存模式。
需要强调的是内存芯片之间是可以并联的,通过并联后即使8位的内存芯片也可以做出16位或者32位的硬件内存。
从硬件角度讲,硬件的设计都是偶数的,市场上根本找不到奇数的内存。
但是从逻辑角度来说,硬件内存是可以任意的,多少位,奇数还是偶数都可以,譬如说24位3个字节的(但是实际上
是买不到的,没有实际意义)。
从逻辑角度来讲不管内存位宽是多少,直接操作即可,对我的操作不构成影响。但是因为你的操作不是纯逻辑而是需要硬件
去执行的,所以不能为所欲为,譬如你搞出个24位的内存,这样搞的话硬件没法搞,或是这样搞硬件的执行效率低。
所以都是受限于硬件的特性的,譬如24位的内存逻辑上和32位的内存没有任何区别,但是实际硬件都是32位的,都要按照32
位硬件的特性和限制来干活。
(6)内存位宽
从硬件角度讲:硬件内存的实现本身是有宽度的,也就是说有些内存条就是8位的,而有些就是16位的
(6)位(1bit)字节(8bit)半字(一般是16位)字(一般是32bit)
在所有的计算机、所有的机器中(不管是32位系统还是16位系统还是以后的64系统),位永远都是1bit
字节永远都是8bit。
历史上曾经出现过16位系统,32位系统、64位系统三种,而且操作系统还有window、Linux、iOS等很多
所以很多的概念在历史上曾经被混乱的定义过。
在windows系统中,字这个概念竟然是16bit的,但是在arm系统字是32bit的。所以字到底是32位还是16位
不知道,很乱。
建议大家对字、半字、双字这些概念不要详细区分,只要知道这些单位具体有多少位是依赖于平台的。
实际工作中在每种平台上先去搞清楚这个平台(包括软件和硬件平台)的定义(字是多少位,半字永远是字的一半,
双字永远是字的的两倍)
编程时一般根本用不到字这个概念,那我们区分这个概念主要是因为有些文档中会用到这些概念,
如果不加区分,可能会造成你对程序的误解。
在Linux+ARM这个软硬件平台上,我们嵌入式核心课的所有课程中,字是32位的,
比如int 这个数据类型,为什么int的大小在不同的平台上会变,因为它和字是绑定在一起的,所有在不同的平台上它对应的位是变化
在32位系统Int就是32位,在16位系统,int就是16bit
7.内存编址方法
(1)内存在逻辑上是一个一个的格子,这些格子可以用来装东西(里面装的东西就是内存中存储的数
),每个格子有一个编号,这个编号就是内存地址,这个内存地址(一个数字)和这个格子的
空间(实质是一个空间)是一一对应的且永久绑定,这就是内存的编址方法。
在程序运行时,计算机中的CPU实际只认识内存地址,而不关心这个地址所代表的空间在哪里,
怎么分布这些实体问题。因为硬件设计保证了按照这个地址就一定能找到这个格子。CPU只关心按照地址就能找到格子。
所以说内存单元的2个概念,地址和空间是内存单元的两个方面,
(2)关键:内存编址是以字节为单位的
我们随便给一个数字(譬如说7),然后说这个数字是一个内存地址,然后问你这个内存地址对应的空间多大?
这个大小是固定的,就是一个字节(8bit)
如果把内存比喻为一栋大楼,那么这个楼里面的一个一个房间就是一个一个内存格子,这个
格子的大小是固定的8bit,就好像这个大楼里面所有的房间户型是一样的
(3)内存和数据类型的关系
C语言中的基本数据类型有:char short int long float double
int 整型(这个整就体现在它和CPU本身的数据位宽是一样的)譬如32位的CPU,整型就是32位,int 就是一个字节(8bit)
32位
数据类型和内存的关系就在于:
数据类型是用来定义变量的,而这些变量需要存储、运算在内存中。所有数据类型和内存才能获得
最好的性能,否则可能不工作或者效率低下。
在32位系统中定义变量最好用Int,因为这样效率高。原因就在于32位的系统本身配合内存等也是32位,
这样的硬件配置天生适合定义32位的int类型变量,效率最高。也能定义8位的char类型变量或者16位的
short类型变量,但是实际上访问效率不高。
在很多32位环境下,我们实际定义bool类型变量(实际只需要1个bit就够了)都是用int来实现bool
的。也就是说我们定义一个bool bl时,编译器实际帮我们分配了32位的内存来存储这个bool变量b1。
编译器这么做实际上浪费了31位的内存,但是好处是效率高。
问题:实际编程是要以省内存为大还是要以运行效率为重?答案是不定的,看具体情况。
很多年前内存很贵,机器上内存都很小,那时候写代码以省内存为主。现在随着半导体技术的发展内存变得
很便宜了,现在的机器都是高配,不在乎省一点内存,而效率和用户体验成了关键。
所以现在写程序大部分都是以效率为重。
8. 内存对齐
内存的对齐不是逻辑的问题,是硬件的问题。从硬件角度来说,32位内存它0 1 2 3四个单元
本身逻辑上就有相关性,这4个字节组合起来当做一个int硬件上就是合适的,效率就高。
就像高铁的座位,ABC逻辑上是连一起的,但是实际B和C被过道隔开了。
32位的内存,0 1 2 3或者4 5 6 7 天生就是一伙的
对齐访问很配合硬件,所以效率很高;非对齐访问因为和硬件本身不搭配,所以效率不高。
(因为兼容性的问题,一般硬件也都是提供非对齐访问,但是效率要低很多)
数组就是内存里面截取的一部分而已,永远记住一个地址的长度只有一个字节。
9.C语言如何操作内存
(1)C语言对内存的封装
汇编代码直接操作的是内存地址,如#define GPJ0CON 0xE0200240,这决定了汇编代码不好写。
譬如在C语言中 int a; a = 5; a += 4; ? ? ?//a == 9;
结合内存来解析C语言语句的本质;
int a; ?//编译器帮我们申请了一个int类型的内存格子(长度是4个字节这个格子肯定有一个地址,地址是确定的
地址只有编译器知道,我们是不知道的,也不需要知道),并且把符号a和这个格子绑定。这在编译
器帮我们做的绑定。我们
a = 5; //编译器发现我们要给a赋值,就会把这个值5丢到符号a绑定的那个内存格子中
a += 4 ;//编译器发现我们要给a加值,a += 4等效于 a = a + 4;
编译器会先把a原来的值读出来,然后给这个值加4,再把加之后的和写入a里面去。
变量名和内存地址本来没有什么关系,但是编译器帮你把他俩联系起来了。
C语言中数据类型的本质含义是:表示一个内存格子的长度和解析方法。
数据类型决定了长度的含义:我们一个内存地址(0x30000000),本来这个地址只代表1个字节的长度,
但是实际上我们可以通过给他一个类型(int),让他有了长度(4),这样这个代表内存的
的数字(0x30000000)就能表示从这个数字(0x30000000)开头的连续的n(4)个字节内存
格子了(0x30000000+0x30000001+0x30000002+0x30000003)。
数据类型决定解析方法的含义;譬如我有一个内存地址(0x30000000),我们可以通过给这个内存地址不同
的类型来指定这个内存单元格子中二进制数的解析方法。譬如(int)0x30000000,含义就是
(0x30000000+0x30000001+0x30000002+0x30000003)这4个字节连起来共同存储的是一个int型数据;
那(float)0x30000000 ,含义就是(0x30000000+0x30000001+0x30000002+0x30000003)这4个字节连起来共同存储的是一个float
数据。同样是32个二级制位,用int类型来解析,解析出来的就是一个整型数,用float类型
来解析,解析出来就是一个浮点类型的数。数据类型转换说的是,格子还是那些格子,格子里面的数还是那些数,
只是解析方法(看法和理解方式)不一样了。
那么函数名的本质是什么?
比如汇编的函数名_start: flash: delay:都是函数名
C语言中,函数就是一段代码的封装。就是把一段代码封在一起。
函数名的实质就是这一段代码的首地址。所以说函数名的本质也是一个内存地址。
函数名,变量名都是直接访问内存的
C语言中可用指针来间接访问内存的
用指针来间接访问内存和我们的内存有什么关系
关于类型(不管是普通类型Int float等,还是指针类型int * float *等),只要记住:5
类型只是对后面数字或者符号(代表的是内存地址)所表征的内存的一种长度规定和解析方法规定而已。
类型只管两件事:长度 和解析方法
每一个类型都附带了一个长度和解析方法。
譬如说int 的长度是4,解析方法是int解析方法;
float的长度是4,解析方法是float方法;
int *的长度也是4,解析方法把它当指针来解析,当指针解析就可以被解引用,解引用到的类型就是一个int类型。
float *的长度也是4个字节,因为所有的指针都是4个字节,只不过这4个字节里面存的是一个指针,指针所指向的
那个数是一个float类型。
其实(int *)0和(int * )a没有什么本质的区别,只不过数字0是我们认为指定的一个
地址,符号a是编译器分配的一个地址。
(int *)a时,编译器会自动给a分配一个内存地址。譬如说是0x12345678
等价于(int *)0x12345678,
(2)指针类型的含义
C语言中的指针,全名叫指针变量,指针变量其实和普通变量没有任何区别
int a 和int * p中的a和p都代表一个内存地址(譬如是0x20000000),
这不过这个内存地址(0x20000000)的长度和解析方法不一样。
a 是int型所以a的长度是4字节,解析方法是按照Int的规定来的;p是int *类型,
所以长度是4字节,解析方法是int *规定来的(0x20000000开头的连续4字节中存储了
1个地址,这个地址所代表的内存单元中存放的是一个Int型的数)。
(3)用数组来管理内存
数组管理内存和变量其实没有本质区别,只是符号的解析方法不同。
(普通变量、数组、指针变量这三个其实没有任何差别)
千万不要以为指针变量和普通变量有什么特殊的,
int a;//编译器分配4字节长度给a ,并且把首地址和符号a绑定起来
int b[10]; //编译器分配40个字节长度给b,并且把首元素首地址和符号b绑定起来。
数组中第一个元素(b[0])就称为首元素,每一个元素类型都是int,
所以长度都是4,其中第一个字节的地址就称为首地址,首元素b[0]首地址就
称为首元素首地址。
10.内存管理之结构体
(1)数据结构这门学问的意义
数据结构就是研究数据如何组织(在内存中排布),如何加工的学问。数据放在内存,所以数据结构和内存有很大的关联。
(2)最简单的数据结构:数组
数据组成的结构都应该算数据结构,数组里面存的也是数,所以也应该算数据
结构
为什么要有数组?
因为程序中有好几个类型相同、意义相关的变量需要管理,这时候如果用单独
的变量来做程序看起来比较乱,用数组来管理会更好管理,用数组我们可以用下标来访问。
譬如:int ages[20];
(3)数组的优势和缺陷
优势:数组比较简单,访问用下标,可以随机访问,它可以不用访问前18个,直接访问第二十个;
缺陷:1.数组中所有元素类型必须相同;如果要管理的数据有年龄,身高,名字,那么我们就不能用数组来管理,因为
年龄,身高都是一个数字,都可以用int类型来表示,但是名字不能用Int类型来表示,名字是一个字符串。
2.数组的大小必须在定义时给出,而且一旦确定不能再改。不具有伸缩性。
(4)结构体隆重登场
结构体发明出来就是为了解决数组的一个缺陷:数组中所有的元素类型必须相同。
我们要管理3个学生的年龄(int 类型),怎么办?
第一种解法:用数组 int ages[3];
第二种解法:用结构体
struct ages
{
int age1;
int age2;
int age3;
};
struct ages age;
分析总结:在这个示例中,数组要比结构体好,数组来得更简单干脆。但是不能得出结论说
数组就比结构体好,在包中元素类型不同时就只能用结构体不能用数组了。
类型相同的情况下,结构体就等价于数组,
数组可以看作是一种特殊的结构体
结构体中元素类型相同的,就可以简化为数组。
结构体中元素的长短是不一样的,所以不能用下标来访问元素,只能用.的方式。
结构体和数组的本质差异还是在于找变量地址的问题,在于在内存中寻址的问题。
题外话:结构体内嵌指针实现面向对象
总的来说:C语言是面向过程的,但是C语言写出的Linux系统时面向对象的
非面向对象的语言,不一定不能实现面向对象的代码。只是说用面向对象的语言来实现
面向对象要更加简单一些、直观一些、无脑一些。
用c++、Java等面向对象的语言来实现面向对象简单一些,因为语言本身帮我们做了
很多事情;但是用C来实现面向对象很麻烦,看起来也不容易理解,这是为什么
大多数学过C语言却看不懂Linux内核代码的原因。
struct s
{
int age; //普通变量
void (*pFunc)(void); //函数指针,指向void func(void)这类的函数
};
使用这样的结构体就可以实现面向对象。
类里面有成员(变量),也可以有成员方法(函数),
结构体里面本来是只能放变量,不能放函数,但是可以放函数指针,函数指针
是一个变量,用函数指针去指向一个函数,就相当于结构体里面有了函数,
函数就是方法,就相当于我的结构体里面既有成员又有方法了。
这样包含了函数指针的结构体就类似于面向对象中的class。结构体中的变量类似于
class中的成员变量,结构体中的函数指针类似于class中的成员方法。
11.内存管理之栈(stack)
(1)什么是栈
栈是一种数据结构,C语言中使用栈来保存局部变量。栈是被发明出来管理内存的。
(2)栈管理内存的特点(小内存,自动化)
先进后出 FILO first in last out 栈
先进先出 FIFO first in first out 队列
栈的特点是入口即出口,只有一个口,另一个口是堵死的,所以先进去的必须后出来。
队列的特点是入口和出口都有,必须从入口进去,从出口出来,所以先进去的必须先出来,否则就堵住后面的。
(3)栈的应用举例:局部变量
C语言中局部变量是用栈来实现的。
我们在C中定义一个局部变量时(int a),编译器会在栈中分配一段空间(4字节)给这个局部变量用
(分配时栈顶指针会移动给出空间,给局部变量a用的意思就是,将这4字节的栈内存地址和我们定义的
局部变量名a给关联起来),对应栈的操作是入栈。
注意:这里栈指针的移动和内存分配是自动的(栈自己完成,不用我们写代码去操作)。
然后等我们函数退出的时候,局部变量要灭亡。对应栈的操作是弹栈(出栈)。出栈时也是栈顶指针移动将
栈空间中与a关联的那4个空间释放,这个动作也是自动的,也不用人写代码干预。
栈的优点:栈管理内存,好处是方便,分配和最后回收都不用程序员操心,C语言自动完成。
分析一个细节:C语言中,定义局部变量时如果未初始化,则值是随机的,为什么?
定义局部变量,其实就是在栈中通过移动栈指针来给程序提供一个内存空间和这个局部变量名绑定。
因为这段内存空间在栈上,而栈内存是反复使用的,(出栈其实是把值拷贝了一份弄出去,读出去,
栈中的值是保存原样的,没有人去把它变成0,只要没有人去把它变成0,值就维持以前的值),而栈
内存是反复使用的(脏的,上次用完没有清零的),所以说使用栈来实现的局部变量定义时如果不显式
初始化,值就是脏的。如果你显式初始化怎么样?
C语言是通过一个小手段来实现局部变量的初始化的。
int a = 15; //局部变量定义时初始化
C语言编译器会自动把这行转成;
int a; //局部变量定义
a = 15; //普通的赋值语句
12.栈的约束(预定栈大小不灵活,怕溢出)
首先,栈是有大小的。所以栈内存大小不好设置。如果太小怕溢出,太大怕浪费内存。
(这个缺点有点像数组)
其次,栈的溢出危害很大,一定要避免。所以我们在C语言中定义局部变量时不能定义太多或者太大。
(譬如不能定义局部变量时int a[10000];使用递归来解决问题时一定要注意递归收敛)
13.内存管理之堆
(1)什么是堆(heap)
堆是一种内存管理方式。内存管理对操作系统来说是一件非常复杂的事情,因为首先内存容量很大,
其次内存需求在时间和大小块上没有规律(操作系统上运行着几十、几百、几千个进程随时都会申请
或者释放内存,申请或者释放的内存块大小随意)。
堆这种内存管理方式特点就是自由(随时申请、释放;大小块随意)。堆内存是操作系统划归给堆管理器
(操作系统中的一段代码,属于操作系统的内存管理单元)来管理的,然后向使用者(用户进程)提供API
(malloc和free)来使用堆内存。
我们什么时候使用堆内存?需要的内存容量比较大(至少几十个字节如int a[1000]),需要反复使用及释放时,
很多数据结构(譬如链表)的实现都要使用堆内存。
14.堆内存的特点(大块内存、手工分配&使用&释放)
特点一:容量不限(常规使用的需求容量都能满足)
特点二:申请及释放都需要手工进行,手工进行的含义就是需要程序员写代码明确进行申请malloc及释放free。
如果程序员申请内存并使用后未释放,这段内存就丢失了(在堆管理器的记录中,这段内存仍然属于你这个进程,
但是进程自己又以为这段内存已经不用了,再用的时候又会去申请新的内存块,这就叫吃内存),称为内存泄漏。
在C/C++语言中,内存泄漏是最严重的程序bug,这也是别人认为Jave/C#(可以帮你自动释放内存)等语言比C/C++
(要程序员自己释放)优秀的地方。
C语言(底层用的比较多,比较注重效率) C++(中间层) Jave(上层,应用层)
15.C语言操作内存的接口(malloc free)
堆内存释放时最简单,直接调用free释放即可。通过man 3 free 查一下free的原型
void free(void *ptr);
堆内存申请时,有3个可选择的类似功能的函数:mallloc,calloc,realloc
原型分别为:
void *malloc(size_t size); 最简单最常用,size_t你可以把它当做Int,size单位是字节,你要申请10个字节让size为10即可
void *calloc(size_t nmemb, size_t size);申请nmemb个单元,每个单元size字节
void *realloc(void *ptr, size_t size);用来改变原来申请空间的大小的
譬如要申请10个Int元素的内存:即40个字节
malloc(40);
malloc(10*sizeof(int));这种写法更好,跟随平台在变
calloc(10, sizeof(int));或者calloc(10, 4);
数组定义时必须同时给出数组元素个数(数组大小),而且一旦定义再无法更改。
在Jave等高级语言中,有一些语法技巧可以更改数组大小,但其实这只是一种障眼法。它的工作原理是:
先创建一个新的数组大小为要更改后的数组,然后将原数组的所有元素复制进新的数组,然后释放掉原数组,
最后返回新的数组给用户;其实没有更改原来数组的大小。偷天换日。
其实C语言也可以这么做,自己写一个函数来实现。
堆内存申请时必须给定大小,然后一旦申请完成大小不变,如果要变只能通过realloc接口。
realloc的实现原理类似于上面说的Jave中的可变大小的数组的方式。
16.堆的优势和劣势
优势:灵活;
劣势:需要程序员去处理各种细节,所以容易出错,严重依赖于程序员的水平。
17.复杂数据结构
(1)链表、哈希表、二叉树、图等
链表是最重要的,链表在Linux内核中使用非常多,驱动,应用编写很多时候都需要使用链表。
所以对链表必须掌握,掌握到:会自己定义结构体来实现链表、会写链表的节点插入(前插、后插)、节点
删除、节点查找、节点遍历等。(至于像逆序这些很少用,掌握了前面)
18.位操作符
(1)位与&
a. 注意:位与符号是一个&,两个&&是逻辑与。
b. 位与和逻辑与的区别:位与时两个操作数是按照二级制位批次对应为相与的,
逻辑与是两个操作数作为整体来相与的。(举例:0xAA & 0xF0= 0XA0 ,0XAA && 0XF0 =1)
(2)位或|
a. 注意:位或符号是一个|,两个||是逻辑或。
b. 位或和逻辑或的区别:位或和逻辑或的区别:位或时两个操作数是按照二级制位批次对应为相或的,
逻辑或是两个操作数作为整体来相或的。
(3)位取反~
a. 注意:C语言中位取反是~,C语言中的逻辑取反是!
b. 按位取反是将操作数的二进制位逐个按位取反()
#include <stdio.h>
int main()
{
unsigned int a = 45; //32bit binary
unsigned int b, c;
b = ~a;
c = !a; //按逻辑取反
printf("b = %u.\n", b);
printf("c = %u.\n", c);
return 0;
}
因为a是一个32位的二进制数,b = 4294967250.
#include <stdio.h>
int main()
{
unsigned int a = 45;
unsigned int b, c;
b = ~~a; \\这种写法与b = ~(~a)一样
c = !!a; \\这种写法与c = !(!a)一样
printf("b = %u.\n", b);
printf("c = %u.\n", c);
return 0;
}
(4)位异或^
a. 位异或真值表:两个数如果相等结果为0,不等结果为1,相异(不相等)或起来操作。
#include <stdio.h>
int main()
{
unsigned int a = 45;
unsigned int b = 23;
unsigned int c;
c = a ^ b;
printf("c = %u.\n", c);
return 0;
}
b=58
(5)左移位<< 与右移位>>
对于无符号数,左移时右侧补0(相当于逻辑移位)
对于无符号数,右移时左侧补0(相当于逻辑移位)
对于有符号数,左移时右侧补0(叫算术移位,相当于逻辑移位)
对于有符号数,右移时左侧补符号位(如果是正数就补0,负数就补1,叫算术移位)
注意:嵌入式中研究的移位,以及使用的移位都是无符号数
19.位与或位异或在操作寄存器时的特殊作用
(1)ARM是内存与IO统一编址的,ARM中有很多内部外设,SoC中CPU通过向这些内部外设的寄存器
写入一些特定的值来操控这个内部外设,进而操控硬件动作。所以可以说:读写寄存器就是
操控硬件。
(2)寄存器的特点是按位进行规划和使用的。当时寄存器的读写却是整体32位一起进行的(例如如果你只想
修改bit5~bit7是不行的,必须整体32bit全部写入)
寄存器操作要求就是:在设定特定位时不能影响其他位。
如何做到?答案是:读改写三部曲。读改写的操作理念,就是:当我想改变一个寄存器中某些特定位
时,我不会直接去给他写,我会先读出寄存器整体原来的值,然后在这个基础上修改我想要修改的特定位,
再将修改后的值整体写入寄存器。这样达到的效果是:在不影响其他位原来值的情况下,我关心的位
的值已经被修改了。
为什么要先读一下?因为不知道寄存器原先的值是多少。
(3)特定位清零用&
任何数(其实就是1或者0)与1位与无变化,与0位与变成0
如果希望将一个寄存器的某些位变成0而不影响其他位,可以构造一个合适的1或0组成的数
和这个寄存器原来的值进行位与操作,就可以将特定位清零。
举例:假设原来32位寄存器中的值为:0x12AAAAAA,我们希望将bit8~bit15清零而其他位不变,
可以将这个数与0xFFFF00FF进行与即可。
#include <stdio.h>
int main()
{
unsigned int a = 0x12AAAAAA;
unsigned int b = 0xFFFF00FF;
unsigned int c;
c = a & b;
printf("a & b = 0x%x.\n", c);
return 0;
}
a & b = 0x12aa00aa.
(4)特定位置1用|
操作手法和位与是类似的。任何数(其实就是1或者0)与1位或变成1,与0位或无变化。
我们要构造这样一个数:要置1的特定位为1,其他位为0,然后将这个数与原来的数进行位或即可。
(5)特定位取反用^
任何数(其实就是1或者0)与1位异或会取反,与0位异或无变化。
20.如何用位运算构建特定二进制数
寄存器位操作经常需要特定位给特定值
(1)从上节可知,对寄存器特定位进行置1或者清0或者取反,关键性的难点在于要事先构建一个特定的
数,这个数和原来的值进行位与、位或、位异或等操作,即可达到我们对寄存器操作的要求。
(2)解法2:自己写代码用位操作符号(主要是移位和位取反)来构建这个特定的二进制数
(3)使用移位获取特定位为1的二进制数
最简单的就是用移位来获取一个特定位为1的二进制数。譬如我们需要一个bit3~bit7为1(隐含的意思就是其他位全部为0)
的二进制数,可以这样:(0x1f<<3)
更难一点的要求:获取bit3~bit7为1,同时bit23~bit25为1,其余位为0的数:
#include <stdio.h>
int main()
{
unsigned int a;
//下面表达式含义:位或说明这个数字由2部分组成,第一部分中左移3位说明第一部分bit3开始,
//第一部分数字为0x1f说明这部分有5位,所以第一部分其实就是bit3到bit7;
//第二部分的解读方法同样的,可知第二部分其实就是bit23到bit25;
//所以两部分结合起来,这个数的特点就是:bit3~bit7和bit23~bit25为1,其余位全部为0
a = ((0x1f<<3) | (0x7<<23));
printf("a = 0x%x.\n", a); //结果为0x038000f8
return 0;
}
(4)在结合取反去特定位为0的二进制数
这次我们要获取bit4~bit10为0,其余位全部为1的数,怎么做?
(0xf<<0) | (0x1fffff<<11)
但是问题是:连续为1的位数太多了,这个数字本身就很难构建,所以这种方法的优势损失掉了。
#include <stdio.h>
int main()
{
unsigned int a;
a = ((0xf<<0) | (0x1fffff<<11));
printf("a = 0x%x.\n", a); //结果为a = 0xfffff80f.
return 0;
}
(5)这种特定位(比较少)为0而其余位(大部分)为1的数,不适合用很多个连续1左移的方式来构建。
适合左移加位取反方式来构造。
(6)思路是:先试图构造出这个数的位相反数,再取反得到这个数。(譬如本例中要构造的数bit4~
bit10为0其余位为1,那么我们就构造一个bit4~bit10为1,其余位为0的数,然后对这个数按位取反即可。)
~(0x7f<<4)
#include <stdio.h>
int main()
{
unsigned int a;
a = ~(0x7f<<4);
printf("a = 0x%x.\n", a); //结果为a = 0xfffff80f.
return 0;
}
总结:
a. 如果你要的数比较少位为1,大部分位为0,则可以通过连续很多个1左移n位得到。
b. 如果你想要的数是比较少位为0,大部分位为1,则可以通过先构建其位反数,然后再位取反得到。
c. 如果你想要的数中连续1(连续0)的部分不止1个,那么可以通过多段分别构造,然后再彼此位或即可。
这时候因为参与位或运算的各个数为1的位是不重复的,所以这时候的位或其实相当于几个数的叠加。
21.位运算实战演练
(1)给定一个整数a,设置a的bit3,保证其他位不变。
a = a | (0x1<<3) ; a |= (1<<)
(2)给定一个整型数a,设置a的bit3~bit7,保持其他位不变。
a = a | (0x1f<<3) 或者 a = a | (0b11111<<3)
(3)
给定一个整型数a,清除a的bit15,保证其他位不变。
a = a & (~(1<<15)); 或者 a &= (~(1<<15));
(4)给定一个整型数a,清除a的bit15~bit23,保持其他位不变
a = a & (~(0x1ff<<15)) ;或者 a &= (~(0x1ff<<15)) ;
(5)给定一个整型数a,取出a的bit3~bit8.
思路:
第一步:先将这个数bit3~bit8不变,其余位全部清零;
第二步:再将其右移3位得到结果;
a = a >> 3;
(6)用C语言给一个寄存器的bit7~bit17赋值937(其余位不受影响)。
思路:
第一步:先把bit7~bit17先清零 a = a & (~(0x7ff<<7))
第二步:用937左移7位,然后再与a或运算 a = a | (937<<7)
关键点:第一,不能影响其他位;
第二,你并不知道原来bit7~bit17中装的值(不能直接位或,有可能里面全是1,应该先清零);
(7)用C语言将一个寄存器的bit7~bit17中的值加17(其余位不受影响)。
思路:
第一步:先把bit7~bit17取出来
b = (a & (0x7ff<<7));
b = b >> 7;
第二步:再把取出来的值加上17,得到和
c = b + 0x11
第三步:再把这个和赋值给bit7~bit17位,其余位不受影响。
a = a & (~(0x7ff<<7));
a = a | (c<<7);
(8)用C语言给一个寄存器的bit7~bit17赋值937,同时给bit21~bit25赋值17.
思路一:这种方法效率不高
第一步:先把bit7~bit17先清零 a = a & (~(0x7ff<<7))
第二步:用937左移7位,然后再与a或运算 a = a | (937<<7)
第三步:把bit21~bit25清零 a = a & (~(0x1f<<21))
第四步:用17左移21位,然后再与a或运算 a = a | (17<<21)
思路二:其实就是思路一合并一起写
a &= ~((0x7ff<<7) | (0x1f<<21)); 其实是bit7~bit17 和 bit21~bit25同时清零
a |= ((937<<7) | (17<<21)); bit7~bit17 和 bit21~bit25同时赋值
22.用宏定义来完成位运算
(1)直接用宏来置位、复位(最右边为第1位)
#include <stdio.h>
#define SET_BIT_N(x, n) (x | (1U<<(n-1))) //加一个U表示无符号的,对右移有用,有符号负数右移是补1的
int main()
{
unsigned int a = 0;
unsigned int b = 0; //定义局部变量最好赋初值,不赋初值它的值是随机的
b = SET_BIT_N(a, 4);
printf("b=0x%x\n", b);
return 0;
}
第二题:用宏定义将32位数x的第n位(右边起算,也就是bit0算第1位)清零
#include <stdio.h>
#define CLEAR_BIT_N(x, n) (x & (~(1U<<(n-1))))
int main(int argc, char **argv)
{
unsigned int a = 0xff;
unsigned int b = 0x0;
b = CLEAR_BIT_N(a, 8);
printf("b=0x%x\n", b);
return 0;
}
第三题:用宏定义将32位数x的第n位到第m位(右边算起,也就是bit0算第1位,m是高位)置位
分析:假如n=3, m=6, 题目就是要把bit2到bit5置位
我们需要一个算式来得到(m-n+1)个1
算法:第一步:先得到32位1:~0U
第二步:将第一步得到的数右移x位即可得到(m-n+1)个1 (~0U)>>(32-(m-n+1))
第三步:((~0U)>>(32-(m-n+1)))<<(n-1) 再把这(m-n+1)个1左移(n-1)位
第四步:(x | ((~0U)>>(32-(m-n+1)))<<(n-1)) 再与x位或
#include <stdio.h>
#define SET_BIT_N_M(x, n, m) (x | ((~0U)>>(32-(m-n+1)))<<(n-1))
int main(int argc, char **argv)
{
unsigned int a = 0x80;
unsigned int b = 0x0;
b = SET_BIT_N_M(a, 1, 7);
printf("b=0x%x\n", b);
return 0;
}
第四题:截取(获取)变量x的第n到第m位
分析:这个题目相当于要把bit(n-1)到bit(m-1)取出来
思路:先让第n到第m位保持不变,其余位全部清零,然后右移取出来
a = a & (0x3f<<3)
我们需要一个算式来得到(m-n+1)个1
算法:第一步:先得到32位1:~0U
第二步:将第一步得到的数右移(32-(m-n+1))位即可得到(m-n+1)个1 (~0U)>>(32-(m-n+1))
第三步:((~0U)>>(32-(m-n+1)))<<(n-1) 再把这(m-n+1)个1左移(n-1)位
第四步:x & (((~0U)>>(32-(m-n+1)))<<(n-1)) 第n到第m位保持不变,其余位全部清零
第五步:(x & (((~0U)>>(32-(m-n+1)))<<(n-1))) >> (n-1)
#include <stdio.h>
#define SET_BIT_N_M(x, n, m) ((x & (((~0U)>>(32-(m-n+1)))<<(n-1))) >> (n-1))
int main(int argc, char **argv)
{
unsigned int a = 0xe2;
unsigned int b = 0x0;
b = SET_BIT_N_M(a, 2, 7);
printf("b=0x%x\n", b);
return 0;
}
注意上述宏有个缺陷:只能在32位机器里面用
改进 : x & ~(~(0U)<<(m-n+1))<<(n-1) 兼容所有系统 注意:位取反~的优先级大于按位左移<<优先级
#include <stdio.h>
#define SET_BIT_N_M(x, n, m) ((x & ~(~(0U)<<(m-n+1))<<(n-1)) >> (n-1))
int main(int argc, char **argv)
{
unsigned int a = 0xe2;
unsigned int b = 0x0;
b = SET_BIT_N_M(a, 2, 7);
printf("b=0x%x\n", b);
return 0;
}
第三部分 指针 指针才是C的精髓
内存结构回顾:内存是由字节组成的,每个字节都有一个地址。
地址的外号叫指针;
变量的每个字节都有一个地址;
变量的第一个字节的地址代表整个变量的地址;也就是说,变量的指针就是变量第一个字节的地址。
就是用这个变量的指针(第一个字节的地址),就能找到这个变量空间,找到这个变量空间干嘛呢?
操作它,怎么操作,读或者写变量空间。
如何使用变量的指针找到整个变量空间?
(1)第一步:通过变量指针找到变量的第一个字节。
(2)第二步:根据变量的类型得到变量所占的总的字节,从第一个字节往后数,就能找到该变量空间所有的字节。
变量的指针就是一个数
前面说过,变量的指针就是变量第一个字节的地址,通过这个指针就可以找到这个变量。而这个地址,或者说这个指针就是一个数。
指针就是一个数,只是这个数有点特殊,它是一个地址。
就好比门牌号一样,门牌号也是一个数,不过它是房间的地址,用来指向某个房间的。
在C语言中,我们使用什么方法才能得到一个变量的指针呢?
方法就是使用取地址符&,比如:
int a;
&a表示的就是a变量的第一个字节的地址,也即a变量指针。
这里再次强调,变量的指针指的是变量第一个字节的地址,因此&a得到的指针就是a变量的第一个字节的地址。
打印一个变量的指针这个数时,我们可以使用%d的形式打印,这也说明地址就是一个比较特殊的整型数,但是
你不能说他是整型数,他是一个地址,我们也可以使用%p,这个是地址的专用打印格式,只是打印出的地址结果是
以十六进制形式显示。如果你希望好理解的话,你也可以使用%d以十进制形式打印,十进制的数更好理解。
什么是指针变量:用于存放指针这种数的变量。
1. 指针到底是什么?
(1)指针变量和普通变量的区别
a. 首先必须非常明确:指针的实质就是个变量,它和我们普通的变量没有任何区别。指针完整的名字应该叫指针变量,简称指针。
#include <stdio.h>
int main(void)
{
//a的实质其实就是一个编译器中的符号,在编译器中a和一个内存空间联系起来
//这个内存空间就是a所代表的那个变量
int a; //定义了int型变量,名字叫a
int *p; //定义了一个指针变量,名字叫p,p指向一个int型变量
a = 4; //可以操作
p = 4; //编译器不允许,因为指针变量虽然实质上也是普通变量,但是它的用途和普通
//变量不同。指针变量存储的应该是另外一个变量的地址,而不是用来随意存一些int类型的数
//举个例子:从街上买了两个一模一样的桶,一个用来装水,一个用来装尿,一但贴上标签后,桶的用途就已经定了,以后就不能胡乱用了
//标签就是类型定义。
p = (int *)4; //我们明知道其实就是数字4,但是我强制类型转换成int *类型的4
//相当于我告诉编译器,这个4其实是个地址(而且是个int类型变量的地址)
//那么(int *)4就和p类型相匹配了,编译器就过了。
}
2.为什么需要指针
(1)指针的出现是为了实现间接访问。在汇编中都有间接访问,其实就是CPU的寻址方式中的间接寻址。
(2)间接访问(CPU的间接寻址)是CPU设计时决定的,这个决定了汇编语言必须能够实现间接寻址,又决定了
汇编之上的C语言也必须实现间接寻址。
(3)高级语言如Java、C#等没有指针,那他们怎么实现间接访问?答案是语言本身帮我们封装了
语言越高级封装得越厉害,越接近人的思维,越远离机器,效率越低。
3.指针使用三部曲:定义指针变量、关联指针变量、解引用
(1)当我们int *p 定义一个指针变量p时,因为p是局部变量,所以也遵循C语言局部变量的一般规律
#include <stdio.h>
int main(void)
{
//演示指针的标准使用方式
//指针使用分3步:定义指针变量、给指针变量赋值(绑定指针)、解引用
int a = 23;
//第一步,定义指针变量
int *p;
//第二步:绑定指针,其实就是给指针变量赋值,也就是让这个指针指向另外一个变量
//当我们没有绑定指针变量之前,这个指针不能被解引用。
p = &a; //实现指针绑定,让p指向变量a,变量a的地址是多少,我也不知道,反正是编译器自动分配的
p = (int *)04; //实现指针绑定,让p指向内存地址为4的那个变量
//第三步,解引用。
//如果没有绑定指针到某个变量就去解引用,几乎一定会出错。
//不出错的情况是这个p巧合指向了恰好可用的原始值,为什么?
//当我们int *p定义了一个指针变量p时,因为p是局部变量,所以也遵循C语言局部变量的一般规律,
//定义局部变量并且未初始化,则值是随机的,所以此时p变量中存储的是一个随机的数字。
//其实我们可以把这个随机的数打印出来,printf("p = %p", p);
//此时如果我们解引用p,则相当于我们访问了这个随机数字为地址的内存空间。
//那这个空间到底能不能访问不知道(也许行也许不行),所以如果直接定义指针变量未绑定有效地址就去
//解引用几乎必死无疑。
*p = 555; //把555放入p所指向的变量中,如果你是p = &a;这样绑定的,那么a=555,如果你是
//这样绑定的p = (int *)4;那么555就赋给内存地址为4的那个变量。
return 0;
}
(2)定义了一个指针变量,不经绑定有效地址就去解引用,就好像拿一个上了膛的枪随意转了几圈然后开了一枪。
(3)指针绑定的意义就在于:让指针指向一个可以访问、应该访问的地方(就好像拿着枪瞄准目标的过程一样)
指针的解引用是为了间接访问目标变量(就好像开枪是为了打中目标一样,定义指针就好像造了一把枪)。
#include <stdio.h>
int main(void)
{
int a = 0;
int *p;
p = &a;
printf("p = %p\n", p); //%p打印指针和%x打印指针,打印出来的值是一样的
printf("p = 0x%x\n", p); //因为指针变量就是个普通的变量,他里面存的值就是个数字,当然可以用%x来打印。
return 0;
}
4.指针带来的一些符号的理解
我们写的代码是给编译器看的,代码要想达到你想象的结果,就必须要编译器对你的代码的理解和你自己对代码的理解一样。
编译器理解代码就是理解符号,所以我们要正确理解C语言中的符号,才能像编译器一样思考程序、理解代码。
(1)星号*
a. C语言中*可以表示乘号,也可以表示指针符号。
这两个用法是毫无关联的,只是恰好用了同一个符号而已。
b. 星号在用于指针相关功能的时候有2种用法:
第一种是指针定义时,*结合前面的类型用于表明定义的指针的类型:
int *p; //*和int结合,表明p的类型是int *,也就是p是指向int类型变量的指针
注意:*与指针变量放在一起,是为了一行定义多个变量时好理解
#include <stdio.h>
int main(void)
{
int a = 4;
int *p1;
int* p2;
int*p3;
int * p4; //实际编译测试,p1到p4都没有警告,说明4种写法编译器认为是一样的,都是定义了
//int *类型的指针p,实际上中间有没有空格,空多少格编译器都认为是一样的。
int *p5, *p6; //这样才是定义了2个int *指针变量p5、p6
int *p5, p6; //p5是int *指针,p6是int普通变量 *与指针变量放在一起,是为了一行定义多个变量时好理解
int* p5, p6; //p5是int *指针,p6是int普通变量
p1 = &a;
p2 = &a;
p3 = &a;
p4 = &a;
printf("p = %d\n", *p1);
printf("p = %d\n", *p2);
printf("p = %d\n", *p3);
printf("p = %d\n", *p4);
return 0;
}
第二种功能是指针解引用,解引用时*p表示p指向的变量本身。
#include <stdio.h>
int main(void)
{
int a = 23;
int b = 0;
int *p;
p = &a; //绑定有效地址
b = *p;
printf("b = %d\n", b);
return 0;
}
(2)取地址符&
a. 取地址符使用时直接加在一个变量的前面,然后取地址符和变量加起来构成一个新的符号,
这个符号表示这个变量的地址。
int a; //&a就表示a的地址。为什么要有取地址符,因为a的地址只有编译器知道,我不知道啊
int *p;
p = &a; //编译器一看到&a,就知道我们是要把变量a的地址赋值给指针变量p
//理解&a,*p这样的符号,关键在于要明白当&和*和后面的变量结合起来后,就共同构成了一个
//新的符号,这个新的符号具有一定的意义。
(3)指针定义并初始化、与指针定义然后赋值的区别
a. 指针定义时可以初始化,指针的初始化其实就是给指针变量初值(跟普通变量的初始化没有任何本质区别)。
b. 指针变量定义同时初始化的格式是:int a = 32; int *p = &a;
不初始化时指针变量先定义再赋值: int a = 32; int *p; p = &a;
(4)左值与右值
a. 放在赋值运算符左边的叫左值,右边的叫右值。所以赋值操作其实就是:左值=右值;
b. 当一个变量做左值时,编译器认为这个变量符号的真实含义是这个变量所对应的那个内存空间;
当一个变量做右值时,编译器认为这个变量符号的真实含义是这个变量的值,也就是这个变量所对应的内存空间
中存储的那个数。
int a = 3, b = 5;
a = b; //这里a是左值,b是右值
//当a做左值时,是a所对应的那个内存空间,a对应空间里面所存的值3我们是不关心的
b = a; //当a做右值时,我们关心的是a所对应空间中存储的数,也就是5
c. 左值与右值的区别,就好像现实中生活中“家”这个字的含义。譬如“我回家了(类似左值)”,这里面的家指的是你家的房子;
但是说“家比事业重要”,这时候的家指的是家人(家人就是住在家所对应的那个房子里面的人,类似于右值)
5.野指针问题
5.1 什么是野指针?哪里来的?有什么危害?
(1)野指针,就是指针指向的位置是不可知的(随机的,不正确的,没有明确限制的)
#include <stdio.h>
int main(void)
{
int *p; //没有绑定有效的地址,当前就是野指针。
//局部变量,分配在栈上面,栈反复被使用,所以值是随机的
//这个值就是栈上一次被使用被赋予的值,因为栈使用完是不会被擦除的
//栈是脏的,所以值是随机的
//这个随机并不是完全随机的,是有一定规律可循的
//规律就是上一次被赋值过的
*p =4; //编译可以通过,但是运行时报段错误 Segmentation fault (core dumped)
} //这个段错误就是野指针造成的
(2)野指针很可能触发运行时段错误(Segmentation fault ),因为野指针指向的地址是不固定的,这个不固定的地址也许是可以
访问的,也许是不可以访问的。大部分情况下都是不可访问的,不可访问导致段错误。就像你在广场上
放一枪,也有可能打不死人,子弹走的路径刚好就没有人。
(3)因为指针变量在定义时如果未初始化,值也是随机的。指针变量的值其实就是别的变量(指针
所指向的那个变量)的地址,所以意味着这个指针指向了一个地址是不确定的变量,这时候去解引用就是去访问
这个地址不确定的变量,所以结果是不可知的。
(4)野指针因为指向的地址是不可预知的,所以有3种情况:
第一种是指向不可访问(操作系统不允许访问的敏感地址,譬如内核空间)的地址,结果是触发段错误,这种算是最好的情况了;
第二种是指向一个可用的、而且没什么特别意义的空间(譬如我们增加使用过的但是已经不用的栈空间或堆空间),
这时候程序运行不会出错,也不会对当前程序造成损害(相当于你拿枪随便放了一枪,打中的东西不重要),这种情况会掩盖
你的程序的错误,让你以为程序没问题,其实是有问题的(结果你下次在还胡乱放一枪,结果把人给打死了);
第三种情况就是指向了一个可用的空间,而且这个空间其实在程序中正在被使用(譬如说是程序的一个变量x),
那么野指针的解引用就会刚好修改这个变量x的值,导致这个变量莫名其妙的被改变,程序出现离奇的错误。
一般最终都会导致程序崩溃,或者数据被损害,这种危害是最大的。
(5)指针变量如果是局部变量,则分配在栈上,本身遵从栈的规律(反复使用,使用完不擦除,所以是脏的,
本次在栈上分配到的变量的默认值是上一次这个栈空间被使用时预留下来的值),就决定了栈的使用多少会影响这个默认
值。因此野指针的值是有一定规律不是完全随机,但是这个值的规律对我们没有意义。因为不管落在上面野指针3种
情况的哪一种,都不是我们想要的,野指针本来就不能让他发生。
5.2 怎么避免野指针?
(1)野指针的错误来源就是指针定义了以后没有初始化,也没有赋值(总之就是指针没有明确的指向一个可用的内存空间)
,然后去解引用(如果不去解引用也不会出现野指针的)。
(2)知道了野指针产生的原因,避免方法就出来了:在指针的解引用之前,一定确保指针指向一个绝对可用的空间。
(3)常规的做法是:
第一点:定义指针时,同时初始化为NULL
第二点:在指针解引用之前,先去判断这个指针是不是NULL
第三点:指针使用完之后,将其赋值为NULL
第四点:在指针使用之前,将其赋值绑定给一个可用地址空间
#include <stdio.h>
int main(void)
{
int a;
int *p = NULL;
//中间省略n行代码......
//p = (int *)4; //4地址不是你确定可以访问的,就不要用指针去解引用了,明知道不可为还要为不是找死吗?
p = &a; //正确的使用指针的方式,是解引用指针前跟一个绝对可用的地址绑定
if (p != NULL)
{
*p = 4;
}
p = NULL; //使用完指针变量后,记得将其重新赋值为NULL
return 0;
}
说白了就是平时不解引用的时候保证他的值为NULL,在解引用之前,真正要去用的时候让他和一个可用的值绑定,
用完之后再次把他的值赋为NULL,免得他还残存上一次不确定的值。
(4)野指针的防治方案4点绝对可行,但是略显麻烦。很多人懒得这么做,那实践中怎么处理?
在中小型程序中,自己水平可以把握的情况下,不必严格参照这个标准,但是在大型程序,或者
自己水平感觉不好把握时,建议严格参照这个方法。
5.3 NULL到底是什么?
(1)NULL在C/C++中定义为:
#ifdef _cplusplus //定义这个符号就是当前是C++环境
#define NULL 0 //在C++中NULL就是0
#else
#define NULL (void *)0 //在C中NULL是强制类型转换为void *的0
#endif
(2)在C语言中,int *p;你可以p = (int *)0; 但是不可以p =0,因为C中要做类型检查,编译器不让你过,
所以要加个强制类型转换。
为什么不把NULL定义为int *类型呢,因为所有的类型的指针(int*,char*...)都会初始化为NULL,如果你定义为
int *那么NULL只能赋给int *类型的指针了。
(3)NULL的实质就是0,然后我们给指针赋初值为NULL,其实就是让指针指向0地址处。
为什么指向0地址处?2个原因。
第一层原因是0地址处作为一个特殊地址(我们认为指针指向这里就表示指针没有被初始化,就表示是野指针)
第二层原因是这个地址0地址在一般的操作系统中都是不可访问的,如果C语言程序员不按规矩(不检查是否等于NULL就去
解引用)写代码直接去解引用就会触发段错误,这种已经是最好的结果了。(不让他导致另外两种结果)
(4)一般在判断指针是否野指针时,都写成
if (NULL != p)
而不是写成
if (p != NULL)
原因是:如果NULL写在后面,当中间是==号的时候,有时候容易忘记写成了=,这时候其实程序已经错误,danshi
编译器不会报错。这错误是很难检查出来,如果习惯了把NULL写在前面,当错误的把==写成了=时,编译器会报错,
程序员会发现这个错误。
if (NULL != p) //推荐这种写法
{
*p = 4;
}
注意:习惯很重要
6. const关键字与指针
6.1 const修饰指针的4种形式
(1)const关键字,在C语言中用来修饰变量,表示这个变量是常量。既然是用来修饰变量,当然可以用来修饰指针,
因为指针本来就是变量的一种。
(2)const修饰指针有4种形式,区分清楚这4种即可全部理解const和指针。
第一种:const int *p;
第二种:int const *p;
第三种:int * const p;
第四种:const int * const p;
(3)关于指针变量的理解,主要涉及到2个变量:第一个是指针变量p本身,
第二个是指p指向的那个变量(*p)。一个const关键字只能修饰一个变量,所以弄清楚这4个表达式的关键就是搞清楚
const放在某个位置是修饰谁的。
#include <stdio.h>
int main(void)
{
int a;
//第一种
const int *p1; //p1本身不是const的,而p1指向的变量是const的
*p1 = 4; //error: assignment of read-only location '*p1' 说明*p1是个常量
p1 = &a; //没有报错,说明p1是个变量
//第二种
int const *p2; //p2本身不是const的,而p指向的变量是const的
*p2 = 5; //error: assignment of read-only location '*p2' 说明*p2也是个常量
p2 = &a; //没有报错,说明p2是个变量
//第三种
int * const p3; //p3本身是const的,p指向的变量不是const的
*p3 = 6; //编译没有报错,说明*p不是常量
p3 = 6; //assignment of read-only variable 'p3',说明p3是个常量,意思就是初始化完成后就不能改了
//第四种
const int * const p4; //p本身是const的,p指向的变量也是const的
p4 = &a; //error: assignment of read-only variable 'p4' 说明p4为常量
*p4 = 5; //error: assignment of read-only location '*p4' 说明*p4为常量
return 0;
}
总结:记忆方法为const往右看,先遇到p说明是修饰p的,如果中间隔*就是修饰*p的
6.2 const 修饰的变量真的不能改吗?
int main(void)
{
const int a = 5;
a = 6; //error: assignment of read-only variable 'a',这样去改肯定改不了
return 0;
}
看看下面的方法:
#include <stdio.h>
int main(void)
{
const int a = 5;
int *p;
p = &a; //warning: assignment discards 'const' qualifier from pointer target type
*p = 6; //编译器警告我们,a是const类型的你不能这么搞
printf("a = %d.\n", a); //结果是a = 6 ,结果证明const类型的变量被改了,
return 0;
}
我们可以加一个强制类型转换就可以把警告消除掉
#include <stdio.h>
int main(void)
{
const int a = 5;
int *p;
p = (int *)&a; //没有警告
*p = 6;
printf("a = %d.\n", a);
return 0;
}
(1)说明const修饰变的变量其实是可以改的(前提是gcc环境下)。
(2)在某些单片机环境下,const修饰的变量是不可以改的。const修饰的变量到底能不能真的
被修改,取决于具体的环境,C语言本身并没有完全严格一致的要求。
(3)在gcc中,const是通过编译器在编译的时候执行检查来确保实现的(也就是说const类型的变量不能改
是编译错误,不是运行时错误。)所以我们只要想办法编过编译器,就可以修改const定义的常量,而运行时不会
报错。
(4)更深入一层的原因,是因为gcc把const类型的常量也放在了data段,其实和普通的全局变量放在data段是一样
实现的,只是通过编译器认定这个变量是const的,运行时并没有标记const标志,所以只要骗过编译器就可以修改了。
6.3 const究竟应该怎么用
(1)const是在编译器中实现的,编译是检查,并非不能骗过。所以在C语言中使用const,就好像是一种道德约束而
非法律约束,所以大家使用const时更多是传递一种信息,就是告诉编译器、也告诉读程序的人,这个变量是不应该也
不必被修改的。
7 深入学习一下数组
7.1 从内存角度理解数组
(1)从内存角度讲,数组变量就是一次分配多个变量,而且这多个变量在内存中的存储单元是依次相连接的,
(2)我们分开定义了多个变量(譬如int a, b, c, d;)和一次定义一个数组(int a[4]);
这两种定义方法相同点是都定义了4个int型变量,而且这4个变量都是独立的单个使用的;
不同点是单独定义时a、b、c、d在内存中的地址不一定相连,但是定义成数组后,数组中的4个元素地址肯定是
依次相连的。
(3)数组中多个变量虽然必须单独访问,但是因为他们的地址彼此相连,因此很适合用指针来操作,因此数组和指针
天生就纠结在一起。
7.2
(1)从编译器的角度来讲,数组变量也是变量,和普通变量和指针变量并没有本质不同。
变量是一个地址,这个地址在编译器中决定具体数值,具体数值和变量名绑定,变量类型决定这个地址的
延续长度。
7.3 数组中几个关键符号(a a[0] &a &a[0])的理解(前提是int a[10])
(1)这4个符号搞清楚了,数组相关的很多问题都有答案了。
理解这些符号的时候要和左值和右值结合起来,也就是搞清楚每个符号分别做左值和右值时的不同含义。
左值:类似饺子皮,房子的空间
右值:类型馅,房子里面的人
(2)a就是数组名,a做左值时表示整个数组的所有空间(10*4=40字节),因为C语言规定
数组操作时要独立单个操作,不能整体操作数组,所以a不能做左值。
#include <stdio.h>
int main(void)
{
int a[10];
a = {1, 2, 3, 4}; //error: expected expression before '{' token,不能整体操作
//必须单个操作a[0]= ...
return 0;
}
(3)a做右值表示数组首元素(数组的第一个元素,也就是a[0])的首地址(首地址就是起始地址,就是4个字节中
最开始第一个字节的地址)。a做右值等同于&a[0];
(4)a[0]表示数组的首元素,也就是数组的第0个元素。
做左值时表示数组第0个元素的对应的内存空间(皮),连续4字节;做右值时表示数组第0个元素的值(馅),
也就是数组第0个元素对应的内存空间中存储的那个数。
(5)&a就是数组名a取地址,字面意思来看就应该是数组的地址。
&a不能做左值(&a实质是一个常量,不是变量因此不能赋值,所以自然不能做左值。)
&a做右值时表示整个数组的首地址。
注意:&a和a做右值时的区别;&a是整个数组的首地址,而a是数组首元素的首地址。
这个两个数字上是相等的,但是意义不一样。意义不一样会导致他们在参与运算的时候
有不同的表现。
(6)&a[0]字面意思是数组第0个元素的首地址(搞清楚[]和&的优先级,[]的优先级要高于&,
所以a先和[]结合再取地址)。做右值时表示数组首元素的值(馅),做右值时&a[0]等同于a;
8.指针与数组的天生姻缘
8.1 以指针方式来访问数组元素
(1)数组元素使用时不能整体访问,只能单个访问。访问方式有2种:数组形式和指针形式。
(2)数组格式访问数组元素是:数组名[下标];(注意小标从0开始)
(3)指针格式访问数组元素是:* (指针+偏移量);
如果指针是数组首元素地址(a或者&a[0]),那么偏移量就是下标;指针也可以不是首元素地址而是其他哪个元素的的地址,
这时候偏移量就要考虑叠加了。
(4)数组小标方式和指针方式均可以访问数组元素,两者的实质是一样的,在编译器内部都是用
指针方式来访问数组元素的,数组小标方式只是编译器提供给编程者的一种壳(语法糖,药的包起来的胶囊)而已。
所以用指针方式来访问数组才是本质的做法,数组方式只是看起来好看一些,要好理解一些而已,更符合人的思维而已。
#include <stdio.h>
int main()
{
int a[5] = {1, 2, 3, 4, 5};
printf("a[3] = %d.\n", a[3]);
printf("*(a+3) = %d.\n", *(a+3)); //等效于:int b = *(a +3); printf("*(a+3) = %d.\n", b);
int *p;
p = a; //a做右值表示数组首元素首地址,等同于&a[0]
printf("*(p+3) = %d.\n", *(p+3));
printf("(p-1) = %d.\n", *(p-1)); //等同于a[-1],已经越界
p = &a[2];
printf("*(p+1) = %d.\n", *(p+1)); //等同于a[3]
printf("*(p-1) = %d.\n", *(p-1)); //等同于a[1]
printf("*(p+3) = %d.\n", *(p+3)); //等同于a[5],已经越界
return 0;
}
8.2指针和数组类型的匹配问题
(1)int *p; int a[5]; p = a; //类型匹配
(2)int *p; int a[5]; p = &a; //类型不匹配。p是int *类型,
&a是整个数组的指针,也就是一个数组指针类型,不是int指针类型,所以不匹配
a和&a[0]都是数组首元素的首地址,&a是整个数组的首地址;
从类型来看,a和&a[0]是元素的指针,也就是int *类型的;
而&a是数组指针,是int (*)[5]类型。
#include <stdio.h>
int main()
{
int a[5] = {1, 2, 3, 4, 5};
printf("a = %p.\n", a);
printf("&a = %p.\n", &a);
printf("&a[0] = %p.\n", &a[0]); //a、&a、&a[0]打印出来的值是一样的
return 0;
}
8.3 总结:指针类型决定了指针如何参与运算
(1)指针参与运算时,因为指针变量本身存储的数值是表示地址的,
所以运算也是地址的运算。
(2)指针参与运算的特点是,指针变量+1,并不是真的加1,而是加
1*sizeof(指针类型);如果是int *指针,则是+1就实际表示地址+4,
如果是char *指针,则+1就表示地址+1;如果是double *指针,则+1就表示
地址+8.
(3)指针变量+1时实际不是加1而是加1xsizeof(指针类型),主要原因是
希望指针+1后刚好指向下一个元素(而不希望错位)。
9.指针与强制类型转换
9.1变量的数据类型的含义
(1)所有的类型的数据存储在内存中,都是按照二进制格式存储的。
所以内存中只知道有0和1,不知道是int的、还是float的还是其他类型。
(2)int、char、short等属于整型,他们的存储方式(数据转换成二进制往内存中
放的方式)是相同的,只是内存格子大小不同(所以这几种整型就
彼此叫二进制兼容模式);而float和double的存储方式彼此不同,和
整型更不同。
(3)int a = 5;时,编译器给a分配4字节空间,并且将5按照int类型的存储
方式转成二进制存到a所对应的内存空间中去(a做左值的);我们printf去打印
a的时候(a此时做右值),printf内部的vsprintf函数会按照格式化字符串(就是printf传参的第一个字符串参数中%d之类的
东西)所代表的类型去解析a所对应的内存空间,解析出的值来输出。也就是说,存进去时是按照这个变量本身的数据类型来
存储的(譬如本例中a为int所以按照Int格式来存储);但是取出来时是按照printf中%d之类的格式化字符串的格式来提取的。
此时虽然a所代表的内存空间中1010序列并没有变(内存是没有被修改的)但是怎么理解(怎么把这些1010转成数字)就不一定了。
譬如我们用%d来解析,那么还是按照int格式解析,那么还是按照int格式解析则值自然还是5;但是如果用%f来解析,则printf
就以为a对应的内存空间存储的是一个float类型的数,值自然是很奇怪的一个数字了。
总结:C语言中的数据类型的本质,就是决定了这个数在内存中怎么存储的问题,也就是决定了
这个数如何转成二进制的问题。一定要记住的一点是内存只是存储1010的序列,而不管这些1010怎么解析,只是一个仓库,内存
非常简单,没有任何思想。所以要求我们平时数据类型不能瞎胡乱搞(譬如按照int类型存却按照float类型取一定会出差,int 类型
按照char类型来取就不一定了)。
分析几个题目:
*按照int 类型存却按照float类型取 一定会出错
*按照int类型存却按照char类型取 有可能出错也有可能不出错
*按照short类型存却按照int类型取 有可能出错有可能不出错
*按照float类型存却按照double取 一定会出错
9.2指针的数据类型的含义
(1)指针的本质是:变量,指针就是指针变量
(2)一个指针涉及2个变量:一个是指针变量自己本身,一个是指针变量指向的那个变量。
(3)int *p;定义 指针变量时,p(指针变量本身)是int *类型,*p(指针指向的那个变量)是
int 类型的。
(4)int *类型说白了就是指针类型,只要是指针类型就都是占4个字节,解析方式都是按照地址的方式来解析
(意思是 里面存的32个二级制加起来表示一个内存地址)的。结论就是:所有的指针类型(不管是int *还是
char *还是double *)的解析方式是相同的,都是地址。
(5)对于指针所指向的那个变量来说,指针的类型就很重要了。指针所指向的那个变量的类型(它所对应的内
存空间的解析方法)要取决于指针类型。譬如指针是int *的,那么指针所指向的变量就是int类型的。
从这里可以看出来,指针变量的类型不是为了它自己,而是为了它所指向的那个变量。指针变量自己的类型是定的,
就是指针类型,说白了所有的指针变量都是指针类型。存的过程和取的过程类型要匹配的,如果不匹配就看它们兼容
不兼容。如果不匹配还不兼容那一定会错,如果不匹配但是兼容那就有可能错,有可能不错。
#include <stdio.h>
int main(void)
{
int a = 5;
printf("a = %d.\n", a); //5
printf("a = %f.\n", a); //一个很奇葩的数字,一看就知道是乱码
return 0;
}
9.3 指针数据类型转换实例分析1(int * -> float *)
#include <stdio.h>
int main(void)
{
int a = 5;
float *p;
p = &a; //a是一个int类型的变量,&a就是int *类型指针变量
//编译器报警告,a是按照int类型来存的,但是你去访问的时候是按照float类型去访问的
printf("*p = %f.\n", *p); //不理编译器的警告,得到一个乱码
return 0;
}
这里面是涉及指针强制类型转换的,可以换一种写法
#include <stdio.h>
int main(void)
{
int a = 5;
int *p1 = &a;
float *p;
p = (float *)p1;
printf("*p1 = %d.\n", *p1); //5
printf("*p = %f.\n", *p); //乱码,
return 0;
}
(1)之前分析过:int和float的解析方式是不兼容的,所以int *转成float *再去访问绝对会出错。
9.4 指针数据类型转换实例分析2(int * -> char *)
(1)int和char类型都是整型,类型兼容的。所以互转的时候有时候错有时候对。
(2)int和char的不同在于char只有1个字节而int有4个字节,所以int的范围比char大。
在char所表示的范围之内int和char是可以互转的不会出错;但是超过了char的范围后
char转成int不会错(像大方向转就不会错,就好比拿小瓶子的水往大瓶子倒不会丢掉),
而从int到char转就会出错(就好像拿大瓶子水往小瓶子倒一样)
正确的情况:
#include <stdio.h>
int main(void)
{
int a = 65;
char *p1 = &a;
printf("*p1 = %c.\n", *p1); // A,打印正确
return 0;
}
#include <stdio.h>
int main(void)
{
int a = 65;
char *p1 = &a;
printf("*p1 = %d.\n", *p1); // 65,打印正确
return 0;
}
#include <stdio.h>
int main(void)
{
int a = 365;
short *p1 = &a;
printf("*p1 = %d.\n", *p1); // 365,打印正确,shor占2个字节,最大值为65535
return 0;
}
错误的情况:
#include <stdio.h>
int main(void)
{
int a = 365;
char *p1 = &a;
printf("*p1 = %d.\n", *p1); // 109,打印错误,char占一个字节最大的数是127,刚好是365-256=109
return 0;
}
#include <stdio.h>
int main(void)
{
int a = 65537;
short *p1 = &a;
printf("*p1 = %d.\n", *p1); // 1,打印错误,刚好是65537-65536=1,溢出的值
return 0;
}
#include <stdio.h>
int main(void)
{
int a[2] = {0x11223344, 0x55667788};
int *p1 = a;
char *p2 = (char *)a;
printf("*p1 = 0x%x.\n", *p1);
printf("*p2 = 0x%x.\n", *p2); // 44
printf("*(p2+1) = 0x%x.\n", *(p2+1)); // 33
printf("*(p2+2) = 0x%x.\n", *(p2+2)); // 22
printf("*(p2+3) = 0x%x.\n", *(p2+3)); // 11
printf("*(p2+4) = 0x%x.\n", *(p2+4));
printf("*(p2+5) = 0x%x.\n", *(p2+5));
return 0;
}
10.指针、数组与sizeof运算符
(1)sizeof是C语言的一个运算符(主要sizeof不是函数,虽然用法很像函数),sizeof的作用
用来返回()里面的变量或者数据类型占用的内存字节数。
(2)sizeof存在的价值?
主要是因为在不同平台下各种数据类型所占的内存字节数不尽相同(譬如int在32位系统中为4字节,
在16位系统中为2字节...)。所以程序中需要使用sizeof来判断当前变量/数据类型在当前环境下占
几个字节。
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
char str[] = "hello"; //数组在定义时没有写大小,数组的大小就是取决于初始化时的大小,
//注意字符串后面还有个'\0',所以这里应该是5+1 相当于str[6]
printf("sizeof(str) = %lu.\n", sizeof(str)); //str在这里既不做左值又不做右值,这里比较特殊。
//sizeof(str)测的就是数组元素所占的字节数(也就是数组的大小)
printf("sizeof(str[0]) = %lu.\n", sizeof(str[0])); //数组第0个元素占的字节数
printf("strlen(str) = %lu.\n", strlen(str)); //strlen()是C语言的一个库函数,用来测试字符串的长度,不包括'\0'
return 0;
}
注意:知道一个函数,但不知道这个函数是在哪个头文件里面定义的,在命令行里面敲入man 函数名就可以找到头文件。例如man strlen
注意:在64位的机器中,sizeof()和strlen()类型为long unsinged int,在32位机器中为unsinged int
#include <stdio.h>
int main(int argc , char *argv[])
{
char str[] = "hello";
char *p = str; //str类型也是char * ,与p类型匹配
printf("sizeof(p) = %d.\n", sizeof(p)); //测的是指针变量p本身的长度,跟p所指向的那个空间没有关系,相当于sizeof(char *)的长度
//请记住32位系统里面所有指针类型的长度都是4个字节,
printf("sizeof(*p) = %d.\n", sizeof(*p)); //*p就是p所指向的变量,这里测的就是p所指向的那个变量,
//那p所指向的那个变量是什么类型的呢?p本身是char *的,那么p所指向的变量就是char型的。
//相当于sizeof(char),值自然是1字节。
printf("strlen(p) = %d.\n", strlen(p)); //相当于strlen(str) 结果为5个字节
return 0;
}
(3)32位系统中所有指针的长度都是4,不管是什么类型的指针。
(4)strlen是一个C库函数,用来返回一个字符串的长度(注意,字符串的长度是不计算字符串末尾的
'\0'的)。一定要注意strlen接收的参数必须是一个字符串(字符串的特征是以'\0'结尾),如果传
的不是一个字符串,那么他会一直朝后延续,一直朝后找,直到找到第一个'\0'结尾为止。我们这里
指针p所指向的数组str[]里面就是一个字符串。
#include <stdio.h>
int main(int argc , char *argv[])
{
int n = 10;
printf("sizeof(n) = %d.\n", sizeof(n)); //sizeof变量本身和sizeof类型结果是一样的
printf("sizeof(int) = %d.\n", sizeof(int));
return 0;
}
(5)sizeof测试一个变量本身,和sizeof测试这个变量的类型,结果是一样的。
(6)sizeof(数组名)的时候,数组名不做左值也不做右值,纯粹就是数组名的含义。
那么sizeof(数组名)实际返回的是整个数组所占用内存空间(以字节为单位的)
#include <stdio.h>
int main(int argc , char *argv[])
{
int b1[100];
short b2[100];
double b3[100];
printf("sizeof(b1) = %d.\n", sizeof(b1)); //400 100xsizeof(int)
printf("sizeof(b2) = %d.\n", sizeof(b2)); //200 100xsizeof(int)
printf("sizeof(b3) = %d.\n", sizeof(b3)); //800 100xsizeof(double)
return 0;
}
C语言高级专题第8部分-4.8 一些杂散但值得讨论的问题
第一部分 章节目录
4.8.1 操作系统究竟是个什么玩意?
4.8.1.1 像人类社会一样的计算机软件系统(有些人只埋头干活,有些人只做管理)
(1)人类社会最开始时人人都干活,这时候没有专业分工,所有人都直接做产生价值的工作。
当时是合适的,因为当时生产力低下,人口稀少。这就像裸机程序一样(裸机程序的特点是:
代码量小,功能简单、所有代码都和直接目的有关,没有服务性代码)。
(2)后来人口增加生产力提高,有一部分人脱离了直接产生价值的体力劳动专职指挥(诞生了阶级)。
本质上来说是合理的,因为资源得到了更大限度的使用,优化了配置,提升了整体效率。
程序也是一样,当计算机技术发展,计算机性能和资源大量增加,这时候写代码也要产生阶级也要
进行分工,不然如果所有代码都去参加直接性的工作,则整体系统效率不高。(因为代码很难进行资源的
优化配置)。
(3)解决方案就是操作系统。操作系统就是分出来的管理阶级,操作系统的代码本身不直接产生价值,它
的主要任务是管理所有资源,它主要为直接产生价值、直接劳动的那些程序(各种应用程序)提供服务。
所以操作系统既是管理者也是服务者。
(4)裸机程序就好像小公司,操作系统下的程序就好像大型跨国公司;裸机程序好像小国家,
操作系统下程序好像大国家;如果我们要做一个产品,软件系统到底应该是裸机还是基于操作系统呢?
本质上取决于产品本身的复杂度。只有极简单的功能、使用极简单的CPU(譬如单片机)的产品才会选择使用
裸机开发;一般的复杂性产品都会选择基于操作系统来开发。
4.8.1.2 操作系统的调用通道:API函数
(1)操作系统负责管理和资源调配,应用程序负责具体的直接劳动,他们之间的接口就是API函数。
当应用程序需要使用系统资源(譬如内存、譬如CPU、譬如硬件操作)时就通过API向操作系统发出
申请,然后操作系统响应申请帮助应用程序执行功能。
4.8.1.3 C库函数和API的关系
(1)单纯的API只是提供了极其简单没有任何封装的服务函数,这些函数应用程序时可用的,
但是不太好用。应用程序了好用,就对这个API进行了二次封装,把它变得好用一些,于是就成了
C库函数。
(2)有时完成一个功能,有相应的库函数可用完成,也有API可以完成,用哪个都行。譬如读写文件,API
的接口是open write read close;库函数的接口是fopen fwrite fread fclose。
fopen本质上是使用open实现的,只是进行了封装。封装肯定有目的(添加缓冲机制)。
4.8.1.4 不同平台(windows linux 裸机)下库函数的差异
(1)不同操作系统API是不同的,但是都能完成所有的任务,只是完成一个任务所调用的API不同。
(2)库函数在不同操作系统下也不同,但是相似性要更高一些。这是人为的,因为人下意识想要屏蔽不同操作系统的差异
,因此在封装API成库函数的时候,尽量使用了同一套接口,所以封装出来的库函数挺像的。但是还是有差异,
所以在一个操作系统上写的应用程序不可能直接在另一个操作系统上编译运行。
(3)于是乎就有个可移植性出来了。跨操作系统可移植平台,譬如QT平台、譬如JAVA语言。
4.8.1.5 操作系统的重大意义:软件体系分工
(1)有了操作系统后,我们做一个产品可以首先分成2部分:一部分人负责操作系统(开发驱动的);
一部分人负责用操作系统实现具体的功能(开发应用)。实际上上层应用层的功能进一步复杂后
又分了好多层。
4.8.2 main函数返回值给谁?
4.8.2.1 函数为什么需要返回值
(1)函数在设计的时候设计了参数和返回值,参数是函数的输入,返回值是函数的输出。
(2)因为函数需要对外输出数据(实际上是函数运行的一些结果值)因此需要返回值。
(3)形式上来说,函数被另一个函数所调用,返回值作为函数式的值返回给调用这个函数的地方
总结:函数的返回值就是给调用它的人返回一个值
4.8.2.2 main函数被谁调用
(1)main函数是特殊的,首先这个名字是特殊的。因为C语言规定了main函数是整个程序的入口。
其他的函数只有直接或间接被main函数所调用才有被执行,如果没有被main直接/间接调用则这个函数
在整个程序中无用。所有说main函数是领导,不巴结好领导根本没有你发挥的空间。
(2)main函数从某种角度来讲代表了我当前这个程序,或者说代表了整个程序。main函数的开始
意味着整个程序开始执行,main函数的结束返回意味着整个程序的结束。
(3)谁执行了这个程序,谁就调用了Main。
(4)谁执行了程序?或者说程序有哪几种被调用执行的方法?
4.8.2.3 linux下一个新程序执行的本质
(1)表面来看,linux中在命令行中./xx执行一可执行程序
(2)我们还可以通过shell脚本来调用执行一个程序
(3)我们还可以在程序中去调用执行一个程序(fork exec)
总结:我们有多种方法都可以执行一个程序,但是本质上是相同的。linux中一个新程序的执行
本质上是一个进程的创建、加载、运行、消亡。linux中执行一个程序其实就是创建一个新进程
然后把这个程序丢进这个进程中去执行直到结束。新进程是被谁开启?
在linux中进程都是被它的父进程fork出来的。
分析:命令行本身就是一个进程,在命令行底下去./xx执行一个程序,其实这个新程序是作为命令行
进程的一个子进程去执行的。
总之一句话:一个程序被它的父进程所调用。
结论:main函数返回给调用这个函数的父进程。父进程要这个返回值干嘛?父进程调用子进程来执行
一个任务,然后子进程执行完后通过main函数的返回值返回给父进程一个答复。这个答复一般是表示
子进程的任务执行结果完成了还是错误了。(0表示执行成功,负数表示失败)
4.8.2.4 实践验证获取main的返回值
(1)用shell脚本执行程序可以获取程序的返回值并且打印出来
编译一个main函数
#include <stdio.h>
int main(void)
{
return 123;
}
得到a.out
创建一个shell
#!/bin/sh
./a.out
echo $? //$?表示子进程的返回值123
(2)linux shell中用$?这个符号来存储和表示上一个程序执行结果
4.8.2.5 启示
(1)任何人任何事物都是有妈生的,不会无缘无故出现或消亡。
(2)看起来没用、改掉或去掉没错,也不见得就真的的没用没错。要大胆总结更要小心求证。
4.8.3 argc、argv与main函数的传参
4.8.3.1 谁给main函数传参
(1)调用main函数所在的程序的它的父进程给main函数传参,并且接收main函数的返回值。
4.8.3.2 为什么需要给main函数传参
(1)首先,main函数不传参是可以的,也就是说父进程调用子程序并且给子程序传参不是必须的。
int main(void)这种形式就表示我们认为不必要给main传参。
(2)有时候我们希望程序有一种灵活性,所以选择在执行程序时通过传参来控制程序中的运行,
达到不需要重新编译程序就可以改变程序运行结果的效果。
4.8.3.3 表面上:给main传参是怎样实现的?
(1)给main传参通过argc和argv这两个c语言预定的参数来实现的。
(2)argc是int类型,表示运行程序的时候给main函数传递了几个参数。argv是一个字符串数组,
这个数组用来存储多个字符串,每个字符串就是我们给main函数传的一个参数。argv[0]就是我们给
main函数的第一个传参,argv[1]就是传给main的第二个参数...
#include <stdio.h>
int main(int argc, char *argv[])
{
int i = 0;
printf("the number of argument is: %d.\n", argc);
for (i=0; i<argc; i++)
{
printf("the %d argument is %s.\n", i, argv[i]);
}
return 0;
}
运行./a.out abc 33 xx 通过空格间隔给参数
the number of argument is: 4.
the 0 argument is ./a.out.
the 1 argument is abc.
the 2 argument is 33.
the 3 argument is xx.
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
int i = 0;
printf("the number of argument is: %d.\n", argc);
if(argc != 2)
{
printf("we need 2 param.\n");
}
if(!strcmp(argv[1], "0"))
{
printf("boy\n");
}
else if(!strcmp(argv[1], "1"))
{
printf("girl\n");
}
else
{
printf("unknown\n");
}
for (i=0; i<argc; i++)
{
printf("the %d argument is %s.\n", i, argv[i]);
}
return 0;
}
运行./a.out 1
the number of argument is: 2.
girl
the 0 argument is ./a.out.
the 1 argument is 1.
运行./a.out 0
the number of argument is: 2.
boy
the 0 argument is ./a.out.
the 1 argument is 0.
4.8.3.4 本质上:给main传参是怎样实现的?
(1)上节课讲过,程序调用有各种方法但是本质上都是是父进程fork一个子进程,然后子进程和一个程序绑定
起来去执行(exec函数),我们exec的时候可以给他同时传参。
(2)程序调用时可以被传参(也就是main的传参)是操作系统层面的支持完成的。
4.8.3.5 给main函数传参要注意什么?
(1)main函数传参都是通过字符串传进去的。
(2)程序被调用时传参,各个参数之间是通过空格来间隔的。(空格可以是多个)
(3)在程序内部如果要使用argv,那么一定要先检验argc。
题目:写一个计算器,然后运行时可以./calculator 3 + 5,程序执行返回8
4.8.4 void 类型的本质
4.8.4.1 C语言属于强类型语言
(1)编程语言分2种:强类型语言和弱类型语言。强类型语言中所有的变量都有自己固定的类型,
这个类型有固定的内存占用,有固定的解析方法;弱类型语言中没有类型的概念,所有的变量都是
一个类型(一般都是字符串的)。程序在用的时候再根据需要来处理变量。像makefile shell
makefile也是一门弱类型的语言
(2)C语言就是典型的强类型语言,C语言中所有的变量都有明确的类型。因为C语言中的一个变量都要
对应内存中的一段内存,编译器需要这个变量的类型来确定这个变量占用内存的字节数和这一段内存的
解析方法。
4.8.4.2 数据类型的本质含义
(1)数据类型的本质就决定变量的内存占用数,和内存的解析方法。
(2)所以得出结论:C语言中变量必须有确定的数据类型,如果一个变量没有确定的类型(就是所谓
的无类型)会导致编译器无法给这个变量分配内存,也无法解析这个变量对应的内存。因此得出结论
不可能没有类型的变量。
(3)但是C语言中可以有没有类型的内存。变量其实就是一个名字,内存是这个变量所对应的实质。
就像一个人的名字对应这个变量,身体对应内存这个实体。名字脱离你这个人也没有意义,他俩是
一体的。在内存还没有和具体的变量绑定之前,内存就可以没有类型。实际上纯粹的内存就是没有类型的。
内存只是因为和具体的变量相关联后才有了确定的类型(其实内存自己本身是不知道的,而编译器知道,
我们程序在使用这个内存时知道类型所以会按照类型的含义去进行内存的读和写)。内存就像一个仓库,
它不关心里面放什么东西,但是你往里面放炸药拿走的也必须是炸药,你存火腿肠拿走的也必须是火腿肠,
否则就会出错。
4.8.4.3 void类型的本质
(1)void类型的正确的含义:不知道类型,不确定类型,还没确定类型。肯定有类型。
(2)void a;定义了一个void类型的变量,含义就是说a是一个变量,而且a肯定有确定的类型,只是目前我还
不知道a的类型,还不确定,所以标记为void。就像你不知道一个人的名字,不代表别人没有名字。
4.8.4.4 为什么需要void类型
(1)什么情况下需要void类型?其实就是在描述一段还没有具体使用的内存时需要使用void类型。
将来存什么还不知道,现在还只是一张白纸。白内存。
(2)void的一个典型应用案例就是malloc的返回值。我们知道malloc函数向系统堆管理器申请一段内存给当
前程序使用,malloc返回的是一个指针,这个指针指向申请的那段内存。mallco刚申请的这段内存尚未用来存储数据,
malloc函数也无法预知这段内存将来被存放什么类型的数据,所以malloc无法返回具体类型的指针,解决方法
就是返回一个void *类型,告诉外部我返回的是一段干净的内存空间,尚未确定类型。所以我们在malloc之后
可以给这段内存读写任意类型的数据。
(3)void *类型的指针指向的内存是尚未确定类型的,因此我们后续可以使用强制类型转换强行将其转为各种类型。
这就是void类型的最终归宿,就是被强制类型转换成一个具体类型。早晚得转成具体的类型,躲得了初一躲不了十五。
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
void a; // error: variable or field 'a' declared void
a = 5; //编译器不会让你过的,你不是一个具体的类型,C语言的变量必须是一个具体的类型
return 0;
}
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
int *p = (int *)malloc(4); //由void *强制转为int *,不会警告,我是在帮助你转为具体的类型,解脱你
return 0;
}
(4)void类型使用时一般都是用void *, 与指针相关联,而不是仅仅使用void。因为你单纯的void变量是没有任何意义的,
本身并不能和内存去用,有*就意味着指针,有指针就意味着有内存,有内存意味着这个内存没有类型。
4.8.5 C语言中的NULL
4.8.5.1 NULL在C/C++中的标准定义
(1)NULL不是C语言关键字,本质上是一个宏定义
(2)NULL的标准定义
#ifdef _cplusplus
#define NULL 0
#else
#define NULL (void *)0 //这里对应C语言的情况
#endif
解释:C++的编译环境中,编译器预先定义了一个宏_cplusplus,程序中可以用条件编译来判断当前的编译环境是
C++的还是C的。
NULL的本质解析:NULL的本质是0,但是这个0不是当一个数字解析,而是当一个内存来解析的,这个0其实是
0x00000000,代表内存的0地址。(void *)0这个整体表达式表示一个指针,这个指针变量本身占用4个字节
,地址在哪里取决于指针变量本身,但是这个指针变量的值是0,也就是说这个指针变量指向0地址(实际是0地址开始的
的一段内存,有多长我们不知道,因为是void *类型的,如果是int *类型的就是4个字节),意思就是它可以是各种
类型的,现在还不能确定。
4.8.5.2 从指针角度理解NULL的本质
(1)int *p; //p是一个函数内的局部变量,则p的值是随机的,也就是说p是一个野指针。
(2)int *p = NULL;
//p是一个局部变量,分配在栈上的地址是由编译器决定的,我们不关心,但是p的值是
(void *)0,实际就是0,意思是指针p指向内存的0地址处。这时候P就不是野指针了。
为什么p不是野指针了呢?因为p已经指向了一个安全的地方了。内存的0地址处就是安全的。
为什么内存0地址处是安全的呢?因为内存的0地址处一般都是不能随便访问的地方,一般都是CPU
重点保护的地方。
为什么是void *呢而不是int *,主要是因为你这个指针p是什么类型的。void *类型是万能的,
不管你的左边是什么类型的指针,我都可以和你应对。
(3)为什么要让一个 野指针指向内存地址0处?主要是因为在大部分的CPU中,内存的0地址处都不是
可以随便访问的(一般都是操作系统严密管控区域,所以应用程序不能随便访问)。所以野指针指向了这个区域可以
保证野指针不会造成误伤。如果程序无意识的解引用指向0地址处的野指针则会触发段错误。这样就可以提示你帮助
你找到程序中的错误。(部队里背枪,枪口都是朝上的,避免误伤,即使走火了也不会打到人)
4.8.5.3 为什么需要NULL
(1)第一个作用就是让野指针指向0地址处安全。
(2)第二个作用就是一个特殊标记,按照标准的指针使用步骤是:
int *p = NULL; //定义p时立即初始化为NULL
p = xx;
if (NULL != p)
{
*p //在确认p不等于NULL的情况下才去解引用p
//如果p=NULL我们就知道是野指针,能够不能解引用的
}
p = NULL; //用完 之后p再次等于NULL
注意:一般比较一个指针和NULL是否相等不写成if (p == NULL), 而写成if (NULL == P)。
原因是第一种写法中如果不小心把==写成了=,则编译器不会报错,但是程序的意思完全不一样
;而第二种写法如果不小心把==写成了=则编译器会发现并报错。
4.8.5.4 注意不要混用NULL与'\0'
(1)'\0'和'0'和0和NULL几个区分开。
(2)'\0'是一个转义字符,他对应的ASCII编码值是0,本质就是0
(3)'0'是一个字符,他对应的ASCII编码值是48,本质是48
(4)0是一个数字,他不是一个字符,他就是0,本质就是0
(5)NULL是一个表达式,是强制类型转换为void *类型的0,本质是0
总结:'\0'用法是C语言字符串的结尾标志,一般用来比较字符串中的字符以判断字符串有没有到头;
'0'是字符0,对应0这个字符的ASCII编码,一般用来获取0的ASCII码值;0是数字,一般用来比较一个int
类型的数字是否等于0;NULL是一个表达式,一般用来比较指针是否是一个野指针。