C语言入门

本章快速过一遍C语言的知识,同时提供一种从逆向角度看待不同语言的视角。

0x01 为了实现

任何一个语言,均是为了实现一个目的而使用的。任何一个语言,均是为了解决一个痛点而出现的。C正是解决了汇编的繁琐而出现的,它把寄存器和内存的各种操作封装到每个过程的变量之中来简化指令的编写。

C语言基于面向过程。CPU从程序入口点抽出一条指令,然后执行,再抽出下一个指令,然后再执行,直到程序结束。这就叫过程,一般来说,面向过程的过程也可以叫做函数。一个函数的结束可以说是一个过程的结束。在C语言中也是如此。

0x02 过程

00 过程的定义

C语言中,过程的定义就是一个有0或多个输入,0或多个输出的函数。通常来讲,只要是{}大括号包裹的空间,都被视为一个单独的过程,包括后面的循环体,选择体等等。

例如:

1
2
3
int main(void){
return 0;
}

这个过程是0输入,1输出的函数。

1
2
3
void say(int a,int b){
printf("%d",a+b);
}

这个过程是一个2输入,0输出的函数。

对于一个函数,其主要作用就是返回一个数值,我们叫它返回值,也就是return 0;这个语句,但是还可能有其它效果,比如打印一个字符串到屏幕,这种除了返回一个值的其它效果被叫做副作用。printf的副作用就是输出内容到屏幕,它的返回值就是输出内容成功的个数。C语言中函数的本质就是它的返回值

01 创建一个过程

1
2
3
4
5
6
7
8
9
10
11
#include<stdio.h>
int main(void){
int a = 0;
int b = 0;
scanf("%d %d",&a,&b);
printf("%d",a+b);
return 0;
}
return_type identifier(type parameter,...){
//function body
}

定义一个过程(在C语言中的函数)需要返回类型,过程名,传入参数和函数体。

上面的例子标识了函数如何定义。

  • return_type 表示返回值的类型(具体见0x03)
  • identifier 是你为这个函数标识的名字(标识符)
  • type parameter 即括号中的内容是传入的参数类型和传入值的标识符,如果没有,可以填void,标识为空(具体见0x03)
  • function body 函数体,你会在这里写这个过程需要干什么,就像上面的main函数一样

值得一提的是:每个C语言程序都需要一个main函数作为用户自定义过程的入口点。也就是说,当用户打开一个C程序,CPU从start函数(自动生成的)开始初始化所有需要的环境,成功后,自动调用main函数从而执行用户自定义的过程,main函数结束后,再自动回收/清理环境内容然后结束程序。

02 调用一个过程

1
2
3
4
5
6
7
8
#include<stdio.h>
int main(void){
int a = 0;
int b = 0;
scanf("%d %d",&a,&b);
printf("%d",a+b);
return 0;
}
1
2
3
4
5
6
7
8
9
int add(int a,int b){
return a+b;
}
int main(void){
int a = 1;
int b = 2;
int c = add(a,b);
return c;
}

调用是指:进入一个过程并完成它,在C语言中还有计算出它的返回值的含义

这个例子中,scanf和printf和add就是被调用的过程。以add举例,CPU执行到这里,会先进入add过程,然后一条一条执行其中的代码,返回出设定的值。函数调用的格式即是identifier(parameters),再次说明:它的含义就是计算出这个过程的返回值,一个函数调用的本质就是得到它的值。对于printf这类含有副作用的函数,它们会在计算返回值的过程中“顺带”表现副作用。

0x03 参数,计算,表达式和语句

刚刚说明了函数的本质就是返回值,那么对于C语言来说,添加/删除/修改”值“就是C程序的本职工作。这一章就介绍一下,C语言如何操作值

00 标识符

在计算机的底层实现中,任何一个内存地址都对应一个值,C语言通过使用标识符的方法来简化对内存空间的操作。现在可以假设内存地址是一个街道的所有门牌号,地址对应的值是门牌号内的东西。

在汇编中实现两数相加的运算过程

1
2
3
4
5
6
;省略
mov [rsp+4],4;在栈对应的内存地址放置一个值
mov rax,3;另外一个操作数放到加法寄存器中
add rax,[rsp+4];执行加法操作
mov rax,rax;把返回值放到rax寄存器中,用于返回,这个步骤可能被优化
;省略

