跳转至

Memory Management

约 5929 个字 31 行代码 预计阅读时间 20 分钟

Chapter 6:存储类别和内存管理

6.1 存储类别

6.1.1 总览

6.1.2 关键词

  • 其实这种关键词叫做存储类别说明符。
  • static关键词让变量具有内部链接,同时具有静态存储期。
  • extern关键词让变量具有外部链接,同时具有静态存储期。

6.1.3 作用域

作用域描述程序中可以访问标识符的区域,包括:块作用域,函数作用域,函数原型作用域和文件作用域。

  • 是用一对花括号括起来的代码区域,包含for循环、while循环、do while循环和if语句所控制的代码,就算这些代码没有被花括号括起来,这也算是一个块。定义在块中的变量具有块作用域(block scope),它的可见范围只是在块内,或者说从定义处到包含该定义的块的末尾。此外,函数的形式参数虽然在花括号表示的块之前,但还是具有块作用域。只有在块内的语句才能访问具有块定义域的变量。

  • 函数作用域仅仅用于goto语句的标签,当这个标签首次出现在函数的内层时,作用域也延伸到整个函数。函数作用域有效防止了标签混乱的情况发生,当然更好的处理方式或许是干脆不用goto语句。

  • 函数原型作用域的作用范围时从形式参数定义处到函数原型声明结束。这表明编译器更多的关心形式参数的类型而不是形参名,而只有在变长数组中,形参名才更有用。

  • 如果在函数的外边定义了一个变量,比如以下程序:

    1
    2
    3
    4
    5
    #include<stdio.h>
    int glb_val =1;
    int main(void){
        printf("%d",glb_val);
    }
    

    这里的变量glb_val就具有文件作用域,更确切地说,具有外部链接的文件作用域,我们也叫它为全局变量。

    Tip:这里的glb_val它的作用域是从定义处到文件结束。

6.1.4 翻译单元

某些我们认为的多个文件可能在编译器里边以单个文件的形式出现,比如C预处理器就将头文件里边的内容替换#include指令。所以,编译器将源代码文件和所有的头文件都看作是一个包含着信息的单独文件,这个文件被称为是翻译单元(translation unit)。

如果程序由多个源代码文件组成,那么这个程序也由多个翻译单元组成,每个翻译单元对应着一个源代码文件和它的头文件。

目前我们的程序还不进行多文件处理。

6.1.5 链接

C 文件有着三种链接属性:外部链接、内部链接和无连接。具有块作用域、函数作用域和函数原型作用域的变量都是无连接变量。具有文件作用域的变量可以是外部链接也可以是内部链接。具有内部链接的变量只能在一个翻译单元使用,而具有外部链接的变量能在多文件程序中使用。

使用extern关键词,或者直接在函数外边定义的变量都是具有外部链接的变量,而使用static关键词的变量是具有内部链接的变量。

6.1.6 存储期

存储期(storage duration)描述了通过这些标识符访问的对象的生存期,某些变量存储期一过,它所占的内存就会被释放,相应的,存储的内容也会丢失。C对象有着四种存储期:静态存储期、自动存储期、线程存储期和动态分配存储期。

  • 如果对象具有静态存储期,那么对象在程序的执行期间就会一直存在,并且只在主调函数前初始化一次,内存不会被释放。关键词externstatic表明了对象的链接属性与存储期(静态存储期)。The static/extern specifier specifies both static storage duration (unless combined with _Thread_local) and internal/external linkage.
  • 具有块作用域的变量一般具有自动存储期,当程序进入这些变量的块的时候时,为这些变量分配内存,当退出这个块的时候,就释放刚才分配的内存。值得注意的时,变长数组的存储期是声明处到块的末尾。
  • 线程存储期用于并发程序设计
  • 动态分配存储期

6.1.7 自动变量

声明在函数头、块内的变量属于自动存储类别的变量,具有自动存储期,块作用域且无连接。我们可以在C中使用关键词auto来表明这个变量的存储类型是自动变量。

6.1.8 寄存器变量

