主要参考《C语言深度解剖》、《深入理解计算机系统》。
感慨:虽然在学校上过C语言程序设计、数据结构的课程,认为自己简单回顾一下语法、数据结构算法就能实际上手,周一新人例会被问到”static“关键字、”作用域“让我深刻意识到了自己对C语言的掌握甚至连门都没摸到,”好像什么都学了,但是什么都不会“。
指引:写这篇Blog主要是为了让自己和正在阅读的你学会C。比起知识的罗列,我更倾向于总结要点以及学习指引,因此每个一部分我都写下了一些问题,非常重要的一点就是一定要”先独立思考,然后再查阅各种资料,最后找到答案“,只有经过思考,形成的记忆和经验才足够深刻。
关键字
先说说C的几个标准。
1. C89/C90/ANSI C/ISO C,这个标准有许多名字,但是内容换汤不换药。作为C标准的最早版本,C89中定义了32个关键字,这其中包含数据类型关键字12个,控制语句关键字12个,储存类型关键字4个,其他关键字4个。
2. C99,1999年形成的新标准,对C进行了一些修改,增加了5个新的关键字,取消了局部变量要在函数开头定义的限制。
3. C11标准,2011年底发布的新标准,增加了7个关键字,增加了安全函数gets_s()\fopen_s()等,增加了<threads.h>头文件以支持多线程,增加了<uchar.h>头文件以支持Unicode字符集。
12个数据类型关键字(ANSI C)
数据类型关键字
关键字 | 说明 |
char | 字符型 |
short | 短整型 |
int | 整型 |
long | 长整型 |
signed | 有符号类型 |
unsigned | 无符号类型 |
float | 单精度浮点型 |
double | 双精度浮点型 |
void | 空类型 |
struct | 结构体 |
union | 共用体 |
enum | 枚举类型 |
数据类型的关键字最主要的作用——说明数据的类型,确定了数据的解释方式。
在C中,每一种数据类型所占用的字节数是固定的,具体而言字节根据系统也会有所变化,但同一个系统规定下是不变的。C中确认了数据类型需要对类型进行转换再赋给其他类型的数据。
对于数据而言,我们在内存中存取数据需要明确三件事:数据存储的位置、数据的长度、数据的处理方式。变量名->数据存储的位置,数据类型->数据处理方式、数据的长度。(待会在内存讲指针的时候可以回顾一下这句话,或许有新的体会)
除了数据类型,还需要关注变量的命名规则、注释的规范。当然这个各有各的规范,一定要多翻阅相关的手册,毕竟自己的工作需要与其他同事的工作耦合,谁都不希望整合出来的project仿佛一盘散沙只有撰写人能读懂甚至过了一段时间连撰写人自己都读不懂。
一些心得体会:在学的时候可以带着问题学习,如下:
- ?整数在内存中如何存储的(原码、反码、补码的天才设计!!)
- ?整数的取值范围不对称以及数值溢出是什么
- ?小数在内存中如何储存的
- ?enum在内存中如何储存的(为什么不能用&取得他们的地址)
- ?struct的内存对齐(为什么要对齐,如何对齐)
- ?空结构体的大小是多少
- ?大端小端模式对union的影响
- ?C语言只使用ASII编码吗(为什么课上所有讲的编码都是ASII码)
- ?函数的缺省类型是什么,int还是void
如果这些问题能够做到心中有数,那么你已经把跨进C的门槛的那只脚的袜子穿上了。
12个控制语句关键字(ANSI C)
控制语句关键字
关键字 | 说明 |
switch | 开关语句 |
case | 开关语句分支 |
default | 开关语句中的”其他“分支 |
if | 条件语句 |
else | 条件语句否定分支 |
goto | 无条件跳转语句 |
for | 循环语句 |
do | 循环语句的循环体 |
while | 循环条件 |
break | 跳出当前循环 |
continue | 结束当轮循环开启下一轮循环 |
return | 返回语句(可以带参数,也可不带) |
C语言三大结构:顺序结构、选择结构(分支结构)和循环结构。
在循环结构中,除了熟悉的while 和 for, C 语言中还有一个 goto 语句,它也能构成循环结构。不过由于 goto 语句很容易造成代码混乱,维护和阅读困难,还容易造成内存泄漏!(既然不推荐使用,先不介绍goto)
关于控制语句的关键字,我相信有C基础的人已经非常熟悉了。当然这里面还是有一些需要清楚的问题。
- ?break和continue关键字用于循环结构时的区别
- ?return后面不跟任何数据时,函数返回什么
- ?case关键字后面跟什么
- ?如何避免死循环
- ?return指针时需要注意什么
4个存储类型关键字(ANSI C)
存储类型关键字
关键字 | 说明 |
auto | 声明自动变量 |
register | 声明寄存器变量 |
static | 声明静态变量、函数 |
extern | 声明外部变量、函数 |
auto->编译器在默认的缺省情况下,所有变量都是auto。
register->放在寄存器的变量(关于是不是真的放在寄存器,只能说听天由命)
static->修饰全局数据和函数时改变了其作用域(链接属性),修饰局部变量时改变了其储存位置
extern->声明外部的变量或函数
学习这四个关键字的时候可以引申出很多内容,从内存到编译,这里也不过多解释,同上也留下一些问题思索。
- ?register修饰的变量需要注意什么
- ?static修饰局部变量时储存位置如何改变
- ?static修饰的局部变量真的只能在函数体内访问到吗(允许间接访问)
- ?static修饰全局变量和函数时其作用域如何变化的
- ?为什么要修改全局变量和函数的作用域
4个(其他?)重要!关键字(ANSI C)
虽然很多资料上都把这四个关键字(const、sizeof、typedef、volatile)称为”其他“关键字。看起来”其他“这两个字以为着无关紧要可有可无,实际上掌握它们非常重要。有一句话,程序是在内存中运行的,学习 C 语言必须要了解内存布局,而这几个关键字与内存有着千丝万缕的关系!
const->简单来说,可以理解为只读变量,本质上来说依旧属于变量(可以思考一下const修饰的变量能不能跟在case关键字后面)。const可以修饰全局变量、局部变量、字符串常量。
[这一段可以先去了解一下内存]针对修饰变量的声明位置和用途,存储的位置不同。编译器初期不为全局const变量分配存储空间,而是保存在符号表中,只有在第一次使用时才会在只读数据区(属于静态存储区)分配空间存储。局部const变量储存在栈上,在函数调用时分配,在函数返回时释放。const修饰的字符串常量存储在常量存储区,在运行期间保持不变。
const修饰指针变量时,可以限制变量本身,也可以限制指针。
const int *p; // p 可变, p 指向的对象不可变
int const *p; // p 可变, p 指向的对象不可变
int *const p; // p 不可变, p 指向的对象可变
const int *const p; //指针 p 和 p 指向的对象都不可变
const修饰函数形参,防止在函数内部修改指针指向的数据,这里以C自带的qsort函数中的cmp函数为例。
#include<stdlib.h>
void qsort(void *base, size_t nelem, size_t width, int (* cmp)(const void*,const void *));
int cmp(const void *a, const void *b){
return *(XXX *)a- *(XXX *)b;//升序排序
}
重要的事情说三遍:sizeof是关键字!sizeof是关键字!sizeof是关键字!那sizeof和函数的区别是什么?sizeof后面跟着变量,括号可加可不加;sizeof后面跟着数据类型,括号必须加。当然,在自己撰写程序的时候,管它是变量还是数据类型,括号都加上。
volatile关键字->告诉编译器,”我随时会变的哦,每次要到内存来找我,不要优化我!“
typedef关键字->给一个已经存在的数据类型取一个别名!
问题:
- ?const修饰的变量一定无法修改吗
- ?const的效率为什么比define高
- ?const volatile int i = 10; 这个i是什么属性
C99新增5个新关键字及C11新增7个关键字
C99标准新增了5个关键字:inline、restrict、_Bool、_Complex、_Imaginary。
C11新增了7个关键字:_Generic、_Alignas、_Alignof、_Atomic、_Static_assert、_Noreturn、_Thread_local。
这里推荐一篇文章,其余的关键字感兴趣可以自行了解RT-Thread-【gcc编译优化系列】static与inline的区别与联系RT-Thread问答社区 - RT-Thread
总结
学习了许多关键字,最大的感触就是,C语言真的要学内存。
内存
程序在计算机中如何运行的?
ALU运算单元是 CPU 的大脑,负责加减乘除、比较、位移等运算工作,每种运算都有对应的电路支持,速度很快。
寄存器 是 CPU 内部非常小、非常快速的存储部件,它的容量有限,对于 32 位的 CPU,每个寄存器一般能存储 32 位的数据,对于 64 位的 CPU,每个寄存器一般能存储 64 位的数据。现代 CPU 内置了几十个甚至上百个的寄存器,嵌入式系统功能单一,寄存器数量较少。
让 CPU 工作,必须借助特定的指令,例如add用于加法运算,sub用于除法运算,cmp用于比较两个数的大小,这称为 CPU 的指令集( Instruction Set) 。
处理器读取并解释存放在主存里的二进制指令,计算机花费了大量的时间在内存 、I/O设备和CPU 寄存器之间复制数据。
虚拟内存是什么?
在C中,最有名的应该就是指针变量了。指针变量存放着内存地址,”&“运算符也是获取变量的内存地址。然而,这些地址实际上都是虚拟的地址,并不是真正的物理地址!
这里小提一下关于指针和数组的区别,可以思考一下sizeof(a)和sizeof(p)分别等于多少?可以回顾数据类型关键字中,数据类型代表的含义是什么。
int a[10];//a指代的是数组名
int *p=a;//p指代是数组的指针
//我们可以从数组名和指针获得元素
//a[i]=p[i]
当然,在介绍为什么要这样做以及如何做到虚拟地址这一机制前,首先介绍一下虚拟地址里划分成哪几部分。下图是一个32位Linux系统中一个进程的内存分段示意图。
常量区和全局数据区也被称为静态数据区。静态和动态的区别在于变量的生命周期。
内存分区
内存分区 | 说明 |
程序代码区(code) | 存放函数体的二进制代码。 |
常量区(constant) | 存放一般的常量、字符串常量等。这块内存只有读取权限,没有写入权限。 |
全局数据区(global data) | 存放全局变量、静态变量等。有读写权限。 |
堆区(heap) | 一般由程序员分配和释放{malloc()\calloc()\free()} |
动态链接区 | 用于在程序运行期间加载和卸载动态链接库 |
栈区(stack) | 存放函数的参数值、局部变量的值等 |
这里也留下一些问题思考。
- ?为什么会栈溢出
- ?函数是如何存储在栈上的
- ?内存泄漏是什么
- ?函数的进栈出栈
- ?内存越界是什么
- ?指针不合规操作有哪些
内存分页机制
为什么要有虚拟地址?虚拟内存是一个抽象概念, 它为每个进程提供了一个假象, 即每个进程都在独占地使用主存。 每个进程看到的内存都是一致的, 称为虚拟地址空间。
从虚拟地址->物理地址,采用的就是内存分页机制。本质上来说,这就是一种映射。(想了解的可以看《深入理解计算机系统》第九章)
编译
之前我很少会关注编译和链接的过程,因为使用的工具都是集成开发环境( IDE),比如 Visual Studio、 Dev C++、 C-Free 等。这些功能强大的 IDE 通常将编译和链接合并到一起,也就是构建( Build) 或运行( Run) 。即使在 Linux 下使用命令行编译一个源文件,简单的一句$gcc demo.c 也包含了非常复杂的过程。
事实上,从源代码生成可执行文件可以分为四个步骤,分别是预处理( Preprocessing)、编译( Compilation)、汇编( Assembly) 和链接( Linking)。
预处理
预处理器(cpp)生成.i文件,预处理过程主要是处理那些源文件和头文件中以#开头的命令,比如 #include、 #define、 #ifdef 等。预处理的规则一般如下:
- 将所有的#define 删除,并展开所有的宏定义。
- 处理所有条件编译命令,比如 #if、 #ifdef、 #elif、 #else、 #endif 等。
- 处理#include 命令,将被包含文件的内容插入到该命令所在的位置,这与复制粘贴的效果一样。注意,这个过程是递归进行的,也就是说被包含的文件可能还会包含其他的文件。
- 删除所有的注释//和/* ... */。
- 添加行号和文件名标识,便于在调试和出错时给出具体的代码位置。
- 保留所有的#pragma 命令,因为编译器需要使用它们。
标准C库函数头文件 | <stjump.h> | 非局部的函数调用 |
<assert.h> | 诊断 | <signal.h> | 异常处理和中断信号 |
<ctype.h> | 字符检测 | <stdarg.h> | 可变长度参数处理 |
<errno.h> | 错误检测 | <stddef.h> | 系统常量 |
<float.h> | 浮点型界限 | <stdio.h> | 输入输出 |
<limits.h> | 整数界限 | <stdlib.h> | 多种公用 |
<locale.h> | 区域定义 | <string.h> | 字符串处理 |
<math.h> | 数学 | <time.h> | 时间与日期 |
- ?用 define 宏定义表达式需要注意什么
编译
编译器(ccl)生成.s文件,编译就是把预处理完的文件进行一些列的词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。这一过程是最复杂的部分。
汇编
汇编器(as)生成目标.o文件(GCC),VS下是.obj。从文件结构上看,目标文件已经是二进制文件,只是缺少了某些变量和函数的地址,程序不能执行,链接的作用就是找到这些变量和函数的地址。需要注意的是,有几个源文件就会生成几个目标文件,并且在生成过程中不受其他源文件的影响(模块化)。
汇编的过程就是将汇编代码转换成可以执行的机器指令。大部分汇编语句对应一条机器指令,有的汇编语句对应多条机器指令。需要注意的一点是,在很多地方我们都会接触到一个概念叫原子的,往往我们需要在一个地方对全局变量进行修改,而且这个修改需要是原子的操作。这里的原子操作值得是只有一条机器指令与其对应。
汇编过程相对于编译来说比较简单,没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编语句和机器指令的对照表一一翻译就可以了。
链接
链接器(ld)生成可执行文件,Windows下的可执行文件格式主要是PE,Linux下为ELF,如果是嵌入式需要转换成.bin文件烧录进FLASH。
从上面的介绍,虽然相比于预处理和汇编,编译和链接描述非常简短。但这并不意味着这两部分不重要,而是这两个部分最重要,需要花大量篇幅才能讲明白其中的原理,已经有人总结得十分全面了。因此在这里推荐一些非常值得研究学习的资料。
【GCC编译优化系列】一文带你了解C代码到底是如何被编译的(RT-Thread技术论坛优秀文章)_tthread.elf如何编译_架构师李肯的博客-CSDN博客《深入理解计算机系统第三版》(吃透这本书,就能学会C的底层)
CSDN-Ada助手: 恭喜你开始博客创作!标题中的关键字、内存和编译让我对你的博客产生了浓厚的兴趣。学习C语言是一个艰巨的任务,但你已经迈出了第一步,这是值得称赞的。在下一篇博客中,或许你可以深入探讨每个关键字的具体用法,或者介绍一些常见的内存问题和编译技巧。继续努力,我期待着你的下一篇博客! 推荐【每天值得看】:https://bbs.csdn.net/forums/csdnnews?typeId=21804&utm_source=csdn_ai_ada_blog_reply1
CSDN-Ada助手: 恭喜你这篇博客进入【CSDN每天最佳新人】榜单,全部的排名请看 https://bbs.csdn.net/topics/616860575。