概述

C语言是一种高级编程语言,与汇编语言基本上是一一对应的关系,而汇编语言和机器指令又是一一对应,因此学习C语言的关键在于理解实际计算机中的内存分配。

可以通过指针的方式操控内存,在内存中开辟空间,按规定类型组织而成数据结构,再辅助以灵魂算法,便形成了C语言程序。

其他的高级语言大多直接或间接由C语言封装而成,因此C语言的使用效率更高,在实时性要求高的平台,如嵌入式等系统中也广泛采用C语言进行编程。

C语言作为一种规范,有着自己的标准,如C90,蕴含着以下几点思想:

  • 相信程序员;
  • 不妨碍程序员做需要完成的事情;
  • 让语言保持短小简单;
  • 只提供一种方法来执行一个操作;
  • 使程序运行速度更快,即使不能保证其可移植性,即对目标机具有针对性;

之后又产生了修订版的C99标准,增加了以下新目标:

  • 支持国际化编程;
  • 整理现有的惯例以解决明显的缺点;
  • 针对科学和工程项目的重要数字计算改进C的适应能力;

编程规范

main函数

main函数意味着程序的入口,在老版本的C代码中会有如下形式:

main()

然而这种形式只在C90标准中允许,在C99标准中不被允许。因此也会有如下形式:

void main()

有些编译器支持这种形式,而另外一些则不支持,所以最保险的方式是如下:

int main(){
return 0;
}

它会传递给编译器以程序成功运行的信息,对某些操作系统也有实际的用途。

注释

为了更好的理解程序,常加上一些备注,便于调试以及二次开发,常见形式如下:

/* 注释内容 */

然而这种注释是有缺陷的,它需要成对应出现/*,*/,如果一不小心删除一个,便会造成程序错误。因此C99增加了另一种注释形式:

//注释内容 
声明

常对数据,方便后续赋值和调用,因此在C语言中必须在使用变量之前就对它进行声明。

一般C语言要求变量声明在代码块的最开始处,然而这种方法会使得有时忘记给变量赋值,因此在C99标准中允许声明放在代码块的任何位置。

声明变量有如下优点:

  • 使读者更容易掌握程序的内容;
  • 促使编者在代码编写前思考程序结构;
  • 帮助检查一些编写过程中的细微错误;
增加可读性技巧

选择有意义的变量名和使用注释,使用空行分隔一个函数的概念上的多个部分,以及每个语句使用一行,让代码读起来像诗一样优美。

常量使用define宏定义,类型名称使用typedef创建合适的名称。

错误

语法错误,是指不符合C语言语法规则的错误,编译器会发生错误导致程序无法运行,语义错误是指意思上的错误,结果没有按照预期的逻辑运行,这类错误编译器不会提示,需要自己调试查找。

常用调试方法有:

  • 单步调试
  • 断点调试
数据

分为整型和浮点型,整型中又有特殊的字符型,需要先声明,再初始化,再应用。变量运用形式如下:

//关键字(类型) 标识符(变量名);
int i;
float k;
//声明多个变量;
int i.j;
//变量初始化;
int i=0,j=0;

附属关键字修饰基本的整数类型:short,用于仅需小数值得场合以节约空间;long,可能占用比int类型更多的存储空间,用于更大数值的场合;unsigned,用于非负的场合,因此能表示的数绝对值更大。

字符还可以用ASCII码进行表示,特殊的可以用转义字符进行表示,从C90开始,还可以用十六进制形式表示字符常量。

字符串是以空字符\0结尾的连续的字符数组,可以用strlen()函数以字符为单位给出字符串的长度。

还有特殊的整数类型,如bool型,常用于条件真假的判断,进行条件转移和循环判断。

整型用于表示整数,而浮点型用于表示小数,浮点型常量的形式如下:

3.14159
.2
4e16
.8E-5
100.
//宏定义
#define N 100
//const关键字
const int n=100

特殊的浮点值如NAN(Not-a-Number)。

用sizeof运算符可以得到各个类型以字节为单位实际占用的大小,选用PC机windows 10 系统得到如下对应关系:

类型intshortlongunsigned intfloatdouble 
大小(字节)424448

C99标准还新增了complex和imaginary部分,用于表示复数以及虚数部分。

可以用(type)来指定转换的数据类型。

