C:PartⅠ
Zane Lv4

前言

这次的记录有两点,首先,对于我感兴趣的嵌入式Linux方向,这个是必不可少的。其次,C语言对我来说一直以来就像一块吃了无数次的蛋糕,这次带着虔诚的敬畏之心去寻找独一的“甜点师”。因此,这次不会设计过多的语法总结,而是对C语言的一些机制、编译、预处理器等进行学习。

每一个大标题我都会描述范围,而每一个小标题往往意味着一个问题一个我目前不太了解的细节,求解过程便是内容。

0. 计算机

0.0 计算机在做什么?

CPU进行大部分的运算工作,从内存中获取并执行一条指令,乐此不疲,常说的主频是3.4GHz的CPU,一秒钟CPU重复这样的操作大约三十四亿次。CPU也会有自己的小工作区——寄存器组成,每一个寄存器可以存储一个数字。一个寄存器存储下一条指令的内存地址,CPU使用该地址获取和更新下一条指令。由于是自己的小工作区,因此它的访问速度要比内存快的多得多,CPU获取指令后再另一个寄存器存储该指令,并更新第一个寄存器存储下一条指令的地址。

CPU能理解的指令有限(指令的集合便是熟知的概念指令集),这些指令相当具体,其中很多指令就是将一个数字从一个位置移动到另一个位置,比如从内存移动到寄存器。

计算机是以数字的形式(二进制)存储数字和字符(ASCII)。

希望计算机做某些事情,就必须提供特殊的指令列表(程序),必须使用计算机能直接明白的语言(机器语言)创建程序。比如:

两数相加

  • 从内存2000(举例而已)上把数字拷贝到寄存器1

  • 从内存位置2032(一个int型数字占用4Byte(32bit),刚好就是2032)拷贝另一个数字到寄存器2(当然用于相加的寄存器不会这么大,通常为8 16位,这里只是简化,但x86架构下也会有很多32 64位的寄存器,而对于普通情况下CPU会多次访问内存调取这个int数字)

  • 把两个不同寄存器的内容相加,结果存在寄存器1中

  • 把寄存器1的内容拷贝到内存位置2064

0.1 寄存器与缓存的区别?

虽然二者都是CPU用来高速访问的存储单元,但是两者在功能上并不相同。

通俗的讲寄存器是CPU为了运算的工作站,而cache是为了弥补CPU同内存速度上的差异设置的缓存。

  • 功能区别: CPU寄存器存储指令需要的数据,包括指令操作码、操作数、内存寄存器、地址等,是CPU内部最快的存储器件。而CPU缓存主要存储内存中的数据和指令,提升CPU对内存的访问速度,缓存数据和指令是有操作系统和硬件控制器自动管理的。

  • 存储容量区别:CPU寄存器容量很小,而缓存容量大的多,像前几年出现的AMD R7 5800X3D就是超大缓存处理器,因此在游戏方面它有更大的吞吐量,所以对于优化没那么好的游戏就能带来更好的游戏体验。

  • 访问速度区别:CPU寄存器访问速度非常快,可以在一个CPU时钟周期内完成读取或写入;而CPU缓存的速度介于寄存器与内存之间。

  • 访问方式区别:CPU寄存器是CPU内部的一部分,通过寄存器名称访问;而CPU缓存通常通过地址访问,需要使用地址映射以及缓存算法管理数据和指令。

1. 重识C

提一嘴,C语言是1972年,大名鼎鼎的贝尔实验室中的丹尼斯·里奇(Dennis Ritch)和肯·汤普逊(Ken Thompson)开发UNIX操作系统时在B语言(汤普逊发明)的基础上设计。

1.1 同样是高级语言为什么C语言在底层上的应用更好?

高效(直接操作内存):C是高效的语言,实际上,C拥有通常汇编语言才具有的为调控职能力(汇编语言是为特殊的中央处理单元设计的一系列内部指令,使用助记符表示;不同的CPU使用不同的汇编),可以根据具体情况微调程序获得更大的运行速度更有效的内存访问。

可移植性:移植是一件极其麻烦的事情,但是从8位微处理器到超级计算机,诸多计算机体系结构都可以使用C编译器(将C转化成计算机内部指令的程序)。

底层编程:C语言的语法以及结构与底层硬件和操作系统密切相关,使得开发人员更自由地进行调用硬件以及操作系统提供的接口。

编译器优化:C语言编译器能够对代码进行优化,生成高效的机器码,因此在处理大量数据、高性能计算、实时控制等领域有很广泛的应用。

