C语言笔记

编译和链接

概述

  1. 编译器的任务
    编译器把源代码(如,用C语言编写的代码)翻译成等价的机器语言代码(也叫作目标代码)。
  2. 链接器的任务
    链接器把编译器翻译好的目标代码库代码启动代码组合起来,生成一个可执行程序

Gcc

C程序的完整编译过程示例

  1. 预处理(Prepressing)
1
gcc -E hello.c -o hello.i
  1. 编译(Compilation)
1
gcc -S hello.i -o hello.s
  1. 汇编(Assembly)
1
gcc -c hello.s -o hello.o
  1. 链接(Linking)
1
gcc hello.o -o hello

GCC基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-o [filename]:指定输出文件名称。

-c:仅编译,不进行链接,生成目标文件(.o)。

-E:仅执行预处理,生成预处理文件(.i)。

-S:将源代码编译为汇编代码(.s)。

-g:生成调试信息,便于使用调试工具(如GDB)。

-O:优化代码,支持-O1、-O2、-O3等不同优化级别。

-Wall:启用所有警告信息。

-static:使用静态链接。

-shared:生成共享库。

-I [dir]:指定头文件搜索路径。

-L [dir]:指定库文件搜索路径。

-l [libname]:链接指定的库。

sizeof和strlen

sizeof strlen
性质 编译时运算符 ‌库函数(string.h)
作用 计算字节大小 计算字符串的长度(不包括结尾的\0字符)

指针

指针(pointer)是一个值为内存地址的变量(或数据对象)。

可以对指针进行哪些操作?

  1. 赋值:可以把地址赋给指针。
  2. 解引用*运算符给出指针指向地址上储存的值。
  3. 取址:和所有变量一样,指针变量也有自己的地址和值。对指针而言,&运算符给出指针本身的地址。
  4. 指针与整数相加:可以使用+运算符把指针与整数相加,或整数与指针相加。无论哪种情况,整数都会和指针所指向类型的大小(以字节为单位)相乘,然后把结果与初始地址相加。
  5. 递增指针:递增指向数组元素的指针可以让该指针移动至数组的下一个元素。
  6. 指针减去一个整数:可以使用-运算符从一个指针中减去一个整数。指针必须是第1个运算对象,整数是第2个运算对象。该整数将乘以指针指向类型的大小(以字节为单位),然后用初始地址减去乘积。
  7. 递减指针:递减指向数组元素的指针可以让该指针移动至数组的上一个元素。
  8. 指针求差:可以计算两个指针的差值。通常,求差的两个指针分别指向同一个数组的不同元素,通过计算求出两元素之间的距离。差值的单位与数组类型的单位相同。
  9. 比较:使用关系运算符可以比较两个指针的值,前提是两个指针都指向相同类型的对象。

复合字面量

  1. 语法形式为
1
2
3
(类型名){ 初始化列表 }
/* 例如 */
int* ptr = (int[2]){ 2, 4 };
  • 类型名
    指定了要创建的匿名对象的类型,如 int[5]struct point等。
  • 初始化列表
    与普通变量初始化语法相同,用于给该匿名对象赋初值。
  1. 作用域与生命周期‌:
  • 当复合字面量出现在函数体外时,它具有‌静态存储期‌(整个程序运行期间存在)。
  • 当出现在函数体内时,它具有‌自动存储期‌(通常在其所在的块作用域结束时失效)。
  • 其地址可以被获取,并且可以像普通变量一样被修改(除非被限定为const)。
  1. ‌提高代码紧凑性与可读性‌:
  • 避免了为仅使用一次的临时变量单独命名和声明,使代码更简洁。

函数指针&函数指针

指针函数 函数指针
性质 函数,返回指针类型 指针变量,存储函数地址
示例 int* fun(int a) int (*fun)(int, int)
使用场景 返回数据地址(如数组、字符串) 动态调用函数(如回调机制)

内存管理

存储类别

作用域