我们使用关键词register来表示该变量的存储类型为寄存器变量。寄存器变量存储在CPU的寄存器之中,寄存器是计算机最快的可用内存,因此访问并且处理这些变量的速度会更快,但是无法获取寄存器变量的地址(因为它没有内存位置)。寄存器变量在绝大多数方面都和自动变量一样,也就是具有块作用域、无链接和自动存储期。

声明变量为register类型更像是一种请求而不是命令,因为编译器必须根据寄存器或者最快可用内存数量来衡量请求;并且由于寄存器的大小有限(通常是一个字,亦即4或8字节),可以声明为寄存器变量的数据类型有限,比如寄存器可能就没有足够大的空间来存储double类型的值。计算机很可能会忽略我们的请求,变量则被声明成一般的自动变量(也就是存储在内存之中),即使这样,仍然不能对该变量使用取地址运算符。

6.1.9 块作用域的静态变量

我们可以创建具有块作用域、无连接的静态变量,只需要在块中(这样就提供块作用域和无连接了)用存储类别说明符static(提供静态存储期)说明这个变量就可以了。

编译器在程序的生命周期内保证静态变量的存在,静态变量只会在程序中被初始化一次,不会在离开和进入作用域时被销毁或者重置。这是因为静态变量和外部变量在程序被载入内存的时候已经执行完毕,所以在逐个步骤调试的时候会发现含有 static声明的变量不太像时程序中的变量 ;-)

6.1.10 外部链接和内部链接的静态变量

外部链接的静态变量具有文件作用域、外部链接和静态存储期,该类别有时被称为外部存储类别,属于该类别的变量称为外部变量。如果未初始化外部变量,则其被默认初始化为0只能用常量表达式初始化文件作用域变量(除了变长数组以外,sizeof()表达式可以看作常量表达式)。

全局变量在main()函数执行之前完成初始化。

我们在文件之间共享全局变量的时候需要特别小心,可以使用以下两个策略:其一,遵循外部变量的常用规则,亦即在一个文件之中使用定义式声明,在另一个文件之中使用引用式说明(使用extern关键字);其二,将需要共享的全局变量放在一个头文件之中,在其他文件中包含这个头文件就可以了,然而,这种处理方式需要我们在头文件中使用static关键词,如果我们不使用static关键词或者使用extern关键词,那么我们就在每一个文件之中都包含了一个定义式声明,C标准是不允许这样子的。然而头文件实际上是给每一个文件提供了一个单独的数据副本,数据是重复的,浪费了很多的内存。

1
2
3
4
5
6
int exint = 1;          //declaration 1
extern int falseint;    //declaration 2
int main(void){
    /*内部不表*/
    extern int exint;   //declaration 3
}

考虑上面的例子:对于外部变量来说,第一次声明declaration 1被称为定义式声明(defining definition),为变量预留了存储空间;第二次声明declaration 3被称为引用式声明(referencing definition),关键词extern表明此次声明不是定义,指示编译器到别处查询定义,这表明declaration 2是不正确的,这时编译器假定falseint定义在程序别处,不会引起分配空间。因此我们不要用extern关键字创建外部定义只使用它引用外部定义

使用关键字static可以声明内部链接的静态变量,只需要在函数外使用static声明就可以,并且在函数内使用时使用extern进行引用式声明即可,但是extern并不改变链接属性

6.1.11 存储类别和函数

函数也有存储类别,可以是外部函数(默认)、静态函数或者内联函数。

  • 使用extern关键词定义的函数是外部函数,是为了表明当前文件中使用的函数被定义在别处,除非使用static关键词,一般函数声明都默认为extern
  • 使用static关键词定义的函数是静态函数,静态函数只能用于其定义所在的文件。可以在其他文件中定义与之同名的函数,这样子就避免了名称冲突的问题。
  • 内联函数:inline

6.2 动态分配内存

我们在前面所探讨的存储类别都有一个共同之处,在确定好存储类别之后,就只能根据确定好的内存存储规则,自动指定存储期和作用域。但是我们也可以利用库函数灵活分配和管理内存,只不过必须好好利用指针。

我们下面讨论malloc()free()calloc()realloc()函数。

