在一个学期对于《CSAPP》这本书的学习过程中,我了解到许多关于计算机软硬件交界面的知识,也了解到如何更好地编写一个更好、更安全、更少bug的程序。我希望能在下面的篇幅中,为与我一样的初学者介绍、总结我的学习感想与学习笔记。也许涵盖范围会比较广,例如会从我对《CSAPP》这本书的学习感触写到Linux系统使用过程中自己总结出来的小技巧。
第一次发帖的确会经验不足,但是总结出来的点都是对我自己十分受用的,希望与诸君共适。
下面是我主体内容的目录:
这一个单元于我看来,主要谈到了各种数据类型在机器中的表示方法,从最基础的各类进制的转换,谈到了数据在机器中的大/小端存储方式,最后谈到了各类数值的编码方式、何时会溢出。下面展开我对第二章知识点的总结。
用我自己的话来说,小端方式便是一种“合理的”存储方式(因其在日常生活中应用较广,如Intel、Android、Apple等,故称“合理”),即“最高有效字节在最高地址,最低有效字节在最低地址,依次排列”。大端方式则相应地,相反。
那么如何检测所用机器是以哪种方式存储呢?有两种方法:
一种是利用show_bytes实现检测:
int val = 0x87654321;
byte_pointer valp = (byte_pointer) &val;
show_bytes(valp,1) ;
可知大/小端法存储的机器运行时输出的结果不会一样,如果输出结果是21,那么就是小端机器;如果87,那么就是大端机器;
第二种方法则是利用结构体顺序存储方式的特点来进行检测,到了第三章结构体存储部分我再详细介绍。
讲述整型数据类型的几个小节内,我认为最重要的知识点便是:溢出情况的讨论、真值与补码之间的转换。
每一种整型数据类型的溢出情况是由自己的位数决定的,如x86-64系统中,int型占4个字节,long型占8个字节,它们的溢出上下界也相应地由位数决定。如下表:
类型 | 范围 |
---|---|
char | -128–127 |
unsigned char | 0–255 |
short | -2^15 – 2^15-1 |
unsigned short | 0 – 2^16-1 |
int | -2^31 – 2^31-1 |
unsigned int | 0 – 2^32-1 |
以此类推 |
如果所要表示的数的真值大/小于所用数据类型的上/下界,那么便会发生正/负溢出。那么如何判断是否发生了溢出呢?一种方法是,利用四种标志:溢出标志O,进(借)位标志 C,零标志Z,符号标志S。计算过程中,如果(最高位进位标志Cn)^(次高位进位标志Cn-1)= 1,则发生了溢出。
真值与补码之间的转换之间有着转换公式,我就将它们直接列出来。
编码方式上,浮点数较整型数据类型是非常不同的,浮点数的编码方式为:符号位+阶码+尾数。下面我从其编码时各个部分,非规格化数的表示,舍入,浮点数运算四个部分展开我对浮点数的学习笔记:
符号位同任何二进制数一样,0表正,1表负;阶码的实际值为:阶码上表示的数e - bias(偏置量);而偏置量bias等于2(k-1)-1(k对单精度为8,对双精度是11);尾数的值为将真值化为1.xxxxxx*2E后,小数点后面的数,且在尾数部分中用原码表示。且,浮点数在机器中的存储结构如下图所示:
非规格化数主要有两个用途:表示0与表示无穷。1.当符号位、阶码、尾数为全0时,则表示该数为0;2.当阶码为全1时,当尾数为全0时,符号位为0或1决定了该数为正无穷还是负无穷;若此时尾数不为0,则称该数为NaN(not a number)。
首先,舍入只针对二进制小数而言;其功能在于,将高位小数化为低位小数(为了符合对应数据类型的位数)时,更加精确,更加接近原值。
舍入共有四种方式,如图所示:
下面主要讨论就近舍入(round to even)的方式:
之所以详细讲round-to-even这种舍入方式是因为这种方式在大多数现实情况中避免了一些统计偏差。
浮点数运算步骤与其存储结构相对应,三个部分:符号+阶码+尾数各自分别运算。此时为了计算的方便,我们运算时需要进行对阶操作,即将指数部分化为同一个数,尾数做出相应变化。
但,当两个数指数部分差值达到25时,则对阶时会将指数更小的那个数直接舍去。例如:1e20-(1e20+3.14) = 0,但是(1e20-1e20)-3.14 = -3.14;这就是因为前一个等式中进行(1e20+3.14)时,两个数之间的阶码差值>25,则括号内运算结果为1e20。
从而我们可以总结一些数据类型转换之间、关于转换前后数值是否准确的规律:
《CSAPP》U2的课后习题中有一个好实验,其以show_bytes代码作为主体,来展示同一个数作为不同数据类型时在机器中的存储结构和地址,并且能够检验机器的存储方式(大端or小端)。代码如下:
#include
#include
#include
typedef unsigned char *byte_pointer;
//typedef char *byte_pointer;
//typedef int *byte_pointer;
void show_bytes(byte_pointer start, size_t len) {
size_t i;
for (i = 0; i < len; i++)
printf("%p\t0x%.2x\n", &start[i], start[i]);
printf("\n");
}
void show_int(int x) {
show_bytes((byte_pointer) &x, sizeof(int));
}
void show_float(float x) {
show_bytes((byte_pointer) &x, sizeof(float));
}
void show_pointer(void *x) {
show_bytes((byte_pointer) &x, sizeof(void *));
}
void test_show_bytes(int val) {
int ival = val;
//float fval = (float) ival;
double fval = (double) ival;
int *pval = &ival;
printf("Stack variable ival = %d\n", ival);
printf("(int)ival:\n");
show_int(ival);
printf("(float)ival:\n");
show_float(fval);
printf("&ival:\n");
show_pointer(pval);
}
int main(int argc, char *argv[])
{
int val = 12345;
if (argc > 1) {
val = strtol(argv[1], NULL, 0);
printf("calling test_show_bytes\n");
test_show_bytes(val);
} else {
printf("No argument!\n");}
return 0;
}
当输入参数ival为1073741824(2的30次方)时,结果如下:
calling test_show_bytes
Stack variable ival = 1073741824
(int)ival:
0xbfd40020 0x00
0xbfd40021 0x00
0xbfd40022 0x00
0xbfd40023 0x40
(float)ival:
0xbfd40020 0x00
0xbfd40021 0x00
0xbfd40022 0x80
0xbfd40023 0x4e
&ival:
0xbfd40020 0x34
0xbfd40021 0x00
0xbfd40022 0xd4
0xbfd40023 0xbf
但此时将byte_pointer类型改为int *型时,输出结果改变了:
calling test_show_bytes
Stack variable ival = 1073741824
(int)ival:
0xbfae9b90 0x40000000
0xbfae9b94 0x40000000
0xbfae9b98 0x40000000
0xbfae9b9c 0xb7521940
(float)ival:
0xbfae9b90 0x4e800000
0xbfae9b94 0x40000000
0xbfae9b98 0x40000000
0xbfae9b9c 0x4e800000
&ival:
0xbfae9b90 0xbfae9ba0
0xbfae9b94 0x40000000
0xbfae9b98 0x40000000
0xbfae9b9c 0x4e800000
将byte_pointer类型改为char *型时,结果也有改变:
calling test_show_bytes
Stack variable ival = 1073741824
(int)ival:
0xbfbe3150 0x00
0xbfbe3151 0x00
0xbfbe3152 0x00
0xbfbe3153 0x40
(float)ival:
0xbfbe3150 0x00
0xbfbe3151 0x00
0xbfbe3152 0xffffff80
0xbfbe3153 0x4e
&ival:
0xbfbe3150 0x60
0xbfbe3151 0x31
0xbfbe3152 0xffffffbe
0xbfbe3153 0xffffffbf
Q:为什么byte_pointer类型发生改变时会出现这样的变化?
A:
因为课程要求,我的实验都是在Linux系统上实现。完全习惯了Windows操作系统的我,上手Linux颇有难度,又由于我的机器的问题,电脑上不能安装Vmtools,因此Windows里面的文件传送到虚拟机上有困难。一开始我搜网上的教程,发现需要建立共享文件夹,我建立之后发现因为电脑原因还是不能够共享文件;因此我尝试了从手机上传文件到电脑的方法:在任意浏览器中登陆网页版微信(非广告),从手机上将目标文件发到网页上,再保存即可。目前我只试过文本文件,其他文件格式可能不尽相同。不过还是希望这种小伎俩能对同等level的初学者有所帮助。
以上便是我对《CSAPP》U2的学习理解,第二单元涵盖了从各种进制之间的转换、到各类数据类型的详细介绍、再到对程序中的应用,可谓十分重要,是该本书学习的基础。我的笔记只是一家之言,但也希望对读者有所帮助,也希望发现了问题的读者能够不吝赐教。
这一个单元从标题上就可知主要内容为高级语言(此处以C语言为例)编写的程序在机器中如何表示。然而从高级语言的繁琐(例如条件语句、循环、递归等各种语法)化简到机器代码(由0、1构成)中间的过程也非常复杂。
因此我接下来的笔记将从总体的高级语言到机器代码的转换过程、x86-64汇编指令系统、C语言的机器表示、复杂数据类型的机器表示、相关Linux命令、相关实验这六个方面展开我对U3的学习笔记。
x86-64系统中指令基本上分为有效地址加载指令、数据传送指令、跳转指令、栈相关指令、算术操作指令,而每个指令都与寄存器与内存相关。指令是什么与为什么是这样的定义这样的问题我就不再多言,此处不是难点,用记忆解决即可;我主要谈一谈在使用这些指令时需要注意的地方:
x86-64指令集系统中,根据每一种简单数据类型的字长,在使用时规定了不同的后缀,如char型占一个byte,因此其后缀为b;我也不做过多解释,记忆即可;
x86-64指令集系统中共有16个通用寄存器,其中每一个寄存器在使用时都要根据其字长来使用,如rax寄存器,对long型或指针型数据则要用rax,对int型数据则要用eax,以此类推;有疑惑、想要了解更多的读者可以移步:x86-64寄存器及栈帧描述
作为使用频率最高的指令,mov指令无疑是最为重要的,以下是几个我自己认为的重点:
有关栈的操作就是压入/弹出栈的操作,其作用主要是保护现场,保证当前语句段执行完后能够找到回去的路,比如函数调用则是一个典型的需要将返回地址进行入栈操作的例子。下面是一些使用栈相关指令时需要注意的点:
subq $8,%rsp movq %rbp,(%rsp)
lea(load effective address)指令有两种用法,
leaq (%rax, %rax,3),%rdi
实现了将rax中内容*5后放到rdi中去);之所以将比较指令/测试指令与跳转指令放在一起是因为,这几条指令一般是连用在一起,构成条件语句/循环语句/goto语句。
比较指令实际上是做减法,测试指令则是测试该内容是否为0;
而跳转指令则较为复杂,在于其后缀的多样,需要记忆,觉得有必要详细理解或是有疑惑的读者可以移步:跳转指令汇总
而且,跳转指令有特殊的编码方式,即:PC相对的(PC relatively);即将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码,公式化后便是:编码=目标地址-跳转指令后一条指令的地址
。《CSAPP》课后习题中有:
下面je指令的目标是什么? 4003fa: 74 02 4003fc: ff d0
由上述公式可知,je指令指向的地址就是(4003fc+02 == 4003fe)。
多个算术操作指令中,我选取移位(s:shift)指令与乘法(imul:multiple)指令来讲:
int Imul(int x,int y){
return x*y;}
若将该函数汇编后得到的汇编代码便是:
imul %rdi,%rsi movl %rsi,%rax
C语言是一门高级语言,C程序中含有多种复杂语法,如条件语句、开关语句、循环语句、递归调用等等。它们化繁为简到机器代码(只含0与1)之间的过程值得我们仔细研究:
条件语句(if—else)通常由比较指令(或测试指令)与跳转语句构成,其思路为:将if——else语句化为goto语句形式,再化为汇编语言。以《CSAPP》书U3 图3-16 图a)中代码为例:
long Inc(long x,long y){
long result;
if(x
化为goto语句形式后,C语言代码为(只展示条件语句):
if(x<=y)
goto false;
return y-x;
false:
return x-y;
化为汇编语句后,为:
cmpq %rsi,%rdi
jge .L2//goto false
movq %rsi,%rax //then-statement
subq %rdi,%rax
ret
.L2:
movq %rdi,%rax//else-statement
subq %rsi,%rax
ret
C语言中含有多种循环语句的写法,如for循环、while循环、do—while循环,但汇编语言中没有专门的循环语句,因此同条件语句一样,汇编语言中通过比较语句与条件跳转语句配合来实现循环结构。同C语言中循环语句的三种形式,我也分别对do-while语句、while语句、for语句进行讲述分析,并在最后给出实例进行分析:
loop:
body-statement
if(test-expr)
goto loop
可见其中的if语句与goto语句都可以像之前说的,简单地使用比较指令、条件跳转指令来实现汇编化;
goto test;
loop:
body-statement;
test:
t = test-expr;
if(t)
goto loop;
init-expr;//循环控制变量的初始化
while(test-expr){
body-statement;
update-expr;//更新操作
}
long fact_for(long n){
long i;
long sum = 0;
for(i=0;i
将其化为goto型代码可以是:
long fact_goto(long n){
long i = 0;// init-expr
long sum = 0;
goto test;
loop:
sum+=i;
i++;
test:
if(i
再将其写成汇编,为:
fact_goto:
movq $0,%rax
movq $0,%rdx
jmp .L8
.L9://循环体
addq %rdx,%rax
incq %rdx
.L8://循环继续条件
cmpq %rdi,%rdx
jl .L9
rep;ret
C语言函数的递归调用在解决实际问题时十分常见,在将C代码化为汇编代码时,递归过程又会如何表示呢?我将从栈的使用、递归过程两个方面阐述递归调用在编译时的具体表示:
C代码:
long rfun(unsigned long x){
if(x==0)
return 0;
unsigned long nx = x>>2;
long rv = rfun(nx);
return x+rv;
}
汇编代码:
rfun:
pushq %rbx
movq %rdi,%rbx //将x入栈,保护现场;
movl $0 , %eax
testq %rdi,%rdi
je .L2
shrq $2,%rdi //此时rdi寄存器中内容已经不是x,而是x>>2
call rfun //此时需要将返回地址,即call指令的下一条指令的地址入栈;
addq %rbx,%rax //此时可知为何需要将x的值入栈保护现场
.L2:
popq %rbx
ret
可见递归调用对栈的调用极其频繁,因此存在一种隐患,当一个入栈的数据,溢出了栈分配给它的空间,可能会影响到其他存放的数据。因此,针对这个特殊的危险情况,cmu官方有一个实验,我在第六个部分“相关实验”中详细讲解这个实验。
复杂数据结构,如数组、结构体、联合体等等在机器中都有其特殊的、独特的表示方式,现在我来讲述这几个复杂数据类型在机器中的具体表示方法。
数组:数组元素都是顺序存储,一维数组不用说,二维数组是按行优先的规则进行顺序存储;对于这种定长数组最重要的就是对一个给定下标与起始地址的数组元素寻址,解决这类问题有如下公式:&D[i][j] = Xd + L(C*i + j)
,其中Xd为起始地址,L为数组数据类型的字长,C为该二维数组的列数。且在汇编语言中,数组元素寻址时一般用间接比例变址的方式(Imm1(Reg1,Reg2,Imm2))寻址。
结构体:结构体元素也是顺序存储,将不同类型的元素整合在一个对象中,而第一个元素的地址便是整个结构体变量的地址。但其结构体元素的存放有一条规则,该元素存放的地址必须是该元素字长的整数倍数。因此存放结构体时可能会存在浪费空间的问题,我们来看一个结构体的定义:
struct rec{
int i;
char a[2];
int *p;}
其存储结构如图:
可见其中有存储空间被浪费了,那么存在让存储空间浪费减少的方法吗?有的。
方法就是将字长最长的结构体成员放在定义最前,最短的放在最后面,依次定义。此时存储结构如图:
可见其所占用的存储空间确实减少了,而且没有浪费的情况出现。
union{
long data;
char a[8];
}
对union.data赋值为0x87654321。其存储结构如图:
因此可见可以利用联合体的存储结构来对机器的存储方式(大or小端)进行检测。
这个单元绝大多数篇幅都是在讲C程序与汇编语言之间的联系,因此应该如何得到汇编代码,如何对C程序编译、优化成为一个亟待解决的问题,因此来引出相关Linux命令:想了解的读者请移步:Linux常用命令大全。
在”2.3.3:递归调用“中,我提到cmu官方有一个关于栈数据溢出的实验。现在将其列出:
主体代码为:
#include
#include
typedef struct {
int a[2];
double d;
} struct_t;
double fun(int i) {
volatile struct_t s;
s.d = 3.14;
s.a[i] = 1073741824; /* Possibly out of bounds */
return s.d; /* Should be 3.14 */
}
int main(int argc, char *argv[]) {
int i = 0;
if (argc >= 2)
i = atoi(argv[1]);
double d = fun(i);
printf("fun(%d) --> %.10f\n", i, d);
return 0;
}
当i不同时,输出结果是不一样的,如下:
gec@ubuntu:/mnt/hgfs/share/csapp_code$ ./a.out 0
fun(0) --> 3.1400000000
gec@ubuntu:/mnt/hgfs/share/csapp_code$ ./a.out 1
fun(1) --> 3.1400000000
gec@ubuntu:/mnt/hgfs/share/csapp_code$ ./a.out 2
fun(2) --> 3.1399998665
gec@ubuntu:/mnt/hgfs/share/csapp_code$ ./a.out 3
fun(3) --> 2.0000006104
gec@ubuntu:/mnt/hgfs/share/csapp_code$ ./a.out 4
fun(4) --> 3.1400000000
段错误 (核心已转储)
为什么会出现这样的变化,甚至在最后出现报错呢?答案是缓冲区溢出。
该函数涉及到的存储结构大致如下:
U3较U2来说,与实际编写程序的过程更加贴近,主体内容为讨论如何将C程序汇编化,期间也涉及到许多等价语法的转化,for循环—>while循环等等。期间可以看到哪一种语句在编译时效率更高,将这一单元学好对以后编程提高效率大有裨益。