C语言解剖整理
1、名词说明:
定义:(编译器)创建一个对象,并为对象分配一块内存并给它取名,即变量名或对象名。例: int a = 0;
声明:没有分配内存
- 告诉编译器,这个名字已经匹配到一块内存上,声明的变量或对象是在别的地方定义的。生命可以出现多次
- 告诉编译器,这个名字先预定了,别的地方再也不能用它来作为变量名或对象名
机器数:一个数在计算机中的表现形式叫做机器数,在计算机中用一个数的最高位(符号位)用来表示它的正负,其中0表示正数,1表示负数。例如正数7,在计算机中用一个8位的二进制数来表示,是00000111,而负数-7,则用10000111表示,这里的00000111和10000111是机器数
原码:用第一位表示符号,其余位表示值。因为第一位是符号位,所以8位二进制数的取值范围就是:[1111_1111 , 0111_1111] 即 [-127 , 127] ,原码是容易被人脑所理解的表达方式。所以对于原码来说,能满足正数的加法,但无法满足负数的加法
反码:正数的补码反码是其本身,负数的反码是符号位保持不变,其余位取反。例如正数1的原码是[0000_0001],它的反码是是其本身[0000_0001],-1的原码是[1000_0001],其反码是[1111_1110],在实际计算中每当跨过0一次,就有一个单位的误差
补码:正数的补码是其本身,负数的补码是在其反码的基础上+1,例如正数1的原码是[0000_0001],他的补码是其本身[0000_0001],-1的补码是[1111_1111]
- 在 C 语言里,赋值符号“=”号两边的数据类型必须是相同的,如果不同需要显示或隐式的类型转换
- C语言中,当一维数组作为函数参数的时候,编译器总是把它解析长一个指向其首元素首地址的指针。但是,这条规则不是递归的,也就是说只有一维数组才是如此,当数组超过一维时,将第一维改写为指向数组首元素首地址的指针后,后面的维数再也不可改写(比如: a[3][4][5]作为参数时可以被改写为(*p) [4][5])。在C语言中,所有非数组形式的数据实参均以传参形式(对实参做一份拷贝并传递给被调用的函数,函数不能修改作为参数的实际变量的值,而只能修改传递给它的那份拷贝)调用
- 函数本身是没有类型的,只有函数的返回值有类型
- 不管什么时候,我们在使用指针之前一定要确保指针是有效的。
- 只有字符串常量才有结束标识符’\0’,比如下面这种写法就没有结束标识符:
char a[7] = {‘a’,’b’,’c’,’d’,’e’,’f’,’g’};
2、关键字
ANSI C语言标准中32个关键字
关键字 | 意义 |
auto | 声明自动变量,缺省时编译器一般默认为 auto |
int | 声明整型变量 |
default | 开关语句中的“其他”分支 |
break | 跳出当前循环 |
volatile | 说明变量在程序执行中可被隐含地改变 |
C99标准在此基础上增加5个关键字:
Inline:
Restrict:
Bool:
_Complex:
_Imaginary:
C11标准又增加7个关键字:
_Alignas:
_Alignof:
_Atomic:
_Static_assert:
_Noretum:
_Thread_local:
_Generic:
==========================================================================
auto:
编译器在默认的缺省情况下,所有的变量都是auto的
register:
请求编译尽可能的将变量存在CPU内部寄存器中而不是通过内存寻址访问以提高效率。寄存器其实就是一块一块小的存储空间,只不过器存储速度要比内存快的多。register必须是能被CPU所接受的类型。意味着register变量必须是一个单个的值,并且其长度应小于或等于整形的长度。而且register变量可能不存放在内存中,所以不能使用”&”来获取register变量的地址
static:
1)修饰变量:存在内存的静态区
静态全局变量:作用域仅限于变量被定义的文件中,确切的作用域为从定义之处开始,到文件结尾处结束,在定义之处之前的位置不能使用。
静态局部变量:作用域在函数体中,会保存上次运行结束的变量值
2)修饰函数:函数作用域仅限于本文件
基本数据类型:short、 int、 long、 char、 float、 double
32位系统:char(1byte)、short(2byte) 、 int(4byte)、float(4byte)、long(4byte)、double(8byte)
sizeof:
sizeof在计算变量所占空间大小时,括号可以省略,而计算类型大小时不能省略
signed/unsigned:
默认为signed 类型,注意界限值
if/else:
一般表示一两个分支或是嵌套少量的分支
C语言规定,else与最近的if匹配;先处理正常情况,在处理异常情况
- bool与”零值”比较:
bool bTestFlag = FALSE;
使用方式:if(bTestFlag) 或者if(!bTestFlag) // 因为有些编译器的TRUE不为1
- float与”零值”比较:需要注意精度
- 指针变量与”零值”比较:
int * p = NULL;/
if(NULL ==p) 或者if(NULL!=p)
switc/ case:
处理分支较多的情况,case语句应处理简单的容易分类的数据
switch(variable)
{
case Value1:
//program code
break;
case Value2:
//program code
break;…
default:
break;
}
1)每个case语句后面需要加break语句
2)必须使用default语句,
3)case后面只能是整型或字符型的常量或常量表达式
4)case语句的排列顺序
4-1)按字母或数字顺序排列各条case语句
4-2)把正常情况放在前面,异常情况放在后面,并做好注释
-
- 按执行频率排列case语句
5)使用case语句注意事项
5-1)简化每种情况对应的操作,代码尽可能精炼
5-2)不要为了使用case语句而可以制造一个变量,可能有隐含错误
5-3)把default子句只用于检查真正的默认情况
do/while/for/break/continue:
C语言中有三种循环语句:while、do-while和for循环
while 循环:先判断 while 后面括号里的值,如果为真则执行其后面的代码;否则不执行。 while(1)表示死循环。
do-while 循环:先执行 do 后面的代码,然后再判断 while 后面括号里的值,如果为真,循环开始;否则,循环不开始。
for 循环: for 循环可以很容易的控制循环次数,多用于事先知道循环次数的情况下
break: 关键字很重要,表示终止本层循环
continue: 表示终止本次(本轮) 循环。当代码执行到 continue 时,本轮循环终止,进入下一轮循环
循环语句使用注意点:
- 在多重循环中,如果有可能,应当将最长的循环放在最内层,最短的放在最外层,以减少CPU跨切循环层的次数
- 建议for语句 的循环控制变量的取值采用”半开半闭区间”写法,
- 不能在for循环体中修改循环变量,防止循环失控
- 循环尽可能的短,要使代码清晰,一目了然。一般来说循环内的代码不要超过20行
- 把循环嵌套控制在3层内
goto:
会破坏结构化的设计语句,也会带来错误或隐患
void:
如果函数没有返回值,那么应声明为void类型。
如果函数的参数可以是任意类型指针,那么应声明其参数为 void *。
void 不能代表一个真实的变量
在C语言中,凡不加返回值类型限定的函数,就会被编译器作为返回整形值处理
void发挥的作用在于:
- 对函数返回的限定
- 对函数参数的限定
void *可以由任何类型的指针直接赋值给它,而不需要强制转换,但并不意味着void*无需强制类型转换就可以赋值给其他类型的指针。
void 指针:
按照ANSI的标准,不能对void指针进行算法操作。ANSI认为:进行算法操作的指针必须是确定知道其指向数据类型的,也就是说必须知道内存目的地址的确切值。
GUN指定void *的算法操作和char *一致
return:
用来终止一个函数并返回其后面跟着的值
return语句不可返回指向“栈内存”的“指针”,因为该内存在函数结束时被自动销毁
const:
修饰只读变量,其值在编译时不能被使用,因为编译器在编译时不知道其存储的内容
- const修饰的只读变量
const修饰的只读变量必须在定义的同时初始化
- 节省空间,避免不必要的内存分配,同时提高效率
#define宏在预编译阶段进行替换,而const修饰的只读变量是在编译的时候确定其值(编译器通常不为普通的const只读变量分配存储空间,而是将它们保存在符号表中,程序运行时,const修饰的只读全局变量保存在静态区)
- 修饰一般变量—简单类型的只读变量
这种只读变量在定义时,修饰符const可以用在类型说明符前,也可以用在类型说明符后,例如:int const i=2; 或 const int i=2;
- 修饰数组
定义或说明一个只读数组可采用如下格式:
int const a[5]={1, 2, 3, 4, 5};或 const int a[5]={1, 2, 3, 4, 5};
- 修饰指针
const int *p; //p可变,*p不可变
int const *p; // p可变,*p不可变
int *const p; //p不可变,*p可变
const int *const p; //p和*p都不可变
- 修饰函数参数和返回值
const修饰的只读变量不能用作定义数组的维数,也不能放在case关键字后面
volatile:
修饰的变量表示可以被某些编译器未知的因素更改
遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问
extern:
表示变量或函数的定义在别的文件中,提供编译器遇到此变量和函数时在其他模块中寻找其定义
struct:
多种数据类型组合成一个整体
- 空结构体
非空结构体类型数据最少也需要占用一个字节的空间,所以编译器为每个结构体类型数据至少保留1个byted的空间。所以空结构体的大小为1byte
- 柔性数组
C99 中,结构中的最后一个元素允许是未知大小的数组,这就叫做柔性数组成员,但结构中的柔性数组成员前面必须至少一个其他成员。 柔性数组成员允许结构中包含一个大小可变的数组。 sizeof 返回的这种结构大小不包括柔性数组的内存。包含柔性数组成员的结构用malloc ()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小
typedef struct st_type
{
int i;
int a[];
}type_a;
sizeof(type_a) = 4,可以使用如下表达式为结构体分配内存:
type_a *p = (type_a*)malloc(sizeof(type_a)+100*sizeof(int));
sizeof(*p) =4
在定义这个结构体的时候,模子的大小就已经确定不包含柔性数组的内存大小。柔性数组只是编外人员,不占结构体的编制。只是说在使用柔性数组时需要把它当作结构体的一个成员,仅此而已。再说白点,柔性数组其实与结构体没什么关系,算不上结构体的成员。
- struct VS class
struct的成员默认属性是public的,class的成员默认属性是private的
union:主要用来压缩空间,如果有些数据不能同时被使用,则使用union
union 维护足够的空间来置放多个数据成员中的“一种”
在union中,所有的数据成员共用一个成员,同一时间只能存储一个数据成员,所有数据成员拥有相同的起始地址
enum:
如果都没有赋值,它们的值从 0 开始依次递增 1。
enum enum_type_name
{
ENUM_CONST_1,
ENUM_CONST_2,
...
ENUM_CONST_n
} enum_variable_name;
enum_type_name 是自定义的一种数据数据类型名,而 enum_variable_name 为
enum_type_name类型的一个变量, 也就是我们平时常说的枚举变量。实际上enum_type_name类型是对一个变量取值范围的限定,而花括号内是它的取值范围,即 enum_type_name 类型的变量 enum_variable_name 只能取值为花括号内的任何一个值,如果赋给该类型变量的值不在列表中,则会报错或者警告。
枚举vs #define
1) #define 宏常量是在预编译阶段进行简单替换。枚举常量则是在编译的时候确定其值。
2)一般在编译器里,可以调试枚举常量,但是不能调试宏常量。
3)枚举可以一次定义大量相关的常量,而#define 宏一次只能定义一个。
typedef:
给一个已经存在的数据类型取一个别名,而非定义一个新的数据类型。
typedef vs #define
3、命名规则
【规则 1-1】命名应当直观且可以拼读,可望文知意,便于记忆和阅读
【规则 1-2】命名的长度应当符合“min-length && max-information”原则
【规则 1-3】当标识符由多个词组成时,每个词的第一个字母大写,其余全部小写
【规则 1-4】尽量避免名字中出现数字编号,如 Value1,Value2 等,除非逻辑上的确需要编号
【规则 1-5】对在多个文件之间共同使用的全局变量或函数要加范围限定符(建议使用模块名(缩写)作为范围限定符)
【规则 1-6】标识符名分为两部分:规范标识符前缀(后缀) + 含义标识 。非全局变量可以不用使用范围限定符前缀。
【规则 1-7】程序中不得出现仅靠大小写区分的相似的标识符
【规则 1-8】一个函数名禁止被用于其它之处
【规则 1-9】所有宏定义、枚举常数、只读变量全用大写字母命名,用下划线分割单词
【规则 1-10】考虑到习惯性问题,局部变量中可采用通用的命名方式,仅限于 n、 i、 j 等作为循环变量使用
【规则 1-11】定义变量的同时千万千万别忘了初始化。定义变量时编译器并不一定清空了这块内存,它的值可能是无效的数据
【规则 1-15】不同类型数据之间的运算要注意精度扩展问题,一般低精度数据将向高精度数据扩展
4、符号
标准C语言的基本符号
注释符号:
- 编译器会将注释剔除,但不是简单的剔除还是用空格代替原来注释
- 只要斜杠(/)和星号(*)之间没有空格,都会被当作注释的开始
- 出色的注释要求:
【规则 3-1】注释应当准确、易懂,防止有二义性。错误的注释不但无益反而有害。
【规则 3-2】边写代码边注释,修改代码同时修改相应的注释,以保证注释与代码的一致性。不再有用的注释要及时删除。
【规则 3-3】注释是对代码的“提示”,而不是文档。程序中的注释应当简单明了,注释太多了会让人眼花缭乱。
【规则 3-4】一目了然的语句不加注释。
【规则 3-5】对于全局数据(全局变量、常量定义等)必须要加注释。
【规则 3-6】注释采用英文,尽量避免在注释中使用缩写,特别是不常用缩写
【规则 3-7】注释的位置应与被描述的代码相邻,可以与语句在同一行,也可以在上行,但不可放在下方。同一结构中不同域的注释要对齐
【规则 3-8】当代码比较长,特别是有多重嵌套时,应当在一些段落的结束处加注释,便于阅读
【规则 3-9】注释的缩进要与代码的缩进一致
【规则 3-10】注释代码段时应注重“为何做(why)”,而不是“怎么做(how)”
【规则 3-11】数值的单位一定要注释
【规则 3-12】对变量的范围给出注释
【规则 3-13】对一系列的数字编号给出注释,尤其在编写底层驱动程序的时候(比如管脚编号)
【规则 3-14】对于函数的入口出口数据给出注释
接续符和转义符
C语言中以反斜杠(\)表示断行,编译器会将反斜杠剔除掉,跟在反斜杠后面的字符自动接续到前一行。但是注意:反斜杠后面不能有空格,反斜杠的下一行之前也不能有空格。
反斜杠除了被用作接续符,还能被用作转义字符的开始标识
常用的转义字符及其含义:
转义字符 转义字符的意义
\n | 回车换行 |
单引号与双引号
双引号引起来的是字符串常量,单引号引起来的是字符常量
逻辑运算符
|| 和&& 是常用的逻辑运算符
位运算符
C语言中位运算符包括下面几种:
& 按位与
| 按位或
^ 按位异或
~ 取反
<< 左移:双目运算符,高位丢弃,低位补0
>> 右移: 对于有符号数,在右移时,符号位将随同移动。当为正数时,最高位补0,而为负数时,符号位为1,最高位补0还是补1取决于编译系统的规定。Turbo C和很多系统都规定为1
左移和右移的位数不能大于数据的长度,不能小于0
花括号
作用: 为了把一些语句或代码打个包包起来,使之形成一个整体,并与外界绝缘
++、--操作符
C语言规定:每一个符号应该包含尽可能多的字符。也就是说,编译器将程序分解成符号的方法是,从左到右一个一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符,判断已经读入的两个字符组成的字符串是否可能是一个符号的组成部分;如果可能,继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串
已不再可能组成一个有意义的符号。这个处理的策略被称为“贪心法”。
++、--作为前缀,是先自加或自减,然后在做其他的打算
++、--作为后缀,
问题:
++i+++i+++i
运算符的优先级
优先级 | 运算符 | 名称或含义 | 使用形式 | 结合方向 | 说明 |
1 | [] | 数组下标 | 数组名[常量表达式] | 左到右 |
|
() | 圆括号 | (表达式) 参表) /函数名(形 |
| ||
. | 成员选择(对象) | 对象.成员名 |
| ||
-> | 成员选择(指针) | 对象指针->成员名 |
| ||
2 | - | 负号运算符 | -表达式 | 右到左 | 单目运算符 |
(类型) | 强制类型转换 | (数据类型)表达式 | |||
++ | 自增运算符 | ++变量名/变量名++ | 单目运算符 | ||
-- | 自减运算符 | --变量名/变量名-- | 单目运算符 | ||
* | 取值运算符 | *指针变量 | 单目运算符 | ||
& | 取地址运算符 | &变量名 | 单目运算符 | ||
! | 逻辑非运算符 | !表达式 | 单目运算符 | ||
~ | 按位取反运算符 | ~表达式 | 单目运算符 | ||
sizeof | 长度运算符 | sizeof(表达式) | |||
3 | / | 除 | 表达式/表达式 | 左到右 | 双目运算符 |
* | 乘 | 表达式*表达式 | 双目运算符 | ||
% | 余数(取模) | 整型表达式/整型表 | 双目运算符 | ||
4 | + | 加 | 表达式+表达式 | 左到右 | 双目运算符 |
- | 减 | 表达式-表达式 | 双目运算符 | ||
5 | << | 左移 | 变量<<表达式 | 左到右 | 双目运算符 |
>> | 右移 | 变量>>表达式 | 双目运算符 | ||
6 | > | 大于 | 表达式>表达式 | 左到右 | 双目运算符 |
>= | 大于等于 | 表达式>=表达式 | 双目运算符 | ||
< | 小于 | 表达式<表达式 | 双目运算符 | ||
<= | 小于等于 | 表达式<=表达式 | 双目运算符 | ||
7 | == | 等于 | 表达式==表达式 | 左到右 | 双目运算符 |
!= | 不等于 | 表达式!= 表达式 | 双目运算符 | ||
8 | & | 按位与 | 表达式&表达式 | 左到右 | 双目运算符 |
9 | ^ | 按位异或 | 表达式^表达式 | 左到右 | 双目运算符 |
10 | | | 按位或 | 表达式|表达式 | 左到右 | 双目运算符 |
11 | && | 逻辑与 | 表达式&&表达式 | 左到右 | 双目运算符 |
12 | || | 逻辑或 | 表达式||表达式 | 左到右 | 双目运算符 |
13 | ?: | 条件运算符 | 表达式 1? 表达式 2: | 右到左 | 三目运算符 |
14 | = | 赋值运算符 | 变量=表达式 | 右到左 | |
/= | 除后赋值 | 变量/=表达式 | |||
*= | 乘后赋值 | 变量*=表达式 | |||
%= | 取模后赋值 | 变量%=表达式 | |||
+= | 加后赋值 | 变量+=表达式 | |||
-= | 减后赋值 | 变量-=表达式 | |||
<<= | 左移后赋值 | 变量<<=表达式 | |||
>>= | 右移后赋值 | 变量>>=表达式 |
&= | 按位与后赋值 | 变量&=表达式 |
| ||
^= | 按位异或后赋值 | 变量^=表达式 |
| ||
|= | 按位或后赋值 | 变量|=表达式 |
| ||
15 | , | 逗号运算符 | 表达式,表达式,… | 左到右 | 从左向右顺 |
容易出错的优先级问题
优先级问题 | 表达式 | 经常误认为的结果 | 实际结果 |
.的优先级高于* | *p.f | p 所指对象的字段 f | 对 p 取 f 偏移,作为 |
[]高于* | int *ap[] | ap 是个指向 int 数组 | ap 是个元素为 int |
函数()高于* | int *fp() | fp 是个函数指针,所 | fp 是个函数,返回 |
== 和!=高于位操作 | (val & mask != 0) | (val & mask)!= 0 | val & (mask != 0) |
== 和!=高于赋值符 | c = getchar() != | (c = getchar()) != | c = (getchar() != |
算术运算符高于位移 | msb << 4 + lsb | (msb << 4) + lsb | msb << (4 + lsb) |
逗号运算符在所有运 | i = 1,2 | i = (1,2) | (i = 1),2 |
5、预处理
ANSI 标准定义的C语言预处理指令
表(3.1) 预处理指令
预处理名称 | 意 义 |
#define | 宏定义 |
#undef | 撤销已定义过的宏名 |
#include | 使编译程序将另一源文件嵌入到带有#include 的源文件中 |
#if | #if 的一般含义是如果#if 后面的常量表达式为 true, 则编译它与#endif 之间的代码,否则跳过这些代码。命令#endif 标识一个#if 块的结束。 #else命令的功能有点象 C 语言中的 else , #else 建立另一选择(在# if 失败的情况下)。 #elif 命令意义与 else if 相同,它形成一个 if else-if 阶梯状语句,可进行多种编译选择。 |
#else | |
#elif | |
#endif | |
#ifdef | 用#ifdef 与#ifndef 命令分别表示“如果有定义”及“如果无定义”,是条件编译的另一种方法。 |
#ifndef | |
#line | 改变当前行数和文件名称,它们是在编译程序中预先定义的标识符 |
#error | 编译程序时,只要遇到 #error 就会生成一个编译错误提示消息,并停止编译 |
#pragma | 为实现时定义的命令,它允许向编译程序传送各种指令例如,编译程序可能有一种选择,它支持对程序执行的跟踪。可用#pragma 语句指定一个跟踪选择。 |
另外 ANSI 标准 C 还定义了如下几个宏:
_LINE_ 表示正在编译的文件的行号
_FILE_ 表示正在编译的文件的名字
_DATE_ 表示编译时刻的日期字符串,例如: "25 Dec 2007"
_TIME_ 表示编译时刻的时间字符串,例如: "12:30:55"
_STDC_ 判断该文件是不是定义成标准 C 程序
宏定义
1)#define宏定义可以出现在任何地方,从本行宏定义开始,以后的代码都认识这个宏了
2)宏函数被调用是以实参代替形参,而不是“值传送”
3)定义宏的时候需要注意什么时候该用空格,什么时候不该用空格。空格仅在定义的时候有用,在使用宏函数的时候,空格会被编译器忽略
4)#undef 是用来撤销宏定义的,也就是说宏的生命周期从#define开始到#undef结束
使用方式如下:
#define XX xx
//code
#undef
条件编译
条件编译的功能使得我们可以按不同的条件去编译不同的程序部分, 因而产生不同的目标代码文件。这对于程序的移植和调试是很有用的。条件编译有三种形式:
1)#ifdef 标识符
程序段 1
#else
程序段 2
#endif
2)#ifndef
标识符程序段 1
#else
程序段 2
#endif
3)#if 常量
表达式程序段 1
#else
程序段 2
#endif
文件包含
用来将多个文件连接成一个文件进行编译,结果生成一个目标文件。C语言提供#include命令来实现文件包含的操作,它实际上是宏定义的延伸,有两种格式:
格式 1:
#include <filename>
其中, filename 为要包含的文件名称,用尖括号括起来,也称为头文件,表示预处理到系统规定的路径中去获得这个文件(即 C 编译系统所提供的并存放在指定的子目录下的头文件)。找到文件后,用文件内容替换该语句。
格式 2:#include “filename”
其中, filename 为要包含的文件名称。双引号表示预处理应在当前目录中查找文件名为filename 的文件,若没有找到,则按系统指定的路径信息,搜索其他目录。找到文件后,用文件内容替换该语句。
需要强调的一点是: #include 是将已存在文件的内容嵌入到当前文件中
#error预处理
#error 预处理指令的作用是,编译程序时,只要遇到 #error 就会生成一个编译错误提示消息,并停止编译。其语法格式为:
#error error-message
注意,宏串 error-message 不用双引号包围。遇到#error 指令时,错误信息被显示,可能同时还显示编译程序作者预先定义的其他内容。
#line预处理
#line 的作用是改变当前行数和文件名称,它们是在编译程序中预先定义的标识符命令的基本形式如下:
#line number["filename"]
其中[]内的文件名可以省略。例如:#line 30 a.h
这条指令可以改变当前的行号和文件名,作用:在编译器的编写中,通过这条指令,可以保证文件名是固定的,不会被这些中间文件代替,有利于进行分析。
#pragma预处理
作用是设定编译器的状态或者是指示编译器完成一些特定的动作,其格式一般为:
#pragma para
其中 para 为参数,下面来看一些常用的参数:
1)#pragma message
Message参数:能够在编译信息输出窗口中输出相应的信息,对于源代码信息的控制是非常重要的。其使用方式为:
#pragma message(“消息文本”)
当编译器执行这条语句时,就在编译输出窗口中将消息文件打印出来
2)#pragma code_seg
格式如下:
#pragma code_seg([“section-name”[,“section-class”]])
能够设置程序中函数代码存放的代码段
3)#pragma once
只要在头文件的最开始加入这条指令就能够保证头文件被编译一次
4)#pragma hdrstop
#pragma hdrstop表示预编译头文件到此为止,后面的头文件不再编译
5)#pragma comment(…)
该指令将一个注释记录放入一个对象文件或可执行文件
6)#pragma pack
使用指令#pragma pack (),编译器将取消自定义字节对齐方式
使用指令#pragma pack (n),编译器将按照 n 个字节对齐。
其对齐的规则是,每个成员按其类型的对齐参数(通常是这个类型的大小)和指定对齐参数(这里是 n 字节)中较小的一个对齐, 即:min( n, sizeof( item )) 。并且结构的长度必须为所用过的所有对齐参数的整数倍,不够就补空字节。
为什么需要字节对齐?字、双字、四字?
- 运算符
#define SQR(x) printf("The square of "#x" is %d.\n", ((x)*(x)));
SQR(8);
则输出的是:
The square of 8 is 64.
8)##预算符
可以用于宏函数的替换部分,这个运算符把两个语言符号组合成单个语言符号。
看例子:
#define XNAME(n) x ## n
如果这样使用宏:
XNAME(8)
则会被展开成这样:
x8
类似于粘合剂
6、指针和数组
指针:
- 将数值存储到指定的内存地址(例:将地址0x12ff7c中存入数值0x100)
int *p = (int *)0x12ff7c; *p = 0x100;
*(int *)0x12ff7c =0x100
数组:
int a[5];
-
- a作为右值时,代表数组首元素的地址而非数组的首地址,仅仅是代表,并没有一个地方来存储这个地址,也就是说编译器并没有为数组a分配一块内存来存储其地址
- a不能作为左值!编译器会认为数组名作为左值代表的意思是a的首元素地址,但是这个地址开始的内存是一个整体,我们只能访问数组的某个元素而无法把数组当一个总体进行访问,所以我们可以把a[i]作为左值,但是不能把a作为左值
- a是20byte空间的名字,a[0],a[1]等为a的元素,但并非是元素的名字,编译器只给这20byte的空间(整体)取了一个名字,并没有为其元素取名字
- &a[0] 和&a的区别:虽然指相等,但意义不同
数组 vs 指针
数组与指针没有任何关系,只是经常穿着相似的衣服
- 以指针的形式访问和以下标的形式访问
- char *p = “abcdef”;
- char a[] = “123456”
-
- 以指针的形式和以下标的形式访问指针
例子 A)定义了一个指针变量 p, p 本身在栈上占 4 个 byte, p 里存储的是一块内存的首地址。这块内存在静态区,其空间大小为 7 个 byte,这块内存也没有名字。对这块内存的访问完全是匿名的访问。比如现在需要读取字符‘e’ ,我们有两种方式:
-
-
- 以指针的形式:*(p+4)。先取出p里存储的地址值,假设为0x0000FF00,然后加上4个字符的偏移量,得到新的地址0x0000FF04。然后取出0x0000FF04地址上的值
- 以下标的形式:p[4],编译器总是把下标的形式的操作解析为以指针的形式的操作。p[4]这个操作会被解析成:先取出 p 里存储的地址值,然后加上中括号中 4 个元素的偏移量,计算出新的地址,然后从新的地址中取出值。也就是说以下标的形式访问在本质上与以指针的形式访问没有区别
- 以指针的形式访问和以下标的形式访问数组
-
例子 B)定义了一个数组 a, a 拥有 7 个 char 类型的元素,其空间大小为 7。数组 a 本身在栈上面。对 a 的元素的访问必须先根据数组的名字 a 找到数组首元素的首地址,然后根据偏移量找到相应的值。这是一种典型的“具名+匿名”访问。比如现在需要读取字符‘5’ ,我们有两种方式:
1.2.1)以指针的形式: *(a+4)。 a 这时候代表的是数组首元素的首地址, 假设为 0x0000FF00,然后加上 4 个字符的偏移量,得到新的地址 0x0000FF04。然后取出 0x0000FF04 地址上的值。
1.2.2)以下标的形式: a[4]。编译器总是把以下标的形式的操作解析为以指针的形式的操作。 a[4]这个操作会被解析成: a 作为数组首元素的首地址,然后加上中括号中 4 个元素的偏移量,计算出新的地址,然后从新的地址中取出值
注意点:上面所描述的偏移4代表的是4个元素,偏移的单位是元素而不是个数
a与&a的区别
对指针进行加1操作,得到的是下一个元素的地址,而不是在原地址值上直接加1。所以,一个类型以T的指针的移动,以sizeof(T)为移动单位。
因此,a是一个一维数组,数组中有 5 个元素; ptr 是一个 int 型的指针。
&a+1:取数组a的首地址,该地址的值加上sizeof(a)的值,即&a+5*sizeof(int),也就是下一个数组的首地址,显然当前指针已经越过了数组的界限
(int*)(&a+1):把上一步计算出来的地址,强制转换为int*类型,赋值给ptr
(*a+1):a和&a的值一样,但是意思不一样。a是数组首元素的首地址,也就是a[0]的首地址,&a的值是一样的,a+1是数组下一元素的首地址,即a[1]的首地址,&a+1是下一个数组的首地址,所以输出为2
*(ptr-1):因为ptr是指向a[5],并且ptr是int *类型,所以*(ptr-1)是指向a[4],输出为5
指针和数组的对比
指针 | 数组 |
保存数据的地址,任何存入指针变量 p 的数据都会被当作地址来处理。 p 本身的地址由编译器另外存储,存储在哪里,我们并不知道 | 保存数据,数组名 a 代表的是数组首元素的首地址而不是数组的首地址。 &a 才是整个数组的首地址。 a 本身的地址由编译器另外存储,存储在哪里,我们并不知道 |
间接访问数据,首先取得指针变量 p 的内容,把它作为地址,然后从这个地址提取数据或向这个地址写入数据。指针可以以指针的形
| 直接访问数据,数组名 a 是整个数组的名字,数组内每个元素并没有名字。只能通过“具名+匿名”的方式来访问其某个元素,不能把数组当一个整体来进行读写操作。数组可以以指针的形式访问*(a+i); 也可以以下标的形式访问 a[i]。 但其本质都是 a 所代表的数组首元素的首地址加上 i*sizeof(类型)个 byte 作为数据的真正地址。 |
通常用于动态数据结构
| 通常用于存储固定数目且数据类型相同的元素。 |
相关的函数为 malloc 和 free。 | 隐式分配和删除 |
通常指向匿名数据(当然也可指向具名数据) | 自身即为数组名
|
指针数组和数组指针
指针数组:首先它是一个数组,数组的元素都是指针,数组占多少字节由数组本身决定。它是“存储指针的数组”的简称。
数组指针:首先它是一个指针,它指向一个数组。在32位系统下永远是占4字节,至于它指向的数组占多少字节。它是“指向数组的指针”的简称。
- int *p1[10];
// “ []”的优先级比“ *”要高。 p1 先与“ []”结合,构成一个数组的定义,数组名为 p1,int *修饰的是数组的内容,即数组的每个元素。
这是一个数组,其包含10个指向int类型数据的指针,即指针数组。
- int (*p2)[10];
//“()”的优先级比“ []”高, “ *”号和 p2 构成一个指针的定义,指针变量名为 p2,int 修饰的是数组的内容,即数组的每个元素。数组在这里并没有名字,是一个匿名数组。p2是一个指针,它指向一个包含10个int类型数据的数组,即数组指针。
这部分还需要深入研究
多维数组和多维指针
二级指针
一级指针保存的是数据的地址,二级指针保存的是一级指针的地址,
数组参数和指针参数
形参:指声明或定义函数时使用的参数
实参:在调用函数时主调函数传递过来的实际值
一维数组参数与一级参数指针:
通过函数修改指针变量:
- 通过return的方式
char * GetMemory(char * p, int num)
{
p = (char *)malloc(num*sizeof(char));
return p;
}
int main()
{
char *str = NULL;
str = GetMemory( str, 10) ;
strcpy(str,”hello”);
free( str);
return 0;
}
- 通过二级指针
void GetMemory( char ** p, int num)
{
*p = (char *)malloc(num*sizeof(char));
return p;
}
int main()
{
char *str = NULL;
//参数使用&str,而非str,这样传递过去的就是str,是一个值。在函数内部,用钥匙“*”来开锁:*(&str)其值就是str,所以malloc分配是真正的分配到了str本身
GetMemory( &str, 10) ;
strcpy(str,”hello”);
free( str);
return 0;
}
二位数组参数与二级参数指针:
void fun(char (*p)[4]) ;
这里的括号绝对不能省略,这样才能保证编译器把 p 解析为一个指向包含 4 个 char 类型数据元素的数组,即一维数组 a[3]的元素
void fun(char a[ ][4]) ;
注意: 如果把上面提到的声明 void fun(char (*p)[4]) 中的括号去掉之后, 声明“void f un(char *p[4])”可以改写成:
void fun(char **p) ;
这是因为参数*p[4],对于 p 来说,它是一个包含 4 个指针的一维数组,同样把这个一维数组也改写为指针的形式,那就得到上面的写法
数组参数 | 指针参数 |
数组的数组: char a[3][4] | 数组的指针: char (*p)[10] |
指针数组: char *a[5] | 指针的指针: char **p |
函数指针
函数指针:顾名思义就是函数的指针,如
char * (*fun1)(char * p1,char * p2);
func1是一个指针变量,指向一个函数。这个函数有两个指针类型的参数,函数的返回值也是一个指针。
函数指针的使用
实例1:
#include <stdio.h>
#include <string.h>
char * fun(char * p1,char * p2)
{
int i = 0;
i = strcmp(p1,p2);
if (0 == i)
{
return p1;
}
else
{
return p2;
}
}
int main()
{
char * (*pf)(char * p1,char * p2);
pf = &fun;
(*pf) ("aa","bb"); // 用(*pf)取出存在这个地址上的函数,然后调用它
return 0;
}
实例2:*(int*)&p ----这是什么?
void Function()
{
printf("Call Function!\n");
}
int main()
{
void (*p)();// 定义了一个指针变量 p, p 指向一个函数,这个函数的参数和返回值都是 void。
*(int*)&p=(int)Function;// 表示将函数的入口地址赋值给指针变量 p
(*p) (); // 表示对函数的调用
return 0;
}
/*
(int*)&p 表示将地址强制转换成指向 int 类型数据的指针
(int)Function 表示将函数的入口地址强制转换成 int 类型的数据
*/
实例3:(*(void(*) ())0)()------这是什么?
(*(void(*) ())0)();
分步分析:
- void(*) (),可以明白这是一个函数指针类型。这个函数没有参数,没有返回值
- (void(*) ())0,这是将 0 强制转换为函数指针类型, 0 是一个地址,也就是说一个函数存在首地址为 0 的一段区域内。
- (*(void(*) ())0),这是取 0 地址开始的一段内存里面的内容,其内容就是保存在首地址为 0 的一段区域内的函数
- (*(void(*) ())0)(),这是函数调用
函数指针数组
“char * (*pf)(char * p)”
定义的是一个函数指针 pf。既然 pf 是一个指针,那就可以储存在一个数组里。把上式修改一下:
char * (*pf[3])(char * p);
这是定义一个函数指针数组。它是一个数组,数组名为 pf,数组内存储了 3 个指向函数的指针。
实例1:
#include <stdio.h>
#include <string.h>
char * fun1(char * p)
{
printf("%s\n",p);
return p;
}
char * fun2(char * p)
{
printf("%s\n",p);
return p;
}
char * fun3(char * p)
{
printf("%s\n",p);
return p;
}
int main()
{
char * (*pf[3])(char * p);
pf[0] = fun1; // 可以直接用函数名
pf[1] = &fun2; // 可以用函数名加上取地址符
pf[2] = &fun3;
pf[0]("fun1");pf[0]("fun2");
pf[0]("fun3");
return 0;
}
函数指针数组的指针
定义一个简单的函数指针数组指针
char * (*(*pf)[3])(char * p);
这个指针指向一个包含了 3 个元素的数组;这个数字里面存的是指向函数的指针;这些指针指向一些返回值类型为指向字符的指针、参数为一个指向字符的指针的函数。
实例1:
#include <stdio.h>
#include <string.h>
char * fun1(char * p)
{
printf("%s\n",p);
return p;
}
char * fun2(char * p)
{
printf("%s\n",p);
return p;
}
char * fun3(char * p)
{
printf("%s\n",p);
return p;
}
int main()
{
char * (*a[3])(char * p);
char * (*(*pf)[3])(char * p);
pf = &a;
a[0] = fun1;
a[1] = &fun2;
a[2] = &fun3;
pf[0][0]("fun1");
pf[0][1]("fun2");
f[0][2]("fun3");
return 0;
}
7、内存管理
野指针
为了避免野指针:定义指针变量的同时最好初始化为 NULL,用完指针之后也将指针变量的值设置为 NULL
栈、堆和静态区
静态区:保存自动全局变量和static变量(包括static全局变量和static局部变量)。静态区的内容在总个程序的声明周期内都存在,由编译器在编译的时候分配。
栈/堆栈(stack):保存局部变量。栈上的内容只能在函数的范围内存在,当函数运行结束,这些内容会自动被销毁。其特点是效率高,但空间大小有限。
堆(heap):由malloc系列函数或new操作符分配的内存。其声明周期由free或delete决定。在没有释放之前一直存在,直到程序结束。特点是使用灵活,空间比较大,但容易出错。
常见的内存错误与对策
指针没有指向一块合法的内存
定义了指针变量,但是没有为指针分配内存,即指针没有指向一块合法的内存
-
- 结构体成员指针未初始化
- 没有为结构体指针分配足够的内存
- 函数的入口校验
一般在函数入口处使用 assert(NULL != p)对参数进行校验。在非参数的地方使用if(NULL != p)来校验。但这都有一个要求,即 p 在定义的同时被初始化为 NULL 了
为指针分配的内存太小
为指针分配了内存,但是内存大小不够,导致出现越界错误
内存分配成功,但并未初始化
定义一个变量时,第一件事就是初始化,但是往往这个时候还不确定这个变量的初值,这样的话可以初始化为0或NULL:
int i = 0;
char *p = NULL;
如果定义的是数组的话,可以这样初始化:
int a[10] = {0};
或者用 memset 函数来初始化为 0:
memset(a,0,sizeof(a)
内存越界
内存分配成功,且已经初始化,但是操作越过了内存的边界。这种错误经常是由于操作数组或指针时出现“多 1”或“少 1”。比如:
int a[10] = {0};
for (i=0; i<=10; i++)
{
a[i] = i;
}
内存泄漏
会产生泄漏的内存就是堆上的内存(这里不讨论资源或句柄等泄漏情况),也就是说由malloc 系列函数或 new 操作符分配的内存。如果用完之后没有及时 free 或 delete,这块内存就无法释放,直到整个程序终止。
- 如何使用malloc函数
使用malloc函数需要由几个要求:
-
- 内存分配给谁?
- 分配多大内存?
- 是否还有足够内存分配?
- 内存用来做什么?
- 分配好的内存在哪里
(void *)malloc(int size)
- malloc 函数使用注意事项:
2.1)内存分配成功之后, malloc 函数返回这块内存的首地址。你需要一个指针来接收这个地址。但是由于函数的返回值是 void *类型的,所以必须强制转换成你所接收的类型。也就是说,这块内存将要用来存储什么类型的数据。比如:
char *p = (char *)malloc(100);
2.2)如果所申请的内存块大于目前堆上剩余内存块(整块),则内存分配会失败,函数返回 NULL 即malloc 函数申请内存有不成功的可能,那我们在使用指向这块内存的指针时,必须用 if(NULL ! = p)语句来验证内存确实分配成功了
2.3)用malloc函数申请0字节:函数并不会返回NULL,而是返回一个正常的内存地址。但是你却无法使用这块大小为 0 的内存。这好尺子上的某个刻度,刻度本身并没有长度,只有某两个刻度一起才能量出长度。对于这一点一定要小心,因为这时候 if(NULL ! = p)语句校验将不起作用
内存释放
free(p);
free函数就是把这块内存和p之间的所有关系斩断。从此p和那块内存之间没有任何瓜葛。至于指针变量p本身保存的地址并没有改变,只是它对那个地址处的那块内存却已经没有所有权了。那块被释放的内存里面保存的值也没有改变,只是再也没有办法使用。
既然使用free函数之后,指针变量p本身保存的地址并没有改变,那就需要重新把p的值变为NULL:
p = NULL;
释放完内存之后,没有指针置为NULL,这个指针就成为了“野指针”。
内存释放后,还继续通过指针使用
一般由三种情况:
1)free(p)之后,继续通过p指针来访问内存。 解决办法:给p置NULL
2)函数返回栈内存。比如在函数内部定义了一个数组, 却用 return 语句返回指向该数组的指针。 解决的办法就是弄明白栈上变量的生命周期
3)内存使用太复杂,弄不清到底哪块内存被释放,哪块没有被释放。解决的办法是重新设计程序,改善对象之间的调用关系
8、函数
函数的优点:
- 降低复杂性:使用函数的最首要原因是为了降低程序的复杂性,可以使用函数来隐含信息,从而使你不必再考虑这些信息
- 避免重复代码段:
- 限制改动带来的影响:由于在独立区域进行改动,因此,由此带来的影响也只限于一个 或最多几个区域中
- 隐含顺序
- 改进性能:把代码段放入函数也使得用更快的算法或执行更快的语言(如汇编)来改进这段代码的工作变得容易些。
- 进行集中控制:专门化的函数去读取和改变内部数据内容,也是一种集中的控制形式
- 隐含数据结构:可以把数据结构的实现细节隐含起来
- 隐含指针操作:指针操作可读性很差,而且很容易引发错误。通过把它们独立在函数中,可以把注意力集中到操作意图而不是集中到的指针操作本身。
- 隐含全局变量:参数传递
编码规则:
【规则1】 每一个函数都必须有注释,即使函数短到可能只有几行。头部说明需要包含包含的内容和次序如下:
【规则2】 每个函数定义结束之后以及每个文件结束之后都要加一个或若干个空行。
【规则3】 在一个函数体内,变量定义与函数语句之间要加空行。
【规则4】 逻揖上密切相关的语句之间不加空行,其它地方应加空行分隔
【规则5】 复杂的函数中,在分支语句,循环语句结束之后需要适当的注释,方便区分各分支或循环体
【规则6】 修改别人代码的时候不要轻易删除别人的代码,应该用适当的注释方式,
【规则7】 用缩行显示程序结构,使排版整齐,缩进量统一使用4个字符(不使用TAB缩进)
【规则8】 在函数体的开始、结构/联合的定义、枚举的定义以及循环、判断等语句中的代码都要采用缩行
【规则9】 同层次的代码在同层次的缩进层上
【规则10】 代码行最大长度宜控制在80 个字符以内,较长的语句、表达式等要分成多行书写。
【规则11】 长表达式要在低优先级操作符处划分新行,操作符放在新行之首(以便突出操作符)。拆分出的新行要进行适当的缩进,使排版整齐,语句可读
【规则12】 如果函数中的参数较长,则要进行适当的划分
【规则13】 用正确的反义词组命名具有互斥意义的变量或相反动作的函数等
【规则14】 如果代码行中的运算符比较多,用括号确定表达式的操作顺序,避免使用默认的优先级
【规则15】 不要编写太复杂的复合表达式。
【规则16】 不要有多用途的复合表达式
【建议17】 尽量避免含有否定运算的条件表达式
【规则18】 参数的书写要完整,不要贪图省事只写参数的类型而省略参数名字。如果函数没有参数,则用void 填充
【规则19】 原则上尽量少使用全局变量。因为全局变量的生命周期太长,容易出错,也会长时间占用空间.各个源文件负责本身文件的全局变量,同时提供一对对外函数,方便其它函数使用该函数来访问变量。比如: niSet_ValueName(⋯); niGet_ValueName(⋯);不要直接读写全局变量,尤其是在多线程编程时,必须使用这种方式,并且对读写操作加锁。
【规则20】 参数命名要恰当,顺序要合理
【规则21】 如果参数是指针,且仅作输入参数用,则应在类型前加const,以防止该指针在函数体内被意外修改
【规则22】 不要省略返回值的类型,如果函数没有返回值,那么应声明为void 类型。如果没有返回值,编译器则默认为函数的返回值是int类型的。
【规则23】 在函数体的“入口处”,对参数的有效性进行检查。尤其是指针参数,尽量使用assert宏做入口校验, 而不使用if语句校验。
【规则24】 return 语句不可返回指向“栈内存”的“指针”,因为该内存在函数体结束时被自动销毁。
【规则25】 函数的功能要单一,不要设计多用途的函数。
【规则26】 函数体的规模要小,尽量控制在80 行代码之内。
【建议27】 相同的输入应当产生相同的输出。尽量避免函数带有“记忆”功能
【建议28】 避免函数有太多的参数,参数个数尽量控制在4个或4个以内。如果参数太多,在使用时容易将参数类型或顺序搞错。
【建议29】 尽量不要使用类型和数目不确定的参数。
【建议30】 有时候函数不需要返回值,但为了增加灵活性如支持链式表达,可以附加返回值
【建议31】 不仅要检查输入参数的有效性,还要检查通过其它途径进入函数体内的变量的有效性,例如全局变量、文件句柄等
【规则32】 函数名与返回值类型在语义上不可冲突
函数递归
实例1:
void fun(int i)
{
if (i>0)
{
fun(i/2);
}
printf("%d\n",i);
}
int main()
{
fun(10);
return 0;
}
8、文件结构:
文件内容的一般规则
【规则1】 每个头文件和源文件的头部必须包含文件头部说明和修改记录。
源文件和头文件的头部说明必须包含的内容和次序如下:
【规则2】 各个源文件必须有一个头文件说明,头文件各部分的书写顺序下:
【规则3】 源文件各部分的书写顺序如下:
【规则4】 需要对外公开的常量放在头文件中,不需要对外公开的常量放在定义文件的头部
文件名命名规则
【规则5】 文件标识符分为两部分,即文件名前缀和后缀。文件名前缀的最前面要使用范围限定符——模块名(文件名)缩写
【规则6】 采用小写字母命名文件, 避免使用一些比较通俗的文件名, 如: public.c 等
C语言实例
1、强制类型转换1
struct Test
{
int Num;
char *pcName;
short sDate;
char cha[2];
short sBa[4];
}*p;
假设 p 的值为 0x100000。 如下表表达式的值分别为多少?
p + 0x1 = 0x___ ?
(unsigned long)p + 0x1 = 0x___?
(unsigned int*)p + 0x1 = 0x___?
/*
1)p + 0x1 的值为 0x100000+sizof(Test) *0x1。至于此结构体的大小为 20byte,所以 p + 0x1 的值为: 0x100014
2)(unsigned long)p + 0x1 的值呢?这里涉及到强制转换,将指针变量 p 保存的值强制转换成无符号的长整型数。任何数值一旦被强制转换,其类型就改变了。所以这个表达式其实就是一个无符号的长整型数加上另一个整数。所以其值为: 0x100001
3)(unsigned int*)p + 0x1 的值呢?这里的 p 被强制转换成一个指向无符号整型的指针。所
以其值为: 0x100000+sizof(unsigned int) *0x1,等于 0x100004。
*/
2、在 x86 系统下,其值为多少? //强制类型转换2
int main()
{
int a[4]={1,2,3,4};
int *ptr1=(int *)(&a+1);
int *ptr2=(int *)((int)a+1);
printf("%x,%x",ptr1[-1],*ptr2);
return 0;
}
/*
ptr1:将&a+1 的值强制转换成 int*类型,赋值给 int* 类型的变量 ptr, ptr1 肯定指到数组 a 的下一个 int 类型数据了。 ptr1[-1]被解析成*(ptr1-1),即 ptr1 往后退 4 个 byte。所以其值为 0x4。
ptr2:按照上面的讲解, (int)a+1 的值是元素 a[0]的第二个字节的地址。然后把这个地址强制转换成 int*类型的值赋给 ptr2, 也就是说*ptr2 的值应该为元素 a[0]的第二个字节开始的连续 4 个 byte 的内容。
其内存布局如下图:
*/
3、&p[4][2] - &a[4][2]的值为多少?
int main()
{
int a[5][5];
int (*p)[4];
p = a;
printf("a_ptr=%#p,p_ptr=%#p\n",&a[4][2],&p[4][2]);
printf("%p,%d\n",&p[4][2] - &a[4][2],&p[4][2] - &a[4][2]);return 0;
}
/*
当数组名 a 作为右值时,代表的是数组首元素的首地址。这里的 a 为二维数组, 我们把数组 a 看作是包含 5 个 int 类型元素的一维数组,里面再存储了一个一维数组。如此,则 a 在这里代表的是 a[0]的首地址。 a+1 表示的是一维数组 a 的第二个元素。 a[4]表示的是一维数组 a 的第 5 个元素,而这个元素里又存了一个一维数组。所以&a[4][2]表示的是&a[0][0]+4*5*sizeof(int) + 2*sizeof(int)
p 是指向一个包含 4 个元素的数组的指针。也就是说 p+1 表示的是指针 p 向后移动了一个“包含 4 个 int 类型元素的数组”。这里 1 的单位是 p 所指向的空间,即4*sizeof(int)。所以, p[4]相对于 p[0]来说是向后移动了 4 个“包含 4 个 int 类型元素的数组”,即&p[4]表示的是&p[0]+4*4*sizeof(int)。由于 p 被初始化为&a[0],那么&p[4][2]表示的是&a[0][0]+4*4*sizeof(int)+2* sizeof(int)
*/