c语言基础

c语言基础

写在前面

为什么要在这里回顾下C/C++的基础知识,无疑是因为它的高效性。

C语言是一种高效、快速、通用的编程语言,起源于1972年,由贝尔实验室的Dennis Ritchie和Ken Thompson共同开发。最初,C语言的设计是为了开发Unix操作系统,它在计算机领域的发展中起到了至关重要的作用。

C语言的应用场景非常广泛,在操作系统开发(Linux、Unix),嵌入式开发,数据库开发(MySQL、SQLite),编译器和网络驱动程序,游戏开发(Unity游戏引擎等)以及应用程序开发等各方面都发挥的重要中。同时C语言在深度学习的各大框架pytorch,tensorflow,mxnet也扮演着举足轻重的作用。

同样,在AI工程化部署中也十分重要。

所以这里我们发一些章节快速的回顾下c、c++的重要概念和技术。

指针

在C语言中,指针是一种非常重要的概念,它允许我们直接访问内存中的数据。下面是指针的基本使用方法:

指针:指针是一个变量,它存储的是另一个变量的内存地址。我们可以通过指针来访问和修改这个变量的值。

int num = 10;  // 定义一个整型变量
int *p;       // 定义一个指向整型变量的指针

p = #     // 将指针p指向变量num的地址

printf("%d\n", *p);  // 输出变量num的值,使用间接访问运算符*

指针索引:指针可以用来遍历数组,通过改变指针的值来访问数组中的元素。

int arr[] = {1, 2, 3, 4, 5};  // 定义一个整型数组
int *p = arr;                 // 将指针p指向数组的第一个元素

for (int i = 0; i < 5; i++) {
    printf("%d ", *(p + i));  // 输出数组元素的值,使用指针索引
}

指针数组:指针数组是一个数组,其中的每个元素都是一个指针。

int num1 = 10;
int num2 = 20;
int num3 = 30;

int *arr[] = {&num1, &num2, &num3};  // 定义一个指针数组,存储三个指针

for (int i = 0; i < 3; i++) {
    printf("%d ", *(*arr[i]));  // 输出指针指向的变量的值
}

*和&符号的使用:在指针操作中,*符号用来声明一个指针变量,或者用来访问指针指向的变量;&符号用来取得变量的地址。

int num = 10;     // 定义一个整型变量
int *p = &num;   // 将指针p指向变量num的地址

printf("%d\n", *p);  // 输出变量num的值

int *ptr;        // 声明一个指针变量

指针的指针:指针的指针是指一个指针变量存储了另一个指针变量的地址。

int num = 10;       // 定义一个整型变量
int *p = &num;     // 将指针p指向变量num的地址
int **pp = &p;     // 将指针pp指向指针p的地址

printf("%d\n", **pp);  // 输出变量num的值

这些是指针的基本使用方法和相关概念,通过这些例子,你可以更好地理解和掌握指针的用法。

指针的指针在某些特殊场景下非常有用,它可以用来实现多级间接访问和动态内存分配。下面是指针的指针的一些特别应用场景的例子:

函数参数传递:指针的指针可以用于通过函数参数来修改原始指针的值。

void changeValue(int **pp) {
    int *p = *pp;  // 获取指针pp指向的指针
    *p = 20;       // 修改指针p指向的变量的值
}

int main() {
    int num = 10;
    int *p = &num;
    changeValue(&p);
    printf("%d\n", *p);  // 输出20,原始指针的值被修改
    return 0;
}

动态内存分配:指针的指针可以用于动态分配多维数组或指针数组。

int main() {
    int rows = 3;
    int cols = 4;
    int **matrix = (int **)malloc(rows * sizeof(int *));  // 分配二维指针数组的内存空间

    for (int i = 0; i < rows; i++) {
        matrix[i] = (int *)malloc(cols * sizeof(int));  // 分配每一行的内存空间
    }

    // 使用指针的指针访问和修改数组元素
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            matrix[i][j] = i + j;
            printf("%d ", matrix[i][j]);
        }
        printf("\n");
    }

    // 释放内存空间
    for (int i = 0; i < rows; i++) {
        free(matrix[i]);
    }
    free(matrix);

    return 0;
}

字符串数组的排序:指针的指针可以用于对字符串数组进行排序。

#include <stdio.h>
#include <string.h>

void sortStrings(char **strArr, int length) {
    for (int i = 0; i < length - 1; i++) {
        for (int j = i + 1; j < length; j++) {
            if (strcmp(strArr[i], strArr[j]) > 0) {
                char *temp = strArr[i];
                strArr[i] = strArr[j];
                strArr[j] = temp;
            }
        }
    }
}

int main() {
    char *names[] = {"John", "Alice", "Bob", "David", "Cindy"};
    int length = sizeof(names) / sizeof(names[0]);

    sortStrings(names, length);

    for (int i = 0; i < length; i++) {
        printf("%s\n", names[i]);
    }

    return 0;
}

这些是指针的指针的一些特别应用场景的例子。通过使用指针的指针,我们可以实现更灵活和高效的编程。

如何编译你的C/C++代码

gcc和g++ 是 GNU 编译器套件中的 C/C++ 编译器。它可以用来编译 C/C++ 源代码并生成可执行文件。下面是 gcc和g++ 编译器的基本用法:

  1. 编译 C++ 源代码:

    g++ source.cpp -o output
    

    这里 source.cpp 是你的 C++ 源代码文件的文件名,-o output 指定生成的可执行文件的名称为 output。

  2. 包含头文件(include): 如果你的源代码中包含了其他头文件,你可以使用 -I 选项来指定头文件的搜索路径。例如:

    g++ source.cpp -o output -I/path/to/include
    
  3. 链接库文件(lib): 如果你的程序需要链接外部的库文件,你可以使用 -l 选项来指定链接的库文件。例如:

    g++ source.cpp -o output -L/path/to/lib -lmylib
    

    这里 -L 选项指定了库文件的搜索路径,-l 选项指定要链接的库文件名。

  4. 顺序注意事项: 当编译包含多个源文件的程序时,需要注意源文件之间的依赖关系以及编译顺序。通常情况下,你可以将所有的源文件一起编译,例如:

    g++ file1.cpp file2.cpp -o output
    

    如果有依赖关系,可以将依赖的源文件放在前面,确保先编译依赖的源文件。另外,还可以使用 Makefile 等工具来管理编译顺序和依赖关系。

其他常用的选项如下:

  1. -g:生成调试信息。这个选项会在编译时生成调试信息,用于调试程序,这个在gdb调试的时候特别有用。例如:

    gcc -g -o program program.c
    
  2. -Wall:开启所有警告。这个选项会让编译器输出所有的警告信息,帮助开发者发现潜在的问题。例如:

    gcc -Wall -o program program.c
    
  3. -O3/-Ofast:启用最高级别的优化。这个选项会让编译器使用最高级别的优化,以提高程序的性能。优化主要通过 (1)函数内联:将函数调用替换为函数体的实际代码,避免了函数调用的开销。(2)循环优化:对循环进行优化,减少循环内部的计算次数,或者利用CPU的流水线进行更有效的指令执行。(3)标量替换:将数组的元素直接替换为变量,避免了数组访问的开销。(4)消除不必要的内存访问:通过分析代码,减少内存访问次数,提高程序的性能例如:

    gcc -O3 -o program program.c
    
  4. -Wextra:开启额外的警告。这个选项会让编译器输出更多的警告信息,帮助开发者更好地发现潜在的问题。例如:

    gcc -Wextra -o program program.c
    
  5. -msse:指定使用SSE指令集。这个选项会让编译器使用SSE指令集来优化程序,这是指令集编码必要的编译选项。例如:

    gcc -msse -o program program.c
    
  6. -Wl,-rpath:指定运行时链接路径。这个选项用于指定程序运行时链接的库文件路径。例如:

    gcc -Wl,-rpath,/path/to/library -o program program.c
    
  7. -fPIC: 用于生成位置无关的代码(Position Independent Code)。位置无关的代码是一种特殊的机器代码,可以在内存中的任何位置执行而无需进行修改。这对于动态链接库(shared libraries)非常重要,因为动态链接库可以加载到内存的任何位置,并且可以被多个进程共享。使用 -fPIC 选项编译代码时,编译器会生成与位置相关的代码不同的代码,通常会牺牲一些性能来获取这种位置无关的特性。这样生成的代码可以作为共享库使用,供多个进程加载和执行

    gcc -c -fPIC example.c -o example.o
    gcc -shared example.o -o libexample.so
    
    

希望这些信息对你有所帮助!

C语言自定义类型

结构体

C语言中的结构体是一种用户自定义的数据类型,允许将不同类型的数据组合在一起。它由多个成员变量组成,每个成员变量可以是不同的数据类型,包括基本数据类型、数组、指针等。

结构体的作用是可以用来组织和管理相关的数据,并且可以进行整体操作。它可以用于表示复杂的数据结构,如学生信息、员工信息等。