作用域描述程序中可访问标识符的区域。一个C变量的作用域可以是块作用域函数作用域函数原型作用域文件作用域

链接

变量有3种链接属性:外部链接内部链接无链接

  1. 具有块作用域函数作用域函数原型作用域的变量都是无链接变量。这些变量属于定义它们的块、函数或原型私有。
  2. 具有文件作用域的变量可以是外部链接内部链接。外部链接变量可以在多文件程序中使用,内部链接变量只能在一个翻译单元中使用。
  • 注:C 标准用“内部链接的文件作用域”描述仅限于一个翻译单元(即一个源代码文件和它所包含的头文件)的作用域,用“外部链接的文件作用域”描述可延伸至其他翻译单元的作用域。但是,对程序员而言这些术语太长了。一些程序员把“内部链接的文件作用域”简称为“文件作用域”,把“外部链接的文件作用域”简称为“全局作用域”或“程序作用域”。

存储期

作用域链接描述了标识符的可见性。存储期描述了通过这些标识符访问的对象的生存期。C对象有4种存储期:静态存储期线程存储期自动存储期动态分配存储期

  1. 如果对象具有静态存储期,那么它在程序的执行期间一直存在。文件作用域变量具有静态存储期。注意,对于文件作用域变量,static表明了其链接属性,而非存储期。以 static声明的文件作用域变量具有内部链接。但是无论是内部链接还是外部链接,所有的文件作用域变量都具有静态存储期。
  2. 线程存储期用于并发程序设计,程序执行可被分为多个线程。具有线程存储期的对象,从被声明时到线程结束一直存在。以关键字_Thread_local声明一个对象时,每个线程都获得该变量的私有备份。
  3. 块作用域的变量通常都具有自动存储期。当程序进入定义这些变量的块时,为这些变量分配内存;当退出这个块时,释放刚才为变量分配的内存。
  4. 变长数组稍有不同,它们的存储期从声明处到块的末尾,而不是从块的开始处到块的末尾。
  • 5种存储类别:
存储类别 存储期 作用域 链接 声明方式
自动 自动 块内
寄存器 自动 块内,register
静态外部链接 静态 文件 外部 所有函数外
静态内部链接 静态 文件 内部 所有函数外,static
静态无链接 静态 块内,static

存储类别说明符

  1. auto说明符表明变量是自动存储期,只能用于块作用域的变量声明中。由于在块中声明的变量本身就具有自动存储期,所以使用auto主要是为了明确表达要使用与外部变量同名的局部变量的意图。
  2. register说明符也只用于块作用域的变量,它把变量归为寄存器存储类别,请求最快速度访问该变量。同时,还保护了该变量的地址不被获取。
  3. static说明符创建的对象具有静态存储期,载入程序时创建对象,当程序结束时对象消失。如果static 用于文件作用域声明,作用域受限于该文件。如果 static用于块作用域声明,作用域则受限于该块。因此,只要程序在运行对象就存在并保留其值,但是只有在执行块内的代码时,才能通过标识符访问。块作用域的静态变量无链接。文件作用域的静态变量具有内部链接。
  4. extern 说明符表明声明的变量定义在别处。如果包含 extern 的声明具有文件作用域,则引用的变量必须具有外部链接。如果包含 extern 的声明具有块作用域,则引用的变量可能具有外部链接或内部链接,这接取决于该变量的定义式声明。

分配内存:malloc和free

  1. 静态存储类别所用的内存数量在编译时确定,只要程序还在运行,就可访问储存在该部分的数据。该类别的变量在程序开始执行时被创建,在程序结束时被销毁。
  2. 自动存储类别的变量在程序进入变量定义所在块时存在,在程序离开块时消失。因此,随着程序调用函数和函数结束,自动变量所用的内存数量也相应地增加和减少。这部分的内存通常作为栈来处理,这意味着新创建的变量按顺序加入内存,然后以相反的顺序销毁。
  3. 动态分配的内存在调用malloc()或相关函数时存在,在调用free()后释放。这部分的内存由程序员管理,而不是一套规则。所以内存块可以在一个函数中创建,在另一个函数中销毁。正是因为这样,这部分的内存用于动态内存分配会支离破碎。也就是说,未使用的内存块分散在已使用的内存块之间。另外,使用动态内存通常比使用栈内存慢。