在C语言中实现两数相加的运算过程

1
2
3
int add(int a,int b){
return a+b;
}

可以看出,C语言省略了对栈空间/内存地址以及寄存器的描述,将我们的精力放到了实现效果上。其中的a和b被叫做标识符,它们代表了内存空间的某个地址(这时又可以被叫做变量)。标识符即是内存空间一个区域的代号,是供我们操作其中值的中介

01 声明一个标识符

1单独声明

基本格式

1
2
3
<qualifier> type identifier;
//特殊写法
int a,b,c;//连续声明

type:说明这个标识符对应空间的大小和默认的类型,其中默认类型影响运算效果,虽然可以强制类型转化,但是还是尽量在定义时就写好

type 大小 默认类型
char 1字节 有符号字符型
short 2字节 有符号短整型
int 4字节 有符号整型
long 4字节 有符号长整型
long long 8字节 有符号长长整形
bool 1字节 只存放0和1,C23

identifier:一个你定义的名字,用来标识这块区域,之后对这块区域的操作转换为对这个标识符的操作。内容必须为:大小写字母和数字和下划线的组合。不能以数字开头,不能和已有内容重名(同一作用域下之前的标识符,关键字(比如char return))

<qualifier>:可选的限定符,可以写多个内容。

qualifier 效果
signed 表明这个区域的值默认为有符号,省略默认为signed
unsigned 表明这个区域的值默认为无符号,使用unsigned时省略type表明是无符号整型
const 表明这个区域的值除了初始赋值外不可更改,声明时必须直接赋值
volatile 不建议编译器优化这个区域的操作
extren 表明这个变量来自其它源代码文件
static 表明这个变量不能被extren,只能在本文件使用
auto 表明这个变量是局部变量,块作用域,省略默认为auto
register 建议编译器把这个值放到寄存器

2声明时赋值

一个区域只声明,其中的内容是不会因为你给了这个区域一个名字而改变的。所以我们一般在声明时直接赋值。

基本格式

1
2
3
<qualifier> type identifier=number;
//特殊写法,连续赋值
auto a=1,b=4,c=0;//auto只能赋值一种类型

这里的=为赋值号,作用是把整个等式右边的值计算出来给左边。形如a=b这样的式子被叫做表达式,和函数类似,这也是一个过程,对于赋值来说,副作用是把右边的值给左边,返回值是结束后左边的空间中的值。

所以

1
a=b=c=d=e=f=5;

的效果就是先让f等于5,再让e等于(f=5)这个表达式的值即5,以此类推,直到a=5.

大家也发现,对于赋值符号,左边一定是一个空间,右边则不一定,空间不一定在左侧,但是数一定在右侧。为了简便描述,我们把可以在赋值号左边的值(标识空间的)称为左值,而不可以在左边的值(只能视为数的)被称为右值

举例:

1
2
a=b=c=d=e=f=5;//a,b,c,d,e,f为左值,5,f=5,e=f=5,d=e=f=5,c=d=e=f=5,b=c=d=e=f=5,a=b=c=d=e=f=5为右值
c = add(a,b)-d;//a,b,c,d为左值,add(a,b),add(a,b)-d,c = add(a,b)-d为右值

对于声明时赋值,number可以是左值也可以是右值。而前面的限定符和类型描述符均和赋值相同。

特别的,声明时赋值可以使用这样的方法:

1
2
3
auto a=1;//默认int
auto b=0.1f;//默认float
auto c=8.9;//默认double

C23引入了这种写法,auto自动判断类型,甚至可以把函数返回值设为auto启用自动推断。

3 声明数组

假设我的程序需要100个变量,难道我要写100个auto a,b,c,d,…吗?当然不是,通过数组,我们可以向系统申请一块连续的内存用来存放我们的多个变量。

声明:

1
2
<qualifier> type identifier[size];
int arr[100];

这里的type identifier[size]就是连续申请size个type大小的空间的意思。这个空间的名字就是identifier。如果写成结构体,类似这样:

1
2
3
4
5
6
struct arr{
int a;
int b;
int c;
...//一共100个
}

我们可以这样赋值和初始化

