Linux C的一些经典问题

1.什么是嵌入式

嵌入式就是在已有的硬件平台上移植了操作系统,降低软硬件之间的耦合度,移植性高;使开发者无需考虑硬件结构参与项目,通过操作系统提供的API就可以完成大部分工作,提高了产品的开发效率,提高了用户体验率。

2.什么样的操作系统可以做为嵌入式操作系统?

嵌入式操作系统?
就是一种用途广泛的系统软件;负责系统全部的软硬件资源分配,任务管理,控制协调各个进程;使软硬件之间的耦合度降低,使其依赖于一个不变的体系;将软硬件分离开来,推动项目进度,提高产品的开发效率。

(1)可移植性强的,但不一定开源;
(2)强实时性
(3)统一的接口
(4)操作方便
(5)轻量级
(6)可定制

3.运行时语言 vs 解释语言 vs 静态语言

运行时语言 :编译出来的可执行文件是直接可以被识别执行的,不需要第三方插入的。
解释语言:运行时解释给第三方,第三方再去做操作。(shell脚本、java、python)
静态语言 :编译时已经固定好了,不需要做太多变化。(fpga)

4.C语言特点

C语言是一种广泛使用的编程语言,具有许多特点和优势。以下是C语言的主要特点:

  1. 简洁而高效: C语言设计简洁,语法相对简单,易于学习和使用。它直接映射到机器代码,执行效率高,适合开发性能要求较高的应用。
  2. 面向过程: C语言是一种面向过程的编程语言,注重函数和过程的设计。它支持模块化编程,使代码更易于组织和维护。
  3. 系统级编程: C语言广泛用于系统级编程和操作系统开发。它允许直接访问内存和硬件,提供了对计算机底层的强大控制能力。
  4. 可移植性: C语言的标准化使得大部分C代码可以在不同的平台和操作系统上运行,只需要进行少量的修改。
  5. 大量库支持: C语言拥有丰富的标准库和第三方库,提供了各种功能的函数和工具,方便开发人员快速构建应用。
  6. 指针: C语言支持指针,允许直接访问内存地址,使得底层操作更加灵活和高效。
  7. 低级控制: C语言提供了对计算机硬件的底层控制,如位操作和直接访问寄存器,适用于嵌入式开发和驱动程序编写。
  8. 广泛应用: C语言广泛应用于系统软件、嵌入式系统、游戏开发、网络编程等多个领域。
  9. 可扩展性: C语言可以通过调用汇编语言或与其他高级语言混合编程,实现更高级的功能和性能优化。

尽管C语言具有许多优势,但也有一些不足之处,如没有内置的面向对象编程特性,相对较弱的动态内存管理等。然而,由于其简洁、高效和可移植性,C语言仍然是许多编程领域的首选语言之一。

5.C语言的语法标准有哪些?

最早的C语言标准有ANSI/C89/C90,由美国国家标准协会(ANSI)于1989年和1990年发布。它定义了C语言的基本语法和功能,并成为后续标准的基础。这个标准通常被称为"ANSI C"或"C89/C90"。

国际标准化组织(ISO)发布的C99标准,C99标准增加了一些新的功能,如变长数组、内联函数、bool类型和复数支持等。

C11: C11是C语言的最新标准,于2011年发布。C11标准在C99的基础上进行了扩展,增加了一些新的特性,如泛型选择、多线程支持和_Atomic关键字等。

6.编译器有哪些?

编译器是一种将高级语言(如C、C++等)代码转换为目标代码(通常是机器码)的软件工具。它将程序员编写的源代码转换成计算机可以执行的指令,从而使得程序能够在计算机上运行。

“GCC”(GNU Compiler Collection)是一个开源的编译器集合,由GNU计划开发。GCC最初是为C语言编写的,但后来扩展到支持多种编程语言,包括C++、Objective-C、Fortran、Ada等。GCC是一个功能强大且广泛使用的编译器,支持多种平台,如Linux、macOS、Windows等。

"Clang"是LLVM项目的一部分,作为LLVM的编译器前端,用于C、C++、Objective-C和Objective-C++编程语言。Clang的目标是提供更快的编译速度、更好的错误信息和更严格的代码检查。它也成为了流行的C/C++编译器之一。

除了GCC和Clang,还有其他编译器,如Microsoft Visual C++编译器(Windows平台)、Intel C++编译器、Oracle Solaris Studio编译器等。每个编译器都有其独特的特点和优势,选择合适的编译器取决于项目的需求、目标平台和开发人员的偏好。

PS:Visual Studio(VS)是一个集成开发环境(IDE),不是一个单独的编译器。 它包含了多种编程语言的编译器和其他开发工具。

7.C语言的数据类型有哪些?

C语言具有多种数据类型,可以分为基本数据类型和复合数据类型。

基本数据类型:

  1. 整数类型:
    • char:字符类型,通常用于表示单个字符。
    • int:整数类型,通常占用4个字节(取决于编译器和平台)。
    • short:短整数类型,通常占用2个字节。
    • long:长整数类型,通常占用4个字节或8个字节。
  2. 浮点类型:
    • float:单精度浮点类型,通常占用4个字节。
    • double:双精度浮点类型,通常占用8个字节。
  3. 布尔类型:
    • Bool:布尔类型,表示逻辑值,可以是true或false(0或非零值)。
  4. 空类型:
    • void:空类型,通常用于函数返回类型或指针类型。

复合数据类型:

  1. 数组: 一组具有相同数据类型的元素的有序集合。
  2. 结构体: 自定义数据类型,可以包含不同数据类型的成员。
  3. 枚举: 定义一组相关的常量值。

指针类型:
指针是一种特殊的数据类型,用于存储变量的内存地址。可以用指针来间接访问变量的值。

8.补码的作用

  1. 简化加法和减法运算:
    补码的一个主要优势是它可以将加法和减法运算统一为相同的操作。在补码表示中,两个有符号整数相加时,不需要特别的处理符号位,而只需要执行普通的二进制加法操作。这简化了运算器的设计和操作,因为不再需要分别考虑加法和减法。
  2. 消除零的多种表示:
    在补码表示中,0的表示是唯一的,不会出现正零和负零的情况。
  3. 使溢出检测更容易:
    当进行加法或减法运算时,如果结果超出了有限位数所能表示的范围,就会发生溢出。在补码中,溢出可以通过检查最高位(符号位)是否发生进位来进行检测。这种方法更加直观和有效。
  4. 支持数值运算和逻辑运算的统一处理:
    在计算机中,数值运算和逻辑运算通常使用相同的硬件和指令来执行。补码的设计使得有符号整数的数值运算和逻辑运算可以统一处理,从而简化了指令集和操作码的设计。

9.sizeof()和strlen()的区别

  1. sizeof():
    sizeof()是一个编译时运算符,而不是函数。它用于获取指定数据类型或变量所占用的字节数。它在编译时执行,返回一个常量值,不需要执行时计算。可以用于任何数据类型、变量、结构体等。

    示例:

    int num = 10;
    size_t size = sizeof(num); // size将是整数类型的字节数,通常是4
    
  2. strlen():
    strlen()是一个函数,用于计算字符串的长度,即字符串中的字符数(不包括空字符’\0’)。它在运行时执行,需要遍历整个字符串才能确定长度。而且,strlen()要求字符串必须以空字符’\0’结尾,否则可能导致不正确的结果。

    示例:

    char str[] = "Hello, World!";
    size_t length = strlen(str); // length将是字符串的长度,不包括空字符,为12
    

10.C语言的内存空间布局,Linux内存空间布局

C语言的内存空间布局通常包括以下几个部分:

  1. 代码段(Text Segment): 也称为只读段,用于存储程序的机器码。这个部分是存放编译后的可执行代码,通常是只读的,不允许对其进行写操作。
  2. 数据段(Data Segment): 用于存储全局变量和静态变量。这些变量在程序执行过程中保持存在,其值在程序运行期间可以修改。
  3. 堆(Heap): 堆是动态分配的内存区域,用于存储动态分配的内存。在C语言中,使用malloc()calloc()等函数从堆中分配内存。程序员需要手动管理堆中的内存,确保在不需要时及时释放。
  4. 栈(Stack): 栈是用于函数调用和局部变量存储的一种内存区域。每当调用一个函数时,函数的局部变量和返回地址都会被压入栈中,当函数执行完毕后会自动从栈中弹出。
  5. 命令行参数和环境变量: 用于存储程序运行时传递的命令行参数和环境变量。

关于Linux操作系统的内存空间:

Linux采用虚拟地址空间,将不同的内存区域隔离开来,每个进程都有自己的用户空间,进程间不会互相干扰。用户空间和内核空间是相互独立的。

  1. 内核空间(Kernel Space): 内核空间是操作系统内核运行的区域,它拥有对整个系统的完全访问权限,可以直接访问硬件设备和底层资源。
  2. 用户空间(User Space): 用户空间是应用程序运行的区域,应用程序在用户空间执行,不能直接访问硬件资源,必须通过操作系统提供的系统调用来访问内核空间。

区别:

  • C语言的内存空间布局指的是在单个进程的内存中,用于存储代码、数据、堆、栈等的不同区域。它主要关注一个进程内存的组织结构和使用方式。
  • Linux操作系统的内存空间指的是整个操作系统内存的划分,将用户空间和内核空间进行了隔离。用户空间用于运行用户程序,而内核空间用于操作系统内核运行和管理。操作系统通过虚拟内存技术将不同的内存区域映射到物理内存中,从而实现进程之间的隔离和保护。

11.局部变量和全局变量

局部变量:

  1. 声明在函数内部或代码块内部:局部变量只能在声明它的函数内部或代码块内部使用,函数外部无法访问它。
  2. 生命周期:局部变量在其所属的函数或代码块执行期间存在,当函数或代码块执行完毕时,局部变量的内存将被释放,其值将不再有效。
  3. 作用域:局部变量的作用域是从声明它的位置开始,到其所属函数或代码块的结束。它只在所属范围内可见,超出范围后无效。
  4. 初始值:局部变量在声明时不会自动初始化,它的值是不确定的,除非在声明时进行了初始化。

全局变量:

  1. 声明在函数外部或文件顶部:全局变量是在函数外部或文件的顶部声明的,因此在整个文件中都可见。
  2. 生命周期:全局变量在程序运行期间始终存在,直到程序结束才会被销毁,其值持久保存。
  3. 作用域:全局变量的作用域是整个文件,因此可以在文件中的任何函数中访问和修改它。
  4. 初始值:全局变量在声明时如果没有显式初始化,将被自动初始化为零或空值(对于静态全局变量和外部链接的全局变量)。

静态局部变量:

静态局部变量是在函数内部声明的一种特殊类型的变量。与普通的局部变量不同,静态局部变量在函数执行结束后并不会被销毁,其值在函数调用之间保持不变。在C语言中,使用static关键字可以将局部变量声明为静态局部变量。

静态局部变量的主要特点和用途如下:

  1. 生命周期延长: 静态局部变量的生命周期超出了其所属函数的执行范围。它在函数第一次执行时被初始化,并在后续的函数调用中保持其值。这意味着,静态局部变量在函数调用之间保持状态,可以记录函数之间的状态信息。
  2. 保持值的持久性: 静态局部变量的值在函数调用之间保持不变。这使得静态局部变量非常适合用于需要保留历史数据的场景,例如计数器、累加器或缓存数据等。
  3. 初始化一次: 静态局部变量只在第一次函数执行时进行初始化,之后的函数调用将保持其值。如果未显式初始化静态局部变量,它将被自动初始化为零(对于数字类型)或空(对于指针类型)。
  4. 局部可见性: 静态局部变量的作用域仍然是局部的,只在声明它的函数内部可见。这使得静态局部变量不会与其他函数的变量产生命名冲突。

全局变量和局部变量的比较:

  • 全局变量具有全局可见性,可以被整个文件中的任何函数访问和修改,但容易引起命名冲突和不必要的耦合。因此,在使用全局变量时,应该避免滥用,只在确实需要全局共享状态时使用。
  • 局部变量具有局部作用域,只在声明它的函数内部可见,更加安全,不容易被其他函数意外修改。使用局部变量可以提高代码的可维护性和可读性,因为变量的作用范围更加明确。

12.static关键字的作用

在C语言中,static关键字具有不同的作用,它可以用于不同的上下文中,包括局部变量、全局变量、函数和函数参数。以下是static关键字的主要作用:

  1. 静态局部变量:static关键字用于局部变量时,它将变量声明为静态局部变量。静态局部变量的生命周期超出其所属函数的执行范围,它在第一次执行时初始化,并在后续的函数调用中保持其值。

    示例:

    void function() {
        static int count = 0; // 这是一个静态局部变量
        count++;
    }
    
  2. 静态全局变量:static关键字用于全局变量时,它将变量声明为静态全局变量。静态全局变量具有文件作用域,只在当前文件中可见,不会与其他文件中同名的全局变量产生冲突。

    示例:

    static int globalVar; // 这是一个静态全局变量
    
    void function() {
        globalVar = 20; // 可以在当前文件的任何函数中使用和修改 globalVar
    }
    
  3. 静态函数:static关键字用于函数时,它将函数声明为静态函数。静态函数具有文件作用域,只在当前文件中可见,不会被其他文件调用。

    示例:

    static void helperFunction() {
        // 这是一个静态函数,只在当前文件内可见
    }
    
    void main() {
        helperFunction(); // 可以调用静态函数
    }
    
  4. 静态函数参数: 在函数声明时使用static关键字,可以将函数参数声明为静态参数。静态函数参数在函数调用之间保持其值不变,类似于静态局部变量的特性。

    示例:

    void function(static int param) {
        // 这是一个带有静态参数的函数
    }
    

它的意义在于修饰局部变量可以解决全局变量线程不安全问题,用来替代全局变量;修饰全局变量和函数可以解决多人合作开发时命名冲突问题。

13.extern关键字作用

extern关键字在C语言中有两种主要作用:

  1. 声明外部变量: 在一个文件中使用extern关键字来声明一个在另一个文件中定义的全局变量。这样做是为了告诉编译器,该变量是在其他地方定义的,编译器在当前文件中只是对它的声明,而不会为它分配内存空间。

    示例:

    // File1.c
    int globalVar = 10;
    
    // File2.c
    extern int globalVar; // 声明在另一个文件中定义的全局变量
    

    在上述示例中,File2.c中的extern int globalVar;声明了globalVar是在File1.c文件中定义的全局变量。

  2. 声明外部函数: 在一个文件中使用extern关键字来声明一个在另一个文件中定义的函数。这样做是为了告诉编译器,在当前文件中只是对函数的声明,函数的定义在其他文件中。

    示例:

    // File1.c
    int add(int a, int b) {
        return a + b;
    }
    
    // File2.c
    extern int add(int a, int b); // 声明在另一个文件中定义的函数
    

    在上述示例中,File2.c中的extern int add(int a, int b);声明了add函数是在File1.c文件中定义的。

