数据类型与存储
- 一、数据类型与存储
- 1.1 大端模式与小端模式
- 1.2 有符号数和无符号数
- 1.3 数据溢出
- 1.4 数据类型转换
- 二、数据对齐
- 2.1 为什么非要地址对齐
- 2.2 结构体对齐
- 2.3 联合体对齐
- 三、数据的可移植性
- 四、Linux内核中的size_t类型
- 五、typedef
- 5.1 typedef的基本用法
- 5.2 typedef的优势
- 5.3 typedef的作用域
- 5.4 typedef的适用范围
一、数据类型与存储
类型,是一组数值及对该组数值进行各种操作的集合。同一种类型的数据,在不同的处理器平台下,存储方式可能不一样。不同类型的数据,在同一个处理器平台下,存储方式和运算规则也可能不一样。很多人都说指针是C语言的灵魂,我认为存储才是C语言的精髓和灵魂。
认识一下ANSI C关键字 和 C99/C11新增关键字。关键字里,除了控制程序结构的一些关键字,绝大部分都与数据类型和存储相关。
1.1 大端模式与小端模式
在计算机中,位(bit)是最小的存储单位,通常使用一个电容器来表示:充电时高电位表示1,放电时低电位表示0。8个bit组成一字节(Byte),字节是计算机最基本的存储单位,也是最小的寻址单元,计算机通常以字节为单位进行寻址。字(Word)代表计算机处理指令或数据的二进制数位数,是计算机进行数据存储和数据处理的运算的单位。在一个32位的计算机系统中,通常4字节组成一个字(Word),字是软件开发者常用的存储单位。(注意:一个字并不都是占4字节,通常由系统硬件(总线,CPU命令字位数等)有关,在16位的系统中(比如8086微机) 1字 (word)= 2字节(byte)= 16(bit),在32位的系统中(比如win32) 1字(word)= 4字节(byte)=32(bit),在64位的系统中(比如win64)1字(word)= 8字节(byte)=64(bit))。
-
字节序
不同字节的数据在内存中的存储顺序被称为字节序。根据字节序的不同,我们一般将存储模式分为大端模式和小端模式。- 大端模式:高地址存储高字节数据,低地址存储低字节数据。
- 小端模式:高地址存储低字节数据,低地址存储高字节数据。
常用的处理器中,ARM、X86、DSP一般都采用小端模式,而IBM、Sun、PowerPC架构的处理器一般都采用大端模式。
如何判断程序运行的当前平台是大端模式还是小端模式呢?很简单,我们只需要将一个整型变量赋值给字符型变量,通常会发生"截断",将会给低8位的字节赋值到字符型变量,通过打印就可以判断是大端模式还是小端模式。
#include <stdio.h> int main(void) { int a = 0x11223344; char b; b = a; if(b==0x44) printf("Little endian\r\n"); else printf("Big endian\r\n"); return 0; }
-
位序
位序指在一字节的存储中,各个比特位的存储顺序。以十六进制数据0x78=01111000(B)为例,其在内存中可能有2种存储方式。一般情况下字节序和位序是一一对应的。小端模式下,低端地址存储低字节数据,在一字节中,bit0地址也用来存储这个字节的bit0位。大端模式则相反,bit0用来存储一字节的高比特位。
一般来讲,小端模式低地址存储低字节数据,比较符合人类的思维习惯;而大端模式则更适合计算机的处理习惯:不需要考虑地址和数据的对应关系,以字节为单位,把数据从左到右,按照由低到高的地址顺序直接读写即可。大端模式一般用在网络字节序、各种编解码中。
作为一名嵌入式工程师,掌握大端模式与小端模式的存储方式很有必要。在开发移植过程当中,如配置寄存器、网络数据传输、移植网络等,需要考虑大小端模式的转换。在一个嵌入式软件中,如何实现大小端模式的转换过程,示例代码如下:
#define swap_endian_u16(A) \ ((A & 0xFF00 >> 8)|(A & 0x00FF << 8))
1.2 有符号数和无符号数
C语言为了能表示负数,引入了有符号数和无符号数的概念,在声明数据类型时分别使用关键字signed和unsigned修饰。我们定义的变量如果没有使用signed或unsigned显式修饰,默认是signed型的有符号数。
一个字符型的有符号数,最高的位bit7是符号位:0表示正数,1表示负数,其余的比特位用来表示大小。而一个字符型的无符号数,所有的比特位都用来表示数的大小。有符号数和无符号数能表示的数值范围是不一样的,对于一个字符型数据而言,有符号数能表示的数值范围为[-128,127],而无符号数的数值范围为[0,255]。可以使用%d和%u格式符分别格式化打印有符号数和无符号数。
一个存储在物理内存中的数据,可以被看作一个有符号数,也可以被看作一个无符号数,就看你怎么去解析它:你使用格式符%d打印,printf()函数就把它看作一个有符号数;你使用无符号格式符%u打印,printf()函数就把它看成了另外一个数。
正所谓“看山是山,看水是水;看山不是山,看水不是水”。如下程序所示
- 无符号数在计算机内存中存储时,所有的比特位都用来表示数的大小,没有原码、补码之说。
- 有符号数,则采用补码形式存储,一个有符号数有原码、反码、补码之说。
反码 = 符号位保持不变,所有的数据位取反。
补码 = 反码 + 1。
正数的补码 = 原码
负数补码 = 反码 + 1
提问:为啥采用补码存储,而不全使用原码?
答:1、解决了0的编码问题。如果所有的数据都使用原码编码,那么+0和-0的编码分别为00000000和10000000,一个数用两个编码表示,编码就出现了问题。采用补码则可以避免这个问题,+0和-0都使用00000000表示,空下的编码10000000就可以多表示一个数:-128。需要注意的是,-128这个数只有补码,没有原码和反码。
2、它可以将减法运算转换为加法运算,省去了CPU减法逻辑电路的实现,CPU只需要实现全加器、求补电路即可同时支持加法运算和减法运算。如下例子所示
正常计算下
我们将其改为加法运算:7+(-3),则省去了减法电路,直接使用加法电路运算即可。
有符号数在运算过程中,符号位也是参与运算的,和其他数据位的计算遵循相同的计算法则和进位处理。用补码表示的数据相加,当最高位有进位时,进位直接被丢弃。
1.3 数据溢出
每一种数据类型都有它能表示的数值范围。
-
无符号数溢出时会进行取模运算,继续“周期轮回”。
例如一个unsigned char类型的数据,它能表示的数据范围为[0,255],当其循环到255最大值时继续加1,这个数就变成了0,开始新的一轮循环,周而复始。
程序运行结果如下:
-
有符号数,当发生数据溢出时,由于C语言的语法宽松性,不对数据类型做安全性检查,因此也不会触发异常,但是会产生一个未定义行为。未定义行为,通俗点理解,就是遇到这种情况时,C语言标准也没有规定该如何操作,各家编译器在处理这种情况时也就没有了参考标准,各自按照自己的方式处理,编译器都不算错误。这也导致了当有符号数发生溢出时,运行结果是不确定的,在不同的编译器环境下编译运行,结果可能不一样。
因此,数据溢出可能会导致程序的运行结果与预期不一致。
防范数据溢出方法:
- 1、两个有符号数相加的情况。如果两个正数相加的和小于0,说明运算过程中发生了数据溢出。同理,如果两个负数相加的和大于0,也说明数据发生了溢出。
- 2、无符号数的相加,如果两个数的和小于其中任何一个加数,此时我们也可以判断数据在计算过程中发生了溢出现象。
1.4 数据类型转换
数据类型转换分为两种:一种是隐式类型转换,一种是强式类型转换。如果程序员在程序中没有对类型进行强式类型转换,则编译器在编译程序时就会自动进行隐式类型转换。
一个C程序中发生隐式类型自动转换,主要是以下几种情况。
- 算术运算、逻辑运算、赋值表达式中运算符两侧数据类型不相同时。
- 函数调用过程中,传递的实参和形参类型不匹配时。
- 函数返回值类型与函数声明的类型不匹配时。
编译器遇到以上情况,就会对数据类型进行自动转换,即隐式类型转换。转换规则一般按照从低精度向高精度、从有符号数向无符号数方向转换。 一个有符号数和无符号数比较大小时,编译器会将它们两个都转换为无符号数。
强制类型转换的过程中需要注意一个问题,数据的值在转换过程中可能会发生改变:在将一个char型数据转换为int型数据时,值保持不变,但存储格式发生了变化,将char型数据保存在32位中的低8位地址空间,其余的高24位使用符号位填充。将一个有符号数转换为无符号数时,数据的存储格式不会发生变化,但是值会发生改变,因为此时有符号数的符号位变成了无符号数的数据位。
二、数据对齐
2.1 为什么非要地址对齐
数据对齐原则,就是C语言中各种基本数据类型要按照自然边界对齐:一个char
型的变量按1字节对齐,一个short
型的整型变量按sizeof(short int)
字节对齐,一个int
型的整型变量要按sizeof(int)
字节对齐。每种数据类型的对齐字节数一般也被称为对齐模数。
为什么非要地址对齐呢?这主要是由CPU硬件决定的。不同处理器平台对存储空间的管理不同,为了简化CPU电路设计,有些CPU在设计时简化了地址访问,只支持边界对齐的地址访问,因此编译器也会根据处理器平台的不同,选择合适的地址对齐方式,以保证CPU能正常访问这些存储空间。
2.2 结构体对齐
C语言的基本数据类型不仅要按照自然边界对齐,复合数据类型(如结构体、联合体等)也要按照各自的对齐原则对齐。
- 结构体内各成员按照各自数据类型的对齐模数对齐。
- 结构体整体对齐方式:按照最大成员的size或其size的整数倍对齐。
因为结构体内各个成员都要按照自身数据类型的对齐模数对齐,所以在结构体内部难免会有“空洞”产生,导致结构体的大小也不一样。结构体之所以要对齐,根本原因就是为了加快CPU访问内存的速度,在具体实现上,一般都采用每种数据类型的默认对齐模数sizeof(type)对齐。
如果在结构体里内嵌其他结构体,那么结构体作为其中一个成员也要按照自身类型的对齐模数对齐。结构体自身的对齐模数是该结构体中最大成员的size,或者其size的整数倍。
2.3 联合体对齐
联合体也有自己的对齐原则。
- 联合体的整体大小:最大成员对齐模数或对齐模数的整数倍。
- 联合体的对齐原则:按照最大成员的对齐模数对齐。
在C程序编译过程中,无论是基本数据类型还是复合数据类型,编译器在为各个变量分配地址空间时,会按照大家各自的默认对齐模数进行地址对齐。除此之外,我们也可以通过#pragma预处理命令或GNU C编译器的aligned/packed属性声明来显式指定对齐方式。
三、数据的可移植性
我们可以使用sizeof关键字去查看int类型的数据在内存中的大小,在不同的编译环境下编译上面的程序并运行,你会发现运行结果可能不一样。在一个跨平台的程序中,有时候我们会需要一个固定大小的存储空间,或者一个固定长度的数据类型。
现在的操作系统一般都支持多种CPU架构、多种处理器平台。操作系统为了实现跨平台运行,一般都会考虑数据的可移植性,如大小端存储模式、数据对齐、字长等。我们在编程时,可以把程序中与系统、平台相关的部分隔离封装在一个单独的头文件或配置文件中,整个程序的可移植部分和不可移植部分也就变得泾渭分明,更加方便后续的管理、维护和升级。
四、Linux内核中的size_t类型
Linux内核中定义了很多变量,使用了各种不同的数据类型,总的来说,可以分为3类。
- C语言基本数据类型:int、char、short。
- 长度确定的数据类型:long。
- 特定内核对象的数据类型:pid_t、size_t。
数据类型size_t一般使用#define宏定义,后面使用一个_t的后缀表示Linux内核中在某些地方特定使用的数据类型。
size_t数据类型一般用在表示长度、大小等无关正负的场合,如数组索引、数据复制长度、大小等。使用size_t不仅仅是考虑到数据类型的可移植性,size_t的另一个优点是其大小并非是固定的,而是用来表征针对某平台的最大长度。当我们使用无符号型的size_t用来表示一个地址或者数据复制的长度时,根本不用担心它表示的数值范围够不够用。
五、typedef
5.1 typedef的基本用法
使用typedef
关键字,可以给student声明一个别名student_t和一个结构体指针类型student_ptr
,然后可以直接使用student_t类型去定义一个结构体变量,不用再写struct
,这样会显得代码更加简捷。
程序的运行结果如下:
typedef
除了与结构体结合使用,还可以与数组结合使用。定义一个数组,通常使用int array[10]
;即可。我们也可以使用typedef
先声明一个数组类型,然后使用这个类型去定义一个数组。声明了一个数组类型array_t
,然后使用该类型定义一个数组array
,这个array
效果其实就相当于int array[10]
。
typedef
还可以与指针结合使用。PCHAR
的类型是char*
,我们使用PCHAR
类型去定义一个变量str
,其实就是一个char*
类型的指针。
typedef
还可以和函数指针结合使用。定义一个函数指针,我们通常采用下面的形式。
在实际编程中,typedef
还可以与枚举结合使用。枚举与typedef
的结合使用方法和结构体类似:可以使用typedef
为枚举类型color
声明一个新名称color_t
,然后使用这个类型就可以直接定义一个枚举变量。
5.2 typedef的优势
-
可以让代码更加清晰简捷。
-
增加代码的可移植性。
如果我们在代码中想使用一个32位的固定长度的无符号类型数据,则可以使用上面的方式声明一个U32的数据类型,在程序中你就可以放心大胆地使用U32。当将代码移植到不同的平台时,直接修改这个声明就可以了。 -
比宏定义更好用。
C语言的预处理指令#define
用来定义一个宏,而typedef
则用来声明一种类型的别名。typedef和宏相比,不是简单的字符串替换,而是可以使用该类型同时定义多个同类型对象。
-
让复杂的指针声明更加简捷。
我们可以使用typedef
优化一下:先声明一个函数指针类型func_ptr_t
,接着定义一个数组,就会更加清晰简捷,可读性它增加了不少。typedef
也是一个存储类关键字。typedef
在语法上是一个存储类关键字。和常见的存储类关键字(如auto、register、static、extern
)一样,在修饰一个变量时,不能同时使用一个以上的存储类关键字,否则编译会报错。
5.3 typedef的作用域
和宏的全局性相比,typedef作为一个存储类关键字,是有作用域的。使用typedef声明的类型和普通变量一样,都遵循作用域规则,包括代码块作用域、文件作用域等。
宏定义在预处理阶段就已经替换完毕,是全局性的,只要保证引用它的地方在定义之后就可以了。而使用typedef声明的类型则和普通变量一样,都遵循作用域规则。
5.4 typedef的适用范围
一般来讲,当遇到以下情形时,使用typedef可能会比较合适,否则可能会适得其反。
- 创建一个新的数据类型。
- 跨平台的指定长度的类型,如U32/U16/U8。
- 与操作系统、BSP、网络字宽相关的数据类型,如size_t、pid_t等。
- 不透明的数据类型,需要隐藏结构体细节,只能通过函数接口访问的数据类型。