1
2
3
4
arr[0] = 1;
arr[1] = 2;
int arr[10] = {0}; //初始化为0
int arr[10] = {}; //初始化为0,从C23开始

其中的0,1用于指定从arr开始我们申请的第几个type长度的小空间,[]中的数我们称为下标,我们通过下标去访问数组的值。

那么,我们的数组下表支持变量吗,回答是肯定的。我们可以这样使用

1
2
3
int arr[10] = {};
int a = 0;
arr[a] = 1; //讲arr[0]的值改为1,因为a是0

其实,可以把内存当作一个超长的数组,每个地址都是一个下标,访问地址0x10000时,可以理解为取memory[0x10000]中存放的值。C语言虽然没有直接定义一个memory数组来让我们直接操作,但是提供了一个额外的类型和操作符*&(解引用运算符和取址运算符)来让我们自由地使用内存,那就是指针,指针就相当于memory数组的下标

4 声明指针

1
2
3
4
5
6
7
type *identifier;
int *ptr;//声明一个指向int型数据的指针

//操作memory[0x10000]
ptr = (int*)0x10000;//这里原来的数据是int型,和指针类型不同,所以要先进行转换
*ptr = 100;//等价于memory[0x10000]=100;
int a = *ptr;//等价于a = memory[0x10000];

通过以上的方法可以实现自由操作内存地址的值。

你可能会好奇,按上面所说ptr直接表示了一个内存地址,为什么还需要添加type标识来表示它指向空间的类型呢。其实这也很好理解。内存是连续的字节数组,但是使用时并不一定按字节使用,如果这里的空间是你给int a生成的空间,那这个空间肯定是int型的,你如果按char来使用,那就会错误。

好了,我们了解了指针如何赋值了,但是有个问题,如果我想要指向一个已经生成的标识符的地址,又该怎么赋值呢,这里就用到了另一个运算符&

1
2
3
4
5
int *ptr;
int a = 0;
ptr = a;//错误,不能把一个数赋给指针(或者说内存数组的下标)
*ptr = a;//正确,可以把数赋给指针指向的地址(等价memory[ptr]=a)
ptr = &a;//正确,对标识符使用&取址运算符取得目标标识符在内存中的地址,然后赋给指针,这样ptr指向的地址就是a的地址。对*ptr进行的操作就是对a进行的操作

不难看出,*和&是两个互逆的运算符,*把一个地址转为地址中的值,&把一个值转为对应的地址,当然,&运算符使用的前提是这里要有这个空间,标识符表明一个空间,因此可以使用&标识符来获取这个空间的地址,单纯的一个数没有空间所以不能&1。

可以来看看指针和普通标识符的区别及联系:

普通标识符 指针标识符
默认表示 数据 目标地址
使用* 无效 数据
使用& 自身地址 自身地址

可以理解为:普通标识符是默认为值的,用&获取地址,而指针标识符是默认为地址的,用*获取值。

现在假设

1
2
3
int a = 0;
int *ptr1 = &a;
int **ptr2 = ptr1;

根据上面的表格可以得出

a ptr1 ptr2
默认表示 0 a的地址 ptr1的地址
使用* 无效数据 0 ptr1的值(即a的地址)
使用** 无效数据 无效数据 0
使用& a的地址 ptr1的地址 ptr2的地址

很好理解吧。

02 计算

基础运算符,这里就只说明一下,具体去网络上搜索

1算数运算符

+,-,*,/,%,结合性均为从左至右

返回值类型为操作数的类型(不一样按空间大的计算),返回值为数学计算出的值

/和%右操作数不能为0,如果/的操作数有一个是浮点型,结果就是浮点型,否则为整除运算.

1
2
3
4
5/3
//结果为1
5.0/3
//结果为1.666666

%的左操作数决定返回值类型

C语言的取整为趋零截断,出现的所有小数变为整数时向0取整

2自增运算符

i++,++i,i–,–i,和最近的一个结合

i++表明先运算完这个语句,然后执行i=i+1

++i表明先执行i=i+1,然后运算这个语句

1
2
3
4
5
i=0;
printf("%d",i++);//0
printf("%d",i);//1
printf("%d",--i);//0
printf("%d",++i);//1

3赋值运算符

结合性从右到左

复合赋值

+=,-=,*=,/=,%=