在使用结构体时,需要注意以下几点:

  1. 定义结构体:使用关键字struct定义结构体,指定成员变量的类型和名称。
  2. 访问结构体成员:通过成员运算符.来访问结构体的成员变量。
  3. 结构体作为参数:可以将结构体作为函数参数传递,并在函数内部对其进行操作。
  4. 结构体指针:可以使用指针来引用和操作结构体,通过指针访问结构体的成员变量。

在使用结构体时,需要注意成员变量的访问权限和数据的有效性,避免出现内存访问错误和数据损坏。同时,要注意结构体的内存对齐和填充,以免造成内存浪费和性能损失。

以下是一个简单的例子:

#include <stdio.h>
#include <stdlib.h>

// 定义一个结构体
struct Student {
    char name[20];
    int age;
    float score;
};

int main() {
    // 用指针指向结构体,并分配内存
    struct Student *studentPtr = (struct Student*)malloc(sizeof(struct Student));

    // 使用指针访问结构体成员
    strcpy(studentPtr->name, "John");
    studentPtr->age = 20;
    studentPtr->score = 85.5;

    // 打印结构体成员
    printf("Name: %s\n", studentPtr->name);
    printf("Age: %d\n", studentPtr->age);
    printf("Score: %.2f\n", studentPtr->score);

    // 释放内存
    free(studentPtr);

    return 0;
}

还可以使用typedef关键字来定义结构体的别名,使代码更加简洁易读,声明结构体变量时不需要加struct:

#include <stdio.h>

// 声明结构体并定义别名
typedef struct {
    char name[20];
    int age;
} Person;

int main() {
    // 定义结构体变量
    Person person1;

    // 使用结构体变量...
}

使用typedef关键字,我们可以直接使用Person来定义结构体变量,而不需要再写struct关键字。

枚举类型

枚举(enum)是一种数据类型,用于定义一组命名的常量。它可以增强代码的可读性和可维护性,避免使用魔术数字,并提供了一种更具表达力的方式来表示一组相关的常量。

枚举的应用场景包括定义状态、选项、标志等。例如,可以使用枚举来定义一组方向(上、下、左、右)、一组颜色(红、绿、蓝)、一组状态(开、关、暂停)等。

以下是一个枚举的简单例子:

#include <stdio.h>

// 定义一个枚举类型
enum Color {
    RED,
    GREEN,
    BLUE
};

int main() {
    // 声明一个枚举变量
    enum Color selectedColor;

    // 使用枚举变量
    selectedColor = GREEN;

    // 打印枚举值
    if (selectedColor == RED) {
        printf("The selected color is RED\n");
    } else if (selectedColor == GREEN) {
        printf("The selected color is GREEN\n");
    } else if (selectedColor == BLUE) {
        printf("The selected color is BLUE\n");
    }

    return 0;
}

在这个例子中,枚举类型Color定义了三种颜色(RED、GREEN、BLUE),然后在main函数中声明了一个Color类型的枚举变量selectedColor,并将其赋值为GREEN。最后根据selectedColor的值打印相应的颜色。

C/C++内存操作

在C/C++语言开发中,内存管理是非常重要的,大多Segmentation fault core问题都是因为内存操作不当导致的。

C语言中,内存管理涉及到堆和栈的管理,以及内存的申请和释放。堆和栈是内存管理中的两个主要概念。

堆是动态分配内存的区域,它的大小并不固定,可以根据需要进行动态扩展或收缩。堆中的内存是通过malloc和calloc函数进行申请的,通过free函数进行释放的。堆内存的分配和释放是在程序运行时动态进行的。

栈是静态分配内存的区域,它的大小是固定的,并且在编译时确定。函数的参数、局部变量和函数调用信息都存储在栈中。栈内存的分配和释放是由编译器自动管理的,程序员无需手动处理。

以下是一些与内存管理相关的函数:

  1. malloc函数:malloc函数用于在堆中分配指定字节数的内存空间。它的原型为:void *malloc(size_t size),其中size是要分配的内存空间的字节数,返回指向分配内存的指针。如果分配失败,则返回NULL。

  2. calloc函数:calloc函数用于在堆中分配指定数量和大小的内存空间,并将其初始化为0。它的原型为:void *calloc(size_t num, size_t size),其中num是要分配的元素数量,size是每个元素的大小,返回指向分配内存的指针。如果分配失败,则返回NULL。

  3. memcpy函数:memcpy函数用于在内存区域之间复制数据。它的原型为:void *memcpy(void *dest, const void *src, size_t n),其中dest是目标内存地址,src是源内存地址,n是要复制的字节数。

  4. memset函数:memset函数用于在内存区域中设置指定值。它的原型为:void *memset(void *s, int c, size_t n),其中s是要设置的内存地址,c是要设置的值,n是要设置的字节数。

  5. free函数:free函数用于释放之前通过malloc或calloc函数分配的内存空间。它的原型为:void free(void *ptr),其中ptr是要释放的内存空间的指针。

下面是一个完整的例子,演示了如何使用malloc和free函数动态分配和释放内存:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    int *ptr;
    int num_elements = 5;

    // 使用malloc函数分配内存空间
    ptr = (int *)malloc(num_elements * sizeof(int));
    if (ptr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }

    // 将分配的内存空间初始化为0
    memset(ptr, 0, num_elements * sizeof(int));

    // 输出初始化后的内存空间内容
    for (int i = 0; i < num_elements; i++) {
        printf("%d ", ptr[i]);
    }
    printf("\n");

    // 释放分配的内存空间
    free(ptr);

    return 0;
}

在这个例子中,我们首先使用malloc函数分配了一个包含5个整型元素的内存空间,然后使用memset函数将内存空间初始化为0,最后使用free函数释放了分配的内存空间。

在C++中,内存分配和销毁是通过运算符new和delete或new[]和delete[]完成的。当使用new运算符分配内存时,它会在堆上分配一块内存,并返回一个指向该内存的指针。当不再需要这块内存时,应当使用delete运算符将其释放,以防止内存泄漏。

另外,使用new[]和delete[]运算符可以用于动态分配和释放数组内存。与new和delete相似,new[]会在堆上分配一块连续的内存用于数组,并返回指向数组第一个元素的指针,而delete[]则会释放整个数组的内存。

在C++中,还可以使用malloc和free函数进行内存分配和释放,但需要注意,malloc和free是C语言中的函数,不会调用构造函数和析构函数,而new和delete会调用对象的构造函数和析构函数。

当使用new和delete运算符分配和释放内存时,可以通过以下示例来说明:

#include <iostream>

class MyClass {
public:
    MyClass() {
        std::cout << "Constructor called" << std::endl;
    }

    ~MyClass() {
        std::cout << "Destructor called" << std::endl;
    }
};

int main() {
    // 使用new运算符分配内存
    MyClass* obj = new MyClass();

    // 使用delete运算符释放内存
    delete obj;

    return 0;
}

在上面的示例中,new运算符被用于动态分配一个MyClass对象的内存,并返回指向该对象的指针。当对象不再需要时,可以使用delete运算符释放内存,并调用MyClass对象的析构函数来执行必要的清理操作。

这个例子展示了如何使用new和delete运算符分配和释放内存,并且在MyClass的构造函数和析构函数中添加了输出语句,以便在对象创建和销毁时输出相应的消息。

内存对齐

头文件

在 C 和 C++ 中,头文件(.h 文件)的作用是声明函数、变量、类以及其他程序实体的接口。头文件通常包含一些函数原型、宏定义、结构体和类的声明等。在大型开发项目中,头文件的应用主要是用于模块化设计,方便不同模块之间的交互和集成。通过头文件,我们可以将模块的接口和实现分离开来,使得代码更易于维护和扩展。

举一个例子来说明头文件在大型项目中的应用。假设我们正在开发一个大型的软件系统,其中包含多个模块,比如用户管理模块、数据处理模块和图形界面模块。我们可以使用头文件来定义每个模块的接口,并在需要的地方包含这些头文件来使用模块提供的功能。

例如,我们可以创建一个名为user_manager.h的头文件来声明用户管理模块的接口,包括用户注册、登录、权限管理等函数的原型。然后在需要使用用户管理功能的文件中,可以包含user_manager.h头文件,这样就可以调用用户管理模块提供的接口函数。

// user_manager.h
#ifndef USER_MANAGER_H
#define USER_MANAGER_H

// 声明用户管理模块的接口函数
void registerUser(const char* username, const char* password);
bool loginUser(const char* username, const char* password);
void managePermissions(const char* username, int permissionLevel);

#endif // USER_MANAGER_H
// main.c
#include "user_manager.h"

int main() {
    // 使用用户管理模块提供的功能
    registerUser("alice", "123456");
    if (loginUser("alice", "123456")) {
        managePermissions("alice", 2);
    }
    return 0;
}

通过这种方式,我们可以将不同模块的实现细节隐藏起来,只暴露必要的接口,从而提高了代码的可维护性和可复用性。

