好奇的探索者,理性的思考者,踏实的行动者。
Table of Contents:
C语言除了能让你了解编程的相关概念,带你走进编程的大门,还能让你明白程序的运行原理,比如,计算机的各个部件是如何交互的,程序在内存中是一种怎样的状态,操作系统和用户程序之间有着怎样的“爱恨情仇”,这些底层知识决定了你的发展高度,也决定了你的职业生涯。
C语言概念少,词汇少,包含了基本的编程元素,后来的很多语言(C++、Java等)都参考了C语言,说C语言是现代编程语言的开山鼻祖毫不夸张。
C语言是计算机产业的核心语言
程序是在内存中运行的,一名合格的程序员必须了解内存,学习C语言是了解内存布局的最简单、最直接、最有效的途径,C语言简直是为内存而生的,它比任何一门编程语言都贴近内存。
所有的程序都在拼尽全力节省内存,都在不遗余力提高内存使用效率,计算机的整个发展过程都在围绕内存打转,不断地优化内存布局,以保证可以同时运行多个程序。
攻克内存后我竟然也能够理解进程和线程了,原来进程和线程也是围绕内存打转的,从一定程度上讲,它们的存在也是为了更加高效地利用内存(另一方面还有CPU)。
从C语言到内存,从内存到进程和线程,环环相扣:不学C语言就吃不透内存,不学内存就吃不透进程和线程。
很多领域都是「一年打基础,两年见成效,三年有突破」,但是很多人在不到一年的时间里就放弃了,总觉得这个行业太难,不太适合自己。
既然C语言能做这么多事情,为什么很多初学者学完C语言以后仍然非常迷茫,只能编写没有界面的控制台程序呢?
这是因为,C语言仅仅是一个工具,它的标准库也很简单,只提供了最基本的功能,如果希望开发出实用的程序,往往还需要学习其他方面的知识。例如:
* 开发硬件驱动要学习数字电路,了解 Windows 或 Linux 内核,阅读硬件厂商的接口说明书;
* 从事嵌入式开发要学习数字电路、模拟电路、ARM、Linux、Qt等;
* 开发PC软件要学习Windows编程,了解 GTK(GNU/Linux下开发图形界面的应用程序的主流开发工具之一)。
可以这么说,如果只会C语言,基本上是找不到工作的,读者要选定一个方向,继续学习其他知识。后面你会发现,C语言不过是冰山一角,是一项基本技能而已,程序员要学习的东西还很多。
字符集和字符编码不是一个概念,字符集定义了文字和二进制的对应关系,为字符分配了唯一的编号,而字符编码规定了如何将文字的编号存储到计算机中。
字符集为每个字符分配一个唯一的编号,类似于学生的学号,通过编号就能够找到对应的字符。
可以将字符集理解成一个很大的表格,它列出了所有字符和二进制的对应关系,计算机显示文字或者存储文字,就是一个查表的过程。
在 ASCII 编码中,大写字母、小写字母和阿拉伯数字都是连续分布的,这给程序设计带来了很大的方便。例如要判断一个字符是否是大写字母,就可以判断该字符的 ASCII 编码值是否在 65~90 的范围内
由于 ASCII 先入为主,已经使用了十来年了,现有的很多软件和文档都是基于 ASCII 的,所以后来的这些字符编码都是在 ASCII 基础上进行的扩展,它们都兼容 ASCII,以支持既有的软件和文档。
兼容 ASCII 的含义是,原来 ASCII 中已经包含的字符编码值不变,只是在这些字符的后面增添了新的字符。
为了达到「既能存储本国字符,又能节省内存」的目的,Shift-Jis、Big5、GB2312 等都采用变长编码方式:
原则是:越常用的字符占用的内存越少,越罕见的字符占用的内存越多。
* 对于原来的 ASCII 编码部分,用一个字节存储足以;
* 对于本国的常用字符(例如汉字、标点符号等),一般用两个字节存储;
* 对于偏远地区,或者极少使用的字符(例如藏文、蒙古文等),才使用三个甚至四个字节存储。
GB2312 --> GBK --> GB18030 是中文编码的三套方案,出现的时间从早到晚,收录的字符数目依次增加,并且向下兼容。GB2312 和 GBK 收录的字符数目较少,用 1~2个字节存储;GB18030 收录的字符最多,用1、2、4 个字节存储。
本节我们多次说 Unicode是一套字符集,而不是一套字符编码,它们之间究竟有什么区别呢?
严格来说,字符集和字符编码不是一个概念:
* 字符集定义了字符和二进制的对应关系,为每个字符分配了唯一的编号。可以将字符集理解成一个很大的表格,它列出了所有字符和二进制的对应关系,计算机显示文字或者存储文字,就是一个查表的过程。
* 而字符编码规定了如何将字符的编号存储到计算机中。如果使用了类似 GB2312 和 GBK 的变长存储方案(不同的字符占用的字节数不一样),那么为了区分一个字符到底使用了几个字节,就不能将字符的编号直接存储到计算机中,字符编号在存储之前必须要经过转换,在读取时还要再逆向转换一次,这套转换方案就叫做字符编码。
有的字符集在制定时就考虑到了编码的问题,是和编码结合在一起的,例如 ASCII、GB2312、GBK、BIG5 等,所以无论称作字符集还是字符编码都无所谓,也不好区分两者的概念。
而有的字符集只管制定字符的编号,至于怎么存储,那是字符编码的事情,Unicode 就是一个典型的例子,它只是定义了全球文字的唯一编号,我们还需要 UTF-8、UTF-16、UTF-32 这几种编码方案将 Unicode 存储到计算机中。
Unicode 可以使用的编码方案有三种,分别是:
* UTF-8:一种变长的编码方案,使用 1~6 个字节来存储; 兼容ANSI
* UTF-32:一种固定长度的编码方案,不管字符编号大小,始终使用 4 个字节来存储;
* UTF-16:介于 UTF-8 和 UTF-32 之间,使用 2 个或者 4 个字节来存储,长度既固定又可变。
UTF 是 Unicode Transformation Format 的缩写,意思是“Unicode转换格式”,后面的数字表明至少使用多少个比特位(Bit)来存储字符。
只有 UTF-8兼容 ASCII,UTF-32 和 UTF-16 都不兼容 ASCII,因为它们没有单字节编码。
Windows 内核、.NET Framework、Cocoa、Java String 内部采用的都是 UTF-16 编码。UTF-16 是幕后的功臣,我们在编辑源代码和文档时都是站在前台,所以一般感受不到,其实很多文本在后台处理时都已经转换成了 UTF-16 编码。
造成乱码的原因就是因为使用了错误的字符编码去解码字节流,因此当我们在思考任何跟文本显示有关的问题时,
请时刻保持清醒:当前使用的字符编码是什么
例如最常见的网页乱码问题。如果你是网站技术人员,遇到这样的问题,需要检查以下原因:
* 服务器返回的响应头Content-Type没有指明字符编码
* 网页内是否使用META HTTP-EQUIV标签指定了字符编码
* 网页文件本身存储时使用的字符编码和网页声明的字符编码是否一致
多字节字符(或窄字符 multibyte character): 采用 1~n 个字节存储,是变长的,例如 UTF-8、GB2312、GBK 等;
宽字符(wide character):编码方式是固定长度的,不管字符编号大小,始终采用 n 个字节存储,例如 UTF-32、UTF-16 等
多字节字符和宽字符(也就是wchar_t)的主要差异在于宽字符占用的字节数目都一样,而多字节字符的字节数目不等,这样的表示方式使得多字节字符串比宽字符串更难处理
Unicode 字符集可以使用窄字符的方式存储,也可以使用宽字符的方式存储;GB2312、GBK、Shift-JIS 等国家编码一般都使用窄字符的方式存储;ASCII 只有一个字节,无所谓窄字符和宽字符。
在计算机屏幕上,一个汉字要占两个英文字符的位置,人们把一个英文字符所占的位置称为“半角”,相对地把一个汉字所占的位置称为“全角”。
【wchar_t】
char是8位字符类型,最多只能包含256种字符,许多外文字符集所含的字符数目超过256个,char型无法表示。
wchar_t数据类型一般为16位或32位,但不同的C或C++库有不同的规定,如GNU Libc规定wchar_t为32位,总之,wchar_t所能表示的字符数远超char型。
【L"string"】
在字符串前加一个L作用: unicode字符集是两个字节组成的。L告示编译器使用两个字节的 unicode 字符集。
如 L"我的字符串" 表示将ANSI字符串转换成unicode的字符串,就是每个字符占用两个字节。
strlen("asd") = 3;
strlen(L"asd") = 6;
【_T
】
_T
宏可以把一个引号引起来的字符串,根据你的环境设置,使得编译器会根据编译目标环境选择合适的(Unicode还是ANSI)字符处理方式
如果你定义了UNICODE,那么_T宏会把字符串前面加一个L。这时 _T("ABCD")
相当于 L"ABCD"
,这是宽字符串,返回的类型是wchar_t.
如果没有定义,那么_T
宏不会在字符串前面加那个L,_T("ABCD") 就等价于 "ABCD"
如果你接触过unicode你就明白在许多API函数中字符串都需要宽字符的,也就是用两个字节来表示一个字符,这与ANSI字符不同,后者使用一个字节表示一个字符,字符串前加L,就是将ANSI字符转换成UNICODE字符。
增加了安全函数,例如 gets_s()、fopen_s() 等;
增加了 <threads.h> 头文件以支持多线程;
增加了 <uchar.h> 头文件以支持 Unicode 字符集;
以及其它一些细节。
数据类型可理解为创建变量的模具(模子);是固定内存大小的别名。
数据类型的作用:编译器 预算对象(变量)分配的内存空间大小
变量本质:(一段连续)内存空间的别名
1、程序通过变量来申请和命名内存空间 int a = 0
2、通过变量名访问内存空间
sizeof
是操作符,不是函数;sizeof测量的实体大小为编译期间就已确定
修改变量的3种方法:
* 1、直接
* 2、间接(指针)。内存有地址编号,拿到地址编号也可以修改内存;
* 3、c++ 引用
int a;创造了一个变量 a,我们把这个过程叫做变量定义。
a=123;把 123 交给了变量 a,我们把这个过程叫做给变量赋值;
又因为是第一次赋值,也称变量的初始化,或者赋初值。
数据类型用来说明数据的类型,确定了数据的解释方式,让计算机和程序员不会产生歧义
数据是放在内存中的,在内存中存取数据要明确三件事情:
1. 数据存储在哪里
2. 数据的长度
3. 数据的处理方式。
实际情况也确实如此,C语言并没有严格规定 short、int、long 的长度,只做了宽泛的限制:
* short 至少占用 2 个字节。
* int 建议为一个机器字长。32 位环境下机器字长为 4 字节,64 位环境下机器字长为 8 字节。
* short 的长度不能大于 int,long 的长度不能小于 int。
总结起来,它们的长度(所占字节数)关系为:
2 ≤ short ≤ int ≤ long
不像整数,小数没有那么多幺蛾子,小数的长度是固定的,float 始终占用4个字节,double 始终占用8个字节。
一个数字,是有默认类型的:对于整数,默认是 int 类型;对于小数,默认是double类型。
如果不想让数字使用默认的类型,那么可以给数字加上后缀,手动指明类型:
* 在整数后面紧跟 l 或者 L(不区分大小写)表明该数字是 long 类型;
* 在小数后面紧跟 f 或者 F(不区分大小写)表明该数字是 float 类型。
加上后缀,虽然数字的类型变了,但这并不意味着该数字只能赋值给指定的类型,它仍然能够赋值给其他的类型,只要进行了一下类型转换就可以了。
将一个小数赋值给整数类型,就得把小数部分丢掉,只能取整数部分,这会改变数字本来的值
由于将小数赋值给整数类型时会“失真”,所以编译器一般会给出警告,让大家引起注意
c++用列表初始化若精度有丢失的话,编译器会报错的。
小数在内存中是以浮点数的形式存储的。浮点数是数字在内存中的一种存储格式,它和定点数是相对的。
C语言使用定点格式存储整数,使用浮点格式存储小数,这是在“数值范围”和“数值精度”两项重要指标之间追求平衡的结果
浮点数和定点数中的“点”指的就是小数点!
用定点格式来存储小数,优点是精度高,因为所有的位都用来存储有效数字了,缺点是取值范围太小,不能表示很大或者很小的数字。
反面例子
在科学计算中,小数的取值范围很大,最大值和最小值的差距有上百个数量级,使用定点数来存储将变得非常困难。
例如,电子的质量为:
0.0000000000000000000000000009 克 = 9 × 10-28 克
太阳的质量为:
2000000000000000000000000000000000 克 = 2 × 1033 克
如果使用定点数,那么只能按照=前面的格式来存储,这将需要很大的一块内存,大到需要几十个字节。
更加科学的方案是按照=后面的指数形式来存储,这样不但节省内存,也非常直观。这种以指数的形式来存储小数的解决方案就叫做浮点数。浮点数是对定点数的升级和优化,克服了定点数取值范围太小的缺点。
C语言标准规定,小数在内存中以科学计数法的形式来存储,具体形式为:
flt = (-1)^sign × mantissa × base^exponent
base因其固定,可以省略
比如:19.625 = (-1)^0 x 1.9625 × 10^1
正确地存储中文字符需要解决两个问题。
1) 足够长的数据类型
char 只能处理 ASCII 编码中的英文字符,是因为 char 类型太短,只有一个字节,容纳不下几万汉字,要想处理中文字符,必须得使用更长的数据类型。
一个字符在存储之前会转换成它在字符集中的编号,而这样的编号是一个整数,所以我们可以用整数类型来存储一个字符,比如 unsigned short、unsigned int、unsigned long 等。
2) 选择包含中文的字符集
C语言规定,对于汉语、日语、韩语等 ASCII 编码之外的单个字符,也就是专门的字符类型,要使用宽字符的编码方式。常见的宽字符编码有 UTF-16 和 UTF-32,它们都是基于 Unicode 字符集的,能够支持全球的语言文化。
在真正实现时,微软编译器(内嵌于 Visual Studio 或者 Visual C++ 中)采用 UTF-16 编码,使用 2 个字节存储一个字符,用 unsigned short 类型就可以容纳。GCC、LLVM/Clang(内嵌于 Xcode 中)采用 UTF-32 编码,使用 4 个字节存储字符,用 unsigned int 类型就可以容纳。
编码字符集和运行字符集
站在专业的角度讲,源文件使用的字符集被称为编码字符集,也就是写代码的时候使用的字符集;
程序中的字符或者字符串使用的字符集被称为运行字符集,也就是程序运行后使用的字符集。
源文件需要保存到硬盘,或者在网络上传输,使用的编码要尽量节省存储空间,同时要方便跨国交流,所以一般使用 UTF-8,这就是选择编码字符集的标准。
程序中的字符或者字符串,在程序运行后必须被载入到内存,才能进行后续的处理,对于这些字符来说,要尽量选用能够提高处理速度的编码,例如 UTF-16 和 UTF-32 编码就能够快速定位(查找)字符。
std::string s = "你好"; // s.size() = 6
std::string s = "ab"; // s.size() = 2
char str1[] = "你好"; // sizeof(str1) = 7
char str2[] = "ab"; // sizeof(str1) = 3
转义字符以\或者\x开头,以\开头表示后跟八进制形式的编码值,以\x开头表示后跟十六进制形式的编码值。对于转义字符来说,只能使用八进制或者十六进制。
表达式必须有一个执行结果,这个结果必须是一个值,例如3*4+5
的结果 17,a=c=d=10的结果是 10,printf("hello")的结果是 5(printf 的返回值是成功打印的字符的个数)。
以分号;结束的往往称为语句,而不是表达式,例如3*4+5;
、a=c=d;
等。
当除数和被除数都是整数时,运算结果也是整数;如果不能整除,那么就直接丢掉小数部分,只保留整数部分,这跟将小数赋值给整数类型是一个道理。
一旦除数和被除数中有一个是小数,那么运算结果也是小数,并且是 double 类型的小数。
取余,也就是求余数,使用的运算符是 %。C语言中的取余运算只能针对整数,也就是说,% 的两边都必须是整数,不能出现小数,否则编译器会报错。
另外,余数可以是正数也可以是负数,由 % 左边的整数决定:
如果 % 左边是正数,那么余数也是正数;
如果 % 左边是负数,那么余数也是负数。
为了让编译器方便给变量分配内存,C89 标准规定,所有的局部变量(函数内部的变量)都必须定义在函数的开头位置,在定义完所有变量之前不能有其它的表达式。因为不方便,所以后来的 C99 标准就取消了这个限制。
一个变量,即使不给它赋值,它也会有一个默认的值,这个值就是默认初始值。
对于全局变量,它的默认初始值始终是 0,因为全局变量存储在内存分区中的全局数据区,这个区域中的数据在程序载入内存后会被初始化为 0。
而对于局部变量,C语言并没有规定它的默认初始值是什么,所以不同的编译器进行了不同的扩展,有的编译器会初始化为 0,有的编译器放任不管,爱是什么就是什么。
+、-、*、/、=
是双目运算符;
++、--
是单目运算符;
? :
是三目运算符(这是C语言里唯一的一个三目元算符)。
char str1[] = "http://c.biancheng.net";
char *str2 = "C语言中文网";
差别:
* 第一种形式的字符串所在的内存既有读取权限又有写入权限,
* 第二种形式的字符串所在的内存只有读取权限,没有写入权限。
printf()、puts() 等字符串输出函数只要求字符串有读取权限,而 scanf()
、gets()
等字符串输入函数要求字符串有写入权限,所以,第一种形式的字符串既可以用于输出函数又可以用于输入函数,而第二种形式的字符串只能用于输出函数。
根据清空缓冲区的时机,可以分为全缓冲、行缓冲、不带缓冲。
1) 全缓冲
在这种情况下,当缓冲区被填满以后才进行真正的输入输出操作。缓冲区的大小都有限制的,比如 1KB、4MB 等,数据量达到最大值时就清空缓冲区。
全缓冲的典型代表是对硬盘文件的读写。
在实际开发中,将数据写入文件后,打开文件并不能立即看到内容,只有清空缓冲区,或者关闭文件,或者关闭程序后,才能在文件中看到内容。这种现象,就是缓冲区在作怪。
2) 行缓冲
在这种情况下,当在输入或者输出的过程中遇到换行符时,才执行真正的输入输出操作。行缓冲的典型代表就是标准输入设备(也即键盘)和标准输出设备(也即显示器)。
对于 scanf(),不管用户输入多少内容,只要不按下回车键,就不进行真正的读取。这是因为 scanf() 是带有行缓冲的,用户输入的内容会先放入缓冲区,直到用户按下回车键,产生换行符\n,才会刷新缓冲区,进行真正的读取。
3) 不带缓冲
不带缓冲区,数据就没有地方缓存,必须立即进行输入输出。
getche()
、getch()
就不带缓冲区,输入一个字符后立即就执行了,根本不用按下回车键。
错误信息输出函数 perror() 也没有缓冲区。错误信息必须刻不容缓、立即、马上显示出来,缓冲区将会增加捕获错误的时间,这是毫无理由的。
1) 输入设备
scanf()、getchar()、gets() 就是从输入设备(键盘)上读取内容。对于输入设备,没有缓冲区将导致非常奇怪的行为,比如,我们本来想输入一个整数 947,没有缓冲区的话,输入 9 就立即读取了,根本没有机会输入 47,所以,没有输入缓冲区是不能接受的。Windows、Linux、Mac OS 在实现时都给输入设备带上了行缓冲,所以 scanf()、getchar()、gets() 在每个平台下的表现都一致。
但是在某些特殊情况下,我们又希望程序能够立即响应用户按键,例如在游戏中,用户按下方向键人物要立即转向,而且越快越好,这肯定就不能带有缓冲区了。Windows 下特有的 getche() 和 getch() 就是为这种特殊需求而设计的,它们都不带缓冲区。
2) 输出设备
printf()、puts()、putchar() 就是向输出设备(显示器)上显示内容。对于输出设备,有没有缓冲区其实影响没有那么大,顶多是晚一会看到内容,不会有功能性的障碍,所以 Windows 和 Linux、Mac OS 采用了不同的方案:
* Windows 平台下,输出设备是不带缓冲区的;
* Linux 和 Mac OS 平台下,输出设备带有行缓冲区。
缓冲区的刷新(清空)
所谓刷新缓冲区,就是将缓冲区中的内容送达到目的地。缓冲区的刷新遵循以下的规则:
* 不管是行缓冲还是全缓冲,缓冲区满时会自动刷新;
* 行缓冲遇到换行符\n时会刷新;
* 关闭文件时会刷新缓冲区;
* 程序关闭时一般也会刷新缓冲区,这个是由标准库来保障的;
* 使用特定的函数也可以手动刷新缓冲区。
对于输出操作,清空缓冲区会使得缓冲区中的所有数据立即显示到屏幕上;很明显,这些数据没有地方存放了,只能输出了。
对于输入操作,清空缓冲区就是丢弃残留字符,让程序直接等待用户输入,避免引发奇怪的行为。
fflush(stdout);
很遗憾地说,没有一种既简洁明了又适用于所有平台的清空输入缓冲区的方案。只有一种很蹩脚的方案能适用于所有平台,那就是将输入缓冲区中的数据都读取出来,但是却不使用。
1) 使用 getchar() 清空缓冲区
#include <stdio.h>
int main()
{
int a = 1, b = 2;
char c;
scanf("%d", &a);
while((c = getchar()) != '\n' && c != EOF); //在下次读取前清空缓冲区
scanf("%d", &b);
printf("a=%d, b=%d\n", a, b);
return 0;
}
2) 使用 scanf() 清空缓冲区
scanf() 还有一种高级用法,就是使用类似于正则表达式的通配符,这样它就可以读取所有的字符了,包括空格、换行符、制表符等空白符,不会再忽略它们了。并且,scanf() 还允许把读取到的数据直接丢弃,不用赋值给变量。
scanf("%*[^\n]"); scanf("%*c");
3) fflush(stdin)
C语言标准规定,当 fflush() 用于 stdout 时,必须要有清空输出缓冲区的作用;
但是C语言标准并没有规定 fflush() 用于 stdin 时的作用,编译器的实现者可以自由决定,所以它的行为是未定义的。
总之,fflush(stdin) 这种不标准的写法只适用于一部分编译器,通用性非常差,所以不建议使用。
里面没啥重点内容
我们可以通过下面的形式将数组的所有元素初始化为 0:
int nums[10] = {0};
char str[10] = {0};
float scores[10] = {0.0};
二维数组在概念上是二维的,但在内存中是连续存放的;换句话说,二维数组的各个元素是相互挨着的,彼此之间没有缝隙。那么,如何在线性内存中存放二维数组呢?有两种方式:
一种是按行排列, 即放完一行之后再放入第二行;
另一种是按列排列, 即放完一列之后再放入第二列。
在C语言中,二维数组是按行排列的。也就是先存放 a[0] 行,再存放 a[1] 行,最后存放 a[2] 行;每行中的 4 个元素也是依次存放。
你可以这样认为,二维数组是由多个长度相同的一维数组构成的。
字符数组只有在定义时才能将整个字符串一次性地赋值给它,一旦定义完了,就只能一个字符一个字符地赋值了。请看下面的例子:
char str[7];
str = "abc123"; //错误
//正确
str[0] = 'a'; str[1] = 'b'; str[2] = 'c';
scanf()
读取字符串时以空格为分隔,遇到空格就认为当前字符串结束了,所以无法读取含有空格的字符串。
gets()
认为空格也是字符串的一部分,只有遇到回车键时才认为字符串输入结束。换句话说,gets() 用来读取一整行字符串。
对于单个源文件的程序,通常是将函数定义放到 main() 的后面,将函数声明放到 main() 的前面,这样就使得代码结构清晰明了,主次分明。
变量的使用遵循就近原则,如果在当前的局部作用域中找到了同名变量,就不会再去更大的全局作用域中查找。另外,只能从小的作用域向大的作用域中去寻找变量,而不能反过来,使用更小的作用域中的变量。
C语言允许在代码块内部定义变量,这样的变量具有块级作用域;在代码块内部定义的变量只能在代码块内部使用,出了代码块就无效了。
递归的条件
要想让递归函数逐层进入再逐层退出,需要解决两个方面的问题:
* 存在限制条件,当符合这个条件时递归便不再继续。对于 factorial(),当形参 n 等于 0 或 1 时,递归就结束了。
* 每次递归调用之后越来越接近这个限制条件。对于 factorial(),每次递归调用的实参为 n - 1,这会使得形参 n 的值逐渐减小,越来越趋近于 1 或 0。
long factorial(int n) {
if (n == 1) {
return 1;
}
else {
return factorial(n - 1) * n; // 递归调用
}
}
factorial() 是最简单的一种递归形式——尾递归,也就是递归调用位于函数体的结尾处。除了尾递归,还有更加烧脑的两种递归形式,分别是中间递归和多层递归:
中间递归:发生递归调用的位置在函数体的中间;
多层递归:在一个函数里面多次调用自己。
菲波那契数就是一个数列,数列中每个数的值就是它前面两个数的和
0 1 1 2 3 5 8 13
long fib(int n) {
if (n <= 2) {
return 1;
}
else {
return fib(n - 1) + fib(n - 2);
}
}
这种递归的复杂度是指数级别增长的
对每个线程来说,栈能使用的内存是有限的,一般是 1M~8M,这在编译时就已经决定了,程序运行期间不能再改变。如果程序使用的栈内存超出最大值,就会发生栈溢出(Stack Overflow)错误。
栈内存的大小和编译器有关,编译器会为栈内存指定一个最大值,在 VC/VS 下,默认是 1M,在 C-Free 下,默认是 2M,在 Linux GCC 下,默认是 8M
。当然,我们也可以通过参数来修改栈内存的大小。
从整体上看,C语言代码是由一个一个的函数构成的,除了定义和说明类的语句(例如变量定义、宏定义、类型定义等)可以放在函数外面,所有具有运算或逻辑处理能力的语句(例如加减乘除、if else、for、函数调用等)都要放在函数内部。
#include <stdio.h>
int a = 10;
int b = a + 20; //错误 int b = a + 20;是具有运算功能的语句,要放在函数内部。我在gcc中试了一下,这样是没问题的。
int main(){
return 0;
}
较之其他编程语言,C/C++ 语言更依赖预处理器。#
号开头的命令称为预处理命令。
编译器会将预处理的结果保存到和源文件同名的.i文件中,例如 main.c 的预处理结果在 main.i 中
使用尖括号< >和双引号" "的区别在于头文件的搜索路径不同:
* 使用尖括号< >,编译器会到系统路径下查找头文件;
* 而使用双引号" ",编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。
个人的习惯是使用尖括号来引入标准头文件,使用双引号来引入自定义头文件(自己编写的头文件),这样一眼就能看出头文件的区别。
「在头文件中定义定义函数和全局变量」这种认知是原则性的错误!不管是标准头文件,还是自定义头文件,都只能包含变量和函数的声明,不能包含定义,否则在多次引入时会引起重复定义错误。
对 #define
用法的几点说明
* 1) 宏定义是用宏名来表示一个字符串,在宏展开时又以该字符串取代宏名,这只是一种简单粗暴的替换。字符串中可以含任何字符,预处理程序对它不作任何检查,如有错误,只能在编译已被宏展开后的源程序时发现。
* 2) 宏定义不是说明或语句,在行末不必加分号,如加上分号则连分号也一起替换。
* 3) 宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束。如要终止其作用域可使用#undef命令。
* 4) 代码中的宏名如果被引号包围,那么预处理程序不对其作宏代替
* 5) 宏定义允许嵌套
宏定义只是简单的字符串替换,由预处理器来处理;
而 typedef 是在编译阶段由编译器处理的,它并不是简单的字符串替换,而给原有的数据类型起一个新的名字,将它作为一种新的数据类型。
对带参数的宏,在展开过程中不仅要进行字符串替换,还要用实参去替换形参。
对带参宏定义的说明:
* 1) 带参宏定义中,形参之间可以出现空格,但是宏名和形参列表之间不能有空格出现。
* 2) 在带参宏定义中,不会为形式参数分配内存,因此不必指明数据类型。而在宏调用中,实参包含了具体的数据,要用它们去替换形参,因此实参必须要指明数据类型。
* 3) 在宏定义中,字符串内的形参通常要用括号括起来以避免出错。例如上面的宏定义中 (y)(y) 表达式的 y 都用括号括起来,因此结果是正确的。
4) 对于带参宏定义不仅要在参数两侧加括号,还应该在整个字符串外加括号
带参数的宏和函数很相似,但有本质上的区别:宏展开仅仅是字符串的替换,不会对表达式进行计算;宏在编译之前就被处理掉了,它没有机会参与编译,也不会占用内存。而函数是一段可以重复使用的代码,会被编译,会给它分配内存,每次调用函数,就是执行这块内存中的代码。
带参数的宏也可以用来定义多个语句,在宏调用时,把这些语句又替换到源程序中,请看下面的例子:
#define SSSV(s1, s2, s3, v) s1 = length * width; s2 = length * height; s3 = width * height; v = width * length * height;
#
的用法
#
用来将宏参数转换为字符串,也就是在宏参数的开头和末尾添加引号。例如有如下宏定义:
#define STR(s) #s
printf("%s", STR(c.biancheng.net)); //展开为 printf("%s", "c.biancheng.net");
##
的用法
##
称为连接符,用来将宏参数或其他的串连接起来。例如有如下的宏定义:
#define CON2(a, b) a##b##00
printf("%d\n", CON2(12, 34)); //展开为: printf("%d\n", 123400);
ANSI C 规定了以下几个预定义宏,它们在各个编译器下都可以使用:
* __LINE__
:表示当前源代码的行号;
* __FILE__
:表示当前源文件的名称;
* __DATE__
:表示当前的编译日期;
* __TIME__
:表示当前的编译时间;
* __STDC__
:当要求程序严格遵循ANSI C标准时该标识被赋值为1;
* __cplusplus
:当编写C++程序时该标识符被定义。
#if
、##ifdef
、#ifndef
的用法详解,C语言条件编译详解Windows 有专有的宏_WIN32
,Linux 有专有的宏__linux__
#if
、#elif
、#else
和 #endif
这种能够根据不同情况编译不同代码、产生不同目标文件的机制,称为条件编译。
#if
后面跟的是“整型常量表达式”,而 #ifdef
和 #ifndef
后面跟的只能是一个宏名,不能是其他的。
比如:
#if NUM == 10 || NUM == 20
printf("NUM: %d\n", NUM);
#else
printf("NUM Error\n");
#endif
VS/VC 有两种编译模式,Debug 和 Release。当以 Debug 模式编译程序时,宏 _DEBUG
会被定义。
#error
命令,阻止程序编译#error
指令用于在编译期间产生错误信息,并阻止程序的编译
例如,我们的程序针对 Linux 编写,不保证兼容 Windows,那么可以这样做:
#ifdef WIN32
#error This programme cannot compile at Windows Platform
#endif
再如,当我们希望以 C++ 的方式来编译程序时,可以这样做:
#ifndef __cplusplus
#error 当前程序必须以C++方式编译
#endif
需要注意的是,结构体是一种自定义的数据类型,是创建变量的模板,不占用内存空间;
结构体变量才包含了实实在在的数据,需要内存空间来存储。
知识点有:结构体数组、结构体指针、结构体指针作为函数参数
枚举和宏其实非常类似:宏在预处理阶段将名字替换成对应的值,枚举在编译阶段将名字替换成对应的值。
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun }; //默认从0开始
int main(){
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun } day;
scanf("%d", &day);
switch(day){
case Mon: puts("Monday"); break;
case Tues: puts("Tuesday"); break;
case Wed: puts("Wednesday"); break;
case Thurs: puts("Thursday"); break;
case Fri: puts("Friday"); break;
case Sat: puts("Saturday"); break;
case Sun: puts("Sunday"); break;
default: puts("Error!");
}
return 0;
}
Mon、Tues、Wed 这些名字在编译的时候都被替换成了对应的数字。这意味着,Mon、Tues、Wed 等都不是变量,它们不占用数据区(常量区、全局数据区、栈区和堆区)的内存,而是直接被编译到命令里面,放到代码区。这就是枚举的本质。
结构体和union的区别在于:结构体的各个成员会占用不同的内存,互相之间没有影响;而共用体的所有成员占用同一段内存,修改一个成员会影响其余所有成员。
结构体占用的内存大于等于所有成员占用的内存的总和(成员之间可能会存在缝隙),共用体占用的内存等于最长的成员占用的内存。共用体使用了内存覆盖技术,同一时刻只能保存一个成员的值,如果对新的成员赋值,就会把原来成员的值覆盖掉。
一个经典的实例是:
//这个结构可以同时记录老师和学生的信息,通过profession字段来判断类型,然后再根据类型去sc union中的字段分别取值
struct{
char name[20];
int num;
char sex;
char profession;
union{
float score;
char course[20];
} sc; } ;
若内存从左到右变大,那么大端的1234的内存就是1234
大端和小端是指数据在内存中的存储模式,它由 CPU 决定:
大端模式(Big-endian):低地址存高位
小端模式(Little-endian):低地址存低位
一般网络协议都采用大端模式进行传输,windows操作系统采用 Utf-16小端模式。
#include <stdio.h>
int main(){
union{
int n;
char ch;
} data;
data.n = 0x00000001; //也可以直接写作 data.n = 1;
if(data.ch == 1){
printf("Little-endian\n");
}else{
printf("Big-endian\n");
}
return 0;
}
共用体的各个成员是共用一段内存的。1 是数据的低位,如果 1 被存储在 data 的低字节,就是小端模式,这个时候 data.ch 的值也是 1。如果 1 被存储在 data 的高字节,就是大端模式,这个时候 data.ch 的值就是 0。
有些数据在存储时并不需要占用一个完整的字节,只需要占用一个或几个二进制位即可。
在结构体定义时,我们可以指定某个成员变量所占用的二进制位数(Bit),这就是位域
struct bs{
unsigned m: 6; //占用 6 位(Bit)
unsigned n: 12; //占用 12 位(Bit)
unsigned p: 4; //占用 4 位(Bit)
};
printf("%d\n", sizeof(struct bs)); //4 之所以为 4,而不是 3,是因为要将内存对齐到 4 个字节,以便提高存取效率
C语言标准并没有规定位域的具体存储方式,不同的编译器有不同的实现,但它们都尽量压缩存储空间。
位域的具体存储规则如下:
* 1) 当相邻成员的类型相同时,如果它们的位宽之和小于类型的 sizeof 大小,那么后面的成员紧邻前一个成员存储,直到不能容纳为止;如果它们的位宽之和大于类型的 sizeof 大小,那么后面的成员将从新的存储单元开始,其偏移量为类型大小的整数倍。
* 2) 当相邻成员的类型不同时,不同的编译器有不同的实现方案,GCC 会压缩存储,而 VC/VS 不会。
* 3) 如果成员之间穿插着非位域成员,那么不会进行压缩。
* 4) 使用&获取位域成员的地址是没有意义的,C语言也禁止这样做。地址是字节(Byte)的编号,而不是位(Bit)的编号
无名位域
struct bs{
int m: 12;
int : 20; //该位域成员不能使用
int n: 4;
};
无名位域一般用来作填充或者调整成员位置。因为没有名称,无名位域不能使用。
a==b
,然后在下面选择 为 true ,然后点击确定即可。所谓声明(Declaration),就是告诉编译器我要使用这个变量或函数,你现在没有找到它的定义不要紧,请不要报错,稍后我会把定义补上。
1) 函数的声明
函数的定义有函数体,函数的声明没有函数体,编译器很容易区分定义和声明,所以对于函数声明来说,有没有 extern 都是一样的。
2) 变量的声明
变量和函数不同,编译器只能根据 extern 来区分,有 extern 才是声明,没有 extern 就是定义。
变量的定义有两种形式,你可以在定义的同时初始化,也可以不初始化:
datatype name = value;
datatype name;
而变量的声明只有一种形式,就是使用 extern 关键字:
extern datatype name;
另外,变量也可以在声明的同时初始化,格式为:
extern datatype name = value;
这种似是而非的方式是不被推荐的,有的编译器也会给出警告,我们不再深入讨论,也建议各位读者把定义和声明分开,尽量不要这样写。
从源代码生成可执行文件可以分为四个步骤,分别是预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。
汇编的过程非常简单,仅仅是查表翻译,我们通常把它作为编译过程的一部分,不再单独提及。这样,源文件经过预处理、编译和链接就生成了可执行文件。
从文件结构上来讲,目标文件已经是二进制文件,它与可执行文件的组织形式非常类似,只是有些变量和函数的地址还未确定,程序不能执行。链接的一个重要作用就是找到这些变量和函数的地址。
另外需要明确的是:编译是针对单个源文件的,有几个源文件就会生成几个目标文件,并且在生成过程中不受其他源文件的影响。也就是说,不管当前工程中有多少个源文件,编译器每次只编译一个源文件、生成一个目标文件。
现在PC平台上流行的可执行文件格式主要是 Windows 下的 PE(Portable Executable)和 Linux 下的 ELF(Executable Linkable Format),它们都是 COFF(Common File Format)格式的变种。
从广义上讲,目标文件与可执行文件的存储格式几乎是一样的,我们可以将它们看成是同一种类型的文件,在 Windows 下,将它们统称为 PE 文件,在 Linux 下,将它们统称为 ELF文件。
另外,动态链接库(DLL,Dynamic Linking Library)(Windows 下的.dll和 Linux 下的.so)和静态链接库(Static Linking Library)(Windows 下的.lib和 Linux 下的.a)也是按照可执行文件的格式存储的。
站在文件结构的角度,可执行文件包含了众多的段(Section),每个段都有不同的作用;
站在加载和执行的角度,所有的段都是数据,操作系统只关心数据的权限,只要把相同权限的数据加载到同一个内存区域,程序就能正确执行。
常见的数据权限无外乎三种:
* 只读(例如 .rodata 只读数据段)、
* 读写(例如 .data 数据段)、
* 读取和执行(例如 .text 代码段)
我们将一块连续的、具有相同权限的数据称为一个 Segment,一个 Segment 由多个权限相同的 Section 构成。
不巧的是,“Segment”也被翻译为“段”,但这里的段(Segment)是针对加载和执行的过程。
在目标文件中,段表(Section Table)用来描述各个 Section 的信息,包括它的名字、长度、在文件中的偏移、读写权限等,通过段表可以详细地了解目标文件的结构。
而在可执行文件中,段表被删除了,取代它的是程序头表(Program Header Table);
程序头表用来描述各个 Segment 的信息,包括它的类型、偏移、在进程虚拟地址空间中的起始地址、物理装载地址、长度、权限等。操作系统就是根据程序头表将可执行文件加载到内存,并为各个 Segment 分配内存空间、确定起止地址。
也就是说,可执行文件不再关注具体的文件结构,而是关注程序的加载和执行过程。
由于可执行文件在加载时实际上是被映射的虚拟地址空间,所以可执行文件很多时候又被叫做映像文件(Image)。
编译器生成的是目标文件,而我们最终需要的是可执行文件,链接(Linking)的作用就是将多个目标文件合并成一个可执行文件
在链接过程中,链接器会将多个目标文件中的代码段、数据段、调试信息等合并成可执行文件中的一个段。段的合并仅仅是一个简单的叠加过程
数据是保存在内存中的,对于计算机硬件来说,必须知道它的地址才能使用。变量名、函数名等仅仅是地址的一种助记符,目的是在编程时更加方便地使用数据,当源文件被编译成可执行文件后,这些标识符都不存在了,它们被替换成了数据的地址。
编译器和链接器的一项重要任务就是将助记符替换成地址。
符号(Symbol)这个概念随着汇编语言的普及被广泛接受,它用来表示一个地址,这个地址可能是一段子程序(后来发展为函数)的起始地址,也可以是一个变量的地址。
现代软件的规模往往都很大,动辄数百万行代码,程序员需要把它们分散到成百上千个模块中。这些模块之间相互依赖又相互独立,原则上每个模块都可以单独开发、编译、测试,改变一个模块中的代码不需要编译整个程序。
在C语言中,一个模块可以认为是一个源文件(.c 文件)。
在程序被分隔成多个模块后,需要解决的一个重要问题是如何将这些模块组合成一个单一的可执行程序。在C语言中,模块之间的依赖关系主要有两种:一种是模块间的函数调用,另外一种是模块间的变量访问。
函数调用需要知道函数的首地址,变量访问需要知道变量的地址,所以这两种方式可以归结为一种,那就是模块间的符号引用。
这种通过符号将多个模块拼接为一个独立的程序的过程就叫做链接(Linking)。
链接(Linking)就是通过符号将各个模块组合成一个独立的程序的过程。
链接的主要内容就是把各个模块之间的相互引用部分处理好,使得各个模块能够正确地衔接。原理无非是找到符号的地址,或者把指令中使用到的地址加以修正。这个过程称为符号决议(Symbol Resolution)或者重定位(Relocation)。
有了链接器,我们可以直接调用其他模块中的函数而无需知道它们的地址,因为在链接的时候,链接器会根据符号 func 自动去 module.c 模块查找 func 的地址,然后将 main.c 模块中所有使用到 func 的指令重新修正,让它们的目标地址成为真正的 func() 函数的地址。
这种在程序运行之前确定符号地址的过程叫做静态链接(Static Linking);
如果需要等到程序运行期间再确定符号地址,就叫做动态链接(Dynamic Linking)。
在C语言中,编译器默认函数和初始化了的全局变量为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol)
强符号之所以强,是因为它们拥有确切的数据,变量有值,函数有函数体;弱符号之所以弱,是因为它们还未被初始化,没有确切的数据。
链接器会按照如下的规则处理被多次定义的强符号和弱符号:
1) 不允许强符号被多次定义,也即不同的目标文件中不能有同名的强符号;如果有多个强符号,那么链接器会报符号重复定义错误。
2) 如果一个符号在某个目标文件中是强符号,在其他文件中是弱符号,那么选择强符号。
3) 如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个。
在 GCC 中,可以通过__attribute__((weak))
来强制定义任何一个符号为弱符号。
需要注意的是,__attribute__((weak))
只对链接器有效,对编译器不起作用,编译器不区分强符号和弱符号,
只要在一个源文件中定义两个相同的符号,不管它们是强是弱,都会报“重复定义”错误。
弱符号对于库来说十分有用,我们在开发库时,可以将某些符号定义为弱符号,这样就能够被用户定义的强符号覆盖,从而使得程序可以使用自定义版本的函数,增加了很大的灵活性。
目前我们所看到的符号引用,在所有目标文件被链接成可执行文件时,它们的地址都要被找到,如果没有符号定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)。
与之相对应的还有一种弱引用(Weak Reference),如果符号有定义,就使用它对应的地址,如果没有定义,也不报错。
链接器处理强引用和弱引用的过程几乎是一样的,只是对于未定义的弱引用,链接器不认为它是一个错误,一般默认其为 0(地址为 0),或者是一个特殊的值,以便程序代码能够识别。
在变量声明或函数声明的前面加上__attribute__((weak))
就会使符号变为弱引用。
弱引用和强引用非常利于程序的模块化开发,我们可以将程序的扩展模块定义为弱引用,当我们将扩展模块和程序链接在一起时,程序就可以正常使用;如果我们去掉了某些模块,那么程序也可以正常链接,只是缺少了某些功能,这使得程序的功能更加容易裁剪和组合。
头文件可以包含如下的内容:
* 可以声明函数,但不可以定义函数。
* 可以声明变量,但不可以定义变量。
* 可以定义宏,包括带参的宏和不带参的宏。
* 结构体的定义、自定义数据类型一般也放在头文件中。
实际上我们一般不直接向用户提供目标文件,而是将多个相关的目标文件打包成一个静态链接库(Static Link Library),例如 Linux 下的 .a 和 Windows 下的 .lib。
打包静态库的过程很容易理解,就是将多个目标文件捆绑在一起形成一个新的文件,然后再加上一些索引,方便链接器找到,这和压缩文件的过程非常类似。
C语言在发布的时候已经将标准库打包到了静态库,并提供了相应的头文件,例如 stdio.h、stdlib.h、string.h 等。
在实际开发中,我们都是将头文件放在当前工程目录下,非常建议大家使用相对路径,这样即使后来改变了工程所在目录,也无需修改包含语句,因为源文件的相对位置没有改变。
#ifndef _INC_STDIO
#define _INC_STDIO
/* 头文件内容 */
#endif
实际开发中,我们通常将不需要被其他模块调用的全局变量或函数用 static 关键字来修饰,static 能够将全局变量和函数的作用域限制在当前文件中,在其他文件中无效
使用 static 修饰的变量或函数的作用域仅限于当前模块,对其他模块隐藏,利用这一特性可以在不同的文件中定义同名的变量或函数,而不必担心命名冲突。
static 变量主要有两个作用:
* 1) 隐藏
程序有多个模块时,将全局变量或函数的作用范围限制在当前模块,对其他模块隐藏。
* 2) 保持变量内容的持久化
将局部变量存储到全局数据区,使它不会随着函数调用结束而被销毁。