Day 2
c程序的执行过程
源文件到可执行文件,打造顾客为两个步骤:
- 广义上的编译过程:
- 链接过程
预处理过程
预处理过程主要就两个作用:
- 执行预处理指令
- 丢弃代码中的注释,注释就不参与后续的任何过程;
#define 用以定义宏常量(符号常量)
其实就是给字面值常量起个名字,本质是文本替换,没有数据类型、取值范围的限制
注:
1.宏常量名字必须大写,如果有多个单词,下划线隔开
2.宏定义是没有分号的
预处理阶段会将代码中的宏常量进行文本替换
#define定义函数宏
// 定义了一个函数宏,用于求正方形的面积
#define SQUARE_AREA(length) ((length) * (length))
// 定义了一个函数宏,用于求两个数的平方差
#define SQUARE_DIFF(x, y) (((x) * (x)) - ((y) * (y)))
注:
1.宏函数名字全部大写 下划线隔开 见名知意
2.在写宏函数定义表达式时,要增加上必要的小括号(),增强表达式的可读性**<建议>**
3.在宏函数定义的表达式中,每一个参数都必须要用小括号括起来()
4..在宏函数定义的表达式中,整个表达式必须用()括起来
#include <stdio.h>
int main(void) {
// 调用函数宏
printf("边长是10的正方形,其面积是:%d\n", SQUARE_AREA(10));
printf("3和2的平方差是:%d\n", SQUARE_DIFF(3, 2));
return 0;
}
#inlcude
包含头文件
<>表示表示去标准库中寻找对应的头文件,也就是说使用C语言标准库头文件,使用<>
“ ”表示先去当前目录下寻找头文件,找不到再去找标准库
scanf函数
用于从键盘录入读取数据,也是两个单词的缩写:scan formatted
scanf(“%d”,&num)其中注意:变量名的前面需要加一个"&",这是必须的语法,不能忘记写。
然后需要注意的是 他表示将读取到的数据存入目标地址,所以它的参数后面不能直接写变量,而是需要写变量的地址,这一般需要添加取地址符号&
编译过程(狭义)
将预处理后的源代码转换成汇编语言,也就是将.i文件转换成.s文件
汇编过程
编译器完成编译后,**汇编器(Assembler)**会将这些汇编指令转换为目标代码(机器代码),生成了一个.o(或者.obj)
文件。从这一步开始,文件中的内容就不是程序员能够肉眼看懂的文本代码了,而是二进制代码指令。
所以汇编过程的主要作用就是将汇编语言代码转换成机器语言,即转换成机器可以执行的指令,也就是生成.o
文件。
在链接阶段,链接器(Linker)会把项目中,经过汇编过程生成的多个(至少有1个).o
文件和程序所需要的其它附加代码整合在一起,生成最终的可执行程序。比如你在代码中调用了标准库函数,那么链接器会将库中的代码包含到最终的可执行文件中。
链接过程
合并外部的代码
生成可在执行的文件
总结编译和链接
=========================================================
使用VS调试程序
编译错误和链接错误
编译错误(Compile-time Errors)出现在代码的编译阶段,表示编译失败。大多数的编译错误,实际上就是疏忽导致的语法错误,比如:
- 忘记在语句结尾加上分号。
- 小括号、中括号或大括号不匹配。
- 类型不匹配,尝试将一个字符串赋值给一个整数类型的变量等。
- 使用未声明的变量。
- …
链接错误(Linking Errors)出现在代码的链接阶段,表示链接失败。链接错误大多和函数调用有关,比如:
- 调用函数时,把函数的名字写错了。比如想调用printf函数,但是写成了print。
- 忘记包含头文件。比如使用printf函数但忘记写#include语句。
- 没有定义一个函数却使用它。
- …
调用一个函数,他有两个参数,但我只给一个,这属于语法错误,属于编译错误
格式化输入输出
输入输出模型
scanf和printf函数看起来一个是从键盘接收数据输入,一个是将数据输出打印到显示器,但计算机内部处理的过程却没有那么简单。
我们通过输入/输出模型来简单了解一下这两个函数大体上的执行原理。
冯诺依曼体系计算机(也叫存储程序控制型计算机),主要包含三大核心组件:CPU、IO设备以及存储器,而存储器当中最重要的则是内存储器,也就是内存。直到今天,硬件设备的发展日新月异,但现代计算机仍没有脱离此体系。
冯诺依曼体系计算机,一个核心问题是CPU、内存以及IO设备三者之间的速度差异从而导致的性能瓶颈,这就是常说的"冯·诺依曼瓶颈”。
具体的说,这个瓶颈指的是:
CPU 的处理速度远远快于内存和 I/O 设备,导致在等待数据处理和传输时,CPU 大部分时间处于空闲等待的状态。这种速度差异造成了显著的性能瓶颈,限制了整个系统的效率。
为了平衡这三者之间的速度鸿沟,一个简单有效的手段是引入缓冲区技术,下面我们简单介绍一下缓冲区技术。
缓冲区
缓冲区本质上是一块临时存储数据的内存区域(一般是在内存中分配的),它在速度较慢的内存和 I/O 设备与速度较快的 CPU 之间起到桥梁的作用。
printf函数和scanf函数:
- printf函数将程序(内存)中的数据打印到外部设备(显示器)上
- scanf函数代表从外部设备(键盘)中读取数据到程序(内存)中
这些都是非常典型的I/O操作过程。
为了更深入地解释缓冲的工作原理,以printf
和scanf
函数为例,可以更直观地解释其运作机制:
- 当你使用
printf
输出数据时,数据并不是立刻写入到输出设备(如屏幕)。它首先被放置在一个stdout缓冲区(标准输入流)中,然后在满足特定条件时,数据会被刷新到输出设备。 - 当你使用
scanf
输入数据时,数据也不是直接从输入设备(如键盘)读取的。它首先被加载到一个stdin缓冲区中,然后scanf
从这个缓冲区中获取数据。 - 不管是stdin还是stdout缓冲区都是内存的一部分。
我们都知道,I/O的过程效率很低。除了硬件性能本身的差异外,I/O操作的复杂性也是非常重要因素。每次进行I/O操作都会带来一些固定的开销,比如:
- 每次 I/O 操作都需要设备初始化和响应等待等。
- 操作系统管理 I/O 请求,涉及中断处理和上下文切换,这些都消耗了大量时间。
- 应用从用户态切换到内核态的系统调用也会带来额外的时间开销。(I/O操作普遍涉及系统调用)
- …
总之,如果每输入或输出一个字符都要进行一次完整的I/O操作,那么这些固定的开销就会迅速积累,导致性能显著下降。
硬件层面的效率低下,我们没有办法通过软件层面的优化去解决。但对于这些大量的固定开销,我们可以通过缓冲区来进行效率优化。
缓冲区的主要目的是暂时存储数据,然后在适当的时机一次性进行大量的I/O操作。这样,多个小的I/O请求可以被组合成一个大的请求,有效地分摊了固定开销,并显著提高了总体性能。
缓冲区的分类
从上述内容中,我们可以明确地看到缓冲区的一个显著特点:当满足特定的条件时,程序会开始对缓冲区内的数据执行输入或输出操作。
这种**“满足条件即触发数据传输”的行为,被我们称为“缓冲区的自动刷新”**机制。
基于这种自动刷新的触发条件的不同,我们可以将常见缓冲区划分为以下三种类型:
-
**全缓冲区,也叫满缓冲区。**顾名思义,仅当缓冲区达到容量上限时,缓冲区才会自动刷新,并开始处理数据。否则,数据会持续积累在缓冲区中直到缓冲区满触发自动刷新。文件操作的缓冲区便是这种类型的经典例子。
实际上所有缓冲区满了以后都会自动刷新
-
**行缓冲区。**缓冲区一旦遇到换行符,缓冲区就会自动刷新,所有数据都会被传输。stdin和stdout缓冲区都是行缓冲区。
-
**无缓冲区,不缓冲。**在此模式下,数据不经过中间的缓冲步骤,每次的输入或输出操作都会直接执行。这种方法适用于需要快速、实时响应的场合。例如,stderr(标准错误输出)就是这种方式,它经常被用来即时上报错误信息。
补充和注意事项:
关于缓冲区,有以下几点需要特别注意:
- 无论是哪种类型的缓冲区,当缓冲区满了时,都会触发自动刷新。
- 全缓冲区:唯一的自动刷新条件是缓冲区满。
- 行缓冲区:除了缓冲区满导致的自动刷新,还有遇到换行符的自动刷新机制。
- 手动刷新。大多数缓冲区提供了手动刷新的机制,比如使用fflush函数来刷新stdout缓冲区。
- 当程序执行完毕(如main函数返回)时,缓冲区通常会自动刷新,除此之外,还有一些独特的机制也可以刷新缓冲区。但这些机制可能因不同的编译器或平台而异,不能作为常规手段。强烈建议依赖手动或者常规自动刷新的机制来完成缓冲区的刷新。
- 不同的编译器和开发环境可能会对输出缓冲进行特殊设置,尤其是在调试模式下,以便提供更好的调试体验。比如在VS的Debug模式下,即使没有换行符,
printf
函数的输出通常也会立即显示在控制台上。这种行为是为了帮助程序员更有效地调试程序,即时看到他们的输出,而不需要固定等待缓冲区刷新条件。
至此,我们已经对输入输出的基本概念有了全面的了解。接下来,我们将深入探讨具体的函数如何使用。
当涉及到函数调用时,虽然查阅文档是重要的学习步骤,但真正的关键在于实践:亲自编写和执行代码。
printf函数
printf函数的核心作用是将各种类型的数据转换为字符形式并输出到stdout缓冲区中。
格式字符串包含两个主要部分:
- 普通字符,printf函数会将普通字符原封不动的进行显示。比如上面代码中的"i = , j = "。
- 转换说明,以字符% 开头,它为后续对应位置的表达式提供了一个占位符。在上述示例中,“%d"和”%f"就是转换说明。
理解转换说明的含义和用法是掌握printf函数的关键。
转换说明在printf
函数中起到了关键的角色,允许开发者对输出格式进行精细的控制。它主要有以下几个作用:
- 占位符的作用。
- 控制输出的格式,比如宽度,精度等。
- 指示被转换成字符数据的对应参数的类型。
系统的讲,转换说明的组成公式如下:
%[标志][字段宽度][.精度][长度]说明符
scanf函数
scanf函数的核心作用是从stdin缓冲区中读取字符形式的数据,并将其转换为特定类型的数据。
从实际效果看,scanf函数会根据格式字符串读取输入的内容,并将这些内容赋值给指定的变量。
调用scanf函数时,首要参数也是格式字符串,紧随其后的参数是变量的地址,表示将读取到的值存放在哪个位置。调用形式如下:
scanf(格式字符串, &变量1, &变量2, ...);
scanf函数的格式字符串中可能包含:
- 普通字符,比如空格和其他字符,scanf函数会期望输入中有与之匹配的字符。一般来说,格式字符串不需要普通字符。
- 转换说明,以字符"%" 开头,它告诉scanf函数应该如何解释输入中的数据并如何存储它。在上述示例中,“%d"和”%f"就是转换说明。
值得注意的是,scanf函数在调用时填入的变量前面要加符号"&",它是取地址运算符,意思是告诉scanf函数将数据存储到某个地址。它一般是必须的,但有些情况下可以省略。
scanf函数本质上是一个**“模式匹配"函数,试图把"stdin缓冲区”**中的字符与格式字符串匹配。
scanf函数会从左到右依次匹配格式字符串中的每一项:
- 如果匹配数据项成功,那么scanf函数会继续处理格式串的剩余部分;
- 如果匹配不成功,那么scanf函数将不再处理格式串的剩余部分,而会立刻返回。
除此之外,scanf函数的转换说明符大都默认忽略前置的空白字符,这样的设计让输入对用户更好友好,比如:
- %d: 忽略前置的空白字符 (包括空格符、水平和垂直制表符、换页符和换行符),然后匹配十进制的有符号整数。
- %f: 忽略前置的空白字符,然后匹配浮点数。
- …
练习,下列代码的执行结果是什么?
代码块 8. scanf函数-练习题1
int i, j;
float x, y;
scanf("%d%d%f%f", &i, &j, &x, &y);
分别键盘录入以下数据:
100 200 0.1 0.2
1-20.3-4.0e3
100a2000.10.2
1002000.10.2
结果是什么呢?为什么?
转换说明scanf的说明符和printf是不同的,i是可以自动匹配十进制 八进制 十六进制的整数,d只能匹配十进制
注意事项:
scanf 函数用 %c 格式化字符串来读取单个字符时,并不会跳过空白字符,%c 会读取输入的下一个字符,无论它是什么,包括空白字符。
所以在录入字符时,尤其是一行录入多个数据且包含输入字符时,一定要在转换说明前面留出一个空格,以匹配可能的空格:
char ch;
int num;
printf("请输入一个数字以及一个字符: ");
scanf("%d %c", &num, &ch); // 注意 %c 前的空格
printf("你输入的数字是: %d\n", num);
printf("你输入的字符是: %c\n", ch);
上述代码运行,键盘录入:
100 a
程序打印结果:
你输入的数字是: 100
你输入的字符是: a
普通字符
scanf函数的格式字符串串中也可以包含普通字符("%"之前的部分是普通字符),和printf函数不同的是,scanf函数的普通字符也用来表示匹配规则。
例如:
- 空白字符:对应输入中的任意数量的空白字符(如空格、制表符或换行)。
- 非空白字符:要求输入中精确地匹配该字符。(是什么字符就匹配什么,写几个该字符就匹配几个)
因此,scanf中的普通字符不仅仅是装饰或分隔,它们也参与到输入数据的匹配中,确保数据的格式正确。
int i, j;
scanf("%d/%d", &i, &j);
分别键盘录入以下数据:
5/ 96
5 / 96
结果是什么?
其它不变,把转换说明改完"%d /%d",再次录入数据,有区别吗?为什么?
基本数据类型
基本数据类型主要包括:整数类型,浮点数类型和字符类型,其中字符类型也可以看作是整数类型中的一种。
所以你可以认为C语言的基本数据类型只有两类:
- 整数类型
- 浮点数类型
sizeof
值的注意的是,我们经常在VS的监视窗口使用sizeof运算符。
整数
代码中最常见的十进制整数字面值,默认情况下是int类型的,如果它超出了 int 的表示范围,那么它的类型是 long 和 long long 中能表示该字面值的 “最小” 类型。
对八进制和十六进制整数字面值来说,整数的字面值首先尝试被视为 int
类型,如果超出 int
范围,依次尝试 unsigned int
、long
、unsigned long
、long long
和 unsigned long long
,直到找到可以容纳该字面值的最小类型。
所以在有必要的情况下,我们可以通过一系列的操作,使得整数字面值改变它的默认类型。比如:
如果要指明某个整数字面值为一个 long 类型,只需要在字面值最后面加字母 L (禁止加小写的L)
整数类型编码 (拓展)
整数类型分为两类:
- 无符号整数
- 将所有的二进制位都作为数值位,在内存中它的编码表示就是这个整数的二进制表示,非常简单。
- 取值范围就是[ 0, 2该整数类型位长-1],比如unsigned short一般占2个字节,位长是16,它的取值范围就是[0, 65535]
- 有符号整数,关于有符号整数在内存中的存储表示,可以直接参考文档补充_有符号整数。这部分内容属于《计算机组成原理》的一部分,建议每一位C程序员都要掌握。
有符号整数规则的总结
如果你实在懒得看文档,那么也请至少记住以下结论,会非常有用:
- 原码
- 其有效数字是该数绝对值的二进制表示。
- 最高位是符号位,如果是负数符号位是1,正数符号位是0。
- 有效数值和最高位之间如果有空隙,加0。
- 最高位是符号位,其它位都是数值位。
- 反码
- 正数的反码与原码相同。
- 负数的反码是对其原码逐位取反,但符号位除外。
- 补码
- 正数的补码与原码相同。
- 负数的补码是在其反码的基础上再加1。
计算机当中存储整数都是以补码的形式存储的,目的是为了统一加减法为加法,降低CPU设计的复杂性,提高性能。
补码有以下两个重要的特性(假设x是一个n位整数):
- x + (-x) = 1, 000…02进制补码(其中0一共有n个,高位的1溢出被舍掉) = 0
- x + (~x) = 111…12进制补码(其中1一共有n个) = -1
字符型
由于C语言自身char类型设计的局限性,尽量不要使用char类型去表示和操作非ASCII码表中的字符,这会带来很多麻烦。
C语言的char类型在绝大多数时候,可以直接作为整数类型进行处理。(当然,下面我们会讲不能作为整数处理的情况)
虽然字符可以直接作为整数处理,但还是尽量在处理字符时使用char类型,而不是滥用char类型,以至于写出一些很奇葩的代码:
'a' * 'b' / 'c'; /* 这个表达式的结果是96,但明显过于奇葩,不要这么用)
字符处理函数
首先,使用字符处理函数,需要包含头文件<ctype.h> 。
这些字符处理函数,又可以分为两大类:
- 检查字符类型函数
- 字符大小写转换函数
// 检查字符类型函数
int isalnum(int c); /* Is c alphanumeric? 检查字符c是否是一个字母或数字 */
int isalpha(int c); /* Is c alphabetic? 检查字符c是否是一个字母 */
int isblank(int c); /* 检查字符c是否是一个空白字符(只包括空格、制表)*/ int isspace(int c); /* 检查字符c是否是一个空白字符(包括空格、制表、换行、换页等)*/
int isdigit(int c); /* Is c a decimal digit? 检查字符c是否是一个十进制整数 */
int islower(int c); /* Is c a lower-case letter? 检查字符c是否是一个小写字母 */ int isupper(int c); /* Is c an upper-case letter? 检查字符c是否是一个大写字母 */
int ispunct(int c); /* Is c punctuation? 检查字符c是否是一个标点符号 */
int isxdigit(int c); /* Is c a hexadecimal digit? 检查字符c是否是一个十六进制数 */
// 字符大小写转换函数
int tolower(int c); // 如果字符 c 是大写字母,则转换为对应的小写字母;否则,返回原字符。
int toupper(int c); // 如果字符 c 是小写字母,则转换为对应的大写字母;否则,返回原字符。
这些函数都不要死记硬背,你只需记住C语言的标准库提供了这些功能就可以了。
到了实际需要用的时候,如果记不起具体的函数名和调用方式,也没有关系。只要你还大体上记得有这个函数能实现xx功能,就可以通过查询的方式很快找到并使用它。
读/写字符
总体上而言,对char类型字符数据进行读/写,我们更推荐使用以下两个标准库函数。即getchar和putchar函数!
putchar
函数用于将单个字符打印到显示器上,其函数的声明如下:
int putchar(int c);
其作用是向标准输出缓冲区(stdout)写入一个字符,并且直接返回该字符作为返回值,如果写入过程发生意外错误,该函数会返回EOF(End of File)。
这就意味着此字符会暂存缓冲区,直到缓冲区满、遇到换行符或者程序结束等场景时,缓冲区刷新,内容才会显示在屏幕上。
getchar
函数用于从键盘录入中读取读单个字符,其函数声明如下:
int getchar(void);
此函数调用不需要任何参数,它会从**标准输入缓冲区(stdin)**中读取一个字符,并且把读取到的字符作为返回值返回。如果已经读取到流(数据源)的末尾或者发生意外错误,此函数会返回EOF。
注意:
- getchar函数在读字符时,仍然不会自动跳过空白字符。
- putchar 和 getchar 函数是针对char类型字符处理专门优化的函数,它们的效率要高于 printf 和 scanf,处理char数据请优先使用它们。
一个重要的惯用法
在编程中,惯用法是指一种常用的编码模式或表达方式,类似汉语中的成语,是程序员前辈们的智慧结晶。惯用法往往代表了处理某种场景的最佳手段。
在C语言中,我们经常需要从流中读取数据,比如getchar函数就是从标准输入流(stdin, 缓冲区)中读取数据,为了确保将流中的数据读完,我们经常会使用以下惯用法:
int ch; // 使用int来存储getchar的返回值,因为它可能返回EOF
int count = 0; // 统计总字符数
while ((ch = getchar()) != '\n') { // 读取到流中的换行符就结束循环
count++;
printf("第%d次读到的字符是%c\n", count, ch);
}
printf("输入的字符一共有%d个\n", count);
注意:
- EOF在大多数平台下实际上就等于-1,但它并不是任何字符,只是一个表示流已经读到末尾的标记、特殊值。所以不应该用char类型作为getchar()函数的返回值,一般我们习惯使用int类型作为函数返回值。
ch = getchar()
赋值运算符组成的赋值表达式是有值的,就是"="右边的表达式的取值,也就是getchar()函数的返回值。- 在读文件流时,只需要把"!="的条件从换行符改成EOF,就是一个通过循环将文件数据读取完的惯用法。(后面文件流会讲)
实际上,不仅是C语言,C++和Java也有几乎一模一样的惯用法,只不过是结束读取流的条件不同。
//ch = getchar()
//主要作用:表达式要返回的值,这就是getchar的返回值
//副作用:赋值,改变了ch变量的取值
`
count++;
printf("第%d次读到的字符是%c\n", count, ch);
}
printf("输入的字符一共有%d个\n", count);
注意:
- EOF在大多数平台下实际上就等于-1,但它并不是任何字符,只是一个表示流已经读到末尾的标记、特殊值。所以不应该用char类型作为getchar()函数的返回值,一般我们习惯使用int类型作为函数返回值。
ch = getchar()
赋值运算符组成的赋值表达式是有值的,就是"="右边的表达式的取值,也就是getchar()函数的返回值。- 在读文件流时,只需要把"!="的条件从换行符改成EOF,就是一个通过循环将文件数据读取完的惯用法。(后面文件流会讲)
实际上,不仅是C语言,C++和Java也有几乎一模一样的惯用法,只不过是结束读取流的条件不同。
//ch = getchar()
//主要作用:表达式要返回的值,这就是getchar的返回值
//副作用:赋值,改变了ch变量的取值