指针

数据存放在内存中,而每个内存都有相应的地址,C可以直接对地址进行读写操作,因此可以直接改变某块内存具体的数值,也被广泛用于嵌入式等直接操作硬件的场合。

指针是C的一个优势所在,其他的语言的引用也是依靠指针来实现的。而C语言没有引用这一机制,但是可以借助指向指针的指针来实现。

//指针赋值
int* pointer=#
//指针求值
*pointer=num[0];
//取指针地址,因此可以设置多级指针
&pointer
//变量指针运算
pointer++;
pointer--;
//求差值计算个数
pointer1-pointer2;
//比较指针的相同类型的值
*pointer1<*pointer2;

不允许指针改变类型,只允许将非const指针赋给const指针,反之不被允许,因为可能改变数据使程序出错。

数组

相同类型的数据排列在一起就形成了数组,结合循环的方式便于有效便捷地处理大量相互关联的数据。

//数组声明
int num[10];
//数组初始化
int num[3]={1,2,3};
//对数组只进行读操作
const int num[3]={1,2,3};

C99新增了一个特性,可以指定数组的初始化项目,未经初始化的元素都将被设置成0。如果多次对一个元素进行初始化,那么最后一次有效。

//指定数组项目初始化
int num[3]={[1]=2};

数组索引从0开始,因此需要注意下标界限,可以通过宏定义的方式声明数组长度。

//宏定义数组长度
#define len 3
int num[len];

C99允许数组长度为变量,即引入了一种新的数组——变长数组(VLA),使C更适于做数值计算。

//变长数组
int len=3;
int num[len];

数组名相当于一个常量指针,指向数组首地址,因此也可以用指针的方式对数组数据进行操作。在将数组作为参数传递给函数时,也可以用指针进行传递操作,使程序运行效率更高。

//数组名是该数组首元素的地址
num==&num[0]
//相同地址
num+i==&num[i]
//相同的值
*(num+i)==num[i]

也有多维数组,和指针之间也有关系,在实际内存中还是一维的,只不过通过不同的指针使得形式上进行了分层。

因此函数传值若是数组传递时,实际上也是利用指针直接对内存进行操作,会改变原来的数组值。