ANSI C类型限定符

const

const关键字声明的对象,其值不能通过赋值或递增、递减来修改。

在指针和形参声明中使用const

  1. const放在*左侧任意位置,限定了指针指向的数据不能改变。
  2. const放在*的右侧,限定了指针本身不能改变。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @brief ptr_a 指向一个int类型的const值
* ptr_a 指向的值不能被改变,而 ptr_a 本身的值可以改变
*
*/
const int* ptr_a; /* 与int const* ptr_a;相同 */

/**
* @brief ptr_b 是一个const指针
* ptr_b 本身的值不能更改,必须指向同一个地址,但是它所指向的值可以改变
*/
int* const ptr_b;

/**
* @brief ptr_c 既不能指向别处,它所指向的值也不能改变。
*/
const int* const ptr_c;

对全局数据使用const

  1. 方案一,在一个文件中使用定义式声明,在其他文件中使
    用引用式声明(extern):
1
2
3
4
/* file1.c -- 定义了一些外部const变量 */
const double PI = 3.14159;
/* file2.c -- 使用定义在别处的外部const变量 */
extern const double PI;
  1. 方案二,把const变量放在一个头文件中,然后在其他文件中包含该头文件:
1
2
3
4
5
6
/* constant.h --定义了一些外部const变量*/
static const double PI = 3.14159;
/* file1.c --使用定义在别处的外部const变量*/
#include "constant.h"
/* file2.c --使用定义在别处的外部const变量*/
#include "constant.h"
  • 必须在头文件中用static声明全局const变量,否则在file1.cfile2.c中包含的constant.h将导致每个文件中都有一个相同标识符的定义式声明。
  • 方法二相当于给每个文件提供了一个单独的数据副本。由于每个副本只对该文件可见,所以无法用这些数据和其他文件通信。不过它们都是完全相同(每个文件都包含相同的头文件)的const数据(声明时使用了const关键字)。
  • 头文件方案的好处是,所有文件都只需包含同一个头文件。但它的缺点是,数据是重复的。如果const数据包含庞大的数组,就不能视而不见了。

const与``#define

1
2
3
4
#define LIMIT 20
const int LIM = 50;
static int data1[LIMIT]; /* 有效 */
static int data2[LIM]; /* 无效 */

在文件作用域(即所有函数之外,全局变量区域)声明数组时,数组的大小必须是一个‌编译时常量表达式‌(整型常量的组合、枚举常量和sizeof表达式),不包括const声明的值(这也是C++和C的区别之一,在C++中可以把const值作为常量表达式的一部分)。

volatile

volatile限定符告知计算机,代理(而不是变量所在的程序)可以改变该变量的值。通常,它被用于硬件地址以及在其他程序或同时运行的线程中共享数据。

  1. volatile的语法和const一样:
1
2
volatile int loc;       /* loc 是一个易变的位置 */
volatile int* ptr_loc; /* ptr_loc 是一个指向易变的位置的指针 */
  1. 可以同时用constvolatile限定一个值。例如,通常用const把硬件时钟设置为程序不能更改的变量,但是可以通过代理改变,这时用volatile。只能在声明中同时使用这两个限定符,它们的顺序不重要,如下所示:
1
2
volatile const int loc;
const volatile int* ptr_loc;

restrict

restrict关键字允许编译器优化某部分代码以更好地支持计算。它只能用于指针,表明该指针是访问数据对象的唯一且初始的方式。

1
2
3
4
5
/**
* 指针restar是访问由malloc()所分配内存的唯一且初始的方式。
* 因此,可以用restrict关键字限定它。
*/
int* restrict restar = (int*) = malloc(10 * sizeof(int));

