基本类型:bool,char,short,int,long,float,double
对于char,short,int,long:
多字节类型赋值给少字节类型,对低字节的细节感兴趣,位模式拷贝。
少字节类型赋值给多字节类型,进行位模式扩展,注意符号,符号扩展。
float表示法:表示为1.x的(-127~128)次方,可以对数字进行乘2或除2得到1.x的模式再进行表示。
在整型与浮点型互相赋值的过程中,会重新计算值,得到新的位模式,而不是单纯的位模式拷贝。
1 int i = 7; 2 float f = i;
直接对原先的位模式进行解析(空间大小相同):
1 int i = 37; 2 float f = *(float*)&i;//对i取地址,并把地址转换成float的指针,再解引用
直接对原先的位模式进行解析(空间大小不同):
1 float f = 7.0; 2 short s = *(short*)&f;
直接对原先的位模式进行解析(空间大小不同):
1 short s = 45; 2 double d = *(double*)&s;
关于大端小端:取地址运算符总是得到最低字节的地址,大端:低字节存储对于值贡献最大的位。linux是小端
结构
1 struct fraction 2 { 3 int num; 4 int denom; 5 }; 6 7 fraction pi; 8 pi.num = 22; 9 pi.denom = 7; 10 ((fraction*)&(pi.denom))->num = 12; 11 ((fraction*)&(pi.denom))->denom = 33;
整个结构的地址总是和第一个域的地址一致
数组
1 int array[10]; 2 3 // array 是等价于 &array[0],即数组地址等价于其第0个元素的地址 4 // array + k 是等价于 &array[k],上边为特例,array 等价于 array + 0 5 // *array 是等价于 array[0] 6 // *(array + k) 是等价于 array[k] 7 // 进行指针算数运算,数字k可以基于类型系统知道实例的空间情况,根据指针的类型进行成倍数的加减,只考虑划分数据。 8 // 数组下标运算都可以转换为与数组类型相关的隐式指针算数运算。 9 // 不能对void*使用数组操作的原因与它不能解引用的原因是一样的,在它之中没有元素大小的信息,也就失去了比较的能力。因为不知道是啥就不用知道怎么比较
传递数组到函数,并不是将整个数组传入,而是将数组的第0个元素地址传入。如果知道数组长度则可以合理的访问数组。C和C++中,原始数组不提供边界检查,可接受大于数组边界的下标,和负数下标。
结构数组
C中字符串不像C++和Java中的字符串,就是暴露细节的字符数组,由字符指针表示。
这个只是说明一下内部构造,并没有赋予内存。
strcpy函数,会把传入地址看作任意长度字符序列空间基地址用来写入字符,直到写完‘\0’为止。
C的模板化,通用编程,通用指针,通用算法
实现下面这个交换程序的通用版本
一个错误的例子:其中参数中的void并不意味着它什么都没有指向,这只是说它指向了没有任何类型信息的某些东西。void可以用于函数的返回类型,表示没什么可以返回的。也可以把void作为唯一参数传入一个函数,说明不需要任何参数。或者使用void表示通用指针void*。不允许对void*解引用,因为机器并不知道要提取出多少字节作为操作的一部分。所以需要指定第三个参数,说明内存布局和被交换的字节数量。
正确的例子:需要交换位模式,课程中的编译器允许声明一个大小依赖于参数的数组。(但是真正的编译器不一定允许将一个非常数值放入数组声明中,实际实现方式可能是动态申请一个size字节大小的块。是向堆中而不是栈中拷贝,最后释放掉。)数组并非C语言中的字符串缓冲区,而是留出足够的空间存放指定大小的字节,作为一个小的存储单元去拷贝一些东西。memcpy函数不是用于字符串拷贝,不关心'\0',必须显示指定要从内存位置上拷贝多少个字节。
与C++模板相比,如果这段代码被编译,那么两种调用产生的汇编代码是一样的。不过对于模板来说,它对每个编译或者调用,根据模板设定进行扩展成两种代码的独立版本,然后根据特定域为int或double进行编译。如果调用次数少的话还好,如果有n多种数据类型调用这个函数,在可执行文件中,就会有n种不同的代码拷贝。
调用方法如下:
1 int x = 17, y = 37; 2 swap(&x, &y, sizeof(int)); 3 double d = 20.0, e = 12.1; 4 swap(&d, &e, sizeof(double));
5 short s = 44; 6 float f = 11.1; 7 swap(&s, &f, sizeof(short));//错误的调用方式
再来两种调用方式:
1 char *husband = strdup("Fred"); 2 char *wife = ("Wilma"); 3 swap(&husband, &wife, sizeof(char*));//正常交换两个变量的内容 4 swap(husband, wife, sizeof(char*));//不改变变量的值,而是改变指向的字符串的值的一部分
实现下面线性搜索程序的通用版本:
1 int lsearch(int key, int array[], int size) 2 { 3 for(int i = 0; i < size; i++) 4 { 5 if(array[i] == key) 6 { 7 return i; 8 } 9 } 10 return -1; 11 }
视作一小块的通用内存,为了能从i=0到i=1前进,需要知道第一个元素从哪里开始,也要传入元素大小信息,才能人工计算地址。通过地址指定key,也通过地址指定数组,指定有多少个小盒子,和每一个盒子的宽度。就知道了for循环的次数,和每一次迭代中在内存中要前进多少。也需要传入一个比较函数,指定key和数组中的第i个元素的相等条件(不能只是使用两个等号进行判断),比较指针指向的内容。
技巧是把基指针转换为char*类型,对char*做指针算数运算和平常算数运算效果一样。
其中memcmp函数对基本类型进行比较,返回正负零三种,但是对含有指针的类型(结构中含有指针的类型,字符指针,C字符串等不能很好比较)
mencpy版本:
1 void lsearch(void* key, void* base, int n, int elemSize) 2 { 3 for(int i = 0; i < n; i++) 4 { 5 void* elemAddr = (char*)base + i * elemSize; 6 if(memcmp(key, elemAddr, elemSize) == 0) 7 return ememAddr; 8 } 9 return NULL; 10 }
函数指针版本:(传入一个专门用来比较的函数指针,函数参数类型跟memcmp差不多,用函数替换memcmp)
1 void* lsearch(void* key, void* base, int n, int elemSize, int (*cmpfn)(void*, void*))//括号内的星号表示是函数指针,可写可不写 2 { 3 for(int i = 0; i < n; i++) 4 { 5 void* elemAddr = (char*)base + i * elemSize; 6 if(cmpfn(key, elemAddr) == 0) 7 return elemAddr; 8 } 9 return NULL; 10 } 11 12 13 int IntCmp(void* elem1, void* elem2) 14 { 15 int *ip1 = elem1; 16 int *ip2 = elem2; 17 return *ip1 - *ip2; 18 } 19 20 21 int array[] = {4,2,3,7,11,8}; 22 int size = 6; 23 int number = 7; 24 int found1 = lserach(&number, array, 6, sizeof(int), IntCmp); 25 if(found1 == NULL)//未找到 26 else//找到 27 28 29 int StrCmp(void* vp1, void* vp2) 30 { 31 char* s1 = *(char**)vp1; 32 char* s2 = *(char**)vp2; 33 return strcmp(s1, s2);//库函数 34 } 35 36 37 char* notes[] = {"Ab", "F#", "B", "Gb", "D"}; 38 char* favouriteNote = "Eb"; 39 char** found2 = lsearch(&favouriteNote, notes, 5, sizeof(char*), StrCmp);//传入函数名子作为参数 40 if(found2 == NULL)//未找到 41 else//找到
函数(function)与方法(method)的区别在于,方法将与其相关的对象的地址作为一个隐含的参数(这个隐含的参数叫做this指针)。上文中传入作为参数的函数类型要么就是与类不相关的全局函数,要么就是类中的一个静态方法(静态方法意味着调用时在内部并没有this指针作为参数传入)。
对于C++的方法来说,定义在类中的各个成员函数通常会使用this这个隐含参数传入接收对象的地址。可以在任何方法的实现中使用this关键字,它会求值为被操作对象的地址。普通函数并没有调用对象,因此没有隐含的this指针被传入作为参数。
C语言(没有引用没有模板没有class,但是有struct,没const没public没private)实现通用数据结构,类似C++范型模板,vector,queue,map,stack,
C++中把行为描述和具体实现分开,通过.h和.cc文件方式。
当调用一个类的构造函数时,改函数要访问到this指针,因为函数会把this作为第-1个参数传入,或者作为一个隐含参数在其他参数传入之前传入。下例中显式实现为第0个参数。
malloc只考虑到分配的空间大小(有一个参数指定需要的原始字节的数目),获取到参数后,到堆中查找合适大小的内存块,在其上做一个标记,表示这块内存已经被占用了,然后哦返回这块内存的基地址。new操作符会隐式的考虑到数据类型。
实现通用的stack数据结构
将一个元素压栈的时候,将它的所有权从自己手里交给了栈。
int版本
1 //stack.h 2 typedef struct 3 { 4 int* elems; 5 int logicallen; 6 int alloclength; 7 } stack; 8 9 10 void stackNew(stack* s);//构造函数 11 12 void stackDispose(stack* s);//析构函数 13 14 void stackPush(stack* s, int value);//入栈 15 16 int stackPop(stack* s);//出栈
1 //stack.c 2 void stackNew(stack* s)//构造函数 3 { 4 s->index = 0; 5 s->sum = 4; 6 s->elems = malloc(4 * sizeof(int)); 7 assert(s->elems != NULL); 8 } 9 10 void stackDispose(stack* s)//析构函数 11 { 12 free(s->elems);//如果存放了char*类型的元素,那么就要写个for挨个遍历释放了。free实际知道某个块有多大,因为内部有某个结构登记这些信息, 所以每次都知道要还给堆多少内存 13 } 14 15 void stackPush(stack* s, int value)//入栈 16 { 17 if(s->index == s->sum) 18 { 19 sum *= 2; 20 s->elems = realloc(s->elems, s->sum * sizeof(int));//重新调整堆空间大小,在后边接上一段新内存或者新分配一块大内存并复制原内 容,实际上的复杂度为m(堆的大小,在堆中搜索合适的内存),而不只是复制时间。 21 assert(s->elems != NULL);//如果不满足重新分配条件的话,函数会保留原来的内存并且返回NULL 22 } 23 s->elems[s->index] = value;//注意顺序 24 s->index++; 25 } 26 27 int stackPop(stack* s)//出栈,但是为了速度考虑,一般并不会缩小所分配的内存块 28 { 29 assert(s->index > 0); 30 s->index--; 31 return(s->elems[s->s->index]);//出栈减1操作和数组访问操作和入栈顺序相反 32 }
实现上面数据结构的通用版本1(int,bool,double,char)
通用版本:首先,因为void*不能做指针运算了,所以要把指针转化成char*类型。其次,还不知道对象的大小,所以要传递一个表示对象大小的参数。
1 //stack.h 2 typedef struct 3 { 4 void* elems; 5 int elemSize; 6 int index; 7 int sum; 8 }stack; 9 10 void stackNew(stack* s, int elemSize);//构造函数 11 12 void stackDispose(stack* s);//析构函数 13 14 void stackPush(stack* s, void* elemAddr);//入栈 15 16 void stackPop(stack* s, void* elemAddr);//出栈
static关键字修饰函数原型时(不是类中的方法,而是一个普通的函数),意味着改函数是私有函数,不应该在函数所在文件之外对其引用。(类似于C++中的private)。全局函数其符号或函数名可以被导出并可以在其他的.o文件中访问,或者被其他的.o文件使用。static修饰函数则被称为是局部的或者内部的,不能在其他文件中被调用,只能在自己的.o文件中使用。为了防止函数版本太多,编译器不知道调用哪一个的情况的冲突。
1 //stack.c 2 void stackNew(stack* s, int elemSize)//构造函数 3 { 4 assert(elemSize > 0); 5 s->elemSize = elemSize; 6 s->index = 0; 7 s->sum = 4; 8 s->elems = malloc(4 * elemSize); 9 assert(s->elems != NULL); 10 } 11 12 void stackDispose(stack* s)//析构函数 13 { 14 free(s->elems); 15 } 16 17 static void stackGrow(stack* s)//栈扩容 18 { 19 s->sum *= 2; 20 s->elems = realloc(s->elems, s->sum * s->elemSize); 21 } 22 23 void stackPush(stack* s, void* elemAddr)//入栈 24 { 25 if(s->index == s->sum) 26 { 27 stackGrow(s); 28 } 29 void* target = (char*)s->elems + s->index * s->elemSize; 30 memcpy(target, elemAddr, s->elemSize); 31 s->index++; 32 } 33 34 void stackPop(stack* s, void* elemAddr)//出栈,最好由调用它的函数来管理内存(负责分配和回收内存),责任对等(对于任何申请了空间的函数,它 也应该负责这些空间的释放工作) 35 { 36 void source = (char*)s->elems + (index-1) * s->elemSize; 37 memcpy(elemAddr, source, s->elemSize); 38 s->index--; 39 }
1 int main(_, _) 2 { 3 const char* friends[] = {"Al", "Bob", "Carl"}; 4 stack stringStack;//此时里边只是无意义的16字节数据 5 stackNew(&stringStack, sizeof(char*));//作相应的初始化工作,但是新申请的空间并未初始化 6 for(int i = 0; i < 3; i++) 7 { 8 char* copy = startup(friends[i]); 9 stackPush(&stringStack, ©); 10 } 11 char* name; 12 for(int i = 0; i < 3; i++) 13 { 14 stackPop(&stringStack, &name); 15 printf("%s\n",name); 16 free(name); 17 } 18 stackDispose(&stringStack);//但是用户不需要弹出所有栈元素才释放栈空间。为了方便,应该先释放掉这些元素并将它们归还给堆空间,再释放掉栈 本身空间。(核心是注意归还各类动态申请的资源或者是打开的文件资源等等) 19 }
通用版本2(非基本类型,涉及动态内存申请。如字符串指针,结构里带字符串,指向结构的指针之类的)
1 typedef struct 2 { 3 void* elems; 4 int elemSize; 5 int index; 6 int sum; 7 void (*freefn)(void*);//基本类型传入NULL。char*,指向结构的指针,一些成员是指向动态申请内存的指针的结构,就可以指定一个释放函数 8 }stack; 9 10 void stackNew(stack* s, int elemSize, void (*freefn)(void*));//构造函数,就是为了初始化基本信息并初始化对应的释放函数的指针作用 11 12 void stringFree(void* elem)//针对字符串版本的释放函数 13 { 14 free(*(char** )(elem)); 15 } 16 17 void stackDispose(stack* s)//析构函数,保证在析构结构体本身之前,先释放申请的资源 18 { 19 if(s->freefn != NULL) 20 { 21 for(int i = 0; i < s->index; i++) 22 { 23 s->freefn((char*)s->elems + i * s->elemSize); 24 } 25 } 26 free(s->elems); 27 } 28 29 void stackPush(stack* s, void* elemAddr);//入栈 30 31 void stackPop(stack* s, void* elemAddr);//出栈。不需要把弹出栈的元素释放掉,因为元素所有权要要移交给变量,变量不能获得一块无用的内存
指针相减和memcpy效率
1 void rotate(void* front, void* middle, void* end) 2 { 3 int frontSize = (char*)middle - (char*)front;//对两个int*类型相减得到两个地址之间的int类型元素的个数,而不是总字节数。指针相减与指 针相加相符合。 4 int backSize = (char*end) - (char*)middle; 5 char buffer[frontSize]; 6 memcpy(buffer, front, frontSize);//尽可能使用memcpy函数,效率高 7 memmove(front, middle, backSize);//在复制时因为有可能会重合,然后使用memcpy的低效版本memmove,会做判断,从低到高或从高到低复制 8 memcpy((char*)end - frontSize, buffer, frontSize); 9 }
堆内存管理(堆在低地址,向高地址生长)
1 int *arr = malloc(40 * sizeof(int)); 2 free(arr);
堆中,分配内存时,每块被分配的内存块都比实际的要大一些,前几字节的头部会存放整块内存(包括头部)的空间大小和其他额外的信息。释放内存时,会对传入的指针向前找几个字节,解释成内存块的大小,并以某种方式释放,加入到空闲列表数据结构中。
系统维护一个空闲列表数据结构。每个空闲块头部会存储指向下一个空白块的地址。
内存压缩(内存空间不足):malloc,free,realloc返回的是句柄,不是直接指向数据的指针,而是距离实际数据两跳的指针 。系统会维护一个一级指针的列表,并把列表的首地址返回给用户,因此用户与实际数据的距离是两跳。
栈内存管理(栈在高地址,向地址值生长)
栈中被使用的部分大体上与活动函数的数量成比例(即栈中正在执行的所有函数的深度)。每次调用新函数时,栈指针会减小其栈帧的大小。栈指针其实是为了访问本函数访问其各个局部变量提供入口地址的基指针,到最后一个参数的偏移距离是0。栈指针实际使用硬件存储(硬件会确保当前相关的活动记录的基地址存储在某个寄存器中),总是跟踪并指向最近被调用函数的活动记录。一个栈帧不能访问之前另一个栈帧的内容,除非作为参数传入。函数调用结束时,栈指针会返回函数调用之前的位置。
代码段(在更低地址的内存中)
代码段内存中存储着C或C++代码对应的编译过后的汇编代码。
通用内存连接到寄存器集,寄存器集连接到ALU(算数逻辑单元),ALU与内存并不直接相连。通过save & load(store:将内存中某块区域的值进行了更新 & load:取数到寄存器中(内存,立即数等))方法完成寄存器和内存之间的数据传输。运算算流程如下(读取-执行-写回,即load-ALU-store):寄存器需要从内存取操作数,执行运算,把运算结果存放到寄存器中,并刷回到内存中。把C和C++代码翻译成汇编代码指令,汇编代码访问内存,执行并且模拟C和C++代码中的功能。
栈段中的局部变量的地址表示为,以栈指针(SP,stack pointer)为基址,并加上一个偏移量。内存对4字节整数倍的存储情况作了优化。汇编不允许一条指令中同时出现save&load(内存到内存)的情况。如果一个语言拥有循环结构,那么汇编语言中会毫无例外的执行跳转任意长度的某些指令,这些指令用来在一起执行循环体。if和switch语句会根据测试结果决定是否执行,也会有跳转指令。PC指向当前指令,跳转就是再加一个偏移。分为无条件跳转和有条件跳转。汇编代码最好能写成上下文无关的形式。汇编指令都是4字节的,一共有59种操作模式,4字节前6位是操作码,后边的位如何解释依赖于操作码的模式。
栈中声明几个变量,从高地址向低地址依次赋值。栈中声明一个数组,数组的0号元素在低字节,最后一个元素在高字节。 栈中声明一个结构体,结构体的第一个元素也是在低地址,最后一个元素在高地址。(总体上来看是从高地址向低地址生长的,但是有结构的(数组,结构体等)都是在内部从低地址向高地址增长)在汇编中,可以忘掉是数组还是结构,只是直接操纵结构中的各个成员域。强制类型转换允许编译器绕过类型检查机制进而通过编译而产生汇编代码。
函数的参数也应该在栈帧中存储,参数从右至左,从高地址到低地址存放,第0个参数总在其它参数下方。所有局部变量都在比参数更低的地址。栈帧中,在参数和局部变量之间有一小块内存,存放着一些信息,告诉我们到底是那段代码调用了本函数(saved PC,或 return link 表示即将返回继续执行的地址)。栈中要生成局部变量,先要生成为局部变量申请内存空间的代码。SP寄存器(stack pointer)其总是指向执行中的栈的最低地址。局部变量的申请和创建,会使得SP减小一个值,SP指向栈段中使用的和还未使用的内存的分界线。
C语言程序例子1:
1 void foo(int bar, int* baz)//一个C语言函数执行的第一步就是为局部变量申请空间,压参数入栈由调用语句实现。同一个函数帧由调用语句和被调用函数共同形成(调用语句压参数和返回地址入栈,被调用函数压局部变量入栈)。当进入到被调用函数中时,栈指针(SP)最初指向的是返回地址。 2 { 3 char snink[4]; 4 short *why; 5 //<foo>:SP = SP - 8;将栈指针的值减小来为两个变量留出空间,从而完成整个活动记录。因为编译器能够在编译时确定到底有多少个字节(可以在产生汇编代码的时候,可以看到在所有函数代码最头部声明的局部变量,计算出他们所占大小的总和,然后在一条汇编代码中统一申请空间),将活动记录扩展成完整的版本,并将栈指针降低并保持新增加的栈空间为未初始化的状态(C代码中未初始化一个局部变量,那么汇编代码也不会初始化它)。 6 why = (short*)(snink + 2); 7 //R1 = SP + 6;通过SP的相对偏移访问局部变量 8 //M[SP] = R1; 9 *why = 50; 10 //R1 = M[SP]; 11 //M[R1] = .2 50;表示只存储2个字节 12 13 //SP = Sp + 8;在函数结束时隐式调用。函数结束时应将SP提升,让SP回到调用函数之前的状态,回收局部变量的栈空间。此时SP指向的内存块存储着返回地址 14 //RET;返回指令:会把SP指向的内容取出来,放到PC寄存器中,并且让SP加4,然后继续执行之后的代码,好像程序看起来一直在执行PC+4这个位置的指令,好像没有调用过foo函数一样。使程序跳转到第29行。 15 } 16 17 int main(int args, char** argv) 18 { 19 int i = 4;//局部变量的申请或创建,会被编译成将SP减去一个值,因为在栈中新申请了一个内存块,然后将变量初始化 20 //sp = sp - 4; 先减SP 21 //M[sp] = 4; 再进行赋值操作 22 foo(i, &i);//调用语句需要为调用函数留出空间,需要为foo创建一个部分的活动记录,调用语句能够指出需要留出多少空间(看函数原型)。并把i的值和i的地址存入对应的内存空间中,并将控制权转给模拟foo执行的那部分汇编代码。 23 //SP = SP - 8; 24 //R1 = M[SP + 8]; 25 //R2 = SP + 8; 26 //M[SP] = R1; 27 //M[sp + 4] = R2; 28 //CALL<foo>;为参数构建完活动记录后,通过汇编代码(一条简单的跳转指令)将控制权移交给foo函数,会跳转到foo函数中的第一条汇编代码指令并执行它,CALL指令通过某种方式保证当执行完成之后跳回这里。 29 //SP = SP + 8;如果没有CALL指令,接下来就执行该汇编指令。(本条汇编指令地址被存储到saved pc那块内存中,存地址动作是在执行CALL指令时自动完成的。当执行CALL指令时,程序知道当时PC值,也就知道PC+4的值(假设每条汇编指令4字节)。执行CALL指令会将SP减去四个字节,然后将本条汇编指令地址(即CALL指令的下一条汇编指令地址)放进saved pc去,以便foo函数执行完毕之后,根据saved pc的信息跳回调用语句的下一条指令的位置(即有一个晶体管记忆着跳转过来之前的地方)。)是执行完14行后要接着执行的指令。回收了为了调用foo参数的栈空间。 30 return 0; 31 //RV = 0;特殊寄存器,用来放置返回信息,专门在调用者和被调用函数之间传递返回值。把0返回给main函数的调用者。一旦返回到调用main函数的函数中时,这个函数会立即查看RV,将里面的值作为返回值取出。 32 }
(其中 saved pc 指向sp = sp + 8)
函数通过减小SP的值为局部变量留出空间,并完成活动记录的创建。CALL指令其实是一个跳转指令,跳到被调用代码的第一条指令。RET指令会跳转到saved pc中存储着的地址。调用者负责把返回地址压入栈中,被调用者负责按照返回地址往回跳。
栈帧中:参数部分和saved pc部分是调用函数申请并留存的,局部变量部分是被调用函数申请并初始化的。整个栈帧构成了一个活动记录,为了让函数能够执行,并让函数可以访问所有的参数和局部变量而构建。栈帧分两部分构造的原因是:只有调用者才知道怎样将有意义的参数值写入内存,并且局部变量这部分内存空间则不能被调用者设置,因为调用者并不知道被调函数如何实现,其实现中有多少局部变量。调用者能够根据函数原型精确知道调用该函数需要多少参数,以及怎样初始化这些参数。调用者并不知道函数中有多少局部变量,就更不要说对其操作了。
C语言程序例子2:
1 //递归函数 2 int fact(int n) 3 { 4 if(n == 0) 5 //<feat>:R1 = M[SP + 4];标号和第一条语句 6 //BNE R1, 0, PC + 12;测试失败应该跳过随后紧挨着的两个汇编语句(C中的返回语句),假设PC是指向当前指令的,真实编译器中是指向下一条指令的(PC+12指向的是11行汇编代码) 7 return 1; 8 //RV = 1; 9 //RET; 10 return n * fact(n - 1); 11 //R1 = M[SP + 4];先计算n-1 12 //R1 = R1 - 1; 13 //SP = SP - 4;创建下一个活动记录,压参数(n-1)入栈 14 //M[SP] = R1; 15 //CALL<fact>;SP下降,CALL指令的下一条地址被存入到saved pc中。一个真正的汇编器或连接器会遍历所有的.o文件,然后这些标号转换成PC相关的地址,这种转换会推迟到连接时刻进行,因为编译器希望能让不同.o文件中的函数之间的跳转全部翻译成这种PC相关的地址的形式 16 //SP = SP + 4;回收局部变量空间内存(本条指令地址被存入saved pc中),return语句使程序执行流跳到这里 17 //R1 = M[SP + 4];从内存中重新读取n,(从一条C语句过渡到下一条语句时,应当重新读取变量,因为可能某个寄存器的值被其他的调用覆盖了,上下文无关) 18 //RV = RV * R1; 19 //RET;没有什么要清理的局部变量,直接返回 20 }
C++语言可能会使用面向对象范式,因此相比于关注函数以及动作,会更加关注对象和数据。C语言中,调用时总是考虑的是函数名,数据则处于附属地位,总是将数据作为参数传入函数,而不是让数据接收方法调用的消息进行编程。不过C++的方法的调用和C的函数调用都会同样的被翻译成汇编代码,都通过函数的调用和返回在汇编代码层次模拟。无论是面向对象还是面向命令式的过程,只是将这些代码转换为汇编代码(模拟C或C++代码)并执行。
程序例子:
1 void swap(int* ap, int* bp)//C版本,指针实现 2 { 3 int temp = *ap; 4 //<swap>:SP = SP - 4;申请局部变量内存 5 //R1 = M[SP + 8]; 6 //R2 = M[R1];解引用(概念上等同于M[M[SP + 8]],但是不支持这种汇编语法) 7 //M[SP] = R2; 8 *ap = *bp; 9 //R1 = M[SP + 12]; 10 //R2 = M[R1];解引用 11 //R3 = M[SP + 8]; 12 //M[R3] = R2;赋值给指针所指变量 13 *bp = temp; 14 //R1 = M[SP]; 15 //R2 = M[SP + 12]; 16 //M[R2] = R1;赋值给指针所指变量 17 //SP = SP + 4;退回局部变量内存,此时SP指向的是saved pc,与刚进入函数时SP指向的位置一致 18 //RET;将SP指向的那个值取出来,将其写到PC寄存器中,同时将SP重新设置,提升SP,回收掉saved pc,指向参数位置 19 } 20 21 void foo()//一个函数,如果它自己有局部变量或调用其他函数,则这个函数自己负责分配和回收其局部变量内存和其调用的函数的参数内存。虽然被调用的函数属于另外一个函数帧,但是参数部分内存还是由调用者管理。 22 { 23 int x; 24 int y; 25 //SP = SP - 8;与第18行对应,分配局部变量内存 26 x = 11; 27 //M[SP + 4] = 11; 28 y = 17; 29 //M[sp] = 17; 30 swap(&x, &y);//指针版本swap和引用版本swap汇编代码是一样的 31 //R1 = SP;&y(调用函数首先要求解参数) 32 //R2 = SP + 4;&x 33 //SP = SP - 8;(然后再降低SP)与第17行对应,分配参数内存 34 //M[SP] = R2;(之后再逆序压入参数) 35 //M[SP + 4] = R1; 36 //CALL <swap>;(最后CALL指令) 37 //SP = SP + 8;与第13行对应,回收参数内存 38 //SP = SP + 8;与第5行对应,回收局部变量内存 39 //RET; 40 } 41 42 //CALL和RET指令都隐藏了对SP的操作和对saved pc的操作,并执行跳转。CALL降低SP,保存返回地址到saved pc中,并跳转到被调用函数中。RET从saved pc中得到返回地址放入PC,提升SP,并跳转回调用函数中。
1 void swap(int& a, int& b)//C++版本,引用实现(引用的工作方式实际上就是对于指针自动解引用),汇编代码和指针版本完全一样 2 { 3 int temp = a; 4 a = b; 5 b = temp; 6 } 7 8 swap(x, y);//(x,y是左值,实际上必须是某个内存空间对应的名字。只有左值才可以通过引用的方式更新和交换数据) 9 //C++编译器在编译的时候这样决定:虽然是x和y,没有加&符号,不过调用中不会对x和y求值。因为根据swap的函数原型,编译器理解为应该传入它们的引用类型。引用只是使用地址传递参数的另一种方式罢了,使用引用不用再接触*和&,编译器会根据函数原型,在每次引用变量a和b时,找到a或b中实际存储的地址中对应的数据。实际上a和b都存储着的是指针。传递引用实际上传递的是指针,会在实现中对于所有的引用的指针进行解引用操作。 10 11 int x = 17; 12 int y = x; 13 int &z = y;//编译器最终会为z留出空间,与y的地址联系起来 14 int *z = &y;
指针实际上只是要传送数据或者被操纵的内存块的原始地址。引用给人一种假象,将这个名字作为其他某个地方声明变量的别名。引用与指针的区别:引用一旦赋值就不能将引用重新绑定给新的左值,但是对于指针可以任意改变。如果只使用引用是没法构造链表的,所以在C++中指针依然存在。函数返回引用意味着在内部返回一个指针,但是不要以对待指针的方式处理返回值,最好假设返回的是一个真实的字符串而不是字符串指针,或者假设返回的是一个整型而不是整型指针。像变量一样对它们操作,但是在内部看,变量实际上是位于内存的其他位置的。指针和引用从语义来讲不太相同,但是实现的汇编代码类似。
底层实现,结构体和类是以相同的方式存储在内存中的。C++中结构体中可以定义方法(构造函数,析构函数,等方法),类也可以不定义任何方法。C++中类和结构体唯一区别在于默认访问修饰符分别是private和public。在最开始有一个switch语句,判断变量是一个结构体声明还是一个类声明,根据结果对没有指定访问控制的变量默认为是private或者是public。
class binky { public: int dunky(int x, int y); char* minky(int* z)//该函数能放问类成员,因为函数知道要操作的对象的地址。this指针总是作为第-1或第0个参数,其他所有参数因为都要让这个指针作为第一个参数而向后移动一位。从用户角度参数是隐式传入的, { int w = *z; return slinky + dunky(winky, winky); //this->dunky(winky, winky);上面调用等价于这个语句,由于没有将tihs指针传入,编译器会将this指针传入,然后复制到栈中 } //C++是专注于数据的,C是可能使用动词类型的函数或是专注于过程。对于编译器来说只是不同语法形式的同一事物,这些不同形式的代码最终都变成了汇编代码指令流,指令会被顺序执行偶尔执行跳转指令以及返回指令。编译器确保能模拟面向过程的C和面向对象的C++。 private: int winky;//数据域 char *blinky; char slinky[8]; }; int n = 17; binky b;//其中变量依次从低地址到高地址顺序存放(如下图) b.minky(&n);//用这种方式调用minky函数,编译器知道你编写的是面向对象代码(参数不再是一个,而是两个),编译器将某个binky类对象地址传入,或者将某个binky结构体地址传入作为第0个参数。 //binky::minky(&b, &n);//与上面调用语句等价,通过名字空间来确认minky是定义在binky中的。实际上上面的调用还是会在调用时压入两个参数。k个参数的成员方法其实是k+1个参数的函数,第0个参数总是相关对象的地址。
static修饰类中的方法:
1 class fraction 2 { 3 public: 4 fraction(int n , int d = 0); 5 void reduce();//约分函数 6 private: 7 static int gcd(int x. int y);//reduce的辅助函数,此函数并不需要this指针(只需要比较两个整型数)。成员函数有static关键字,意味着调用它的时候,并不需要类的实例作为参数,可以将它当作一个单独的函数来使用,只不过它的作用域是在类的定义中的。静态方法由于不需要传递隐式的this指针,因此从内部实现被看作普通函数,可被成员函数调用。 8 //static会影响继承使用特性,不能继承它,当继承类调用静态方法的时候,并不能得到正确的结果。所以类内一般都写非static成员函数,非static函数会根据类对象的不同有不同的反应,而static函数根据类不同有不同反应。 9 };
makefile命令或gcc命令流程
首先会调用预处理器(预处理器一般处理#define和#include命令),然后调用编译器(编译器负责将.c文件.cc文件生成对应的.o文件,这些文件在输入make指令后就会在目录下生成。整个编译之后生成的是可执行文件,其中一个.o函数包含着main函数,在生成可执行文件之前还要链接),然后调用链接器(链接器将一系列.o文件按顺序排列,并确保在执行过程中任何调用的函数都能从该函数所在的.o文件中找到),然后生成a.out文件(其实就是所有a.out文件的打包整合)。
汇编指令中的CALL<标号>指令,其实当链接结束后就会变成CALL<PC + 1234>这种形式,这样就可以根据PC的相对地址跳转到对应标号函数的开始地址了。因为所有的符号在可执行文件创建后都已经有了明确的地址,如果连接器知道每一个.o文件中的所有符号,以及这些.o文件是怎样顺序排列在一起的话,连接器就可以移除这些符号而用一个相对于PC的地址代替它。
预处理
预处理器依次读入传入的.c文件的内容,只关心#符号开头的行。读到有#开头的行时候,预处理内部会有一个hashset,左边为key右边为value,存储着被定义过的名字。随着预处理器一行行读入代码,会把遇到的key(字符串常量中的除外)文本替换成value。预处理器就是将一个.c文件内容读入,并将结果输出到同一个.c文件,但是去掉了#define和其他的预处理指令,所以经过预处理后就没有#define指令了(#define只是大规模的查找和替换,用来为常数或常量字符串赋予一个有意义的名字)。在预处理阶段进行的都只是文本替换,结果作为数据传到下一阶段,预处理阶段并不进行类型检查,文本替换产生的问题会在之后的编译阶段进行识别。
#define,1定义常量,2作为宏:
1 #define KWidth 480 2 #define KHeight 720 3 #define KPerimeter 2 * (KWidth + KHeight)//其中KWidth和KHeight也会被替换
1 #define MAX(a, b) (((a) > (b)) ? (a) : (b)) //并不是一个定义常量的define,而是作为一个宏,是参数化的#define表达式,参数就是上下文中出现的a,b。括号用来澄清二意性。#define可以实现简单功能的内连函数,无需调用和返回函数的消耗。 2 3 MAX(10, 40) //在预处理阶段,将宏扩展为对应的表达式,转化成(((10) > (40)) : (10) ? (40)) 4 int max = MAX(fib(100, fract(4000))); 5 //int max = (fib(100) > fract(4000)) ? fib(100) : fract(4000);展开替换后的结果,编译器不会保留中间结果,函数被调用了两次,尤其是大规模的函数会导致性能下降 6 int larger = MAX(m++, n++);//最终会对大的两次自增,小的一次自增 7 //int larger = ((m++) > (n++)) ? (m++) : (n++);//一共自增了3次
1 #define NthElemAddr(base, elemSize, index) ((char*)base + index * elemSize)//最好将重复代码写成函数或者是小段的宏,便于替换。 2 3 void* VectorNth(vector *v, int position) 4 { 5 assert(position > 0) 6 assert(position < v->logLength) 7 return NthElemAddr(Vector->Elem, Vector->Size, positon); 8 //void*可以接受任何类型指针,就是所谓的上转型(upcasting),将一个更具体的指针转换成一个类型更泛化的指针。编译器知道这种类型转换并不会带来风险。如果进行下转型(downcasting),就告诉编译器,我现在有一个类型更加泛化的指针,我知道此指针具体类型是什么,但是如果涉及引用就想要进行强制转换。 9 }
为了避免define在预处理时不进行类型检查的缺点,应用static const定义全局变量。
#assert宏:
1 #ifdef NDEBUG //是关于某个define是否存在的判断,如果定义了NDEDUG,那么程序中所有的assert都会替换成空操作语句 2 #define assert(cond) (void)0 //将数字0强制转换为void类型,不要把这个0用在任何地方,也不许被赋值,作为一个空操作(nop),在?和:中间占位。虽然看上去是一条语句,但是它不会被编译成任何一条汇编指令 3 #else 4 #define assert(cond) \ 5 (cond) ? ((void)0) : fprintf(stderr,"..文件名..行号等.."),exit(0); 6 #endif
#include:
#inlude 使用尖括号包含.h文件时,认为是系统文件,应该是编译器提供的,预处理器可以通过默认路径找到这些文件。/usr/bin/include和/usr/include中查找。使用双引号时,编译器会假设这是用户编写的.h文件,会默认从当前工作目录查找该头文件。通过makefile可以设定一些选项告诉编译器从哪里寻找这些包含的头文件。
#include指令也是查找并替换。用文件的内容替换掉该行#include指令。对于#include的处理是递归的,如果#include的指定文件本身还包含#include行,那么预处理器会一直深入下去直到最底层,层层替换直到生成不包含#include和#define的文本流。所以预处理后的文本流中,所有的#include和#define都被移除了。
gcc -E filename.c 只进行预处理然后将结果输出,但不进行后续操作。在生成的文件中前半部分都是导入的其他代码,在快结尾部分是自己的代码。
gcc -c filename.c 编译源文件,但是不生成可执行文件。编译阶段之后就停止,只生成.o文件,不进行链接。
-o选项,指定可执行文件的名称
避免循环包含头文件,预处理器也不会让某个头文件被包含两次:
1 #ifndef _vector_h_ 2 #define _vector_h_ 3 //列出所有vector.h中的原型 4 #endif
所有的.h文件都是定义某些原型的,不产生代码。好比定义某个结构类型,但是不会产生该结构体相应的代码。而且在.h文件中也不能申请任何存储空间,除非定义共享全局变量(很少使用)。但是.c和.cpp文件不同,它们定义了全局变量,全局函数和类方法等,都要被翻译成机器码(一系列01串),机器码可看作是汇编指令。包含.c和.cpp可认为是重复定义函数。声明一个函数和定义一个函数是不同的,对于函数实现而言,编译阶段会生成相应的代码,对于函数声明却不会产生任何代码。
vector.c文件包含了a.h,b.h,c.h文件。经预处理后去掉了#define和#include头。再经过编译得到.o文件(内容是汇编代码)。然后再经过链接得到可执行文件。(链接阶段将所有相关的.o文件组织到一起,连接器尝试使用这些文件创建可执行文件。这阶段需要有一个main函数,连接器才知道从哪里开始执行程序,对于每一个要被调用的函数都应该有定义,要求所有定义了的函数只被定义一次)
如果在makefile或者gcc命令没有加额外选项的话,编译器会继续下面各个阶段并且生成可执行文件,默认情况下文件名为a.out。
链接会将.o文件和其他.o文件的各个部分进行混合重组(除了自己编写的模块,其他部分都是来自于编译器标准库或者是标准的.o代码)。
1 #include <stdlib.h>//负责malloc,free,realloc,将其注释掉,就没有malloc,free,realloc的函数原型了。函数在执行到第7行时,也会将malloc推测成一个函数,并且推测函数有一个int参数,且返回一个int值,编译器并不会查看赋值函数来推测返回值是什么类型。因此编译器会对这行给出两条警告:第7行根据推测的函数类型,会认为是对一个指针赋值,而赋值的类型却是一个普通的整型。第10行编译器同样不知道free是什么,并推测它的原型,free的参数是void*,并且返回值是int,产生的内容和没有注释该行的.o文件完全一样。只是会报出三个错误,其中两条是说明缺失原型的,而一条是左值和右值类型不兼容,但是还是生成.o文件。当链接的时候链接器会忘掉这些警告,它不会记录有没有包含某个头文件,也不会记录编译时存在的警告。但是生成的.o文件和代码的语义是完全一致的,所以当链接并运行程序是没问题的。 2 #inlcude <stdio.h>//负责printf,将这一行注释掉之后,预处理器生成的翻译单元中将不会有printf函数的声明。有的编译器会在编译阶段报错(函数未声明),gcc则不会报错。gcc会在编译时刻分析源程序,看看哪部分像是函数调用,会根据函数调用推测函数原型。编译器看到了调用printf,printf只有一个字符串作为参数,发出未找到printf函数原型的警告,但是不会停下来,还是继续生成.o文件。gcc推测一个函数原型时,将返回类型推测为int。如果还有其他的printf函数,那么只能同样是只有一个字符串参数(推测出的原型,函数参数个数不可变)。推测出的函数原型会与实际的函数原型稍有区别,但是生成的.o文件实际上完全没变(因为.h头文件只是包含结构的定义以及一些原型,对头文件来说不会产生任何汇编代码,头文件的用处只是告诉编译器一些规则,让编译器判定程序的语法正确与错误)。ld命令用来链接,链接命令会根据编译过程中出现的警告查找标准库,printf对应的代码就在标准库中,因此在链接阶段会被加进来,虽然在链接阶段之前并没有见过printf的原型。因此include并不能保证相应的函数实现在连接时可用,如果某个函数定义在了标准库中,那么在链接时就可以被加进来,而无论我们是否声明了函数原型。 3 //如果将前两条include都注释掉,那么会产生4条警告,但是依然会生成.o文件,并且会链接生成a.out文件并执行它。其实头文件做的全部事情就是告诉编译器有哪些函数原型。但是在.h文件中并没有说明这些函数的代码在哪里,链接阶段则负责去标准库中寻找这些代码,而malloc,free,printf正是在标准库中。只要被调用的函数在标准库中存在,那么无论编译时有没有警告,生成的.o文件都会没有区别(包含原先代码的语义),因为在链接的时候可以用到标准库的代码,并将调用到的函数的代码加到.o文件集合中,因此会在.o文件中出现相应标号的函数,生成可执行文件。 4 #include <assert.h>//注释掉之后编译器遇到第8行,看到的只是assert这个符号,而不是宏替换后的代码,一次编译器猜测它是一个函数调用,会在.o文件中出现CALL<assert>,造成编译成功,但是链接失败了,原因是标准库中根本没有assert函数。 5 int main() 6 { 7 void *mem = malloc(400); 8 assert(mem != NULL); 9 printf("Yay!"); 10 free(mem); 11 return 0; 12 }
函数原型的存在是为了让调用者和被调用者关于saved PC上面的活动记录的布局达成一致(就是让函数的调用参数复合调用参数类型规定)。原型其实质涉及参数,参数在活动记录中位于saved PC之上,在saved PC之下的那部分内容是被调用者负责的。
1 //没有任何头文件(在C语言环境下) 2 //int strlen(char* s, int len); 如果加这个声明,就可以消除对推测函数原型的警告, 3 int main() 4 { 5 int num = 65; 6 int length = strlen((char*)&num, num);//解释的结果应该是空字符串或者是A'。 7 //在.o文件中并没有记录下某个调用有多少个参数,在栈中会给两个参数留出空间(SP = SP - 8),而函数库中strlen只是带有一个参数的形式,但是在链接阶段却不会发生错误(参数个数不同)。因为在链接阶段gcc只会考察符号的名称而并不检查形参类型,但是这样函数调用和签名就对不上了,因为在链接阶段并不会管它,链接阶段要做的就只是查找strlen的定义。因此在链接时侯不出错,而也不会有运行时错误(SP = SP + 8)。 8 //strlen的调用语句负责把两个参数压入栈中(SP = SP + 8),实际的strlen对应的函数原型却是只有一个参数的版本,所以实际的strlen函数只能访问栈中参数两个参数中的一个(位于saved PC上边的四个字节),在strlen返回的时候,这块空间还是会被回收(SP = SP - 8)。 9 printf("length = %d\n", length);//分为小端返回1,大端返回0 10 return 0; 11 }
1 //一个完全相反的例子,memcmp函数本来需要三个参数,只传一个参数。 2 //真正memcmp的函数原型为/*int menmcmp(void * v1, void * v2, int size)*/ 3 int menmcmp(void *v1); 4 5 int n = 17; 6 int m = memcmp(&n);//程序会编译通过,也可以运行,但是运行时可能会崩溃(因为可能访问了随机的4字节地址,可能是非法的栈指针,堆指针,代码段指针)。C语言程序一般很容易通过编译,C++是完全的强类型系统,并不知道裸指针void*还有类型转换之间的关系,所以在C++中不用void*泛型而用模板,C++编译不好通过,但是编译好的程序不容易崩溃,因为模板模板和容器屏蔽了指针的实现细节。之前的例子在C++就会失败。在C++中对函数进行重载,可以通过同一个函数名字带有不同的参数个数和类型来解决,但是在C语言中,定义了一个函数名字之后就不能再定义这个函数名字了。C++语言在编译时候并不会使用函数名作为该函数第一条指令的标号,它会用函数名和参数列表中的参数的数据类型按照参数顺序来构造一个更加复杂的函数符号。 7 //C: CALL<memcmp> 8 //C++: CALL<memcmp_void_p> 9 //C++: CALL<memcmp_void_p_void_p_int> //C++中生成的汇编语言的函数标号不同 10 //所以本例在C++环境中,函数声明和函数库中的实现不符会导致链接错误,看来C++貌似会更安全一些。
总线错误和段错误
1 *(NULL);
seg fault,段错误:常出现于对错误的指针解引用,是因为引用了一个并没有映射到段中的地址才发生,大多数是对NULL或者很小的地址解引用。如果对NULL指针进行解引用 ,在0地址,操作系统就会识别出该地址不在任何一个段中,操作系统并未将0地址映射到栈,堆,代码段中,因此对于0地址引用是不对的,因为访问的是不该访问的地址(地址并不是某个局部变量的地址,也不是malloc调用返回的地址)。对这种情况就只能发出一个段错误。
1 void* vp = value;//首先把一个地址赋值给vp,地址是不确定的 2 *(char*)vp = 7;//有50%机会得到一个总线错误(奇数偶数) 3 *(int*)vp = 55;//有75%机会得到一个总线错误(4的倍数)
bus error,总线错误:在对4个段中地址解引用时发生(栈段,堆段,代码段,数据段),一般出现在手工打包数据情况下。总线错误可以表明,你所引用的地址实际上并不是你想要的地址。如果vp的值是4个段中某个值的话,程序不会出现段错误,因为地址就在段内。但是操作系统,硬件,编译器共同规定所有的整型数据的地址都应该是4的倍数,并且short数据对应的地址都应该是偶数地址,对于字符类型并没有什么限制,为了简便,规定除了字符型和short型,其余类型都是4字节对齐的。
缓冲区溢出
1 int main()//缓冲区溢出,不断重复赋值的永真循环,死循环 2 { 3 int i; 4 int array[4]; 5 for(i = 0; i <=4; i++) 6 array[i] = 0; 7 return 0; 8 }
1 int main()//缓冲区溢出,有的系统可以正常工作(不会出现死循环),区别在于大端(多赋值一次),小端(死循环)。图示为小端,内存地址布局左下最小,右上最大。 2 { 3 int i; 4 short array[4]; 5 for(i = 0; i <=4; i++) 6 array[i] = 0; 7 return 0; 8 }
1 void foo()//saved PC本应指向CALL<foo>指令的下一条指令,但是由于saved PC被减4,所以又重新指向了CALL<foo>指令,这样当函数要返回时,其实是再次调用foo函数,就不停的执行foo函数,是一个更高级的死循环,在foo函数的末尾再一次调用foo函数本身。 2 { 3 int array[4]; 4 int i; 5 for(i = 0; i <= 4; i++) 6 array[i] -= 4; 7 }
1 int main()//本例并没有全局变量存在 2 //第二个调用的函数与之前调用的函数的活动记录完全一样,它会打出之前函数操作之后留在内存的那些数据的值,因为上一个函数返回后不会清空占用过的位模式。可以引出一种编程方式,提前将参数写入内存为以后的调用作准备。要善用内存布局。 3 { 4 DeclareAndInitArray(); 5 PrintArray();//依然能打印出0到99 6 } 7 8 void DeclareAndInitArray() 9 { 10 int array[100]; 11 int i; 12 for(i = 0; i < 100; i++) 13 array[i] = i; 14 } 15 16 void PrintArray() 17 { 18 int array[100]; 19 int i; 20 for(i = 0; i < 100; i++) 21 printf("%d\n", array[i]); 22 }
printf既可以有一个参数也可以有任意多个参数,其第一个参数是控制字符串(是传递给控制台输出的模板),后边有一个参数是...原型如下:
1 int printf(const char* control, ...);//可添加0到任意个数,任意类型参数。返回值是成功解析的占位符的个数,如果调用出错会返回-1,如果遇到文件尾返回-1。 2 printf("hello\n");//返回0 3 printf("%d+%d=%d", 4, 4, 8);//返回3
反向的压参数入栈原因:编译时,根据函数原型编译器认为两个函数调用都是合法的,但当对第二个printf编译时,编译器计算参数个数,并计算需要让栈指针减去多少个字节为参数提供空间。当函数跳转到printf函数时,函数并不知道char*参数上面还有多少个参数。由于printf函数实现使用了特殊符号,因此栈中的参数可以动态变化。printf函数会通过这个字符的分析控制字符串来指出这些数据都是什么类型,如果没有控制字符串,那么栈中内容就会是未知的,无法读取。如果控制字符串位于其他参数的上方,那么就没有一个一致可靠的方式来解释活动记录中的其它参数。
函数的第一个参数,结构体变量,类变量的第一个成员域必须是最低地址(第一个域的偏移为0),有利于设计和实现。一般相关的结构体前半部分保持相同,后半部分进行功能扩展。
1 struct base//将一个数据类型使用结构体进行包装 2 { 3 int code; 4 }; 5 6 struct type_one 7 { 8 int code;//值为1 9 //其他的成员 10 }; 11 12 struct type_two 13 { 14 int code;//值为2 15 };
多线程
make,是应用程序,读会取makefile数据文件,指出怎样调用GCC和G++以及链接器和优化程序,使用这些工具来生成可执行文件。
几个虚拟地址空间(虚拟暗指地址无限长)。不同进程不会共享栈段,堆段,代码段。make程序以为自己占有所有的内存执行操作,操作系统会将虚拟地址映射成实际地址,操作系统通过地址映射隔离各个程序(造成各个程序独占硬件的假象)。程序都假定系统有足够的大的空间建立自己的栈段,堆段等满足程序的要求。但是只有一个真实地址空间,是物理内存,需要管理各种虚拟地址空间。那些根据代码运行的程序都存储在可执行文件中。虚拟地址相同的程序不占用相同的真实地值。操作系统有一个内存管理单元,会建立一个表格,把xx线程的xxx虚拟地址映射到内存中的真实地址。对虚拟地址的任何操作都被转化为对真实物理地址的操作。所有对进程虚拟地址空间的操作都被后台的守护进程所管理,进行着虚拟地址到物理地址间的映射。虚拟地址中的内存会被整块的映射到物理内存中。单处理器情况下,在同一时刻,只有一个处理器和寄存器集合吸收指令。表面上每个线程都有自己的堆段,栈段。(不同的是同一线程中有两个函数的概念,在一个单线程中同时运行两个函数。)
在单处理器,单内存的情况下,多线程网络程序的优点是能节约网络连接的时间,而不是节能约处理数据的时间。
150张票,10个代理卖,原始版:
1 int main()//150张票,10个代理卖,硬编码 2 { 3 int numAgents = 10; 4 for(int i = 0; i < 10; i++) 5 sellTickets(i, 15); //可以让这个函数运行10个不同的线程,即产生10个不同的线程,使用相同的方法。 6 7 }
改进版:
1 int main()//该例子是顺序执行的,输出160行 2 { 3 int numAgents = 10; 4 int numTickets = 150; 5 for(int agent = 1; agent <= numAgents; agent++) 6 sellTickets(agent, mumAgents/agent); 7 return 0; 8 } 9 10 void SellTickets(int agentID, int numTicketsToSell) 11 { 12 while(numTicketsToSell > 0) 13 { 14 printf("Agent %d sells a ticket \n", agentID); 15 numTicketsToSell--; 16 } 17 printf("Agents %d : all done! \n", agentID); 18 }
使用线程包/库(package/library)版。多线程无共享资源:
1 int main()//在运行线程之前,必须先执行InitThreadPackage函数一次并设置好所有的线程。 2 { 3 int numAgents = 10; 4 int numTickets = 150; 5 InitThreadPackage(false);//传入false表示请不要打印线程调试信息,调试的时候可以传入true 6 for(int agent; agent <= numAgents; agent++) 7 { 8 char name[32]; 9 sprintf(name, "agent %d thread", agent);//sprintf输出到缓存字符串中,不输出到屏幕 10 ThreadNew(name, SellTickets, 2, agent, numTickets/numAgents);//传入线程名字和线程要执行的函数的地址(函数指针),和该函数需要的参数个数和所需参数。 11 } 12 runAllThreads();//起到引导线程执行的作用,所有线程开始执行 13 return 0; 14 } 15 16 void SellTickets(int agentID, int numTicketsToSell)//十个线程等待执行这一段代码,他们有各自的指针,指向这段由编译器生成的代码。假如有一个线程刚好进入到这个函数中,那么这个线程陷入到这段代码中,然后可能被处理器暂停运行,甚至被从就绪队列里删除,放到阻塞队列中,一直到规定的时间过去。 17 { 18 while(numTicketsToSell > 0) 19 { 20 printf("Agent %d sells a ticket \n", agentID); 21 numTicketsToSell--;//并非原子操作(三条汇编指令,取值,减一,放回),当它失去对处理器的控制权时,它可能正好处于编译器生成的三条指令之前,中,后,不过再继续执行时会接着原来的指令继续执行。 22 if(RandomChance(0.1))让线程有10%的几率被强制暂停运行 23 ThreadSleep(1000);//暂停使用处理器至少1秒钟,传入数字是时间参数,单位是毫秒 24 } 25 printf("Agents %d : all done! \n", agentID); 26 }
不让每个售票点都卖预先设定好的数量的票,而是让这些售票点访问同一个共享整数(即主变量)。多线程共享资源(无锁有bug版):
1 int main() 2 { 3 int numAgents = 10; 4 int numTickets = 150; 5 InitThreadPackage(false); 6 for(int agent; agent <= numAgents; agent++) 7 { 8 char name[32]; 9 sprintf(name, "agent %d thread", agent); 10 ThreadNew(name, SellTickets, 3, agent, &numTickets);//传入共享资源的地址 11 } 12 runAllThreads(); 13 return 0; 14 } 15 16 void SellTickets(int agentID, int *numTicketsp) 17 { 18 while(*numTickets > 0)//如果有最后一张票几个线程都想要,由于某些原因,未执行减减操作就暂停使用处理器了,当它们重新占用处理器时,不会重新检查之前的执行过程,都会试图减少共享全局变量,则可能变成-9,因为线程都依赖于共享的内存数据。如果不注意操作数据的方式,执行操作的中途退出,并依据很快就要过时的数据做判断,这样当它失去处理器控制权的时候,全局数据的完整性就被破坏了。 19 { 20 printf("Agent %d sells a ticket \n", agentID); 21 (*numTickets)--; 22 if(RandomChance(0.1)) 23 ThreadSleep(1000); 24 } 25 printf("Agents %d : all done! \n", agentID); 26 }//所以整个while区域为临界区域,即我进入这个区域并对全局变量执行某种操作时,没人能再次进入这块区域。应该放一些指令阻塞其他线程。锁(二进制锁) 27 }
几个线程的栈都可以访问main中的变量。
增加信号量版本,多线程共享资源(有锁无bug版):
1 int main() 2 { 3 int numAgents = 10; 4 int numTickets = 150; 5 Semaphore lock = SemaphoreNew(-, 1);//信号量初始化函数,初始化为1,第一个参数不怎么用,第二个是个整数。编程中,信号量是一个同步计数变量,总是大于等于0,它支持加一和减一原子操作的变量类型。lock的加一减一的原子操作由两个不同函数实现,这两函数利用特定硬件或者特定汇编指令改变放在它们之间整数的值。信号量类型是一个指向不完全类型的指针(该类型内部含有共享的整型变量?)。如果信号量初始化为0,那么每个线程都会认为是其他的线程占用着信号量,所以所有的线程都暂停使用处理器,每个线程都处于等待状态,形成死锁。如果信号量初始化为2或更多,会让多个线程进入临界区(25,26,27行,临界区里任何时间只能有一个线程,或没线程。应该保持尽量小的临界区域),则会在同一时间搞乱全局数据,也有可能让两线程以一种相互之间不信任的方式处理同一个共享的全局变量。在本例中,用信号量来限制对临界区的访问。 6 //SemaphoreWait(lock);减一操作。把信号量想象是在跟踪一项资源,一个线程要么允许进入临界区,要么在临界区等侯(即要么获得资源,要么等待资源可用,直到不需要该资源为止)。 7 //SemaphoreSignal(lock);加一操作。当不需要该资源时,要么signal这个变量,要么释放锁。 8 //信号量从不允许从非负变为负数,如果SemaphoreWait了一个值为0信号量,但是并不会把该信号量变成-1(这是不允许的),如果检测到为0,就会做一个阻塞(block)的动作,把信号量阻塞起来,这时候它就会暂停占用处理器资源。 9 InitThreadPackage(false); 10 for(int agent; agent <= numAgents; agent++) 11 { 12 char name[32]; 13 sprintf(name, "agent %d thread", agent); 14 ThreadNew(name, SellTickets, 3, agent, &numTickets, lock);//传入共享资源的地址,和锁 15 } 16 runAllThreads(); 17 return 0; 18 } 19 20 void SellTickets(int agentID, int *numTicketsp, Semaphore lock) 21 { 22 while(ture) 23 { 24 SemaphoreWait(lock);//如果检测到信号量从0变为1,那么就可以接管信号量,并把信号量从1变成0(在浴室外边等着并不断的敲门,如果碰巧第一个检测到门开了,那么就进去)。 25 if(*numTicketsp == 0)//获得锁之后,发现无票可卖,当退出的时候,应该释放锁(从浴室走的时候不要锁门,要把门开着) 26 break; 27 (*numTicketsp)--; 28 printf("Agent %d sells a ticket \n", agentID);//这句也可以移动到锁外 29 SemaphoreSignal(lock); 30 //sleep... 31 } 32 SemaphoreSignal(lock);//获得锁之后,发现无票可卖,当退出的时候,应该释放锁(从浴室走的时候不要锁门,要把门开着) 33 } 34 //这个sell函数就是模拟一个线程不断的获得锁并释放锁的过程:锁,解锁,锁,解锁这样的循环。但是有可能在获得了锁之后却失去了处理器资源,这时候就很危险,除非这时信号量处于0的状态,这时其他线程过来,它们会等待值为0的信号量然后阻塞,直到获得锁。锁住了之后其他线程不能进入,等原先失去资源的线程再次获得处理器并交出处理器之后,其他线程才能继续执行。
读者写者问题。模拟计算机服务端,客户端在同一时间中运行。
1 char buffer[8];//全局变量 2 3 int main()//1,可能出现覆盖了尚未被读取的数。2,可能Reader先获取处理器,读入尚未被写入的信息。 4 { 5 ITP(false); 6 ThreadNew("Writer", Writer, 0); 7 ThreadNew("Reader", Reader, 0); 8 RunAllThread(); 9 } 10 11 void Writer()//希望在Reader读取之前写入数据,但是不要过度的写,覆盖了尚未被读取的数。 12 { 13 for(int i = 0; i < 40; i++) 14 { 15 char c = PrepareRandomChar(); 16 buffer[i % 8] = c; 17 } 18 } 19 20 void Reader() 21 { 22 for(int i = 0; i < 40; i++) 23 { 24 char c = buffer[i % 8]; 25 ProcessChar(c); 26 } 27 }
增加两个信号量,线程通信是1:1
1 char buffer[8]; 2 Semaphore emptyBuffers(8);//只允许在1-8之间 3 Semaphore fullBuffers(0); 4 //可以改成(4,0),但是(8,0)*度更高,吞吐量更大,如果改成(1,0),那么就是写一个读一个,如果改成(0,0),那就死锁。 5 //如果信号量对改成(8,1),则允许Reader线程领先Writer线程一跳。 6 //如果信号量对改成(16,0),Writer允许使用两次循环,两次遍历Reader线程,但是Writer最多提前Reader8个插槽,而不是16个。 7 8 int main()//1,可能出现覆盖了尚未被读取的数。2,可能Reader先获取处理器,读入尚未被写入的信息。 9 { 10 ITP(false); 11 ThreadNew("Writer", Writer, 0); 12 ThreadNew("Reader", Reader, 0); 13 RunAllThread(); 14 } 15 16 void Writer()//希望在Reader读取之前写入数据,但是不要过度的写,覆盖了尚未被读取的数。 17 { 18 for(int i = 0; i < 40; i++) 19 { 20 char c = PrepareRandomChar(); 21 SemaphoreWait(emptyBuffers); 22 buffer[i % 8] = c; 23 SemaphoreSignal(fullBuffers); 24 } 25 } 26 27 void Reader() 28 { 29 for(int i = 0; i < 40; i++) 30 { 31 SemaphoreWait(fullBuffers); 32 char c = buffer[i % 8]; 33 SemaphoreSignal(emptyBuffers); 34 ProcessChar(c); 35 } 36 }
多个Writer情况,Writer之间互相竞争给单个Reader发信息。(略)
就餐问题,每人拿一个叉子,可能会死锁
1 Semaphore forks[] = {1, 1, 1, 1, 1};//表示5个全局的信号量,Semaphore数组形式,要调用5次semaphoreNew 2 3 void Philosopher(int id)//知道自己的位置,要得到fork[i],fork[i+1],当i很大,i+1可能为0。 4 { 5 for(int i = 0; i < 3; i++) 6 { 7 Think(); 8 SemaphoreWait(forks[id]);//五个线程如果都在两个SemaphoreWait中间暂停,就会形成死锁 9 SemaphoreWait(forks[(id+1) % 5)]; 10 Eat(); 11 SemaphoreSignal(forks[id]); //两个叉子释放顺序随便 12 SemaphoreSignal(forks[(id+1) % 5)]; 13 } 14 Think(); 15 }
就餐问题,启发式策略去掉死锁1
1 Semaphore forks[] = {1, 1, 1, 1, 1}; 2 Semaphore numAllowedToEat(2);//最多允许两人同时就餐 3 4 void Philosopher(int id) 5 { 6 for(int i = 0; i < 3; i++) 7 { 8 Think(); 9 SemaphoreWait(numAlloedToEat);//如果填2,就有3个线程会被阻塞在这条语句,过早的钝化掉某些线程。 10 SemaphoreWait(forks[id]); 11 SemaphoreWait(forks[(id+1) % 5)]; 12 Eat(); 13 SemaphoreSignal(forks[id]); 14 SemaphoreSignal(forks[(id+1) % 5)]; 15 SemaphoreSignal(numAlloedToEat); 16 } 17 Think(); 18 }
就餐问题,启发式策略去掉死锁2
1 Semaphore forks[] = {1, 1, 1, 1, 1}; 2 Semaphore numAllowedToEat(4);//允许4个之中的1个首先获得叉子,然后SemaphoreWait。限制同时要求吃饭的科学家的数量,某人不被允许拿叉子就餐,那么至少有一哲学家线程能拿叉子吃饭。 3 4 void Philosopher(int id)//知道自己的位置,要得到fork[i],fork[i+1],当i很大,i+1可能为0。 5 { 6 Think(); 7 SemaphoreWait(numAlloedToEat);//如果填4,就只有1个线程会被阻塞在这条语句,大多数线程都在尽量的前进。 8 SemaphoreWait(forks[id]); 9 SemaphoreWait(forks[(id+1) % 5)]; 10 Eat(); 11 SemaphoreSignal(forks[id]); 12 SemaphoreSignal(forks[(id+1) % 5)]; 13 SemaphoreSignal(numAlloedToEat); 14 } 15 Think(); 16 }
一个信号量本质上代表了一个资源的可用性
使用带二进制锁的全局变量,叫做忙等待。会浪费处理器时间,而这段时间可以用于完成其他线程的工作。可以用于多处理器的情况,但是单处理器,没有必要搞一个自旋锁线程并不断检查一个全局变量的值。
而信号量则会告诉线程管理器把我(该线程)推送到阻塞队列中去,只有当别的线程释放了我所被阻塞的信号量,处理器才会考虑该线程。
FTP下载,每个文件有一个对应的线程下载。主线程可能会先返回。
1 int DownloadSingleFile(const char* server, const char* path)//主机位置,目录结构和文件名,返回下载的整个文件的字节数 2 3 int DownloadAllFiles(const char* server,const char* files[], int n)//假设所有文件位于同一台服务器。这是主线程的一个子线程,子线程又产生大量的子子线程。 4 { 5 int totalBytes = 0; 6 Semaphore lock = 1; 7 for(int i = 0; i < n; i++) 8 { 9 ThreadNew(__, DownloadHelper, 4, server, files[i], &totalBytes, lock);//DownloadHelper是一个代理函数,位于ThreadNew和DownloadAllFiles之间,因为直接调用DownloadAllFiles第二个参数返回值类型应该为void,没法获取返回值,代理函数能接收返回值并对总值做加法。这条语句产生一个子子线程,并把该线程加入就绪队列中去。 10 } 11 //但是,会产生一个问题,没有等到所有的子子线程都运行完毕(子子线程可能比较耗时),没有等到任何子子线程取得进展之前,子线程就自己返回了,也许返回结果是0个字节。 12 return totalBytes; 13 } 14 15 void DownloadHelper(const char* server, const char* path, int* numBytesp, Semaphore lock)//Semaphore本身就是指针,没必要加&号 16 { 17 //如果把SemaphoreWait(lock)语句提前到这里,那么就会顺序下载文件,很慢,像没用线程一样。 18 int bytesDownloaded = DownloadSingleFile(server, path); 19 SemaphoreWait(lock); 20 (*numBytesp) += bytesDownloaded; 21 SemaphoreSignal(lock); 22 }
FTP下载,线程通信是1:n,类似于读者写者
1 int DownloadSingleFile(const char* server, const char* path) 2 3 int DownloadAllFiles(const char* server,const char* files[], int n) 4 { 5 Semaphore childrenDone = 0; 6 int totalBytes = 0; 7 Semaphore lock = 1; 8 for(int i = 0; i < n; i++) 9 { 10 ThreadNew(__, DownloadHelper, 5, server, files[i], &totalBytes, lock, childrenDone);//把childrenDone额外传过去 11 } 12 for(int i = 0; i < n; i++)//子线程阻塞在这里,等到子子线程全部结束,再继续运行。但是这里有点忙等待。 13 { 14 SemaphoreWait(childrenDone);//更高级的信号量版本可以wait一个具体值更多的参数,只要一个语句,而不调用一个外置的for循环,貌似信号量有自己的内置for循环。 15 } 16 return totalBytes; 17 } 18 19 void DownloadHelper(const char* server, const char* path, int* numBytesp, Semaphore lock, Semaphore ParentToSignal) 20 { 21 int bytesDownloaded = DownloadSingleFile(server, path); 22 SemaphoreWait(lock); 23 (*numBytesp) += bytesDownloaded; 24 SemaphoreSignal(lock); 25 SemaphoreSignal(ParentToSignal);//每个线程释放信号量一次 26 }
冰淇淋商店模拟(可能有最多52个线程)
经理---------批准是否售卖冰淇淋
//店员和经理交互,获得时间锁,同一时间只能有一个店员和冰淇淋出现在经理的办公室
10-40个店员-------------每个顾客产生一个店员线程,每个店员负责1个冰淇淋
//顾客见不到经理,只能和店员交互
10个顾客-----------------订购1-4个冰淇淋
//顾客之间通过收银员交流
收银员
1 int main(__,__)//main函数负责产生所需要的所有线程 2 { 3 int totalCones = 0;//甜筒总量 4 InitThreadPackage(); 5 SetupSemaphores(); 6 for(int i = 0; i < 10; i++) 7 { 8 int numCones = RandomInteger(1,4); 9 ThreadNew(__, Customer, 1, numCones);//顾客线程,负责产生店员线程 10 totalCones += numCones; 11 } 12 ThreadNew(__, Cashier, 0);//收银员线程 13 ThreadNew(__, Manger, 1, totalCones);//经理线程 14 RunAllThreads(); 15 FreeSemaphores();//因为信号量是在堆中创建的,所以要记得释放。 16 return 0; 17 } 18 19 20 struct inspection//经理和店员之间 21 { 22 bool passed;//检测甜筒是否通过(false) 23 Semaphore requsted;//店员向经理发送请求检查(0) 24 Semaphore finished;//(0) 25 Semaphore lock;//经理办公室(1) 26 }; 27 28 29 void Manager(int totalConesNeeded)//经理 30 { 31 int numApproved = 0; 32 int numInspected = 0; 33 while(numApproved < totalNumNeed) 34 { 35 SenmaphoreWait(inspection.requsted); 36 numInspected++; 37 inspection.passed = RandomChance(0, 1); 38 if(inspection.passed) 39 numApproved++; 40 SemaphoreSignal(inspection.finished); 41 } 42 } 43 44 45 void Clerk(Semaphore semaToSignal)//店员 46 { 47 bool passed = false; 48 while(!passed) 49 { 50 MakeCone(); 51 SemaphoreWait(inspection.lock);//加锁 52 SemaphoreSignal(inspection.request); 53 SwmaphoreWait(inspection.finished); 54 passed = inspection.pased;//如果不锁的话,这个共享值就可能会读取出错 55 SemaphoreSignal(inspection.lock);//解锁 56 } 57 SemaphoreSignal(semaToSignal); 58 } 59 60 61 void Customer(int numCones)//顾客 62 { 63 BrowX(); 64 Semaphore ClerksDone;(0) 65 for(int i = 0; i < numCones; i++)//两个for不能和并,如果和并那么就变成一步一步的了 66 { 67 ThreadNew(__, Clerk, 1, ClerksDone); 68 } 69 for(int i = 0; i < numCones; i++) 70 { 71 SemaphoreWait(ClerksDone); 72 } 73 SemaphoreFree(ClerksDone); 74 WalkToCashier(); 75 SemaphoreWait(line.lock); 76 int place = line.number++; 77 SemaphoreSignal(line.lock); 78 SemaphoreSignal(line.required); 79 SemaphoreWait(line.customers[place]); 80 } 81 82 83 struct line//顾客和收银员之间 84 { 85 int number;//(0) 86 Semaphore requested;//(0) 87 Semaphore Customers [10]; 88 Semaphore lock;//(1) 89 }; 90 91 92 void Cashier()//收银员 93 { 94 for(int i = 0; i < 10; i++) 95 { 96 SemaphoreWait(line.requested); 97 Checkout(i); 98 SemaphoreSignal(line.customers[i]); 99 } 100 }