在使用头文件时,有一些需要注意的地方:

  1. 避免循环包含:当多个头文件相互包含时,可能会导致循环包含的问题,这会导致编译错误或者隐藏的逻辑错误。为了避免这种情况,应该尽量减少头文件的依赖关系,并使用前置声明或者使用 include guards(头文件保护宏)来解决循环包含的问题。

  2. 避免在头文件中包含过多的实现细节:头文件应该主要包含接口的声明,而不是实现的细节。过多的实现细节会增加编译时间并增加文件之间的依赖关系。在头文件中只包含必要的声明,而将实现细节放在对应的源文件中。

  3. 避免在头文件中定义全局变量:头文件中定义的全局变量可能会导致重复定义的问题,因此应该避免在头文件中定义全局变量,而是将全局变量定义在源文件中,然后使用 extern 关键字声明即可。

  4. 使用 include guards 或 #pragma once:为了防止头文件被多次包含,可以在头文件中使用 include guards 或者 #pragma once。这样可以确保头文件只被包含一次,避免重复定义问题。

  5. 命名规范:为了便于代码维护和组织,应该遵循良好的命名规范来命名头文件,并且使用有意义的文件名来表示头文件的用途。

总的来说,使用头文件时需要注意保持代码的整洁性和可维护性,避免对其他模块产生负面影响。同时,对于大型项目,建议使用合适的工具来管理头文件的依赖关系,以确保代码结构的清晰性和可靠性。

c++类

什么是类

C语言是面向过程的语言,而C++是面向对象的编程语言,其中面向对象的结构就是类。这个在很多编程语言都存在如python,java等。

在面向对象的编程语言中,类是一种用来描述对象的模板或蓝图。它定义了对象的属性和行为,并可以用来创建对象实例。类的作用包括:

  1. 封装:类将数据和操作封装在一起,使得对象可以隐藏内部状态和实现细节,从而提高代码的可维护性和安全性。

  2. 继承:类可以通过继承机制派生出子类,子类可以继承父类的属性和方法,并可以重写或扩展父类的行为。

  3. 多态:类的继承和多态性使得可以使用父类的引用指向子类的对象,从而可以以统一的方式处理不同的子类对象。

  4. 代码复用:类的继承和组合机制使得可以通过复用已有的类来构建新的类,从而提高代码的重用性和可扩展性。

类是面向对象编程中的重要概念,它提供了一种组织和抽象数据和行为的方式,使得代码更加模块化、可维护和可扩展。

类的组成部分:

  1. 数据成员:类中的变量,用于存储对象的状态信息。
  2. 成员函数:类中的函数,用于操作对象的数据,包括构造函数和析构函数。

C++中的类

C++中的类是面向对象编程的重要组成部分,它允许程序员将数据和方法组合在一起,以便更好地组织和管理代码。下面是一个简单的例子:

#include <iostream>
using namespace std;

// 定义一个简单的类
class Person {
private:
    string name;
    int age;
public:
    // 构造函数
    Person(string n, int a) {
        name = n;
        age = a;
    }
    // 成员函数
    void display() {
        cout << "Name: " << name << ", Age: " << age << endl;
    }
};

int main() {
    // 创建一个Person对象
    Person p1("Alice", 25);
    // 调用成员函数
    p1.display();
    return 0;
}

在这个例子中,我们定义了一个名为Person的类,它有两个私有数据成员name和age,以及一个公有成员函数display。在main函数中,我们创建了一个Person对象p1,并调用了它的display函数来显示对象的信息。这个例子展示了类的基本定义和使用方法。

C++类的生命周期

C++类的生命周期描述了一个类对象从创建到销毁的整个过程。它包括对象的创建、初始化、使用和销毁几个阶段。

  1. 创建:类对象的生命周期从它被创建开始。在C++中,对象可以通过构造函数来创建。

  2. 初始化:对象被创建后,会经历初始化阶段,其中成员变量会被赋予初始值,构造函数将会被调用。

  3. 使用:在对象的生命周期中,它会被用来进行各种操作,调用成员函数,修改成员变量,参与各种计算。

  4. 销毁:当对象不再需要时,它会被销毁。在C++中,对象的销毁通过析构函数来完成,析构函数会执行对象的清理工作,释放资源,关闭文件等。

总的来说,C++类的生命周期可以概括为:创建对象、初始化对象、使用对象和销毁对象这几个阶段。在每个阶段,程序员可以在构造函数和析构函数中进行必要的操作来管理对象的生命周期。

需要注意的是,在C++中可以手动创建对象并控制它的生命周期,也可以利用动态内存分配来创建对象,需要程序员自行管理对象的生命周期。

当对象的生命周期结束时,会调用对象的析构函数来进行清理工作。下面是一个简单的C++例子来说明类的析构函数:

#include <iostream>
using namespace std;

// 定义一个简单的类
class MyClass {
public:
    // 构造函数
    MyClass() {
        cout << "Constructor called" << endl;
    }
    // 析构函数
    ~MyClass() {
        cout << "Destructor called" << endl;
    }
};

int main() {
    // 创建一个MyClass对象
    MyClass obj;
    // 对象生命周期结束,析构函数被调用
    return 0;
}

在这个例子中,我们定义了一个名为MyClass的类,它有一个构造函数和一个析构函数。在main函数中,我们创建了一个MyClass对象obj,当main函数执行完毕时,obj的生命周期结束,程序会自动调用obj的析构函数来进行清理工作。运行这个程序会输出以下内容:

Constructor called
Destructor called

这表明在对象生命周期结束时,析构函数被自动调用来释放资源或执行其他必要的清理工作。

类的继承和多态

当一个类继承另一个类时,它会继承父类的属性和方法,并且可以增加自己的属性和方法。下面是一个简单的C++类继承的例子:

// 父类
class Animal {
public:
    void eat() {
        cout << "Animal is eating" << endl;
    }
};

// 子类
class Dog : public Animal {
public:
    void bark() {
        cout << "Dog is barking" << endl;
    }
};

在这个例子中,Dog类继承了Animal类的eat方法,同时也有自己的bark方法。

关于C++中的多态,它允许不同的子类对象使用同一个方法名,但根据调用的对象的不同,会执行不同的实现。下面是一个简单的C++多态的例子:

// 父类
class Shape {
public:
    virtual void draw() {
        cout << "Shape is drawn" << endl;
    }
};

// 子类
class Circle : public Shape {
public:
    void draw() {
        cout << "Circle is drawn" << endl;
    }
};

// 另一个子类
class Square : public Shape {
public:
    void draw() {
        cout << "Square is drawn" << endl;
    }
};

在这个例子中,Shape类的draw方法被声明为虚函数,这意味着它可以被子类重写。当你使用多态调用draw方法时,根据对象的实际类型,会执行不同子类的实现。例如:

Shape *shape1 = new Circle();
Shape *shape2 = new Square();

shape1->draw(); // 输出 "Circle is drawn"
shape2->draw(); // 输出 "Square is drawn"

这展示了多态的特性,同一个方法名draw被不同的子类对象调用时,执行了不同的实现。

宏

在C语言中,宏是一种预处理器指令,用于在程序中定义具有特定含义的符号常量、函数或代码片段。当程序被编译时,预处理器会将宏的定义替换为其对应的值或代码,从而可以简化代码和提高代码的可维护性。

下面是一个宏的使用例子:

#include <stdio.h>

#define PI 3.14159
#define AREA(r) (PI * r * r)

int main() {
    float radius = 5.0;
    float area = AREA(radius);
    printf("The area of the circle is: %f\n", area);
    return 0;
}

在这个例子中,#define指令用于定义了两个宏:PI 和 AREA(r)。PI代表了圆周率的值,AREA(r)代表了计算圆的面积的宏。在main函数中,使用了AREA宏来计算圆的面积,预处理器会将AREA(radius)替换为实际的计算公式(PI * radius * radius),最终程序会输出圆的面积。

当你在C语言中使用宏时,你还可以使用宏来定义一些常用的代码片段。例如,你可以使用宏来定义一个用于交换两个变量值的代码片段:

#include <stdio.h>

#define SWAP(x, y) do { \
    typeof(x) temp = x; \
    x = y; \
    y = temp; \
} while(0)

int main() {
    int a = 5, b = 10;
    printf("Before swap: a = %d, b = %d\n", a, b);
    
    SWAP(a, b);
    
    printf("After swap: a = %d, b = %d\n", a, b);
    
    return 0;
}

在这个例子中,SWAP宏定义了一个交换两个变量值的代码片段,并且在程序中使用了这个宏来交换变量a和b的值。

条件编译

在C语言中,条件编译是一种预处理器功能,它允许根据条件来选择性地包含或排除代码片段。条件编译通常使用#if、#ifdef、#ifndef、#elif、#else和#endif等预处理指令来实现。条件编译允许程序员在编译时根据不同的条件编译不同的代码,例如根据不同的操作系统或编译器进行条件编译。

下面是一个条件编译的使用例子:

#include <stdio.h>

#define DEBUG 1

int main() {
    #if DEBUG
        printf("Debug mode is enabled\n");
    #else
        printf("Debug mode is disabled\n");
    #endif

    return 0;
}

在这个例子中,#define DEBUG 1指令定义了一个名为DEBUG的宏,并设置为1。在main函数中,使用了条件编译指令#if和#else,根据DEBUG宏的定义来选择性地输出不同的调试信息。在编译时,如果DEBUG宏被定义,则程序会输出"Debug mode is enabled";否则,程序会输出"Debug mode is disabled"。

