7.1 数据类型与存储
大端模式与小端模式
- 字节是计算机最基本的存储单位,也是最小的寻址单元
- 不同架构的处理器,存储模式一般也不同。ARM、X86、DSP一般都采用小端模式,而IBM、Sun、PowerPC架构的处理器一般都采用大端模式。
位序指在一字节的存储中,各个比特位的存储顺序
- 一般情况下字节序和位序是一一对应的。小端模式下,低端地址存储低字节数据,在一字节中,bit0地址也用来存储这个字节的bit0位。大端模式则相反,bit0用来存储一字节的高比特位。
- 大端模式则更适合计算机的处理习惯:不需要考虑地址和数据的对应关系,以字节为单位,把数据从左到右,按照由低到高的地址顺序直接读写即可。大端模式一般用在网络字节序、各种编解码中。
大小端存储模式的转换呢
- 我们可以定义一个宏,将高、低地址上的数据互换,即可完成大小端存储模式的转换。
- [插图]
有符号数和无符号数
- 无符号数在计算机内存中存储时,所有的比特位都用来表示数的大小,没有原码、补码之说,直接将其转换为二进制即可。
- 对于有符号数,则采用补码形式存储
- +0和-0都使用00000000表示,空下的编码10000000就可以多表示一个数:-128。需要注意的是,-128这个数只有补码,没有原码和反码
- 计算机使用补码来存储数据,除了解决0编码问题,更重要的意义在于它可以将减法运算转换为加法运算,省去了CPU减法逻辑电路的实现,CPU只需要实现全加器、求补电路即可同时支持加法运算和减法运算。
- 有符号数在运算过程中,符号位也是参与运算
- 和其他数据位的计算遵循相同的计算法则和进位处理。用补码表示的数据相加,当最高位有进位时,进位直接被丢弃。
数据溢出
数据类型转换
- 一个计算机系统中,当处理器对两个数进行算术运算时,一般要求两个数的类型、大小、存储方式都相同。这是由CPU的硬件电路特性决定的:CPU比较死板,不像人脑那样变通,只能对同类型的数据进行运算
- 编译器就会对数据类型进行自动转换,即隐式类型转换。转换规则一般按照从低精度向高精度、从有符号数向无符号数方向转换
- 在将一个char型数据转换为int型数据时,值保持不变,但存储格式发生了变化,将char型数据保存在32位中的低8位地址空间,其余的高24位使用符号位填充。
7.2 数据对齐
- 有些CPU在设计时简化了地址访问,只支持边界对齐的地址访问
结构体对齐
- C语言的基本数据类型不仅要按照自然边界对齐
- 复合数据类型(如结构体、联合体等)也要按照各自的对齐原则对齐
- 结构体内各成员按照各自数据类型的对齐模数对齐。
- 结构体整体对齐方式:按照最大成员的size或其size的整数倍对齐。
- 结构体之所以要对齐,根本原因就是为了加快CPU访问内存的速度,在具体实现上,一般都采用每种数据类型的默认对齐模数sizeof(type)对齐
- 不同的编译器有时候可能会采取不同的对齐标准,以GCC为例,GCC默认的最大对齐模数为4,当一种数据类型的大小超过4字节时会仍然按照4字节对齐
如果在结构体里内嵌其他结构体,那么结构体作为其中一个成员也要按照自身类型的对齐模数对齐
- 结构体自身的对齐模数是该结构体中最大成员的size,或者其size的整数倍。
联合体对齐
- ● 联合体的整体大小:最大成员对齐模数或对齐模数的整数倍。● 联合体的对齐原则:按照最大成员的对齐模数对齐。
- 在C程序编译过程中,无论是基本数据类型还是复合数据类型,编译器在为各个变量分配地址空间时,会按照大家各自的默认对齐模数进行地址对齐。
- ,我们可以分别使用#pragma预处理命令和aligned/packed属性声明显式指定结构体或结构体成员的对齐方式。
7.3 数据的可移植性
- 操作系统为了实现跨平台运行,一般都会考虑数据的可移植性,如大小端存储模式、数据对齐、字长等。
7.4 Linux内核中的size_t类型
长度确定的数据类型:long。
- 数据类型size_t一般使用#define宏定义,
- [插图]
- size_t数据类型一般用在表示长度、大小等无关正负的场合,如数组索引、数据复制长度、大小等。
- 使用size_t不仅仅是考虑到数据类型的可移植性,size_t的另一个优点是其大小并非是固定的,而是用来表征针对某平台的最大长度。
7.5 为什么很多人编程时喜欢用typedef
typedef的基本用法
使用typedef需要注意的地方
- 当const和常见的类型(如int、char)共同修饰一个变量时,const和类型的位置可以互换。但是如果类型为指针,则const和指针类型不能互换,否则其修饰的变量类型就发生了变化
- 当typedef和const一起修饰一个指针类型时
- [插图]
typedef在语法上是一个存储类关键字
- 和常见的存储类关键字(如auto、register、static、extern)一样,在修饰一个变量时,不能同时使用一个以上的存储类关键字,否则编译会报错。
typedef的作用域
- 宏定义在预处理阶段就已经替换完毕,是全局性的,只要保证引用它的地方在定义之后就可以了。
7.6 枚举类型
- 枚举的本质
- 在C语言中,枚举是一种类型,属于整型类型。使用enum定义的枚举值列表,其实就是从0开始的一组整数序列。
- 最终编译生成的可执行文件中都会被整型数值代替。
- 枚举类型则在编译阶段全部被替换为整型。
- 和宏相比,枚举的优势是:枚举可以自动赋值,而宏则需要一个一个单独定义。
使用枚举需要注意的地方
- 枚举作为整型类型的一种,在编程使用过程中,也有一些注意的地方,如作用域。
7.7 常量和变量
- 变量名的本质,其实就是一段内存空间的别名。编译器在编译程序时会将变量名看成一个符号,符号值即变量的地址,各种不同的符号保存在符号表中。我们可以通过变量名对和它绑定的内存单元进行读写,而不是直接使用内存地址。通过变量名访问内存,既方便了程序的编写,也大大增强了程序的可读性。
- 在C语言中,一块可以存储数据的内存区域,一般被称为对象,而操作这片内存的表达式,即引用对象的表达式,我们称之为左值。
- 数组名、函数、枚举常量、函数调用等都不能作为左值,也不能通过它们去修改对象
- 一个变量作为左值时,通常表示对象的地址,我们对变量名的引用其实就是对该地址区域进行各种操作。一个变量名作为右值时,通常表示对象的内容,我们此时对变量的引用就相当于取该地址区域上的内容。
- 常用的修饰符有auto、register、static、extern、const、volatile、restrict、typedef等。这些修饰限定符往往会决定变量的存储位置、作用域或生命周期,所以一般也被称为存储类关键字
- [插图]
- 全局变量一般存储在数据段中,使用extern关键字可以将一个全局变量的作用域扩展到另一个文件中,也可以使用static关键字将其作用域限定在本文件中。
- 在一个函数内定义的变量,如果没有使用其他存储类修饰符修饰,默认就是auto类型,即自动变量。自动变量存储于当前函数的栈帧内,函数中的每一个局部变量只有在函数运行时才会给其分配存储空间,在函数执行结束退出时自动释放,其生命周期只存在于函数运行期间,这也是我们称这些局部变量为自动变量的根本原因。
- 一个函数内部定义的自动变量如果没有初始化,那么它的值将是随机的,这是因为在函数运行期间分配的存储单元地址是随机的,存储单元的数据也是随机的。
- 当一个C语言程序中存在常量表达式时,编译器在编译时会把常量表达式优化成一个固定的常量值,以节省存储空间。我们把这种编译优化称为常量折叠。
7.8 从变量到指针
- 计算机内存RAM支持随机寻址功能,在C语言中对内存的访问可直接通过地址进行读写
- 内存一般可分为静态内存和动态内存
- 一个程序被加载到内存运行时,代码段和数据段就属于静态内存,而堆栈则属于动态内存
- 静态内存的特点是内存中各个变量的地址在编译期间就确定了,在程序运行期间不再改变
- 而动态内存中变量的地址在程序运行期间是不固定的,如函数的局部变量,如果这个函数多次被调用运行,那么每次运行都要在栈上随机分配一个栈帧空间
- 对于用户使用malloc()函数申请的堆内存,不仅是动态变化的,而且还是匿名内存,我们无法借助变量名或栈指针来访问,只能使用指针来间接访问了。
- 指针的原始初衷用途,其实就是访问一片匿名的动态内存。通过指针我们可以直接读写指定的内存
- 如果从存储的角度去看指针,你会发现指针和汇编语言中的符号(symbol)是一一对应的。汇编语言中的symbol分为object symbol和func symbol,而指针根据指向的数据类型不同,一般也分为对象指针和函数指针。
- [插图]
- 无论指针是什么类型,它存放的都是一个地址,只不过这个地址上存放不同类型的数据而已。
- 为一个指针指定类型主要是为了应对编译器的类型检查
- 指针变量一般采用间接寻址。当指针变量通过间接寻址时,其又等价为一个普通变量(下面代码中的*p与a是等价的),既可当左值,又可当右值。
- 函数指针:void(fp)(int,int)。● 对象指针:char、int*、long*、struct xx*。● void*指针:一般作为通用指针,作为函数的参数。
- void*指针既不属于对象指针,也不属于函数指针。
- 指针声明:int*。● 取址运算符:&。● 间接访问运算符:*。● 自增自减运算符:++、—。● 成员选择运算符:.、→。● 其他运算符:[]、()。
- 这些运算符的优先级按照从高到低的顺序依次为:[]、()、.、→、++、—、*、&。
- 首先从最里面的圆括号(未定义标识符)看起,先往右看,再往左看,每当遇到圆括号时,就应该掉转阅读方向。一旦解析完圆括号里所有的东西,就跳出圆括号。重复这个过程,直到整个声明解析完毕。
- 类型就是一组数值和对这些数值相关操作的集合
- 不同的指针类型会有不同的运算操作。如我们经常看到的指针运算p++,它和普通的数值运算就不一样,不是简单的算术加1操作,把它转换成数值运算就相当于p+1*sizeof(type)。
- p++总是指向下一个元素的地址。
- 中通常看到的指针运算是指针和一个常数做加减运算。除此之外,两个指针也可以直接相减,但前提是指针类型要一致,而且只能相减,不能相加,相减的结果表示两个指针在内存中的距离
- 两个指针相减的结果以数据类型的长度sizeof(type)为单位,而非以字节为单位
- 比较的前提是指针类型必须相同,指针关系运算一般用在同一个数组或链表中,不同的比较结果代表不同的含义。● p<q:指针p所指的数在q所指数据的前面。● p>q:指针p所指的数在q所指数据的后面。● p=q:p和q指向同一个数据。● p!=q:p和q指向不同的数据
7.9 指针与数组的“暧昧”关系
- 数组名作为函数参数时相当于一个指针地址。● 数组和指针一样,都可以通过间接运算符*访问。● 数组和指针一样,都可以使用下标运算符[]访问。
- [插图]
- 当我们对一个数组a[n]通过下标访问时,编译器会将其转换为*(a+n)的形式,数组名a代表的是数组首元素的地址,相当于一个指针常量。
- ,数组
- 是a+1和&a+1的值为什么不一样
- 数组名其实也存在隐式转换
- 当我们使用数组名声明一个数组,或者使用数组名和sizeof、取址运算符&结合使用时,数组名表示的是数组类型。
- [插图]
- 程序
- [插图]
- 二维数组的数组名作为右值时表示的是数组首元素的地址,二维数组可以看成是特殊的一维数组,数组里的每个数组元素还是一个数组,pa=a;相当于将一维数组的地址赋值给了pa,pa+1转换成数组运算就是pa+sizeof(int[4]);pa[0]相当于一维数组的数组名,再通过pa[0][i]下标访问就可以依次遍历这个一维数组了。
- 数组指针一个经典的应用就是作为函数参数,用来传递一个二维数组的地址
- 思考:指针数组和数组指针作为函数参数,都可以用来传递一个二维数组的地址,有什么区别和注意的地方?
- 指针数组的一个典型应用,就是用来保存我们的main()函数的参数。
7.10 指针与结构体
- 结构体则是由一组不同类型的数据组成的集合,我们可以通过成员访问运算符.去访问各个成员,也可以通过指针间接访问运算符→ 去访问各个成员
指针、结构体相关的运算符
- ● 成员访问运算符:.。● 成员间接访问运算符:→。● 结构体成员取址:&stu.num。● 结构体成员自增自减:++stu.num、stu.num++。● 间接访问运算符:*stu.p。
- 访问结构体的成员有两种方法:直接成员访问和间接成员访问,对应的运算符分别为stu.num和p→num
- 通过结构体成员访问运算符.直接访问成员,通过结构体指针和间接访问运算符→ 访问
- 结构体是一个标量,当结构体作为函数的参数或者返回值时,传递的是整个结构体所有成员的值,这一点和数组是不同的,数组名作为参数时传递的仅仅是一个地址。
7.11 二级指针
二级指针
- 修改指针变量的值。● 指针数组传参。● 操作二维数组。
- 当指针数组作为函数参数时,数组名也会隐式转换为首元素的地址,即指针的地址——二级指针。
- 定义的指针类型不同,操作数组的方式也不同。
- 当数组作为函数的参数时,其可以匹配的形参类型
- [插图]
7.12 函数指针
- 函数名的本质其实就是指向函数的指针常量,即函数的入口地址。
- fp=func;语句中,函数名会通过隐式转换,转换成fp=&func
- 当我们通过指针调用函数时,(*fp)()间接访问其实就等效为fp()表达式。
- :类型是什么?类型是一组数值集合和针对该数值操作的一组集合,不同的类型有不同的运算法则
7.13 重新认识void
- void指针主要用来作为函数的参数,表示函数的参数可以是任意指针类型。当函数的返回类型为void时,返回的指针可以指向任意数据类型。
- void作为一种指针类型,除了修饰函数原型,一般不参与具体的指针运算。我们不能使用间接访问运算符访问void*,不能对void*做下标运算,但是在GNU C中可以做自增自减运算