a+=1表示a=a+1同理其它的

4关系运算符

返回值均为真假(0或1),结合从左到右

<,>,==,!=,>=,<=

分别为是否小于,是否大于,是否等于,是否不等于,是否大于等于,是否小于等于

注意==和=的区别

5逻辑运算符

a && b 是否同时成立

a || b 是否至少一个成立

!a 是否不成立

6三元运算符

a?b:c

表示a如果不为0,则返回(执行)为b,否则返回(执行)c,这里写执行是提醒副作用

7位运算符

& 按位与

| 按位或

~ 按位非

^ 按位异或

<< 左移,空位补0

> > 右移,如果有符号,补符号,称为算数右移,否则是逻辑右移

以上优先级表请自行查看,注意位运算符和算数运算的优先级关系

好了,通过以上内容,我们已经可以操作一个空间进行赋值和计算了,然后就是面向过程的核心,操作过程,即控制程序的执行流程。

0x04 过程控制

面向过程中分为三种结构:顺序,选择,循环

顺序就是指令依次执行从上到下的一种结构,所有指令执行且只执行一次。
选择就是根据条件来执行分支中代码的结构。指令执行0次或1次。
循环就是根据条件多次重复执行代码的结构。指令执行0次,1次或多次。

使用上面三种结构使得所有代码可以有逻辑地执行我们想让它执行的次数。
一般情况下,我们用顺序结构做连接,让选择和循环结构分块来执行我们的程序。由于顺序结构就是正常的语句,我们这里直接跳过。

01 选择结构

我们一般使用if/else和switch来写选择结构

if-else

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if(x){//单if

}

if ( x ){//if+else
//xxx
}
else{
//xxx
}

if (x){//if+else{if+else}
//xxx
}
else
if(y){
//xxx
}
else{
//xxx
}

若if括号中任意内容的返回值为非0则执行,否则跳过,如果有else就执行else,如果if或else后只有一条语句则不需要写大括号,由于if本身也是一条语句所以:

1
2
3
4
5
6
7
if(a)
if(b)
if(c)
if(d)
printf("不用写大括号,爽!");
else
printf("我和if(d)匹配");//else只和最近的未匹配的if匹配

因此,if-else if语法也不需要定义可以直接使用因为if else整个是一条语句

1
2
3
4
5
if (a)
printf("hh");
else if(b)
else
printf("神奇的");

如果我们要选择的内容是离散的,可以直接枚举,那么使用switch更方便

1
2
3
4
5
6
7
8
9
switch(a){
case x://如果a的值是x
printf("匹配成功");
case y://如果a的值是y
printf("匹配成功");
case z:
break;
default://如果上面的都不是,默认
}

可以看到这里有一个新语法叫做case :,c语言中,只有两个地方使用了冒号,一个是switch,一个是goto,可以想象:冒号用于给一个语句开头贴标签,语句跳转时,直接跳转到这个标签的位置继续执行而不会考虑这里有没有其它标签。因此如果a的值是x,会输出两次匹配成功,因为它跳转到case x的标签处继续执行。那么有什么办法可以防止多个情况被匹配到呢,这里使用了break关键字来跳出这个块,从而结束了继续执行。因此,可以在每个case最后放一个break;来防止继续执行到下一个地方

02 循环结构

如何让一个操作多次执行呢,使用循环结构吧

基本循环:

1
2
3
for(init;check;cycle){
//body
}

解释一下for的小括号里面的内容:

第一个是循环进入时执行的语句,cycle是每次循环结束时执行的语句,check是每次进入循环前执行的语句,如果这个语句的条件为否就跳出循环,否则一直循环。注意,这里的三个内容均为语句,不可以直接写表达式!比如:1。但是可以留空。

顺序是init->check->body->cycle->check->body->cycle->check->…

while循环

1
2
3
4
5
6
7
while(a){
//body
}

do{
//body
}while(a);

这里的a可以写任意内容,只有当内容不为0时才会继续执行,否则跳出。

1控制语句

有三个语句可以用来改变执行流:

  • break;:离开循环体或switch块
  • continue;:跳过这个语句之后的内容。进行下一次循环
  • goto x;:跳到x标签的位置,设置标签直接在函数内使用x:就可以在这一行设置标签。

(语法篇结束)