条件编译还可以使用其他条件,比如#ifdef检查宏是否被定义,#ifndef检查宏是否未被定义,#elif用于多个条件判断,等等。条件编译在C语言中是一个非常有用的功能,可以根据不同的编译环境灵活地控制程序的行为。

当涉及到跨平台编程时,条件编译非常有用。以下是一些条件编译的示例,可用于根据不同的编译环境灵活地控制程序:

  1. 跨平台代码:
#ifdef _WIN32
    #include <windows.h>
    // Windows特定的代码
#else
    #include <unistd.h>
    // Linux/Unix特定的代码
#endif

在这个例子中,根据宏_WIN32 是否被定义,选择包含Windows特定的头文件或Linux/Unix特定的头文件。这使得代码可以在不同的操作系统上编译运行。

  1. 调试信息输出:
#ifdef DEBUG
    #define DEBUG_PRINT(x) printf("Debug: %s\n", x)
#else
    #define DEBUG_PRINT(x) do {} while (0)
#endif

在这个例子中,根据宏DEBUG 是否被定义,选择性地定义了一个DEBUG_PRINT宏,用于输出调试信息。在调试时可以定义DEBUG宏,在发布版本中则不会输出调试信息,从而减少程序的运行开销。

  1. 特性支持:
#if defined(_OPENMP)
    #include <omp.h>
    #define PARALLEL_FOR _Pragma("omp parallel for")
#else
    #define PARALLEL_FOR
#endif

在这个例子中,根据宏_OPENMP 是否被定义,选择性地定义了一个PARALLEL_FOR宏,用于并行化for循环。如果编译器支持OpenMP并行化,则定义PARALLEL_FOR宏,否则不定义。

这些例子展示了如何使用条件编译来根据不同的编译环境灵活地控制程序,使得代码可以适应不同的平台、调试需求或特性支持。条件编译在C编程中是非常常用的技术,可以提高代码的灵活性和可移植性。

当涉及到不同版本的库或接口时,条件编译可以用来选择不同版本的代码。以下是一个使用条件编译选择不同版本的OpenCV库的C++示例:

#include <iostream>

// 检查OpenCV的版本是否大于等于3
#if defined(CV_VERSION_EPOCH) && CV_VERSION_EPOCH >= 3
    #include <opencv2/core.hpp>
#else
    #include <opencv2/core/core.hpp>
#endif

int main() {
    cv::Mat image;

    // 根据不同版本的OpenCV选择不同的函数
    #if defined(CV_VERSION_EPOCH) && CV_VERSION_EPOCH >= 3
        image = cv::Mat::zeros(100, 100, CV_8UC3);
    #else
        image = cv::Mat::zeros(100, 100, CV_8UC3);
    #endif

    std::cout << "OpenCV version: " << CV_VERSION << std::endl;

    return 0;
}

在这个例子中,根据宏CV_VERSION_EPOCH和其值来选择包含不同版本的OpenCV头文件。然后,根据不同版本的OpenCV选择不同的函数来创建图像。通过使用条件编译,可以确保代码在不同版本的OpenCV库中都能正常编译和运行。

这个例子展示了如何使用条件编译来选择不同版本的库,以确保代码在不同的环境中都能正常工作。条件编译在处理不同版本的库、接口或标准时非常有用,能够提高代码的灵活性和可移植性。

日志

日志在任何一种语言编程中都会涉及到,python中有logging库,java中有log4j。当然C也有日志功能,一般可以用宏和函数来实现。

需要明确下日志的功能,一般会设置一个日志等级,比如trace < debug < info < warn < error < fatal 等,根据设置的等级高低来判断是否显示日志。

宏实现

可以使用宏定义和条件编译来实现一个简单的日志函数,如下所示:

#include <stdio.h>

// 定义日志等级
#define LOG_LEVEL_DEBUG 0
#define LOG_LEVEL_INFO  1
#define LOG_LEVEL_WARN  2
#define LOG_LEVEL_ERROR 3

// 定义日志级别
#ifndef LOG_LEVEL
#define LOG_LEVEL LOG_LEVEL_INFO
#endif