_Atomic(C11)

1
2
3
_Atomic int hogs;      /* hogs 是一个原子类型的变量 */
atomic_store(&hogs, 12); /* stdatomic.h 中的宏 */
/* 在hogs中储存12是一个原子过程,其他线程不能访问hogs */

文件输入/输出

标准I/O

exit()

  1. exit()函数关闭所有打开的文件并结束程序。
1
2
3
4
5
6
#include <stdlib.h>

/* 0 或 EXIT_SUCCESS 用于表明成功结束程序 */
exit(EXIT_SUCCESS);
/* 1 或 EXIT_FAILURE 用于表明结束程序失败 */
exit(EXIT_FAILURE);
  1. 根据ANSI C的规定,在最初调用的main()中使用return与调用exit()的效果相同。因此,在main()
    return 0;exit(0);的作用相同。
  2. 但是,我们说的是“最初的调用”。如果main()在一个递归程序中,exit()仍然会终止程序,但是return只会把控制权交给上一级递归,直至最初的一级。然后return结束程序。
  3. returnexit()的另一个区别是,即使在其他函数中(除main()以外)调用exit()也能结束整个程序。

fopen()

fopen()模式字符串说明

‌模式字符串‌ ‌含义‌
“r” 以读模式打开文件(仅读,文件需存在)
“w” 以写模式打开文件,把现有文件的长度戳截为0(新建/覆盖,不存在则创建)
“a” 以写模式打开文件,在现有文件末尾添加内容(追加,不存在则创建)
“r+” 以更新模式打开文件(读写,文件需存在)
“w+” 以更新模式打开文件(读写,新建/覆盖)
“a+” 以更新模式打开文件;可以读整个文件,但只能从末尾添加内容(读写,追加)
“rb”、”wb”、”ab”
“ab+”、”a+b”
“wb+”、”w+b”
二进制模式(对应文本模式功能)
“wx”、”wbx”、”w+x”
“wb+x”或”w+bx”
(C11)独占模式(文件存在或以独占模式打开文件,则打开文件失败)

getc()putc()

getc()putc()函数与getchar()putchar()函数类似。所不同的是,要告诉getc()putc()函数使用哪一个文件。

1
2
3
4
5
6
7
int ch;
FILE* fp;
/* 从fp指定的文件中获取一个字符 */
ch = getc(fp);

/* 把字符ch放入FILE指针fpout指定的文件中 */
putc(ch, fpout);

fclose()

fclose(fp)函数关闭fp指定的文件,必要时刷新缓冲区。对于较正式的程序,应该检查是否成功关闭文件。如果成功关闭,fclose()函数返回0,否则返回EOF:

1
2
3
if (fclose(fp) != 0) {
printf("Error in closing file %s\n", argv[1]);
}

如果磁盘已满、移动硬盘被移除或出现I/O错误,都会导致调用fclose()函数失败。

文件I/O

fprintf()fscanf()

printf()scanf()类似

1
2
/* \033[1;31m 31表示前景色为红色, \033[0m 恢复默认属性*/
fprintf(stderr, "\033[1;31mCan't create output file.\n\033[0m");

fgets()fputs()

1
2
fgets(buf, len, fp);
fputs(buf, fp);

puts()函数不同,fputs()在打印字符串时不会在其末尾添加换行符。

随机访问:fseek()ftell()

fseek()ftell()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @brief fseek()
* @return 0: 正常
* -1: 出现错误(如试图移动的距离超出文件的范围)
* @note SEEK_CUR 当前位置
* SEEK_END 文件末尾
* SEEK_SET 文件开头
*/
/* 定位至文件开始处 */
fseek(fp, 0L, SEEK_SET);
/* 从文件当前位置前移2个字节 */
fseek(fp, 2L, SEEK_CUR);
/* 从文件结尾处回退1个字节 */
fseek(fp, -1L, SEEK_END);
1
2
3
4
5
/**
* @brief ftell()
* @return long 返回的是当前的位置
*/
long last = ftell(fp);