6.2.1 void* malloc(size_t size)函数

malloc()函数接受一个参数:所需要的内存字节数,之后它会找到合适的内存块,匿名分配sizebyte大小的内存,返回动态分配内存块的首字节地址。如果无法分配内存,malloc()函数就会返回一个空指针。最早,由于char类型只占用一个字节,所以malloc()函数返回一个char *类型的指针,后来malloc()返回void *类型的通用指针,指向什么都可以,完全不需要考虑类型匹配的问题,但是为了增加代码的可读性,应该坚持强制类型转换。

我们可以利用malloc()函数提供第三种声明数组的方式:将调用malloc()函数的返回值赋给指针,利用指针访问数组的元素,这样创建的其实是一个动态数组。比如:

double *ptd;
ptd = (double *) malloc(30*sizeof(double));

我们完全可以使用正常声明数组一样的方式访问这个数组ptd,比如ptd[18]

malloc()函数也可以声明多维数组,但是语法会复杂一些:

1
2
3
4
5
6
7
int numrow = 6,numcolumn = 5;
int **array2 = (int **)malloc(sizeof(int*)*numrow);
for(int i = 0; i<m; i++){
    array2[i] = (int *)malloc(sizeof(int)*numcolumn);
}
//或者
int (* array)[numcolumn] = (int (* array)[numcolumn])malloc(sizeof(int)*numcolumn*numrow);

先看第一种定义方式:在第二行创建了一个二级指针,也就是存储着指针的数组array2,在接下来的循环中,逐个为二维数组的每一行分配空间,同时将数组指针存储在array2[i]中。在读取元素array2[1][2]的时候,我们先读取出array2[1],发现是个指针(其实是数组),然后读取这个数组的第三个元素(编号是2),这样就读出来了元素array2[1][2]

再看第二种定义方式:简而言之,等号左侧定义了一个指针变量array,指向的是int[numcolumn]类型的指针,说白了array也是一个二级指针。如果还要整花活,我们发现*(*(array+1)+2)array[1][2]其实是一样的。换句话说,array指向一个内含6个整型的数组,因此array[i]表示一个由numcolumn个整数构成的元素,array[i][j]表明一个整数。

逻辑上看,二维数组是指针的数组(亦即二级数组);但是从物理上来看,二维数组是一块连续的内存,对于二维数组array3[4][5]:我们完全可以按照5进制来理解这块内存的排布,五进制数 ij 表示的数所对应的内存上边的内容就是array[i][j]存储的内容。

6.2.2 void* calloc(size_t num,size_t size)函数

calloc()函数分配numsize大小的连续内存空间,并且将每一个字节都初始化为0,所以calloc()调用的结果是分配了num*size个字节长度的内存空间。calloc()函数的返回值和malloc()函数的一样。

6.2.3 void* realloc(void* ptr,size_t new_size)函数

realloc()函数重新分配指针ptr指向位置内存块的大小。函数返回新分配内存块的起始位置的地址,并且原指针ptr在调用后不再可用。

realloc()函数被调用时,只会做下面两种行为之中的一种:

  • 其一,将ptr指向的内存区域扩大或者缩小,并且尽可能保留剩余原有区域的内容(The contents of the area remain unchanged up to the lesser of the new and old sizes.),如果内存区域扩大,新的内存内容为未定义的(the contens of the new part of the array are undefined.)。
  • 其二,重新分配一块新的内存,将原内存区域的内容拷贝过来,并释放原内存(copying memory area with size equal the lesser of the new and the old sizes, and freeing the old block.)。

6.2.4 void free(void *ptr)函数

free()函数接受先前被malloc(),calloc(),realloc()动态分配过的内存地址,之后将这些内存释放(deallocate),如果free()接受一个空指针,那么它什么都不会做。free()函数不返回任何值。如果free()函数接受的参数不是先前被malloc(),calloc(),realloc()分配过的内存地址,它的行为并未被定义。(The behavior is undefined if the value of ptr does not equal a value returned earlier by malloc(),calloc(),realloc())我们也不能释放同一内存两次(The behavior is undefined if the memory area referred to by ptr has already been deallocated, that is, free(), free_sized(), free_aligned_sized() (since C23), or realloc() has already been called with ptr as the argument and no calls tomalloc(), calloc(), realloc() or aligned_alloc() (since C11) resulted in a pointer equal to ptr afterwards.)。

