阮一峰C教程: https://wangdoc.com/clang/intro.html
一站式学习C编程(升级版) by 宋劲彬 (z-lib.org).pdf

1. 程序的基本概念

程序由指令组成,基本指令如下:

  • 输入
  • 输出
  • 基本运算
  • 测试和分支
  • 循环

C/C++、Java、Python 等是高级语言。
汇编语言和机器语言是低级语言,汇编语言和机器语言的指令是一一对应的,由汇编器查表把汇编语言中的助记符翻译成机器语言。
而高级语言和低级语言之间并不是一一对应的,由编译器来将高级语言翻译成低级语言。
image.png

image.png
image.png
注意区分编译时 和 运行时(Run-time)两个概念。

2. 常量、变量、表达式

float 单精度浮点型
double 双精度浮点型
long double 精度更高的浮点型

printf 时的 %f 是 double 型的转换说明。

转义序列是在编译时处理的,而转换说明是在运行时调用 printf 函数处理的:

  1. printf("character: %c\ninteger: %d\nfloating point: %f\n", '}', 34, 3.14);

字符类型(char)背后有对应的字符编码,最常用的是 ASCII 码表,每个字符都对应着字符表中的一个整数。
所以 char 可以当做整数参与运算。
int 和 char 都可称为整数类型(整型)。

putchar(char c) 打印一个字符,相当于 printf 的 %c。

在C语言中整数除法取的既不是Floor也不是Ceiling,无论操作数是正是负总是把小数部分截掉,在数轴上向零的方向取整。

3. 简单函数

C 标准包含了 C 语言语法的描述C 标准库的描述。只有同时实现了 C 编译器和 C 标准库才算实现了 C 标准。

使用 math.h 头文件中的库函数的话,gcc 编译时要加 -lm 参数,告诉编译器用到的函数去到 libm.so 库文件中去找。
而使用 printf 这样基本的函数,则不需要额外指定 gcc 参数,因为已经默认加了 -lc 参数,意指 libc.so 库,其中包含了最常用的的 C 标准库函数和系统函数。

Linux 中最广泛使用的 C 函数库是 glibc。

main 函数是程序的入口函数,执行程序时由操作系统来调用 main 函数。

  1. int main(void) { return 0; }
  2. // int main(int argc, char *argv[])

不使用系统传入的两个参数时,参数列表因此写成 void,表示没有参数。
main 函数返回的整数是给操作系统看的,通常执行成功就返回 0,否则返回非 0, shell 中使用 echo $? 可以看到上一条命令的退出状态。

void threelines(void); 明确写了返回值和参数类型,但没有函数体,这叫做函数原型。

形参相当于函数中定义的变量,调用函数传递参数的过程相当于定义形参变量并且用实参的值来初始化。

使用 man 3 printf 可以查看 printf 函数的文档:
image.png

在函数中定义的变量称为局部变量,局部变量在每次函数调用时分配存储空间,在函数返回时释放存储空间。
全局变量定义在所有函数体之外,在程序开始运行时分配存储空间,在程序结束时释放存储空间。
原则是能用函数传参就少用全局变量。

全局变量在定义时可以不进行初始化,初始值会是 0,但若要同时进行初始化,则必须只能用常量表达式进行初始化,不能是函数调用或者混合了变量的表达式。
因为程序在一开始时,就要用初始值来初始化全局变量,那么就要求初始值提前保存在编译时生成的可执行文件中。

局部变量如果定义时不初始化,则初始值是不确定的,所以局部变量定义时要进行初始化。此外,语句块也能创建局部作用域和局部变量。

4. 分支语句

真-true-1,假-false-0,真假值在 C 语言中分别用 int 型的 1 和 0 表示,对于 if 来说只要不是 0,就都当做是真值。

if 控制表达式的两边类型要一致,都是整型或者浮点型。浮点型精度有限,不适合用 == 做精确比较,可能判定为 equal,也可能判定为 unequal。

== != 优先级小于 > < >= <=

C 语言中 % 取模运算符的结果总是与被除数同号。