需要注意的是,extern关键字只是进行变量或函数的声明,并不会为其分配内存或定义函数体。它是为了告诉编译器,在其他地方已经定义了这个变量或函数,当前文件只是对它的声明。这样,在编译时,编译器就知道这些标识符在其他文件中定义,并能正确地链接它们。

14.register关键字

register是C语言中的一个关键字,用于给编译器提供一个提示,建议将指定的变量存储在寄存器中,以便在频繁访问的情况下提高变量的访问速度。然而,需要注意的是,register关键字仅仅是一个建议,编译器是否真正将变量存储在寄存器中取决于编译器的实现和优化策略。在现代编译器中,由于编译器已经具有强大的优化能力,通常不再需要显式使用register关键字。

C++中已经弃用。

15.const

const关键字在C语言中用于创建常量、修饰指针、修饰函数参数、修饰结构体成员等,能够提高程序的安全性和可维护性。合理使用const关键字有助于编写更加健壮和清晰的代码。

1. 常量定义:
const用于定义常量,常量一旦被定义,其值在程序执行过程中不能被修改。常量可以用于表示不变的数值、固定的参数等。

const int MAX_VALUE = 100; // 定义一个常量 MAX_VALUE,并初始化为100
const float PI = 3.14159; // 定义一个常量 PI,并初始化为3.14159

2. 常量指针:
表示指针所指向的值是常量,不能通过该指针修改所指向的值。

int num = 10;
const int* ptr = # // 定义一个指向常量整数的指针
*ptr = 20; // 错误!不能通过ptr修改num的值

3. 指针常量:
表示指针本身是常量,不能修改指针所指向的地址。

int num = 10;
int* const ptr = # // 定义一个指针常量,不可修改ptr的值
*ptr = 20; // 可以通过ptr修改num的值

4. 常量指针常量:
const同时用于修饰指针和指针指向的值,表示指针本身是常量,指针指向的值也是常量,即指针和指针指向的值都不能修改。

const int num = 10;
const int* const ptr = # // 定义一个常量指针常量
*ptr = 20; // 错误!不能通过ptr修改num的值
ptr = &anotherNum; // 错误!不能修改ptr的值

5. 函数参数中的const:
const用于修饰函数的参数,表示函数内部不会修改该参数的值。这样可以增加函数的安全性和可读性。

void printValue(const int value) {
    printf("Value: %d\n", value);
}

int num = 100;
printValue(num); // 可以传递变量作为参数
printValue(42);  // 也可以传递常量作为参数

6. const与数组:
const用于定义常量数组,这样数组的元素值在程序执行过程中不能被修改。

const int SIZE = 5;
const int arr[SIZE] = {1, 2, 3, 4, 5}; // 定义一个常量数组
arr[0] = 10; // 错误!不能修改数组元素的值

7. const与函数返回值:
const用于修饰函数的返回值,表示函数返回的值是常量。

const int getConstantValue() {
    return 100; // 函数返回一个常量值
}

int num = getConstantValue();
num = 200; // 错误!函数返回值是常量,不能修改

8. const与结构体:
const用于修饰结构体,表示结构体内的成员值是常量,不能修改。

struct Point {
    const int x;
    const int y;
};

struct Point p1 = {1, 2};
p1.x = 10; // 错误!不能修改结构体成员的值

注意事项:

  • const关键字可以用于任何基本数据类型和自定义数据类型(结构体、枚举等)。
  • const定义的常量必须在声明时初始化,且初始化后不能再修改。
  • const定义的常量在编译时即被确定,存储在符号表中,不占用存储空间。
  • 在多文件编程中,若一个全局变量要在多个文件中共享,通常需要在一个文件中定义该全局变量,并在其他文件中使用extern声明,同时可以使用const关键字修饰来防止被修改。
// File1.c
const int MAX_VALUE = 100;

// File2.c
extern const int MAX_VALUE; // 声明在另一个文件中定义的全局常量

16.break、continue区别

1. break:

break关键字用于立即终止循环(forwhiledo-while)的执行,并跳出循环体。当程序执行到break语句时,循环内剩余的代码将不再执行,而是直接跳出整个循环,继续执行循环之后的代码。

for (int i = 1; i <= 10; i++) {
    if (i == 5) {
        break; // 当i等于5时,立即终止循环
    }
    printf("%d ", i);
}
// 输出结果:1 2 3 4

在上述代码中,当i等于5时,break语句被执行,导致循环立即终止,因此只输出了1、2、3、4四个数字。

2. continue:

continue关键字用于跳过循环中余下的代码,直接进入下一次循环的判断条件。当程序执行到continue语句时,循环体中剩余的代码将被跳过,但循环条件会重新进行判断,从而决定是否进行下一次循环迭代。

for (int i = 1; i <= 5; i++) {
    if (i == 3) {
        continue; // 当i等于3时,跳过循环内的剩余代码,进行下一次循环迭代
    }
    printf("%d ", i);
}
// 输出结果:1 2 4 5

在上述代码中,当i等于3时,continue语句被执行,导致循环内剩余的代码(即printf("%d ", i);)被跳过,直接进入下一次循环的判断条件。因此,输出结果中没有数字3。

总结:

  • break用于立即终止循环,跳出循环体,执行循环之后的代码。
  • continue用于跳过循环内余下的代码,直接进行下一次循环迭代。

17.使用异或交换两个变量的值vs四则运算交换

在C语言中,可以使用异或运算和四则运算来交换两个变量的值。下面分别介绍这两种方法的实现:

1. 使用异或运算交换两个变量的值:

异或运算有一个重要的性质:对于任意整数a和b,有a ^ b ^ b = a,即连续两次异或同一个值会得到原来的值。基于这个性质,可以使用异或运算来交换两个变量的值。

使用异或运算的方法相比使用四则运算的方法更加巧妙,因为它不需要借助额外的临时变量,从而节省了内存空间。

#include <stdio.h>

void swapUsingXOR(int* a, int* b) {
    if (a != b) { // 确保a和b不是同一个地址
        *a = *a ^ *b;
        *b = *a ^ *b;
        *a = *a ^ *b;
    }
}

int main() {
    int x = 10, y = 20;
    printf("Before swap: x = %d, y = %d\n", x, y);

    swapUsingXOR(&x, &y);

    printf("After swap: x = %d, y = %d\n", x, y);

    return 0;
}

在上述代码中,swapUsingXOR函数使用异或运算交换两个变量的值。首先,通过*a = *a ^ *b;将a和b的值异或,并将结果保存到a中;然后,通过*b = *a ^ *b;将a和b的值异或,并将结果保存到b中;最后,通过*a = *a ^ *b;将a和b的值异或,并将结果保存到a中,此时a的值就变成了原来b的值,b的值就变成了原来a的值,完成了变量的交换。

2. 使用四则运算交换两个变量的值:

除了使用异或运算,还可以使用四则运算来交换两个变量的值。这种方法需要借助一个临时变量。

#include <stdio.h>

void swapUsingArithmetic(int* a, int* b) {
    if (a != b) { // 确保a和b不是同一个地址
        int temp = *a;
        *a = *b;
        *b = temp;
    }
}

int main() {
    int x = 10, y = 20;
    printf("Before swap: x = %d, y = %d\n", x, y);

    swapUsingArithmetic(&x, &y);

    printf("After swap: x = %d, y = %d\n", x, y);

    return 0;
}

在上述代码中,swapUsingArithmetic函数使用四则运算交换两个变量的值。首先,通过int temp = *a;将a的值保存到一个临时变量temp中;然后,通过*a = *b;将b的值赋给a;最后,通过*b = temp;将临时变量temp的值赋给b,此时a的值就变成了原来b的值,b的值就变成了原来a的值,完成了变量的交换。

18.掩码的作用?什么时候使用& | ^

掩码就是一串二进制代码对目标字段进行位运算,屏蔽当前的输入位,最终得到一个合理的需求,用来辅助位运算的。
按位与是用来对特定位清零或者去取特定位的值,按位或是将特定位置1,其他位保持不变,按位异或是将特定位值取反,同时异或还具有不引入第三方量,交换两个变量值的功能。
总的来说一般清零和取位用与、置位用或,交换变量的值和取反特定位用异或。

19.位运算的作用?

位运算在计算机底层编程、嵌入式开发、算法设计等方面发挥着重要作用。位运算主要是直接操控二进制数时使用 ,主要目的是节约内存,使程序速度更快,还有就是在对内存要求苛刻的地方使用。

20.谈谈你对指针的理解(什么是指针?指针的作用?指针的运算?指针变量的大小为什么是固定大小?为什么会有不同类型的指针?)

什么是指针?

指针是一个存储其他变量地址的变量。它包含了一个内存地址,指向另一个变量在内存中的存储位置。通过指针,可以间接地访问和修改这个地址所指向的变量的值。指针的类型必须与它所指向的变量类型相匹配。

指针的作用?

  1. 动态内存分配: 使用指针可以在运行时动态地分配内存,这对于创建灵活的数据结构和处理不确定大小的数据非常有用。
  2. 传递参数: 通过指针可以实现函数之间的数据传递,包括传递大型数据结构或多个值。
  3. 数组和字符串操作: 指针与数组紧密相关,通过指针可以遍历数组元素或进行字符串操作。
  4. 指针与函数: 可以使用函数指针来动态选择要调用的函数,从而实现回调功能。
  5. 优化性能: 使用指针可以减少数据的拷贝和传递,提高程序的执行效率。
  6. 数据结构的灵活性: 指针为数据结构提供了灵活性,允许在堆上分配内存并建立动态数据结构,如链表、树等。这些动态数据结构在插入、删除和调整时更加高效。

指针的运算?

指针可以进行一些基本的运算,包括:

  1. 指针加法和减法: 可以对指针进行加法和减法运算,用于访问数组中的元素。
  2. 指针的自增和自减: 可以通过自增和自减运算来遍历数组或链表等数据结构。

指针变量的大小为什么是固定大小?

指针变量的大小在不同的架构和操作系统中是固定的,通常是与系统的寻址能力相关的。在32位系统中,指针的大小通常是4字节(32位),而在64位系统中,指针的大小通常是8字节(64位)。这是因为指针必须能够存储一个完整的内存地址,而内存地址的大小由系统的寻址能力决定。

为什么会有不同类型的指针?

不同类型的指针是为了处理不同类型的数据而存在的。指针的类型必须与它所指向的变量类型相匹配,这样才能正确地访问和操作内存中的数据。例如,指向整数的指针必须是int*类型,指向字符的指针必须是char*类型,指向结构体的指针必须是相应结构体类型的指针等。不同类型的指针可以用于处理各种数据类型,使得程序更加灵活和通用。同时,不同类型的指针也要求开发人员在使用时要注意类型匹配,避免出现潜在的错误。

21.万能指针的作用及注意事项

万能指针(void*)是C语言中一种特殊类型的指针,它可以指向任意类型的数据。由于其灵活性,它被称为"万能指针"。

万能指针可以接受任何指针变量的值,函数的返回值和形参使用万能指针可以提高通配性。使用时要注意不能通过*来获取指向空间。

编程时需要注意以下事项:

1. 无类型指针: void*是一种无类型指针,它没有指向具体类型的信息。因此,在使用万能指针时,必须明确指定其指向的数据类型。

2. 需要显式转换: 由于void*是无类型指针,无法直接进行指针运算和解引用。在使用之前,必须将其转换为特定类型的指针。转换时要确保指向的数据类型与实际数据类型匹配,否则可能导致未定义的行为。

3. 内存泄漏风险: 由于void*丧失了类型信息,容易导致内存泄漏。如果使用void*进行内存分配(例如,使用malloc返回void*),在释放内存时需要注意转换回正确的类型。

4. 潜在错误: 由于万能指针无法提供类型检查,如果误用或不正确地转换指针类型,可能导致程序运行时出现意想不到的错误,如数据错误、段错误等。

5. 慎用万能指针: 虽然万能指针在某些场景下非常有用,但在一般情况下,应该避免频繁使用它。最好使用类型安全的指针,这样可以在编译时进行类型检查,减少错误的发生。

6. 使用场景: 万能指针通常用于处理未知类型的数据或在不同类型之间进行通用的数据传递。在使用万能指针时,最好有充分的理由,并且需要仔细检查类型转换的正确性。

22.什么是野指针?如何避免野指针?如何检测野指针?

什么是野指针?

野指针是指指向无效内存地址的指针。这种指针指向的内存地址可能是未初始化的、已释放的或者超出了其作用域的,因此访问这个指针指向的内存会导致未定义的行为。野指针通常是由于不正确地使用指针引起的,是一种常见的编程错误。

如何避免野指针?

  1. 初始化指针: 在声明指针变量时,尽量初始化为NULL或者一个有效的内存地址。避免在声明时不给指针赋值,以免产生未知的初始值。
  2. 避免悬空指针: 在指针不再需要时,及时将其赋值为NULL或者释放指向的内存,避免指针悬空。
  3. 注意指针的作用域: 确保指针的作用域不会超出其有效范围。尽量在需要指针的地方声明和使用它,避免在多个函数之间共享指针而引发问题。
  4. 避免指针运算错误: 对指针进行加减运算时,要确保操作后的指针仍指向有效的内存位置。
  5. 慎用动态内存分配: 使用动态内存分配(如malloccalloc等)时,要确保适时释放内存,并避免产生内存泄漏。

如何检测野指针?

检测野指针是一项重要的防御性编程措施,以及确保程序的安全性和稳定性的手段。可以通过以下方法检测野指针:

  1. NULL检查: 在使用指针前,始终进行NULL检查。在对指针进行解引用操作(如*ptr)之前,先判断指针是否为NULL。如果指针为NULL,说明它是一个野指针。
  2. 指针作用域: 确保指针的作用域在合理的范围内,并在指针不再使用时及时赋值为NULL或释放内存。
  3. 静态代码分析工具: 使用静态代码分析工具可以帮助检测潜在的野指针问题。这些工具能够静态分析代码并找出可能的指针错误。
  4. 运行时检测: 有些调试工具或编程语言提供运行时检测功能,可以检测指针是否超出范围或指向无效内存。
  5. 编程经验和代码审查: 充分的编程经验和代码审查可以帮助发现并修复潜在的野指针问题。

23.一维数组名的作用?一维数组的地址取值的意义?

一维数组名的作用:

  1. 表示数组的首元素地址:一维数组名实际上是指向数组第一个元素的指针。当使用一维数组名时,它会自动转换为指向数组第一个元素的指针,即数组的首元素地址。
  2. 数组名作为指针常量:在大多数情况下,一维数组名被视为指针常量,即不能再修改数组名的值。例如,arr++; 是非法的,因为数组名 arr 是指针常量,不能被赋值或修改。