最重要的是:动态分配的内存必须被释放,否则会发生内存泄漏(memory leak)

6.3 ANSI C 类型限定符

值得注意的是,C99标准为限定符增加了一个新的属性:幂等性。也就是说可以在同一个声明之中使用多个相同的限定符,多余的限定符将被忽略。

6.3.1 const限定符

const关键词声明的对象将成为只读变量,其值不能通过赋值、递增或递减等方式修改,但是至少初始化变量是没问题的,这样我们就只可以使用但不能修改对象的值了。

如果对指针使用const限定符,如果const限定符在*的前面,也就是const int *num或者int const *num,其实限定了指针指向的值为constnum指向了一个int类型的const值。如果const限定符在*的后面,也就是int * const num,则我们创建的指针本身的值不能改变,但是它指向的值可以改变。

更加常见的用法是声明为函数形参的指针。比如void display(const int array[], int num),另外一个更熟悉的例子是字符串函数void strcat(char * restrict string1,const char * restrict string2)。 这使得传进去的数组的值没有被修改,这其实表明了const限定符实际提供了一种保护数据的方法。

我们同样可以对全局变量使用const限定符保护数据,因为extern限定符使得程序的任何一个部分都能使用并且改变这个变量,所以会平白无故产生许多危险,而const限定符让变量变成只读变量,这样就可以另程序更加安全。

6.3.2 volatile限定符

6.3.3 restrict限定符

Chapter 7:文件处理

7.1 文件和文件类型

7.1.1 文件

文件其实是硬盘上的一段已经被命名的存储区域,C将文件看成一系列连续的字节,每一段字节都可以被单独读取。

7.1.2 文件模式

C提供两种文件模式:文本模式和二进制模式。

  • 所有文件的内容都以二进制形式存储,但是如果文件最初使用二进制编码的字符表示文本,那么这个文件就是文本文件,其中包含文本内容。如果文件中的二进制值表示机器语言代码或者数值数据或者图片以及音乐编码,那么这个文件就是二进制文件,其中包含二进制内容。
  • C提供了两种访问文件的途径:文本模式和二进制模式。在二进制模式之中,程序可以访问文件的每一个字节,而在文本模式之中,程序看见的内容和文件实际的内容不同,换行符会进行不同样式的映射转换。

7.2 基本的文件处理

7.2.1 标准文件和标准I/O

C程序会自动打开三个文件:标准输入、标准输出和标准错误输出。通常时候下,标准输入是普通的输入设备,一般是键盘;标准输出和标准错误输出都是系统的普通输出设备,一般是显示屏。函数getchar()、函数printf()和函数puts()都使用的是标准输出。标准错误输出提供了一个逻辑上不同的地方来显示错误输出,如果我们将输出发送给文件,那么发送到标准错误输出的内容仍然会被发送到屏幕上。