else 总是和它上面最近的一个 if 配对。

布尔代数 && (AND)、|| (OR) 和 ! (NOT) ,还有 switch 语句没什么说的。

5. 深入理解函数

void print_log(double x) 即使没有返回值的函数,也可以使用 return 提前退出函数。

保证函数的返回值类型行为一致。

封装是为了复用:
image.png

递归

直接或间接调用自己的函数称为递归函数。

求 n 的阶乘
图中用实线箭头表示调用,用虚线箭头表示返回,右侧的框表示在调用和返回过程中各层函数调用的存储空间变化情况。每次调用函数时分配参数和局部变量的存储空间,退出函数时释放它们的存储空间 。
随着函数调用的层层深入,不断创建新的栈帧用来存储当前函数的参数和局部变量,因此栈空间不断增长。
然后随着函数调用的层层返回,栈帧不断释放,栈空间又逐渐缩短。
image.png
递归函数需要有 Base Case,也就是最简单的一种情况,简单到不需要再自己调用自己,如此才不会创建新的栈帧,否则就会无穷递归,直到程序预留的栈空间被耗尽,程序崩溃(段错误)。
所以递归函数就是 递推关系 + Base Case。

递归和循环是等价的,如 LISP 语言只有递归没有循环。

6. 循环语句

循环主要由累加器和循环变量构成。

用递归解决这个问题靠的是递推关系n!=n·(n-1)!
用循环解决这个问题则更像是把这个公式展开了:n!=n·(n-1)·(n-2)·…·3×2×1
前一种思路称为函数式编程(Functional Programming),而后一种思路称为命令式编程(Imperative Programming)。
有些时候循环比递归更简单,有些时候递归比循环更简单,因为可能无法公式展开。

do…while 和 for 循环都可以改造为 while。
例如for (;1;) {…}等价于while (1) {…}死循环。C语言规定,如果控制表达式2为空,则认为控制表达式2的值为真,因此死循环也可以写成for (;;) {…}。

7. 结构体

基本类型可以组成复合类型,比如由字符组成的字符串,结构体类型、枚举类型和数组类型等。

结构体是一种数据抽象,函数是一种过程抽象。结构体成员的存储空间是相邻的。

complex_struct 标识符称为 Tag,下面整体可看作一个类型名:

  1. struct complex_struct {
  2. double x, y;
  3. };
  4. // 声明结构体类型之后,就可以在需要的时候定义该结构体变量了
  5. struct complex_struct z1;
  6. struct complex_struct z2 = { 3.0, 4.0 }; // 定义变量时初始化

也可以在定义结构体类型的同时定义变量:

  1. struct complex_struct {
  2. double x, y;
  3. } z1, z3;

此时若省略 Tag 标识符也是可以的,但下次就无法复用该结构体类型了:

  1. struct {
  2. double x, y;
  3. } z1;

使用 .后缀运算符来访问结构体成员。

  1. struct complex_struct { double x, y; };
  2. struct complex_struct add_complex(
  3. struct complex_struct z1,
  4. struct complex_struct z2
  5. ) {
  6. z1.x = z1.x + z2.x;
  7. z1.y = z1.y + z2.y;
  8. return z1;
  9. }

结构体初始化时,未指定的成员将用 0 来初始化,就像未初始化的全局变量一样(实测结构体声明的变量无论是否是全局变量,都会默认用 0 来初始化):

double x = 3.0;
struct complex_struct z1 = { x, 4.0, }; /* z1.x=3.0, z1.y=4.0 */
struct complex_struct z1 = { 3.0, }; /* z2.x=3.0, z2.y=0.0 */
struct complex_struct z1 = { 0 }; /* z3.x=0.0, z3.y=0.0 */

还可以用 Designated Initializer 语法对每个成员做初始化,其他成员会自动用 0 初始化,如下面这样只针对 y:

struct complex_struct z1 = { .y = 4.0 }; /* z1.x=0.0, z1.y=4.0 */

注意!{}这种语法不能用于结构体的赋值,只有表达式才能用来赋值,所以下面是错误的:

struct complex_struct z1;
z1 = { 3.0, 4.0 }; /* 错误!*/

枚举类型

enum 关键字和 struct 关键字类似,把标识符定义为一个 Tag,用来表示一个枚举类型。
枚举类型的成员是常量,是一种整型,其值在编译时确定,所以可以用于初始化全局变量或者作为 case 分支的判断条件,默认从 0 开始,也可以手动指定:

enum coordinate_type { RECTANGULAR = 1, POLAR }; /* 直角坐标、极坐标 */
struct complex_struct {
    enum coordinate_type t;
    double a, b;
};

结构体的成员名和变量名不在同一命名空间中,所以不会冲突,但枚举的成员名是和变量名在同一命名空间中的,所以要注意避免命名冲突(查看 18.3 节):

enum coordinate_type { RECTANGULAR = 1, POLAR };
int RECTANGULAR; /* 不行,和枚举成员名冲突了! */

嵌套结构体

结构体也是一种递归定义,可以嵌套定义:

struct complex_struct { double x, y; };
struct segment {
    struct complex_struct start;
    struct complex_struct end;
};

/* 嵌套初始化 */
struct segment s = {{ 1.0, 2.0 }, { 4.0, 6.0 }};
/* 平坦初始化 */
struct segment s = { 1.0, 2.0, 4.0, 6.0 }; /* 还可以和嵌套混合使用,但应避免 */
/* 按成员初始化 Memberwise Initialization */
struct segment s = { .start.x = 1.0, .end.x = 2.0 };

/* 使用嵌套结构体的成员,为其赋值 */
s.start.x = 5.0;

8. 数组

数组也是一种复合数据类型,由一系列相同数据类型的元素组成。数组存储空间和结构体类似,也是相邻的。

int count[4];
/* 定义一个将结构体作为成员的数组 */
struct complex_struct { double x, y; } a[4];
/* 定义一个包含数组成员的结构体 */
struct {
    double x, y;
    int count[4];
} s;

后缀运算符:

  • 后缀 ++
  • 后缀 --
  • 结构体取成员 .
  • 数组取下标 []
  • 函数调用 ()

单目(前缀)运算符:

  • 前缀 ++
  • 前缀 --
  • 正号 +
  • 负号 -
  • 逻辑非 !

C 语言中,后缀运算符优先级最高,其次是单目运算符。

数组通过索引 index 取值,index 的类型是整型。
使用数组下标时注意不要超出数组长度范围,使用常量或常量表达式时,编译器会检查是否越界,但对于使用变量作为索引时,编译器并不会检查是否越界,运行时(Run-time)会不正确:

int main(void) {
    int index = 100;
    int count[4];
    printf("%d", count[index]); /* 获取到的值是错误的随机数 */
}

数组初始化类似结构体,未赋初值的元素会用 0 来初始化(局部数组一定记得初始化):

int count[4] = { 3, 2, }; /* 3, 2, 0, 0 */

定义的同时初始化,允许省略数组长度:

int count[] = { 3, 2, 1, }; /* 数组长度为 3 */

利用 C99 特性的按成员初始化 Memberwise Initialization,其余的会用 0 初始化:

int count[4] = { [2] = 3 };

数组不能相互赋值,或相互初始化,不能用数组类型作为函数的参数或返回值。

void foo(int a[5]) { /* 实际等价于声明了 void foo(int *a) */
    printf("a=%p\n", a);
    printf("foo is invoked");
}

int main(void) {
    int arr[5] = {0};
    foo(array);
}

编译器不会报错,但这里不是传递数组类型参数的意思。
数组特殊规则:数组类型做右值使用时,自动转换成指向数组首元素的指针。
函数声明时的特殊规则:在函数原型中,如果参数写成数组形式,则该参数实际上是指针类型。

所以上面的函数调用实际传了一个指针类型的参数,等第 22 章讲指针时再议。

随机数

