Linux C开发基础知识复习上

环境搭建

传统的linux C开发通常采用服务器/虚拟机,加远程SSH连接的方法,并采用vim编辑器的环境开发,这里有一个弊端,就是vim比较难用,而且没有代码补全,错误提示等功能,对于开发者十分不友好,所以这里采用一种现代化的方法,通过VS2017以后的远程开发功能或者vs code + MinGW + 远程开发功能,这里推荐后者。因为轻量、

 

因此,我们采用一种折中的更灵活的方案,在Windows上使用GCC编译器以及一些GNU工具链来模拟Linux 下的C语言学习体验,这样一来,我们既能使用熟悉的Windows系统,也能利用GNU工具链来学习C语言,熟悉了GNU工具链,就相当于掌握了Linux C的开发环境,在通过远程开发环境直接调用POSIX API, 由于有强大的代码补全功能,开发效率比较高。

配置vscode C/C++ 开发环境,这一步网上自行查资料解决。熟悉windows下gdb调试模块。

 

C语言快速复习

在终端下,使用gcc编译C语言程序

单个文件编译命令

gcc hello.c -o hello

有时候我们写的程序有语法错误,我们希望编译的时候编译器能给出详细的提示信息,这时候就可以加上另一个参数-Wall,让编译器在编译器时输出更多更详细的的信息,建议每次编译都加上这个参数,这样有什么错误也好查

gcc hello.c -Wall -o hello

以上就是我们本篇学习的编译命令,总结一下就是两个参数

  • -o 指定生成的文件名

  • -Wall 让编译器工作时输出更多详细信息

C语言数据类型

以上表中,long类型有两种情况,使用32位编译器编译,则long为4字节,占32位,如果使用64位编译器,则long为8字节,占64位二进制。当然这个也不是绝对的,仍然与系统环境有关。以上就是标准C中的基础数据类型,要记住,C中没有long long类型,很多人将C语言与C++语言搞混,切记!