// 定义日志函数
#define LOG(level, format, ...) \
    do { \
        if (level >= LOG_LEVEL) { \
            printf("[%s] " format "\n", #level, ##__VA_ARGS__); \
        } \
    } while(0)

int main() {
    int value = 42;
    
    LOG(LOG_LEVEL_DEBUG, "Debug message, value = %d", value);
    LOG(LOG_LEVEL_INFO, "Info message, value = %d", value);
    LOG(LOG_LEVEL_WARN, "Warning message, value = %d", value);
    LOG(LOG_LEVEL_ERROR, "Error message, value = %d", value);
    
    return 0;
}

在这个例子中,我们定义了四个日志级别,并使用宏定义和条件编译来控制日志输出的级别。在main函数中,我们使用LOG宏输出不同级别的日志信息。

您可以根据您的需要修改LOG_LEVEL的值来控制输出的日志级别。

这里##__VA_ARGS__是一个预处理器的特殊标记,用于处理可变参数列表。这个标记的作用是将可变参数列表展开并与它前面的文本连接起来。通常,##__VA_ARGS__用于定义带有可变参数的宏。当宏被调用时,##__VA_ARGS__会将可变参数列表展开,并将展开后的参数直接与宏定义中的文本相连接。

函数实现

我们创建一个名为log的函数,函数接受日志级别和消息作为参数,并在函数内部根据日志级别输出相应的日志信息。以下是一个使用函数来实现日志功能的示例代码:

#include <stdio.h>

// 定义日志等级
typedef enum {
    LOG_LEVEL_DEBUG,
    LOG_LEVEL_INFO,
    LOG_LEVEL_WARN,
    LOG_LEVEL_ERROR
} LogLevel;

// 定义日志函数
void log(LogLevel level, const char *format, ...) {
    va_list args;
    va_start(args, format);
    
    if (level == LOG_LEVEL_DEBUG) {
        printf("[DEBUG] ");
    } else if (level == LOG_LEVEL_INFO) {
        printf("[INFO] ");
    } else if (level == LOG_LEVEL_WARN) {
        printf("[WARN] ");
    } else if (level == LOG_LEVEL_ERROR) {
        printf("[ERROR] ");
    }
    
    vprintf(format, args);
    printf("\n");
    
    va_end(args);
}

int main() {
    int value = 42;
    
    log(LOG_LEVEL_DEBUG, "Debug message, value = %d", value);
    log(LOG_LEVEL_INFO, "Info message, value = %d", value);
    log(LOG_LEVEL_WARN, "Warning message, value = %d", value);
    log(LOG_LEVEL_ERROR, "Error message, value = %d", value);
    
    return 0;
}

在这个例子中,我们定义了一个枚举类型LogLevel来表示不同的日志级别,然后创建了一个log函数来处理日志输出。在main函数中,我们使用log函数输出不同级别的日志信息。

这种方法不使用宏,而是使用了函数来实现日志功能,使得日志功能更加灵活和易于维护。

值得注意的是:在这个函数声明中,"..."表示可变参数列表。这意味着函数可以接受任意数量的参数。在C语言中,可变参数列表通常使用stdarg.h中的宏来处理。在函数内部,可以使用这些宏来访问传递的可变参数。

另外,在C语言中,va_list、va_start和va_end是用于处理可变参数列表的宏和函数。

  1. va_list: va_list是一个类型,用于声明一个指向参数列表的指针。它是一个指向参数列表的指针,可以用于遍历参数列表中的每个参数。

  2. va_start: va_start宏用于初始化可变参数列表的遍历。它接受两个参数,第一个是一个va_list类型的变量,第二个是可变参数列表中的最后一个固定参数。va_start会将指向参数列表的指针指向第一个可变参数。

  3. va_end: va_end宏用于结束可变参数列表的遍历。它接受一个参数,即va_list类型的变量。va_end会清理可变参数列表的状态,确保不会出现内存泄露。

使用这些宏和函数,可以在函数内部遍历可变参数列表,获取每个参数的值,并进行相应的处理。这在需要处理不定数量参数的函数中非常有用,比如printf函数就是一个典型的例子。

回调函数

回调函数是一种常见的编程概念,在程序设计中经常被使用。回调函数是指在某个特定事件发生时,由某个已注册的函数进行调用。通常情况下,回调函数作为参数传递给另一个函数,当特定事件发生时,这个函数会调用传入的回调函数。

回调函数的作用在于允许程序员在不同的逻辑单元之间建立联系,实现模块之间的松耦合。常见的例子包括事件处理、异步操作、API调用等。在这些场景下,回调函数可以在特定的条件满足时被调用,用来处理特定的逻辑。

在C语言中,回调函数通常实现为函数指针的形式,它允许函数能够通过函数指针的方式调用其他函数。在其他编程语言中,回调函数的实现方式可能会有所不同,但核心思想是一致的。

在C语言中,可以通过指针来实现一个回调函数,函数的参数是一个函数。首先定义一个函数指针作为回调函数的参数类型,然后在调用函数时将需要回调的函数作为参数传入。

例如:

#include <stdio.h>

// 定义函数指针作为回调函数的参数类型
typedef void (*CallbackFunc)(int);

// 回调函数
void callbackFunction(int value) {
    printf("Callback function called with value: %d\n", value);
}

// 调用函数,将回调函数作为参数传入
void performOperation(int value, CallbackFunc callback) {
    // 执行需要回调的操作
    printf("Performing operation with value: %d\n", value);
    
    // 调用回调函数
    callback(value);
}

int main() {
    // 调用 performOperation 函数,并传入回调函数
    performOperation(10, callbackFunction);
    
    return 0;
}

在上面的例子中,performOperation 函数接受一个整型参数和一个函数指针作为参数,然后在函数内部执行需要回调的操作,并调用传入的回调函数。在 main 函数中调用 performOperation 时,将 callbackFunction 作为回调函数传入。

其中typedef void (*CallbackFunc)(int);表示:创建一个名为 CallbackFunc 的新类型别名,该别名代表一个指向接受一个整型参数并且没有返回值的函数的指针类型。

回调函数是一种强大的编程技术,可以帮助程序员编写更加灵活和可扩展的代码,使得不同部分的代码能够更好地协同工作。

多线程编程

1. 多线程简单例子

多线程编程是一种在程序中同时执行多个任务的编程方式。它能够有效地利用多核或多处理器的计算资源,提高程序的执行效率。在C语言中,我们可以使用pthread库来进行多线程编程。

pthread库是一个开源的多线程库,它提供了丰富的多线程功能,包括创建、销毁线程、线程同步、线程通信等。下面是一个使用pthread库进行多线程编程的简单例子:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

// 定义一个函数,该函数将由多个线程执行
void *print_message_function( void *ptr ) {
   char *message;
   message = (char *) ptr;
   printf("%s \n", message);
   pthread_exit(NULL);
}     

int main () {
   pthread_t threads[2];
   char *message1 = "Thread 1";
   char *message2 = "Thread 2";
   
   // 创建两个线程,分别执行print_message_function函数
   pthread_create(&threads[0], NULL, print_message_function, (void*) message1);
   pthread_create(&threads[1], NULL, print_message_function, (void*) message2);
   
   // 等待两个线程完成执行
   for(int i=0; i<2; i++){
        pthread_join(threads[i], NULL);
   }
   
   pthread_exit(NULL);
}

在这个例子中,我们首先定义了一个函数print_message_function,这个函数将会由多个线程执行。然后我们在main函数中创建了两个线程,并且分别传入不同的参数来执行print_message_function函数。每个线程将会打印出对应的消息。最后,我们使用pthread_join函数来等待线程的结束,确保主线程在所有线程结束后退出。

线程池

下面是一个简单的线程池实现例子,它使用了pthread库的各种函数,包括pthread_cond_wait,pthread_cond_broadcast和pthread_mutex_lock等。注意,这只是一个基本的实现,可能需要再进行适应你具体需求的修改。

#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

#define MAX_THREADS 5

void *worker(void *arg);

typedef struct one_thread{
    pthread_t thread;
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    int status;
    int task_params;
}one_thread;


int stop = 0;

int main(void){
    one_thread  *thread_pool = (one_thread*)calloc(MAX_THREADS, sizeof(one_thread));
    int i;

    // init 
    print("========> init thread pool");
    for (i = 0; i < MAX_THREADS; i++){
        pthread_mutex_init(&thread_pool[i].mutex, NULL);
        pthread_cond_init(&thread_pool[i].cond, NULL);
        pthread_create(&thread_pool[i].thread, NULL, worker, (void*)&thread_pool[i]);
    }

    // run task
    print("========> run task");
    for (i = 0; i < 10; i++){
        int thread_idx = i % MAX_THREADS;
        thread_pool[thread_idx].task_params = i;
        thread_pool[thread_idx].status = 0;
        pthread_mutex_lock(&thread_pool[thread_idx].mutex);
        pthread_cond_signal(&thread_pool[thread_idx].cond);
        pthread_mutex_unlock(&thread_pool[thread_idx].mutex);
    }

    
    stop = 1;
    for (i = 0; i < MAX_THREADS; i++){
        pthread_mutex_lock(&thread_pool[i].mutex);
        pthread_cond_broadcast(&thread_pool[i].cond);
        pthread_mutex_unlock(&thread_pool[i].mutex);
    }


    for (i = 0; i < MAX_THREADS; i++){
        pthread_join(thread_pool[i].thread, NULL);
    }

    // detrory thread
    for (i = 0; i < MAX_THREADS; i++){
        pthread_mutex_destroy(&thread_pool[i].mutex);
        pthread_cond_destroy(&thread_pool[i].cond);
    }

    free(thread_pool);


    return 0;
}

void *worker(void *arg){
    one_thread *ctx = (one_thread*)arg;
    while(1){
        pthread_mutex_lock(&ctx->mutex);
        while(!stop && ctx->status == 0){
            pthread_cond_wait(&ctx->cond, &ctx->mutex);
        }
        if(stop){
            pthread_mutex_unlock(&ctx->mutex);
            break;
        }
        printf("Thread %lu did some work, task param = %d \n", pthread_self(), ctx->task_params);
        sleep(1);
        ctx->status = 0;
        pthread_mutex_unlock(&ctx->mutex);
    }

    return NULL;
}

上面的例子创建了5个线程的线程池,

这个程序首先初始化了一个互斥锁和一个条件变量。然后,它创建了五个工作线程,每个线程都等待条件变量被激活。主线程循环10次,每次发出一个信号,唤醒一个等待的线程。然后它休眠一秒,再次循环。最后,主线程发出一个广播信号,唤醒所有等待的线程,然后等待所有线程完成。

C/C++编译工具:makefile

什么是makefile

Makefile是一种用于管理和组织源代码的工具,通常用于构建和编译软件项目。它由一系列规则组成,每个规则指定如何生成一个或多个目标文件。Makefile也包括变量和注释,使得用户能够灵活地配置和定制构建过程。

Makefile通常用于C、C++和其他编程语言的项目中,它可以自动化编译、链接和打包过程,减少了手动执行命令的工作量。通过Makefile,开发人员可以轻松地管理项目中的各种依赖关系和构建规则,确保代码的可维护性和可靠性。

Makefile是一种强大的工具,可以帮助开发人员更加高效地构建和管理软件项目。

基本使用

Makefile的基本使用是通过编写规则来描述每个目标文件的依赖关系和构建步骤。每个规则由一个目标、一个或多个依赖项和相应的命令组成。

下面是一个简单的Makefile示例,用于编译一个C程序:

# 定义变量
CC = gcc
CFLAGS = -Wall

# 默认构建目标
all: myprog

# 构建规则
myprog: myprog.o utils.o
	$(CC) $(CFLAGS) -o myprog myprog.o utils.o

myprog.o: myprog.c
	$(CC) $(CFLAGS) -c myprog.c

utils.o: utils.c
	$(CC) $(CFLAGS) -c utils.c

# 清理规则
clean:
	rm -f myprog *.o

在这个示例中,我们首先定义了一些变量,如CC表示编译器,CFLAGS表示编译选项。然后定义了一个默认的构建目标"all",它依赖于"myprog"。接下来定义了"myprog"、"myprog.o"和"utils.o"这三个目标的构建规则,分别指定了它们的依赖关系和构建命令。最后定义了一个清理规则"clean",用于删除生成的可执行文件和目标文件。

通过这个Makefile,我们可以通过运行"make"命令来编译和构建我们的C程序。例如,运行"make clean"可以清理生成的文件,运行"make"可以编译整个项目。

这就是一个简单的Makefile示例,它展示了如何使用Makefile来管理C程序的编译和构建过程。

进阶使用

关键字和函数

在 Makefile 中,有一些重要的关键字和函数,下面列出了其中一些:

  1. 关键字:

    • target: dependencies:定义目标及其依赖
    • $(variable):引用变量的值
    • all:一个默认目标,执行 make 命令时会被执行
    • clean:清理文件的目标
  2. 函数:

    • $(wildcard pattern):匹配文件名

      # 查找所有的.c文件
      sources := $(wildcard *.c)
      
    • $(patsubst pattern,replacement,text):替换字符串

      # 将所有.c文件替换为.o文件
      objects := $(patsubst %.c,%.o,$(sources))
      
    • $(shell command):执行 shell 命令

      # 获取当前目录
      curdir := $(shell pwd)
      
    • ifeq 和 endif:条件判断

      # 判断变量的值
      ifeq ($(debug),yes)
      CFLAGS = -g
      else
      CFLAGS = -O2
      endif
      

这些关键字和函数是 Makefile 中非常重要的部分,它们可以帮助你定义目标、处理文件、执行命令和进行条件判断。

在Makefile中,$@和$^是自动化变量,用于表示目标和所有的依赖文件。

  • $@ 表示当前规则中的目标文件名。
  • $^ 表示所有的依赖文件列表,以空格分隔。

例如,考虑以下示例:

# 定义一个规则
program: main.o func1.o func2.o
	gcc -o $@ $^

在这个示例中,$@会被替换为 "program",$^会被替换为 "main.o func1.o func2.o"。这样,当make命令执行时,会将main.o、func1.o和func2.o链接在一起生成名为program的可执行文件。

编译多个目标

在一个 Makefile 中,你可以为不同的源文件设置多个目标来编译。下面是一个简单的示例:

# 定义变量
CC = gcc
CFLAGS = -Wall -g

# 设置第一个目标
all: program1 program2

# 编译第一个源文件为 program1
program1: program1.c
	$(CC) $(CFLAGS) -o program1 program1.c

# 编译第二个源文件为 program2
program2: program2.c
	$(CC) $(CFLAGS) -o program2 program2.c

# 清理文件
clean:
	rm -f program1 program2

在这个示例中,我们定义了两个目标 program1 和 program2,并分别指定了它们依赖的源文件。当你运行 make 命令时,Makefile 将会编译这两个目标。

makefile如何输入变量到c/c++程序

在Makefile中,你可以使用预处理器标志 -D 来传递变量。在下面的例子中,我将创建一个Makefile,该Makefile将变量传递给C++代码。

首先,这是一个简单的C++程序,它将打印出我们从Makefile中传递的一个变量。

// main.cpp
#include <iostream>

#ifndef MY_VARIABLE
#define MY_VARIABLE "You didn't define it!"
#endif

int main() {
    std::cout << MY_VARIABLE << std::endl;
    return 0;
}

现在,让我们在Makefile中定义这个变量,并将其传递给C++代码:

# Makefile
# MY_VARIABLE = Hello, world!
CXX_FLAGS = -D MY_VARIABLE=\"$(MY_VARIABLE)\"
all:
    g++ $(CXX_FLAGS) -o output main.cpp

在这个Makefile中,我们使用 CXX_FLAGS 变量来存储C++预处理器标志 -D,分配了我们的 MY_VARIABLE。注意,我们需要转义引号,这是因为命令行需要它们。

你可以在运行make时传递MY_VARIABLE,如下所示:

make MY_VARIABLE="Hello, world!"

C/C++编译工具:cmake

基本使用

CMake 是一个跨平台的开源构建工具,用于管理软件构建流程。它使用一个名为 CMakeLists.txt 的文本文件来描述构建过程。以下是一个简单的 CMakeLists.txt 文件的示例,用于构建一个简单的 C++ 程序:

# 指定 CMake 最低版本要求
cmake_minimum_required(VERSION 3.10)

# 指定项目名称
project(MyProject)

# 指定生成可执行文件
add_executable(my_program main.cpp)

在这个示例中,我们指定了 CMake 的最低版本要求,并且指定了项目的名称。然后,我们使用 add_executable 命令来告诉 CMake 创建一个名为 my_program 的可执行文件,该可执行文件由 main.cpp 文件构建而成。

为了使用 CMake 构建项目,我们需要执行以下步骤:

  1. 创建一个 CMakeLists.txt 文件来描述构建过程。
  2. 在项目根目录下创建一个 build 文件夹,并进入该文件夹。
  3. 运行 cmake 命令来生成构建系统所需的文件。例如:cmake ..。
  4. 运行生成的构建系统,例如使用 make 命令进行构建。

通过这些步骤,CMake 将会根据 CMakeLists.txt 文件生成相应的构建系统文件,并且帮助我们完成项目的构建过程。

基本语法

command(arg1 arg2 ...) # 运行命令
set(var_name var_value) # 定义变量,或者给已经存在的变量赋值
command(arg1 ${var_name}) # 使用变量

# 控制语句
IF(expression) COMMAND1(ARGS)
ELSE(expression) COMMAND2(ARGS)
ENDIF(expression)

# expression
IF(var) # 不是空, 0, N, NO, OFF, FALSE, NOTFOUND 或 _NOTFOUND时,为真
IF(NOT var) # 与上述条件相反。
IF(var1 AND var2) # 当两个变量都为真是为真。
IF(var1 OR var2) # 当两个变量其中一个为真时为真。
IF(COMMAND cmd) # 当给定的cmd确实是命令并可以调用是为真
IF(EXISTS dir) # 目录名存在
IF(EXISTS file) # 文件名存在
IF(IS_DIRECTORY dirname) # 当dirname是目录
IF(file1 IS_NEWER_THAN file2) # 当file1比file2新,为真
IF(variable MATCHES regex) # 符合正则

# 循环
WHILE(condition) COMMAND1(ARGS) // ...
ENDWHILE(condition)

AUX_SOURCE_DIRECTORY(. SRC_LIST)
FOREACH(one_dir ${SRC_LIST}) MESSAGE(${one_dir})
ENDFOREACH(onedir)

基本操作

  • add_library: 指定的源文件(CPP文件)生成链接文件,然后添加到工程中去。生成动态库或者静态库
    add_library(<name> [STATIC | SHARED | MODULE]
            [EXCLUDE_FROM_ALL]
            [source1] [source2 ...])
    
    add_library(mod1 SHARED mod1.c mod1_func.c) # 生成动态库 libmod1.so
    add_library(mod2 STATIC mod2.c) # 生成静态库 libmod2.a
    
  • add_subdirectory: 在子文件夹添加了library或者executable之后,在上层目录添加subdirectory, 也可以在同一个CMakeList.txt中使用
  • target_link_libraries: 设置要链接的库文件的名称 具体的动态链接库文件.so, 在生成的可执行文件或动态库中连接进去其他的库
    target_link_libraries(<target> [item1 [item2 [...]]]
                        [[debug|optimized|general] <item>] ...)
    target_link_libraries(main mod1) 
    
  • include_directories: 添加头文件目录 g++选项中的-I参数的作用,也相当于环境变量中增加路径到CPLUS_INCLUDE_PATH变量的作用
  • link_directories 添加需要链接的库文件目录。 它相当于g++命令的-L选项的作用,也相当于环境变量中增加LD_LIBRARY_PATH的路径的作用
  • link_libraries 添加需要链接的库文件路径

下面是一个使用 CMake 构建的示例,其中包括了 target_link_libraries,link_directories,include_directories,add_executable 和 add_library 等关键部分,以生成一个可执行文件和一个动态库。

cmake_minimum_required(VERSION 3.5)

project(MyProject)

# 设置编译选项
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -Ofast -Wfatal-errors -pthread -fPIC -O3 -mavx")

# 添加动态库的源文件
set(SOURCE_FILES_LIB mylib.cpp)
add_library(mylib SHARED ${SOURCE_FILES_LIB})

# 添加可执行文件的源文件
set(SOURCE_FILES main.cpp)

# 添加包含目录
include_directories(include)

# 添加链接目录
link_directories(${CMAKE_SOURCE_DIR}/lib)

# 添加可执行文件
add_executable(myapp ${SOURCE_FILES})

# 链接动态库
target_link_libraries(myapp mylib)

假设项目的目录结构如下所示:

MyProject/
├── CMakeLists.txt
├── include/
│   └── mylib.h
├── src/
│   ├── main.cpp
│   └── mylib.cpp
└── lib/
    └── libmylib.so

在项目根目录中执行以下命令来生成构建文件和编译项目:

mkdir build
cd build
cmake ..
make

这将生成一个名为 myapp 的可执行文件和一个名为 libmylib.so 的动态库。

指定安装目录

我们进行cmake命令,会执行make命令进行编译,最后如果有需要的话会执行make install将编译好的库或者头文件安装到指定的未知。

在CMake中,可以使用install命令来设置安装相关信息,包括安装目标、安装路径等。以下是一个详细的例子,展示了如何在CMake中设置安装相关信息:

cmake_minimum_required(VERSION 3.10)

project(MyProject)

# 添加可执行文件
add_executable(MyExecutable main.cpp)

# 设置安装路径
set(CMAKE_INSTALL_PREFIX /usr/local)  # 安装到/usr/local目录下

# 安装可执行文件
install(TARGETS MyExecutable
        DESTINATION bin)  # 将可执行文件安装到bin目录下

# 安装头文件
file(GLOB HEADER_FILES "*.h")
install(FILES ${HEADER_FILES}
        DESTINATION include/myproject)  # 将头文件安装到include/myproject目录下

# 安装额外文件
install(FILES README.md
        DESTINATION share/myproject)  # 将README.md文件安装到share/myproject目录下

在上面的例子中,我们首先使用add_executable命令添加了一个可执行文件MyExecutable。然后使用set命令设置了安装路径为/usr/local。接下来使用install命令分别安装了可执行文件、头文件和额外文件到指定的安装路径下。

需要注意的是,安装路径可以根据实际需要进行调整。在使用CMake时,可以根据具体的项目需求来设置不同的安装路径和安装目标。

区分开发版与发布版

在CMake中,可以通过MAKE_BUILD_TYPE在确定不通类型的棒棒,常用的构建类型包括Debug、Release、MinSizeRel和RelWithDebInfo。

举例来说,假设我们有一个CMakeLists.txt文件,其中需要根据不同的构建类型来设置编译选项。我们可以使用MAKE_BUILD_TYPE来指定构建类型,然后根据不同的构建类型来设置不同的编译选项。

cmake_minimum_required(VERSION 3.10)

project(MyProject)

# 根据构建类型设置编译选项
if (MAKE_BUILD_TYPE STREQUAL "Debug")
    add_definitions(-DDEBUG)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g")
elseif (MAKE_BUILD_TYPE STREQUAL "Release")
    add_definitions(-DNDEBUG)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3")
endif()

# 添加源文件
add_executable(MyExecutable main.cpp)

在上面的例子中,根据不同的构建类型,我们设置了不同的预处理宏和编译选项。当构建类型为Debug时,会定义DEBUG宏并启用调试信息;当构建类型为Release时,会定义NDEBUG宏并开启优化。

这样我们就可以main.cpp中,通过条件编译的方式,确定是哪些debug模型的需要编译的,而在release发布版本不需要编译,形如:

#ifndef NDEBUG 
    printf("author: %s, release_date: %s\n", AUTHOR, RELEASE_DATE ); // 只在开发版本编译
#endif
cmake -DMAKE_BUILD_TYPE=Debug ..
cmake -DMAKE_BUILD_TYPE=Release ..

大杀器find_package

find_package是CMake中用于查找和加载第三方库的命令。它用于在系统中查找指定的软件包,并将其路径或库的相关信息导入到CMake中,以便在项目中使用。

要配置find_package,你需要在CMakeLists.txt文件中使用find_package命令,并提供要查找的软件包的名称。通常情况下,你还需要指定软件包的版本号。

下面是一个使用find_package的简单例子:

假设你想在CMake项目中使用OpenCV,你需要在CMakeLists.txt文件中添加以下内容:

# 查找OpenCV包
find_package(OpenCV 4.0 REQUIRED)

# 如果找到OpenCV包,将其包含路径和链接库添加到项目中
if(OpenCV_FOUND)
    include_directories(${OpenCV_INCLUDE_DIRS})
    target_link_libraries(your_project_name ${OpenCV_LIBS})
endif()

find_package语法为:FIND_PACKAGE( <name> [version] [EXACT] [QUIET] [NO_MODULE] [ [ REQUIRED | COMPONENTS ] [ componets... ] ] )

在这个例子中,find_package命令用于查找OpenCV 4.0,并将其导入到项目中。如果找到了OpenCV包,将其包含路径添加到项目的include路径中,并将其链接库添加到项目的链接库中。

请注意,在实际项目中,你可能还需要根据你的项目结构和依赖项的不同做一些适当的调整。

那find_package怎么知道从哪里去查找相关依赖库呢? 查找的目录路径:

<package>_DIR
CMAKE_PREFIX_PATH
CMAKE_FRAMEWORK_PATH
CMAKE_APPBUNDLE_PATH
PATH

其中,PATH中的路径如果以bin或sbin结尾,则自动回退到上一级目录。
找到根目录后,cmake会检查这些目录下的

<prefix>/(lib/<arch>|lib|share)/cmake/<name>*/          (U)
<prefix>/(lib/<arch>|lib|share)/<name>*/                (U)
<prefix>/(lib/<arch>|lib|share)/<name>*/(cmake|CMake)/  (U)
cmake找到这些目录后,会开始依次找<package>Config.cmake或Find<package>.cmake文件。找到后即可执行该文件并生成相关链接信息。

最重要的一个是PATH。由于/usr/bin/在PATH中,cmake会自动去/usr/(lib/<arch>|lib|share)/cmake/<name>*/寻找模块
另外一个比较重要的是<package>_DIR。我们可以在调用cmake时将这个目录传给cmake。由于其优先级最高,因此cmake会优先从该目录中寻找,这样我们就可以随心所欲的配置cmake使其找到我们希望它要找到的包。而且除上述指定路径外,cmake还会直接进入<package>_DIR下寻找。如我在3rd_parties目录下编译了一个OpenCV,那么执行cmake时可以使用

OpenCV_DIR=../../3rd-party/opencv-3.3.4/build/ cmake .. 

多个CMakeLists.txt

在一个复杂的项目中,通常会以模块化的方式来组织项目的框架,统一一个如main.cpp做为入口程序(如ffmpeg)。那么如果所有的程序的编译信息都写在一个CMakeLists.txt会很难维护。cmake里运行多个CMakeLists.txt,每个模块有自己的CMakeList负责编译。如下面的结构

  .
  ├── build
  │   ├── CMakeCache.txt
  │   ├── CMakeFiles
  │   ├── cmake_install.cmake
  │   ├── lib
  │   │   ├── libmod1.so
  │   │   └── mo2_lib
  │   │       ├── libmod2.a
  │   │       └── Makefile
  │   └── Makefile
  ├── CMakeLists.txt
  ├── main.c
  └── mod1
      ├── CMakeLists.txt
      ├── mod1.c
      ├── mod1_func.c
      ├── mod1_func.h
      ├── mod1.h
      └── mod2
          ├── CMakeLists.txt
          ├── mod2.c
          └── mod2.h

具体的,当使用CMake构建一个项目时,add_subdirectory命令可以用来包含子目录中的CMakeLists.txt文件,从而将子目录中的源代码文件添加到主项目中。下面是一个使用add_subdirectory的简单示例:

假设我们有以下项目目录结构:

project/
    CMakeLists.txt
    main.cpp
    subdirectory/
        CMakeLists.txt
        helper.cpp
        helper.h

在主项目的 CMakeLists.txt 文件中,可以包含子目录的 CMakeLists.txt 文件使用 add_subdirectory 命令。示例如下:

# 主项目的 CMakeLists.txt

cmake_minimum_required(VERSION 3.0)

project(MyProject)

add_executable(my_app main.cpp)

# 包含subdirectory中的CMakeLists.txt
add_subdirectory(subdirectory)

# 将子目录中的源文件添加到主项目中
target_sources(my_app PRIVATE subdirectory/helper.cpp)

在子目录的 CMakeLists.txt 文件中,可以定义子目录中的源文件,示例如下:

# 子目录的 CMakeLists.txt

# 将子目录中的源文件添加到一个库中
add_library(helper_lib helper.cpp helper.h)

在这个例子中,add_subdirectory命令被用来引入子目录中的CMakeLists.txt文件,并将子目录中的源文件添加到主项目中。

打印信息

cmake可以通过message来打印一些调试信息

    cmake_minimum_required(VERSION 2.8)
    project(find_package_learning)
    find_package(OpenCV 3 REQUIRED)

    message(STATUS "OpenCV_DIR = ${OpenCV_DIR}")
    message(STATUS "OpenCV_INCLUDE_DIRS = ${OpenCV_INCLUDE_DIRS}")
    message(STATUS "OpenCV_LIBS = ${OpenCV_LIBS}")

    include_directories(${OPENCV_INCLUDE_DIRS})  
    add_executable(opencv_test opencv_test.cpp)  
    target_link_libraries(opencv_test ${OpenCV_LIBS})

动态库和静态库

1.什么是动态库和静态库

我们在编写接口或者使用第三方应用时,都会打包或者引入.so或者.a文件。这个so就是动态库,.a文件就是静态库。

具体的,动态库和静态库是在C语言中用来组织和共享代码的两种方式。

静态库(Static Library)是一组预编译的对象文件的集合,它们被组合成一个单一的文件。当使用静态库时,链接器会将库中的代码和数据复制一份到最终的可执行文件中。这意味着可执行文件会包含所有需要的函数和数据,使得程序可以独立运行。静态库的文件扩展名通常为 .a(Unix-like系统)或 .lib(Windows系统)。

动态库(Dynamic Library)是一组目标文件的集合,它们在程序运行时被加载到内存中。当程序运行时,操作系统会加载动态库中的代码和数据,多个程序可以共享已加载的库,这样可以减少内存占用并方便对库文件进行更新。动态库的文件扩展名通常为 .so(Unix-like系统)或 .dll(Windows系统)。

动态库和静态库区别在于:

  1. 静态库的代码和数据会被复制到可执行文件中,程序独立运行;而动态库的代码和数据是在程序运行时加载到内存中,多个程序可以共享已加载的库。
  2. 静态库会增加可执行文件的大小,动态库不会影响可执行文件的大小。
  3. 静态库在程序编译时就已经链接到可执行文件中,而动态库则是在程序运行时动态加载的。

应用场景:

  • 静态库常用于需要独立运行且不需要频繁更新的程序,或者在无法依赖外部动态库的环境下使用。
  • 动态库常用于需要共享代码、减少内存占用或者便于库文件更新的场景。

我们知道,在编译c语言程序时,会生成.o的中间文件和最终可执行文件,其中".o"文件是编译器生成的目标文件,它包含了源代码文件编译后的机器代码和相关的符号表信息。".elf"文件是可执行和可链接格式(Executable and Linkable Format)文件,它包含了程序的可执行代码、数据、符号表和其他信息,用于在操作系统中执行程序。通常,".o"文件是编译后的中间文件,而".elf"文件是最终的可执行文件。而本质上看,静态库和动态库都是众多.o文件的集合。

2.如何编译

编译动态库和静态库的过程略有不同。以下是具体的步骤:

静态库

假设你有一个叫做hello.c的文件,你想把它编译成一个静态库。以下是步骤:

  1. 首先,你需要使用gcc编译hello.c,但是你需要使用-c标志来生成一个对象文件而不是一个完整的程序。这将生成一个hello.o文件。
gcc -c hello.c
  1. 然后,你使用ar命令来创建静态库。此命令会生成libhello.a文件。
ar rcs libhello.a hello.o

ar命令的rcs选项执行以下操作:

  • r:替换旧的对象文件(如果存在)。
  • c:创建新的库(如果不存在)。
  • s:创建一个对象文件索引(对于库的链接很重要)。

动态库

如果你有一个源文件hello.c,你想要编译成一个动态库。以下是步骤:

  1. 首先,你需要使用gcc编译hello.c,但是你需要使用-c标志来生成一个对象文件而不是一个完整的程序。这将生成一个hello.o文件。
gcc -c -fPIC hello.c

-fPIC标志告诉gcc生成位置无关代码(PIC),这是创建共享库所必需的。

  1. 然后,你可以使用gcc命令和-shared选项来创建动态库。这将生成一个libhello.so共享库。
gcc -shared -o libhello.so hello.o

-shared选项告诉链接器创建一个共享库。

在链接时,需要使用-L和-l选项指定库的路径和名称。

例如,如果你的程序名为main.c,并且你想链接之前创建的静态库或动态库,你可以这样做:

对于静态库:

gcc main.c -L. -lhello -o main

对于动态库:

gcc main.c -L. -lhello -o main

请注意,-L.告诉链接器在当前目录中查找库,-lhello告诉它链接到libhello库(无论是.a还是.so)。程序的输出在-o后指定,这里是main。

特别注意的是,动态库需要加上-fPIC, 在cmake里动态库关键字为share,静态库为static

另外在链接so库的时候,编译器是如何通过-l后面的名字寻找相对应的so库呢,动态链接库的有一定的寻找顺序:

  • rpath 指定的目录;
  • 环境变量 LD_LIBRARY_PATH 指定的目录;
  • runpath 指定的目录;
  • /etc/ld.so.cache 缓存文件,通常包含 /etc/ld.so.conf 文件编译出的二进制俩别哦(比如 CentOS 上,该文件会使用 include 从而使用 ld.so.conf.d 目录下面所有的 *.conf 文件,这些都会缓存在 ld.so.cache 中)
  • 系统默认路径,比如 /lib,/usr/lib。

在编译时若使用 -z nodefaultlib 选项编译,则会跳过 4 和 5。至于 runpath,和 rpath 类似,都是二进制(ELF)文件的动态 section 属性(分别为 DT_RUNPATH 和 DT_RPATH),唯一区别就是是否优先于 LD_LIBRARY_PATH 来查找

3.如何使用

动态库和静态库使用分为隐式使用和显示打开,其中隐式使用就是通过-l直接链接到主程序中,而显示使用时通过dlopen的方式在程序使用的时候打开so文件并获取通过dlsym相应的函数接口。

这里详细介绍下dlopen的方式。

#include <stdio.h>
#include <dlfcn.h>
#include <iostream>

// g++ dlopen_test.cpp -o test -ldl
int main(int argc, char* argv[]){
    void *handle;
    int (*hello)();

    // 打开动态库
    handle = dlopen("./libso21.so", RTLD_LAZY);
    if (!handle) {
        fprintf(stderr, "%s\n", dlerror());
        return 1;
    }

    // 获取动态库中的函数指针
    *(void **)(&hello) = dlsym(handle, "hello_world_so");
    if (!hello) {
        fprintf(stderr, "%s\n", dlerror());
        dlclose(handle);
        return 1;
    }

    // 调用动态库中的函数
    (*hello)();


    // 关闭动态库
    dlclose(handle);

通过dlopen的方式,编译并不会检查so的正确性,只会在运行时加载so到内存中,并获取相应的函数。

4.查看动态库和静态库信息

通常我们需要查看打包的动态库和静态库是否符合要求,链接是否正常以及是否包含相应的函数符号等,有一些命令可以帮助我们更可了解它们。

  1. patchelf:该命令可以用来修改ELF可执行文件的动态链接器(即解释器),RPATH,或者动态符号表。主要用于处理库路径问题,有时候运行某个程序时,系统找不到其依赖的库,可以通过这个命令来修改。

    使用示例:patchelf --set-rpath /path/to/lib your_program

    这个命令将程序二进制文件中的rpath设置为/path/to/lib

  2. nm:这个命令可以显示从目标文件的符号表中列出符号。默认情况下,nm会按照字母顺序列出符号。

    使用示例:nm /path/to/libyourlib.so

    这个命令将列出动态库文件libyourlib.so中的所有符号。

  3. ldd:这个命令可以用来查看可执行文件或者动态库所依赖的其他动态库文件。它会列出所有的依赖库以及这些库的位置。

    使用示例:ldd /path/to/your_program

    这个命令将列出可执行文件your_program依赖的所有动态库的位置。

  4. objdump:这个命令可以显示一个或多个目标文件的信息。它可以显示二进制文件的详细信息,包括文件头,节信息,符号表等。

    使用示例:objdump -x /path/to/your_program

    这个命令将显示可执行文件your_program的详细信息。

  5. readelf:这是一个用于显示ELF格式文件信息的程序。它可以显示ELF文件的各种信息,包括文件头信息,节信息,段信息,符号表等。

    使用示例:readelf -a /path/to/your_program

    这个命令将显示可执行文件your_program的所有ELF信息。

请记住,每一个命令都有很多选项可以使用,这里只是提供了一些基本示例。一定要查阅对应命令的man手册以获取更详细的信息。

调试技巧:GDB

1. 什么是GDB

当我们的程序出现问题且不知道具体的原因时,我们就需要对程序进行调试分析,这时GDB调试工具就会起到很大作用。

GDB是GNU开源组织发布的一款强大的Unix下的程序调试工具,在Linux系统中应用非常广泛。它可以用于调试C,C++,Objective-C等语言编写的程序。

2. GDB 基本使用

  1. GDB安装:在Linux系统中,你可以通过包管理器来安装GDB。例如在Ubuntu中,你可以使用sudo apt-get install gdb来安装。

  2. GDB基本命令:

    • gdb your_program:启动GDB,其中your_program是你想要调试的程序名。
    • run:运行程序。
    • break main:在main函数处设置断点,也可以选择行数。
    • break your_function:在your_function函数处设置断点。
    • continue:继续执行程序,直到遇到下一个断点或者程序结束。
    • step:进入函数内部调试。
    • next:执行下一行代码,不进入函数内部。
    • print variable_name:打印变量值。
    • info local variables:查看当前函数的所有局部变量。
    • quit:退出GDB。
  3. GDB断点调试:

假设我们有一个C语言程序,文件名为example.c,我们想要在main函数的第5行代码处设置断点,并查看该行的变量值。

首先,我们需要使用gcc编译程序时加入调试信息:

gcc -g example.c -o example

然后,我们可以使用gdb启动程序进行调试:

gdb example

在gdb中,我们可以使用break main命令设置断点,然后使用run命令运行程序:

(gdb) break main
(gdb) run

当程序执行到main函数的第5行时,程序会停止执行,这就是所谓的“断点”。此时我们可以使用print variable_name命令查看变量的值,例如:

(gdb) print x

其中,x是我们想要查看的变量名。如果我们要继续执行程序直到下一个断点或者程序结束,我们可以使用continue命令。如果我们想要进入函数内部调试,可以使用step命令;如果我们想要执行下一行代码而不进入函数内部,可以使用next命令。如果我们想要查看当前函数的所有局部变量,可以使用info local variables命令。最后,如果我们想要退出gdb,可以使用quit命令。

CORE文件

在Linux系统中,"core文件"通常指的是一种调试文件,它是程序发生崩溃时由操作系统生成的文件。当一个程序崩溃时,操作系统会创建一个名为"core"的文件,通常在程序崩溃时所在的目录中生成,文件名为程序的名称加上".core"后缀。

要生成core文件,可以按照以下步骤进行:

  1. 确保程序在编译时加入了调试信息。在编译程序时,使用-g选项可以生成带有调试信息的可执行文件。例如,使用以下命令编译程序:
gcc -g your_program.c -o your_program
  1. 当程序崩溃时,操作系统会自动生成一个core文件。你可以在程序崩溃时所在的目录中查找该文件。core文件的命名规则是program_name.core,其中program_name是崩溃的程序名称。
  2. 使用GDB等调试工具打开core文件进行分析。你可以使用以下命令使用GDB打开core文件:
gdb your_program <core_file_path>

其中,your_program是编译时的可执行文件名,<core_file_path>是core文件的路径。 4. 在GDB中,你可以使用各种命令来查看程序的状态、变量的值、堆栈跟踪等信息。例如,你可以使用info registers命令查看寄存器的状态,使用print variable_name命令查看变量的值,使用backtrace命令查看堆栈跟踪等。

通过分析core文件,你可以更好地了解程序崩溃时的状态和原因,从而有助于调试和修复问题。

在linux系统中,core文件是默认不打开的。可以通过

ulimit -c unlimited

你是一名c语言开发工程师,我要调试一些c语言的代码,请介绍下调试工具的使用,涉及gdb功能介绍,gdb基本命令的使用,gdb断点调试等,并举例说明每一个要点