C语言
C语言进阶

C语言进阶

函数

创建和使用函数

C语言的函数调用需要在函数定义之前,因此可以先定义原型函数,在原型函数之后再进行函数的具体定义。或者直接将函数的定义定义在函数被调用前。

void hello(); // 先申明后定义
void helloDirect() { printf("Hello Direct"); } // 直接定义
 
int main() {
  hello();
  helloDirect();
  putchar('\n');
}
 
void hello() { printf("Hello World"); }

函数参数与返回

若希望每次调用函数时能保存变量的值,可以使用静态变量

void printCount() {
  static int count = 0;
  count += 10;
  printf("%d", count);
}

递归调用

函数自身调用自身的行为被成为递归,递归的函数一般有退出递归的条件,否则理论情况下函数将被无限的执行。但函数拥有最大深度限制,计算机不会让函数无限的被递归下去

// 这段函数将会在被执行一定次数后退出
void test(int i) {
  printf("%d", i);
  putchar('\n');
  test(i + 1);
}

指针

可以使用*&符号来申明一个指针变量,或获取一个变量的地址

int a = 10;
int *b = &a; // 获取a的地址并赋值给b

此时可以通过*(解引用操作符)来获取指针变量中存储的值

printf("%d", *b);

此处访问指针所指向地址的值时是根据类型来获取的,只会读取对应长度的数据

同样的,我们也可以通过这种方式修改指针位置存放的值

*b = 20;

如果不给指针变量赋初值,指针将指向一个不确定的地址,在申明一个指针时建议直接初始化或将其初始化为NULL

常量指针与指针常量

int a = 10;
int b = 30;
int* const p = &a; // 指针常量
const int* q = &a; // 常量指针
const int* const r = &a; // 常量指针常量
*p = 20; // 指针常量可以修改其指向的值
q = &b; // 常量指针可以修改指针的指向
printf("%d\n", *p);
printf("%d\n", *q);

const常量修饰符在类型变量名前,即代表是其对应的常量

指针与数组

数组实际上变向地使用了指针,可以将其看作一个指针变量,其存储的即为地一个元素的起始地址

可以使用数组或指针的方法表示一个数组

char str[] = "Hello World";
char* p = str;
printf("%c,%c", str[0], str[1]);
printf("%c,%c", *p, *(p + 1));

此处的+1并不是将指针+1,而是让地址+ 1倍对应类型大小

同样的,二维数组也能使用指针进行表示