补充说明
在C99新标准中,对C语言进行了扩展,其中提供了几种新的类型

  1. 新增复数类型(_Complex)和虚数类型(_Imaginary

  2. 新增布尔类型(_Bool

  3. 新增整数类型long long int,该类型用于表示64位整数,共8字节,请注意与C++中的long long区分

在C99 版本以前,C语言中是没有这些类型的,然而,C99并不是一个被广泛支持的C语言版本,例如微软旗下的VC编译器就坚决不支持C99,这些阻碍导致C99无法被普及,使得C语言新特性被割裂。

总结一句话就是这些新特性目前并没什么用,使用新特性语法将导致C语言难以被移植到其他平台,比如Windows,最后提一句,我们使用的GCC编译器是支持C99标准的,它不仅支持C99,它还支持目前C语言最新的标准C11,可见GCC编译器是非常厉害的。

 

修饰数值类型

除了直接使用这些类型,通常还会使用一个关键字unsigned来修饰,它表达的意思是无符号,例如:

1unsigned int len = 10;

我们以前讲过,计算机中,最高位是符号位,例如32位,只有31位是有效位,因为最高位要用来表示符号,为0表示正数,1则表示负数。这样一来能用于表示的实际范围就变小了,有时候我们根本不需要使用负数,这时候就可以使用unsigned关键字来提升表示的范围,例如用unsigned修饰int后,就能将32位都有效的用于表示范围,则变量len能表示的范围变成了0 ~ (,如果不加关键字unsigned,则默认被signed修饰,即int len = 10; 等价于signed int len = 10;,通常我们当然是用简洁写法,什么都不写啦。

 

基本数据类型的打印

在第一个示例中,我们已经使用printf函数打印了hello world,这里print是打印的意思,那么f是什么意思呢?其实这里的f是缩写,是format的意思,代表格式化打印。既然是格式化打印,那一定会有格式占位符了,例如我要打印“”He is 18 years old"这句话,显然18是一个变量,他今年18,明年就是19,所以在这句话中需要把一个变量拼进去,这个时候就可以使用占位符,占位符的作用就是先把位置占着,到合适的时候在替换,这就像拿个水杯到图书馆占座一样。根据输入输出的变量的类型不同,占位符也不同,这里介绍最常用的几个

  • %d 有符号十进制整数

  • %f 浮点数

  • %s 字符串

  • %c 单个字符

  • %x 十六进制整数

 

C语言还有一种在一行声明多个变量并初始化的方式,请警惕这其中的陷进

1    int a, b, c=10;

以上代码中,只有变量c在声明的同时进行了初始化,而ab均未初始化,在后续中可能会导致未对其初始化就使用了。建议在声明时都进行零值初始化

1int a = 0, b = 0, c = 10;

 

常量

C语言中使用const关键字修饰的就是常量,常量是不能修改的。C语言中,约定使用变量名大写来表示常量,多个单词则使用下划线分隔,例如MD_MARK,这只是一种编码风格,不是必须的,但是建议遵守它,否则你可能会受到同行的鄙视。这样的好处是看的人一眼能识别出这是一个常量,而且能避免一些命名冲突。

1const int PI = 3.14;

 

小拓展:C语言中int的正确使用姿势

上一节已经讲过,由于C语言中,整型的实际长度和范围不固定的问题,会导致C语言存跨平台移植的兼容问题,因此,C99标准中引入了stdint.h头文件,有效的解决了该问题。

 #include<stdio.h>
 #include<stdint.h>
 
 int main(void){
    // 使用stdint.h中定义的类型表示整数
    int8_t a = 0;
    int16_t b = 0;
    int32_t c = 0;
    int64_t d = 0;

    // 前面加u,表示unsigned,无符号
    uint32_t e = 0;
    printf("int8 size is %d\n",sizeof(int8_t));
    printf("int16 size is %d\n",sizeof(int16_t));
    printf("int32 size is %d\n",sizeof(int32_t));
    printf("int64 size is %d\n",sizeof(int64_t));
    printf("uint32 size is %d\n",sizeof(uint32_t));
}

打印结果:

int8 size is 1
int16 size is 2
int32 size is 4
int64 size is 8
uint32 size is 4

int8_t即表示8位整型,同理,int64_t就是64位整型,类型定义明确清晰,且能兼容多种平台。以上代码,使用32位编译器,编译成32位系统下的程序后,运行得到的结果依然不变。这里一定会有朋友质疑,为什么32位的系统下,还能表示并使用int64这种64位的整型?这当然就是stdint.h库给我们带来的便利了,简单说一下原理,如果当前平台的是32位的,那么经过组合,我们可以使用两个32位拼起来,不就能表示64位了吗?同理,即使是8位的CPU,经过这种拼合思路,照样能表示64位!当然,聪明人一眼就看出了弊端,使用这种拼合的方式,数据需要经过组合转换,处理也更加复杂,同时还会带来性能的损失,但是C99标准库已经为我们处理好了一切,虽然付出了一定的性能损失,但是成功的实现了C语言整型的跨平台兼容,这样的损失是完全值得的。

由于stdint.h头文件是C99标准引入的新特性,前面也说过微软的VC编译器不支持C99,那是不是VC就不能用了呢?好东西,当然人人眼馋,微软虽然表面上说不支持C99,但是这种有用的特性还是会引入,因此VS2010也引入了stdint.h头文件,在VS2010及其以后的版本中,可以放心使用。但是要注意,只是引入了这个新特性,而不是支持C99。这里就要吐槽了,目前还在使用VC6.0教学的,还是上个世纪的人么?说和工具没关系的这些人,害人匪浅。

 

那么i++++i的区别是什么呢?
关于这两者的区别,某些教材和网上一些资料是这样解释的,++做前缀,是先让i加1,做后缀则后加1,既在下一行代码前i被加1。类似这种说法其实是不准确的,甚至是错误的,理解太过于表面,只是对现象的概括而已。这里咱们就一次把这个问题彻底搞明白,永不犯迷糊。

前面已经说了,C语言强调的是表达式而不是语句,那么表达式和语句有什么区别呢?我个人认为其中一个区别就是表达式整体一定有一个值,而语句可以没有返回值。有其他编程基础的朋友一定清楚所谓返回值的概念,那么就是说表达式一定有一个返回值,或者应该说是表达式整体的值。

i++作为一个表达式,那么他的表达式的值是什么呢?其实我们可以用一个变量来保存表达式的值int r = i++;

int i = 0;
int r = i++;

printf("r=%d\n",r);
int i = 0;
int r = ++i;

printf("r=%d\n",r);

 前者r = 0, 后者r=1, 这种题目在笔试题会出现

 

因此,遇到复杂的自增运算符时,只需要问自己两个问题,自增变量的值是几?表达式整体的返回值又是几?下面我们看一个很常见的问题,问ij打印的值各是几?

1    int i = 0;
2    int j = i++ + ++i;
3    printf("i=%d, j=%d\n",i,j);

按照我们上面讲的知识来分解,先把式子拆分成(i++) + (++i);(i++)这个表达式整体的值是0,但此时i的值已经变成1了。而在(++i)这个表达式中,i的值则是1 + 1,所以执行(++i)后,i的值为2,那么j的值也就是0 + 2

大家千万要记住,不管是i++也好,++i也罢,变量i的值都会立刻增加,所以只看i的值,这两者是没有区别的,它的区别在我们说的另一个概念上,也就是所谓的表达式的返回值。

好了,授人以鱼不如授人以渔,如何证明我说的就是对的,别人的是错误的呢?C语言就是有一个好处,一切纷繁复杂的表象都能回归事物的本质。因为C语言与汇编语言是一一对应的,因此我们只需要查看C语言翻译成汇编语言后,在计算机内部到底发生了什么就能掌握真理,而无需人云亦云。

 

 

  • 进阶语法

    • 指针与数组

      • 指针的算术运算

      • 数组名与指针

    • 指针与字符串

      • 字符串的进阶

        • 实现简单正则表达式匹配器

    • 指针常量与常量指针

      • 指针常量

      • 常量指针

      • 指向常量的常量指针

进阶语法

指针与数组

 1#include <stdio.h>
 2
 3int main(){
 4    int arr[5]={1,2,3,4,5};
 5
 6    // 依次打印数组每个元素的地址
 7    for (int i = 0; i < 5; i++){
 8        printf("p: %x\n",&arr[i]);
 9    }
10    return 0;
11}

打印结果

1p: 22fe30
2p: 22fe34
3p: 22fe38
4p: 22fe3c
5p: 22fe40

由上例可验证,数组的内存空间是连在一起的,它的第一个元素地址是0x22fe30,第二个元素的地址是0x22fe34,紧随其后。因为是int数组,每个元素都需要占用4个字节空间,因此地址的间隔也是4。

指针的算术运算

 1#include <stdio.h>
 2
 3int main(){
 4    int arr[5]={1,2,3,4,5};
 5
 6    // 声明指针p,指向数组的首元素
 7    int *p = &arr[0];
 8
 9    // 将指针变量加1,表示偏移一个单位
10    printf("arr[0]=%d  address=%x\n",*p, p);
11    printf("arr[1]=%d  address=%x\n",*(p + 1), (p+1));
12    printf("arr[2]=%d  address=%x\n",*(p + 2), (p+2));
13
14    return 0;
15}

打印结果:

1arr[0]=1  address=22fe30
2arr[1]=2  address=22fe34
3arr[2]=3  address=22fe38

在这里插入图片描述


同理,如果我们取数组最后一个元素的地址,然后对指向最后一个元素的指针执行减1运算,那么指针就会像前偏移,指向倒数第二个元素。

 

学会了指针的运算,再结合解引用,就可以使用指针遍历数组。但是千万要注意,指针偏移时不能越界,也就是说指针必须始终小于或等于数组的最后一个元素的地址,不能超过最后一个元素。

指针变量本质上就是一个32位的整型,内存地址本身也就是一个编号,因此对指针进行算术运算、比较运算都是合理的。

 include <stdio.h>
 
 int main(){
     int arr[5]={1,2,3,4,5};
 
     int *p = &arr[0];
 
     // 使用指针遍历数组
     for (; p <= &arr[4]; p++){
        printf("%d\n",*p);
    }
    return 0;
}

打印结果:

11
22
33
44
55

当然,对于指向数组首元素的指针,我们仍然可以使用下标访问。但是一定要确认,该指针当前是否还指向数组首元素,如果你对指针做过偏移运算,那么它就不再指向首元素,这时使用下标访问,很可能导致访问越界。

 #include <stdio.h>
 
 int main(){

     int arr[5]={1,2,3,4,5};
 
     int *p = &arr[0];
 
     for (int i = 0; i < 5; i++){
         printf("%d\n",p[i]);
    }
    return 0;
}

数组名与指针

 #include <stdio.h>
 
 int main(){
     int arr[5]={1,2,3,4,5};
 
     int *p = &arr[0];
 
     printf("p=%x\n",p);
     printf("arr=%x\n",arr);

    return 0;
}

打印结果:

p=22fe30
arr=22fe30

可以看到,实际上数组名这个变量保存的就是数组的首元素地址。但是数组变量和指向它首元素的指针变量又是完全不同的两个概念。那么数组名和指针又有什么区别呢?

  1. 类型不同。如上,变量p是指针类型,变量arr是数组类型

  2. 性质不同。p是变量,可以修改值,重新指向其他地址。arr内部保存的指针是个常量,不能修改和运算。

  3. 数组类型可以使用sizeof运算,求得整个数组的内存大小,而对指针p进行sizeof运算,只能得到当前指针所占用的内存大小。

现在我们明白了,就算数组名和指针保存的值相同,它们也是两个完全不同的概念。但是我们知道了数组名保存的是首元素地址,那么以后就可以简化代码

   int arr[5]={1,2,3,4,5};

   // 直接使用数组名对指针变量进行初始化,省略&arr[0]的写法,效果是同等的
   int *p = arr;

到这里,大家应该能明白上一章函数部分中,数组做函数的形式参数时,自动退化为指针是什么意思了吧。一旦将数组作为函数的参数,实际上都是将数组的首元素地址复制给了函数的形参,即使你声明的是数组类型的形参也一样。

1// 形参声明为数组类型:char ch[] ,没用!
2// 实际上仍然会退化为指针,编译器不允许在函数传参时,对数组内容进行复制操作,无法实现值传递
3// 因此,ch实际上是一个char *类型的指针而已
4void convstr(char ch[], int flags);

我们可以写个简单代码验证

 #include <stdio.h>
 
 void test(int a[]){
     // 真正的数组类型,是不能进行指针运算的
     // 因此a不是一个数组类型,它就是个指针类型
     printf("a=%x\n",a++);
 }
 
int main(){
    int arr[5]={1,2,3,4,5};
    test(arr);
    return 0;
}

我们上面已经总结了,数组名内部的指针是个常量,不能进行运算,而test函数的形参数组a却可以++运算,说明数组做形参,自动退化为指针类型。

指针与字符串

弄清楚了指针与数组的关系,再看指针与字符串其实就水到渠成了。

 #include <stdio.h>
 
 int main(){
     // 使用字符串指针表示字符串
    char *greet = "hello, Alex";
 
    printf("address=%x\n",greet);
    printf("%s\n",greet);
    return 0;
}

打印 结果:

address=404000
hello, Alex

需要注意,使用字符串指针时,指针本身就表示了字符串,而不要对其进行解引用。

使用字符串指针时,要注意指向字面常量和指向字符数组的区别

 #include <stdio.h>
 
 int main(){
     char *str1 = "hello, Alex";
     char str2[] = "hello, Alice";
 
     str1[0] = 'f';  //报错,不可修改
     str2[0] = 'f';
 
    printf("%s\n",str1);
    printf("%s\n",str2);
    return 0;
}

可以看到,指针str1指向的是一个字面常量,这个字面常量和数组str2所在的内存区域是不同的,它是只读的,不能做修改。而str2是一个字符数组,里面的元素是可以修改的。

字符串的进阶

实现一个类似strlen的函数,计算字符串的长度。

 #include <stdio.h>
 
 int len(char *str){
     int i = 0;
     for (; *str !='\0'; str++,i++);
     return i;
 }
 
int main(){
    char *str1 = "hello,Alex";
    char str2[] = "hello,Alice";

    printf("%d\n",len(str1));
    printf("%d\n",len(str2));
    return 0;
}

打印结果:

 

 

指针常量与常量指针

指针常量

指针常量仅指向唯一的内存地址,一旦被初始化后,就不能再指向其他地址。简单说就是指针本身是常量。

声明格式:【指针类型】 const 【变量名】

     int n = 7;
     int l = 10;
 
     //声明并初始化指针常量
     int* const p1 = &n;
     p1 = &l; // 错误,无法编译!指针常量不能再指向其他地址
 
     // 普通指针,可以指向其他地址
     int *p2 = &n;
    p2 = &l;

声明指针常量时需要注意,星号是紧挨类型的,在之前的章节已经讲过,int* 普通类型加星号合起来才是表示指针类型,因此const关键字是修饰指针变量本身的。当我们对指针常量使用解引用符修改内容时不受影响。

    int n = 7;
    int* const p1 = &n;

    //可使用解引用符,修改指针常量所指向的内存空间的值
    *p1 = 1;    //相当于n=1

当然,也有人喜欢使用另一种风格来声明指针常量,将星号与const紧挨

    int n = 7;
    int *const p1 = &n;

常量指针

常量指针的意思是说指针所指向的内容是个常量。既然内容是个常量,那就不能使用解引用符去修改指向的内容。但指针自己本身却是个变量,因此它仍然可以再次指向其他的内容。

声明格式:const【指针类型】 【变量名】

    int n = 7;
    int l = 10;

    //声明常量指针
    const int *p1 = &n;
    *p1 = 0; // 错误,无法编译!不能修改所指向的内容

    p1 = &l; //它可以再指向其他地址

指向常量的常量指针

指向常量的常量指针,即将上述两种结合到一起,简单说就是指针自己本身是一个常量,它指向的内容也是一个常量。因此它既不能修改指向的内容,也不能重新指向新地址。

声明格式:const【指针类型】const 【变量名】

1    int n = 7;
2    int l = 10;
3
4    //声明指向常量的常量指针
5    const int* const p1 = &n;
6    *p1 = 0; // 错误! 不能修改指向的内容
7    p1 = &l; //错误! 不能重新指向新地址

 

进阶语法

模块化编程

所谓模块化开发,是对源文件的一种组织方式。

多个源文件

最早的C语言仅仅用来编写小而美的代码,总共不超过100行,随着计算机软件的发展,小程序变成了大型软件工程,整个项目是由多人协同开发完成的,一个人显然已经玩不动了,这时候也就出现了模块化编程的概念。

假设现在有小明、小张和小王三人,这三人决定同时开发一个C程序,由小明负责主函数的编写和调用,小张编写一个加法函数,小王编写一个减法函数。这时候三人显然不能同时编辑同一个源码文件,那么就需要每个人编写一个源码文件。

小明的源码 main.c

 #include <stdio.h>
 
 //声明但不实现
 int add(int a, int b);
 int sub(int a, int b);
 
 int main(){
     printf("1+2=%d",add(1,2));
     printf("18-9=%d",sub(18,9));
    return 0;
}

小张的加法源码 t1.c

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

小王的减法源码 t2.c

int sub(int a, int b){
    return a-b;
}

小明写完代码时,发现另两位早就完成了,接下来三人将三份源码放到一起执行编译,这里使用gcc命令编译

gcc t1.c t2.c main.c -o main

多文件编译命令

 

使用头文件

上面的例子是比较简单的演示,但当真实项目中,有几十上百的函数要编写时,多人协作就会显得有些混乱,而且声明与实现混合,代码结构也变得冗长。特别是当完成项目之后,我们需要给每个函数编写注释,解释函数的功能和用法时,会变得很麻烦,非常不易于阅读和维护。这时候我们就需要一种被称为头文件的文本文件,来描述函数。

之前我们一直使用别人的头文件,现在自己也来做一份头文件,创建calculate.h文件,并将函数声明都挪到头文件中

/* 加法函数 */
int add(int a, int b);

/* 减法函数 */
int sub(int a, int b);

这时main.c源文件就变得更简单清晰了

#include <stdio.h>
#include "calculate.h"  //包含头文件

int main(){
    printf("1+2=%d\n",add(1,2));
    printf("18-9=%d\n",sub(18,9));
    return 0;
}

再次执行命令编译,成功!

gcc t1.c t2.c main.c -o main

 

这里有几点需要注意

  1. 头文件和.c源文件放到一个文件夹下

  2. 我们自己本地的头文件,在包含时应当写英文双引号,而不是尖括号

有了头文件以后,我们的声明都可以放到头文件中,然后在源码文件的顶部去包含它。这就是将声明和实现分离,声明单独放一个文件,实现放在源码文件中。这种开发模式,就是模块化开发,也被人称为面向接口的开发。

试想一下,在多人开发之前,大家只要协商好头文件,后面就只需要对照着头文件去写代码,省了很多事。开发完成后,将源代码编译,这时候头文件就相当于一份功能说明书,可以很方便的将二进制和头文件一同发布。

 

关于头文件的总结

以上例子是演示完了,但细心的朋友会发现,这里还遗留了一些问题。

  1. 头文件到底是什么?

  2. 头文件一定要和源代码放在一起吗?

  3. 在包含头文件时,<>""到底有什么区别?

首先回答第一个问题,头文件实际上并不是什么特殊的东西,它仅是一个普通的文本文件,它也可以是任意后缀名的文本文件。例如,我们将calculate.h文件改为calculate.txt,包含时使用#include "calculate.txt",再次使用gcc t1.c t2.c main.c -o main编译,完全没有任何问题。

第二个问题,头文件是可以放置到本机的任意文件夹下的。但一定要学会如何处理头文件路径问题。当我们想将头文件和C语言源文件放在同一根路径下时,为了方便查看,可以单独为头文件再创建一个目录,例如创建一个head目录,将头文件移入,则需要使用相对路径包含的写法#include "head/calculate.h"

当头文件和源代码不在同一级目录下时,则可以为其指定绝对路径,这时又有两种方法。首先将头文件移入到其他盘的任意目录

gcc t1.c t2.c main.c -o main -ID:\workspace\head
1set C_INCLUDE_PATH=D:\workspace\head
2gcc t1.c t2.c main.c -o main

 

预处理

所谓预处理,就是在办正事之前做一点准备工作。预处理指令都是以#号开头的,这一点很好辨认。

在之前,我们已经了解过了#include#define这两个指令,实际上预处理指令并不是C语言词法的一部分,它仅仅是写给编译器看的,让编译器在正式编译之前,先帮我们做点小事情。

学习预处理最好的方法,就是将C语言的预处理-编译-汇编-链接四个阶段拆开,分步进行,这时候正好体现出使用gcc命令行学习C语言的优势。

首先为了简单,先去除标准库的头文件包含,代码如下

#include "calculate.h"  //包含头文件

int main(){
    printf("1+2=%d\n",add(1,2));
    printf("18-9=%d\n",sub(18,9));
    return 0;
}

 

gcc -E main.c -o main.i

 

 # 1 "main.c"
 # 1 "<built-in>"
 # 1 "<command-line>"
 # 1 "main.c"
 
 # 1 "D:\\workspace\\head/calculate.h" 1 3
 
 # 3 "D:\\workspace\\head/calculate.h" 3
int add(int a, int b);
int sub(int a, int b);
 #3 "main.c" 2

# 5 "main.c"
int main(){
    printf("1+2=%d\n",add(1,2));
    printf("18-9=%d\n",sub(18,9));
   return 0;
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值