1.2 如果需要C语言编程,应该怎么执行?

  • 定义程序目标

  • 设计程序

  • 编写代码

  • 编译

  • 运行

  • 测试调试

  • 维护

我往往会丢掉前两步,对于在本科的课设、示例代码,直接编写、运行的效率是最高的。一旦接触大的项目,会显得无从入手,这正是短缺了前两步的原因。

在动手写程序之前,要在脑中有清晰的思路。想要程序做什么首先明白自己想做什么,思考需要哪些信息,要进行哪些计算和控制,大致框架不涉及具体的语言。

在有了概念性的认识后,决定程序中如何表示数据,以及用什么方法处理数据。

1.3 C语言的语言标准都有什么?这些标准的用处是什么?

ANSI/ISO C标准 C89 C90

由ANSI建立的一个委员会开发。定义了C语言和C标准库。

C99

同样是上述委员会,在修订C标准后发布C99,同时,委员会的用意不是增加新特性,而是达成以下目标

  • 支持国际化编程。提供多种方法处理国际字符集;

  • 调整现有实践致力于解决明显的缺陷。C移到64位处理器时,委员会根据现实生活中处理问题的经验增加标准;

  • 适应科学和工程项目中的关键数值计算,提高C的适应力。

C99的修订保留了C语言的精髓,仍是精炼高效的语言,尽管便准已经是上世纪的产物,但并非所有编译器都完全实现了C99的改动,因地制宜,可能需要改变你的编译器设置才能用。

C11

2011年发布,强调,修订标准并非原标准不能用,而是跟进新的技术。如添加里可选项支持多处理器的计算机;

意义

  • 提供一种标准化语言规范:通过统一的语法和语义规范,不同的编译器可以产生相同的结果,这有助于提高代码的可移植性和可靠性。

  • 支持新的特性和功能:每个C语言标准都增加了一些新的特性和功能,如C99引入了可变长度数组和内联函数等特性,C11引入了多线程支持等特性,这些新特性和功能可以增强C语言的功能和灵活性。

  • 促进C语言的发展和应用:随着C语言不断发展,新的标准和特性的引入,使得C语言在不同领域和场景中得到了广泛的应用和推广。

  • 提高程序员的编码水平:遵循C语言的标准规范,可以培养程序员的规范化编码习惯和良好的代码风格,这有助于提高程序员的编码水平和代码质量。

总之,C语言的标准化有助于提高C语言的可移植性、可靠性和可维护性,推动C语言在不同领域和场景中的应用,同时也提高了程序员的编码水平和代码质量。

1.4 C语言编译器都做了什么?

高级语言优化了编程工作。首先不必用数字码表示指令;其次使用的指令更符合常识逻辑,而不是上边提到过的计算机计算的繁琐步骤;对我们来说符合逻辑、表现风格统一的高级语言程序对于计算机就是一堆无法理解的数据;编译器的作用就是把高级程序语言翻译成机器语言指令集的程序。

除此之外,不同CPU制造商使用的指令系统和编码格式是不同的。但是,可以通过找到与特定类型CPU匹配的编译器,对高级语言程序进行编译,翻译成不同CPU使用的机器语言。

总之,编译器通过将源代码转换为机器语言来将高级语言应用于不同的设备上。这涉及到词法分析、语法分析、语义分析、代码生成和优化等过程(这些就太过底层可能我会在学习《编译原理》的时候讲到这些)。最终生成的机器代码可以在不同的设备上运行。

1.5 什么是目标代码文件、可执行文件和库?

C将源码转化成可执行文件(包含可执行的机器语言代码)。典型的C实现通过编译链接的两个步骤完成这个过程,编译器把源码转换成中间代码,链接器将中间代码和其他代码进行合并,生成可执行文件。

中间文件有多种形式。在源代码转换成机器语言代码,并把结果放在目标代码文件中。虽然目标文件中包含机器语言代码,但不能直接执行,因为目标文件中存储的是编译器翻译的源代码,还不是完整程序。

  • 目标文件缺失启动代码(startup code)。充当程序与操作系统间的接口;

  • 目标文件还缺失库函数。几乎所有C程序都要使用C标准库中的函数;

链接器的作用是,将目标代码、系统的标准启动代码和库代码这3部分合并成一个文件,即可执行文件。对于库代码,链接器只会把程序中用到的库代码提取出来。如下图:

1.6 大名鼎鼎的GNU编译器集合与LLVM项目

