【引子】温故而知新,“三日不弹,手生荆棘”,代码也是如此。另一方面,自己挖的坑要自己填。在《全栈的技术栈设想》中埋下了4种编程语言的伏笔,已经兑现了Javacript,Python和Java, 本想将C/C++一并整理,但涉及面向对象等设计技术,最终还是C 梳理一下,从0到1吧。
C语言简洁,使用方便灵活,能直接访问物理地址,并进行高效的位运算。生成的目标文件质量高,执行效率高,但这是相对而言的,比汇编语言的效率还是低了15%左右。数据处理尤其是图像处理能力强,可移植性也好。
关键字
ANSI C 共有32个关键字和9种控制语句,按照惯例编一首打油诗。
while signed for return,unsigned case continue default.
register goto auto union, do short long struct.
void typedef switch extern, volatile char double const.
if break static int, enum sizeof else float.
在C99中,又增加了5个关键字inline restrict _Bool _Complex _Imaginary, 后来的C11中又增加了7个关键字_Alignas _Alignof _Atomic _Static_assert _Noreturn _Thread_local _Generic, 所有这些关键字,不但要有所了解,还要知道其典型的应用场景。
数据结构
C语言为用户提供了丰富的数据结构,还允许用户自定义复杂的数据结构。C语言提供的数据结构是以数据类型的形式给出的,C的数据类型划分如下:
- 基本类型
- 数值类型
- 字符类型
- 枚举类型
- 构造类型
- 数组类型
- 结构类型
- 联合类型
- 指针类型
数据有常量与变量之分,习惯上用大写字母代表常量,用小写字母代表变量。数值类型要注意数的范围不同。字符常量是用单引号括起来的一个字符,还允许以一个“\”开头的特殊字符常量。枚举类型是一种基本数据类型,而不是一种构造类型,因为它不能再分解为任何基本类型。在编译中,对枚举元素按常量处理,故称枚举常量,它们不是变量,不能对它们赋值。
数组是有序数据的集合,数组中每一元素都属于同一数据类型,用一个统一的数组名和下标来唯一的确定数组中的元素。结构体是C语言提供的一种数据结构,一般形式如下:
- struct 结构体名字
- {
- 成员列表
- } 变量名列表;
一般地,可以利用宏取得结构内的偏移量:
#undef offsetofstruct #define offsetofstruct(TYPE, ELEMENT) ((size_t) &((TYPE *)0)->ELEMENT) #endif
联合也是一种派生类型,语法和结构体相同,不同是它的成员共享存储空间。联合定义了一组可供选择的值,它们共享一块内存。
一个变量在内存中的地址就称为该变量的指针,这是C语言中的精华,下面单独描述。
C语言还提供了十分丰富的运算符,主要有如下34种:
- 算术:+、-、*、/、++等
- 关系:>、<、==、!=等
- 逻辑:&&、||、!等
- 位:>>、<<、~等
- 赋值:等号(=)及其扩展赋值运算符(+=、-=、*=、/=等)
- 指针:*、&
用各种运算符将运算对象连接起来形成了表达式。
指针
C 语言的核心是指针,其灵活性和超长之处源自于指针。指针提供了动态操控内存的机制,强化了对数据结构的支持,且实现了访问硬件的功能。
指针是一个存放内存地址的变量。定义一个指针时,必须规定它指向的变量类型。任何指针都是指向某种类型的变量。当通过指针来访问指针所指向的内存区时,指针所指向的类型决定了编译器将把那片内存区里的内容当做什么来看。要注意区分指针的类型(即指针本身的类型)和指针所指向的类型是两个概念。
void指针类型,即不指定它是指向哪一种类型数据的指针变量。void指针它可以指向任何类型数据,可以用任何类型的指针直接给void指针赋值。但是,如果需要将指针的值赋给其它类型的指针,则需要进行强制类型转换。在指针定义语句的类型前加const,表示指向的对象是常量。
指针变量可以指向另一个指针,指针的指针。程序中的函数代码同样也占有内存空间,每个函数都有地址,因此指针同样可以指向函数,指向函数地址的指针称为函数指针。总之,指针可以指向什么是没有限制的,可以是变量、数组元素、动态分配的内存块以及函数。
正确理解指针变量和函数指针的声明,例如:(*(void(*)())0)(); 注意*p()和(*p)()的区别,前者含义是函数返回值为一个指针类型,后者含义p是一个指向函数的指针。
指针的典型用法:
- 直接访问系统内存
- 引用函数
- 构造链式数据结构
- 引用动态分配的数据结构
- 实现引用调用
- 传递数组参数
- 访问和迭代数据元素
- 代表字符串
- 作为其他值的别名
函数
一个大程序可分为若干个小程序模块,每一个模块用来实现一个特定的功能,这个模块称为函数。一个C程序可由一个主函数和若干子函数构成。由主函数调用其他函数,其他函数也可以互相调用。同一个函数可以被一个或多个函数调用任何多次。
从用户来看,可以将函数分为库函数和自定义函数。从函数自身看,可以分为有参数和无参两种。传参过程中要根据需要进行值传递和地址传递,也就是形参和实参。只有在发生函数调用时,函数中的形参才被分配内存单元。在调用结束后,形参所占的内存单元也被释放。
函数应当在同一文件中它被调用的位置之前定义,否则就会默认返回值是整型。如果调用函数处和被调用函数不在同一文件,且返回值类型不同,连接时会报错。如果被调用函数参数包括char、short、float等类型,则在调用该函数的文件中必须声明该函数,且括号内带上参数类型。
本质上,函数表示法就是指针表示法,函数名称经过求值会变成函数的地址,然后函数参数会被传递给函数。
程序栈是支持函数执行的内存区域,通常和堆共享,包括返回地址,局部数据存储,参数存储,栈指针和基指针(运行时管理栈的指针)。系统在创建栈帧时,将参数以跟声明相反的顺序推到帧上,最后推入局部变量。
从函数返回指针时可能存在的潜在问题:
- 返回未初始化的指针
- 返回指向无效地址的指针
- 返回局部变量的指针
- 返回指针但是没有释放内存
函数指针可以 以编译时未确定的顺序来执行函数。
- void (*foo)()
使用函数指针时一定要小心,因为c 不会检查参数传递是否正确,建议使用fptr作为前缀。函数指针数组可以基于某些条件选择要执行的函数。传递指针的指针可以让参数指针指向不同的内存地址。
内存存储
C中主要有4种存储类型:
- auto只能用来标识局部变量的存储类型,对于局部变量,auto是默认的存储类型,不需要显示的声明。因此,auto标识的变量存储在栈区中。
- extern用来声明全局变量。如果全局变量未被初始化,那么将被存在BBS区中,且在编译时,自动将其值赋值为0,如果已经被初始化,那么就被存在数据区中。全局变量,不管是否被初始化,其生命周期都是整个程序运行过程中,为了节省内存空间,在当前文件中使用extern来声明其它文件中定义的全局变量时,就不会再为其分配内存空间。
- register的变量在由内存调入到CPU寄存器后,则常驻在CPU的寄存器中,因此register将在很大程度上提高效率,因为省去了变量由内存调入到寄存器过程中的多个指令周期。
- static无论是全局的还是局部的,都存储在数据区中,其生命周期为整个程序,如果是静态局部变量,其作用域为一对{}内,如果是静态全局变量,其作用域为当前文件。静态变量如果没有被初始化,则自动初始化为0。静态变量只能够初始化一次。
在使用内存时,申请与释放要配对,本着谁申请,谁释放的原则,释放后,要把指针置空。常见的内存使用问题有3种:
- 野指针:Free后,没有置空,后续继续使用该指针;
- 内存泄漏:申请后没有释放
- 内存越界:数组索引和内存访问溢出
避免内存越界,必须对数组的索引进行有效值检查,字符串操作API最好要带n 例如strncpy,strncat等,内存拷贝的size要做检测,避免野指针。
在条件允许的情况下,可以自己实现内存池管理,按字节切割内存池(例如 8字节的整数倍)。每次分配的内存地址空间,在启止位置进行初始化特殊值,然后用单独线程每隔一小段时间,对内存池中每个有效块进行扫描,做好内存碎片整理。
动态分配存储字符串的空间(malloc方式)时,注意不要忘记字符串需要多分配一个字节保存字符串结尾'\0'。
编译
C语言的编译过程有预编译——>语法分析——>代码生成——>优化——>汇编——>连接。预编译器完成宏替换,词法分析,并创建符号表。语法分析包含了语义分析,创建语法树。代码生成器来生成中间代码,优化器负责指令优化,汇编程序生成汇编代码,最后由连接器生成目标文件和可执行文件。连接器对目标模块中的外部对象做同名检查,如果没有命名冲突就加入到载入模块。
函数和初始化的全局变量(包括初始化为0)是强符号,未初始化的全局变量是弱符号。符号的意义就是将对一个对同一个名字的读写操作都指向同一块内存,即使这些操作分散在不同的.o中。
对于它们,下列三条规则使用:
- 同名的强符号只能有一个,否则编译器报"重复定义"错误。
- 允许一个强符号和多个弱符号,但定义会选择强符号的。
- 当有多个弱符号相同时,链接器选择占用内存空间最大的那个。
切记比较运算符==不要错写为赋值符号=,反之亦然,二者大为不同.词法分析采用的是从左至右的贪心法,例如a---b等价于a-- -b,而不等价于a- --b;
预编译
通常在C编译系统对程序进行编译前,先对程序中一些特殊的命令进行“预处理”,然后将预处理的结果和源程序一起进行编译处理,得到目标代码, 以“#”开始的行成为预处理指令。
带参数的宏与函数非常类似,在引用函数时也是在函数名后的括号 内写实参,且要求实参的数目等于形参的数目,但它们还是有区别的:
对参数的使用方式不一样。函数调用时,先求出实参表达式的值,然后带入形参;宏只进行简单的字符替换。
处理机制不一样。函数调用在程序运行时处理,且要分配内存;宏展开在编译时进行,不分配内存单元,不发生值的传递处理,也不存在返回值
定义时的要求不一样。函数定义时,实参和形参都要定义类型;宏定义时不存
预处理程序提供了条件编译的功能。可以按不同的条件去编译不同的程序部分,因而产生不同的目标代码文件,这对于程序的移植和调试是很有用的。条件编译有三种形式:
- #ifdef 标识符
- codes1
- #else
- codes2
- #endif
- #ifdef 标识符
- codes3
- #endif
- #ifndef 标识符
- codes4
- #else
- codes5
- #endif
头文件
一般的,通过头文件来调用库功能。在很多场合,源代码不便(或不准)向用户公布,只要向用户提供头文件和二进制的库即可。用户只需要按照头文件中的接口声明来调用库功能,而不必关心接口怎么实现的。编译器会从库中提取相应的代码。头文件还能加强类型安全检查。如果某个接口被实现或被使用时,其方式与头文件中的声明不一致,编译器就会指出错误,这一简单的规则能大大减轻程序员调试、改错的负担。
使用尖括号引入的头文件在包含文件目录中去查找(包含目录是由用 户在设置环境时设置的),而不在源文件目录去查找。使用双引号则表示首先在当前的源文件目录中查找,若未找到才到所包含目录中去查找。用户编程时可根据自己的文件所在的目录来选择某一种命令形式。
程序框架与库
C语言中的程序框架是由头文件,变量声明,main函数和子函数组成。无处不在的helloword 在C中是这样的:
- #include <stdio.h>
- int main()
- {
- printf("Hello, World! \n");
- return 0;
- }
里面没有变量声明和子函数。那没有main 函数是否可以呢?或者说,不写成main函数,换个其他的名字是否可以呢?这涉及到编译的指定,main 是c中默认的调用入口。
C中的那些库就大都没有main函数。C语言中的库分为静态库(.a)和动态库(.so)。
静态库实际上是一些目标文件的集合,用于连接器生成可执行文件阶段。连接器会将程序中使用到函数的代码从库文件中拷贝到应用程序中,一旦连接完成生成可执行文件之后,在执行程序的时候就不需要静态库了。动态库也叫共享库,在程序链接的时候只是作些标记,然后在程序开始启动运行的时候,动态地加载所需库(模块)。
C标准库有各种不同的实现,比如最著名的glibc, 用于嵌入式Linux的uClibc,还有ARM自己的C语言标准库等。不同标准库的实现并不相同,提供的函数也不完全相同,不过有一个它们都支持的最小子集,这也就是最典型的C语言标准库。
C标准库由在15个头文件中声明的函数、类型定义和宏组成,每个头文件都代表了一定范围的编程功能。有人说,C标准库可以分为 3 组,如何正确并熟练的使用它们,可区分出 3 个层次的程序员:
- 合格程序员:
、 、 、 - 熟练程序员:
、 、 、 - 优秀程序员:
、 、 、 、 、 、
运行时
在C语言运行时的数据结构中,堆栈为局部变量提供存储空间,为函数调用提供还原信息,其临时存储区,用于计算复杂算术表达式;调用记录支持过程调用,并记录调用结束后返回调用点所需要的全部信息;全局变量的数据有static变量,常量等。
BSS段(bss segment)
通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS段属于静态内存分配。
数据段(data segment)
通常是指用来存放程序中 已初始化 的 全局变量 的一块内存区域。数据段属于静态内存分配。
代码段(code segment/text segment)
通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于 只读 , 某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些 只读的常数变量 ,例如字符串常量等。程序段为程序代码在内存中的映射。
堆(heap)
堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc/free等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张)/释放的内存从堆中被剔除(堆被缩减)。
栈(stack)
栈又称堆栈,存放程序的局部变量(但不包括static声明的变量, static 意味着 在数据段中 存放变量)。除此以外,在函数被调用时,栈用来传递参数和返回值。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。
程序在进入main函数之前,已经完成数据在内存中的分配、初始化,包括数据区,堆栈区等。关于这部分代码对于开发者不可见,属于C标准运行时的一部分。
函数在调用和被调用过程中,都伴随着入栈和出栈,因此栈发挥着重要作用。函数的局部变量、参数、返回值都存在栈区中。函数结束后,栈区空间自动释放,栈区担任着一个临时存储的角色,是计算机利用内存空间的一种机制。
了解了C 运行时的空间分布是远远不够的,最好了解一下一个编译后的代码是如何运行起来的,以及库中的函数是如何链接到目标代码的,尤其是函数指针链表的维护,之后会有一种对代码完全掌控的感觉。
不是小结的小结
C语言不但能让我们了解编程的相关概念,还能让我们明白程序的运行原理,比如,计算机的各子系统是如何交互,程序在内存中是一种怎样的,操作系统和程序之间的“爱恨情仇”,这些底层知识对程序员的职业生涯大有裨益。
C语言被一些人誉为“上帝语言”,它几乎奠定了软件产业的基础,还创造了很多其它语言。但是,鉴于水平有限,难以举重若轻,本文中的基础描述只是老码农的碎碎念罢了。