fgetpos()fsetpos()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @brief fgetpos()
* @param stream
* @param pos 该值描述了文件中的一个位置
* @return 0: 成功; 非0: 失败
*/
int fgetpos(FILE* restrict stream, fpos_t* restrict pos);

/**
* @brief fsetpos()
* @param stream
* @param pos 通过调用fgetpos()获得
* @return 0: 成功; 非0: 失败
*/
int fsetpos(FILE* stream, const fpos_t* pos);

标准I/O的机理

  1. 调用fopen()打开文件(C程序会自动打开3种标准文件)。fopen()函数不仅打开一个文件,还创建了一个缓冲区(在读写模式下会创建两个缓冲区)以及一个包含文件和缓冲区数据的结构。另外,fopen()返回一个指向该结构的指针。假设把该指针赋给一个指针变量fp,我们说fopen()函数“打开一个流”。如果以文本模式打开该文件,就获得一个文本流;如果以二进制模式打开该文件,就获得一个二进制流。这个结构通常包含一个指定流中当前位置的文件位置指示器。除此之外,它还包含错误和文件结尾的指示器、一个指向缓冲区开始处的指针、一个文件标识符和一个计数(统计实际拷贝进缓冲区的字节数)。我们主要考虑文件输入。
  2. 通常,第2步是调用一个定义在stdio.h中的输入函数,如fscanf()getc()fgets()。一调用这些函数,文件中的数据块就被拷贝到缓冲区中。缓冲区的大小因实现而异,一般是512字节或是它的倍数,如4096或16384(随着计算机硬盘容量越来越大,缓冲区的大小也越来越大)。最初调用函数,除了填充缓冲区外,还要设置fp所指向的结构中的值。尤其要设置流中的当前位置和拷贝进缓冲区的字节数。通常,当前位置从字节0开始。在初始化结构和缓冲区后,输入函数按要求从缓冲区中读取数据。在它读取数据时,文件位置指示器被设置为指向刚读取字符的下一个字符。由于stdio.h系列的所有输入函数都使用相同的缓冲区,所以调用任何一个函数都将从上一次函数停止调用的位置开始。当输入函数发现已读完缓冲区中的所有字符时,会请求把下一个缓冲大小的数据块从文件拷贝到该缓冲区中。以这种方式,输入函数可以读取文件中的所有内容,直到文件结尾。函数在读取缓冲区中的最后一个字符后,把结尾指示器设置为真。于是,下一次被调用的输入函数将返回EOF
  3. 输出函数以类似的方式把数据写入缓冲区。当缓冲区被填满时,数据将被拷贝至文件中。

二进制I/O:fread()fwrite()

fwrite()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @brief 把二进制数据写入文件
* @param ptr 待写入数据块的地址
* @param size 待写入数据块的大小(以字节为单位)
* @param nmemb 待写入数据块的数量
* @param fp 待写入的文件
* @return 成功,则该返回值就是nmemb
* 如果出现写入错误,返回值会比nmemb小
*/
size_t fwrite(const void* restrict ptr, size_t size, size_t nmemb, FILE* restrict fp);

/* 例,要保存一个大小为256字节的数据对象(如数组): */
char buffer[256];
fwrite(buffer, 256, 1, fp);

fread()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @brief 从文件读取二进制数据
* @param ptr 待读取文件数据在内存中的地址
* @param size 待读取数据块的大小(以字节为单位)
* @param nmemb 待读取数据块的数量
* @param fp 定待读取的文件
* @return 成功,则该返回值就是nmemb
* 如果出现读取错误或读到文件结尾,返回值会比nmemb小
*/
size_t fread(void * restrict ptr, size_t size, size_t nmemb,FILE * restrict fp);

/* 例,要保存一个大小为256字节的数据对象(如数组): */
double earnings[10];
fread(earnings, sizeof(double), 10, fp);

数据类型

struct