我们熟知的gcc命令就可以调用GCC C编译器,便是隶属于GNU项目(GNU’s Not Uniix)中的,适用于不同硬件平台和操作系统。

LLVM项目同样也是最早的cc的另一个替代品,其中包含Clang编译器处理C代码。

省略了如Win Linux Mac下的编译器的不同;

2. 进阶C

0.1 不知道的#include 和头文件

往往这会是一个.c文件的开头,它的作用是将<>中的文件内容去拿不输入到该行所在的位置,通俗的将可以理解成是一种“拷贝-粘贴”的过程。

这行代码是C预处理器指令(preprocessor directive)。通常在编译器对源代码做的准备工作,即预处理(preprocessing)

包括 #define 与const 一个变量的区别就是处理的时间不同。

const类型限定符声明的时变量,不是常量。

通常,头文件包括了编译器创建最终可执行程序要用到的信息。比如上边提到的变量,或者指明函数名如何使用它们。只是,函数的实际代码在一个预编译代码的库文件中。

ANSI/ISO C规定了编译器必须提供哪些头文件。特定C实现的文档中应该包含对C库函数的说明。这些说明确定了那些函数需要哪些头文件。

相比较其他高级语言,C语言简洁到了本身不具备I/O(输入输出包)的地步,这正是使得C语言成为流行的嵌入式编程语言的原因之一。

0.2 不知道的main()函数

main()函数是程序的入口,是程序的起点。它的返回值类型是int,表示程序的执行结果。当程序执行完毕后,main()函数会将其返回值返回给操作系统。

操作系统会根据main()函数的返回值来确定程序的执行状态。一般来说,main()函数的返回值为0表示程序正常执行结束,非0的返回值则表示程序执行出现了错误或异常。这些错误或异常的具体含义可以由程序员自己定义和规定。

在实际编程中,main()函数的返回值可以被用于与其他程序交互,例如在shell脚本中可以通过判断main()函数的返回值来确定程序执行成功还是失败,以便执行后续操作。

另外需要注意的是,虽然在C语言中main()函数的返回值类型是int,但是如果程序不需要返回任何结果,可以在main()函数中直接使用return 0;来结束程序的执行。这种情况下,main()函数的返回值不会被操作系统使用。

细节

  • main()函数可以不带参数,也可以带两个参数:argcargv,这两个参数是命令行参数的数量和值的数组,用于在命令行中传递参数给程序。如果程序不需要命令行参数,可以省略这两个参数,即int main()

    其中,第1个参数通常是int类型的argc,其值是命令行的单词数量。第2个参数通常是一个指向数组的指针argv,数组内含指向char的指针。每个指向char的指针都指向一个命令行参数字符串,argv[0]指向命令名称,argv[1]指向第1个命令行参数,以此类推。

  • main()函数的返回值类型必须是int类型,但是main()函数的返回值不仅仅可以是整型值,还可以是枚举、指针等其他类型。这是因为在C语言中,任何类型都可以强制转换为整型,所以main()函数的返回值实际上是一个整型值。

  • main()函数可以调用其他函数,也可以被其他函数调用,不过这种情况比较少见。

  • main()函数的返回值应该是0或者一个正整数。如果main()函数返回负数,可能会导致意外的行为或者错误的程序执行结果。

  • main()函数的返回值只有一个,如果需要返回多个值,可以使用全局变量或者指针等其他方式来实现。

  • main()函数可以有多个return语句,但是只有一个return语句会被执行。通常情况下,main()函数的最后一个语句应该是return 0;,表示程序执行结束并且正常退出。

程序员在编写main()函数时应该注意以上细节,并且遵循C语言的规范和约定,这样才能写出高质量、稳定、可维护的程序。

1. 数据

1.1 声明与命名的细节

在C语言中,可以用小写字母、大写字母、数字和下划线(_)来命名,且第一个字符必须是字符或下划线不能是数字。

但需要注意的是,在操作系统以及C库中经常使用以一个或两个下划线字符开始的标识符,因此为避免名称冲突,程序员应尽量不写这种命名方式。

诸多高级语言包括远古时期的FORTRAN和BASIC都允许不声明直接使用变量,但在C中为了以下几点,故,需要声明。

  1. 变量声明在一个块的最前面,有助于理解查找程序中变量的作用,可读性更高。

  2. 在编写程序时,迫使程序员思考程序如何进行数据的存储,使用方式。

1.2 整型变量中的细节