int num[2][2];
//数组首地址,虽然起始地址相同但是指向对象大小不同
num==&num[0]
//指针运算
num+1!=&num[0]+1
//二阶指针
**num==*&num[0][0]
//声明指向二维数组的指针数组
int (*pointer)[2]=num;
//表示数组时用数组名或指针
num[m][n]==*(*(num+m)+n)
pointer[m][n]==*(*(pointer+m)+n)
//作为函数参数被调用
int sum(int(*pointer)[2]);
//一般的声明N维数组的指针式,仅最左边方括号可以留空
int sum(int pointer[][2]);
//变长数组作为参数
int sum(int num[*][*];
//定义了函数指针的别名
typedef int (*pfun)(int num[][]);

C99新增了复合文字,即数组常量,可以作为函数参数被调用。

//创建无名称数组
(int [2]){10,20}
//省略长度
(int []){10,20}
//使用复合文字
int *pointer=(int [2]){10,20};
结构

数组是相同类型的数据排列在一起,而数据类型不同仍要排列在一起则可以通过结构体来实现。事实上,结构体也包含了C++封装成类的思想,有面向对象的编程内涵。

//结构题声明
struct boy{
    char name[20];
    int age;
};
//声明一个结构变量
struct boy gzj;
//等价于
struct boy{
    char name[20];
    int age;
}gzj;
//也可申明一个结构体数组
struct boy{
    char name[20];
    int age;
}smart_boys[3];
//也可以无标记,但为了多次使用一般加标记,比如链式使用
struct {
    char name[20];
    int age;
}gzj;
//结构体初始化
struct boy gzj={"gzj",22};
//访问结构体成员
gzj.name="gzj";
gzj,age=22;
//C99支持指定初始化项目
struct boy gzj={.age=22};
//结构体数组
struct boy boys[2];
//结构体嵌套
struct boy{
    char name[20];
    int age;
};
struct student{
    struct boy;
}

此外指针也能被用于指向结构体,使得结构体被作为参数传递时可以借助指针实现。但是结构名并非结构体首地址。

但是注意若在结构体中包含有结构体指针,则必须在初始化外部结构体的时候也对内部结构体进行初始化,可以指向NULL。

//指向结构的指针
struct boy *pointer=&gzj;
//用指针访问成员
pointer->name="gzj";
pointer->age=22;
//结构体作为函数参数,也可以进行返回
struct boy record(struct boy gzj);
//允许一个结构赋给另一个结构,代替传递指针参数修改实参
boy1=boy2;

把指针作为参数有两个优点,执行迅速且只需传递单个地址,但缺少对数据的保护,需要加const限制。而结构体作为参数,相对安全,编程风格清晰,缺点是浪费存储空间。

联合是一个能在同一个存储空间里(不同时)存储不同数据类型的数据类型,以与结构体同样的方式建立,用以存储某种没有规律实现也未知的顺序保存混合类型数据。也可以表示一定的逻辑关系。

//联合体
union num{
    int i;
    float j;
}index;
index.i=1;
index.j=1.1;

可以使用枚举类型声明代表整数常量的符号名称,目的是提高程序的可读性。也可以作为标签,很方便地提供给case语句使用。

//枚举类型声明
enum human={man,woman};
//指定数据声明
enum human={man=1,woman=0};
运算符
运算符功能
=赋值
+
*
/
sizeof类型大小
%取模
++增量
减量
+=加法更新
-=减法更新
*=乘法更新
/=除法更新
逗号运算符
&&逻辑与
||逻辑或
!逻辑非
?:条件运算符
&按位与
|按位或
^按位异或
<<按位左移
>>按位右移

合理使用运算符可以合并控制处理过程,使程序更加简洁,但将使代码难于理解,需要考虑运算符的优先级以及结合性。

增量运算符在标识符前还是后决定了其后缀增量动作发生时间。在C语言中,编译器可以选择函数参数计算顺序,因此在函数调用时需要慎用增量运算符。

逗号运算符规定表达式从左到右顺序进行运算,最后表达式结果为最右表达式取值。

逻辑运算符可以获得多个表达式的逻辑组合功能。

条件运算符也可以执行分支和跳转功能:

//条件运算符,若表达式1真则表达式值为表达式2,否则为表达式3
expression1?expression2:expression3

表达式由运算符以及操作数组成,其最终结果可能是具体的数值,当然也可能是布尔型数值0,1。因此表达式的混合使用给C语言编程带来了巨大的灵活性,在条件判断转移中可以发挥巨大的作用。

位操作

数据在计算机中都以二进制的方式进行存储,而C语言提供了位运算,能够以二进制的形式,直接对个别位进行操作,使其成为了编写设别驱动程序以及嵌入式代码的首选语言。

//定义一个符号常量MASK,00000010
#define MASK 2
//掩码,即某些位设置为1,而另一些设置为0的组合
flags=flags & MASK;
//打开位
flags=flags | MASK;
//关闭位
flags=flags & ~MASK;
//转置位
flags=flags ^ MASK;
//查看一位的值
(flags & MASK)==MASK

C语言还提供了位字段操作,由一个结构声明建立,为每一个字段提供标签并决定字段的宽度。

//位字段声明
struct{
    unsigned int one:1;
    unsigned int two:1;
}number;
//可以用未命名字段填充宽度
struct{
    unsigned int one:1;
    unsigned int    :1;
    unsigned int two:1;
}number;
循环

循环是为了让机器自动去做大量重复性的操作,一般形式如下:

//一般形式
while(expression)
    statement

表达式为真时执行一直执行该语句,循环条件一般判断整型数据,判断浮点型数据时可以借助绝对值。

在建立一个重复执行固定次数得循环时涉及如下动作:初始化一个计数器;计数器与某个有限值进行比较;每次执行循环,计数器的值都要递增。

有一种将三种动作结合在一起的方法,叫for循环:

//for循环
for(initialize;test;update)
    body;
//等同while效果
initialize;
while(test){   
     body;
     update;
}

在test为假前重复执行循环,三个表达式分别用来代表三个动作,更加灵活的是initialize不一定要初始化,可以是某种类型的printf语句。

逗号运算符扩展了for循环的灵活性,使得可以在一个循环中使用多个初始化或者更新表达式。

上述方法为入口条件循环,判断条件在执行循环之前,与之相对的还有退出条件循环:

//退出条件循环
do{
    body;
}while(test);

适用于那些至少需要执行一次循环的情况。

continue可以跳过当前执行循环,但不能跳出当前循环,而break可以跳出当前循环。

分支和跳转

根据条件执行转移,使程序灵活并满足一定的逻辑功能。

//if语句
if(expression)
    body;
//if-else语句
if(expression)
    body;
else
    body;
//if-else if
if(expression)
    body;
else if(expression)
    body;

若没有花括号指明,else和他最接近的一个if匹配。条件运算符内容和if else相似,但形式更为简洁。

switch结构可以执行多重选择功能,但不能用于判断一个浮点型变量或是表达式的值:

switch(expression){
    case label1:statement1
    case label2:statement2
    default:statement3
}

goto语句是鸡肋,虽然可以模仿汇编语言jmp执行类似跳转操作,但是在C程序编写过程中一般用不到,且方式灵活没有固定套路,故跳过。

函数

C语言是面向过程的程序设计语言,为了过程的规范化以及模块化,增设了函数。函数有原型、定义、调用三个过程。

函数机制有着如下优点:

  • 省去重复代码的编写;
  • 使得程序模块化,利于程序的修改和完善;
//函数原型,告知编译器函数类型
int sum(int n);
//函数调用,导致函数执行
sum(4);
//函数定义,指定具体功能
int sum(int n){
    for(int i=0,sum=0;i<n;sum+=i,i++);
    return sum;
}

函数原型的使用,既定义了函数返回值类型,也解决了参数匹配的问题,它可以使编译器发现函数时可能出现的错误。一般将函数原型放在头文件中,然后通过#include语句引用。

函数调用函数自身,就形成了递归,递归可以代替循环语句使用,使程序结构优美,但是执行效率低于循环。一个函数调用其他函数就形成了函数的嵌套。

最简单的递归形式为尾递归,即递归调用语句恰在return语句之前。某些反向计算问题常用递归方式实现。

//尾递归
void function();
void function(){
    function()
    return;
}

可以声明指向函数的指针,以便将函数作为参数进行传递,告诉下一个函数调用哪个函数,这种指针被称为函数指针。

//函数指针声明
int (*sum)(int *);
//函数指针作为参数
int calculate(int (*sum)(int *));
//在代码中使用函数
function(sum);
//使用返回值
function(sum((int[3]){1,2,3});

函数指针的使用是为了方便灵活地调用同类型的函数,以便应对不同的情况。同时在函数中进行调用,能利用hook机制对某些消息机制实现操作。

类型别名

typedef关键字的引入是为了增强程序变量类型的可读性,我们可以对任意类型进行取别名的操作。

//普通变量类型
typedef int u_int64;
//数组
typedef int a[22];
//结构体
typedef struct s{
}s;
//函数
typedef int (*pfun)(int);

编译原理

编译过程

在编辑器中编写源代码(.c),然后经由编译器编译生成目标代码(.obj),最后经由链接器生成可执行代码,即程序(.exe)。

数据类型

编译器一般通过数据书写来辨认常量数据类型,而通过声明来辨认变量数据类型。

默认情况下,将整型数据当作int类型,将浮点型数据当作double类型,如果值过大,编译器将依次尝试用long,unsigned long,long long,unsigned long long类型。

在内存中,整数类型被以二进制方式进行存储,而浮点型被分为小数部分和指数部分并分别进行存储。

在传递函数参数时,C会自动将short类型转换为int类型,因为int类型被认为是计算机处理起来最方便有效的整数类型,将float类型转换为double类型。当然可以通过函数原型得方式来阻止自动提升的发生。

当出现在表达式里时,char和short类型都将被自动转换为int;在包含两种数据类型的任何运算里,两个值都被转换成两种类型里较高的级别;在赋值语句里,计算的最后结果被转换成将要被赋予值得那个变量的类型。

函数传值

通过实参和形参传递函数值,再通过返回值或是指针式参数得到函数运算结果。在调用时使用实参,定义时使用形参,从实参到形参只是值传递,若要对实参进行改变,需要用指针。

在执行函数时,程序在内存中开辟了一块栈空间存放参数,然后被调函数从栈空间中读取数据,而读取的数据类型根据函数原型确定,因此原型解决了参数匹配的问题。

函数传值可以通过参数或是通过返回值进行传递,但是一般定义返回操作结果状态码,以此通过参数传值,特别的当参数为结构体 时,若进行结构体成员的运算,则传递结构体,如果对结构体进行修改则传递结构体指针,直接对内存进行修改操作。若是需要返回结构体地址,则可以选择引用或是传递结构体指针地址。

预处理器

对程序预处理前,编译器会进行几次翻译处理,比如将注释以及空白字符去掉,查找反斜线后紧跟换行符的实例。

//紧跟换行符
pritf("ha\
ha");

然后先根据预处理指令进行预处理,宏define是常见的一种预处理指令,用于替换文本。

//常量
#define N 10
//使用参数
define SQUARE(X) X*X
SQUARE(4)==16
//预处理粘合剂,##运算符
#define XNAME(n) x##n
int XNAME(1)=1;//x1=1

在字符串中使用宏参数是,字符串被看作普通文本,除非前面加#。

许多模块即可以用带参数的宏完成,也可以用函数完成,宏产生内联代码,而函数节约空间,但是函数花费时间多,因此主要选择依据需要进行时空的权衡。

C99提供了另一种方法内联函数,建议编译器尽可能快速地调用该函数,而建议的具体效果由实现来定义。

#内联函数定义/原型
inline int sum(int *){
}

其他预处理指令:

指令作用
#undef取消定义一个给定的#define
#if条件编译,防止头文件重复
#ifdef
#ifndef
#else
#elif
#endif
#line重置行和文件信息
#error给出错误消息
#pragma向编译器发出指示

条件编译就相当于设置一个标志,使得每个头文件最多只被编译一次,很好地解决了变量重复定义的问题。

常用函数

printf()

printf

printf(Control-string,item1,item2,…);

用于在屏幕上输出字符串以进行交互,在控制字串中用带有%的修饰符进行转换说明。函数返回值为所打印的字符的数目,若输出有错,则会返回一个负数。

调用该函数时,先将输出传递给缓冲区,直到缓冲区满或是遇到换行符时,再将缓冲区内容传递给屏幕进行显示。因此,防止此问题,可以用换行符刷新缓冲区。

有三种方法打印较长的字符串,使用多个printf语句;使用/和回车键组合换行;字符串连接。

%*.*说明字符,能动态设置转换的数据的宽度。

scanf()

scanf

scanf(Control-string,item1,item2,…);

该函数返回成功读入的项目的个数,若发生错误,则返回0,当检测到文件结尾,则返回EOF。

调用该函数时,跳过空白字符,直到遇到第一个非空白字符,所以在开始时将跳过空白字符,并以空白字符结束,一般用于读入一个单词。

允许将普通字符放在格式字符串中,但输入必须与之精确匹配。

%*说明字符,能使函数跳过相应的项目,常用于读取表格某特定列。

malloc()

malloc

(type*)malloc(length);

用于申请一块满足存储数据大小的空间,并以指针的形式进行返回。在调用之后还应当利用free()函数释放掉申请的内存块。

注意在申请字符串长度时是否需要考虑结尾’\0’字符。

总结与展望

整理这篇文章既是问了再次回顾C语言某些遗忘的语法特性,同时也是为了方便之后的程序编写有章可循。

在码农江湖中功法固然重要,然而根基即内功更为重要,那么C语言可当之无愧为程序界的内功心法。

学完C语言之后,入门其他语言很快,因为其他语言的语法特性也可以最终用C语言描述。

我之前已经学习过几遍C的语法了,然而再学还是会有新的感悟,因此这个文章以后也会继续不定期更新,完善一些细节部分。

至于学完C语言该何去何从?当初我学完C的黑框框后转向了网络编程socket。

如今,为了系统地学习计算机理论,便于实现更上层的数字算法。接下来应当学一下数据结构,用C语言描述并实现,然后学习编译原理、操作系统等,虽不是CS科班出身,但那从来就不是止步于此的理由。

江湖山且高,水且长,路且远,但又何妨,一把键盘,琴瑟之声,如同天籁。

——黑帅

2020.6.20

By gzj7108

人生只有一条路,就是勇往直前。

发表评论

邮箱地址不会被公开。 必填项已用*标注