可以使用 C 标准库中的 stdlib.h 中的 rand 函数生成伪随机数,返回值介于 0 ~ RAND_MAX 之间。
RAND_MAX 值在 mac 中测试是 2 的 31 次方(0x7fffffff)。
然后通过 % 取模运算来控制最终想要的随机数字范围:
rand() % 上限(比如是10),那么就能获得 0 ~ 9 之间的随机数。

#include <stdio.h>
#include <stdlib.h>
#define N 10000

int a[N];

void gen_random(int upper_bound) {
    int i;
    for (i = 0; i < N; i++)
        a[i] = rand() % upper_bound;
    printf("%d\n", RAND_MAX);
    printf("%d", rand());
}

int main(void) {
    gen_random(10);
}

上面制造了 10000 个 0~9 之间的随机数并存入数组 a 中。

#号开头的行称为预处理指示(第20章会详细介绍),C 编译器实际上分为两个阶段:

  • 预处理阶段
  • 编译阶段

上方代码中的预处理器干了两件事:

  • include 是把其他头文件代码内联展开进来

  • 把 #define 定义的标识符 N 替换为实际的值 10000,代码中用到的 N 都会替换为 10000(RAND_MAX 也会被替换)

注意现在只是预处理阶段,还没进入正式的编译阶段。

define 定义不仅用于定义常量,还可以定义更复杂的语法结构,称为宏(Macro)定义。
define 定义是预处理阶段,而枚举是在编译阶段处理的。

使用 gcc -E 或者 cpp 可以获得预处理之后、编译之前的程序。

上面提到的 rand 是伪随机数,可以用 stand 函数指定 seed,然后再调用 rand 得到每次运行程序都不同的随机数,(下方的剪刀石头布小游戏有例子),strand(time(NULL));

字符串

C 语言中的字符串本质是数组,每个元素都是字符型。字符串末尾都有一个字符 ‘\0’ ,也就是 ASCII 码为 0 的 Null 字符。故字符串又称为 “以 Null 结尾的字符串”。

字符串可以像数组那样使用下标访问:

char c = "Hello, world.\n"[14]; /* 获取到的是 ASCII 字符 \0,即 Null */

字符串字面值所代表的存储空间是只读的,不能修改,所以下面是错误的:

"Hello, world.\n"[0] = 'A';

字符串和数组类型一样,做右值使用时自动转换为指向首元素的指针,因此 printf 函数的第一个参数是指针类型,所以 pinrtf("Hello, world.\n"); 看似传入了字符串,其实传的是指针。

字符数组可以使用字符串进行初始化,str 后面 4 个元素没有指定,自动初始化为 ‘\0’:

char str[10] = "Hello";
/* 实际存储情况如下 */
char str[10] = { 'H', 'e', 'l', 'l', 'o', '\0', '\0', '\0', '\0', '\0' };

注意,如果数组的长度和用来初始化的字符串长度不够匹配,字符串数组长度不够可能无法存入 ‘\0’ 字符了,这就有问题了,将来打印时可能会编译报错或导致一些潜在问题。
所以最好不指定字符数组长度,让编译器自己计算。

字符数组可以用 %s 来打印,数组会被转为字符串:

printf("string: %s\n", str);

嵌套数组

就像结构体可以嵌套一样,数组也能嵌套,称为多维数组,比如定义一个二维数组:

/* 三行两列,初始化时是按照一行一行的顺序进行的初始化 */
int a[3][2] = { 1, 2, 3, 4, 5 };

在物理模型中,这 6 个元素在存储空间中仍然是连续的,这种按行拼成串进行存储叫做 Row-major 方式,对应的有按 Column-major 方式存储的语言 FORTRAN。
image.png
多维数组也可以像嵌套体一样嵌套初始化:

/* 可能这样更能看出来是 Row-major */
int a[3][2] = { { 1, 2 }, { 3, 4}, { 5, } };

同样,多维数组也能按成员初始化:

int a[3][2] = { [0][1] = 9, [2][1] = 8 };

结构体和数组混合在一起的嵌套,也同样能通过 .后缀 结合 []下标来初始化或取值。

多维字符数组还可以嵌套使用字符串字面值做初始化:

char days[8][10] = { "", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" };

image.png
写代码最重要的是数据结构,控制流程和算法尚在其次。

剪刀石头布

scanf("%d", &man) 等待用户输入一个整数并回车,如果输入合法(输入了数字而不是其他字符),则 scanf 返回 1,表示成功读入一个数据并保存到整型变量 man 中,& 运算符作用是得到一个指针类型:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void) {
    char gesture[3][10] = { "scissor", "stone", "cloth" };
    int man, computer, result, ret;

    srand(time(NULL));
    while (1) {
        computer = rand() % 3;
        printf("\nInput your gesture (0-scissor 1-stone "
            "2-cloth):\n");
        ret = scanf("%d", &man);
        printf("ret = %d\n", ret);
        if (ret != 1 || man < 0 || man > 2) {
            printf("Invalid input!\n");
            return 1;
        }
        printf("You %s\tComputer: %s\n",
            gesture[man], gesture[computer]);
        result = (man - computer + 4) % 3 - 1;
        if (result > 0)
            printf("You win!\n");
        else if (result == 0)
            printf("Draw!\n");
        else
            printf("You lose!\n");
    }
    return 0;
}

9. 编码风格

以 Linux 内核风格为范例,讲讲它的 C 语言编码风格。

缩进和空白

  • if while for 后面加个空格。
  • , ; 后面加个空格,这是英文习惯。
  • 长字符串一行写不下,可以分两波写 “xxx” “hhh”,C 编译器会自动拼接相邻多个字符串。
  • 使用 Tab 缩进,不要用空格代替缩进,且 Tab 建议设置为 8个空格宽度,因为在标准字符终端上一个 Tab 看起来大概是 8 个空格的宽度。
  • case、default 和 switch 保持对齐,不进行缩进,只有下方的语句进行缩进。

注释

  • 单行注释使用 / comment /。
  • 整个源文件顶头注释,文件名、作者和版本历史等。

标识符命名

  • 变量、函数和类型采用全小写加下划线的方式命名:aaa_bbb,常量名则是 全大写加下划线:AAA_BBB
  • 越是作用域范围大的变量和函数,命名就越要详细,比如全局变量和全局函数。
  • 禁止使用汉语拼音(= =!)。

函数

  • 函数内部缩进不宜过多,少于 4 层为宜,缩进太多说明太复杂,应该对函数进行拆分。
  • 函数是一系列动作的抽象,因此函数名通常应包含动词。
  • 一个函数内部,5~10个局部变量就已经很多了,应考虑拆分函数。

使用 indent 工具可以格式化代码,比如 indent -kr -i8 main.c,K&B 风格,缩进八个空格的长度,符合 Linux 内核的编码风格(自行试验)。

10. gdb

GDB调试原理——ptrace系统调用 https://www.cnblogs.com/xsln/p/ptrace.html

gcc 编译时添加 -g 选,gcc -g main.c -o main 生成能用于源码级调试的可执行文件。

会在生成的可执行文件中加入源代码信息,第几行机器指令对应源代码第几行,并非嵌入整个源文件,所以调试时还须保证 gdb 能找到源文件。

gdb 会提供类似 Shell 的命令行交互环境。
gdb [可执行文件名] 进入交互环境,start 开始调试即可,来张图感受一下:
image.png
常用命令如下,不需要背,需要时查表即可,敲多了就顺手了。
回车键会默认执行上一次的命令。

单步执行和跟踪函数调用

image.png
断点是当程序执行到某一代码行时中断,而观察点是当程序访问某个存储单元时中断,如果我们不知道某个存储单元是在哪里被改动的,这时候观察点尤其有用。

断点

image.png

观察点

image.png
image.png

(gdb) watch inputs[5] 设置观察点,观察 inputs 数组第 5 个元素。
x 命令打印指定存储单元里保存的内容,后缀 7bx 是打印格式。
(gdb) x/7bx [某存储单元变量名]

段错误

Segmentation fault.
如果某个函数的局部变量发生访问越界,有可能并不会立即产生段错误,而是在函数返回时产生段错误。
关于段错误,后面再进一步研究。