抛出问题,假如有一个变量(variable)—weight,表示白金的重量通过用户输入获得,以及一个常量(constrant)—14.5833,表示每克白金的价格,那么相乘得到的结果是常量还是变量?

现实中,价格并不会是常量,但是在程序中这样的价格被视作常量。

在C语言中十六进制的前缀通常为0x,而八进制的前缀通常为0

十进制:%d; 十六进制:%x;八进制:%x;如果需要显示各进制的前缀需要如下:

                 (%#x) (%#o)

short long 关键字的用处是给变量更小的存储空间,适合较小或较大的值使用

unsigned只用于非负值的场合。会导致范围不同,比如16位的unsigned int允许的值为0-65535,而不是-32768-32767。

随着处理器的位宽逐步普及为64位,引入了long long关键字。

个人计算机上最常见的设置,long long占64位;long占32位;short占16位;int占16或32位(取决于计算机的字长)。

1.3 什么是可移植类型:stdint.h和inttypes.h

stdint.hinttypes.h 是 C 语言中的两个头文件,主要用于定义固定大小的整数类型和格式化输出和输入宏。其中,stdint.h 定义了 C99 标准引入的一些类型,如 int8_tuint16_tint32_t 等,这些类型都是固定大小的整数类型,其大小和精度是固定的,不受编译器和操作系统的影响。使用这些类型可以保证在不同平台上,整数类型的大小和行为都是一致的。而 inttypes.h 则提供了一些宏,用于在格式化输入和输出时,指定整数类型的长度和符号。比如,PRIu32 可以用于格式化输出一个 uint32_t 类型的无符号整数,SCNd64 可以用于格式化输入一个 int64_t 类型的有符号整数。这些宏的定义是由实现来决定的,可以确保在不同平台上,格式化输入和输出的行为都是一致的。因此,stdint.hinttypes.h 可以被看作是 C 语言中实现跨平台编程的重要工具之一。

1.4 double与float的区别是什么?

double(双精度),double与float的最小取值范围相同,但至少必须能表示10位有效数字。一般情况下,double占用64位而不是32位。一些系统将多出的32位用来表示非指数部分,这不仅增加了有效数字的位数,还较少了舍入误差。

float精确度为6到7位有效数字,而double的精确度为15到16位有效数字。这意味着在进行计算时,double类型的数据可以保留更多的有效数字,从而获得更高的精确度。

当然,由于double所占用的内存更大,因此在一些资源有限的环境下,可能会优先选择使用float类型。

1.5 复数与虚数类型怎么表示?

简而言之,C语言有3种复数类型:float_Comp1exdouble_Complexlong double_Complex
例如,float_Complex类型的变量应包含两个float类型的值,分别表示复数的实部和虚部。类似地,
C语言的3种虚数类型是float_Imaginarydouble_Imaginarylong double_Imaginary

如果使用complex.h头文件,便可用complex代替_Complex,用imaginary代替_Imaginary,可以用I代替-1平方根。

1.5 “x”与’x’的不同?字符串中strlen()与sizeof()

区别在于’x‘是基本类型(char),而”x“是派生类型(char数组);

同时,”x“实则由’x’以及空字符\0组成。

sizeof()相较于strlen()一个是存储单元的大小,一个是实际特定量的大小。

往往由于数组中有\0字符,因此在使用sizeof()时会导致结果上增一。

1.6 _Bool 类型

_Bool input_is_good;

1.7 结构体类型

初始化器

C99和C11提供了指定初始化器,其语法和数组的指定初始化器类似。但是,结构体的指定的初始化器使用点运算符和成员名(而不是方括号和下标)标识指定元素。如:

struct book surprise = { .value = 10.99 };可以按照任意顺序使用指定初始化器。

指向结构体的指针
  • 指向结构指针通常比结构本身更容易操作。

  • 早期C中,结构不能作为参数传递函数,但是可以传递指向结构的指针。

  • 传递指针效率更高。

  • 用以表示数据的结构中包含指向其他结构的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct guy{
struct names handle;
char favfood[LEN];
char job[LEN];
float income;
}; //定义一个结构体

struct guy fellow [2]= { //初始化结构体
{
//...
},
{
//...
}
}

struct guy * him; //声明一个结构体指针
him = &fellow[0]; //告诉编译器该指针指向何处
printf("him->income is ¥%.2f: (*him).income is $%.2f\n" \
,him->income,(*him).income);

指针him指向结构变量fellow[0],如何通过him获得fellow[0]的成员的值?

  • 使用->运算符/

    • 如果him == &barney,则him->income即是barney.income

    • 如果him == &fellow[0],则him->income即是fellow[0].income

通俗的讲就是,->运算后面的结构指针和.运算符后面的结构名工作佛那个是相同(不能协程him.income,因为him不是结构名)

  • 以这样的顺序指定结构成员的值:若him == &fellow[0],那么*him == fellow[0]。因此可以做以下替代:

    fellow[0].income == (*him).income,使用圆括号,因为.运算符的优先级高于*运算符。

结构体指针与结构体的选择?

指针作为参数有两个优点:无论是以前还是现在C都能使用这个方法,而且执行起来很快,只需要传递一个地址。缺点就是无法保护数据。

结构体作为参数的优点是,函数处理的是原始数据的副本,这保护了原始数据。

复合字面量和结构(C99)

复合字面量可以提供一个临时结构值,用以创建一个数组作为函数的参数或赋给另一个结构。

(struct bool) {"The Idiot", "Fyodor Dostoyevsky", 6.99}

伸缩型数组成员(C99)

利用该特性声明的结构,其最后一个数组成员具有一些特性;

  • 该数组不会立即存在

  • 使用伸缩性数组组成员可以编写合适的代码,如同它确实存在并具有所需数目的元素一样。

规则如下:

  • 伸缩性数组成员必须是结构的最后一个成员;

  • 结构中至少有一个成员;

  • 伸缩性数组的声明类似普通数组,只是方括号中是空的。

1
2
3
4
5
6
struct flex
{
int count;
double average;
double scores[];
}

声明一个struct flex类型的结构变量时,不能用scores做任何事情,因为没有过这个数组预留存储空间。

当我们声明一个指向struct flex类型的指针,并且使用malloc()函数分配足够的空间,以存储struct flex类型结构的常规内容和伸缩性数组所需的额外空间。假设,用scores表示一个内含5个double类型的数组。

1
2
3
4
5
struct flex *pf;
pf = malloc(sizeof(struct flex) + 5 * sizeof(double));
// 便可使用指针访问
pf->count = 5;
pf->scores[2] = 18.5; //访问数组成员的一个元素

这样做就实现了伸缩数组。

匿名结构(C11)

匿名结构就是一个没有名称的结构成员。

在使用嵌套结构时:

1
2
3
4
5
6
7
8
9
10
struct names
{
char first[20];
char last[20];
};
struct person
{
int id;
struct names name; //嵌套结构成员
}

若使用匿名结构我们可以在定义person结构时:

1
2
3
4
5
struct person
{
int id;
struct {char first[20]; char last[20];}; //匿名结构
}

初始化方式与常规相同:struct person ted = {8483, {"Ted", "Grass"};

1.8 联合类型

声明语法与结构体相似,但是它是在同一个内存空间存储不同的数据类型(不是同时存储)

典型用法:设计一种表以储存及即规律也事先不知道的顺序的混合类型

1
2
3
4
5
6
7
8
9
union hold{
int digit;
double bigfl;
char letter;
};
union hold fit;
fit.digit = 23; //把23储存在fit,占2字符
fit.bigfl = 2.0; //清除23,储存2.0占8字节
fit.letter = 'h'; //清除2.0,储存h,占1字节

利用指针访问结构->运算符一样,使用指针访问联合也要如此;

1
2
pu = &fit;
x = pu->digit; //相当于x = fit.digit

同样联合也拥有匿名联合,语法和使用与结构相似,省略。

1.9 枚举类型

声明符号名称来表示整型常量。使用enum关键字,创建一个新类型并指定它可具有的值。

1
2
enum spectrum {red, orange, yellow, green, blue, violet};
enum spectrum color;

解释: 第1个声明创建了spetrum作为标记名,允许把enum spetrum作为一个类型名使用。第2个声明使得color可能为以上的值。这些符号常量成为枚举符

用法:

1
2
3
4
5
6
int c;
color = blue;
if (color == yellow)
...;
for(color = red; color <= violet; color++)
...;

从技术层面上看,枚举符仍然是int类型,但是枚举变量可以是任意整型类型,前提是该整数类型可以存储枚举常量。

默认情况下,枚举列表中的常量被赋予0、1、2等。

1.10 typedef

仅仅利用typedef可以为某一类型自定义名称,与#define的区别:

  • typedef创建的符号名仅只限于类型,而define可以用于值

  • typedef由编译器解释,而非预处理器

  • 在其受限范围,前者更灵活

由 Hexo 驱动 & 主题 Keep