7.2.2 基本文件处理

  • [[noreturn]] void exit(int exit_code)函数

    exit()函数关闭所有打开的文件并且结束程序,正如函数声明处所说

  • File *fopen(const char *restrict filename, const char *restrict mode)函数

    fopen()函数打开一个文件,其文件名由传入函数的第一个参数标识,返回文件指针。其需要的第二个参数是一个字符串,指定了待打开文件的模式。

    我们常见的打开文件模式有下面这些:

    • "r" 以只读模式打开文件;
    • "w" 以写模式打开文件,并且将现有文件的长度截为 0,如果文件不存在,则创建一个新文件;
    • "a" 以写模式打开文件,在现有文件结尾添加内容,若文件不存在,则创建一个新文件;
    • "r+" 以更新模式打开文件,亦即可以读写文件。

    如果打开文件失败,且不创建新文件,返回一个空指针

    值得注意的是,文件指针并不指向任何实际文件,只是指向一个包含文件信息的数据对象(换句话说是一个结构)其中包含了操作文件所用函数所需要的缓冲区信息。

  • int fclose(FILE *stream)函数

    fclose()函数关闭由stream给出的文件流,无论关闭是否成功,stream均与这个文件无关。The behavior is undefined if the value of the pointer stream is used after fclose returns.

    如果关闭成功,fclose()函数返回0,反之返回EOF

  • int fprintf(FILE *restrict stream, const char *restrict format, ...)函数

    fprintf()函数和printf()函数基本相同,只不过输出流从默认的stdout变成了需要自行给出的stream,亦即函数接受的第一个参数表示需要输出的位置。

  • int fscanf(FILE *restrict stream,const char *restrict format, ...)函数

    这个函数和scanf()函数大差不差,只不过接受的第一个参数需要是待读取文件的文件指针。

  • char *strerror(int errnum)函数

    返回一个指针,指向错误代码errnum代表的文字描述。errnum一般需要从变量errno中取得。

  • long ftell(FILE *stream)函数

    ftell()函数返回一个long类型的值,为stream的位置标识符的当前值,亦即。如果出现错误,ftell()函数将会返回-1,全局变量errno被设置为一个正值,我们可以使用errno变量来查看错误代码。比如:

    1
    2
    3
    4
    5
    6
    if(fseek(fp, 0L, SEEK_END) == -1)
    {
        fprintf(stderr,"ERROR:%s\n",strerror(errno));
        fclose(fp);
        return 1;
    }
    
  • int fseek(FILE *stream, long offset, int origin)函数

    fseek()函数将文件看做是数组,将位置标识符stream移动到目标位置。函数的第三个参数是模式,这个参数确定起始点。stdio.h 头文件内有三个表示模式的文件常量:SEEK_SET 表示文件开始处;SEEK_CUR 表示当前位置;SEEK_END 表示文件末尾。第二个参数是相对于origin的偏移量,以字节为单位,可以为正值(前移)、负值(后移)或者0(保持不动)。

    如果一切正常,fseek()返回0,若出现错误(比如试图移动的距离超出文件范围了),返回值为-1

    ftell()函数在文本模式和在二进制模式的工作方式不同,ANSI C规定,ftell()函数的返回值可以当做fseek()函数的第二个参数。对于MS-DOS,ftell()返回的值将\r\n当做一个字节计数。

  • 函数

  • 函数

  • 函数

  • size_t fread(void *restrict buffer, size_t size, size_t count, FILE * resrict stream)函数

    fread()函数接受的参数和fwrite()相同。在fread()函数之中,buffer是待读取文件数据在内存之中的地址,stream指定要读取的文件,该函数可以用于读取文件之中的数据,size代表着待读取数据每个元素的大小,count代表待读取项的项数。函数返回成功读取项的项数,一般是count,如果出现错误或者读到EOF,返回的值就会比count小。

    值得一提的是:The file position indicator for the stream is advanced by the number of characters read.

  • size_t fwrite(const void* buffer, size_t size, size_t count, FILE* stream)函数

    fwrite()函数将缓冲数组buffer里面的count个元素写入到流stream之中。函数将需要写入的数据重新编译为unsigned char类型的数组,通过重复调用fptc()函数将其写入stream之中。和fread()函数相同,The file position indicator for the stream is advanced by the number of characters read. 在实际使用fread()函数和fwrite()函数读写文件之中的数据时,文件位置指示符不断前进,这使得我们不会重复读取数据,亦即可以实现这样的操作,一个循环就能实现文件从头读到尾的操作:

    1
    2
    3
    4
    5
    while(fread(&buffer, sizeof(int16_t), 1, input))
    {
        buffer *= factor;
        fwrite(&buffer, sizeof(int16_t), 1, output);
    }
    

7.3 流简介

在C中,我们处理的是流是一系列连续的字节,不同属性和不同种类的输入由属性更加统一的流来表示。流告诉我们,我们可以利用和处理文件相同的方式来处理键盘输入。打开文件的过程就是把流与文件相关联,读写都通过流来完成。

在标准头文件stdio.h之中,定义了三个文本流stdin stdout stderr