MACOX 折腾

关于 mac 下调试可能会有问题,毕竟书上的代码只经过 linux 环境的验证。
经过了一番折腾,最后我还是选择使用 docker,随便起一个 linux 环境,-v 参数挂载宿主文件夹,即可无缝体验,容器里缺什么工具安装即可。

docker run -it -d --restart=always --name my-gcc -v /Users/peng/IdeaProjects/tmp/c-learn:/home/c-learn gcc
docker exec -it my-gcc bash

下面是 mac 折腾记录,留作备案:

值得一提的是,MACOS 下的 gcc 实际上是 clang,但表面使用是差不多的。
可以用 brew 安装原版 gcc,但装好后 gcc 这个名字被占用了,所以要么使用刚安装的 gcc-11 (11 是版本号),要么重新改一下 gcc 别名指向。

gdb调试程序函数名为问号,什么原因? xxxxx in ?? () http://bbs.chinaunix.net/thread-1823649-1-1.html http://www.bubuko.com/infodetail-1877415.html 其实就是3个原因:源代码和可执行程序版本不一致;没有符号表,这不只是-g加上就万能,还可能涉及到具体的编译选项比如-g2 -gdwarf-2,具体查看gcc编译选项;gdb版本比gcc版本老,有些内容无法解析。别无第四原因。

11. 排序与查找

插入排序、归并排序、线性查找、折半查找、算法的时间复杂度分析。

12. 栈与队列

堆栈:深度优先

一个简单的栈:

#include <stdio.h>

char stack[512];
int top = 0;

void push(char c)
{
    stack[top++] = c;
}

char pop(void)
{
    return stack[--top];
}

int is_empty(void)
{
    return top == 0;
}

int main(void)
{
    push('a');
    push('b');
    push('c');

    while (!is_empty())
        putchar(pop());
    putchar('\n');

    return 0;
}

DFS 解迷宫:

#include <stdio.h>

#define MAX_ROW 5
#define MAX_COL 5

struct point { int row, col; } stack[512];
int top = 0; /* 指向栈顶下一个 */

struct point predecessor[MAX_ROW][MAX_COL] = {
    {{-1, -2}, {-1, -1}, {-1, -1}, {-1, -1}, {-1, -1}},
    {{-1, -1}, {-1, -1}, {-1, -1}, {-1, -1}, {-1, -1}},
    {{-1, -1}, {-1, -1}, {-1, -1}, {-1, -1}, {-1, -1}},
    {{-1, -1}, {-1, -1}, {-1, -1}, {-1, -1}, {-1, -1}},
    {{-1, -1}, {-1, -1}, {-1, -1}, {-1, -1}, {-1, -4}},
};

void push(struct point p)
{
    stack[top++] = p;
}

struct point pop(void)
{
    return stack[--top];
}

int is_empty(void)
{
    return top == 0;
}

int maze[MAX_ROW][MAX_COL] = {
    0, 1, 0, 0, 0,
    0, 1, 0, 1, 0,
    0, 0, 0, 0, 0,
    0, 1, 1, 1, 0,
    0, 0, 0, 1, 0,
};

void print_maze(void)
{
    int i, j;
    for (i = 0; i < MAX_ROW; i++) {
        for (j = 0; j < MAX_COL; j++)
            printf("(%d, %d), ", predecessor[i][j].row, predecessor[i][j].col);
        putchar('\n');
    }
    printf("************\n");
}



int main(void)
{
    print_maze();

    return 0;
}

逆序打印:

#include <stdio.h>
#define LEN 3

char buf[LEN] = {'a', 'b', 'c'};

void print_backward(int pos)
{
    if (pos == LEN)
        return;
    print_backward(pos+1);
    putchar(buf[pos]); // 打印放在递归调用的后面,这样出栈的时候,先进后出特性,即可逆序打印
}

int main(void)
{
    print_backward(0);
    putchar('\n');

    return 0;
}

队列

广度优先,enqueue 入队列,dequeue 出队列。
环形队列。