int arr[][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
for (int i = 0; i < 3; i++) {
  int *outer = *(arr + i); // 获取到数组 
  for (int j = 0; j < 3; j++) {
    int inner = *(outer + j); // 获取到数组内的元素
    printf("%d\t", inner);
  }
}

多级指针

即指针之间的嵌套

int a = 10;
int *p = &a;
int **pp = &p;
int ***ppp = &pp;
printf("%d", ***ppp);

指针数组与数组指针

指针数组:存放指针作为元素的数组

数组指针:指向数组的指针

int a, b, c;
int* arr[] = {&a, &b, &c};  // 指针数组
*arr[0] = 900;
printf("%d", a);
int arr[3] = {111, 222, 333}; // 数组指针
int(*p)[3] = &arr;
printf("%d", *(*p + 0));

指针函数与函数指针

函数可以返回一个指针类型的结果,这种函数被称为指针函数

int *returnPointer(int *a) { return a; }

指向函数的指针被称为函数指针,可以解引用或者直接使用指针变量调用函数

int sum(int a, int b) { return a + b; }
 
int main() {
  int (*p)(int, int) = sum;
  // 类型(*指针变量名称)(函数参数)
  int resultOne = p(1, 2);
  int resultTwo = (*p)(1, 2);
  printf("%d,%d\n", resultOne, resultTwo);
}

通过函数指针,我们可以编写函数回调

int operate(int (*operation)(int, int), int a, int b) {
  return operation(a, b);
}
 
int sum(int a, int b) { return a + b; }
 
int main() {
  int result = operate(sum, 1, 2); // 传入函数指针
  printf("%d\n", result);
}

结构体、联合体和枚举

创建和使用结构体

使用struct可以定义一个结构体,它能够作为函数的参数与返回值

struct Student {
  int id;
  int age;
  char *name;
};

使用结构体名称作为类型即可创建一个结构体变量,并用{}为其赋予初始值

struct Student student = {10, 10, "Cherry"};

在初始化时,若不提供初始值,那么剩下的内容将不会被赋予初始值

也可以为指定的字段赋予值

struct Student studentA = {10, 10};
struct Student studentB = {10, .name = "Cherry"};

使用.来为读取或修改结构体中的属性

struct Student studentA = {10, 10};
struct Student studentB = {10, .name = "Cherry"};

可以使用sizeof来获取结构体的大小

但结构体大小的计算满足以下规则:

规则一:结构体中元素按照定义顺序依次置于内存中,但并不是紧密排列的。从结构体首地址开始依次将元素放入内存时,元素会被放置在其自身对齐大小的整数倍地址上(0默认是所有大小的整数倍)

规则二:如果结构体大小不是所有元素中最大对齐大小的整数倍,则结构体对齐到最大元素对齐大小的整数倍,填充空间放置到结构体末尾。

规则三:基本数据类型的对齐大小为其自身的大小,结构体数据类型的对齐大小为其元素中最大对齐大小元素的对齐大小。

结构体指针

可以创建一个指向结构体的指针,它指向结构体所对应的内存地址

可以通过->快速的将结构体指针中的值取出

struct Student student = {10, 20, "Cherry"};
struct Student *studentPointer = &student;
printf("%s", studentPointer->name);

结构体在作为函数参数传递时,也是值传递,若想要在函数中修改结构体,推荐传入结构体指针

void setAge(struct Student *student, int age) { student->age = age; }
 
int main() {
  struct Student student = {10, 10, "Cherry"};
  setAge(&student, 30);
  printf("%d", student.age);
}

联合体

使用union可以定义一个联合体

union Object {
  int a;
  char b;
  float c;
};

在联合体中的所有变量共用内存空间,其用法与结构体类似

int main() {
  union Object object;
  object.a = 10;
  printf("%d", object.b);
}

枚举

使用enum可以定义一个枚举

enum power { low = 0, middle = 1, high = 2 };
enum power p = low;
printf("%d",p); // 0

枚举能够被应用到switch语句中:

enum power p = low;
switch (p) {
case low:
    printf("Power is low");
    break;
case middle:
    printf("Power is middle");
    break;
case high:
    printf("Power is high");
    break;
}

如果不给枚举元素赋予初始值,将会默认从0开始依次赋值

如果中途被设置了其他值,将会从指定值再次开始

enum power { a, b, c = 7, d };

typedef 关键字

typedef能够给任意类型定义别名

typedef struct Student * StudentPointer;

预处理

文件包含

使用include关键字能够包含头文件

#include<stdio.h>

#include<xxxx.h>:引用编译器的库路径内的头文件

#include"xxxx.h":引用相对路径中的头文件,如果找不到再去上方的库中寻找

一般在头文件中定义函数原型,具体的函数实现还是采用C语言编写

// test.h
int test(int a,int b);
// test.c
#include "test.h"
 
int test(int a, int b) { return a + b; }

实现方法所在的源文件不一定需要同名,但在使用CMake时,需要将对应实现文件加入CMakeLists.txt

// main.c
#include <stdio.h>
 
#include "test.h"
 
int main() { printf("%d", test(10, 20)); }

系统库介绍

string.h

计算字符串长度 : strlen

字符串拼接 :

第二个参数的字符串将会拼接在第一个字符串后,且必须要保证第一个字符串的长度足够

char a[15] = "Hello";
char *b = "World";
printf("%s", strcat(a, b));

值得注意的是,第一个参数不能写为指针的形式,因为指针形式时字符串的长度固定

字符串拷贝: strcpy

字符串比较: strcmp

char *strA = "Hello";
char *strB = "HelloWorld";
printf("%d\n", strcmp(strA, strB));

将两个字符串从首字符开始逐个字符的比较,直到某个字符不相同后才停止比较,字符的比较将按照ASCII码进行判断

math.h

不小于x的最小整数 : ceil

不大于x的最大整数 : floor

快速求绝对值 : fabs

使用三角函数 : tan

printf("%f",tan(M_PI/2));
// 16331239353195370.000000

对于C语言中的三角函数tan,由于PI是一个无限不循环的无理数,无法使用有限的二进制位表示,所以PI实际被存储为一个略小于PI实际值的值。因而tan(M_PI/2)输出的将会是一个很大的值,而不是INF(无限大)

stdlib.h

void指针:void *

void指针是一种特殊的指针,表示为无类型指针,由于void没有特定的类型,因此它可以指向任意的元素类型

快速排序:qsort:

int compare(const void *a, const void *b) {
  int *x = (int *)a, *y = (int *)b;
  return *x - *y;
}
 
int main() {
  int arr[] = {1, 1, 4, 5, 1, 4, 1, 9, 1, 9, 8, 1, 0};
  qsort(arr, sizeof(arr) / sizeof(int), sizeof(int), compare);
  for (int i = 0; i < sizeof(arr) / sizeof(int); i++) {
    printf("%d\t", arr[i]);
  }
  return 0;
}

退出程序:exit

申请\释放内存空间:malloc & free

mallocfree一般成对使用,且需要注意指向被释放的内存空间的指针

宏定义

把参数批量替换到文本中,这种实现通常称之为宏或定义宏,C语言可以通过define定义宏

define可以被添加参数

#define MUL(x) x* x
 
int main() {
  printf("%d", MUL(9));
  return 0;
}

若想在字符串中添加一个宏定义的参数,可以使用#

#define hello(str) printf("Hello " #str "")

使用##可以对宏定义进行拼接

#define hello(str) Hello##str
 
int main() {
  int hello(Cherry) = 10;
  HelloCherry = 30;
  printf("%d", HelloCherry);
  return 0;
}

同样的,也可以使用undef取消宏定义

C语言中也预置了一些宏(部分)

宏名称含义类型
__DATE__当前的日期字符串
__TIME__当前的时间字符串
__FILE__当前源文件路径字符串
__LINE__当前源文件代码行数整数

条件编译

可以使用ifdefelseendifendif这四种条件编译指令,仅当特定符号被定义后才执行预处理命令

#ifdef PI
#define M 30
#else
#define M 40
#endif

同时,也能使用ifelif

#define M 30
#if M == 30
#define N 20
#elif
#define N 30
#endif

文件输入/输出

可以使用stdio.h中的file函数打开一个文件

#include <stdio.h>
 
int main() { FILE *file = fopen("../hello.txt", "rw"); }

第一个参数为文件路径,第二个为文件打开模式

模式含义
"r"只读模式打开文件
"w"以写模式打开文件,把现有文件长度截为0,如果不存在则创建一个文件
"a"以写模式打开文件,在现有文件末尾追加内容,如果文件不存在则创建一个文件
"r+"以更新模式打开文件,这个文件必须存在
"w+"以更新模式打开文件,如果文件存在,则将其长度截为0,如果不存在,则创建一个文件
"a+"以更新模式打开文件,在现有文件的末尾添加内容,如果不存在则创建一个新文件,可以读写整个文件,但是只能从末尾添加内容
加上"b"与a+类似,但是以二进制模式打开文件

getc读取

可以使用getc来读取文件(文件模式必须可读)

FILE *file = fopen("../hello.txt", "r"); // 只读打开一个文件
if (file != NULL) {
  int c;
  while ((c = getc(file)) != EOF) { // EOF代表文件末尾
    putchar(c);
  }
  fclose(file); // 操作完毕后需要关闭文件释放资源
} else {
  printf("File open faild");
}

putc写入

FILE *file = fopen("../hello.txt","w");
if (file != NULL) {
  for (int i = 0; i < 10; i++) {
    putc('A' + i, file);
  }
  fclose(file);
}

fflush刷新缓冲区

FILE *file = fopen("../hello.txt", "w");
if (file != NULL) {
  while (1) {
    char c = getchar();
    if (c == 'q') break;
    putc(c, file);
    fflush(file);
  }
  fclose(file);
}

可以使用setvbuf手动设置缓冲区大小,其中:

参数说明
_IONBF表示不使用缓冲区
_IOFBF表示只有缓冲区填满了才更新到文件
_IOLBF表示遇到换行就更新文件
char buf[3];
setvbuf(file, buf, _IOFBF, 3); // 设置缓冲区大小为3

fprintf & fgets

FILE *file = fopen("../hello.txt", "w");
if (file != NULL) {
  char *name = "Cherry";
  fprintf(file, "Hello %s\n", name); // 以指定格式输出到文件
  fputs("Hello World", file); // 输出字符串到文件
  fclose(file);
}

随机访问

跳转到指定位置:fseek:

FILE *file = fopen("../hello.txt","r");
if (file != NULL) {
  fseek(file, 2L, SEEK_SET); // 依据偏移量(long类型)跳转到指定位置
  putchar(getc(file));
  fclose(file);
}

其中,起始点有:

起始点说明
SEEK_SET从文件开始处开始
SEET_CUR从当前位置开始
SEEK_END从文件末尾开始

获取当前位置:ftell

获取/设置当前位置:fgetposfsetpos:

FILE *file = fopen("../hello.txt", "r");
if (file != NULL) {
  fpos_t pos; // 文件位置需要用fpos_t类型存储
  fgetpos(file, &pos); // 将文件位置赋予给pos
  fseek(file, -2L, SEEK_END);
  fsetpos(file, &pos); // 将文件位置设置为pos
  printf("%ld", ftell(file));  // 文件位置仍然为pos位置
  fclose(file);
}

二进制文件读写

FILE *file = fopen("../target.png", "rb");
FILE *copy = fopen("../result.png", "wb");
if (file != NULL) {
  char buf[1024]; // 使用char类型的数组暂存数据,每次读取1024字节
  size_t s;
  while ((s = fread(buf, sizeof(char), 1024, file)) > 0) {
    // 每次从文件中读取1024字节到数组中
    fwrite(buf, sizeof(char), s, copy);
    // 再将这1024字节写入文件
    // s代表每次实际读取到的长度_
  }
  fclose(file);
  fclose(copy);
}
FILE* file = fopen("../target.iso", "rb");
FILE* target = fopen("../result.iso", "wb");
if (file != NULL) {
  fseek(file, 0L, SEEK_END);
  long size = ftell(file); // 获得文件的大小
  fseek(file, 0L, SEEK_SET);
 
  char buf[1024];
  size_t s, all = 0;
  while ((s = fread(buf, sizeof(char), 1024, file)) > 0) {
    fwrite(buf, sizeof(char), s, target);
    all += s;
    printf("%f%%\n", (double)all / (double)size * 100);
  }
  fclose(file);
}

程序编译和调试

GCC编译过程

gcc -E main.c -o main.i # 对源文件预编译
gcc -S main.i -o main.s # 将源文件编译为汇编程序
gcc -c main.s -o main.o # 将汇编文件编译为二进制文件
gcc main.o -o main # 进一步链接,转换为可执行文件

或者可以省略上面的命令,直接

gcc main.c -o main

若多个文件被引入,则需要在编译时将其余文件也加入命令中,或将文件单独编译为二进制文件,最后再一起编译

使用Make和CMake进行构建

Make

编写Makefie以定义Make的构建方式

一个Makefile需要有以下格式:

targets:prerequisites
  command

例如,需要将main.cstudent.c编译为二进制文件后再一起编译为二进制文件

main:main.o student.o
 gcc main.o student.o -o main
 
student.o:student.c
 gcc -E student.c -o student.i
 gcc -S student.i -o student.s
 gcc -c student.s -o student.o
 
main.o:main.c
 gcc -E main.c -o main.i
 gcc -S main.i -o main.s
 gcc -c main.s -o main.o

输入make指令即可开始编译,在上一次编译后若没有文件被更新,则make不会被触发

CMake

Cmake是一个跨平台的Makefile生成工具

以以下CMakeLists.txt文件为例

cmake_minimum_required(VERSION 3.5) # 指定所需CMake的最小版本
project(Demo C) # 指定项目名称和编程语言
set(CMAKE_C_STANDARD 99) # 设置环境变量
add_executable(Demo main.c) # 添加需要编译的文件

使用以下的命令即可触发生成

cmake -S . -B test -G "Unix Makefiles"

-S后代表源文件目录、-B代表构建目录、-G为选择生成器

使用LLDB调试工具

值得注意的是,程序在编译阶段启用了Debug才能够被调试工具调试

使用lldb命令即可对程序进行调试

r:重头开始运行程序

b 行号:在指定行打断点

c:继续运行

p:打印变量的值

q:结束程序