一维数组的地址取值的意义:

  1. 获取数组元素的地址:可以通过数组名加上索引来获取特定元素的地址。例如,对于一维数组 int arr[5]&arr[0]&arr[1] 等表示数组中不同元素的地址。
  2. 传递整个数组给函数:在函数中,可以通过将数组名作为参数传递,来传递整个数组的地址。通过传递数组的地址,函数可以直接操作数组的内容,而不需要复制整个数组。
  3. 动态内存分配:在动态内存分配中,可以使用数组的地址来获取数组的指针,并根据需要分配内存空间。
  4. 指针运算:数组的地址取值也可以用于进行指针运算,例如将指针向后移动,来遍历数组元素。
int main() {
    int arr[5] = {1, 2, 3, 4, 5};

    printf("Array address: %p\n", &arr);        // 表示整个一维数组 arr 的地址
    printf("Array address: %p\n", arr);        // 数组的首元素地址
    printf("Element 0 address: %p\n", &arr[0]); // 数组第一个元素的地址
    printf("Element 1 address: %p\n", &arr[1]); // 数组第二个元素的地址

    return 0;
}

24.二维数组名的作用?二维数组地址取值的意义?

  1. 二维数组名的作用
    • 标识整个数组:数组名本身代表整个二维数组。它是一个常量指针,指向数组的第一个元素的地址,即数组的首行首元素的地址。
    • 支持数组元素的访问:可以使用数组名来访问二维数组中的元素,通过使用两个索引来确定特定行和列的元素。例如,arr[i][j] 表示数组 arr 中的第 i 行第 j 列的元素。
    • 传递给函数:可以将二维数组名传递给函数,函数可以使用数组名来访问和操作整个数组,或者使用指针的方式来操作数组元素。
  2. 二维数组地址取值的意义
    • 指向子数组的指针:取二维数组名的地址会得到一个指向数组的指针。这个指针实际上是一个指向包含整个二维数组的一维数组的指针。它的类型通常是 int(*)[n],其中 n 是数组的列数。
    • 用于函数传递:将二维数组名的地址传递给函数时,函数可以使用这个指针来访问整个数组,或者可以用指针的方式来遍历和修改数组元素。这允许函数操作二维数组的不同部分,而不需要复制整个数组,从而提高了效率。
    • 动态分配内存:在动态分配二维数组内存时,可以使用二维数组名的地址来管理内存。这使得在运行时根据需要创建二维数组变得更加灵活。

示例:

#include <stdio.h>

int main() {
    int arr[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};

    printf("Array address: %p\n", &arr);      // 整个二维数组的地址
    printf("Array address: %p\n", arr);      // 二维数组的首元素地址
    printf("Array address: %p\n", &arr[0]);      // 二维数组的首元素地址
    printf("Address of first row: %p\n", arr[0]); // 第一行的地址

    return 0;
}

arr 表示整个二维数组的首元素地址:
arr 是一个指向包含 4 个元素的一维数组的指针。在指针运算中,arr + 1 会指向二维数组的下一行,即第二行的首元素地址。

&arr[0] 表示二维数组中的第一行的地址:
&arr[0] 是一个指向包含 4 个元素的一维数组的指针。在指针运算中,&arr[0] + 1 会指向二维数组中的下一行,即第二行的首元素地址。

总结:

  • arr 是指向二维数组中第一行的指针,表示整个二维数组的首元素地址。
  • &arr[0] 是指向二维数组中第一行的地址,它也是一个指向包含 4 个元素的一维数组的指针。

25.数组指针变量的作用?使用场景?

数组指针变量是指向数组的指针,它可以指向一维数组、二维数组或更高维度的数组。数组指针变量的类型取决于它所指向的数组的类型和维度。使用数组指针可以方便地操作数组元素,而无需知道数组的具体维度和大小。

数组指针变量的作用:

  1. 方便数组元素的访问: 数组指针可以通过指针运算来访问数组的元素,不需要使用下标索引。
  2. 作为函数参数: 可以将数组指针作为函数的参数传递,这样可以在函数内部直接访问和修改数组的内容,而无需复制整个数组。
  3. 动态内存分配: 可以使用数组指针来动态分配内存空间,特别适用于多维数组的动态分配。
  4. 处理多维数组: 数组指针可以更方便地处理多维数组,无需指定所有维度的大小。

使用场景:

  • 在函数中传递数组指针作为参数,以便在函数内部直接处理数组。
  • 动态分配多维数组的内存空间。
  • 在处理多维数组时,使用数组指针可以更高效地遍历数组元素。
  • 用于处理不同大小的数组,因为数组指针的类型可以根据指向的数组的类型和维度来灵活适应不同的情况。

示例:

#include <stdio.h>