和数组不同,结构名不是结构的地址,要在结构名前使用&运算符才能获得结构的地址。

union

union能在同一个内存空间中储存不同的数据类型(不是同时储存)。其大小取决于其最大成员的大小。

  1. 用一个成员把值储存在一个联合中,然后用另一个成员查看内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef union {
short short_value[2];
int int_value;
float float_value;
} converter_t;

int main(void){
···
converter_t conv;

/* 存储浮点数 */
conv.float_value = 3.14159f;
/* 查看整数值(查看内存表示) */
printf("float type: %.5f, addr: 0x%X (short_value[0]: %04X, short_value[1]: %04X)\n", conv.float_value, conv.int_value, conv.short_value[0], conv.short_value[1]);
···
}

输出:

1
float type: 3.14159, addr: 0x40490FD0 (short_value[0]: 0FD0, short_value[1]: 4049)

enum (enumerated type)

使用enum关键字,可以创建一个新“类型”并指定它可具有的值(实际上,enum常量是int类型)。

复杂的声明

1
2
3
4
5
6
7
8
9
10
11
int board[8][8];        /* 声明一个内含int数组的数组 */
int** ptr; /* 声明一个指向指针的指针,被指向的指针指向int */
int* risks[10]; /* 声明一个内含10个元素的数组,每个元素都是一个指向int的指针 */
int (*rusks)[10]; /* 声明一个指向数组的指针,该数组内含10个int类型的值 */
int* oof[3][4]; /* 声明一个3×4 的二维数组,每个元素都是指向int的指针 */
int (*uuf)[3][4]; /* 声明一个指向3×4二维数组的指针,该数组中内含int类型值 */
int (*uof[3])[4]; /* 声明一个内含3个指针元素的数组,其中每个指针都指向一个内含4个int类型元素的数组 */

char* fump(int); /* 返回字符指针的函数 */
char (*frump)(int); /* 指向函数的指针,该函数的返回类型为char */
char (*flump[3])(int); /* 内含3个指针的数组,每个指针都指向返回类型为char的函数 */
  1. 数组名后面的[]和函数名后面的()具有相同的优先级。它们比*(解引用运算符)的优先级高。
  2. []()的优先级相同,都是从左往右结合。

C预处理器

宏和函数的选择

  1. 宏和函数的选择实际上是时间和空间的权衡。
  2. 宏生成内联代码,即在程序中生成语句。如果调用20次宏,即在程序中插入20行代码。如果调用函数20次,程序中只有一份函数语句的副本,所以节省了空间。
  3. 然而,函数的调用过程包括建立调用、传递参数、跳转到函数代码并返回,这显然比内联代码花费更多的时间。

泛型编程(C11)

1
2
3
4
5
6
#define MYTYPE(X) _Generic((X),\
int: "int",\
float : "float",\
double: "double",\
default: "other"\
)

_Generic是C11的关键字。_Generic后面的圆括号中包含多个用逗号分隔的项。第1个项是一个表达式,后面的每个项都由一个类型、一个冒号和一个值组成。第1个项的类型匹配哪个标签,整个表达式的值是该标签后面的值。

inline function(C99)

  1. 把函数变成内联函数,编译器可能会用内联代码替换函数调用,并(或)执行一些其他的优化,但是也可能不起作用。
  2. 编译器优化内联函数必须知道该函数定义的内容。这意味着内联函数定义与函数调用必须在同一个文件中。鉴于此,一般情况下内联函数都具有内部链接。因此,如果程序有多个文件都要使用某个内联函数,那么把内联函数定义放入头文件即可。

_Noreturn函数(C11)

表明调用完成后函数不返回主调函数。exit()函数是
_Noreturn函数的一个示例。

断言库

1
2
3
4
5
6
// #define NDEBUG
#include <assert.h>
...
int a = b + c;
assert(a >= 0);
...
作者

Shana-wen

发布于

2019-12-13

更新于

2026-01-28

许可协议

评论