结合CMU CSAPP课程和自己看的教材做的笔记。
数据类型大小
C的不同类型数据结构的大小。记住几个常用的,char是1个byte,short是2个,int是4个。
C里的位运算和逻辑运算不要搞混。比如&和&&,&是按位与,&&是且运算,位运算的结果仍是一个不同的数值,逻辑运算的结果不是0就是1。
左移和右移
位左移会把高位的移除,低位的补零。
右移有逻辑右移和算术右移。
逻辑右移和左移类似,低位移除,高位补0。算术右移低位移除,高位补的是符号位。
补码(符号数表示)
负数表示的思路是,最高位作为符号位,因为当最高位取为1的时候,正好可以把这串比特表示的数字范围拆成两半。比如我打算用4个比特表示数字,可以表示16个,1000的十进制是8,那么在1000以下的和1000以上的各能表示8个数字,就可以分别表示正数和负数了。
如下,16位表示的无符号数和有符号数的范围。最大的负数是最高位1其余0,最大的正数是最高位0其余1,所以在正数的最大值再加个1,溢出的数值就是最大的负数。
下面的公式表示了二进制位的无符号和有符号数的公式。公式就是二进制转十进制的加权求和,注意有符号数,符号位以下的位的权重和无符号数是一样的,只有符号位的权重不一样,带了负号。
C语言的
常量无特殊声明的情况下默认是按有符号表示,如果后面加上U则是无符号,如0U,1234U。
类型转换
C语言中,显式类型转换就是用显式地写出来的转换操作,比如用了函数,或者(int)a这种形式。隐式类型转换就是没有显式地写出来,但为了执行命令系统自动做的转换,常发生在赋值和逻辑比较中。
比如int a和unsigned int b,令a=b就发生了隐式类型转换,b会被转换成有符号类型,再赋给a,或者执行a
有符号类型和无符号类型混用,无特殊声明的情况下,总是有符号会被转换成无符号。例如,假如-1和0U做比较,结果是-1大于0U,因为-1的位级表示是全1,对应的无符号数就最大的正数。所以,当无符号数和有符号数混用时,都是正数没有关系,要小心有符号数是负数的情况。
老师补充了编程中典型的例子。下面是个死循环,因为i是无符号的,所以永远会是正数。位级的解释就是i最后减小到0U,再减去1后的-1是全1,对应的无符号数就是最大正数。
unsigned i;
for(i=n-1;i>=0;i--){
func(a[i]); //do anything
}
再来个坑。假如刚开始把i声明成有符号了,但是循环条件这么写:
int i;
for(i=n-1;i-sizeof(char)>=0;i--){
func(a[i]); //do anything
}
后果也是死循环。因为sizeof返回的是无符号数,i和它做运算的结果始终还是无符号数。所以要格外注意隐式转换。
拓展和截断
从k位的数字拓展到k+N位的数字,保持数值大小不变,只要对符号位拓展就行了。可以自己用之前的加权求和公式计算一下,实际上拓展出的N位符号位,和只有1位的结果正好是一样的。
因此,C对短类型转长类型,比如short转int,做的事情就在前面拓展符号位(准确地说有符号数才有符号位,意思知道了就行)。
而从长位到短位的数据转换,如uint转ushort,会把高位的数据直接截断,在数值上的结果就是做了一次对高位代表的权重的取余。
举个例子,考虑无符号数,11011是16+8+0+2+1=27,截掉最高位1,得到1011,1011=8+0+2+1=11,11就是27mod16。
而对于有符号数,截断的过程中负数可能会变正,正数可能会负,要格外注意,如int转short。
整数加减法,溢出
计算两个无符号数相加,像十进制那样做进位加法就可以,但是可能会存在最高位有进位的情况,也就是说,两个N位的数字相加,最终结果有时需要N+1位保存,但是容量只有N位,机器的做法是舍去多出来的一位,只保留N位,这就引出了溢出问题。
假设有4个bit表示无符号整数,能表示的范围是0到15,如果计算8+12,理论上结果是20,但实际结果是4,发生了溢出,溢出的规律是:理论结果mod/舍去位的权重,即4=20mod16。
对于有符号整数的情形,假如也用4个bit表示,能表示的范围是-8到+7,如果计算7+1,理论结果是8,实际结果是0111+0001=1000,这是最大的负数,即-8,发生了正溢出。如果计算(-8)+(-1),理论结果是-9,实际结果是1000+1111=0111,这是最大的正数,即7,发生了负溢出。不难发现溢出的规律是,从相反的最大值方向再重新轮次,刚好发生正溢出时会变成最大的负数,反之一样。
乘除的结果是类似的,发生溢出后的实际结果也是相当于做了模操作。
总结一下,发生溢出时做的操作就是高位截断,所以如果计算溢出后的实际结果的值,只要先计算出它的理论结果(二进制表示),然后根据它的数据类型的容量位数去截断高位,保留下来的就是实际结果。
幂
数据左移k位就相当于乘以2的k次幂,这是编程中常见的操作。如0011=3,左移1位就是0110,正好是6,再左移一位就是1100=12。处理2的k次幂乘积运算时,编译器就会将其优化为左移运算。
如果是做2的k次幂的除法,也只要右移即可,如果无法整除,会向下取整。注意对于有符号数,是算术右移,但是自己计算一下实际结果不是向下取整,是向上取整了,所以系统的做法是先对被除数加一个偏移量1,然后再做除法,这样最终的结果也是向下取整形式的,保持了统一。
相反数
想得到一个有符号数x的相反数-x,只要对其取反再+1。其实就是补码的一种快速计算方法。
小端&大端
指的是字节存储顺序,有小端模式和大端模式,目前绝大多数机器使用小端模式,如x86的机器,ARM处理器。
假设有个4字节数0x01234567,直观上看,我们认为这个数字是从左到右的,也就是说左边是数据的低位,右边是数据的高位,存储时内存地址从低到高也对应数据的从左到右。大端序就是这么存储,但是小端序反了过来,右边的数字会被存储到地址的低位,即67存在最低位,01存在最高位。
浮点数
浮点数的表示方法借鉴了科学计数法的思想,科学计数法是如下的形式:
只需要正负号,小数和指数三个部分就可以表示一个十进制数。同理,二进制数也可以表示成:
S决定正负号,称为符号位,F决定小数部分,称为“尾数”,E决定指数部分,称为“阶码”,或者指数。
单精度浮点数,阶码有8位。
十进制浮点数转二进制表示的公式,尾数不难理解,关键是理解指数部分的“偏移量”的概念。
其实想对指数部分编码,当然可以沿用补码的形式,假设指数是-1,就用1111 1111表示,-128就用1000 0000表示,+1就用0000 0001表示,+127就用0111 1111表示。只不过这样干的话,比较两个浮点数的大小时不够直观,对于两个规格化的浮点数,符号相同时,指数大的那个会大一个量级以上,单从数值上看,1000 0000是比0000 0001要大的,但转补码后,实际上前者比后者小。另一方面,纯0,无穷大等,非常非常接近0的小数等特殊的数字,怎么表示出来?
于是IEEE想的办法就是改变一下映射关系。我们希望用一种新的编码方式表示指数部分,叫“阶码”,使得对于指数,原来是正的a>b,转二进制后仍是a>b,负的c>d,转二进制后仍是c>d,且a>b>c>d,说白了,像无符号整数那样不改变递增关系。因此,阶码是不像补码那样有个符号位的,它是这么表示的:
1000 0000用来表示+1,向上递增就是正整数,以下的部分就是负整数。这样正好把表示范围拆成两半。观察这个二进制表示,和+1原本的补码0000 0001相差多少?相差127(0111 1111),即0000 0001 + 0111 1111 = 1000 0000。就是说——
阶码 = 补码 + 127
一旦建立这个新的映射关系,从负到正的大小关系就可以表示成类似无符号数的形式了。那么,计算一下,-1的阶码是1111 1111(补码) + 0111 1111(偏移量)= 0111 1110,0的阶码是紧随以后的0111 1111。那么全0用来表示什么了?
全0拿去编码特殊数字了,即浮点数纯0和非规格化数。当阶码为0000 0000,尾数也为0时,表示浮点数0,尾数不为0时,表示非规格化数。
另外,全1也拿去编码特殊数字了,即无穷大。当阶码为1111 1111,符号位为正时,就表示正无穷,符号位为负时,就表示负无穷。
所以阶码的编码方式就是:
- 特殊数字:0000 0000(0或非规格化数),1111 1111(无穷大)
- 负指数:0000 0001 ~ 0111 1110 (表示-126~-1)
- 指数为0:0111 1111
- 正指数:1000 0000 ~ 1111 1110 (表示+1~+127)
以上都是单精度的例子,对于双精度也一样。
双精度的阶码长度为11,+1的阶码表示当然仍是最高位为1,即100 0000 0000,和原本的补码表示000 0000 0001(也拓展到11位),相差的偏移就是011 1111 1111,即1023。可以看出来了,如果阶码有k位,偏移量就是2^(k-1)-1。