要点
基本概念
-
位(bit)是计算机中最小的存储单元,只能表示0或1。
-
字节(byte)是计算机中常用的存储单元,由8个连续的位组成。
-
字长(word size) 指明指针数据的标称大小(normal size),因为虚拟地址是以这样一个字来编码的,所以字长会决定虚拟地址空间最大大小。
-
虚拟地址空间(virtual Address) CPU虚拟寻址时通过生成一个虚拟的地址来访问内存,在访问前会把虚拟地址转化为物理地址,我们平时写程序时看见的地址都是虚拟地址。
整数表示
-
无符号整数使用二进制表示,可以直接转换为十进制。
-
补码是表示有符号整数的一种常用方式,其最高位表示符号位。
-
补码可以用于解决有符号和无符号整数之间的转换问题。
整数运算
-
位级运算(包括与、或、异或、取反等)在底层实现中效率很高。
-
移位运算(左移和右移)可以实现乘法和除法的效果。
浮点数表示
-
浮点数用于表示非整数数字,由符号位、指数位和尾数位组成。
-
IEEE 754是一种常见的浮点数表示标准,定义了单精度和双精度格式。
浮点数运算
-
浮点数的运算需要考虑舍入误差和溢出的可能性。
-
避免比较浮点数的相等性,应该使用近似相等的判断方式。
字符编码
-
ASCII是最常用的字符编码方案,将字符映射到7位二进制表示。
-
Unicode是一种更全面的字符编码标准,可以表示几乎所有的字符。
IEEE 754
IEEE 754是一种常见的浮点数表示标准,广泛用于计算机系统中的浮点数运算。
该标准定义了两种浮点数格式:单精度(32位)和双精度(64位)
以下是IEEE 754浮点数表示的主要要点:
-
符号位(Sign bit):浮点数的第一位表示符号位,0表示正数,1表示负数。
-
指数位(Exponent bits):指数位用于表示浮点数的指数部分,用于调整浮点数的大小范围。对于单精度浮点数,指数位占8位;对于双精度浮点数,指数位占11位。
-
尾数位(Significand bits/Mantissa bits):尾数位用于表示浮点数的有效数字部分。对于单精度浮点数,尾数位占23位;对于双精度浮点数,尾数位占52位。
-
阶码偏移(Bias):为了表示负指数,IEEE 754采用了阶码偏移的概念。对于单精度浮点数,阶码偏移为127;对于双精度浮点数,阶码偏移为1023。
-
特殊值:IEEE 754定义了几个特殊的浮点数值,包括正无穷大、负无穷大、NaN(不是一个数)等。
-
规格化与非规格化数:IEEE 754使用规格化和非规格化数来表示浮点数。规格化数具有隐含的1位,非规格化数则没有。
未定义行为
未定义行为(Undefined Behavior)是指在程序中使用了一种不符合C语言标准规定的语法或进行了一些不合法的操作,从而导致程序的行为变得不可预测。
在这种情况下,编译器不会提供任何保证,程序可能会产生各种不确定的结果,包括崩溃、错误输出、死循环等。
以下是一些常见的导致未定义行为的情况:
-
未初始化的变量:使用一个未初始化的变量,其值是未定义的,可能包含任意的垃圾值。
-
数组越界访问:访问数组的越界元素,即超出数组边界的索引,这会导致内存越界访问,破坏了内存的完整性。
-
空指针解引用:对空指针进行解引用操作,即访问空指针指向的内存区域,这会导致程序崩溃或产生不可预测的结果。
-
整数溢出:进行整数运算时,如果结果超出了该类型的范围,将发生溢出,这会导致未定义行为。
-
栈溢出:当递归调用或局部变量占用过多内存时,可能会导致栈溢出,覆盖其他数据或破坏程序的执行流程。
-
使用已释放的内存:对已经释放的内存进行读取或写入操作,这可能导致访问无效的内存,破坏程序的状态。
-
多次修改同一变量的值而没有序列点:在表达式中多次修改同一个变量的值而没有明确定义的序列点,这会导致未定义行为。
-
格式化字符串不匹配:在使用格式化函数(如
printf
、scanf
等)时,提供的格式化字符串与实际参数不匹配,会导致输出结果的不确定性。
int copy_from_kernel(void *user_dest, int maxlen) {
int len = KSIZE < maxlen ? KSIZE : maxlen;
memcpy(user_dest, kbuf, len);
return len;
}
这段代码从内核空间复制数据到用户空间,但它存在bug:如果maxlen为负数,就会出现未定义行为
大端序与小端序
大端字节序(Big Endian)和小端字节序(Little Endian)是用于描述多字节数据在内存中存储的方式。
在计算机中,多字节数据(例如整数、浮点数)在内存中被划分为若干字节,每个字节都有一个唯一的内存地址。
大端字节序和小端字节序指的是在多字节数据的存储中,最高有效字节(Most Significant Byte,MSB)和最低有效字节(Least Significant Byte,LSB)的存储顺序。
大端字节序和小端字节序在不同的计算机体系结构和通信协议中被广泛使用。不同的体系结构和协议可能采用不同的字节序,因此在进行跨平台数据交换时需要注意字节序的转换。
在现代计算机中,大多数x86架构的个人计算机采用小端字节序,而某些嵌入式系统、网络协议和存储设备则可能采用大端字节序。编程中需要注意处理不同字节序的数据转换,以确保数据的正确解析和传递。
为了进行字节序转换,可以使用一些特定的函数或方法,例如网络字节序和主机字节序之间的转换函数(如 htonl
、htons
、ntohl
、ntohs
)等,这些函数可以确保在不同字节序之间进行正确的转换。
字节顺序
字节顺序 | 含义 |
---|---|
大端 | 最大有效字节位于单词左端 |
小端 | 最大有效字节位于单词右端 |
网络字节序(network byte order) (在TCP/IP协议族)是大端,而 主机字节序(host byte order) 大端和小端序均有
其实最简单方法是直接看末端字节权重,最大的就是大端,最小的就是小端
比如0x1234567 最高权重的是
12
,因为它对这个数值大小的影响最大(比如转换成十进制时要乘以2的次方最高)
有效字节
有效字节一般是指计算机读取数据时,计数器所记录的计数值。如果在一个计数器中计数值的同时,又要往同一个计数器中写入新的计数值时,如果不注意先后顺序,很容易出错。所以有效字节也可以指最高有效字节和最低有效字节之间的字节。有效字节在计算机进行数据读取时很重要,特别是计算机进行网络数据传输时,定义有效字节有利于进行数据传输双方正确进行数据传输。
最高地址内存
在计算机体系结构中,最高内存地址指的是系统中可寻址的最大内存地址。它对应于系统内存的最高端,即内存空间的结束位置。
最高内存地址通常是由系统的物理地址线的位数决定。例如,对于32位体系结构,最高内存地址是232-1(约为4GB)。而在64位体系结构下,最高内存地址是264-1(约为18,446,744,073,709,551,615),这是一个非常巨大的数值,远远超过目前实际可用的内存大小。
在操作系统中,最高内存地址通常被用于内存管理、地址空间布局和访问权限的控制。根据系统架构和操作系统的设计,最高内存地址的使用方式可能会有所不同。
需要注意的是,最高内存地址不同于程序中的指针的最大值。在32位系统中,指针的最大值通常是232-1,对应于最高内存地址。然而,在64位系统中,指针的最大值通常是264-1,远远大于最高内存地址。这是因为在64位系统中,指针可以表示更大范围的内存地址,但实际的可用内存大小仍受限于硬件和操作系统的限制。
示例代码
int is_little_endian(void){ union w { int a; char b; }c; c.a = 1; return (c.b == 1);// 小端返回TRUE,大端返回FALSE } int main(int argc, char *argv[]) { if(is_little_endian()) printf("Little Endian\n"); return 0; }
结果
我的win32机器本地主机是小端序
可以看到,字节地址从左往右递增,第一个字节01
就是最高有效字节,位置在四个字节的第一位,也是最高位,因此是小端序
让我们把示例换成0x1234567
来看看:
同理
-
67
是最低有效字节,它存储在最低内存地址处。 -
45
是次低有效字节,它存储在紧随其后的内存地址处。 -
23
是次高有效字节,它存储在再次紧随其后的内存地址处。 -
01
是最高有效字节,它存储在最高内存地址处。
因此,可以确认我的系统采用小端字节序。
让我们再换成0x7654321
看看:
权重最高的是07
,在最高位,因此可以再次确认
union中使用char的原因
因为 char
类型的大小是 1 字节,在内存中的布局是明确定义的,每个字节都有一个唯一的内存地址。通过访问字符型成员的值,我们可以推断出系统中最低有效字节(LSB)的存储位置。
对于其他类型,如 short
、int
、float
、double
等,它们的内存布局和字节序可能会受到编译器、体系结构和编译选项等因素的影响,因此使用这些类型进行字节序检测可能会导致不可移植或未定义行为。
虽然使用字符类型来检测字节序是一个常见的技巧,但是在实际应用中,为了更加可靠和可移植,可以使用专门的字节序转换函数(如 htonl
、htons
、ntohl
、ntohs
)来处理字节序的问题,而不依赖于联合或特定类型的行为。这样可以确保在不同的系统和编译器中都能正确处理字节序转换。
浮点数
计算机中小数采用浮点数方式保存,采用工业标准IEEE754标准。一个浮点数的表现形式如下:
(-1)^S2^E(b_0b_1b_2b_3…b_{n-1})
$$
-
其中
(-1)^S
是浮点数的正负符号,S
为0表示正数,S
为1表示负数; -
E
为指数(阶码),用移码表示; -
b0b1b2b3…bn-1
是尾数,长度为n位,用原码表示。
假设用一个4字节(32bit)表示一个浮点数,每个内容段所占的bit位数如下所示,从左到右bit位是由高到低:
阶符(符号位) | 阶码 | 尾数 |
---|---|---|
1bit | 8bit | 23bit |
转换举例
176.1875表示为单精度浮点数(4字节)
-
分别将整数部分和小数部分转成二进制,转换之后的数据叫做非规格化的数据
176.1875
−−−>10110000.0011
-
将非规格化数据转成规格化数据
10110000.0011
−−−> 1.01100000011∗2^7
-
确定阶码和符号位
可知指数为7,即阶码真值为7,那么只需要转换一下:阶码
= 阶码
+ 偏置值
单精度的浮点数的偏置值是127,双精度的偏置值是1023
所以阶码
= 7 + 127 = 134 = 10000110 ,因为是正数,符号位为 0
结果如下:
阶符(符号位) | 阶码 | 尾数 |
---|---|---|
0 | 10000110 | 01100000011 000000000000 |
偏置值bias
为什么需要bias?
1.使指数以无符号形式存储
2.便于浮点数加减运算时对阶
舍入误差
首先,让我们来跑一下这段代码:
int main() { double sum = 0.0; for (int i = 0; i < 10000; i++) sum += i + 1; printf("Sum: %f\n", sum); return 0; }
结果:
Sum: 50002896.000000
可以发现,结果与我们预期并不一致,这是因为浮点数在累加的过程中发生了舍入误差
在浮点数的存储过程中,由于使用有限位数来表示无限数量的实数,会产生舍入误差。这是因为某些实数无法用有限位数准确地表示,因此在存储和计算过程中会引入一定的近似误差。
当进行浮点数计算时,舍入误差会逐渐累积,导致最终结果与预期的精确结果之间存在差异。尤其是在涉及大量累加操作或涉及非常大或非常小的数值时,舍入误差的影响会更加显著。
当对浮点数进行累加操作时,舍入误差可能会导致结果与预期值之间存在一定的偏差。下面我将通过一个简化的例子来图形化地解释这个问题。
假设我们有一个非常简单的浮点数累加过程,从 0 开始逐个累加整数,并将结果存储在浮点数变量 sum
中。我们希望得到的结果是累加到某个较大的数时的总和,即 1 + 2 + 3 + … + N。
图中的横轴表示累加的次数,纵轴表示累加结果。我们期望的结果应该是一个直线,斜率为 1,随着累加次数的增加,结果应该逐渐增加。然而,由于浮点数的精度限制和舍入误差,实际结果可能会有所偏差。
|
|
| *
| *
| *
| *
| *
|*
+------------------------------------>
累加次数
在图中,期望的直线表示为虚线,而实际的累加结果以实际曲线表示。可以看到,在某些累加步骤之后,实际结果与期望结果之间的差异变得更加明显。这是由于舍入误差的累积效应。
例如,考虑使用单精度浮点数表示的情况下,将整数累加到一个浮点数变量时。当累加一个较小的整数时,如 1
,它通常可以精确表示为单精度浮点数。但当累加的整数变得很大时,如 1,000,000
,它的精确值无法用有限位数准确表示,因此会进行近似,并引入舍入误差。
通过使用更高精度的浮点类型,如 double
,可以减少舍入误差和精度损失,使结果更接近期望值。但是,对于非常大的累加操作,仍然可能存在一些小的差异,因为浮点数的精度是有限的。
让我们提高精度再试一下:
int main() { double sum = 0.0f; for (int i = 1; i <= 10000; i++) { sum += i; } printf("Sum: %f\n", sum); return 0; }
结果:
Sum: 50005000.000000
这样就得到了我们想要的结果,但是,随着累加数的增大,它依然会发生舍入误差
那么,该如何避免这个问题呢?
只需每次校正即可:
int main() { float sum = 0.0f, corr = 0.0f; /* corr:舍入误差校正值 */ for (int i = 0; i < 10000; i++) { float y = (i + 1) - corr; /* 每次先进行更正 */ float t = sum + y; /* 位可能会丢失 */ corr = (t - sum) - y; /* 得到校正值 */ sum = t; } printf("Sum: %f\n", sum); return 0; }
这段代码会在产生舍入误差时进行校正
结果:
Sum: 50005000.000000