void printArray(int *arr, int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main() {
    int arr1[5] = {1, 2, 3, 4, 5};
    int arr2[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};

    int *ptr1 = arr1; // 数组指针指向一维数组
    int (*ptr2)[3] = arr2; // 数组指针指向二维数组的一维数组

    printArray(ptr1, 5); // 通过数组指针传递一维数组
    for (int i = 0; i < 3; i++) {
        printArray(ptr2[i], 3); // 通过数组指针传递二维数组的每个一维数组
    }

    return 0;
}

输出:

1 2 3 4 5 
1 2 3 
4 5 6 
7 8 9 

在上面的示例中,ptr1 是指向一维数组 arr1 的数组指针变量,ptr2 是指向二维数组 arr2 的数组指针变量。通过这些数组指针变量,我们可以方便地传递数组给函数并遍历数组的元素。

26.什么是动态数组?什么是零长数组?应用场景?

动态数组:
动态数组是在程序运行时根据需要动态分配内存空间的数组。它的大小可以在运行时确定,并且可以根据实际需求进行动态调整。在 C 语言中,动态数组通常通过使用 malloccallocrealloc 函数来分配内存空间,并通过 free 函数来释放内存空间。

零长数组:
零长数组是指数组的长度为0的数组。在 C 语言中,零长数组通常用于占位或作为某些数据结构的末尾标志。虽然零长数组本身不占用内存空间,但它通常用于占位,以便在需要时在同一块内存中动态分配实际的数组长度。

应用场景:

  • 动态数组的应用场景主要是在需要根据输入数据的大小动态调整数组大小的情况下。例如,读取用户输入的数据并存储在数组中,但用户可能输入不同大小的数据。
  • 动态数组还可以用于避免固定数组大小带来的内存浪费。如果数组的大小事先不确定,使用动态数组可以节省内存空间。
  • 零长数组通常用于数据结构中的柔性数组成员,这种成员允许在同一块内存中存储不同大小的数据。例如,在动态链表中,可以使用零长数组作为可变长度的数据块。

27.数组的特点

数组是静态分配空间,空间是物理连续的,且空间利用率低;数组名是指针常量,一维数组名是首个元素的地址,二维数组名是首个一维数组的地址;数组提供了一种简单的访问机制,空间连续(只需知道首地址),数组的访问是直接访问,效率高。使用数组容易造成数组越界。数组做不了形参,默认转化为对应类型的指针变量(实参)。
在c89中,不能通过变量来定义数组的大小,而在c99中就可以,但是要注意通过变量名定义数组大小不能直接初始化(不知道它的长度),
数组大小最好用宏定义来规范,提高代码移植性和可读性。

28.数组做指针的注意事项

  1. 数组名是指针常量: 数组名在大多数情况下会被隐式地转换为指向数组首元素的指针,但是数组名本身是一个常量指针,它存储了数组的地址,且不可更改。例如,对于 int arr[5]arr 表示指向数组 arr 的第一个元素的指针,但不能修改 arr 的值。

  2. 数组名作为函数参数: 当数组作为函数的参数传递时,数组名会退化为指向数组首元素的指针。在函数内部,形参看起来像一个指针变量,并不是数组。因此,函数内部无法获取数组的大小。通常,我们需要通过额外的参数传递数组大小。在函数内部使用 sizeof 运算符来获取数组的大小时,返回的是指针的大小,而不是整个数组的大小

    示例:

    void printArray(int arr[], int size) {
        // 在函数内部,arr 类似于指针 int* arr
        // 无法获取数组的大小,需要额外的参数 size
        for (int i = 0; i < size; i++) {
            printf("%d ", arr[i]);
        }
    }
    
    int main() {
        int arr[] = {1, 2, 3, 4, 5};
        printArray(arr, 5); // 1 2 3 4 5
        return 0;
    }
    
  3. sizeof 运算符与数组名:sizeof 运算符中,数组名会被解释为数组,返回整个数组的字节大小。例如,sizeof(arr) 返回整个数组 arr 的字节大小,而不是指针的大小。

  4. 数组名与指针运算: 数组名可以进行指针运算,如 arr + 1 将得到数组中下一个元素的地址。但要注意,数组名不能被赋值或修改,因为它是一个常量指针。

    示例:

    int arr[] = {1, 2, 3, 4, 5};
    int* ptr = arr;  // 数组名被隐式转换为指向数组首元素的指针
    
    // 数组名可以进行指针运算
    printf("%d\n", *(ptr + 1)); // 输出 2
    
    // 但是数组名本身不能被赋值或修改
    // arr = ptr; // 错误:不能修改数组名 arr
    

总结:在使用数组做指针时,需要注意数组名是指针常量,无法修改;在函数参数传递中,数组名会退化为指针,无法在函数内部获取数组大小;在 sizeof 运算符中,数组名表示整个数组的字节大小;数组名可以进行指针运算,但本身不能被赋值或修改。理解这些细节有助于避免数组操作中的错误和误解。

29.传值与传地址的区别(如何选择)

传值(Pass by Value):

  1. 传值是将实际参数的值拷贝给函数形参,函数中对形参的修改不会影响到实际参数。
  2. 传值对于函数内部的计算不会影响原始数据,适用于简单数据类型或需要保护原始数据的情况。
  3. 传值会在函数调用时创建形参的副本,所以可能会导致内存消耗较大,特别是在传递大型数据结构时。

传地址(Pass by Address,也称为传引用):

  1. 传地址是将实际参数的地址传递给函数形参,函数中对形参的修改会影响到实际参数。
  2. 传地址适用于需要在函数中修改原始数据的情况,可以避免拷贝大量数据而带来的内存开销。
  3. 传地址避免了在函数调用时复制数据,因此效率较高。但需要注意,如果在函数内部不小心修改了地址指向的内容,可能会影响到其他部分的代码。

选择使用传值还是传地址:

  1. 如果数据量较小且不需要在函数中修改原始数据,可以选择传值,因为它简单、安全且不会对原始数据造成影响。
  2. 如果数据量较大,或者需要在函数中修改原始数据,可以选择传地址,以避免不必要的数据拷贝和内存开销。
  3. 对于复杂的数据结构(如数组、字符串、结构体等),传地址通常是更好的选择,因为它避免了在函数调用时复制大量数据。
  4. 需要注意在传地址时,要确保函数中正确处理了指针的边界情况,避免指针为空或野指针引起的问题。

30.传出参数的作用?

传出参数是指在函数调用中,通过函数的形参将数据从函数内部传递给函数外部,从而实现函数对外部数据的修改。传出参数的作用主要在于让函数能够返回多个值或修改外部数据。

传出参数的作用如下:

  1. 返回多个值: 有些情况下,函数需要返回多个值。使用传出参数可以让函数返回多个结果,而不是将这些结果封装在一个复杂的数据结构中。
  2. 修改外部数据: 有时需要在函数内部修改外部数据,通过传出参数可以实现这个目的。这样可以避免全局变量的使用,提高代码的可维护性。
  3. 避免使用全局变量: 使用传出参数可以避免使用全局变量,减少了函数间的耦合性,使代码更加模块化和可重用。
  4. 提高函数灵活性: 传出参数可以使函数更加灵活,使得函数可以适用于不同的场景和数据。

需要注意的是,在使用传出参数时,应确保传入的指针不为空,并在函数内部正确处理指针的边界情况,以避免出现指针为空或野指针引起的问题。

示例:

#include <stdio.h>

// 传出参数实现返回多个值
void calculateSumAndProduct(int a, int b, int *sum, int *product) {
    *sum = a + b;
    *product = a * b;
}

// 传出参数实现修改外部数据
void incrementNumber(int *num) {
    *num = *num + 1;
}

int main() {
    int x = 5, y = 10;
    int sum, product;

    calculateSumAndProduct(x, y, &sum, &product);
    printf("Sum: %d, Product: %d\n", sum, product);

    int number = 10;
    printf("Before increment: %d\n", number);
    incrementNumber(&number);
    printf("After increment: %d\n", number);

    return 0;
}

输出:

Sum: 15, Product: 50
Before increment: 10
After increment: 11

在上述示例中,calculateSumAndProduct 函数使用传出参数 sumproduct 实现返回多个值;incrementNumber 函数使用传出参数 num 实现修改外部数据。这样可以避免使用全局变量,并提高函数的灵活性。

31.如何让函数返回多个值

在 C 语言中,函数本身只能返回一个值。如果需要让函数返回多个值,可以通过以下几种方式实现:

  1. 使用指针参数: 函数可以接受指针作为参数,并通过指针参数将多个结果传递给函数外部。
  2. 使用结构体: 可以定义一个结构体,将多个需要返回的值封装在结构体中,然后将结构体作为函数的返回值。
  3. 使用全局变量: 将需要返回的值保存在全局变量中,函数内部对全局变量进行赋值,然后在函数外部读取这些值。
  4. 使用数组: 可以定义一个数组,将多个需要返回的值存储在数组中,然后将数组作为函数的返回值或通过指针参数传递给函数。

32.主函数的形参代表的意义

在 C 语言中,主函数是程序的入口点,它具有以下形式:

int main(int argc, char *argv[])

主函数的形参代表的意义:

  1. int argc:表示命令行参数的数量(Argument Count)。argc 是一个整数,用于记录命令行参数的个数。至少为 1,因为第一个参数始终是程序的名称。
  2. char *argv[]char **argv:表示命令行参数的字符串数组(Argument Vector)。argv 是一个指向指针的指针,每个指针指向一个命令行参数的字符串。

主函数运行前要进行预处理、编译、汇编、链接操作,链接结束后会将启动代码链接进去。主函数被启动代码调用,而启动代码是由编译器添加到程序中的,也是程序和操作系统的桥梁,事实上,int main()描述的是main()是操作系统之间的接口。

事实证明main函数只是一个程序的入口,也相当于一个普通的函数,也能被自身调用,也能被其他函数调用。这和一般的函数之间互相调用的概念是一样的。不过需要注意的是,main函数不管是自身的调用还是被其他函数调用,都要设置函数终止的条件,这个递归函数有点相似,不然就会陷入死循环。

33.resrtict关键字

restrict是c99新增的关键字,restrict指针是限定指针指向不同内存空间的,是一个限定符,是编译器用来优化的。使用者可以通过restrict限定符提示调用者某些指针是指向不同空间的,相同空间会被优化成不同空间。
使用时要注意编译器并不能完全优化不同的空间,谨慎使用restrict。

在使用 restrict 关键字时,需要遵循以下几个注意事项:

  1. 指针别名: restrict 关键字用于告知编译器,通过这个指针访问的内存区域不会与其他指针重叠。因此,使用 restrict 关键字时要确保指针指向的内存区域没有被其他指针别名访问。
  2. 指针赋值: 不能将一个指针赋值给 restrict 修饰的指针,否则可能导致别名规则被破坏。
  3. 别名检查: 编译器不会对 restrict 关键字进行别名检查,它假设程序员已经正确使用了 restrict 关键字。

示例:

#include <stdio.h>

void addArrays(int n, int * restrict a, int * restrict b, int * restrict result) {
    for (int i = 0; i < n; i++) {
        result[i] = a[i] + b[i];
    }
}

int main() {
    int a[] = {1, 2, 3, 4, 5};
    int b[] = {5, 4, 3, 2, 1};
    int result[5];

    addArrays(5, a, b, result);

    for (int i = 0; i < 5; i++) {
        printf("%d ", result[i]);
    }
    printf("\n");

    return 0;
}

34.return 0;语句的作用?return vs exit(1)

return 0; 语句的作用是在 C 语言中用于表示程序执行成功,并向调用该程序的进程返回一个退出状态码。在 C 语言中,主函数 main 的返回类型为 int,并且规定主函数必须以 return 语句结束,返回一个整数值作为程序的退出状态码。通常情况下,返回 0 表示程序执行成功,返回其他非零值表示程序执行出现异常或错误。

returnexit(1) 都可以用于终止程序的执行,但它们有以下区别:

  1. 使用场景:
    • return 用于从函数中返回一个值,并结束该函数的执行。
    • exit(1) 用于立即终止整个程序的执行,不会执行后续的代码。
  2. 终止位置:
    • return 语句用于从函数中返回一个值,并将控制权返回给调用该函数的代码处,继续执行后续代码。
    • exit(1) 函数用于立即终止程序的执行,并返回操作系统,不会继续执行任何后续代码。
  3. 退出状态码:
    • return 语句可以返回一个整数值作为退出状态码,表示程序的执行结果。通常情况下,返回 0 表示程序执行成功,返回其他非零值表示程序执行出现异常或错误。
    • exit(1) 函数的参数表示程序的退出状态码,一般来说,非零的状态码表示程序执行出现错误,而 0 表示程序执行成功。

35.函数指针,指针函数

  1. 函数指针:
    • 定义: 函数指针是指向函数的指针变量。它可以存储函数的地址,使得我们可以通过函数指针来调用特定的函数。函数指针的定义需要与目标函数的签名(参数列表和返回值类型)匹配。
    • 作用: 函数指针的主要作用是动态调用函数。它允许我们在运行时根据需要选择调用不同的函数,提高代码的灵活性和可扩展性。(函数指针体现了C面向对象编程的思维逻辑,多态。)
    • 使用场景: 函数指针常用于以下情况:
      • 回调函数:将函数指针作为参数传递给其他函数,以实现回调机制。
      • 函数映射:通过函数指针数组实现根据索引调用不同的函数。
      • 排序和查找算法:使用函数指针实现通用的排序和查找算法,可在不同的场景下使用不同的比较函数。
    • 注意事项:
      • 函数指针的类型必须与目标函数的签名匹配,否则会导致错误。
      • 函数指针可以为空指针(NULL),在调用函数指针之前,最好检查它是否为空。

示例:

#include <stdio.h>

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

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

int main() {
    int (*operation)(int, int); // 声明一个函数指针变量

    operation = add; // 将函数 add 的地址赋值给函数指针
    printf("Result of add: %d\n", operation(5, 3)); // 输出 8

    operation = subtract; // 将函数 subtract 的地址赋值给函数指针
    printf("Result of subtract: %d\n", operation(5, 3)); // 输出 2

    return 0;
}
  1. 指针函数:
    • 定义: 指针函数是指返回指针的函数。它的返回类型是一个指针类型,表示返回指向某个数据类型的指针。
    • 作用: 指针函数可以用于返回动态分配的内存空间,从而实现在函数外部访问函数内部动态分配的内存。
    • 使用场景: 指针函数常用于以下情况:
      • 动态内存分配:通过指针函数返回动态分配的内存空间,避免在函数外部使用静态数组或全局变量。
      • 返回数组:在函数内部创建数组,通过指针函数返回数组的首地址。
    • 注意事项:
      • 在使用指针函数返回的指针之前,确保指针指向的内存空间是有效的,避免出现悬挂指针或内存泄漏的问题。
      • 在函数调用结束后,需要释放通过指针函数返回的动态分配的内存,以免造成内存泄漏。

示例:

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

int* createIntArray(int size) {
    int* arr = (int*)malloc(size * sizeof(int));
    return arr;
}

int main() {
    int* array = createIntArray(5);

    for (int i = 0; i < 5; i++) {
        array[i] = i * 2;
        printf("%d ", array[i]); // 输出 0 2 4 6 8
    }

    free(array); // 释放动态分配的内存

    return 0;
}

在上述示例中,createIntArray 是一个指针函数,它返回一个指向动态分配整型数组的指针。在 main 函数中,我们调用 createIntArray 函数来获取一个动态分配的整型数组,并在使用完后释放了这段内存。

总结:

  • 函数指针允许动态调用不同的函数,提高代码的灵活性。
  • 指针函数用于返回指针类型,通常用于动态内存分配或返回数组。
  • 使用函数指针和指针函数时,要确保正确匹配函数的签名和释放动态分配的内存,避免出现错误和内存泄漏。

36.函数指针数组的定义及初始化?及作用?

函数指针数组是一个数组,其元素是函数指针。函数指针数组的定义和初始化方式如下:

定义和初始化:

#include <stdio.h>

// 假设有两个函数原型:
int add(int a, int b);
int subtract(int a, int b);

// 定义函数指针数组,数组元素为指向函数的指针
int (*operation[])(int, int) = {add, subtract};

上述代码中,我们定义了一个名为 operation 的函数指针数组,该数组的元素是指向函数的指针。在这个例子中,我们假设有两个函数 addsubtract,它们都接受两个整型参数并返回一个整型结果。然后,我们将这两个函数的地址分别存储在 operation 数组中,使得 operation[0] 指向 add 函数,operation[1] 指向 subtract 函数。

作用:

函数指针数组的主要作用是实现函数映射或函数调度表。通过函数指针数组,我们可以根据索引或其他条件选择性地调用不同的函数。这样可以提高代码的灵活性和可扩展性。

使用函数指针数组的典型场景包括:

  1. 函数调度表: 可以根据输入的索引或条件,在函数指针数组中选择合适的函数来执行特定的操作。
  2. 回调函数: 将函数指针数组作为参数传递给其他函数,在其他函数中根据不同的情况选择性地调用不同的回调函数。
  3. 状态机: 将函数指针数组作为状态机的转换表,根据当前状态和输入选择性地调用不同的状态处理函数。

示例:

#include <stdio.h>

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

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

int main() {
    // 定义函数指针数组,包含两个函数指针
    int (*operation[])(int, int) = {add, subtract};

    int choice;
    int a = 10, b = 5;

    printf("Enter 0 for add, 1 for subtract: ");
    scanf("%d", &choice);

    // 根据用户输入的选择调用相应的函数
    int result = operation[choice](a, b);

    printf("Result: %d\n", result);

    return 0;
}

在上述示例中,根据用户输入的选择,我们使用函数指针数组 operation 调用了不同的函数(addsubtract)。这样,在运行时可以根据用户的选择执行不同的函数操作,实现了函数调度表的功能。

总结:

  • 函数指针数组是一个数组,其元素是函数指针。
  • 函数指针数组的作用主要是实现函数映射或函数调度表,使得程序在运行时可以根据索引或条件选择性地调用不同的函数,提高代码的灵活性和可扩展性。

37.数组做指针的注意事项

对于一维数组来说,数组作为函数参数传递,实际上传递了一个指向数组的指针,当数组名作为函数参数时,在函数体内数组名自动退化为指针。此时调用函数时,相当于传址,而不是传值,会改变数组元素的值。
对于高维数组来说,可以用二维数组名作为实参或者形参,在被调用函数中对形参数组定义时可以指定所有维数的大小,也可以省略第一维的大小说明,但不能省略第二维的大小说明。

38.为什么需要内存管理,内存管理的方式?优缺点?

内存管理的根本目的是解决运行时的错误,特别是由于内存资源有限而导致的内存相关问题。确保内存的高效合理使用可以提高系统的性能和稳定性。

内存管理方式主要分为用户管理和系统管理(垃圾回收机制)两种:

  1. 用户管理: 用户管理是指由程序员手动管理内存的分配和释放,通常在低级语言中使用,如 C 和 C++。优点是效率高,因为程序员可以精确控制内存的分配和释放,避免了垃圾回收器的开销。然而,用户管理容易出错,容易引起内存泄漏和悬挂指针等问题,需要更多的注意和谨慎。
  2. 系统管理(垃圾回收机制): 系统管理是指由编程语言或运行时环境提供的自动内存管理机制。优点是简化了开发,程序员无需手动管理内存,减少了出错的可能性。垃圾回收器可以自动回收不再使用的内存,避免了内存泄漏。然而,垃圾回收机制可能会带来一定的性能开销,并且可能导致应用暂停执行,影响实时性要求高的应用。

39.C语言分配内存方式有哪些?

C语言的分配内存方式分为栈上的静态分配和堆上的动态分配。
静态分配一般通过数组和alloca函数,栈上分配内存需要注意栈空间的大小是有限的。
动态分配一般是通过指针,堆上分配容易照成内存泄漏。

40.动态分配内存的函数

以下函数通常用于在堆上分配内存,可以通过指针来访问这些内存。

  1. malloc:
    函数原型:void* malloc(size_t size);
    功能:用于分配指定大小的内存块,并返回一个指向该内存块的指针。分配的内存块不会被初始化,其内容是未定义的。如果分配失败,返回空指针(NULL)。
    注意:使用 malloc 分配的内存在使用之前需要手动进行初始化,否则可能会包含随机值。
  2. calloc:
    函数原型:void* calloc(size_t num_elements, size_t element_size);
    功能:用于分配指定数量和大小的内存块,并返回一个指向该内存块的指针。分配的内存块会被初始化为全零。如果分配失败,返回空指针(NULL)。
  3. realloc:
    函数原型:void* realloc(void* ptr, size_t new_size);
    功能:用于重新分配已经分配的内存块大小,可以扩大或缩小内存块的大小。如果内存块被扩大,新分配的内存区域中的内容是未定义的(需要手动初始化)。如果分配失败,返回空指针(NULL)。如果 ptr 是空指针,则 realloc 的行为等同于 malloc(new_size)
  4. free:
    函数原型:void free(void* ptr);
    功能:用于释放之前通过 malloccallocrealloc 分配的内存块。释放内存后,指向该内存块的指针不再有效,应该避免继续使用该指针。

41.源文件到可执行文件经历那几个步骤?

将源文件(如 C/C++ 文件)编译成可执行文件的过程涉及多个步骤。以下是通常的步骤:

  1. 预处理(Preprocessing): 在这一阶段,编译器对源文件进行预处理。预处理器会处理源文件中的预处理指令(以 # 开头的指令),如宏定义、头文件包含等,并将其替换成相应的内容。预处理后的文件称为预处理文件。
  2. 编译(Compilation): 在这一阶段,预处理后的文件(即预处理文件)会被编译器翻译成汇编代码。编译器会将源代码转换为汇编代码,这是一个与具体硬件架构相关的低级别代码,但仍保留了源代码的结构。
  3. 汇编(Assembly): 在这一阶段,汇编器将汇编代码翻译成机器代码,也就是目标文件。目标文件包含了处理器可以直接执行的二进制指令,但还没有链接到最终的可执行文件。
  4. 链接(Linking): 在这一阶段,链接器将目标文件与其他可能需要的目标文件或库文件进行链接。链接器的主要作用是解析目标文件之间的符号引用,并将它们关联到正确的地址。最终生成的可执行文件包含了所有所需的目标代码以及标准库或其他依赖库的代码。

最终,经过上述步骤,源文件就会被编译成一个可执行文件,可以在相应的操作系统上运行。

42.宏定义的作用?

宏定义的作用是在编译过程中将指定的文本片段替换为预定义的内容。宏定义通常用于定义常量、函数或代码块的缩写,以及在编译时进行简单的代码替换。

宏定义的主要作用有以下几点:

  1. 定义常量: 宏定义可以用于定义常量,在代码中使用宏名称时会被替换为其对应的值。这样可以提高代码的可读性和可维护性,同时便于修改常量的值。

    示例:

    #define MAX_SIZE 100
    int arr[MAX_SIZE];
    
  2. 代码块缩写: 宏定义可以用于定义代码块的缩写,将一组代码片段替换为一个宏名称。这样可以简化代码,使其更加简洁易读。

    示例:

    #define SQUARE(x) ((x) * (x))
    int result = SQUARE(5); // 将会被替换为 int result = ((5) * (5));
    
  3. 条件编译: 宏定义可以用于条件编译,在预处理阶段根据条件判断是否包含某段代码。这在处理不同平台或配置下的代码时非常有用。

    示例:

    #define DEBUG
    #ifdef DEBUG
    // 在调试模式下执行的代码
    #endif
    
  4. 函数宏: 宏定义还可以用于定义函数宏,即将宏定义替换为一段代码块。函数宏在使用时类似于函数调用,但是避免了函数调用的开销。

    示例:

    #define MIN(a, b) ((a) < (b) ? (a) : (b))
    int min_value = MIN(x, y);
    

43.宏函数和自定义函数的优缺点?

宏函数是用编译时间换取内存空间,它的优点是省去了函数调用返回一系列操作,省去了形参的内存空间,提高了程序的运行效率,缺点就是傻瓜式替换,不安全,复杂的功能无能为力。

自定义函数是用内存空间换取运行时间,它的优点是在编译时能够及时检测出语法的问题,可以实现复杂的功能。缺点是函数运行返回释放需要一定的运行时间,对于形参还会去开辟内存空间,运行效率较慢。

44.宏(Macro)和 typedef 的区别

总的来说,宏主要用于定义常量和缩写代码块,其定义是简单的文本替换,不做参数 检查;而 typedef 主要用于给数据类型起别名,提高代码的可读性和可维护性,具有类型安全性。


45.inline关键字的作用?

inline 是 C 和 C++ 中的一个关键字,用于对编译器提出函数内联的建议。函数内联是一种编译器优化技术,它通过将函数调用处直接替换为函数体,以减少函数调用的开销,从而提高程序的执行效率。
需要注意的是,inline必须与函数定义体放在一起才能使用,使函数成为内联函数,仅将inline放在函数声明前不起任何作用。

46.container_of宏的作用及使用场景

container_of宏的作用是通过结构体内某个成员变量的地址和该变量名,以及结构体类型,找到该主结构体的地址。函数在定义时,会给形参开辟空间,如果传整个结构体会导致空间过大,如果使用container_of宏传递某个成员变量的地址,就可以节约形参的开辟空间。

container_of 宏的定义一般如下:

其中,ptr 是结构体成员的指针,type 是包含该成员的结构体类型,member 是结构体成员的名称。

47.C语言有哪些常见的内置宏及作用

C语言中常见的内置宏一般用于调试程序,常见的内置宏有:
LINE:打印所在的行号
func:打印函数名
TIME:打印编译时刻的时间
DATE:打印编译时时刻的日期
FILE:打印文件名

48.如何解决头文件重复包含导致重复定义问题?

头文件重复包含导致重复定义问题可以通过使用预处理器指令来解决,常用的解决方法有以下几种:

  1. 头文件保护(Header Guards): 在头文件的开头和结尾使用预处理器指令,可以防止头文件被重复包含。常见的头文件保护格式如下:
#ifndef HEADER_NAME_H
#define HEADER_NAME_H

// 头文件内容

#endif // HEADER_NAME_H

HEADER_NAME_H 是一个自定义的宏名称,可以根据头文件的名称自行命名。在第一次包含头文件时,HEADER_NAME_H 会被定义,防止头文件内容重复包含。在后续的包含中,由于 HEADER_NAME_H 已经被定义,预处理器会跳过头文件的内容,避免了重复定义问题。

  1. #pragma once 指令(仅限部分编译器): 一些编译器支持 #pragma once 指令,它也可以用来避免头文件的重复包含。使用 #pragma once 指令的头文件会在第一次被包含时,标记为“已包含”,之后的包含会被忽略。
#pragma once

// 头文件内容

需要注意的是,#pragma once 并非 C/C++ 标准规范,虽然大多数编译器都支持该指令,但并不是所有编译器都支持。为了确保代码的可移植性,通常推荐使用头文件保护的方法。

通过以上方法,可以有效避免头文件重复包含导致的重复定义问题,提高代码的可维护性和可移植性。在编写头文件时,建议养成良好的头文件保护习惯,以确保头文件在包含时能够正确地工作。

49.#include<> vs #include""如何指定第三方搜索路径?

  1. #include <filename>
    • 这种形式用于包含标准库头文件或系统提供的头文件。
    • 编译器在搜索头文件时会从系统的标准库路径中查找
    • 使用 #include <filename> 形式时,编译器不会在用户指定的搜索路径中查找头文件,只会在系统标准库路径中查找。
  2. #include "filename"
    • 这种形式用于包含用户自定义的头文件或第三方库的头文件。
    • 编译器首先在当前源文件所在目录下查找头文件,如果找不到,则会在用户指定的搜索路径中查找。
    • 用户可以通过编译器的命令行选项或环境变量来指定第三方库头文件的搜索路径。具体的方法和命令行选项会因不同的编译器而有所差异。

在 GCC 编译器中,可以使用 -I 选项来指定头文件搜索路径。例如,假设有一个名为 libfoo 的第三方库,其头文件位于 /path/to/libfoo/include 目录下,那么可以使用以下命令来编译包含该库的源文件:

gcc -o main.o -c main.c -I/path/to/libfoo/include

这样编译器在包含头文件时就会在指定的路径下查找。如果头文件被包含在多个源文件中,可以在编译命令中的每个源文件都加上 -I 选项,也可以将路径添加到 C_INCLUDE_PATHCPLUS_INCLUDE_PATH 环境变量中。

总结:#include <filename> 用于标准库或系统提供的头文件,搜索路径为系统标准库路径;#include "filename" 用于用户自定义的头文件或第三方库的头文件,搜索路径可以通过编译器的选项或环境变量来指定。

50.struct vs union的区别

结构体变量的大小是按照字节对齐的方式进行相加,是所有成员变量大小和字节长度的和。满足成员变量中最大数据类型的整数倍。
共用体变量是共用同一内存空间,大小由字节最长的成员变量和最大成员变量的数据类型决定,满足字节对齐方式,也满足成员变量中最大数据类型的整数倍。
因为共用体内所有成员公用一段内存,容易造成数据覆盖。用于函数传参时,传递不同类型的值,但只能存储一个值,多个值会产生覆盖。

51.struct的使用注意事项

使用结构体时要注意结构体是按照字节对齐的方式进行数据的存储,容易造成内存空洞,尽量将同类型的成员放在一起。
当成员变量中存在指针变量的时候,一定要注意浅拷贝问题,赋值符号会进行隐式浅拷贝,此时我们要自定义深拷贝函数解决浅拷贝的逻辑问题。

当结构体中包含指针变量时,需要特别小心浅拷贝问题。默认情况下,结构体的赋值符号进行的是浅拷贝,即只是简单地复制指针的值,而不会复制指针指向的数据。这样可能会导致多个结构体指针指向同一块内存,一旦其中一个指针修改了内存内容,会影响其他指针所指向的数据。为了解决浅拷贝问题,需要自定义深拷贝函数,确保在复制结构体时,也复制指针指向的数据,而不是仅仅复制指针本身。

示例:

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

struct Person {
    char *name;
    int age;
};

// 自定义深拷贝函数
void deepCopyPerson(struct Person *dest, const struct Person *src) {
    dest->name = strdup(src->name); // 使用 strdup 进行字符串的深拷贝
    dest->age = src->age;
}

int main() {
    struct Person p1;
    p1.name = "Alice";
    p1.age = 30;

    struct Person p2;
    deepCopyPerson(&p2, &p1);

    // 修改 p2 的姓名,不会影响 p1
    p2.name = "Bob";

    printf("p1: %s, %d\n", p1.name, p1.age);
    printf("p2: %s, %d\n", p2.name, p2.age);

    // 释放内存
    free(p2.name);

    return 0;
}

52.使用union如何判断机器的大小端

使用联合(union)可以判断机器的大小端(Endian)。大小端是指在多字节数据类型(如整数、浮点数)在内存中的存储方式。常见的大小端类型有大端序(Big-Endian)和小端序(Little-Endian)。

以下是使用联合来判断机器的大小端的方法:

#include <stdio.h>

// 定义一个联合类型
union EndianChecker {
    int value;
    char bytes[sizeof(int)];
};

int main() {
    union EndianChecker checker;
    checker.value = 1;

    // 如果小端序,bytes[0] 存放的是 1
    // 如果大端序,bytes[3] 存放的是 1
    if (checker.bytes[0] == 1) {
        printf("Little-Endian\n");
    } else {
        printf("Big-Endian\n");
    }

    return 0;
}

请注意,这种方法是一种常见的用于判断大小端的方法,但在实际应用中,最好使用系统提供的相关函数或宏来获取机器的大小端,以确保代码的可移植性和准确性。例如,在 C 语言中,可以使用 htonlntohl 函数来转换网络字节序和主机字节序,从而获取当前机器的大小端信息。在 C++ 中,可以使用 std::endian 命名空间下的相关宏来获取大小端信息。

53.枚举的作用及使用注意事项

枚举实际上就是一系列整数宏定义。与结构体和共用体不同,枚举不存在枚举变量。
枚举元素是常数,因此枚举元素又称为枚举常量。因为是常量,所以不能对枚举元素进行赋值。
在实际问题中,有些变量的取值被限定在一个有限的范围内,此时就可以使用枚举类型。

54.文件指针的作用

文件指针就是FILE类型指针,指向文件类型的结构,结构里包含该文件的各种属性,可以对它指向的文件进行各种操作。可以随机访问文件,可以把I/O操作抽象为文件操作。

55.fflush作用

使用 fflush 主要有两个作用:

  1. 刷新输出缓冲区:在使用标准输出函数(如 printf)向屏幕输出信息时,这些信息会先被写入到输出缓冲区,然后根据一定的条件(比如缓冲区满、换行符 \n 出现等)进行实际的输出。如果想要立即将缓冲区的内容输出到屏幕上,可以在适当的位置使用 fflush(stdout),这样可以实时查看输出结果,而不需要等到程序执行结束。
  2. 刷新输入缓冲区:在使用标准输入函数(如 scanf)从用户获取输入时,输入数据会先被写入输入缓冲区,然后根据一定的条件(比如用户按下 Enter 键)进行实际的读取。如果输入缓冲区中还有数据未读取,而程序又需要等待用户输入时,可以使用 fflush(stdin) 来清空输入缓冲区,防止遗留的数据干扰后续的输入操作。

注意事项:

  • fflush 函数的使用应该谨慎,一般来说,在标准输出和标准输入中使用 fflush 是合理的。但是在文件 I/O 操作中,fflush 可能会导致性能下降,因为频繁的刷新缓冲区会引起多次磁盘写入,降低文件操作效率。因此,在文件 I/O 操作中,通常不需要手动调用 fflush,因为当文件关闭时,缓冲区会被自动刷新。如果确实需要强制刷新文件缓冲区,可以使用 fflush 函数。

示例用法:

#include <stdio.h>

int main() {
    int num;
    printf("Enter a number: ");
    scanf("%d", &num);

    // 清空输入缓冲区,防止遗留的换行符影响后续输入
    fflush(stdin);

    printf("You entered: %d\n", num);
    printf("Flushing output buffer...\n");

    // 刷新输出缓冲区,立即显示输出结果
    fflush(stdout);

    return 0;
}

56.如何用c实现面向对象编程?

面向对象的三大特性:封装、继承、多态。

	面向对象的核心原则是使用对象来组织程序。对象是可以执行某些行为的东西。为了保证行为是正确的,对象需要维护控制行为的一组状态。要避免状态被外部代码破坏,对象必须保护这些状态,这就产生了面向对象的第一个特性:封装。

	在C语言中,我们需要使用结构体和函数相结合的方法来实现封装。这里要注意,为了避免客户代码直接修改结构体,需要将结构体定义保存在私有的.c文件中。头文件保留前向声明,在函数中使用结构体指针。

	面向对象的第二个特性是继承:子类可以继承父类的状态和行为。继承状态可以通过将父类结构体包含在子类中实现。为了让子类继承父类行为,我们将父类的操作保存到一个接口结构体中,通过复制这个接口结构体实现行为的继承。父类需要作为子类的第一个成员,这样通过指针引用时,子类实例和父类实例可以使用同一个指针表示。

	面向对象的另一个特性是多态。为了实现多态,我们需要根据实例的具体类型进行函数分派。我们的做法是将函数分派表保存到类实例中。每个类拥有自己的函数表。在初始化类的时候设置这个函数表。同一个类的实例共享这个函数表。每个实例中都包含一个指针指向类函数表。函数表也使用结构体表示。父类函数表是子类的函数表的第一个成员。在初始化子类(不是初始化子类实例)时,将父类的函数表复制到子类函数表起始的位置。然后初始化子类的特有函数。在分派函数时,根据实例内的指针找到函数表,然后根据函数表进行分派。

57.特殊的文件指针

在 C 语言中,除了常规的文件指针 FILE*,还有一些特殊的文件指针用于特定的文件操作或位置控制。这些特殊文件指针包括:

  1. stdin:标准输入文件指针,通常用于从键盘读取输入。它是一个预定义的文件指针,指向标准输入流(stdin)。
  2. stdout:标准输出文件指针,通常用于向屏幕输出信息。它是一个预定义的文件指针,指向标准输出流(stdout)。
  3. stderr:标准错误文件指针,通常用于向屏幕输出错误信息。它是一个预定义的文件指针,指向标准错误流(stderr)。

58.系统调用、库函数的区别

  1. 系统调用:
    系统调用是操作系统提供给用户程序的接口,用于访问底层操作系统功能。当一个程序需要执行特定的操作,例如创建新进程、读写文件、分配内存等,它必须通过系统调用请求操作系统来完成这些任务。系统调用是用户程序与操作系统之间的一种界面,通过这个接口,用户程序可以请求操作系统为其执行需要特权或底层硬件访问的任务。

系统调用的特点:

  • 需要较高的特权级别:系统调用涉及操作系统内核的功能,因此需要较高的权限级别来执行。
  • 开销较大:由于涉及从用户模式切换到内核模式,并且进行安全检查等操作,因此系统调用通常会比较耗时。
  • 提供更底层的功能:系统调用允许程序直接访问操作系统提供的底层功能,但同时也需要处理更多的细节和安全性。
  1. 库函数:
    库函数是由编程语言或操作系统提供的一组函数集合,用于在用户程序中重复使用常见的功能。这些函数通常是在用户空间中实现的,而不涉及操作系统内核的调用。库函数通过封装一系列操作,提供了更高级别的抽象,使得程序员可以更方便地使用这些功能,而不必关心底层的实现细节。

库函数的特点:

  • 位于用户空间:库函数在用户程序的地址空间中运行,因此不需要切换特权级别或进行安全检查。
  • 较小的开销:由于在用户空间执行,避免了从用户模式到内核模式的切换,因此库函数通常比系统调用执行起来更快。
  • 提供高级抽象:库函数将底层功能进行封装和抽象,使得程序员可以更简单地调用这些功能,而无需处理底层细节。

总结:
使用系统调用时,程序直接请求操作系统执行底层任务,需要切换特权级别和涉及较多的安全检查,开销较大,但能够访问更底层的功能。而使用库函数时,程序调用封装好的高级抽象接口,运行在用户空间,开销较小,但功能通常更有限,且不能直接访问底层硬件或特权级功能。

在实际编程中,通常建议优先使用库函数,除非需要特定的底层功能或操作系统接口,才考虑使用系统调用。库函数使得程序开发更简单,易于维护,并且通常具有良好的跨平台兼容性。

59.什么时候选择是使用系统调用API?什么时候使用库函数?

选择使用系统调用API或库函数取决于需求和应用场景。下面提供了一些指导原则来帮助做出正确的选择:

使用系统调用API的情况:

  1. 访问底层功能:如果需要直接访问操作系统提供的底层功能,例如创建新进程、分配内存、管理文件系统等,那么通常需要使用系统调用API。系统调用提供了更底层、更直接的接口来操作操作系统。
  2. 特权操作:某些操作,例如修改系统配置、管理硬件设备等,需要较高的权限级别。这时,只有通过系统调用来请求操作系统执行这些特权操作。
  3. 跨平台功能:有时候,不同操作系统可能提供不同的功能和特性。在需要跨平台兼容性的情况下,使用系统调用可以确保程序在不同操作系统上的行为一致。
  4. 高性能需求:在性能要求非常高的场景下,可能需要直接使用系统调用,以避免库函数带来的额外开销和抽象。

使用库函数的情况:

  1. 便捷性:库函数提供了更高级别的抽象和封装,使得编程更加简单和便捷。在不需要直接访问底层功能的情况下,库函数通常更易于使用。
  2. 提高可移植性:库函数通常是经过各种平台测试和优化的,使用库函数可以提高程序的可移植性,避免因为操作系统差异导致的问题。
  3. 安全性:库函数通常会处理一些底层细节,例如边界检查、错误处理等,从而提供更高的安全性,避免一些常见的编程错误。
  4. 提高开发效率:使用库函数可以大大减少开发时间和工作量,因为库函数已经为常见任务提供了现成的实现。

综合考虑以上因素,通常优先选择使用库函数,除非需要直接访问底层或特定的操作系统功能,或者对性能要求非常高,这时才考虑使用系统调用API。对于大部分应用程序,使用库函数可以提供更好的开发体验和更高的可移植性。

60.什么存储映射? mmap的函数接口?使用注意事项?

存储映射(Memory-mapped I/O)是一种将文件的内容直接映射到内存地址空间的技术。它允许将文件数据看作是内存中的一部分,从而实现对文件的读写操作,而无需使用传统的read和write函数。在许多操作系统中,这项技术由mmap函数提供支持。

mmap函数接口(在C语言中)通常如下:

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
  • addr: 指定映射的起始地址,通常设为0,让操作系统自动选择映射的地址。
  • length: 指定映射的长度,一般为文件的大小。
  • prot: 指定内存保护模式,可以是以下值的组合:
    • PROT_READ:可读
    • PROT_WRITE:可写
    • PROT_EXEC:可执行
    • PROT_NONE:无法访问
  • flags: 指定映射选项,可以是以下值的组合:
    • MAP_SHARED:允许多个进程共享映射区,对映射区的修改会影响到文件。
    • MAP_PRIVATE:创建一个私有的映射区,对映射区的修改不会影响到文件。
  • fd: 文件描述符,指定要映射的文件。
  • offset: 文件的偏移量,指定映射从文件的哪个位置开始。

使用注意事项:

  1. 确保对齐:在一些体系结构中,对内存的访问要求数据的地址与数据大小的对齐。因此,确保映射的地址和长度满足对齐要求是重要的。
  2. 内存管理:由于mmap映射的内存可能在进程退出或munmap后仍然留在内存中,所以应该注意管理内存,避免内存泄漏。
  3. 文件大小与映射长度:mmap映射的长度应该小于等于文件的大小。如果要扩展文件,应该先将文件扩展到所需大小,然后再重新映射。
  4. 文件描述符的生命周期:在映射期间,文件描述符应保持打开状态,否则映射可能出错。
  5. 多进程访问:当使用MAP_SHARED标志时,多个进程可以共享映射区,需要注意同步和竞态条件,以避免数据不一致的问题。
  6. 读写保护:应该根据需要设置合适的内存保护模式(prot),防止非法的内存访问。

使用存储映射可以提高文件读写性能和简化代码逻辑,但在使用时需要谨慎,并遵循上述注意事项,以确保正确且安全地使用该功能。

61.阻塞IO和非阻塞IO的区别?

阻塞I/O(Blocking I/O)和非阻塞I/O(Non-blocking I/O)是两种不同的I/O操作方式,它们的主要区别在于进程或线程在执行I/O操作时的行为和状态。

  1. 阻塞I/O(Blocking I/O):
    在阻塞I/O模式下,当一个进程或线程执行I/O操作时,如果请求的数据还没有准备好,进程或线程会被阻塞(挂起)等待,直到数据准备好或者I/O操作完成为止。在这个等待的过程中,进程或线程不能进行其他任务,它会一直停在I/O操作上。
  2. 非阻塞I/O(Non-blocking I/O):
    在非阻塞I/O模式下,当一个进程或线程执行I/O操作时,如果请求的数据还没有准备好,进程或线程不会被阻塞,而是立即返回一个错误码或指示数据未准备好的消息。这样,进程或线程可以继续执行其他任务,而不必等待I/O操作的完成。

主要区别:

  • 阻塞I/O会导致进程或线程被阻塞,无法进行其他任务,直到I/O操作完成;非阻塞I/O允许进程或线程继续执行其他任务,不需要等待I/O操作完成。
  • 阻塞I/O适合在有足够时间等待的情况下使用,例如在读取文件或网络数据时,因为数据的读取可能需要一定时间;非阻塞I/O适合在不能或不愿等待的情况下使用,例如实时系统或需要同时处理多个任务的场景。

注意:

  • 使用阻塞I/O时,可能会导致程序在I/O操作上出现较长的延迟,从而影响整体性能。
  • 非阻塞I/O通常需要在代码中轮询检查数据是否准备好,可能会增加CPU的开销,但可以提高系统的响应性。可以配合使用异步I/O技术来避免轮询的开销。
  • 在某些情况下,可以将I/O操作放在专门的线程或进程中进行,以避免阻塞主程序的执行。

62.IO多路复用的作用及实现方式?

IO多路复用(IO Multiplexing)是一种高效的I/O操作模型,用于在单个线程或进程中同时监视多个I/O事件,并在有事件发生时进行响应。它的作用在于提高程序的并发性和响应性,使得一个线程或进程能够同时处理多个I/O操作而无需阻塞。常见的应用包括网络服务器,特别是在高并发环境下,使用IO多路复用能有效地提高服务器的性能和响应能力,避免多线程或多进程中可能出现的资源竞争和线程切换开销。

IO多路复用的实现方式主要有以下几种:

  1. select():
    select是最早引入的IO多路复用函数,它能同时监视多个文件描述符(通常是套接字),并在其中有可读、可写或出错等事件发生时返回。但是select有一些限制,例如文件描述符数量有限,每次调用select时都需要传递所有要监视的文件描述符集合,导致效率低下。

  2. poll():
    poll与select类似,也能同时监视多个文件描述符,但它没有select的一些限制,可以更好地处理大量的文件描述符。poll的实现方式避免了select的一些性能问题。

  3. epoll():
    epoll是Linux特有的一种高效IO多路复用机制。它采用回调机制,当文件描述符就绪时,操作系统通知应用程序进行相应的处理。相比于select和poll,epoll在处理大量文件描述符时性能更好。epoll有两种工作方式:ET(边缘触发)和LT(水平触发)。ET模式只在文件描述符状态发生变化时通知应用程序,而LT模式会在文件描述符状态变化期间持续通知应用程序。

  4. select() 函数:

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • 参数:
    • nfds:要监视的最大文件描述符值加1。
    • readfds:可读文件描述符集合。
    • writefds:可写文件描述符集合。
    • exceptfds:异常文件描述符集合。
    • timeout:等待超时时间,如果为NULL,则select将阻塞直到有文件描述符就绪;如果为0,则立即返回,即非阻塞模式。
  • 返回值:
    • 成功:返回就绪文件描述符的数量(可能为0)。
    • 失败:返回-1,并设置errno来指示错误。
  • 功能:
    select函数用于同时监视多个文件描述符,当有文件描述符就绪时,它会返回,并告知应用程序哪些文件描述符已经准备好读、写或出错。
  1. poll() 函数:
#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • 参数:
    • fds:一个指向pollfd结构数组的指针,每个结构描述了一个要监视的文件描述符及其感兴趣的事件。
    • nfds:要监视的文件描述符数量。
    • timeout:等待超时时间,单位是毫秒。如果为-1,则poll将一直阻塞,直到有文件描述符就绪;如果为0,则立即返回,即非阻塞模式。
  • 返回值:
    • 成功:返回就绪文件描述符的数量(可能为0)。
    • 失败:返回-1,并设置errno来指示错误。
  • 功能:
    poll函数与select函数类似,用于同时监视多个文件描述符,当有文件描述符就绪时,它会返回,并告知应用程序哪些文件描述符已经准备好读、写或出错。
  1. epoll() 函数:
#include <sys/epoll.h>

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • 参数:
    • epfd:epoll实例的文件描述符,由epoll_create函数返回。
    • op:要进行的操作,可以是EPOLL_CTL_ADD(添加文件描述符)、EPOLL_CTL_MOD(修改事件)、EPOLL_CTL_DEL(删除文件描述符)。
    • fd:要添加、修改或删除的文件描述符。
    • event:指向epoll_event结构的指针,描述文件描述符的事件。
    • maxevents:事件数组events的大小,即最多可以返回的就绪事件数量。
    • timeout:等待超时时间,单位是毫秒。如果为-1,则epoll_wait将一直阻塞,直到有文件描述符就绪;如果为0,则立即返回,即非阻塞模式。
  • 返回值:
    • epoll_create:成功时返回一个新的epoll实例的文件描述符,失败时返回-1,并设置errno来指示错误。
    • epoll_ctl:成功时返回0,失败时返回-1,并设置errno来指示错误。
    • epoll_wait:返回就绪文件描述符的数量(可能为0),失败时返回-1,并设置errno来指示错误。
  • 功能:
    epoll是一种高效的IO多路复用机制,通过epoll_create函数创建一个epoll实例,然后通过epoll_ctl函数向实例添加、修改或删除文件描述符的事件,最后通过epoll_wait函数等待文件描述符就绪事件。

63.IO多路复用每种方式的优缺点?

IO多路复用的每种方式(select、poll、epoll、kqueue等)都有各自的优点和缺点。下面分别列出它们的特点:

  1. select:
    优点:
  • 可移植性较好,几乎在所有平台上都能使用。
  • 支持的文件描述符数量没有上限。
    缺点:
  • 对文件描述符的扫描是线性的,随着文件描述符数量的增加,性能会下降。
  • 每次调用select时,需要传递所有监视的文件描述符集合,导致效率较低。
  • 不支持多线程并发处理。
  1. poll:
    优点:
  • 支持的文件描述符数量没有上限。
  • 不需要传递所有监视的文件描述符集合,解决了select的效率问题。
  • 支持多线程并发处理。
    缺点:
  • 对文件描述符的扫描是线性的,随着文件描述符数量的增加,性能会下降。
  • 每次调用poll时,需要将所有监视的文件描述符传递给内核,可能会导致开销较大。
  1. epoll:
    优点:
  • 支持高并发,对大量文件描述符的扫描效率高,不会随着文件描述符数量增加而下降。
  • 使用回调通知机制,无需遍历文件描述符集合,只通知就绪的文件描述符,提高效率。
  • 支持水平触发和边缘触发两种工作模式。
  • 支持多线程并发处理。
    缺点:
  • 只能在Linux系统上使用,不具备跨平台性。
  • API相对复杂,使用相对select和poll需要更多的代码。

总体而言,epoll和kqueue在性能和功能上都优于select和poll。如果在Linux系统上开发,可以优先考虑使用epoll,而在BSD或macOS系统上开发,可以优先考虑使用kqueue。如果需要跨平台兼容,可以根据具体情况选择select或poll,但需要注意它们在大规模并发环境下可能带来的性能问题。

64.谈谈你对进程的理解?

1、进程是操作系统最小的资源分配单位,相对程序来讲,程是一个独立运行的程序在计算机中的一次执行过程。进程是动态的,程序是静态的;
2、每个进程都拥有自己的地址空间、代码、数据、文件描述符等资源,并且相互之间是独立的、隔离的。一个进程消亡不会影响其他进程;所以进程实现多任务是安全的;但是多进程的开销比较大;
3、多进程每个进程都有三种状态:进程可以处于运行、就绪、阻塞等状态。运行状态表示进程正在CPU上执行指令,就绪状态表示进程已准备好执行,但还没有得到CPU执行时间,阻塞状态表示进程在等待某些事件发生。
4、进程调度的策略:根据进程的三态,通过时间片轮转(实时性差)来实现进程的调度。抢占式:(可以在一个时间片内再次获取CPU):高优先级优先、短进程优先、先来先服务,非抢占式:不可以在一个时间片内再次获取CPU。
5、每个进程都有自己的ID号,叫做进程的pid;
6、操作系统在系统启动时会自动创建出三个进程:
0:负责引导系统启动,也会创建一个1号进程init进程
1:负责初始化硬件,回收资源
2:负责资源的分配,系统的调度

65.fork、 vfork

forkvfork是两个创建新进程的系统调用,在Unix-like系统中常见。它们的主要区别在于创建子进程的方式和父子进程之间的行为。

  1. fork()
  • 原型:pid_t fork(void);
  • 功能:fork函数用于创建一个新的子进程,子进程是父进程的副本,从fork调用之后的代码开始执行。子进程拥有与父进程几乎完全相同的地址空间和资源,包括代码、数据、堆栈等。fork函数会被调用一次,但返回两次,一次在父进程中返回子进程的PID(进程ID),一次在子进程中返回0。因此,通过返回值可以在父进程和子进程中区分执行的代码。
  • 父子进程的行为:fork创建子进程时,子进程继承了父进程的地址空间和资源。父子进程共享同一文件表项,但文件表项中的文件偏移量是各自独立的,因此它们在共享文件时需要注意文件指针的位置。
  1. vfork()
  • 原型:pid_t vfork(void);
  • 功能:vfork函数用于创建一个新的子进程,子进程是父进程的副本,从vfork调用之后的代码开始执行。与fork不同的是,vfork保证在子进程中先执行,并且子进程共享父进程的地址空间,不会为子进程创建新的页表,这使得vfork的性能通常比fork更高。
  • 父子进程的行为:由于vfork保证在子进程中先执行,而子进程与父进程共享地址空间,因此子进程在调用exec系列函数或者退出后应该立即调用_exit函数来避免在共享地址空间中造成意外的修改。vfork通常用于创建临时子进程,它在父进程调用exec函数前临时运行一些代码。

总结:

  • fork创建的子进程拥有与父进程几乎相同的地址空间和资源,适用于一般的进程复制需求。
  • vfork创建的子进程与父进程共享地址空间,适用于创建临时子进程并在子进程中执行一些代码的场景,通常与exec函数结合使用。

66.进程间通信的方式有哪些?优缺点?

进程间通信(Inter-Process Communication,IPC)是多个进程之间进行数据交换和通信的机制。在多进程编程中,进程间通信是实现进程间协作的重要方式。常见的进程间通信方式包括:

  1. 管道(Pipe):
    • 函数原型:int pipe(int pipefd[2]);
    • 参数:pipefd 是一个包含两个整数的数组,用于存放管道的两个文件描述符。pipefd[0] 用于读取数据,pipefd[1] 用于写入数据。
    • 功能:pipe函数用于创建一个无名管道,它是一个单向通信通道,用于在有亲缘关系的进程之间进行通信。管道是半双工的,数据只能在一个方向上流动。
    • 返回值:成功返回0,失败返回-1。
    • 优点:简单易用,不需要显式的创建和销毁通信通道;可以实现单向或双向通信。
    • 缺点:只适用于有亲缘关系的进程之间通信;管道是半双工的,数据只能在一个方向上流动。
  2. 命名管道(Named Pipe):
    • 函数原型:int mkfifo(const char *pathname, mode_t mode);
    • 参数:pathname 是命名管道的路径名,mode 是权限掩码,用于指定管道的权限。
    • 功能:mkfifo函数用于创建一个命名管道,它可以在没有亲缘关系的进程之间进行通信。命名管道是一种特殊的文件类型,可以通过文件名进行访问。
    • 返回值:成功返回0,失败返回-1。
    • 优点:可以在没有亲缘关系的进程之间进行通信;可以实现双向通信。
    • 缺点:只能在同一主机上的进程之间通信;不能实时传递数据。
  3. 信号量(Semaphore):
    • 函数原型:int sem_init(sem_t *sem, int pshared, unsigned int value);
    • 参数:sem 是指向信号量的指针,pshared 是一个标志,用于指定信号量是在进程间共享还是在线程间共享,value 是信号量的初始值。
    • 功能:sem_init函数用于初始化一个信号量,用于进程间同步和互斥。
    • 返回值:成功返回0,失败返回-1。
    • 优点:可以用于进程间同步和互斥;可以实现多进程之间的共享资源控制。
    • 缺点:编程复杂,易出现死锁问题;只能用于进程之间的同步,不能传递大量数据。
  4. 消息队列(Message Queue):
    • 函数原型:int msgget(key_t key, int msgflg);
    • 参数:key 是消息队列的键值,msgflg 是标志位,用于指定消息队列的访问权限和创建选项。
    • 功能:msgget函数用于创建一个消息队列或获取一个已经存在的消息队列。
    • 返回值:成功返回消息队列的标识符(非负整数),失败返回-1。
    • 优点:可以实现多进程之间的通信和同步;支持不同类型的消息;可以实现消息的顺序传递。
    • 缺点:不适合传递大量数据;对消息的处理需要编程细节。
  5. 共享内存(Shared Memory):
    • 函数原型:int shmget(key_t key, size_t size, int shmflg);
    • 参数:key 是共享内存的键值,size 是共享内存的大小,shmflg 是标志位,用于指定共享内存的访问权限和创建选项。
    • 功能:shmget函数用于创建一个共享内存区域或获取一个已经存在的共享内存区域。
    • 返回值:成功返回共享内存的标识符(非负整数),失败返回-1。
    • 优点:速度快,适合传递大量数据;可以实现多进程之间的高效通信。
    • 缺点:需要显式进行内存管理和同步操作;不适合用于保护敏感数据。
  6. 套接字(Socket):
    • 函数原型:int socket(int domain, int type, int protocol);
    • 参数:domain 是通信协议族,如AF_UNIX(Unix域套接字)或AF_INET(IPv4套接字);type 是套接字的类型,如SOCK_STREAM(流式套接字)或SOCK_DGRAM(数据报套接字);protocol 是协议,一般设为0,让系统根据type自动选择合适的协议。
    • 功能:socket函数用于创建一个套接字,用于在不同主机上的进程之间进行通信,支持网络通信。
    • 返回值:成功返回套接字的文件描述符,失败返回-1。
    • 优点:可以在不同主机上的进程之间进行通信;支持网络通信,适用于分布式系统。
    • 缺点:编程复杂,需要网络编程知识。

67.进程VS线程?优缺点?使用场景?

首先进程是资源分配的最小单位,线程是任务调度的最小单位;进程比线程更健壮,每个进程拥有独立的地址空间,一个进程的崩溃不会影响其他进程、而线程之间共享地址空间,一个线程的崩溃可能影响其他线程或者整个程序;由于线程的调度必须通过频繁加锁来保持同步,线程的性能比进程低;但线程之间切换比进程之间切换开销少,成本低;

进程:

  • 优点:
    • 隔离性好:每个进程拥有独立的地址空间,一个进程的崩溃不会影响其他进程。
    • 可靠性高:一个进程的崩溃不会导致整个系统崩溃,其他进程仍然可以继续执行。
    • 安全性高:不同进程之间的数据不会相互干扰,进程间通信需要显式进行。
  • 缺点:
    • 创建销毁开销大:进程的创建和销毁需要较多的系统资源和时间。
    • 进程间通信复杂:不同进程之间通信需要使用IPC机制,编程复杂度较高。
  • 使用场景:
    • 需要高度隔离性、可靠性和安全性的任务。
    • 需要充分利用多核处理器的多核心能力。
    • 需要在多机分布的场景下进行任务处理。

线程:

  • 优点:
    • 创建销毁开销小:线程的创建和销毁比进程快,因为它们共享进程的资源。
    • 线程间通信方便:线程共享进程的地址空间,线程间通信更方便。
    • 效率高:线程切换比进程切换开销小,因为线程共享进程的资源,切换时不需要切换地址空间。
  • 缺点:
    • 隔离性差:一个线程的崩溃可能导致整个进程崩溃,影响其他线程。
    • 安全性低:多个线程共享进程的资源,需要考虑同步和互斥问题,容易出现数据竞争等问题。
  • 使用场景:
    • 需要高并发性的任务,如Web服务器处理并发请求。
    • 需要频繁进行计算密集型操作,可以利用多线程并行计算。
    • 需要高效地共享数据和通信的任务,如图形界面程序中的用户界面响应。
    • 需要响应用户输入等事件,保持界面的交互性。

综合应用场景:

  • 强相关处理:对于强相关性的任务,建议使用线程来实现,因为线程可以共享地址空间,通信方便,且创建销毁开销小,适合高并发、频繁计算等任务。
  • 弱相关处理:对于弱相关性的任务,建议使用进程来实现,因为进程具有良好的隔离性,一个进程崩溃不会影响其他进程,适合需要高度隔离性和可靠性的任务。
  • 分布式场景:对于需要在多机分布的任务处理,可以使用多进程来实现,以充分利用多台机器的处理能力。
  • 多核场景:对于需要充分利用多核处理器的多核心能力的任务,可以使用多线程来实现,并行处理计算密集型任务。

在实际应用中,可能会根据实际需求综合考虑进程和线程的特点,选择合适的方式来实现多任务并发。

68.线程的同步方式有哪些?及作用?

线程的同步是指在多个线程并发执行的情况下,通过一些机制来协调线程之间的执行顺序和共享资源的访问,以避免竞争条件和数据不一致等问题。常见的线程同步方式包括:

  1. 互斥锁(Mutex):
    • 作用:互斥锁用于保护临界区(一段需要互斥访问的代码),确保同一时间只有一个线程可以进入临界区执行,其他线程需要等待。
    • 特点:只有持有锁的线程可以执行临界区代码,其他线程在获取锁之前会被阻塞。
    • 避免问题:避免了多个线程同时访问共享资源导致的数据竞争问题。
  2. 读写锁(Read-Write Lock):
    • 作用:读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。适用于读多写少的场景。
    • 特点:多个线程可以同时持有读锁,但写锁是互斥的。
    • 避免问题:避免了多个读线程之间的竞争,以及读线程和写线程之间的竞争。
  3. 条件变量(Condition Variable):
    • 作用:条件变量用于在多个线程之间进行等待和通知,允许线程在某个条件满足时继续执行。
    • 特点:通常与互斥锁一起使用,等待时会释放锁,通知时会重新获取锁。
    • 避免问题:避免了忙等待,节省了CPU资源。
  4. 信号量(Semaphore):
    • 作用:信号量用于控制对资源的访问数量,可以实现互斥和同步。
    • 特点:有计数功能,可以控制允许同时访问资源的线程数量。
    • 避免问题:避免了资源的过度共享和竞争,控制了线程的并发度。

69.线程池的作用?如何实现线程池(线程池的定义)?

线程池的作用:
主要作用:避免创建过多的线程时引发的内存溢出问题,因为创建线程还是比较耗内存的。

  1. 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  2. 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行。
  3. 提高线程的可管理性:线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。

为什么使用线程池?

  • 创建/销毁线程伴随着系统开销,频繁创建/销毁线程影响效率,线程池减少了这种开销。
  • 控制线程的并发数,避免过多的线程抢占资源导致阻塞。

何时使用线程池?

  • 单个任务处理时间短,需要处理大量任务。
  • 需要充分利用系统资源,提高系统性能。

何时不适宜使用线程池?

  • 线程执行时间较长,任务耗时严重。
  • 需要详细的线程优先级控制。
  • 在执行过程中需要对线程进行操作,比如睡眠,挂起等,因为线程池本身就是为突然大量爆发的短任务而设计的,如果要使线程睡眠、挂起的话,与线程池设计思想相悖。

线程池的实现步骤:

  1. 创建线程池控制结构的结构体。
  2. 初始化线程池:分配空间、初始化信息、创建线程。
  3. 向线程池的任务队列中添加任务:加锁、判断线程池状态、解锁。
  4. 任务执行(回调函数):加锁、判断线程池状态、解锁、执行回调函数。

70.TCP服务器和客户端的创建过程?(API)

TCP服务器创建过程:

  1. 创建套接字(Socket):
    • 函数:socket()
    • 原型:int socket(int domain, int type, int protocol);
    • 参数:
      • domain:地址族,通常为AF_INET(IPv4)。
      • type:套接字类型,通常为SOCK_STREAM(TCP流式套接字)。
      • protocol:协议,通常为0(由给定的domain和type自动选择合适的协议)。
    • 功能:创建一个新的套接字。
    • 返回值:成功时返回套接字描述符,失败时返回-1。
  2. 绑定地址和端口:
    • 函数:bind()
    • 原型:int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
    • 参数:
      • sockfd:套接字描述符。
      • addr:指向要绑定的地址结构的指针,通常是一个struct sockaddr_in类型的指针。
      • addrlen:地址结构的长度。
    • 功能:将套接字绑定到指定的IP地址和端口号。
    • 返回值:成功时返回0,失败时返回-1。
  3. 监听连接请求:
    • 函数:listen()
    • 原型:int listen(int sockfd, int backlog);
    • 参数:
      • sockfd:套接字描述符。
      • backlog:等待队列的最大长度,即允许等待连接的最大客户端数量。
    • 功能:开始监听连接请求。
    • 返回值:成功时返回0,失败时返回-1。
  4. 接受连接:
    • 函数:accept()
    • 原型:int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
    • 参数:
      • sockfd:套接字描述符。
      • addr:用于存储客户端地址的结构体指针。
      • addrlen:指向存储addr结构体长度的变量的指针。
    • 功能:等待并接受客户端连接请求。
    • 返回值:成功时返回新的套接字描述符,用于与客户端通信,失败时返回-1。
  5. 与客户端通信:
    • 使用新的套接字描述符与客户端进行通信,可以使用send()recv() 等函数发送和接收数据。
  6. 关闭套接字:
    • 函数:close()
    • 原型:int close(int sockfd);
    • 参数:套接字描述符。
    • 功能:关闭套接字。
    • 返回值:成功时返回0,失败时返回-1。

TCP客户端创建过程:

  1. 创建套接字(Socket):同上。
  2. 连接服务器:
    • 函数:connect()
    • 原型:int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
    • 参数:
      • sockfd:套接字描述符。
      • addr:指向服务器地址结构的指针,通常是一个struct sockaddr_in类型的指针。
      • addrlen:地址结构的长度。
    • 功能:连接到服务器的指定IP地址和端口号。
    • 返回值:成功时返回0,失败时返回-1。
  3. 与服务器通信:
    • 使用套接字描述符与服务器进行通信,可以使用send()recv() 等函数发送和接收数据。
  4. 关闭套接字:同上。

71.listen函数的作用?

listen() 函数用于将一个套接字(socket)标记为被动(监听)套接字,以便它可以接受传入的连接请求。在TCP服务器程序中,listen() 函数的作用是开始监听指定的套接字,使其可以接受客户端的连接请求。

函数原型:

int listen(int sockfd, int backlog);

参数:

  • sockfd:要监听的套接字描述符。
  • backlog:等待队列的最大长度,即允许等待连接的最大客户端数量。

功能:

  • listen() 函数用于将指定的套接字转换为被动套接字,使其可以开始监听连接请求。
  • 当套接字被标记为被动套接字后,可以使用 accept() 函数来接受传入的连接请求。

返回值:

  • 如果函数调用成功,返回值为0,表示监听操作已经开始。
  • 如果函数调用失败,返回值为-1,可能是因为套接字无效或者其他错误。

需要注意的是,一旦调用 listen() 函数后,套接字将变为被动状态,只能用于接受连接请求,而不能直接用于数据的发送和接收。接下来,通常会使用 accept() 函数来接受客户端的连接请求,获取新的套接字用于与客户端通信。

72.为什么要将“套接字文件描述符”转为被动描述符后,才能监听连接?

将套接字(Socket)文件描述符转为被动描述符后才能监听连接,涉及到网络通信的基本原理和TCP协议的工作机制。

TCP协议是一种面向连接的协议,它在通信双方之间建立起一条可靠的、全双工的数据传输通道。在建立TCP连接时,通常有一个主动方(客户端)和一个被动方(服务器端)。这种主动方和被动方的区分在套接字的角色上有所体现:

  1. 主动方(客户端):客户端通过创建一个套接字,然后主动发起连接请求,即向服务器端发出连接请求。
  2. 被动方(服务器端):服务器端通过创建一个套接字,然后将该套接字标记为被动(监听)状态,以等待客户端的连接请求。

将套接字文件描述符转为被动描述符后,主要是为了准备服务器端接受连接请求的环境。这涉及到以下原因:

  1. 连接请求的排队等待:当服务器正在与一个客户端进行通信时,可能会有其他客户端也想连接到服务器。这时,服务器需要有一个队列来保存这些连接请求,使得可以逐个接受这些连接。将套接字标记为被动描述符后,服务器就可以开始监听连接请求并将其排队。
  2. 连接三次握手:在TCP连接的建立中,存在三次握手的过程,通过这个过程确保了客户端和服务器的互通性和可靠性。服务器需要准备好接受这个握手过程,才能建立可靠的连接。将套接字标记为被动描述符后,服务器可以接受传入的连接请求,进行连接的三次握手。

总之,将套接字文件描述符转为被动描述符后,服务器端可以进入监听状态,等待客户端的连接请求,并在连接请求到来时准备好进行握手和建立连接,从而实现可靠的网络通信。

73.为什么会出现无法绑定的错误?

bind函数普遍遇到的问题是试图绑定一个已经在使用的端口,此时禁止绑定端口。它由 TCP 套接字状态 TIME_WAIT 引起。该状态在套接字关闭后约保留 2 到 4 分钟。在 TIME_WAIT 状态退出之后,套接字被删除,该地址才能被重新绑定而不出问题。

根本原因:TCP的断开连接是一个四次挥手的过程,假设客户端先断开连接,此时服务器端向客户端发送断开请求时,客户端处于TIME_WAIT,这个时间是2MSL,确保服务器端能够收到确认消息,正常退出,也就是说,在这个时间过程中,服务器是没有断开连接的,那么端口号就一直被占用,直到2MSL时间过后。服务器断开连接,释放资源。

解决:

在server代码的socket()和bind()调用之间插入如下代码:

//设置套接字端口属性为端口释放后可以重复使用
int opt = 1;
setsockopt(listenfd, SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt)); //设置端口复用:地址可以被重复绑定

setsockopt使用场景:

如果在已经处于 ESTABLISHED状态下的socket(一般由端口号和标志符区分)调用close(socket)(一般不会立即关闭而经历TIME_WAIT的过程)后想继续重用该socket;
如果要已经处于连接状态的soket在调用close(socket)后强制关闭,不经历TIME_WAIT的过程。一个已经在使用的端口

74.accept函数的作用?

accept()函数接受连接请求等待队列中待处理的客户端连接请求。函数调用成功时,accept ()内部将产生用于数据I/O的套接字,并返回器文件描述符。需要强调的是,套接字是自动创建的,并自动与发起连接请求的客户端建立连接。
从处于 ESTABLISHED状态下的连接队列头部取出一个已经完成的连接,如果这个队列没有已经完成的连接,accept()函数就会阻塞,直到取出队列中已完成的用户连接为止。

75.recv/send vs read/write?

在网络编程中,以及在处理文件和数据流的情况下,有两组常用的函数用于数据的读写和传输:recvsend,以及 readwrite。这些函数在不同的上下文中用于不同的目的,但本质上都是用于在套接字(sockets)或文件中进行数据传输。

  1. recvsend
    • 这对函数主要用于网络编程中的套接字通信,如在 TCP 和 UDP 协议中。
    • recv 用于从套接字接收数据。它等待并接受数据,然后将数据存储到指定的缓冲区中。
    • send 用于将数据从缓冲区发送到套接字。它负责将数据发送给连接的远程主机。
    • 这些函数通常提供更多的控制选项,如处理数据的边界、标志等。
  2. readwrite
    • 这对函数通常用于文件 I/O 操作,例如处理文件、管道、流等。
    • read 用于从文件或数据流中读取数据。它会将数据读取到指定的缓冲区中。
    • write 用于将数据从缓冲区写入文件或数据流。它将缓冲区中的数据写入到目标文件或流中。
    • 这些函数通常用于标准文件描述符(例如标准输入和标准输出)以及文件操作。

76.udp的创建过程?(API)

UDP(User Datagram Protocol)是一种无连接的传输协议,服务器端和客户端之间的通信不需要建立持久连接。以下是UDP服务器端和客户端创建过程的一般步骤:

服务器端:

  1. 包含头文件: <sys/socket.h><netinet/in.h>
  2. 创建套接字: 使用 socket() 函数来创建套接字。指定套接字的地址族(AF_INET 或 AF_INET6)和套接字类型(SOCK_DGRAM 用于 UDP 套接字)。
    int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (socket_fd == -1) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }
    
  3. 配置地址和端口: 定义和配置套接字地址信息,包括 IP 地址和端口号。
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // Use any available network interface
    server_addr.sin_port = htons(PORT);  // Define the port number
    
  4. 绑定套接字: 使用 bind() 函数将套接字与指定的地址和端口绑定。
    if (bind(socket_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("Binding failed");
        exit(EXIT_FAILURE);
    }
    
  5. 接收和发送数据: 使用 recvfrom() 函数接收数据,使用 sendto() 函数发送数据。这两个函数用于 UDP 数据的接收和发送。
    char buffer[MAX_BUFFER_SIZE];
    struct sockaddr_in client_addr;
    socklen_t addr_len = sizeof(client_addr);
    
    ssize_t bytes_received = recvfrom(socket_fd, buffer, sizeof(buffer), 0, (struct sockaddr *)&client_addr, &addr_len);
    if (bytes_received == -1) {
        perror("Receive failed");
        exit(EXIT_FAILURE);
    }
    
    // Process and handle received data...
    
    ssize_t bytes_sent = sendto(socket_fd, buffer, bytes_received, 0, (struct sockaddr *)&client_addr, addr_len);
    if (bytes_sent == -1) {
        perror("Send failed");
        exit(EXIT_FAILURE);
    }
    
  6. 关闭套接字: 在不再需要套接字时,使用 close() 函数关闭套接字。
    close(socket_fd);
    

UDP客户端:

  1. 包含头文件<sys/socket.h><netinet/in.h>
  2. 创建UDP套接字:使用 socket() 函数来创建UDP套接字,指定地址族和套接字类型。
int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (socket_fd == -1) {
    perror("Socket creation failed");
    exit(EXIT_FAILURE);
}
  1. 配置服务器地址和端口:定义服务器的地址信息,包括服务器的IP地址和端口号。
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(SERVER_IP);  // 服务器的IP地址
server_addr.sin_port = htons(PORT);  // 服务器的端口号
  1. 发送数据到服务器:使用 sendto() 函数发送UDP数据包到服务器。
ssize_t bytes_sent = sendto(socket_fd, data_buffer, data_length, 0, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (bytes_sent == -1) {
    perror("Send failed");
    exit(EXIT_FAILURE);
}
  1. 接收服务器响应(如果需要):使用 recvfrom() 函数接收来自服务器的UDP响应数据包。
char response_buffer[MAX_BUFFER_SIZE];
ssize_t bytes_received = recvfrom(socket_fd, response_buffer, sizeof(response_buffer), 0, NULL, NULL);
if (bytes_received == -1) {
    perror("Receive failed");
    exit(EXIT_FAILURE);
}

// 处理服务器的响应数据...
  1. 关闭套接字:在完成通信后,使用 close() 函数关闭UDP套接字以释放资源。
close(socket_fd);

77.TCP的创建过程?(API)

TCP服务端:

  1. 包含头文件: 首先,您需要包含适当的头文件,通常是 <sys/socket.h><netinet/in.h>
  2. 创建TCP套接字:使用 socket() 函数来创建TCP套接字。指定地址族(通常为 AF_INET 或 AF_INET6)和套接字类型(SOCK_STREAM 用于TCP套接字)。
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
if (server_socket == -1) {
    perror("Socket creation failed");
    exit(EXIT_FAILURE);
}
  1. 配置服务器地址和端口:定义服务器的地址信息,包括IP地址和端口号。
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 使用任意可用网络接口
server_addr.sin_port = htons(PORT);  // 指定端口号
  1. 绑定套接字到服务器地址和端口:使用 bind() 函数将套接字与指定的地址和端口绑定。
if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
    perror("Binding failed");
    exit(EXIT_FAILURE);
}
  1. 监听连接请求:使用 listen() 函数开始监听客户端的连接请求。
if (listen(server_socket, BACKLOG) == -1) {
    perror("Listen failed");
    exit(EXIT_FAILURE);
}
  1. 接受客户端连接:使用 accept() 函数接受客户端的连接请求,创建一个新的套接字用于与客户端通信。
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_socket = accept(server_socket, (struct sockaddr *)&client_addr, &addr_len);
if (client_socket == -1) {
    perror("Accept failed");
    exit(EXIT_FAILURE);
}
  1. 与客户端通信:使用 client_socket 套接字与客户端进行通信,发送和接收数据。
char buffer[MAX_BUFFER_SIZE];
ssize_t bytes_received = recv(client_socket, buffer, sizeof(buffer), 0);
if (bytes_received == -1) {
    perror("Receive failed");
    exit(EXIT_FAILURE);
}

// 处理接收到的数据...

ssize_t bytes_sent = send(client_socket, response_buffer, response_length, 0);
if (bytes_sent == -1) {
    perror("Send failed");
    exit(EXIT_FAILURE);
}
  1. 关闭套接字:在完成通信后,关闭服务器套接字和客户端套接字以释放资源。
close(client_socket);
close(server_socket);

TCP客户端:

  1. 导入必要的库:同样,你需要导入网络编程库。
  2. 创建TCP套接字:使用 socket() 函数来创建TCP套接字,指定地址族和套接字类型。
int client_socket = socket(AF_INET, SOCK_STREAM, 0);
if (client_socket == -1) {
    perror("Socket creation failed");
    exit(EXIT_FAILURE);
}
  1. 配置服务器地址和端口:定义服务器的地址信息,包括服务器的IP地址和端口号。
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(SERVER_IP);  // 服务器的IP地址
server_addr.sin_port = htons(PORT);  // 服务器的端口号
  1. 连接到服务器:使用 connect() 函数连接到服务器。
if (connect(client_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
    perror("Connection failed");
    exit(EXIT_FAILURE);
}
  1. 与服务器通信:使用 client_socket 套接字与服务器进行通信,发送和接收数据。
char buffer[MAX_BUFFER_SIZE];

// 发送数据到服务器
ssize_t bytes_sent = send(client_socket, data_buffer, data_length, 0);
if (bytes_sent == -1) {
    perror("Send failed");
    exit(EXIT_FAILURE);
}

// 接收来自服务器的响应数据
ssize_t bytes_received = recv(client_socket, response_buffer, sizeof(response_buffer), 0);
if (bytes_received == -1) {
    perror("Receive failed");
    exit(EXIT_FAILURE);
}

// 处理服务器的响应数据...
  1. 关闭套接字:在完成通信后,关闭客户端套接字以释放资源。
close(client_socket);

这些示例提供了TCP服务器端和客户端的基本创建和通信过程的框架。具体实现可能因编程语言、操作系统和网络库而有所不同,但总体步骤基本相同。在实际应用中,还需要考虑错误处理、超时处理等更复杂的情况。

78.如何实现并发服务器模型?优缺点?

服务器模型分为两种:循环服务器,并发服务器

  • 循环服务器:循环服务器在同一个时刻只能相应一个客户端请求

  • 并发服务器:并发服务器在同一个时刻可以相应多个客户端的请求

TCP服务器默认是循环服务器,因为TCP服务器有两个读阻塞函数accept和recv,这两个函数默认无法独立执行,所以无法实现并发

UDP默认是并发服务器,因为udp只有一个读阻塞函数recvfrom

如何实现TCP并发服务器?

  • 方法1:使用多线程实现并发服务器

缺点:

线程的个数受限(硬件资源(内存),操作系统资源(pid号)) --解决方法:线程池

当客户端不与服务器交互时,此时资源消耗;(所有创建的读线程不断切换)--解决方法:IO多路复用

优点:

高性能(最优)
  • 方法2:使用多进程实现并发服务器

缺点:

1、开销大(创建开销、通信开销、切换开销),

2、进程的个数受限(硬件资源(内存),操作系统资源(pid号) )–解决方法:进程池

3、当客户端不与服务器交互时,此时资源消耗;(所有创建的读进程不断切换)–解决方法:IO多路复用

优点:

高并发(最优)

79.close vs shutdown区别?

close()shutdown() 都是用于关闭套接字(socket)连接的函数,但它们在行为和用法上有一些区别。

1. close():

  • close() 用于关闭套接字连接。它会关闭套接字的读写功能,并释放与该套接字相关的资源。
  • 调用 close() 后,不能再对该套接字进行任何读写操作。
  • 如果套接字有其他正在使用的引用(例如,其他线程或进程仍在使用套接字),close() 并不会立即销毁套接字,而是将套接字的引用计数减一。只有当没有引用时,套接字才会被真正关闭和销毁。

2. shutdown():

  • shutdown() 用于关闭套接字的一部分功能,即可以关闭读功能、写功能或同时关闭读写功能。
  • 它的参数可以是 SHUT_RD(关闭读功能)、SHUT_WR(关闭写功能)或 SHUT_RDWR(同时关闭读写功能)。
  • 调用 shutdown() 后,套接字的某些功能将被关闭,但套接字本身并不会被销毁。可以继续使用未关闭的功能,但已关闭的功能将无法使用。

总结:

  • 如果您希望完全关闭套接字并释放相关资源,应该使用 close() 函数。
  • 如果您希望关闭套接字的部分功能,例如只关闭写功能或读功能,可以使用 shutdown() 函数,并根据需要指定相应的参数。
  • 在一些情况下,例如多线程或多进程环境中,可能需要注意关闭套接字的正确顺序和时机,以避免资源泄漏或竞争条件。

80.嵌入式数据库的特点?有哪些数据库可以作为嵌入式数据库?

数据存储的方式:文件VS 数据库
文件缺点:1、无格式(操作复杂、查找性能低)2、安全性
数据库(DataBase,简记为DB)就是一个有结构的、集成的、可共亨的统管理的数据集合。它不仅包括数据本身,而且包括相关数据之间的联系。数据库技术主要研究如何存储、使用和管理数据;
数据库的特点:
可视化,数据保存有格式,便于查找,操作方便
不需要配置,不需要安装和管理
提供了简单和易于使用的API
可跨平台使用
有哪些数据库课作为嵌入式数据库?
目前有许多数据库产品,如Oracle、SQL Server、DB2、MySQL 、Access,SQLite3等产品各以自己特有的功能,在数据库市场上占有一席之地。

81.SQL语句的使用(常用的SQL语句)

这些函数是 SQLite C/C++ 接口中常用的函数,用于在应用程序中操作 SQLite 数据库。以下是对这些函数的简要介绍:

  1. *sqlite3_open(const char filename, sqlite3 ppDb):
    • 用于打开或创建一个 SQLite 数据库文件。
    • filename 参数是数据库文件的名称。
    • ppDb 参数是一个指向 SQLite 数据库指针的指针,用于接收数据库连接句柄。
    • 返回值表示操作是否成功。
  2. sqlite3_close(sqlite3 pDb):
    • 用于关闭先前打开的 SQLite 数据库连接。
    • pDb 参数是要关闭的数据库连接句柄。
    • 返回值表示操作是否成功。
  3. sqlite3_exec(sqlite3 db, const char sql, int (callback)(void, int, char, char), void *data, char errmsg):
    • 用于执行一个 SQL 查询语句(例如,SELECT、INSERT、UPDATE 等)。
    • db 参数是数据库连接句柄。
    • sql 参数是要执行的 SQL 查询语句。
    • callback 参数是一个回调函数,用于处理查询结果(可以为 NULL,不需要处理结果时可省略)。
    • data 参数是传递给回调函数的用户数据。
    • errmsg 参数是一个指向错误消息的指针,用于接收执行中的错误信息。
    • 返回值表示操作是否成功。
  4. **sqlite3_get_table(sqlite3 *db, const char *sql, char ***result, int nrow, int ncolumn, char **errmsg):
    • 用于执行一个 SQL 查询语句,并将结果存储在一个表格中。
    • db 参数是数据库连接句柄。
    • sql 参数是要执行的 SQL 查询语句。
    • result 参数是一个指向结果表格的指针,将存储查询结果。
    • nrow 参数用于接收表格的行数。
    • ncolumn 参数用于接收表格的列数。
    • errmsg 参数是一个指向错误消息的指针,用于接收执行中的错误信息。
    • 返回值表示操作是否成功。
  5. sqlite3_free_table(char result):
    • 用于释放 sqlite3_get_table() 函数分配的结果表格内存。
    • result 参数是要释放的表格指针。

这些函数是 SQLite C/C++ 接口中的一部分,用于方便地在应用程序中进行数据库操作。它们提供了打开数据库、执行查询、获取查询结果等功能。使用这些函数可以进行数据库的增、删、改、查等操作,使得应用程序能够与 SQLite 数据库进行交互。

82.自旋锁和互斥锁的区别

互斥锁,顾名思义,当一个线程加互斥锁成功后,其他准备加锁的线程会加锁失败,实现同一时间内只有一个线程在占用CPU,避免多线程占用共享资源而出现错误,其他线程加锁失败后会有用户态转到内核态,内核会将线程转为睡眠状态,加锁成功的线程将锁释放掉之后内核会将其他处于睡眠状态的线程唤醒;
自旋锁,区别于互斥锁,只在用户态进行加锁和解锁的操作,不会进行进程间上下文切换,减少了进程间的开销,没有加锁的进程会处于忙等待状态,不会进入睡眠状态。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值