嵌入式c面试题

第一章 进程线程

1.1 进程线程的基本概念

1.1.1 什么是进程,线程,彼此有什么区别⭐⭐⭐⭐⭐

进程和线程都是操作系统中用于执行程序的概念,他们之间有一些重要的区别

  1. 进程(Process)

进程是程序的一个实例在运行时的表现,它拥有独立的内存空间和系统资源,包括CPU时间,文件句柄,网络连接等;

每个进程都由操作系统进行管理,它们之间相互独立,无法直接访问其他进程的数据;

进程之间通常通过进程间通讯(IPC)的方式进行数据交换和协作;

  1. 线程(Thread)

线程是进程内的一个执行单元,同一个进程中的多个线程共享相同的内存空间和系统资源;

线程可以看作时轻量级的进程,它们之间的切换开销较小,可以更高效的实行并发执行;

一个进程可以包含一个或多个线程,这些线程可以同时执行不同的任务;

  1. 区别:

资源使用:进程拥有独立的内存空间和系统资源,而线程共享相同的内存空间和系统资源

通信与同步:进程间通信相对复杂,需要使用IPC机制,而线程间通信可以直接通过共享内存等方式进行

切换开销:线程切换的开销通常比进程切换的开销小,因为线程共享相同的地址空间,上下文切换的代价低

1.1.2多进程、多线程的优缺点⭐⭐⭐⭐

多进程和多线程是并发编程中的两种主要方式,各自有其优缺点。下面是它们的详细对比分析:

多线程(Multi-threading)

优点
  1. 共享内存

    • 多线程运行在同一个进程空间内,共享相同的内存地址空间,这使得线程之间的数据共享和通信变得非常高效,不需要使用复杂的进程间通信(IPC)机制。
  2. 创建和切换开销较低

    • 相比于进程,线程的创建和销毁开销较小,线程之间的上下文切换也比进程要快。
  3. 资源利用率高

    • 多线程程序能够更好地利用多核处理器的性能,提高CPU的利用率,适合CPU密集型任务。
缺点
  1. 数据同步复杂

    • 由于线程共享内存空间,需要仔细管理对共享资源的访问,防止竞态条件和死锁等问题的发生,编程复杂度较高。
  2. 稳定性较差

    • 由于一个线程的崩溃可能导致整个进程的崩溃,影响程序的稳定性。
  3. 调试困难

    • 多线程程序的调试和测试比单线程程序要复杂得多,线程间的交互和问题的重现性较差。

多进程(Multi-processing)

优点
  1. 独立性强

    • 每个进程有独立的内存空间,进程之间相互独立,一个进程的崩溃不会影响其他进程的运行,提高了程序的稳定性和安全性。
  2. 更容易实现并行

    • 多进程可以在多核CPU上真正实现并行执行,适合I/O密集型任务和大规模并行计算任务。
  3. 简化的同步

    • 由于进程之间不共享内存,需要通过进程间通信(如管道、消息队列、共享内存等)来交换数据,避免了多线程中的数据同步问题。
缺点
  1. 资源开销大

    • 进程的创建和销毁开销较大,进程之间的上下文切换也比线程要慢,占用更多的系统资源。
  2. 数据共享复杂

    • 进程间通信相对复杂,数据传递效率低下,需要使用特定的IPC机制来实现进程间的数据共享和同步。
  3. 资源浪费

    • 每个进程都有独立的内存空间,这可能导致内存资源的浪费,尤其是当多个进程需要共享大量数据时。

使用场景

  1. 多线程适用场景

    • GUI程序:需要高响应性和快速更新界面的应用程序,如桌面应用和移动应用。
    • CPU密集型任务:如科学计算、图像处理等需要大量计算的任务。
    • 需要频繁进行轻量级任务切换的场景。
  2. 多进程适用场景

    • 高可靠性要求:如Web服务器、数据库服务器等需要高稳定性的场景。
    • I/O密集型任务:如文件读写、网络通信等需要频繁I/O操作的任务。
    • 需要进行大规模并行计算的场景,如分布式计算、MapReduce等。

结论

选择多线程还是多进程,取决于具体的应用场景和需求。在需要高效共享内存、快速任务切换以及充分利用多核CPU性能的场景下,多线程可能是更好的选择。而在需要高稳定性、高隔离性以及大规模并行计算的场景下,多进程则可能更加合适。

1.1.3什么时候用进程,什么时候用线程⭐⭐⭐

选择使用进程还是线程主要取决于应用的需求和特性。以下是一些具体情境和考虑因素,可以帮助决定在何时使用进程或线程:

使用进程的场景

  1. 高可靠性和隔离性

    • 场景:Web服务器、数据库服务器、后台服务等需要高稳定性和可靠性的应用。
    • 原因:进程之间相互独立,一个进程的崩溃不会影响其他进程,提高了程序的稳定性和隔离性。
  2. I/O密集型任务

    • 场景:文件读写操作、网络通信、数据处理任务。
    • 原因:多进程能够在多核CPU上真正实现并行执行,适合处理大量I/O操作的场景,避免了GIL(全局解释器锁)对Python等语言的限制。
  3. 安全性要求高

    • 场景:需要处理敏感数据的应用,或运行不受信任代码的环境。
    • 原因:进程有独立的内存空间,数据隔离性强,能更好地保护敏感数据,避免内存中的数据被其他进程访问。
  4. 大规模并行计算

    • 场景:分布式计算、MapReduce、大数据处理。
    • 原因:多进程能够充分利用多核处理器进行并行计算,提高计算效率。
  5. 多语言互操作

    • 场景:需要在同一应用中使用多种编程语言。
    • 原因:可以在不同进程中运行不同语言的代码,通过进程间通信(如消息队列、共享内存)进行数据交换。

使用线程的场景

  1. 轻量级并发任务

    • 场景:GUI应用、移动应用、实时游戏等需要高响应性和频繁任务切换的场景。
    • 原因:线程创建和销毁开销较小,上下文切换快,能提高应用的响应性和用户体验。
  2. CPU密集型任务

    • 场景:科学计算、图像处理、视频编码等需要大量计算的任务。
    • 原因:多线程能够更好地利用多核处理器,提高CPU利用率。
  3. 需要频繁共享数据

    • 场景:Web爬虫、数据分析、流式处理等需要频繁数据交换的场景。
    • 原因:线程共享同一进程的内存空间,数据共享和通信高效,避免了复杂的进程间通信机制。
  4. 资源受限环境

    • 场景:嵌入式系统、移动设备等资源有限的环境。
    • 原因:线程的资源开销小,适合在内存和计算资源受限的环境中使用。

综合考虑因素

  1. 语言和运行时的限制

    • Python:由于GIL(全局解释器锁)的存在,多线程在Python中的并行计算能力受限,适合I/O密集型任务。对于CPU密集型任务,可以考虑使用多进程。
    • Java:Java线程管理较为高效,适合多线程编程。
    • C/C++:如果需要进行高性能计算和对系统资源的精细控制,多进程和多线程都可以根据具体需求选择。
  2. 操作系统支持

    • 不同操作系统对多进程和多线程的支持和优化程度不同。例如,Linux对进程的创建和管理较为高效,而Windows在某些情况下对线程的支持更优。
  3. 开发和维护成本

    • 多线程编程复杂度高,尤其是数据同步和死锁问题,需要更高的开发和维护成本。多进程编程相对简单,但进程间通信和资源管理复杂度较高。

总之,选择进程还是线程,取决于应用的具体需求、性能要求、开发环境以及运行时限制。通过权衡各方面的因素,选择最适合的并发模型,以实现最佳的性能和可靠性。

1.1.4多进程、多线程同步(通讯)的方法⭐⭐⭐⭐⭐

在多进程和多线程编程中,同步和通信是非常重要的环节。不同的同步和通信方法有各自的优缺点,适用于不同的场景。下面详细介绍这些方法:

多线程同步方法

  1. 互斥锁(Mutex)
    • 描述:互斥锁用于确保同一时间只有一个线程能访问共享资源。
    • 使用场景:需要对共享资源进行互斥访问的场景,如变量修改、文件写操作等。
    • 优点:实现简单,控制精细。
    • 缺点:容易导致死锁,需要小心使用。
#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx; // 创建一个互斥锁

void thread_safe_function() {
    mtx.lock(); // 锁定互斥锁
    // 访问共享资源
    // ...
    mtx.unlock(); // 解锁互斥锁
}

int main() {
    std::thread t1(thread_safe_function);
    std::thread t2(thread_safe_function);

    t1.join();
    t2.join();

    return 0;
}
  1. 读写锁(Read-Write Lock)
    • 描述:读写锁允许多个线程同时读取,但在写入时需要独占锁。
    • 使用场景:读多写少的场景,如缓存读写。
    • 优点:提高读操作的并发性能。
    • 缺点:实现较复杂。
#include <iostream>
#include <mutex>
#include <thread>
#include <condition_variable>

std::recursive_mutex mtx; // 创建一个递归互斥锁
std::condition_variable read_ready; // 创建一个条件变量
int readers = 0; // 初始化读者数量

void acquire_read_lock() {
    std::unique_lock<std::recursive_mutex> lock(mtx);
    read_ready.wait(lock, []{ return readers >= 0; });
    ++readers;
}

void release_read_lock() {
    std::unique_lock<std::recursive_mutex> lock(mtx);
    --readers;
    if (readers == 0) {
        read_ready.notify_all();
    }
}

void acquire_write_lock() {
    std::unique_lock<std::recursive_mutex> lock(mtx);
    read_ready.wait(lock, []{ return readers == 0; });
    readers = -1;
}

void release_write_lock() {
    std::unique_lock<std::recursive_mutex> lock(mtx);
    readers = 0;
    read_ready.notify_all();
}

int main() {
    // 线程使用示例
    std::thread readerThread(acquire_read_lock);
    std::thread writerThread(acquire_write_lock);

    readerThread.join();
    writerThread.join();

    return 0;
}
  1. 信号量(Semaphore)
    • 描述:信号量用于控制对共享资源的访问,计数器表示当前可以访问资源的线程数。
    • 使用场景:限流、资源池等场景。
    • 优点:适用于控制资源访问的并发数。
    • 缺点:可能导致过度同步,影响性能。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>

sem_t semaphore; // 声明一个信号量

void* limited_access_function(void* arg) {
    sem_wait(&semaphore); // 等待信号量
    // 访问共享资源
    // ...
    sem_post(&semaphore); // 释放信号量
    return NULL;
}

int main() {
    // 初始化信号量
    if (sem_init(&semaphore, 0, 3) != 0) {
        perror("sem_init");
        exit(EXIT_FAILURE);
    }

    pthread_t threads[4];

    // 创建线程
    for (int i = 0; i < 4; ++i) {
        if (pthread_create(&threads[i], NULL, limited_access_function, NULL) != 0) {
            perror("pthread_create");
            exit(EXIT_FAILURE);
        }
    }

    // 等待所有线程完成
    for (int i = 0; i < 4; ++i) {
        if (pthread_join(threads[i], NULL) != 0) {
            perror("pthread_join");
            exit(EXIT_FAILURE);
        }
    }

    // 销毁信号量
    sem_destroy(&semaphore);

    return 0;
}
  1. 条件变量(Condition Variable)
    • 描述:条件变量用于线程间的通知机制,一个线程等待某个条件,另一个线程通知条件满足。
    • 使用场景:生产者-消费者模型。
    • 优点:适用于复杂的线程间同步。
    • 缺点:实现复杂,需小心使用。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

pthread_cond_t condition = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* consumer(void* arg) {
    pthread_mutex_lock(&mutex); // 锁定互斥锁
    pthread_cond_wait(&condition, &mutex); // 等待条件变量
    // 消费数据
    pthread_mutex_unlock(&mutex); // 解锁互斥锁
    return NULL;
}

void* producer(void* arg) {
    pthread_mutex_lock(&mutex); // 锁定互斥锁
    // 生产数据
    pthread_cond_signal(&condition); // 通知条件变量
    pthread_mutex_unlock(&mutex); // 解锁互斥锁
    return NULL;
}

int main() {
    pthread_t consumerThread, producerThread;

    // 创建消费者线程
    if (pthread_create(&consumerThread, NULL, consumer, NULL) != 0) {
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }

    // 创建生产者线程
    if (pthread_create(&producerThread, NULL, producer, NULL) != 0) {
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }

    // 等待线程完成
    if (pthread_join(consumerThread, NULL) != 0) {
        perror("pthread_join");
        exit(EXIT_FAILURE);
    }
    if (pthread_join(producerThread, NULL) != 0) {
        perror("pthread_join");
        exit(EXIT_FAILURE);
    }

    // 销毁条件变量和互斥锁
    pthread_cond_destroy(&condition);
    pthread_mutex_destroy(&mutex);

    return 0;
}

多进程同步和通信方法

  1. 队列(Queue)
    • 描述:队列是多进程安全的FIFO数据结构,用于进程间的消息传递。
    • 使用场景:任务分发、结果收集等。
    • 优点:简单易用,进程安全。
    • 缺点:性能受限于队列的实现。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <mqueue.h>
#include <sys/stat.h>
#include <pthread.h>

#define QUEUE_NAME "/myqueue"
#define MAX_MESSAGES 10
#define MESSAGE_SIZE 1024

void* worker(void* arg) {
    mqd_t queue = *(mqd_t*)arg;
    char message[] = "data";
    if (mq_send(queue, message, sizeof(message), 0) == -1) {
        perror("mq_send");
        exit(EXIT_FAILURE);
    }
    return NULL;
}

int main() {
    mqd_t queue;
    struct mq_attr attr;
    attr.mq_flags = 0;
    attr.mq_maxmsg = MAX_MESSAGES;
    attr.mq_msgsize = MESSAGE_SIZE;
    attr.mq_curmsgs = 0;

    // 创建消息队列
    queue = mq_open(QUEUE_NAME, O_CREAT | O_RDWR, 0644, &attr);
    if (queue == -1) {
        perror("mq_open");
        exit(EXIT_FAILURE);
    }

    pthread_t workerThread;

    // 创建工作线程
    if (pthread_create(&workerThread, NULL, worker, &queue) != 0) {
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }

    // 等待线程完成
    if (pthread_join(workerThread, NULL) != 0) {
        perror("pthread_join");
        exit(EXIT_FAILURE);
    }

    char buffer[MESSAGE_SIZE];
    // 接收消息
    if (mq_receive(queue, buffer, MESSAGE_SIZE, NULL) == -1) {
        perror("mq_receive");
        exit(EXIT_FAILURE);
    }

    printf("Received data: %s\n", buffer);

    // 关闭消息队列
    if (mq_close(queue) == -1) {
        perror("mq_close");
        exit(EXIT_FAILURE);
    }

    // 删除消息队列
    if (mq_unlink(QUEUE_NAME) == -1) {
        perror("mq_unlink");
        exit(EXIT_FAILURE);
    }

    return 0;
}
  1. 管道(Pipe)
    • 描述:管道提供两个连接的文件描述符,用于双向通信。
    • 使用场景:双工通信。
    • 优点:适合双向数据传输。
    • 缺点:复杂度高于队列。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <pthread.h>

#define SOCKET_PATH "/tmp/uds_socket"

void* worker(void* arg) {
    int sockfd = *(int*)arg;
    char message[] = "data";
    if (write(sockfd, message, sizeof(message)) == -1) {
        perror("write");
        exit(EXIT_FAILURE);
    }
    close(sockfd);
    return NULL;
}

int main() {
    int sockfd, clientfd;
    struct sockaddr_un addr;
    pthread_t workerThread;

    // 创建Unix域套接字
    sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sockfd == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 绑定套接字地址
    memset(&addr, 0, sizeof(addr));
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);
    if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        perror("bind");
        exit(EXIT_FAILURE);
    }

    // 监听套接字
    if (listen(sockfd, 1) == -1) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    // 接受连接
    clientfd = accept(sockfd, NULL, NULL);
    if (clientfd == -1) {
        perror("accept");
        exit(EXIT_FAILURE);
    }

    // 创建工作线程
    if (pthread_create(&workerThread, NULL, worker, &clientfd) != 0) {
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }

    char buffer[1024];
    // 接收数据
    if (read(clientfd, buffer, sizeof(buffer)) == -1) {
        perror("read");
        exit(EXIT_FAILURE);
    }

    printf("Received data: %s\n", buffer);

    // 等待线程完成
    if (pthread_join(workerThread, NULL) != 0) {
        perror("pthread_join");
        exit(EXIT_FAILURE);
    }

    // 关闭套接字
    close(sockfd);
    // 删除套接字文件
    unlink(SOCKET_PATH);

    return 0;
}
  1. 共享内存(Shared Memory)
    • 描述:共享内存用于多个进程共享数据。
    • 使用场景:高性能共享数据。
    • 优点:数据传输高效。
    • 缺点:需要手动管理同步,容易出错。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <sys/wait.h>

#define SHM_SIZE 4096

union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
};

void worker(int semid, double *num, int *arr) {
    struct sembuf sb = {0, -1, SEM_UNDO}; // 减少信号量的值

    // 等待信号量
    if (semop(semid, &sb, 1) == -1) {
        perror("semop");
        exit(EXIT_FAILURE);
    }

    // 写入共享数据
    *num = 3.1415;
    for (int i = 0; i < 10; ++i) {
        arr[i] = -arr[i];
    }

    sb.sem_op = 1; // 增加信号量的值
    if (semop(semid, &sb, 1) == -1) {
        perror("semop");
        exit(EXIT_FAILURE);
    }
}

int main() {
    int shm_id, semid;
    double *num;
    int *arr;
    pid_t pid;

    // 创建共享内存
    shm_id = shmget(IPC_PRIVATE, SHM_SIZE, 0644 | IPC_CREAT);
    if (shm_id == -1) {
        perror("shmget");
        exit(EXIT_FAILURE);
    }

    // 映射共享内存
    num = (double *)shmat(shm_id, NULL, 0);
    arr = (int *)(num + 1);

    // 初始化共享数据
    *num = 0.0;
    for (int i = 0; i < 10; ++i) {
        arr[i] = i;
    }

    // 创建信号量
    semid = semget(IPC_PRIVATE, 1, 0644 | IPC_CREAT);
    if (semid == -1) {
        perror("semget");
        exit(EXIT_FAILURE);
    }

    // 初始化信号量
    union semun arg;
    arg.val = 1;
    if (semctl(semid, 0, SETVAL, arg) == -1) {
        perror("semctl");
        exit(EXIT_FAILURE);
    }

    // 创建子进程
    pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        // 子进程
        worker(semid, num, arr);
        exit(EXIT_SUCCESS);
    } else {
        // 父进程
        wait(NULL);
        printf("Number: %f\n", *num);
        for (int i = 0; i < 10; ++i) {
            printf("Array[%d]: %d\n", i, arr[i]);
        }

        // 解除共享内存映射
        if (shmdt(num) == -1) {
            perror("shmdt");
            exit(EXIT_FAILURE);
        }

        // 删除共享内存
        if (shmctl(shm_id, IPC_RMID, NULL) == -1) {
            perror("shmctl");
            exit(EXIT_FAILURE);
        }

        // 删除信号量
        if (semctl(semid, 0, IPC_RMID, arg) == -1) {
            perror("semctl");
            exit(EXIT_FAILURE);
        }
    }

    return 0;
}
  1. 管理器(Manager)
    • 描述:管理器提供了共享对象的服务,可以在进程间共享Python对象。
    • 使用场景:需要共享复杂对象的场景。
    • 优点:易于使用,支持多种数据类型。
    • 缺点:性能低于直接共享内存。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <sys/wait.h>

#define SHM_SIZE 4096

// 哈希表项结构
typedef struct HashEntry {
    void *key;
    void *value;
} HashEntry;

// 哈希表结构
typedef struct HashTable {
    HashEntry *table;
    int size;
} HashTable;

// 链表节点结构
typedef struct ListNode {
    int value;
    struct ListNode *next;
} ListNode;

// 链表结构
typedef struct List {
    ListNode *head;
} List;

union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
};

void worker(HashTable *d, List *l, int semid) {
    struct sembuf sb = {0, -1, SEM_UNDO}; // 减少信号量的值

    // 等待信号量
    if (semop(semid, &sb, 1) == -1) {
        perror("semop");
        exit(EXIT_FAILURE);
    }

    // 操作共享数据
    // 由于C语言的限制,我们无法直接使用浮点数作为键,这里使用整数键代替
    d->table[1].key = (void *)1;
    d->table[1].value = (void *)"1";
    d->table[2].key = (void *)2;
    d->table[2].value = (void *)"2";
    d->table[0].key = (void *)0;
    d->table[0].value = NULL;

    // 反转链表
    ListNode *prev = NULL;
    ListNode *current = l->head;
    ListNode *next = NULL;
    while (current != NULL) {
        next = current->next;
        current->next = prev;
        prev = current;
        current = next;
    }
    l->head = prev;

    sb.sem_op = 1; // 增加信号量的值
    if (semop(semid, &sb, 1) == -1) {
        perror("semop");
        exit(EXIT_FAILURE);
    }
}

int main() {
    int shm_id, semid;
    HashTable *d;
    List *l;
    pid_t pid;

    // 创建共享内存
    shm_id = shmget(IPC_PRIVATE, SHM_SIZE, 0644 | IPC_CREAT);
    if (shm_id == -1) {
        perror("shmget");
        exit(EXIT_FAILURE);
    }

    // 映射共享内存
    void *shm_ptr = shmat(shm_id, NULL, 0);
    d = (HashTable *)shm_ptr;
    l = (List *)(d + 1);

    // 初始化共享数据
    d->table = (HashEntry *)(l + 1);
    d->size = SHM_SIZE - sizeof(HashTable) - sizeof(List);
    for (int i = 0; i < d->size; ++i) {
        d->table[i].key = NULL;
        d->table[i].value = NULL;
    }
    l->head = (ListNode *)((char *)shm_ptr + sizeof(HashTable) + sizeof(List));
    ListNode *current = l->head;
    for (int i = 0; i < 10; ++i) {
        current->value = i;
        current->next = (i < 9) ? (ListNode *)((char *)current + sizeof(ListNode)) : NULL;
        current = current->next;
    }

    // 创建信号量
    semid = semget(IPC_PRIVATE, 1, 0644 | IPC_CREAT);
    if (semid == -1) {
        perror("semget");
        exit(EXIT_FAILURE);
    }

    // 初始化信号量
    union semun arg;
    arg.val = 1;
    if (semctl(semid, 0, SETVAL, arg) == -1) {
        perror("semctl");
        exit(EXIT_FAILURE);
    }

    // 创建子进程
    pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
                // 子进程
        worker(d, l, semid);
        exit(EXIT_SUCCESS);
    } else {
        // 父进程
        wait(NULL);
        // 打印共享数据
        // ...

        // 解除共享内存映射
        if (shmdt(shm_ptr) == -1) {
            perror("shmdt");
            exit(EXIT_FAILURE);
        }

        // 删除共享内存
        if (shmctl(shm_id, IPC_RMID, NULL) == -1) {
            perror("shmctl");
            exit(EXIT_FAILURE);
        }

        // 删除信号量
        if (semctl(semid, 0, IPC_RMID, arg) == -1) {
            perror("semctl");
            exit(EXIT_FAILURE);
        }
    }

    return 0;
}

结论

选择适当的同步和通信方法取决于具体的应用场景和需求。多线程同步方法适合共享内存的快速数据交换,但需要小心处理同步问题。多进程通信方法适合高隔离性和高可靠性的场景,通过队列、管道、共享内存和管理器等方式实现进程间的数据交换和同步。根据应用的具体需求,选择最适合的方法以实现高效可靠的并发编程。

1.1.5进程的空间模型⭐⭐⭐⭐

进程的空间模型描述了一个进程在内存中的布局和结构,了解这些有助于理解进程是如何管理其资源和执行任务的。典型的进程空间模型包括以下几个主要部分:

  1. 文本段(Text Segment)

    • 描述:也称为代码段,包含进程执行的机器指令,即程序的代码。
    • 特点:通常是只读的,以防止程序自我修改。多个进程可以共享相同的代码段,例如运行相同程序的多个实例。
    • 作用:存储可执行代码。
  2. 数据段(Data Segment)

    • 描述:包含已初始化的全局变量和静态变量。
    • 特点:数据段在程序启动时由操作系统初始化,并在整个程序生命周期内保持存在。
    • 作用:存储程序的已初始化数据。
  3. 未初始化数据段(BSS Segment)

    • 描述:包含未初始化的全局变量和静态变量,BSS是Block Started by Symbol的缩写。
    • 特点:在程序启动时由操作系统初始化为零。
    • 作用:存储未初始化数据,节省存储空间,因为未初始化的数据段不需要在可执行文件中占用空间。
  4. 堆(Heap)

    • 描述:用于动态内存分配,存储由malloccallocrealloc等函数分配的内存。
    • 特点:堆在程序运行时动态增长和收缩,内存由程序员手动管理。
    • 作用:存储动态分配的数据结构,如链表、树等。
  5. 栈(Stack)

    • 描述:用于存储函数调用的信息,包括函数的参数、局部变量和返回地址。
    • 特点:栈是一个后进先出(LIFO)数据结构,每次函数调用都会在栈上创建一个新的栈帧,函数返回时栈帧被销毁。
    • 作用:管理函数调用过程中的临时数据。

进程空间模型示意图

+-----------------+
|                 |
|    栈(Stack)   | 高地址
|                 |
+-----------------+
|                 |
|    堆(Heap)    |
|                 |
+-----------------+
|                 |
|  BSS段(BSS)   |
|                 |
+-----------------+
|                 |
|  数据段(Data) |
|                 |
+-----------------+
|                 |
|  代码段(Text) |
|                 |
+-----------------+
|  低地址         |
+-----------------+

各段详细说明

  1. 代码段(Text Segment)

    • 特点:代码段在进程间可以共享,这在共享库或多线程程序中尤为重要。代码段是只读的,防止意外或恶意的修改。
  2. 数据段(Data Segment)

    • 特点:数据段包含已初始化的静态变量和全局变量,这些变量在程序生命周期内保持其值不变。
  3. BSS段(BSS Segment)

    • 特点:BSS段包含未初始化的静态变量和全局变量,在程序启动时自动初始化为零。这部分内存并不需要在可执行文件中实际分配,而是由操作系统在加载时分配。
  4. 堆(Heap)

    • 特点:堆区的内存由程序员显式分配和释放,如果没有正确管理(如内存泄漏),可能导致内存耗尽。
  5. 栈(Stack)

    • 特点:栈的大小一般是固定的,由操作系统在进程启动时分配。栈的自动管理使得函数调用和局部变量的使用非常高效,但栈溢出(stack overflow)会导致程序崩溃。

进程地址空间的管理

操作系统通过内存管理单元(MMU)来管理进程的地址空间。MMU负责将虚拟地址映射到物理地址,使得每个进程拥有独立的地址空间,从而提高了系统的稳定性和安全性。以下是一些关键概念:

  1. 虚拟内存(Virtual Memory)

    • 每个进程拥有独立的虚拟地址空间,操作系统负责将虚拟地址映射到物理内存。
  2. 分页(Paging)

    • 内存被划分为固定大小的页(page),虚拟内存页被映射到物理内存的页框(frame)。分页有助于内存管理和减少碎片。
  3. 段(Segmentation)

    • 内存划分为不同的段,每个段代表不同类型的内存区域(如代码段、数据段、堆、栈)。分段管理提供了内存保护和隔离。

进程间通信(IPC)

进程间通信是多进程系统中的重要组成部分。常用的IPC机制包括:

  1. 管道(Pipes)

    • 用于单向或双向通信的通道,常用于父子进程之间。
  2. 消息队列(Message Queues)

    • 提供消息传递机制,允许进程以消息的形式交换数据。
  3. 共享内存(Shared Memory)

    • 允许多个进程共享同一块内存,提供最快的IPC方式,但需要同步机制以防止竞态条件。
  4. 信号(Signals)

    • 用于进程之间的异步通知和通信。
  5. 套接字(Sockets)

    • 支持通过网络进行进程间通信,适用于分布式系统。

通过理解进程的空间模型和IPC机制,可以更好地设计和优化多进程应用,确保资源的高效利用和系统的稳定性。

1.1.6进程线程的状态转换图 什么时候阻塞,什么时候就绪⭐⭐⭐

进程和线程的状态转换图描述了它们在操作系统中可能经历的各种状态以及状态之间的转换关系。下面是典型的进程和线程状态转换图,以及阻塞和就绪状态的说明:

进程状态转换图

+-----------------------------------+
|                                   |
|             就绪(Ready)          |
|                                   |
+----------------+------------------+
                 |
                 ↓
+----------------+------------------+
|                                   |
|          运行(Running)           |
|                                   |
+----------------+------------------+
                 |
                 ↓
+----------------+------------------+
|                                   |
|           阻塞(Blocked)          |
|                                   |
+-----------------------------------+

线程状态转换图

+-----------------------------------+
|                                   |
|             就绪(Ready)          |
|                                   |
+----------------+------------------+
                 |
                 ↓
+----------------+------------------+
|                                   |
|          运行(Running)           |
|                                   |
+----------------+------------------+
                 |
                 ↓
+----------------+------------------+
|                                   |
|           阻塞(Blocked)          |
|                                   |
+-----------------------------------+

状态说明

  1. 就绪(Ready)状态

    • 进程或线程已经准备好执行,但由于CPU资源限制或调度算法等原因,暂时无法执行。在就绪状态中,进程或线程等待着分配到CPU时间片来执行。
  2. 运行(Running)状态

    • 进程或线程正在执行,占用了CPU资源,并正在执行其任务。
  3. 阻塞(Blocked)状态

    • 进程或线程在执行过程中遇到了某种阻塞条件,无法继续执行,例如等待I/O操作完成、等待某个信号量或锁释放等。在阻塞状态中,进程或线程暂时停止执行,直到阻塞条件解除才能进入就绪状态。

阻塞和就绪状态说明

  • 阻塞状态(Blocked)
    • 当进程或线程在等待某个事件发生时(如等待I/O操作完成),会进入阻塞状态。在这种状态下,它不会占用CPU资源,直到等待的事件发生后才能重新进入就绪状态。
  • 就绪状态(Ready)
    • 进程或线程处于就绪状态时,表示它已经准备好执行,并且可以立即被调度执行。然而,由于CPU资源有限或者其他就绪进程或线程的优先级更高,它可能需要等待一段时间才能获得CPU时间片,进入运行状态。

总结

理解进程和线程的状态转换图以及阻塞和就绪状态的概念对于设计和优化多任务系统非常重要。通过合理地管理状态转换和资源调度,可以提高系统的吞吐量和响应性,确保任务的顺利执行。

1.1.7父进程、子进程的关系以及区别⭐⭐⭐⭐

父进程和子进程是在多进程系统中常见的概念,它们之间存在着特定的关系和区别。

父进程(Parent Process)

父进程是生成(或创建)其他进程的进程。通常,一个进程在创建新进程时会成为新进程的父进程。父进程负责创建、管理和监控子进程的执行,它通常会等待子进程的结束,并可能处理子进程的退出状态。父进程的结束通常不会影响子进程的执行,除非子进程是通过终止父进程而被终止的。

子进程(Child Process)

子进程是由父进程创建的新进程。子进程的执行通常是独立于父进程的,它们有自己的执行环境和资源。子进程可以执行与父进程完全不同的任务,或者是执行相同的任务但在不同的数据或参数下执行。子进程可以被认为是一个新的独立任务单元,在父进程的控制下执行。

父进程和子进程的关系

  1. 生成关系

    • 父进程通常会调用系统调用(如fork())来创建子进程。
  2. 资源共享

    • 父进程和子进程之间共享某些资源,如文件描述符。在默认情况下,子进程会继承父进程的大部分资源和状态,包括打开的文件、信号处理器等。
  3. 独立执行

    • 子进程可以独立于父进程执行,它们有自己的地址空间和执行环境。
  4. 通信和同步

    • 父进程和子进程之间可以通过进程间通信(IPC)来进行数据交换和同步。

区别

  1. 角色

    • 父进程是生成其他进程的起始点和管理者,而子进程是由父进程生成的新进程。
  2. 资源继承

    • 子进程会继承父进程的大部分资源和状态,如文件描述符、信号处理器等。
  3. 执行环境

    • 子进程可以在独立的执行环境中执行,其执行不受父进程的影响。
  4. 生命周期

    • 子进程的生命周期通常与父进程相关联,但子进程也可以在父进程终止后继续执行。

在编程中,父进程和子进程之间的关系通常用于实现一些特定的功能,如并行计算、任务分发等。合理地管理父子进程之间的通信和同步可以实现复杂的并发控制和任务管理。

1.1.8什么是进程上下文、中断上下文⭐⭐

进程上下文和中断上下文是操作系统中的两个重要概念,它们描述了系统在不同执行环境下的状态和上下文信息。下面分别介绍这两个概念:

进程上下文(Process Context)

进程上下文是指操作系统中一个正在执行的进程所需的所有信息,包括进程的状态、寄存器的值、内存映射、文件描述符、堆栈指针等。当操作系统需要切换执行不同的进程时,会保存当前进程的上下文信息,并加载下一个进程的上下文信息,以便继续执行。进程上下文的保存和恢复是操作系统进行进程调度的关键步骤之一。

进程上下文包括以下重要内容:

  1. 程序计数器(Program Counter):记录了当前正在执行的指令地址,用于指示下一条要执行的指令。

  2. 寄存器(Registers):保存了进程的执行状态,如通用寄存器、栈指针、指令指针等。

  3. 内存映射(Memory Mapping):描述了进程的内存布局,包括代码段、数据段、堆、栈等。

  4. 文件描述符表(File Descriptor Table):保存了进程打开的文件描述符和与之相关联的文件信息。

  5. 信号处理器状态(Signal Handler State):保存了进程注册的信号处理器(Signal Handler)的状态信息。

中断上下文(Interrupt Context)

中断上下文是指在处理中断时,CPU自动保存和恢复的所有寄存器和状态信息。当系统发生硬件中断(如时钟中断、IO中断等)或软件中断(如系统调用、异常等)时,CPU会暂停当前进程的执行,转而执行中断服务程序(Interrupt Service Routine,ISR)。此时,CPU会自动保存当前进程的上下文信息,并加载中断服务程序的上下文信息,以便处理中断。

中断上下文包括以下重要内容:

  1. 中断类型(Interrupt Type):描述了引发中断的原因,如时钟中断、IO中断、系统调用等。

  2. 中断向量(Interrupt Vector):用于确定中断服务程序的入口地址。

  3. 当前进程上下文(Current Process Context):保存了引发中断前当前进程的上下文信息,包括程序计数器、寄存器等。

  4. 中断处理器状态(Interrupt Handler State):保存了中断服务程序的执行状态,包括执行到哪一步、是否需要切换进程等。

区别与联系

  1. 概念:进程上下文描述了一个进程的执行状态和信息,而中断上下文描述了在处理中断时CPU的状态和信息。

  2. 触发方式:进程上下文切换是由操作系统进行调度和管理的,而中断上下文是由硬件或软件中断引发的。

  3. 执行环境:进程上下文切换涉及不同进程之间的切换,而中断上下文切换发生在同一进程内部,是由中断引起的CPU执行环境的切换。

  4. 保存和恢复:进程上下文切换由操作系统负责保存和恢复进程的上下文信息,而中断上下文切换是由CPU自动保存和恢复寄存器和状态信息。

尽管进程上下文和中断上下文描述了不同的场景和状态,但它们在操作系统中起着至关重要的作用,分别用于进程调度和中断处理,保证了系统的正常运行和高效执行。

1.1.9一个进程可以创建多少线程,和什么有关⭐⭐

一个进程可以创建的线程数量是有限制的,这个限制受多种因素的影响,主要包括以下几点:

  1. 操作系统限制

    • 操作系统会限制单个进程可以创建的最大线程数量。这个限制因操作系统而异,不同的操作系统可能有不同的线程数量限制。
  2. 系统资源限制

    • 线程的创建受到系统资源的限制,如内存、CPU等。当系统资源不足时,创建新线程的操作可能会失败。
  3. 进程资源限制

    • 有些操作系统或编程环境允许用户设置进程的资源限制,包括线程数量限制。当达到进程资源限制时,进程无法创建更多线程。
  4. 线程资源消耗

    • 每个线程都会消耗一定的系统资源,包括内存、线程栈等。创建过多的线程可能导致系统资源不足,影响系统性能。
  5. 应用程序设计

    • 应用程序的设计也会影响线程的创建数量。如果应用程序设计不合理,过多的线程可能会导致竞争条件、死锁等问题,降低程序的可维护性和性能。

总的来说,一个进程可以创建的线程数量是受多方面因素影响的,并没有固定的上限。在设计应用程序时,需要考虑系统资源限制和应用程序的实际需求,合理控制线程的创建数量,以确保系统的稳定性和性能。

1.2 并发,同步,异步,互斥,阻塞,非阻塞的理解

1.2.1什么是线程同步和互斥⭐⭐⭐⭐⭐

线程同步(Thread Synchronization)和互斥(Mutual Exclusion)是并发编程中常用的两种技术,用于保证多个线程之间的协调和数据访问的正确性。

  1. 线程同步

    • 线程同步是指多个线程按照一定的顺序执行,以确保共享资源的正确访问和操作。在多线程环境中,如果没有合适的同步机制,可能会导致竞态条件(Race Condition)和数据不一致等问题。
  2. 互斥

    • 互斥是一种线程同步的机制,用于保护共享资源,确保在同一时间只有一个线程可以访问共享资源。互斥通常通过锁(Mutex)来实现,当一个线程获取了锁时,其他线程需要等待锁释放才能访问共享资源。

线程同步和互斥的重要性

在多线程编程中,线程之间的并发执行可能导致以下问题:

  • 竞态条件(Race Condition):当多个线程同时访问共享资源,并且至少有一个线程对共享资源进行了写操作时,可能会导致不确定的结果。

  • 数据不一致:当多个线程同时对共享数据进行读写操作时,可能会导致数据不一致的问题,例如读取到了未完全更新的数据。

  • 死锁(Deadlock):当多个线程相互等待对方释放资源时,可能会导致死锁,使得所有线程都无法继续执行。

  • 饥饿(Starvation):某些线程由于无法获取所需的资源而无法执行,导致无限期地等待。

通过合适的线程同步和互斥机制,可以避免上述问题的发生,确保多线程程序的正确性和可靠性。常见的线程同步和互斥机制包括使用互斥锁、条件变量、信号量、读写锁等。这些机制可以根据具体的应用场景和需求进行选择和使用。

1.2.2线程同步与阻塞的关系?同步一定阻塞吗?阻塞一定同步吗?⭐⭐⭐⭐

线程同步和阻塞是并发编程中相关但不同的概念,它们之间存在一定的关系,但并不是绝对的对应关系。

线程同步与阻塞的关系

  1. 关系

    • 线程同步通常涉及多个线程之间的协调和顺序执行,以确保共享资源的正确访问。
    • 阻塞是指线程暂时挂起,等待某个条件满足或事件发生后才能继续执行。
  2. 关联

    • 在某些情况下,线程同步可能会导致线程阻塞,例如线程需要等待某个锁释放后才能访问共享资源。
    • 同样地,线程阻塞也可能与线程同步相关,例如通过条件变量等待某个条件的满足后再继续执行。

同步不一定阻塞

  1. 同步不一定阻塞
    • 线程同步并不一定导致线程阻塞,可以通过非阻塞的同步机制实现多个线程之间的协调和数据同步。
    • 例如,通过自旋锁等待资源释放时,线程会忙等待而不是进入阻塞状态。

阻塞不一定同步

  1. 阻塞不一定同步
    • 线程阻塞并不一定意味着线程需要进行同步操作,可能是在等待外部事件的发生或者资源的可用。
    • 例如,线程等待IO操作完成时,可能会阻塞但不涉及其他线程的协调或共享资源的同步。

总结

虽然线程同步和阻塞在某些情况下可能相关联,但它们并不是完全一致的概念。线程同步是为了确保多线程环境下共享资源的正确访问,而阻塞是指线程在等待某个条件满足时暂时挂起。在实际编程中,需要根据具体的场景和需求选择合适的同步和阻塞机制,以保证程序的正确性和性能。

1.2.3并发,同步,异步,互斥,阻塞,非阻塞的理解⭐⭐⭐⭐⭐

这些概念在并发编程中扮演着重要的角色,让我逐一解释:

  1. 并发(Concurrency)

    • 并发是指系统能够同时执行多个独立的任务。这些任务可以是并行执行的(在多核处理器上同时执行),也可以是交替执行的(在单个核心上通过时间片轮转)。并发是一种提高系统资源利用率和响应性的方式。
  2. 同步(Synchronous)

    • 同步是指任务按照一定的顺序执行,需要等待前一个任务完成后才能执行下一个任务。在同步编程模型中,任务之间的执行是相互依赖的,会阻塞当前任务的执行直到依赖的任务完成。
  3. 异步(Asynchronous)

    • 异步是指任务可以在不等待其他任务完成的情况下继续执行。在异步编程模型中,任务之间的执行是相互独立的,可以并行执行或按照完成顺序执行。
  4. 互斥(Mutual Exclusion)

    • 互斥是一种保护共享资源的机制,确保在同一时间只有一个任务可以访问共享资源。互斥通常通过锁来实现,当一个任务获取了锁时,其他任务需要等待锁释放才能访问共享资源。
  5. 阻塞(Blocking)

    • 阻塞是指任务被暂时挂起,直到某个条件满足或事件发生才能继续执行。在阻塞状态下,任务会等待外部事件的发生,例如等待I/O操作完成、等待锁释放等。
  6. 非阻塞(Non-blocking)

    • 非阻塞是指任务可以在不等待外部事件的情况下继续执行。在非阻塞状态下,任务会立即返回,不会等待外部事件的发生,而是通过轮询或回调等方式检查事件状态。

这些概念通常在并发编程中一起使用,用于描述不同的编程模型和任务执行方式。合理地使用同步、异步、互斥、阻塞和非阻塞等机制可以提高系统的性能和响应性,避免竞争条件和死锁等并发问题。

1.3 孤儿进程、僵尸进程、守护进程的概念

1.3.1基本概念⭐⭐⭐⭐⭐

这些是关于进程状态和特性的重要概念,让我逐个解释:

  1. 孤儿进程(Orphan Process)

    • 孤儿进程是指其父进程先于它结束或者父进程退出后没有及时对其进行处理的进程。在 Unix-like 操作系统中,当父进程退出时,系统会将孤儿进程的父进程设置为 init 进程(PID为1)。这样做的目的是让 init 进程来接管孤儿进程,防止它成为僵尸进程。
  2. 僵尸进程(Zombie Process)

    • 僵尸进程是指一个已经终止执行的子进程,但其父进程还没有对其进行善后处理(调用wait()或waitpid()等系统调用来获取子进程的终止状态)。在父进程没有处理僵尸进程的情况下,操作系统会维护僵尸进程的一些信息(如进程ID、终止状态等),但不再执行任何代码。大量的僵尸进程可能会占用系统资源,因此及时处理僵尸进程是很重要的。
  3. 守护进程(Daemon Process)

    • 守护进程是在后台运行的一种特殊类型的进程,通常不会与用户交互。它们经常被用于在系统启动时启动服务、执行周期性任务等。守护进程通常以root用户权限启动,并且会脱离控制终端,以避免意外终止。守护进程通常会调用fork()函数来创建子进程,然后父进程退出,子进程成为孤儿进程,最终被init进程接管。

1.3.2如何创建守护进程:⭐⭐

创建守护进程的步骤可以概括如下:

  1. 创建子进程:使用fork()系统调用创建一个子进程。

  2. 在子进程中执行一些操作:在子进程中执行一些初始化操作,例如关闭不需要的文件描述符、改变工作目录、设置文件掩码等。

  3. 调用setsid()函数:setsid()函数可以创建一个新的会话(session),并将调用进程设置为该会话的领头进程(session leader)。这样可以确保守护进程脱离控制终端。

  4. 再次fork()并退出父进程:为了防止守护进程意外获得控制终端,再次调用fork(),并且让父进程退出。这样子进程会成为一个孤儿进程,并由init进程接管。

  5. 在子进程中执行守护进程的主要任务:在子进程中执行守护进程的主要任务,例如监听网络请求、处理请求、定期清理等。

下面是一个简单的示例代码,演示了如何创建一个守护进程:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>

int main() {
    pid_t pid = fork();

    // 如果fork失败
    if (pid < 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    // 如果是父进程,退出
    if (pid > 0) {
        exit(EXIT_SUCCESS);
    }

    // 创建一个新的会话并设置为领头进程
    if (setsid() < 0) {
        perror("setsid");
        exit(EXIT_FAILURE);
    }

    // 切换工作目录
    if (chdir("/") < 0) {
        perror("chdir");
        exit(EXIT_FAILURE);
    }

    // 关闭不需要的文件描述符
    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);

    // 执行守护进程的主要任务
    while (1) {
        // 这里可以放置守护进程的主要逻辑
        // 例如监听网络请求、处理请求、定期清理等
        sleep(10); // 示例:每10秒执行一次任务
    }

    return 0;
}

这只是一个简单的示例,实际上创建守护进程可能需要更多的步骤和细节,具体取决于你的应用程序的需求和环境。

1.3.3正确处理僵尸进程的方法⭐⭐⭐⭐

正确处理僵尸进程的方法通常包括以下几个步骤:

  1. 捕获SIGCHLD信号

    • SIGCHLD信号是子进程退出时父进程会收到的信号,通过捕获SIGCHLD信号并注册信号处理函数,可以在子进程退出时及时对其进行处理。
  2. 调用wait()或waitpid()等系统调用

    • 在SIGCHLD信号的处理函数中,调用wait()或waitpid()等系统调用来获取子进程的终止状态。这样操作系统就可以回收子进程的资源,避免其成为僵尸进程。
  3. 处理僵尸进程

    • 在获取子进程的终止状态后,可以根据具体情况对子进程进行一些善后处理,例如记录日志、释放资源等。

下面是一个简单的示例代码,演示了如何正确处理僵尸进程:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

// SIGCHLD信号的处理函数
void sigchld_handler(int signum) {
    int status;
    pid_t pid;

    // 循环处理所有已经退出的子进程
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status)) {
            printf("子进程 %d 正常退出,退出状态:%d\n", pid, WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("子进程 %d 被信号 %d 终止\n", pid, WTERMSIG(status));
        }
    }
}

int main() {
    pid_t pid;

    // 注册SIGCHLD信号的处理函数
    signal(SIGCHLD, sigchld_handler);

    // 创建子进程
    pid = fork();

    if (pid == 0) {
        // 子进程执行一些任务
        sleep(2);
        exit(0);
    } else if (pid > 0) {
        // 父进程等待一段时间
        sleep(5);
        printf("父进程退出\n");
        exit(0);
    } else {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    return 0;
}

在这个示例中,父进程创建了一个子进程,并且注册了SIGCHLD信号的处理函数sigchld_handler()。在父进程中通过sleep(5)模拟了一段时间的运行,然后正常退出。在子进程中通过sleep(2)模拟了一段时间的执行,然后正常退出。当子进程退出时,父进程会收到SIGCHLD信号,调用sigchld_handler()函数来处理子进程的退出状态。

第二章 C/C++高频面试题

2.1 c和c++区别、概念相关面试题

2.1.1 new和malloc的区别⭐⭐⭐⭐⭐

newmalloc() 是在 C++ 中动态分配内存的两种方法,它们有以下几点不同之处:

  1. 语法

    • new 是 C++ 中的运算符,可以通过 new 关键字来分配动态内存,并返回指向分配内存的指针。例如:int *ptr = new int;
    • malloc() 是 C 语言中的函数,位于 <stdlib.h> 头文件中,用于分配指定大小的内存块,并返回一个 void* 类型的指针。例如:int *ptr = (int*)malloc(sizeof(int));
  2. 类型安全

    • new 是类型安全的,它在分配内存时会自动计算所需的空间,并调用相应类型的构造函数进行初始化。
    • malloc() 不是类型安全的,它只是简单地分配一块指定大小的内存,不会调用构造函数进行初始化。
  3. 分配失败的处理

    • new 在分配内存失败时会抛出 std::bad_alloc 异常,需要使用异常处理机制进行处理。
    • malloc() 在分配内存失败时会返回 NULL,需要手动检查返回值以进行错误处理。
  4. 处理数组

    • new 可以用于分配数组,并且可以指定数组大小。例如:int *arr = new int[10];
    • malloc() 不能直接用于分配数组,需要手动计算所需的内存大小,并将指针转换为适当的类型。例如:int *arr = (int*)malloc(10 * sizeof(int));
  5. 释放内存

    • new 分配的内存可以使用 delete 运算符来释放。例如:delete ptr;delete[] arr;
    • malloc() 分配的内存可以使用 free() 函数来释放。例如:free(ptr);

总的来说,new 提供了更方便、类型安全和易于使用的动态内存分配方式,而 malloc() 则是 C 语言中的标准函数,在 C++ 中也可以使用,但相对来说不如 new 方便和安全。在 C++ 中推荐优先使用 newdelete,而在混合编程或需要与 C 语言兼容的情况下使用 malloc()free()

2.1.2 malloc的底层实现⭐⭐⭐⭐

malloc() 的底层实现因不同的操作系统和C运行时库而异,但通常都遵循以下基本原理:

  1. 内存管理器malloc() 是由操作系统提供的内存管理函数之一。在大多数现代操作系统中,内存管理器会维护一张内存分配表,记录内存的使用情况,以便动态分配和释放内存。

  2. 内存池:内存管理器通常会维护一个或多个内存池(Memory Pool),内存池中的内存会被分配给应用程序。内存池可以是连续的内存块,也可以是由多个碎片组成的内存集合。

  3. 分配算法:当调用 malloc() 请求分配内存时,内存管理器会根据一定的算法在内存池中找到合适大小的空闲内存块,并将其标记为已分配状态。常见的分配算法包括首次适应算法、最佳适应算法等。

  4. 内存对齐:为了提高内存访问效率和减少内存碎片,内存管理器通常会对分配的内存进行对齐操作。对齐操作可以确保分配的内存块位于内存地址的特定位置,例如按照4字节或8字节对齐。

  5. 内存池扩展:当内存池中的空闲内存不足以满足分配请求时,内存管理器可能会向操作系统请求更多的内存空间。这个过程通常称为内存池扩展。

  6. 内存释放:当调用 free() 释放内存时,内存管理器会将被释放的内存块标记为未分配状态,以便后续的分配请求可以再次使用。内存管理器可能还会合并相邻的未分配内存块,以减少内存碎片。

总的来说,malloc() 的底层实现涉及操作系统的内存管理功能和C运行时库的内存管理机制,它们共同协作来实现动态内存分配和释放的功能。具体的实现细节取决于操作系统和C运行时库的具体实现。

2.1.3在1G内存的计算机中能否malloc(1.2G)?为什么?⭐⭐

在1GB内存的计算机中无法直接调用 malloc(1.2GB) 分配1.2GB内存,因为内存分配的大小受到物理内存大小的限制。

原因如下:

  1. 物理内存限制:1GB内存的计算机只有1GB的物理内存可供分配,因此无法满足 malloc(1.2GB) 的请求。即使操作系统的虚拟内存技术可以将部分数据存储到硬盘上的交换文件中以增加可用内存空间,但对于连续的1.2GB内存分配来说,物理内存仍然不足以满足需求。

  2. 连续内存分配malloc() 函数通常会分配一块连续的内存空间来存储数据。对于较大的内存分配请求,操作系统需要找到一块足够大且连续的内存空间来满足需求。在物理内存有限的情况下,可能无法找到足够大的连续空间来分配1.2GB的内存。

  3. 内存碎片:即使操作系统中有足够的总内存空间,但由于内存分配和释放的不规则性,可能会导致内存碎片化,使得找到一块足够大且连续的内存空间变得更加困难。

因此,要在1GB内存的计算机上调用 malloc(1.2GB) 是不可行的,除非增加物理内存或者通过其他方式增加可用内存空间。

2.1.4指针与引用的相同和区别;如何相互转换?⭐⭐⭐⭐⭐

指针(Pointer)和引用(Reference)是 C++ 中用于访问和操作内存的两种主要方式。它们有以下相同点和区别:

相同点:

  1. 都用于访问内存:指针和引用都可以用来访问内存中的变量或对象。

  2. 都可以用于函数参数传递:指针和引用都可以作为函数的参数传递,使得函数能够修改调用者提供的变量或对象。

  3. 都可以用于实现数据结构:指针和引用都可以用于实现数据结构,例如链表、树等。

区别:

  1. 语法

    • 指针使用 * 符号来声明和访问,例如 int *ptr;
    • 引用使用 & 符号来声明和访问,例如 int &ref = var;
  2. 空值

    • 指针可以为空(指向空地址或者空指针),引用必须在声明时初始化,并且不允许为空。
  3. 指向对象的性质

    • 指针可以重新赋值指向其他对象,而引用一旦初始化后,就无法指向其他对象,它始终引用同一个对象。
  4. 取地址操作

    • 对指针进行取地址操作会返回指针自身的地址,而对引用进行取地址操作会返回引用所绑定对象的地址。

相互转换:

在 C++ 中,指针和引用可以相互转换,但有一些限制:

  1. 指针转换为引用:可以将指针转换为引用,但是要求指针所指向的对象必须存在,否则会出现未定义行为。

    int* ptr = new int;
    int& ref = *ptr; // 将指针转换为引用
    
  2. 引用转换为指针:可以将引用转换为指针,但是引用本身不是一个对象,所以无法取地址。因此,引用转换为指针时,得到的是指向引用绑定对象的指针。

    int var = 10;
    int& ref = var;
    int* ptr = &ref; // 将引用转换为指针
    

总的来说,指针和引用在语法和使用上有一些区别,但在某些情况下可以相互转换。在选择使用时,需要根据具体情况来决定使用哪种方式。

2.1.5 C语言检索内存情况 内存分配的方式⭐⭐⭐

在 C 语言中,检索内存情况和内存分配的方式通常涉及以下内容:

检索内存情况:

  1. 内存信息查看工具:可以使用操作系统提供的工具或者第三方工具来查看系统内存的使用情况,例如 topfreeps 等命令行工具。

  2. 编程方式:通过编写 C 语言程序来检索内存情况,可以使用 malloc_stats() 函数或者操作系统特定的系统调用(例如 Linux 中的 /proc/meminfo 文件)来获取系统内存的使用情况。

内存分配的方式:

  1. 静态内存分配

    • 在编译时分配固定大小的内存空间,例如全局变量、静态变量等。这些变量的内存分配和释放由编译器负责管理,程序执行期间不会改变。
  2. 动态内存分配

    • 在运行时根据需要动态分配内存空间,以满足程序运行时的需求。C 语言提供了 malloc()calloc()realloc() 等函数来进行动态内存分配,以及 free() 函数来释放动态分配的内存。
  3. 堆内存分配

    • 动态内存分配通常使用堆(Heap)来管理内存。堆是一个动态分配的内存区域,可以在程序运行时动态地分配和释放内存。通常情况下,malloc()calloc()realloc() 等函数从堆中分配内存,而 free() 函数用于释放堆内存。
  4. 栈内存分配

    • 栈(Stack)是另一种内存分配方式,用于存储局部变量、函数参数、返回地址等。栈内存分配是由编译器自动管理的,变量的生命周期与其所在的作用域相关联,当变量超出作用域时,其内存会自动释放。因此,栈内存分配是一种自动的、静态的分配方式。
  5. 内存池

    • 内存池是一种优化内存分配和释放的方式,通过预先分配一定数量的内存块,并将其放入池中。当需要分配内存时,可以从内存池中取出已分配的内存块,当不再需要时,将其放回内存池中以便重复利用,而不是频繁地进行动态内存分配和释放。内存池通常用于需要频繁分配和释放同一大小的内存块的场景,可以提高内存分配和释放的效率。

这些是常见的 C 语言中检索内存情况和内存分配的方式,可以根据具体的需求选择合适的方法进行内存管理。

2.1.6 extern”C” 的作用⭐⭐⭐

extern "C" 是 C++ 中的关键字,用于指定使用 C 语言的编译和链接规范。它的作用主要有两个方面:

  1. 指定函数或变量使用 C 语言的编译和链接规范

    • 当在 C++ 中调用 C 语言编写的函数或使用 C 语言编写的变量时,由于 C++ 和 C 的编译和链接规范略有不同,需要使用 extern "C" 来指定使用 C 语言的编译和链接规范,以确保能够正确调用和使用这些函数或变量。
    • 例如,在 C++ 中调用 C 语言编写的函数:
      // C 语言中的头文件 example.h
      #ifdef __cplusplus
      extern "C" {
      #endif
      
      void c_function(); // 声明一个 C 语言编写的函数
      
      #ifdef __cplusplus
      }
      #endif
      
      在 C++ 中包含该头文件并调用函数:
      #include "example.h"
      
      int main() {
          c_function(); // 调用 C 语言编写的函数
          return 0;
      }
      
  2. 避免 C++ 名字修饰

    • 在 C++ 中,函数名、变量名等会经过一系列的名称修饰(Name Mangling)过程,以支持函数重载等特性。但是 C 语言中不存在函数重载等概念,因此使用 extern "C" 可以告诉编译器不对函数名进行名称修饰,使得 C++ 编译后的函数名与 C 语言的函数名一致,从而能够正确链接 C 语言编写的函数。
    • 例如,当使用 extern "C" 修饰一个函数时,编译器不会对函数名进行名称修饰:
      // C++ 中的函数声明
      extern "C" void c_function(); // 使用 extern "C" 修饰函数名
      
      // 编译后的函数名为 c_function,不会进行名称修饰
      

总的来说,extern "C" 的作用是指定函数或变量使用 C 语言的编译和链接规范,并且避免 C++ 名字修饰。这在 C++ 中调用 C 语言编写的代码时非常有用。

2.1.7头文件声明时加extern定义时不要加 因为extern可以多次声明,但只有一个定义⭐⭐⭐⭐

你说的很对!在 C/C++ 中,extern 关键字用于声明一个变量或函数,表明该变量或函数是在其他文件中定义的,而不是在当前文件中定义的。这在头文件中声明变量或函数时很常见,因为头文件通常会被多个源文件包含,为了避免重复定义,需要使用 extern 来声明,而实际的定义则在一个源文件中。

在头文件中声明变量或函数时,通常会使用 extern 关键字,而在实际定义时不需要加 extern。这是因为 extern 只是用于声明,并不会分配内存或创建函数的实现,而实际的定义会在其他源文件中完成,编译器会通过链接将声明与定义进行关联。

举个例子,在头文件中声明一个全局变量和一个函数:

// header.h

#ifndef HEADER_H
#define HEADER_H

extern int global_variable; // 声明一个全局变量

extern void function(); // 声明一个函数

#endif

然后在另一个源文件中定义这个全局变量和函数:

// source.c

#include "header.h"

int global_variable = 0; // 定义全局变量

void function() {
    // 函数实现
}

在这个例子中,头文件 header.h 中使用了 extern 来声明全局变量和函数,而实际的定义则在源文件 source.c 中完成,这样就避免了在多个源文件中重复定义全局变量和函数。

2.1.8函数参数压栈顺序,即关于__stdcall和__cdecl调用方式的理解⭐⭐⭐

__stdcall__cdecl 是两种不同的函数调用约定(Calling Convention),它们决定了函数参数压栈的顺序、参数传递方式以及函数调用的规则。

__stdcall:

  1. 参数压栈顺序:从右往左。
  2. 参数传递方式:参数以右至左的顺序被依次压入栈中,调用方负责清理栈。
  3. 函数调用规则:在函数调用结束后,调用方负责清理栈上的参数。
  4. 适用范围:Windows API 中的大多数函数都使用 __stdcall 调用约定。

__cdecl:

  1. 参数压栈顺序:从右往左。
  2. 参数传递方式:参数以右至左的顺序被依次压入栈中,被调用方负责清理栈。
  3. 函数调用规则:在函数调用结束后,被调用方负责清理栈上的参数。
  4. 适用范围:C 语言中常用的默认调用约定。

理解:

  1. 参数压栈顺序:即参数的压栈顺序。在两种调用约定中,参数都是从右至左压入栈中的,即先压入最后一个参数,然后依次压入前面的参数。

  2. 参数传递方式:主要区别在于调用方与被调用方对于栈的清理责任。在 __stdcall 中,调用方负责清理栈上的参数;而在 __cdecl 中,被调用方负责清理栈上的参数。

  3. 函数调用规则:在 __stdcall 中,调用方负责清理栈上的参数,这意味着函数的参数清理工作发生在函数返回之前;而在 __cdecl 中,被调用方负责清理栈上的参数,这意味着函数的参数清理工作发生在函数返回之后。

  4. 适用范围__stdcall 主要用于 Windows API 中的函数调用,而 __cdecl 是 C 语言中默认的调用约定,在其他大多数情况下也能使用。

总的来说,__stdcall__cdecl 之间的主要区别在于参数清理的责任,以及函数调用结束后栈的状态。在选择使用哪种调用约定时,需要考虑到函数的实际用途以及与其他代码的兼容性。

2.1.9重写memcpy()函数需要注意哪些问题⭐⭐

重写 memcpy() 函数时需要注意以下几个问题:

  1. 正确性:重写的 memcpy() 函数必须能够正确地将源内存区域的数据复制到目标内存区域,即使源和目标内存区域有重叠部分也必须能够正确处理。

  2. 性能memcpy() 是一个高性能的内存复制函数,重写的函数应该尽量保持高性能,尽可能减少不必要的内存访问和复制操作,以提高复制效率。

  3. 对齐要求:一些平台对内存访问有对齐要求,重写的 memcpy() 函数应该能够正确处理不同的内存对齐要求,以确保复制操作能够正常进行。

  4. 内存访问优化:可以使用一些优化技巧来提高内存访问效率,例如使用宽度大的内存复制指令(如 SSE、AVX 等)来进行快速复制操作,或者利用处理器的缓存机制来提高内存访问速度。

  5. 异常处理:应该考虑到可能出现的异常情况,例如内存访问错误、越界访问等,对这些异常情况进行适当的处理,以确保函数的稳定性和可靠性。

  6. 标准兼容性:重写的 memcpy() 函数应该符合标准库的定义和行为,以便能够与标准库和其他代码兼容。

  7. 平台兼容性:需要考虑到不同平台的特性和要求,确保重写的 memcpy() 函数在不同的操作系统和体系结构下都能够正常工作。

综上所述,重写 memcpy() 函数需要考虑到正确性、性能、对齐要求、内存访问优化、异常处理、标准兼容性和平台兼容性等多个方面的问题,以确保函数能够稳定、高效地运行。

2.1.10数组到底存放在哪里⭐⭐⭐

在计算机内存中,数组的存放位置取决于数组的类型和声明方式:

  1. 栈内存:在函数中声明的局部数组通常存放在栈内存中。栈内存是由操作系统分配给每个线程的,用于存放函数的局部变量、函数参数等。当函数调用结束时,栈内存会被自动释放,因此栈上的数组的生命周期通常与函数调用的生命周期相对应。

  2. 堆内存:通过动态内存分配函数(如 malloc()calloc()realloc() 等)分配的数组存放在堆内存中。堆内存是由程序员显式分配和释放的,通过调用相应的内存分配和释放函数来管理。堆上的内存的生命周期由程序员负责管理,需要手动释放以避免内存泄漏。

  3. 全局/静态存储区:在全局作用域或使用 static 关键字声明的数组存放在全局/静态存储区中。全局/静态存储区在程序的整个生命周期内都存在,并且在程序启动时就会被初始化。这种数组的生命周期与程序的生命周期相同,直到程序结束时才被销毁。

  4. 常量区:对于被声明为 const 的常量数组,编译器通常会将其存放在常量区中。常量区是存放常量数据的区域,通常位于程序的只读数据段中,且其内容在程序运行期间不可修改。

总的来说,数组可以存放在栈内存、堆内存、全局/静态存储区或常量区中,具体取决于数组的声明方式和类型,以及编译器和操作系统的实现。

2.1.11 struct和class的区别 ⭐⭐⭐⭐⭐

在 C++ 中,structclass 都是用于定义自定义数据类型的关键字,它们之间的主要区别在于默认的访问权限和默认的继承方式:

  1. 默认的访问权限

    • struct 中,默认的访问权限是公共的(public),即结构体中定义的成员变量和成员函数都是公共的,可以在结构体外部被访问。
    • class 中,默认的访问权限是私有的(private),即类中定义的成员变量和成员函数都是私有的,只能在类的内部被访问,需要通过公共的成员函数进行访问。
  2. 默认的继承方式

    • struct 中,默认的继承方式是公共的(public),即结构体继承的成员在派生类中仍然是公共的。
    • class 中,默认的继承方式是私有的(private),即类继承的成员在派生类中默认是私有的。

除了上述两点之外,structclass 在语法上几乎是完全一样的,它们都可以包含成员变量、成员函数、构造函数、析构函数、静态成员等,并且都支持访问修饰符(publicprotectedprivate)。

因此,可以将 struct 看作是一种更为简单的类,用于定义公共的数据结构,而 class 则更常用于面向对象编程中,用于定义具有更复杂行为和封装性质的数据类型。在实际开发中,选择使用 struct 还是 class 取决于具体的需求和习惯。

2.1.12 char和int之间的转换;⭐⭐⭐

在 C/C++ 中,charint 之间可以进行隐式类型转换和显式类型转换。

隐式类型转换:

  1. char 转换为 int

    • 在表达式中,char 类型的变量会自动转换为 int 类型,其对应的 ASCII 码值将被使用。
    char ch = 'A';
    int ascii_value = ch; // 隐式类型转换,ch 被转换为 'A' 对应的 ASCII 码值
    
  2. int 转换为 char

    • 如果将 int 类型的变量赋值给 char 类型的变量,会发生截断,只保留低位的一个字节,且可能会导致数据丢失或溢出。
    int num = 65;
    char ch = num; // 隐式类型转换,将 num 的低位字节赋值给 ch,即 'A'
    

显式类型转换:

  1. 强制类型转换

    • 可以使用 C 风格的强制类型转换或 C++ 中的类型转换操作符(static_cast、reinterpret_cast、const_cast、dynamic_cast)进行显式类型转换。
    char ch = 'A';
    int num = static_cast<int>(ch); // 显式类型转换,将 ch 转换为 int 类型
    
  2. 字符到整数转换

    • 可以使用标准库函数 atoi()atol()atoll() 或者 C++ 中的 std::stoi()std::stol()std::stoll() 等函数将字符串转换为整数。
    char str[] = "123";
    int num = atoi(str); // 将字符串转换为整数
    
  3. 整数到字符转换

    • 可以使用字符类型的构造函数或者 C++ 中的 std::to_string() 将整数转换为字符串。
    int num = 65;
    char ch = static_cast<char>(num); // 显式类型转换,将整数转换为字符
    

总的来说,charint 之间的转换在 C/C++ 中是常见的,可以根据具体的需求选择合适的转换方式。需要注意的是,对于从 intchar 的转换,可能会导致数据截断或溢出,因此需要谨慎处理。

2.1.13 static的用法(定义和用途)⭐⭐⭐⭐⭐

static 是 C 和 C++ 中的一个关键字,它可以用于多种情况,具有不同的用法和含义:

  1. 在全局变量前的用法

    • 在全局变量前使用 static 关键字可以将其作用域限制在当前文件内,使得该变量对其他文件不可见,实现了信息隐藏和封装性。
    static int global_variable = 10; // 全局变量 global_variable 只在当前文件内可见
    
  2. 在局部变量前的用法

    • 在局部变量前使用 static 关键字可以使得该变量在函数调用之间保持状态,即使函数调用结束后仍然保持原来的值,实现了静态存储和持久性。
    void function() {
        static int counter = 0; // 静态局部变量 counter 会在函数调用之间保持状态
        counter++;
        printf("Counter: %d\n", counter);
    }
    
  3. 在函数前的用法

    • 在函数前使用 static 关键字可以将函数的作用域限制在当前文件内,使得该函数对其他文件不可见,实现了信息隐藏和封装性。
    static void helper_function() {
        // 函数实现
    }
    
  4. 在类中的静态成员变量和静态成员函数

    • 在类中声明的静态成员变量和静态成员函数属于类本身,而不是类的对象,它们可以直接通过类名进行访问,不需要创建类的对象。
    class MyClass {
    public:
        static int static_member; // 静态成员变量
        static void static_function(); // 静态成员函数
    };
    
    int MyClass::static_member = 0; // 静态成员变量的定义和初始化
    void MyClass::static_function() {
        // 静态成员函数的实现
    }
    

总的来说,static 关键字在 C 和 C++ 中有多种用法,主要用于限制变量或函数的作用域、保持变量状态、定义类的静态成员变量和静态成员函数等。通过合理地使用 static 关键字,可以实现信息隐藏、封装性、静态存储和持久性等特性。

2.1.14 const常量和#define的区别(编译阶段、安全性、内存占用等) ⭐⭐⭐⭐

const 常量和 #define 宏定义是 C 和 C++ 中常用的定义常量的两种方式,它们有以下几点区别:

  1. 编译阶段

    • const 常量是在编译阶段进行处理的,编译器会为 const 常量分配内存空间,并在程序的数据段中存储其值。因此,const 常量具有类型安全性,能够进行类型检查和作用域检查。
    • #define 宏定义是在预处理阶段进行处理的,预处理器会直接将宏定义中的标识符替换为其对应的文本内容,因此在编译器看来,宏定义并不是一个具体的常量,而是一个文本替换,不会分配内存空间,也不会进行类型检查和作用域检查。
  2. 安全性

    • const 常量具有类型安全性,编译器会进行类型检查,确保常量的类型与其使用的地方匹配,防止类型错误。
    • #define 宏定义不具有类型安全性,预处理器只进行文本替换,不会进行类型检查,可能导致类型错误或其他错误。
  3. 作用域

    • const 常量具有作用域,遵循普通变量的作用域规则,只在定义的作用域内有效。
    • #define 宏定义没有作用域限制,会在宏定义出现的位置被替换为其对应的文本内容,直到源文件的末尾或者遇到 #undef
  4. 内存占用

    • const 常量会分配内存空间存储其值,因此会占用内存。
    • #define 宏定义不会分配内存空间,只是进行文本替换,不会占用额外的内存。
  5. 调试和维护

    • const 常量具有可调试性,可以在调试器中查看其值,也可以通过修改代码来改变常量的值。
    • #define 宏定义不具有可调试性,调试器无法查看其值,也无法通过修改代码来改变其值,只能通过修改宏定义的方式来修改其值。

总的来说,const 常量和 #define 宏定义各有优缺点,在选择使用时需要根据具体的需求和场景进行权衡。通常情况下,推荐使用 const 常量来定义常量,因为它具有类型安全性、作用域限制、可调试性等优势。

2.1.15 volatile作用和用法 ⭐⭐⭐⭐⭐

volatile 是 C 和 C++ 中的一个关键字,用于告诉编译器所修饰的变量可能会在程序执行过程中被意外修改,因此编译器不应该对该变量进行优化,而应该每次都重新从内存中读取该变量的值。volatile 的作用和用法包括以下几个方面:

  1. 告知编译器不要优化

    • 使用 volatile 关键字修饰的变量告知编译器,在程序执行过程中可能会发生变化,因此编译器不应该对该变量进行优化,而应该每次都重新从内存中读取该变量的值。
  2. 多线程同步

    • 在多线程编程中,volatile 关键字可以用于告知编译器,某个变量可能会被其他线程修改,因此需要保证线程间对该变量的可见性,不应该将该变量缓存到寄存器或者对其进行优化,从而确保每次都从内存中读取最新的值。
  3. 中断处理

    • 在中断处理程序中,通常需要访问外部硬件设备的寄存器或者其他变量,这些变量可能会被硬件设备修改,因此需要使用 volatile 关键字修饰这些变量,以确保每次都从内存中读取最新的值。
  4. 内存映射 I/O

    • 在进行内存映射 I/O 操作时,通常需要访问外部设备的寄存器或者其他内存地址,这些地址对应的变量可能会被外部设备修改,因此需要使用 volatile 关键字修饰这些变量,以确保每次都从内存中读取最新的值。
  5. 防止优化

    • 在某些特殊情况下,即使变量没有被其他线程或者外部设备修改,但是希望告知编译器不要对该变量进行优化,也可以使用 volatile 关键字修饰该变量,以确保每次都从内存中读取该变量的值。

总的来说,volatile 关键字的主要作用是告知编译器某个变量可能会被意外修改,因此不应该对该变量进行优化,而应该每次都重新从内存中读取该变量的值。在多线程编程、中断处理、内存映射 I/O 等场景中,通常会使用 volatile 关键字来确保变量的可见性和一致性。

2.1.16有常量指针 指针常量 常量引用 没有 引用常量⭐⭐⭐

在 C++ 中,有以下几种常量指针、指针常量和常量引用的概念:

  1. 常量指针(const pointer):

    • 声明形式为 int* const ptr;,表示指针本身是常量,指针指向的内存地址不能改变,但指向的内存内容可以改变。
    int value = 10;
    int* const ptr = &value; // 常量指针,ptr 指向 value,但 ptr 本身是常量,不能指向其他地址
    *ptr = 20; // 合法,可以修改 ptr 指向的值
    
  2. 指针常量(pointer to const):

    • 声明形式为 const int* ptr;int const* ptr;,表示指针指向的内存内容是常量,不能通过该指针修改所指向的值,但指针本身可以改变指向其他地址。
    int value = 10;
    const int* ptr = &value; // 指针常量,ptr 指向 value,但不能通过 ptr 修改 value 的值
    // *ptr = 20; // 非法,不能通过 ptr 修改 value 的值
    
  3. 常量引用(const reference):

    • 声明形式为 const int& ref = value;,表示引用是常量,不能通过该引用修改所引用的值,但被引用的变量本身可以修改。
    int value = 10;
    const int& ref = value; // 常量引用,ref 引用了 value,但不能通过 ref 修改 value 的值
    // ref = 20; // 非法,不能通过 ref 修改 value 的值
    
  4. 引用常量(reference to const):

    • 这个概念不存在。引用本身不可更改,因此不可能有引用常量的概念。

综上所述,C++ 中有常量指针、指针常量和常量引用的概念,但没有引用常量的概念。常量指针表示指针本身是常量,指针指向的内存地址不能改变;指针常量表示指针指向的内存内容是常量,不能通过指针修改所指向的值;常量引用表示引用本身是常量,不能通过引用修改所引用的值。

2.1.17没有指向引用的指针,因为引用是没有地址的,但是有指针的引用⭐⭐⭐

是的,你说得对。在 C++ 中,虽然不能创建指向引用的指针,因为引用本身并不是一个独立的对象,没有自己的地址,但是可以创建指针的引用,即指针的引用可以作为一种间接访问指针的方式。

以下是指针的引用的一个示例:

#include <iostream>

int main() {
    int value = 10;
    int* ptr = &value; // 指针 ptr 指向 value

    int* &ptr_ref = ptr; // ptr_ref 是指针 ptr 的引用

    std::cout << "Value: " << *ptr_ref << std::endl; // 通过 ptr_ref 访问 ptr 指向的值

    *ptr_ref = 20; // 通过 ptr_ref 修改 ptr 指向的值

    std::cout << "Value after modification: " << *ptr << std::endl; // 查看 value 的值是否被修改

    return 0;
}

在这个示例中,ptr_ref 是指向指针 ptr 的引用,通过 ptr_ref 可以访问和修改 ptr 所指向的值。指针的引用可以用来简化对指针的操作,提高代码的可读性和可维护性。

2.1.18 c/c++中变量的作用域⭐⭐⭐⭐⭐

在 C 和 C++ 中,变量的作用域(scope)是指变量在程序中可见的范围,即变量的有效范围。变量的作用域取决于它们的声明位置,可以分为以下几种类型:

  1. 全局作用域(Global Scope)

    • 在函数外部声明的变量具有全局作用域,它们可以在程序的任何地方被访问。这些变量的生命周期从程序开始到程序结束,即它们在整个程序运行期间都是有效的。
    #include <iostream>
    
    int global_variable = 10; // 全局作用域
    
    int main() {
        std::cout << "Global variable: " << global_variable << std::endl; // 可以在 main 函数中访问全局变量
        return 0;
    }
    
  2. 局部作用域(Local Scope)

    • 在函数内部声明的变量具有局部作用域,它们只能在声明它们的函数内部被访问。这些变量的生命周期从它们所在的代码块开始,直到代码块结束时被销毁。
    #include <iostream>
    
    int main() {
        int local_variable = 20; // 局部作用域
        std::cout << "Local variable: " << local_variable << std::endl; // 可以在 main 函数中访问局部变量
        return 0;
    }
    
  3. 块作用域(Block Scope)

    • 在任意代码块(花括号 {})内部声明的变量具有块作用域,它们只能在声明它们的代码块内部被访问。这些变量的生命周期从它们所在的代码块开始,直到代码块结束时被销毁。
    #include <iostream>
    
    int main() {
        {
            int block_variable = 30; // 块作用域
            std::cout << "Block variable: " << block_variable << std::endl; // 可以在代码块中访问块变量
        }
        // std::cout << block_variable; // 错误,block_variable 超出了作用域
        return 0;
    }
    
  4. 命名空间作用域(Namespace Scope)

    • 在命名空间内部声明的变量具有命名空间作用域,它们可以在整个命名空间范围内被访问。这些变量的生命周期和命名空间的生命周期相同。
    #include <iostream>
    
    namespace MyNamespace {
        int namespace_variable = 40; // 命名空间作用域
    }
    
    int main() {
        std::cout << "Namespace variable: " << MyNamespace::namespace_variable << std::endl; // 可以在 main 函数中访问命名空间变量
        return 0;
    }
    

总的来说,变量的作用域在 C 和 C++ 中非常重要,它决定了变量在程序中的可见范围和生命周期。正确理解和使用变量的作用域可以提高程序的可读性、可维护性和安全性。

2.1.19 c++中类型转换机制?各适用什么环境?dynamic_cast转换失败时,会出现什么情况?⭐⭐⭐

在 C++ 中,有四种类型转换机制:

  1. 静态转换(Static Cast)

    • 使用 static_cast 关键字进行转换,主要用于显式类型转换,可以将一种类型转换为另一种类型,但在编译时确定转换的类型。
    • 适用于大多数隐式转换、兼容的类型之间的转换,如数值类型之间的转换、指针类型之间的转换等。
    • 静态转换是编译时进行的转换,不进行运行时类型检查,因此转换失败时不会抛出异常,而是进行静态断言或者直接进行转换。
  2. 动态转换(Dynamic Cast)

    • 使用 dynamic_cast 关键字进行转换,主要用于基类指针或引用和派生类指针或引用之间的转换,需要进行运行时类型检查。
    • 适用于基类指针或引用向派生类指针或引用的安全转换,只能用于含有虚函数的类层次结构中。
    • 动态转换在运行时进行类型检查,如果转换失败(即无法将指针或引用转换为目标类型),则返回空指针(对于指针)或抛出 std::bad_cast 异常(对于引用)。
  3. 常量转换(Const Cast)

    • 使用 const_cast 关键字进行转换,用于移除对象的 constvolatile 属性,允许对常量对象进行修改操作。
    • 常量转换主要用于将指向常量对象的指针或引用转换为指向非常量对象的指针或引用。
    • 常量转换不会进行任何运行时检查,因此可能导致未定义的行为,应谨慎使用。
  4. 重新解释转换(Reinterpret Cast)

    • 使用 reinterpret_cast 关键字进行转换,用于将一个指针或引用转换为另一种类型的指针或引用,通常用于不同类型之间的转换。
    • 重新解释转换不进行类型检查,允许将指针或引用转换为任意类型的指针或引用,但可能导致未定义的行为,应慎用。

dynamic_cast 转换失败时,会根据转换的上下文情况产生不同的行为:

  • 如果转换的源指针是空指针,则 dynamic_cast 返回空指针。
  • 如果转换的源指针指向的对象不是目标类型的派生类对象,则 dynamic_cast 返回空指针。
  • 如果转换的源指针指向的对象是目标类型的派生类对象,则 dynamic_cast 返回指向该对象的指针。
  • 如果转换的源指针指向的对象是目标类型的基类对象,但目标类型有虚函数,且转换的源指针指向的对象实际上是目标类型的派生类对象,则 dynamic_cast 返回指向该派生类对象的指针。
  • 如果转换的源引用引用的对象不是目标类型的派生类对象,则 dynamic_cast 抛出 std::bad_cast 异常。

2.2 继承、多态相关面试题 ⭐⭐⭐⭐⭐

2.2.1继承和虚继承 ⭐⭐⭐⭐⭐

2.2.2多态的类,内存布局是怎么样的 ⭐⭐⭐⭐⭐

在多态的类中,内存布局通常包含以下几个部分:

  1. 虚函数表指针(vptr)

    • 在包含虚函数的类的对象中,通常会有一个指向虚函数表(vtable)的指针,称为虚函数表指针(vptr)。
    • 虚函数表是一个数组,存储了该类中所有虚函数的地址,通过虚函数表指针可以在运行时动态地调用正确的虚函数。
    • 虚函数表指针位于对象的起始位置或者虚函数表之后的位置,具体取决于编译器和平台。
  2. 成员变量

    • 在虚函数表指针之后是类的成员变量,按照声明的顺序排列。
    • 成员变量的布局遵循内存对齐规则,通常按照成员变量的声明顺序依次存放在对象中。
  3. 虚函数表(vtable)

    • 虚函数表存储了该类中所有虚函数的地址,以及可能存在的虚基类的偏移量。
    • 虚函数表通常是在编译时生成的,每个类只有一个虚函数表,存储在程序的数据段中,所有该类的对象共享同一个虚函数表。
  4. 其他辅助信息

    • 除了上述内容之外,还可能包含一些编译器或运行时需要的额外信息,如虚基类偏移量等。

具体的内存布局在不同的编译器和平台上可能会有所差异,但通常遵循上述结构。内存布局的设计旨在实现多态的特性,使得在运行时可以正确地调用对象的虚函数,实现动态绑定和多态行为。

2.2.3被隐藏的基类函数如何调用或者子类调用父类的同名函数和父类成员变量 ⭐⭐⭐⭐⭐

在 C++ 中,如果子类中定义了与父类同名的函数或成员变量,父类的同名函数或成员变量会被隐藏(而不是覆盖)。如果想要在子类中调用被隐藏的基类函数或访问基类的同名成员变量,可以通过以下几种方式实现:

  1. 使用作用域解析运算符 ::

    • 可以使用作用域解析运算符 :: 来显式指定调用基类的函数或访问基类的成员变量。这样可以直接指定要调用的是基类的函数或成员变量。
    #include <iostream>
    
    class Base {
    public:
        void display() {
            std::cout << "Base class display() function" << std::endl;
        }
        
        int value = 10;
    };
    
    class Derived : public Base {
    public:
        void display() {
            std::cout << "Derived class display() function" << std::endl;
        }
    };
    
    int main() {
        Derived derivedObj;
        derivedObj.display(); // 调用 Derived 类中的 display() 函数
        derivedObj.Base::display(); // 调用 Base 类中的 display() 函数
        std::cout << "Value: " << derivedObj.Base::value << std::endl; // 访问 Base 类中的 value 成员变量
        return 0;
    }
    
  2. 使用 using 声明

    • 可以使用 using 声明将父类的同名成员引入子类的作用域,使得基类的同名成员在子类中可见。
    class Derived : public Base {
    public:
        using Base::display; // 将 Base 类的 display() 函数引入 Derived 类的作用域
    };
    

这些方法可以使得在子类中调用被隐藏的基类函数或访问基类的同名成员变量成为可能,但应该谨慎使用,以避免混淆和歧义。

2.2.4多态实现的三个条件、实现的原理 ⭐⭐⭐⭐⭐

多态的实现通常需要满足以下三个条件:

  1. 继承

    • 存在继承关系,即派生类继承自基类。
  2. 虚函数

    • 基类中有虚函数(virtual function),并且子类可以重写(override)这些虚函数。
  3. 通过基类指针或引用调用虚函数

    • 通过基类的指针或引用来调用虚函数,然后在运行时根据对象的实际类型来确定调用的是哪个版本的虚函数。

多态的实现原理是通过动态绑定(Dynamic Binding)来实现的,也称为运行时多态。当通过基类指针或引用调用虚函数时,编译器不会确定调用哪个版本的虚函数,而是在运行时根据对象的实际类型来确定调用的是哪个版本的虚函数。这种动态确定调用的函数版本的过程称为动态绑定。

具体实现动态绑定的方法是通过在对象的内存布局中添加虚函数表(vtable)指针(通常称为 vptr)。这个指针指向对象所属类的虚函数表,虚函数表中存储了该类所有虚函数的地址。当调用虚函数时,通过对象的 vptr 找到对应的虚函数表,并根据函数在虚函数表中的索引找到要调用的函数地址,然后调用该函数。这样可以实现在运行时根据对象的实际类型来动态调用正确的虚函数,实现多态的效果。

2.2.5对拷贝构造函数 深浅拷贝 的理解 拷贝构造函数作用及用途?什么时候需要自定义拷贝构造函数?⭐⭐⭐

拷贝构造函数是一种特殊的构造函数,用于在创建对象时使用另一个同类型的对象来初始化它。拷贝构造函数的声明形式为 ClassName(const ClassName &obj),其中 ClassName 是类的名称,obj 是要拷贝的对象。

深拷贝和浅拷贝是两种不同的对象拷贝方式:

  1. 浅拷贝(Shallow Copy)

    • 浅拷贝是指简单地将一个对象的数据成员的值复制到另一个对象中,包括指针成员的值也只是简单地复制其地址。
    • 如果对象中包含指针成员,浅拷贝会导致两个对象共享同一块内存,当其中一个对象释放内存时,另一个对象仍然可以访问到已释放的内存,可能会导致悬空指针问题。
  2. 深拷贝(Deep Copy)

    • 深拷贝是指在拷贝对象时,不仅复制对象的数据成员的值,还要对指针成员指向的内存进行复制,使得两个对象指向不同的内存空间。
    • 深拷贝可以避免因共享内存导致的悬空指针问题,每个对象都拥有自己独立的内存空间。

拷贝构造函数的作用和用途包括:

  • 初始化对象:拷贝构造函数在创建对象时使用另一个同类型的对象来初始化它,可以用于对象的直接初始化、拷贝初始化或者参数传递过程中。

  • 对象拷贝:拷贝构造函数用于实现对象的拷贝,即复制一个对象的内容到另一个对象中,用于对象的赋值、传递和返回过程中。

需要自定义拷贝构造函数的情况包括:

  • 类中包含指针成员,并且需要进行深拷贝,避免浅拷贝导致的悬空指针问题。
  • 类中包含资源管理的成员,如动态分配的内存、文件句柄等,需要在拷贝时对资源进行复制或共享。
  • 需要在对象拷贝时执行特定的操作,如打印日志、更新计数器等。

总的来说,拷贝构造函数是用于对象的初始化和拷贝的重要成员函数,可以根据需要自定义来实现深拷贝或其他特定的拷贝行为。

2.2.6析构函数可以抛出异常吗?为什么不能抛出异常?除了资源泄露,还有其他需考虑的因素吗?⭐⭐⭐

在 C++ 中,析构函数(Destructor)应当尽量避免抛出异常,因为抛出异常的析构函数可能导致程序无法预期的行为。以下是一些原因:

  1. 程序状态不稳定

    • 在析构函数执行过程中抛出异常会导致程序状态不稳定,因为析构函数通常在对象生命周期结束时被调用,如果在对象被销毁的过程中抛出异常,可能会导致对象被销毁不完全,从而影响程序的正确执行。
  2. 异常安全性

    • 如果在析构函数中抛出异常,可能会导致资源释放不完全,进而导致资源泄露或者资源重复释放,从而破坏程序的异常安全性。
  3. 难以处理

    • 如果在析构函数中抛出异常,通常很难进行处理,因为析构函数是由编译器自动调用的,没有机会捕获和处理异常,可能会导致程序崩溃或者出现未定义的行为。
  4. 规范

    • 标准库和许多编程规范都建议析构函数不应该抛出异常,因为异常抛出的析构函数可能会影响代码的可靠性和可维护性,降低代码的可读性和可理解性。

除了资源泄露之外,还有其他因素需要考虑,例如:

  • 性能:异常处理会带来一定的性能开销,如果析构函数频繁抛出异常,可能会影响程序的性能和效率。

  • 可靠性:异常抛出的析构函数可能会导致程序不可预测的行为,降低程序的可靠性和稳定性。

因此,为了确保程序的稳定性和可靠性,通常应当避免在析构函数中抛出异常,而应该在析构函数中进行资源的安全释放和清理操作,尽量保持析构函数的简单和高效。

2.2.7什么情况下会调用拷贝构造函数(三种情况)⭐⭐⭐

在 C++ 中,拷贝构造函数会在以下三种情况下被调用:

  1. 对象的直接初始化

    • 当一个对象被直接初始化为另一个对象时,会调用拷贝构造函数。例如:
    MyClass obj1; // 调用默认构造函数
    MyClass obj2 = obj1; // 调用拷贝构造函数,将 obj1 初始化为 obj2
    
  2. 对象的拷贝初始化

    • 当一个对象通过另一个对象进行拷贝初始化时,会调用拷贝构造函数。例如:
    MyClass obj1; // 调用默认构造函数
    MyClass obj2(obj1); // 调用拷贝构造函数,将 obj1 拷贝给 obj2
    
  3. 对象的值传递

    • 当一个对象作为参数传递给函数或者作为返回值返回时,会调用拷贝构造函数。例如:
    void func(MyClass obj) {
        // 函数体
    }
    
    MyClass obj1; // 调用默认构造函数
    func(obj1); // 调用拷贝构造函数,将 obj1 作为参数传递给 func 函数
    

在这些情况下,会调用拷贝构造函数来创建一个新的对象,该对象与原始对象具有相同的值或状态,但是它们是两个独立的对象。因此,拷贝构造函数在对象的复制和传递过程中起着重要作用。

2.2.8析构函数一般写成虚函数的原因⭐⭐⭐⭐⭐

析构函数一般写成虚函数的原因包括:

  1. 多态销毁对象

    • 如果一个类被设计成作为其他类的基类,且希望通过基类指针来销毁派生类对象,那么基类的析构函数应该声明为虚函数。这样可以确保在删除派生类对象时,会调用正确的析构函数,实现多态的销毁行为。
  2. 资源的正确释放

    • 如果派生类中包含了动态分配的资源(如堆内存、文件句柄等),那么应该在派生类的析构函数中释放这些资源。如果基类的析构函数不是虚函数,当通过基类指针删除派生类对象时,只会调用基类的析构函数,导致派生类的资源无法正确释放,可能会发生资源泄露。
  3. 避免未定义行为

    • 如果基类的析构函数不是虚函数,当通过基类指针删除派生类对象时,会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类中的资源没有正确释放,从而引发未定义的行为或内存泄漏。

因此,为了确保多态销毁对象时能够调用正确的析构函数,以及确保派生类中的资源能够正确释放,通常将基类的析构函数声明为虚函数。这样可以保证在删除派生类对象时,会根据对象的实际类型调用正确的析构函数,实现正确的资源释放行为。

2.2.9构造函数为什么一般不定义为虚函数⭐⭐⭐⭐⭐

构造函数一般不定义为虚函数的原因包括:

  1. 编译器限制

    • C++ 标准规定,构造函数不能是虚函数。因为在创建对象时,需要确定对象的类型,而虚函数的调用需要在运行时确定对象的类型,这与构造函数的初衷相违背。
  2. 对象的创建过程

    • 对象的创建过程包括分配内存、初始化成员变量和执行构造函数等步骤,构造函数的目的是初始化对象的状态,而不是确定对象的类型。因此,在对象创建时,构造函数的虚拟机制并不适用。
  3. 内存布局的确定性

    • 在对象的创建过程中,编译器需要确定对象的内存布局,包括对象的虚函数表指针(如果有的话)。如果构造函数是虚函数,那么在对象的内存布局中需要包含虚函数表指针,这会增加对象的大小和内存开销。
  4. 安全性和稳定性

    • 将构造函数定义为虚函数可能会导致一些不可预期的行为,比如构造函数在派生类中被调用时可能会引发虚函数调用的问题,从而导致对象状态未定义,增加程序的复杂性和不稳定性。

综上所述,由于构造函数的特殊性以及对象创建过程中的需求,构造函数一般不定义为虚函数。虚函数主要用于实现多态和运行时多态调用的场景,而构造函数的目的是初始化对象的状态,两者的用途不同,因此通常不将构造函数定义为虚函数。

2.2.10什么是纯虚函数⭐⭐⭐⭐⭐

纯虚函数是在基类中声明的虚函数,但没有在基类中给出具体的实现,而是要求派生类必须提供实现。纯虚函数的声明形式为在函数声明的结尾处添加 = 0。纯虚函数使得基类成为抽象类,不能直接实例化对象,而只能作为其他类的基类,被用来派生出具体的类。

使用纯虚函数的主要目的是定义一个接口,规定了派生类必须提供的方法,但不关心具体的实现。通过纯虚函数,基类可以约束派生类必须实现的功能,实现了接口与实现的分离,同时也能实现多态性。纯虚函数的实现可以在派生类中进行,也可以在派生类中被覆盖重写。

示例代码如下所示:

class Base {
public:
    // 纯虚函数
    virtual void func() = 0;
};

class Derived : public Base {
public:
    // 派生类必须提供对纯虚函数的具体实现
    void func() override {
        // 具体的实现
    }
};

需要注意的是,包含纯虚函数的类被称为抽象类,无法直接实例化对象。如果派生类没有提供纯虚函数的具体实现,那么派生类也会变成抽象类,无法实例化对象。

2.2.11静态绑定和动态绑定的介绍⭐⭐⭐⭐

静态绑定(Static Binding)和动态绑定(Dynamic Binding)是指在编译时期和运行时期确定函数调用的机制。

  1. 静态绑定(Static Binding)

    • 静态绑定是在编译时期确定函数调用的机制。在静态绑定中,编译器根据变量的静态类型(即声明时的类型)来确定调用的函数版本。
    • 静态绑定适用于非虚函数的调用,以及对于指针或引用的调用,会根据指针或引用的类型来确定调用的函数版本。
    • 静态绑定的优点是效率高,因为在编译时期已经确定了函数的调用版本,不需要额外的运行时开销。
  2. 动态绑定(Dynamic Binding)

    • 动态绑定是在运行时期确定函数调用的机制。在动态绑定中,编译器根据对象的实际类型(即运行时类型)来确定调用的函数版本。
    • 动态绑定适用于虚函数的调用,通过基类指针或引用调用虚函数时,会根据对象的实际类型来确定调用的函数版本。
    • 动态绑定的优点是灵活性高,可以实现运行时多态性,即在运行时期根据对象的实际类型来调用正确的函数版本。

静态绑定和动态绑定的选择取决于实际的需求和情况。通常情况下,如果需要实现多态性和灵活性,则使用动态绑定;如果函数调用的版本在编译时期已经确定,并且不需要多态性,则使用静态绑定。

2.2.12 C++所有的构造函数 ⭐⭐⭐

C++ 中的构造函数包括以下几种:

  1. 默认构造函数(Default Constructor)

    • 默认构造函数是不带任何参数的构造函数,如果一个类没有显式定义任何构造函数,那么编译器会自动生成一个默认构造函数。
    • 默认构造函数用于创建对象时不提供任何参数的情况,会执行对象的默认初始化操作。
  2. 参数化构造函数(Parameterized Constructor)

    • 参数化构造函数是带有参数的构造函数,用于根据给定的参数值来初始化对象的数据成员。
    • 参数化构造函数允许在创建对象时提供初始化参数,用于自定义对象的初始化过程。
  3. 拷贝构造函数(Copy Constructor)

    • 拷贝构造函数是用于通过另一个同类型的对象来初始化对象的构造函数,通常是通过对象的复制、传递和返回操作来调用。
    • 拷贝构造函数可以在对象的创建、复制、传递和返回过程中被调用,用于创建一个新对象,其值与原始对象相同。
  4. 移动构造函数(Move Constructor)

    • 移动构造函数是用于将临时对象的资源(如堆内存、文件句柄等)移动到目标对象中的构造函数,通常用于实现移动语义和提高性能。
    • 移动构造函数允许在对象的移动操作中进行资源的转移和所有权的转移,以避免不必要的资源复制和释放操作。

以上是常见的构造函数类型,每种构造函数都有特定的作用和用途,可以根据需要选择合适的构造函数类型来实现对象的初始化和创建。

2.2.13重写、重载、覆盖的区别⭐⭐⭐⭐⭐

重写(Override)、重载(Overload)和覆盖(Overwrite)是面向对象编程中常用的概念,它们在不同的语境下有着不同的含义和用法:

  1. 重写(Override)

    • 重写是指在派生类中重新定义(override)基类的虚函数,以改变函数的行为。重写要求函数签名(包括函数名、参数列表和 const 修饰符)与基类中的虚函数完全相同。
    • 重写通常用于实现多态性,当通过基类指针或引用调用虚函数时,根据对象的实际类型来动态确定调用的函数版本。
  2. 重载(Overload)

    • 重载是指在同一个作用域内,根据函数的参数列表的不同定义多个同名函数的机制。重载的函数具有相同的名称但参数列表不同。
    • 重载通常用于提供多种形式或多种方式的函数调用,使得函数可以处理不同类型或数量的参数,以增加代码的灵活性和可复用性。
  3. 覆盖(Overwrite)

    • 覆盖是指在派生类中重新定义(overwrite)基类的非虚函数,以隐藏基类中的同名函数。覆盖并不涉及到多态性的概念,它只是简单地隐藏了基类的同名函数,使得派生类中的同名函数覆盖了基类中的函数。
    • 覆盖通常用于在派生类中提供特定于派生类的实现,而不是改变函数的行为或实现多态性。

总的来说,重写用于改变虚函数的行为并实现多态性,重载用于提供多种形式的函数调用,而覆盖则是简单地隐藏基类的同名函数而提供特定于派生类的实现。这三个概念在使用时需要根据具体的需求和场景来选择合适的方式。

2.2.14成员初始化列表的概念,为什么用成员初始化列表会快一些(性能优势)?⭐⭐⭐⭐

成员初始化列表是在 C++ 中用于初始化类成员变量的一种方式,它出现在构造函数的参数列表之后,通过在构造函数的初始化列表中对成员变量进行初始化。

使用成员初始化列表有以下几个优势,特别是在性能方面:

  1. 效率高

    • 使用成员初始化列表可以直接在对象创建时对成员变量进行初始化,而不需要先调用默认构造函数创建对象,然后再通过赋值操作进行初始化。这样可以节省一次对象构造和赋值的开销,提高初始化的效率。
  2. 避免多次调用构造函数

    • 如果成员变量没有使用初始化列表进行初始化,在构造函数体中对成员变量进行赋值操作时,会先调用默认构造函数创建成员变量,然后再调用赋值运算符进行赋值。这样会导致成员变量被多次构造,浪费了额外的资源和时间。
  3. 初始化顺序控制

    • 使用成员初始化列表可以控制成员变量的初始化顺序,可以按照需要的顺序进行初始化,而不是按照声明顺序或者编译器的默认顺序进行初始化。
  4. 对 const 和引用类型成员的必要性

    • 对于 const 类型和引用类型的成员变量,必须使用初始化列表来进行初始化,因为它们只能在对象创建时进行初始化,不能通过赋值操作进行初始化。

总的来说,使用成员初始化列表可以提高初始化的效率,避免多次调用构造函数,控制成员变量的初始化顺序,以及对 const 和引用类型成员的必要性。因此,建议在构造函数中使用成员初始化列表来对成员变量进行初始化,特别是在性能要求较高的场景下。

2.2.15如何避免编译器进行的隐式类型转换;(explicit)⭐⭐⭐⭐

在 C++ 中,可以使用 explicit 关键字来阻止编译器进行隐式类型转换。通常情况下,explicit 关键字用于修饰单参数构造函数或转换运算符,以防止编译器在需要特定类型的对象时进行隐式类型转换。

下面是 explicit 关键字的用法示例:

  1. 单参数构造函数的用法
class MyClass {
public:
    // 使用 explicit 关键字修饰构造函数,阻止隐式类型转换
    explicit MyClass(int value) : m_value(value) {}
private:
    int m_value;
};

void func(MyClass obj) {
    // 函数体
}

int main() {
    // 编译错误:不能隐式将整数转换为 MyClass 对象
    // MyClass obj = 10;
    
    // 正确:显式创建 MyClass 对象
    MyClass obj(10);
    
    // 正确:显式传递 MyClass 对象
    func(MyClass(10));
    
    return 0;
}
  1. 转换运算符的用法
class MyClass {
public:
    explicit operator int() const {
        return m_value;
    }
private:
    int m_value;
};

int main() {
    MyClass obj;
    
    // 编译错误:不能隐式将 MyClass 对象转换为整数
    // int value = obj;
    
    // 正确:显式转换为整数
    int value = static_cast<int>(obj);
    
    return 0;
}

通过使用 explicit 关键字,可以避免因隐式类型转换而引发的意外行为,增强代码的安全性和可读性。

第三章 网络编程

3.1 TCP UDP

3.1.1 TCP、UDP的区别 ⭐⭐⭐⭐⭐

TCP(Transmission Control Protocol)和UDP(User Datagram Protocol)是两种不同的传输层协议,用于在计算机之间进行数据传输。它们之间的主要区别包括以下几个方面:

  1. 连接性

    • TCP 是面向连接的协议,建立连接前需要进行三次握手,以确保通信双方的连接建立和可靠性。
    • UDP 是无连接的协议,不需要建立连接,通信双方之间直接发送数据包,不对数据包进行可靠性确认和重传。
  2. 可靠性

    • TCP 提供可靠的数据传输服务,通过序列号、确认应答和重传机制来保证数据的完整性和可靠性。
    • UDP 不提供可靠性保证,数据包可能会丢失、重复、乱序,应用层需要自行处理这些问题。
  3. 数据传输方式

    • TCP 采用流式传输方式,将数据分割成小的数据段进行传输,接收端按顺序重组数据段,保证数据的完整性。
    • UDP 采用数据报传输方式,将数据封装成数据报发送,每个数据报独立处理,不保证数据的顺序和完整性。
  4. 头部开销

    • TCP 的头部包含了较多的控制信息,如序列号、确认号、窗口大小等,导致 TCP 的头部开销较大。
    • UDP 的头部相对较小,只包含了基本的源端口、目标端口、长度和校验和等信息,头部开销较小。
  5. 适用场景

    • TCP 适用于需要可靠数据传输和顺序传输的应用,如文件传输、网页访问、电子邮件等。
    • UDP 适用于实时性要求较高、对数据传输的可靠性要求较低的应用,如视频流、音频流、在线游戏等。

综上所述,TCP 和 UDP 在连接性、可靠性、数据传输方式、头部开销和适用场景等方面有所不同,选择合适的协议取决于具体的应用需求和性能要求。

3.1.2 TCP、UDP的优缺点⭐⭐⭐

TCP 和 UDP 都是常见的传输层协议,它们各自有优点和缺点:

TCP 的优点

  1. 可靠性高:TCP 提供可靠的数据传输服务,通过序列号、确认应答和重传机制来保证数据的完整性和可靠性。

  2. 顺序传输:TCP 保证数据包按顺序传输和重组,确保数据的有序性,适用于需要顺序传输的应用场景。

  3. 错误检测和恢复:TCP 提供了较强的错误检测和恢复能力,可以检测和纠正数据传输过程中的错误。

  4. 流量控制:TCP 使用滑动窗口机制进行流量控制,可以调整发送数据的速率,避免网络拥塞和数据丢失。

TCP 的缺点

  1. 连接建立开销大:TCP 需要进行三次握手来建立连接,需要较多的开销和时间,不适用于短连接和实时性要求较高的应用场景。

  2. 头部开销大:TCP 的头部包含了较多的控制信息,导致头部开销较大,降低了传输效率。

  3. 实时性差:TCP 面向连接,提供可靠的数据传输服务,但是在实时性要求较高的应用场景下,可能会因为重传机制和流量控制导致延迟较大。

UDP 的优点

  1. 传输效率高:UDP 是无连接的协议,不需要建立连接和进行连接管理,头部开销较小,传输效率高。

  2. 实时性好:UDP 不提供可靠性保证,适用于实时性要求较高的应用场景,如音视频传输、在线游戏等。

  3. 适应性强:UDP 适用于各种网络环境和应用场景,可以根据具体需求进行灵活配置。

UDP 的缺点

  1. 可靠性差:UDP 不提供可靠性保证,数据包可能会丢失、重复、乱序,需要应用层自行处理这些问题。

  2. 无流量控制:UDP 不提供流量控制机制,容易导致网络拥塞和数据丢失。

  3. 无序传输:UDP 不保证数据包的顺序传输,可能会导致数据包的乱序,需要应用层自行处理顺序问题。

综上所述,TCP 和 UDP 在可靠性、传输效率、实时性、连接管理等方面有所不同,选择合适的协议取决于具体的应用需求和性能要求。

3.1.3 TCP UDP适用场景⭐⭐⭐

TCP 和 UDP 适用于不同的应用场景,根据它们各自的特点和优缺点,可以选择合适的协议来满足具体的需求:

TCP 适用场景

  1. 可靠数据传输:对于需要可靠数据传输和数据完整性保障的应用,如文件传输、数据库访问、网页浏览等,通常选择 TCP 协议。

  2. 顺序传输:对于要求数据按顺序传输和接收的应用,如文件传输、数据备份、邮件传输等,TCP 可以保证数据的有序性。

  3. 长连接应用:对于需要长时间保持连接并进行持续通信的应用,如网络电话、视频会议、即时通讯等,TCP 更适合于长连接的场景。

  4. 对延迟和丢包要求较低:对于对网络延迟和数据丢包要求较低的应用,如网页访问、文件传输等,TCP 提供了较好的性能和稳定性。

UDP 适用场景

  1. 实时性要求较高:对于实时性要求较高的应用,如音视频流传输、在线游戏、实时监控等,UDP 更适合于快速传输数据和降低延迟。

  2. 短消息传输:对于短消息传输和数据量较小的应用,如 DNS 查询、SNMP 管理、NTP 时间同步等,UDP 简单高效,更适合于短消息传输。

  3. 广播和多播应用:对于需要进行广播和多播的应用,如视频直播、网络广播、实时视频会议等,UDP 提供了广播和多播的支持,更适合于这类场景。

  4. 对实时性要求高、丢包可接受的应用:对于对网络延迟敏感、但可以接受一定程度的丢包的应用,如音视频传输、实时通信等,UDP 提供了更好的实时性,可以快速传输数据,但需要应用层自行处理丢包和重传问题。

综上所述,TCP 和 UDP 在可靠性、实时性、连接管理等方面有所不同,根据具体的应用需求和性能要求,选择合适的协议是非常重要的。

3.1.4 TCP为什么是可靠连接⭐⭐⭐⭐

TCP(Transmission Control Protocol)是一种面向连接的、可靠的传输层协议,其可靠性体现在多个方面:

  1. 三次握手建立连接:TCP 在建立连接时采用三次握手的机制,即客户端发送 SYN 报文给服务器,服务器接收到后回复 SYN-ACK 报文给客户端,客户端再发送 ACK 报文给服务器,确认连接建立。这种方式可以确保通信双方的连接建立正确。

  2. 序列号和确认应答:TCP 在数据传输过程中使用序列号和确认应答机制来保证数据的有序传输和可靠接收。发送端将数据进行分段并编号,接收端按序接收数据,并向发送端发送确认应答,如果发送端未收到确认应答,会进行重传。

  3. 流量控制:TCP 使用滑动窗口机制进行流量控制,通过调整窗口大小来控制发送端的发送速率,以避免网络拥塞和数据丢失。接收端根据自身接收能力向发送端发送窗口大小信息,发送端根据接收端的窗口大小来控制发送数据的速率。

  4. 拥塞控制:TCP 通过拥塞窗口机制进行拥塞控制,根据网络拥塞程度调整发送端的发送速率,以避免网络拥塞和数据丢失。TCP 使用慢启动、拥塞避免、快速重传和快速恢复等算法来控制拥塞。

  5. 超时重传机制:TCP 在数据传输过程中采用超时重传机制,如果发送端未收到确认应答或者接收端未收到数据,会进行重传操作,以确保数据的可靠传输。

综上所述,TCP 通过建立连接、序列号和确认应答、流量控制、拥塞控制和超时重传机制等多种机制来保证数据的可靠传输,因此被认为是一种可靠连接的传输层协议。

3.1.5典型网络模型,简单说说有哪些;⭐⭐⭐

典型的网络模型有 OSI 模型和 TCP/IP 模型,它们是描述计算机网络体系结构的两种主要模型。

  1. OSI 模型(Open Systems Interconnection 模型)

    • OSI 模型是国际标准化组织(ISO)制定的一种网络体系结构模型,将网络通信划分为七个不同的层次,从物理层到应用层,分别是:
      1. 物理层(Physical Layer)
      2. 数据链路层(Data Link Layer)
      3. 网络层(Network Layer)
      4. 传输层(Transport Layer)
      5. 会话层(Session Layer)
      6. 表示层(Presentation Layer)
      7. 应用层(Application Layer)
    • OSI 模型提供了一种通用的框架,用于理解和设计网络协议和体系结构,但在实际应用中并没有被广泛采用。
  2. TCP/IP 模型

    • TCP/IP 模型是互联网通信协议的基础,由美国国防部高级研究计划局(ARPA)开发,后来成为互联网的核心协议。
    • TCP/IP 模型将网络通信划分为四个层次,分别是:
      1. 网络接口层(Network Interface Layer)或链路层(Link Layer)
      2. 网际层(Internet Layer)
      3. 传输层(Transport Layer)
      4. 应用层(Application Layer)
    • TCP/IP 模型与 OSI 模型相比,将会话层和表示层合并到应用层,简化了网络通信的结构。

这两种网络模型都提供了一种结构化的方法来理解和设计计算机网络,但在实际应用中,TCP/IP 模型被广泛采用,因为它更加贴近实际网络的工作原理和应用场景。

3.1.6 Http1.1和Http1.0的区别⭐⭐⭐

HTTP 1.1 和 HTTP 1.0 是两个不同版本的超文本传输协议(HTTP),它们之间的主要区别包括以下几个方面:

  1. 持久连接(Persistent Connections)

    • HTTP 1.0 默认使用短连接,每次请求都需要建立一个新的 TCP 连接,请求结束后立即关闭连接。
    • HTTP 1.1 引入了持久连接的概念,允许多个 HTTP 请求和响应共享同一个 TCP 连接,提高了性能并减少了连接建立和关闭的开销。
  2. 管道化(Pipeline)

    • HTTP 1.1 支持请求管道化(Request Pipelining),即在同一个 TCP 连接上可以同时发送多个请求,而不需要等待之前的请求响应完成。
    • HTTP 1.0 不支持请求管道化,每个请求必须等待上一个请求的响应完成后才能发送下一个请求。
  3. 缓存控制(Caching)

    • HTTP 1.1 引入了更加灵活和精细的缓存控制机制,包括缓存过期时间、验证机制、缓存相关头部等,使得缓存更加有效和可控。
    • HTTP 1.0 的缓存控制机制相对简单,缓存控制头部较少,不能进行精细的缓存控制。
  4. 分块传输编码(Chunked Transfer Encoding)

    • HTTP 1.1 支持分块传输编码,允许服务器将响应分成多个块(Chunk)传输,提高了传输效率,并且可以在传输过程中动态生成响应内容。
    • HTTP 1.0 不支持分块传输编码,响应数据必须全部生成完毕后才能发送给客户端。
  5. Host 头部

    • HTTP 1.1 要求每个请求头部必须包含 Host 头部,用于指定请求的目标主机,以支持虚拟主机的实现。
    • HTTP 1.0 并没有强制要求 Host 头部,因此在同一个 IP 地址上部署多个域名的虚拟主机需要其他方法来区分请求的目标。

综上所述,HTTP 1.1 相对于 HTTP 1.0 在连接管理、性能优化、缓存控制等方面有了较大改进,提高了网络传输的效率和性能。

3.1.7 URI(统一资源标识符)和URL(统一资源定位符)之间的区别⭐⭐

URI(Uniform Resource Identifier,统一资源标识符)和 URL(Uniform Resource Locator,统一资源定位符)是两个相关但不完全相同的概念:

  1. URI(统一资源标识符)

    • URI 是一个更广泛的概念,用于唯一标识和定位资源,可以分为两个子集:URL 和 URN(Uniform Resource Name,统一资源名称)。
    • URI 是用于唯一标识资源的字符串,可以是 URL 或 URN,其目的是为了在全球范围内唯一标识一个资源。
  2. URL(统一资源定位符)

    • URL 是 URI 的一个子集,用于定位和指定资源的具体位置。
    • URL 包含了资源的位置信息(通常是网络上的地址)以及访问该资源所需的协议信息,如 HTTP、FTP 等。

综上所述,URI 是用于唯一标识和定位资源的统一标识符,而 URL 是 URI 的一个子集,用于具体定位和定位资源的具体位置。URI 比 URL 更加通用,URL 是 URI 的一种特定形式。

3.2 三次握手、四次挥手

3.2.1什么是三次握手⭐⭐⭐⭐⭐

三次握手是 TCP(Transmission Control Protocol,传输控制协议)在建立连接时所采用的一种通信过程,用于确保通信双方的连接建立正确。这个过程包括以下三个步骤:

  1. 客户端发送 SYN 报文

    • 客户端首先向服务器发送一个 SYN(同步)报文,其中包含一个随机生成的初始序列号(ISN)作为起始值。
  2. 服务器响应 SYN 报文

    • 服务器收到客户端发送的 SYN 报文后,会回复一个 SYN-ACK(同步-确认)报文,其中包含确认号(ACK)和服务器生成的初始序列号作为起始值。
  3. 客户端发送 ACK 报文

    • 客户端收到服务器发送的 SYN-ACK 报文后,会发送一个 ACK(确认)报文给服务器,确认号是服务器发送的序列号加一。

完成这个过程后,TCP 连接就建立成功了,通信双方可以开始进行数据传输。这个过程的目的是确保通信双方都能够正常发送和接收数据,同时建立起可靠的连接。如果在三次握手过程中任何一方未收到对方的确认或响应,都会触发超时重传机制,继续尝试建立连接,直到连接建立成功或达到最大重试次数。

3.2.2为什么三次握手中客户端还要发送一次确认呢?可以二次握手吗?⭐⭐⭐⭐

在 TCP 的三次握手过程中,客户端发送确认的目的是为了确认服务器的 SYN-ACK 报文,以确保服务器收到了客户端发送的 SYN 报文,并且能够正常响应。这是建立可靠连接的重要步骤之一。

为什么不能只进行两次握手呢?这是因为在两次握手过程中,虽然客户端发送了 SYN 报文,但服务器的 SYN-ACK 报文可能会丢失,导致客户端无法确认服务器的接收情况。如果此时客户端直接开始发送数据,而服务器并未收到确认,就会造成数据的丢失或不完整。因此,为了确保建立的连接可靠,需要客户端再发送一次确认。

简而言之,三次握手确保了双方都能够发送和接收数据,并建立了可靠的连接。如果只进行两次握手,可能会出现不可靠的情况,无法确保通信的可靠性。

至于是否可以进行二次握手,实际上在某些特定情况下是可能的,例如如果客户端已经有了一个持续存在的连接,并且知道服务器端也在等待连接,那么可以跳过第一次握手,直接发送 SYN 报文并携带之前连接的一些信息。这种情况下,就可以实现二次握手。但这需要双方之间的预先协商和约定,不是通常情况下的做法。通常为了保证连接的可靠性和稳定性,仍然会采用三次握手的方式。

3.2.3为什么服务端易受到SYN攻击?⭐⭐⭐⭐

服务端易受到 SYN 攻击的主要原因是 TCP 协议的设计特性以及网络的工作原理。

在 TCP 的三次握手过程中,客户端发送 SYN 报文给服务器,服务器接收到 SYN 报文后会回复 SYN-ACK 报文给客户端,并等待客户端发送 ACK 报文进行确认,完成连接的建立。SYN 攻击利用了这个过程中的一些漏洞和弱点,造成服务器资源的耗尽和拒绝服务。

具体来说,SYN 攻击的原理如下:

  1. 大量伪造的 SYN 请求:攻击者发送大量伪造的 SYN 请求给服务器,每个请求都会触发服务器为其分配资源并等待确认。

  2. 资源耗尽:由于每个伪造的 SYN 请求都会占用服务器的资源,并且服务器需要等待一定时间才能超时释放资源,因此攻击者可以通过发送大量的 SYN 请求,耗尽服务器的资源,导致服务器无法响应正常的请求。

  3. 未完成连接队列溢出:在服务器的未完成连接队列中,每个未完成的连接需要占用一定的内存空间,攻击者发送大量伪造的 SYN 请求会导致未完成连接队列溢出,使得正常的连接无法进入队列。

  4. 拒绝服务:由于服务器忙于处理大量的 SYN 请求,无法处理正常的请求,导致服务不可用,造成拒绝服务(DoS)攻击。

为了应对 SYN 攻击,可以采取以下一些防御措施:

  • SYN Cookie 技术:服务器可以使用 SYN Cookie 技术来抵御 SYN 攻击,当服务器收到 SYN 请求时,不立即分配资源,而是根据请求信息生成一个 Cookie 发送给客户端,只有客户端回复 ACK 报文并携带正确的 Cookie 时,服务器才分配资源并完成连接的建立。

  • 连接数限制:服务器可以限制每个 IP 地址的连接数,防止单个 IP 地址发送过多的 SYN 请求。

  • 防火墙过滤:在防火墙上设置规则,过滤掉来源于可疑 IP 地址的 SYN 请求,减少对服务器的影响。

  • 网络设备升级:更新网络设备的固件和软件,加强对 SYN 攻击的防护能力。

通过采取这些防御措施,可以有效减轻 SYN 攻击对服务器造成的影响,保障网络的安全和稳定运行。

3.2.4什么是四次挥手⭐⭐⭐⭐⭐

四次挥手是 TCP(Transmission Control Protocol,传输控制协议)在关闭连接时所采用的一种通信过程,用于确保通信双方都能够正常关闭连接。这个过程包括以下四个步骤:

  1. 客户端发送 FIN 报文

    • 客户端在完成数据传输后,发送一个 FIN(结束)报文给服务器,表示客户端不再发送数据,但仍然可以接收数据。
  2. 服务器响应 ACK 报文

    • 服务器收到客户端发送的 FIN 报文后,会发送一个 ACK(确认)报文给客户端,确认收到了客户端的 FIN 报文。
  3. 服务器发送 FIN 报文

    • 服务器在完成数据传输后,也发送一个 FIN 报文给客户端,表示服务器不再发送数据。
  4. 客户端响应 ACK 报文

    • 客户端收到服务器发送的 FIN 报文后,会发送一个 ACK 报文给服务器,确认收到了服务器的 FIN 报文。此时,TCP 连接正式关闭。

完成这个过程后,TCP 连接就成功关闭了,通信双方都可以释放连接并进行其他操作。四次挥手的目的是确保双方都能够正确关闭连接,并且在关闭之前完成所有的数据传输和确认操作,避免数据丢失或不完整。

3.2.5为什么客户端最后还要等待2MSL?⭐⭐⭐⭐

客户端在发送最后的 ACK 报文后,需要等待一段时间才能完全关闭连接,这段时间称为 2MSL(Maximum Segment Lifetime,最大报文生存时间)。

等待 2MSL 的主要原因有两个:

  1. 确保客户端发送的 ACK 报文被完全接收

    • 在 TCP 的四次挥手过程中,客户端发送 ACK 报文给服务器后,服务器可能会因为网络延迟或其他原因未及时接收到这个 ACK 报文。为了确保服务器收到 ACK 报文,客户端需要等待一段时间,让网络中的所有报文都能够被清除掉,以避免造成后续连接的混乱。
  2. 处理可能残留的延迟报文

    • 在 TCP 连接关闭后,可能会有一些延迟的报文在网络中残留,如果客户端立即释放连接,而这些残留的报文被错误地当作新的连接请求或数据传输,就会导致连接错误或数据混乱。等待 2MSL 的时间可以确保网络中的所有延迟报文都能够被丢弃,避免对后续连接造成影响。

综上所述,等待 2MSL 的过程是为了确保连接的正常关闭,防止可能的网络问题和报文残留导致的连接错误,保障连接的可靠性和稳定性。

3.2.6为什么建立连接是三次握手,关闭连接确是四次挥手呢?⭐⭐⭐⭐

建立连接是三次握手的过程,而关闭连接是四次挥手的过程,主要是因为 TCP 协议的设计和连接的状态转换所决定的。

在建立连接时,客户端和服务器之间需要交换一些必要的信息,确保双方都能够正常通信。三次握手的过程是为了确保连接的可靠性和安全性,包括确认双方的能力、初始化序列号、协商连接参数等。因此,需要三次握手来完成这个过程。

而在关闭连接时,客户端和服务器之间需要通知对方自己已经没有数据要发送了,并且确认对方也没有数据要发送。四次挥手的过程包括了客户端和服务器各自发送 FIN 报文表示自己不再发送数据,以及收到对方的确认后发送 ACK 报文,最终确认关闭连接。由于在关闭连接时需要客户端和服务器分别发送确认和最终确认,所以挥手的过程比建立连接时多了一次交互,因此是四次挥手。

总的来说,建立连接和关闭连接是两个不同的过程,它们的机制和目的不同,因此所采用的握手和挥手的次数也不同。建立连接需要确保连接的可靠性和安全性,而关闭连接需要确保数据的完整性和连接的正常终止。

第四章 常见算法

4.1 排序算法

4.1.1各种排序算法的时间空间复杂度、稳定性⭐⭐⭐⭐⭐

下面是常见的几种排序算法的时间复杂度、空间复杂度和稳定性:

  1. 冒泡排序(Bubble Sort)

    • 时间复杂度:最好情况 O(n),平均情况和最坏情况均为 O(n^2)。
    • 空间复杂度:O(1)(原地排序)。
    • 稳定性:稳定。
  2. 选择排序(Selection Sort)

    • 时间复杂度:最好情况、平均情况和最坏情况均为 O(n^2)。
    • 空间复杂度:O(1)(原地排序)。
    • 稳定性:不稳定。
  3. 插入排序(Insertion Sort)

    • 时间复杂度:最好情况 O(n),平均情况和最坏情况均为 O(n^2)。
    • 空间复杂度:O(1)(原地排序)。
    • 稳定性:稳定。
  4. 快速排序(Quick Sort)

    • 时间复杂度:最好情况 O(nlogn),平均情况 O(nlogn),最坏情况 O(n^2)(当选取的枢轴不平衡时)。
    • 空间复杂度:平均情况 O(logn),最坏情况 O(n)(取决于递归深度)。
    • 稳定性:不稳定。
  5. 归并排序(Merge Sort)

    • 时间复杂度:最好情况、平均情况和最坏情况均为 O(nlogn)。
    • 空间复杂度:O(n)(需要额外的空间来存储临时数组)。
    • 稳定性:稳定。
  6. 堆排序(Heap Sort)

    • 时间复杂度:最好情况、平均情况和最坏情况均为 O(nlogn)。
    • 空间复杂度:O(1)(原地排序)。
    • 稳定性:不稳定。
  7. 计数排序(Counting Sort)

    • 时间复杂度:O(n+k),其中 k 为待排序数据的范围。
    • 空间复杂度:O(n+k)。
    • 稳定性:稳定。
  8. 桶排序(Bucket Sort)

    • 时间复杂度:最好情况 O(n+k),平均情况 O(n+k),最坏情况 O(n^2)。
    • 空间复杂度:O(n+k)。
    • 稳定性:稳定。
  9. 基数排序(Radix Sort)

    • 时间复杂度:O(d*(n+k)),其中 d 为数字的位数,k 为基数。
    • 空间复杂度:O(n+k)。
    • 稳定性:稳定。

排序算法的选择需要根据具体的场景和需求来决定,综合考虑时间复杂度、空间复杂度和稳定性等因素。

4.1.2各种排序算法什么时候有最好情况、最坏情况(尤其是快排) ⭐⭐⭐⭐

不同排序算法在不同情况下具有不同的时间复杂度。以下是各种排序算法的最好情况和最坏情况的描述:

  1. 冒泡排序(Bubble Sort)

    • 最好情况:输入数组已经有序,时间复杂度为 O(n)。
    • 最坏情况:输入数组完全逆序,时间复杂度为 O(n^2)。
  2. 选择排序(Selection Sort)

    • 最好情况:无论输入数据的顺序如何,都需要进行相同数量的比较和交换,因此最好情况下时间复杂度为 O(n^2)。
    • 最坏情况:输入数组完全逆序,时间复杂度为 O(n^2)。
  3. 插入排序(Insertion Sort)

    • 最好情况:输入数组已经有序,时间复杂度为 O(n)。
    • 最坏情况:输入数组完全逆序,时间复杂度为 O(n^2)。
  4. 快速排序(Quick Sort)

    • 最好情况:每次选择的枢轴都恰好位于中间位置,划分的两个子数组大小相等,时间复杂度为 O(nlogn)。
    • 最坏情况:每次选择的枢轴都是最大或最小的元素,导致划分的两个子数组大小差异极大,时间复杂度为 O(n^2)。这种情况通常发生在输入数组已经有序或基本有序的情况下,或者选择的枢轴不合适时。
  5. 归并排序(Merge Sort)

    • 最好情况、平均情况和最坏情况均为 O(nlogn)。归并排序在任何情况下都可以保证稳定的时间复杂度。
  6. 堆排序(Heap Sort)

    • 最好情况、平均情况和最坏情况均为 O(nlogn)。堆排序在任何情况下都可以保证稳定的时间复杂度。
  7. 计数排序(Counting Sort)

    • 最好情况、平均情况和最坏情况均为 O(n+k),其中 k 为待排序数据的范围。计数排序在任何情况下都具有相同的时间复杂度。
  8. 桶排序(Bucket Sort)

    • 最好情况、平均情况和最坏情况均为 O(n+k)。桶排序在任何情况下都可以保证稳定的时间复杂度。
  9. 基数排序(Radix Sort)

    • 最好情况、平均情况和最坏情况均为 O(d*(n+k)),其中 d 为数字的位数,k 为基数。基数排序在任何情况下都具有相同的时间复杂度。

总的来说,排序算法的最好情况和最坏情况通常取决于输入数据的顺序和算法的实现方式。

4.1.3冒泡排序⭐⭐⭐⭐

冒泡排序是一种简单的排序算法,其原理是多次遍历待排序序列,每次比较相邻的两个元素,如果顺序不正确,则交换它们,直到整个序列都是有序的为止。具体步骤如下:

  1. 从第一个元素开始,依次比较相邻的两个元素,如果顺序不正确,则交换它们。
  2. 继续对每一对相邻的元素进行相同的比较和交换,直到最后一个元素,此时最大的元素会被交换到序列的末尾。
  3. 重复以上步骤,每次比较和交换都会将未排序部分的最大元素交换到末尾,直到整个序列都是有序的为止。

冒泡排序的关键在于每次遍历时,都将未排序部分的最大元素交换到末尾,因此每次遍历后,末尾的元素都会是当前未排序部分的最大值,逐步形成有序序列。

下面是冒泡排序的C++实现代码:

void bubbleSort(int arr[], int n) {
    for (int i = 0; i < n - 1; ++i) {
        // 标记是否发生了交换
        bool swapped = false;
        for (int j = 0; j < n - i - 1; ++j) {
            // 如果相邻元素顺序不正确,则交换它们
            if (arr[j] > arr[j + 1]) {
                std::swap(arr[j], arr[j + 1]);
                swapped = true;
            }
        }
        // 如果一轮遍历中没有发生交换,则说明已经有序,提前结束
        if (!swapped) {
            break;
        }
    }
}

冒泡排序的时间复杂度为 O(n^2),空间复杂度为 O(1)。在最好情况下,即待排序序列已经是有序的情况下,冒泡排序只需要进行一次遍历,时间复杂度为 O(n)。在最坏情况下,即待排序序列完全逆序的情况下,冒泡排序需要进行 n-1 轮遍历,每轮遍历需要比较和交换的次数依次为 n-1,n-2,…,1,总次数为 n*(n-1)/2,时间复杂度为 O(n^2)。

4.1.4选择排序⭐⭐⭐⭐

选择排序是一种简单直观的排序算法,其原理是每次从未排序的序列中选择最小(或最大)的元素,然后将其与未排序序列的第一个元素交换位置,直到整个序列都是有序的为止。具体步骤如下:

  1. 遍历待排序序列,记录当前未排序部分的最小元素的索引。
  2. 将未排序部分的最小元素与未排序部分的第一个元素交换位置,将最小元素放到已排序部分的末尾。
  3. 继续对剩余的未排序序列重复以上步骤,直到整个序列都是有序的。

选择排序的特点是每次遍历都会确定未排序部分的最小元素,并将其交换到已排序部分的末尾,因此每次遍历后,已排序部分都会增加一个元素,逐步形成有序序列。

下面是选择排序的C++实现代码:

void selectionSort(int arr[], int n) {
    for (int i = 0; i < n - 1; ++i) {
        // 记录当前未排序部分的最小元素索引
        int minIndex = i;
        // 在未排序部分中找到最小元素的索引
        for (int j = i + 1; j < n; ++j) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j;
            }
        }
        // 将最小元素与未排序部分的第一个元素交换位置
        std::swap(arr[i], arr[minIndex]);
    }
}

选择排序的时间复杂度为 O(n^2),空间复杂度为 O(1)。无论输入数据的顺序如何,选择排序的时间复杂度都是相同的,因为每次遍历都需要在未排序部分中找到最小元素,总共需要进行 n*(n-1)/2 次比较和交换。由于选择排序每次遍历只交换一次,因此选择排序是一种不稳定的排序算法。

4.1.5插入排序⭐⭐⭐⭐

插入排序是一种简单直观的排序算法,其原理类似于整理扑克牌的过程。具体步骤如下:

  1. 将待排序序列分为两部分:已排序部分和未排序部分,初始时已排序部分只包含第一个元素,未排序部分包含剩余的元素。
  2. 从未排序部分依次取出一个元素,将其插入到已排序部分的合适位置,使得已排序部分仍然保持有序。
  3. 重复以上步骤,直到未排序部分的所有元素都被插入到已排序部分,整个序列就变成有序的了。

插入排序的特点是每次从未排序部分取出一个元素,然后将其插入到已排序部分的合适位置,因此每次遍历后,已排序部分都会增加一个元素,逐步形成有序序列。

下面是插入排序的C++实现代码:

void insertionSort(int arr[], int n) {
    for (int i = 1; i < n; ++i) {
        // 将待插入元素保存到临时变量中
        int key = arr[i];
        // 将待插入元素与已排序部分进行比较,并找到合适的位置
        int j = i - 1;
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j];
            --j;
        }
        // 将待插入元素插入到合适的位置
        arr[j + 1] = key;
    }
}

插入排序的时间复杂度为 O(n^2),空间复杂度为 O(1)。在最好情况下,即待排序序列已经是有序的情况下,插入排序只需要进行 n-1 次比较,时间复杂度为 O(n)。在最坏情况下,即待排序序列完全逆序的情况下,插入排序需要进行 n*(n-1)/2 次比较,时间复杂度为 O(n^2)。由于插入排序每次只移动相邻元素,因此是一种稳定的排序算法。

4.1.6希尔排序⭐⭐⭐⭐

希尔排序是插入排序的一种改进版本,也称为缩小增量排序。它通过将待排序序列划分为若干个子序列,并对子序列进行插入排序,从而实现对整个序列的排序。希尔排序的主要思想是使数组中任意间隔为 h 的元素都是有序的。具体步骤如下:

  1. 选择一个增量序列(通常是 n/2,n/4,…,1),将待排序序列分为多个子序列,每个子序列包含间隔为增量的元素。
  2. 对每个子序列进行插入排序,即将子序列中的元素依次插入到已排序部分的合适位置。
  3. 逐步减小增量,重复以上步骤,直到增量为 1,此时对整个序列进行一次插入排序,完成排序。

希尔排序的关键在于选择增量序列和对子序列的插入排序。增量序列的选择对排序效率有很大影响,一般而言,增量序列的最后一个元素应该是 1,确保最后一次排序是一次插入排序,从而使整个序列最终有序。

下面是希尔排序的C++实现代码:

void shellSort(int arr[], int n) {
    // 初始增量设置为数组长度的一半
    for (int gap = n / 2; gap > 0; gap /= 2) {
        // 对每个子序列进行插入排序
        for (int i = gap; i < n; ++i) {
            // 将 arr[i] 插入到已排序部分的合适位置
            int temp = arr[i];
            int j = i;
            while (j >= gap && arr[j - gap] > temp) {
                arr[j] = arr[j - gap];
                j -= gap;
            }
            arr[j] = temp;
        }
    }
}

希尔排序的时间复杂度取决于增量序列的选择,一般情况下时间复杂度在 O(nlogn) 到 O(n^2) 之间。希尔排序是一种不稳定的排序算法,因为在插入排序的过程中可能会改变相等元素的相对位置。

4.1.7归并排序⭐⭐⭐⭐

归并排序是一种高效的排序算法,基于分治思想。它将待排序序列分成两个子序列,分别对两个子序列进行排序,然后将两个有序子序列合并成一个有序序列。具体步骤如下:

  1. 分解:将待排序序列递归地分解成两个子序列,直到每个子序列只有一个元素为止。
  2. 合并:将两个有序子序列合并成一个有序序列,合并过程中按照顺序将元素从两个子序列中取出比较,较小(或较大)的元素先放入临时数组中,直到其中一个子序列为空。
  3. 递归合并:递归地合并子序列,直到整个序列合并完成。

归并排序的关键在于合并两个有序子序列的过程,该过程是稳定的,可以保证排序的稳定性。

下面是归并排序的C++实现代码:

// 合并两个有序子序列
void merge(int arr[], int l, int m, int r) {
    int n1 = m - l + 1;
    int n2 = r - m;

    // 创建临时数组存储两个子序列
    int L[n1], R[n2];
    for (int i = 0; i < n1; ++i) {
        L[i] = arr[l + i];
    }
    for (int j = 0; j < n2; ++j) {
        R[j] = arr[m + 1 + j];
    }

    // 合并两个子序列
    int i = 0, j = 0, k = l;
    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) {
            arr[k] = L[i];
            ++i;
        } else {
            arr[k] = R[j];
            ++j;
        }
        ++k;
    }

    // 将剩余的元素复制到 arr 中
    while (i < n1) {
        arr[k] = L[i];
        ++i;
        ++k;
    }
    while (j < n2) {
        arr[k] = R[j];
        ++j;
        ++k;
    }
}

// 归并排序的递归函数
void mergeSort(int arr[], int l, int r) {
    if (l < r) {
        int m = l + (r - l) / 2;
        // 分别对左右两个子序列进行归并排序
        mergeSort(arr, l, m);
        mergeSort(arr, m + 1, r);
        // 合并两个有序子序列
        merge(arr, l, m, r);
    }
}

// 归并排序入口函数
void mergeSort(int arr[], int n) {
    mergeSort(arr, 0, n - 1);
}

归并排序的时间复杂度始终稳定在 O(nlogn),空间复杂度为 O(n)(需要额外的临时数组来合并子序列)。归并排序是一种稳定的排序算法,因为在合并子序列的过程中,如果两个元素相等,优先将左侧的元素放入临时数组中,保证了相等元素的相对位置不变。

4.1.8快速排序⭐⭐⭐⭐⭐

快速排序是一种高效的排序算法,基于分治思想。它选择一个基准元素,将待排序序列分成两部分,一部分小于基准元素,一部分大于基准元素,然后对这两部分分别进行快速排序,直到整个序列有序为止。具体步骤如下:

  1. 选择基准元素:从待排序序列中选择一个基准元素(通常选择第一个元素),将序列分成两部分。
  2. 分区:将序列中小于基准元素的元素放在基准元素的左边,大于基准元素的元素放在右边,基准元素处于最终位置。
  3. 递归排序:对左右两部分分别递归进行快速排序,直到每个子序列只有一个元素,此时整个序列有序。

快速排序的关键在于分区过程,即如何将待排序序列分成两部分。通常采用的方法是使用双指针,一个指针从左往右扫描,一个指针从右往左扫描,当两个指针相遇时停止,交换相遇位置的元素,然后继续进行扫描,直到两个指针相遇。

下面是快速排序的C++实现代码:

// 分区函数,返回基准元素的最终位置
int partition(int arr[], int low, int high) {
    // 选择第一个元素作为基准元素
    int pivot = arr[low];
    int i = low + 1; // 左指针
    int j = high;    // 右指针
    while (true) {
        // 左指针向右移动,找到第一个大于基准元素的元素
        while (i <= j && arr[i] <= pivot) ++i;
        // 右指针向左移动,找到第一个小于基准元素的元素
        while (i <= j && arr[j] >= pivot) --j;
        // 如果左右指针相遇,则分区完成
        if (i >= j) break;
        // 交换左右指针所指向的元素
        std::swap(arr[i], arr[j]);
    }
    // 将基准元素交换到正确的位置
    std::swap(arr[low], arr[j]);
    return j; // 返回基准元素的最终位置
}

// 快速排序的递归函数
void quickSort(int arr[], int low, int high) {
    if (low < high) {
        // 分区并获取基准元素的位置
        int pivotIndex = partition(arr, low, high);
        // 对基准元素左右两侧的子序列进行递归排序
        quickSort(arr, low, pivotIndex - 1);
        quickSort(arr, pivotIndex + 1, high);
    }
}

// 快速排序入口函数
void quickSort(int arr[], int n) {
    quickSort(arr, 0, n - 1);
}

快速排序的时间复杂度在平均情况下为 O(nlogn),在最坏情况下(序列已经有序或基本有序)为 O(n^2)。快速排序是一种不稳定的排序算法,因为在分区过程中可能会改变相等元素的相对位置。

4.1.9快排的partition函数与归并的Merge函数⭐⭐⭐

快速排序的 partition 函数和归并排序的 merge 函数都是用于将待排序序列分成两部分并进行合并的关键步骤,但它们的实现方式和作用略有不同。

  1. 快速排序的 partition 函数

    • 作用:partition 函数用于将待排序序列分成两部分,左边部分的元素都小于等于基准元素,右边部分的元素都大于等于基准元素,并返回基准元素的最终位置。
    • 实现方式:一般采用双指针法,在待排序序列中选择一个基准元素,然后使用两个指针(左指针和右指针)分别从序列的两端开始向中间移动,当两个指针相遇时停止,交换相遇位置的元素,然后继续移动指针,直到左右指针相遇,此时将基准元素交换到正确的位置,并返回基准元素的最终位置。
  2. 归并排序的 merge 函数

    • 作用:merge 函数用于将两个有序子序列合并成一个有序序列。
    • 实现方式:一般采用双指针法,分别从两个有序子序列的开头开始比较元素,并按顺序将较小(或较大)的元素放入临时数组中,直到其中一个子序列被合并完毕,然后将剩余的元素依次放入临时数组中,完成合并。

虽然 partition 函数和 merge 函数在实现方式和作用上有所不同,但它们都是快速排序和归并排序的关键步骤,负责将待排序序列分成两部分并进行合并,从而实现排序的目的。

4.2 STL库相关

4.2.1 vector list异同⭐⭐⭐⭐⭐

std::vectorstd::list 都是 C++ 标准库提供的容器,但它们在实现和使用上有一些不同之处。

相同点:

  1. 都是容器类: 两者都是用于存储元素的容器类,可以存储各种类型的数据。
  2. 动态大小: 两者都是动态大小的容器,可以根据需要动态地增加或减少元素的数量。
  3. 迭代器支持: 两者都提供了迭代器,可以用于遍历容器中的元素。
  4. 标准库容器: std::vectorstd::list 都是 C++ 标准库中的容器,具有标准的接口和语义。

不同点:

  1. 底层实现:

    • std::vector 使用数组作为底层数据结构,元素在内存中是连续存储的,支持随机访问,但在插入和删除元素时可能需要移动其他元素。
    • std::list 使用双向链表作为底层数据结构,元素在内存中不是连续存储的,不支持随机访问,但在任意位置插入和删除元素的时间复杂度为 O(1)。
  2. 随机访问:

    • std::vector 支持随机访问,可以通过下标直接访问元素,时间复杂度为 O(1)。
    • std::list 不支持随机访问,无法通过下标直接访问元素,只能通过迭代器遍历访问元素,时间复杂度为 O(n)。
  3. 插入和删除操作的效率:

    • std::vector 在尾部插入和删除元素的时间复杂度为 O(1),在中间或头部插入和删除元素的时间复杂度为 O(n)。
    • std::list 在任意位置插入和删除元素的时间复杂度都为 O(1)。
  4. 空间占用:

    • std::vector 由于元素在内存中是连续存储的,因此可能会有一定的空间浪费,但是空间利用率相对较高。
    • std::list 由于元素在内存中是通过指针连接的,因此不会出现空间浪费,但是每个元素都需要额外的指针空间。

根据实际需求和操作的特点,选择合适的容器可以提高代码的效率和性能。如果需要频繁地在容器的中间位置进行插入和删除操作,并且不需要随机访问元素,可以选择 std::list;如果需要频繁地进行随机访问,并且插入和删除操作相对较少,可以选择 std::vector

4.2.2 vector内存是怎么增长的vector的底层实现⭐⭐⭐⭐

std::vector 的底层实现通常基于动态数组,其内存增长策略是在容器内部维护一个动态分配的数组,当向 vector 添加元素时,如果当前数组的容量不足,就会重新分配一块更大的内存空间,并将原有元素拷贝到新的内存空间中,然后将新元素添加到数组的末尾。为了避免频繁的内存重新分配,vector 在分配新的内存空间时通常会预留一定的额外容量。

具体而言,std::vector 的内存增长策略一般是按照当前容量的某个倍数进行增长,常见的增长因子有 2 倍和 1.5 倍。当需要增加元素时,如果当前容量不足,则根据增长因子重新分配一块更大的内存空间,并将原有元素拷贝到新的内存空间中,然后添加新元素。这种策略可以保证 vector 的添加操作的平均时间复杂度为 O(1)。

值得注意的是,由于 vector 使用动态数组作为底层实现,在频繁添加元素时可能会触发内存重新分配和元素拷贝,导致性能损失。因此,在需要频繁添加大量元素的情况下,建议预先设置 vector 的初始容量,以减少内存重新分配的次数,提高性能。

4.2.3 vector和deque的比较⭐⭐⭐⭐

std::vectorstd::deque 都是 C++ 标准库提供的容器,它们在某些方面有相似之处,但在实现和使用上也存在一些不同之处。

相似之处:

  1. 动态大小: 两者都是动态大小的容器,可以根据需要动态地增加或减少元素的数量。
  2. 迭代器支持: 两者都提供了迭代器,可以用于遍历容器中的元素。
  3. 标准库容器: std::vectorstd::deque 都是 C++ 标准库中的容器,具有标准的接口和语义。

不同之处:

  1. 底层实现:

    • std::vector 使用数组作为底层数据结构,元素在内存中是连续存储的,支持随机访问,但在插入和删除元素时可能需要移动其他元素。
    • std::deque 使用分段数组(即双端队列)作为底层数据结构,元素在内存中不是连续存储的,支持高效的头部和尾部插入和删除操作,但不支持随机访问。
  2. 随机访问:

    • std::vector 支持随机访问,可以通过下标直接访问元素,时间复杂度为 O(1)。
    • std::deque 不支持随机访问,无法通过下标直接访问元素,只能通过迭代器遍历访问元素,时间复杂度为 O(1)。
  3. 插入和删除操作的效率:

    • std::vector 在尾部插入和删除元素的时间复杂度为 O(1),在中间或头部插入和删除元素的时间复杂度为 O(n)。
    • std::deque 在头部和尾部插入和删除元素的时间复杂度为 O(1),在中间插入和删除元素的时间复杂度为 O(sqrt(n))。
  4. 空间占用:

    • std::vector 由于元素在内存中是连续存储的,因此可能会有一定的空间浪费,但是空间利用率相对较高。
    • std::deque 由于元素在内存中是通过多个分段数组连接的,因此不会出现空间浪费,但是每个分段数组都需要额外的指针空间。

根据实际需求和操作的特点,选择合适的容器可以提高代码的效率和性能。如果需要频繁地在容器的头部和尾部进行插入和删除操作,并且不需要随机访问元素,可以选择 std::deque;如果需要频繁地进行随机访问,并且插入和删除操作相对较少,可以选择 std::vector

4.2.4为什么stl里面有sort函数list里面还要再定义一个sort⭐⭐⭐

在STL(标准模板库)中,确实有 std::sort 函数用于对序列进行排序,而且 std::list 也提供了自己的 sort 成员函数。这两者之间的区别在于它们适用的情况和实现方式。

  1. std::sort 函数:

    • std::sort 是一个通用的排序函数,可以用于对任何支持随机访问的容器(如 std::vectorstd::array)进行排序。
    • std::sort 使用的是快速排序(或者在一些情况下可能使用堆排序或归并排序)算法,它的时间复杂度为 O(nlogn),是一种比较高效的排序算法。
    • 对于随机访问容器,使用 std::sort 函数是一个不错的选择,因为它可以直接对容器的元素进行排序,而且排序效率较高。
  2. std::list::sort 成员函数:

    • std::list::sortstd::list 容器自己提供的成员函数,用于对链表容器进行排序。
    • std::list::sort 使用的是归并排序算法,它的时间复杂度为 O(nlogn),在链表这种数据结构中,归并排序比快速排序更适合,因为它可以在链表中进行高效的合并操作。
    • 对于链表容器,使用 std::list::sort 成员函数是一个不错的选择,因为它可以利用链表的特性进行排序,排序效率较高。

虽然 std::sortstd::list::sort 都可以对容器进行排序,但它们的实现方式和适用情况有所不同。对于随机访问容器,推荐使用 std::sort 函数;对于链表容器,推荐使用 std::list::sort 成员函数。

4.2.5 STL底层数据结构实现⭐⭐⭐⭐

STL(标准模板库)中的各种容器和算法的底层实现是由C++标准库提供的,通常是由C++标准委员会确定的,并且在不同的标准库实现中可能会有所不同。以下是一些常见的STL容器和算法的底层实现方式:

  1. std::vector 底层通常使用动态数组实现,即通过连续的内存块存储元素,并且在需要时动态扩展容量。当容量不足时,会重新分配一块更大的内存,并将原有元素拷贝到新的内存空间中。这样的实现方式能够提供较好的随机访问性能,但在插入和删除元素时可能需要移动其他元素。

  2. std::list 底层通常使用双向链表实现,即每个节点包含指向前一个节点和后一个节点的指针。这种实现方式能够提供较好的插入和删除性能,但访问元素的随机性能较差。

  3. std::deque 底层通常使用分段数组(即双端队列)实现,即由多个连续的内存块组成,并通过指针连接起来。这种实现方式能够提供较好的头部和尾部插入和删除性能,但不支持随机访问。

  4. std::mapstd::set 底层通常使用平衡二叉搜索树(例如红黑树)实现,以保持元素的有序性和高效的查找性能。这种实现方式能够提供较好的插入、删除和查找性能,但可能会占用较多的内存空间。

  5. std::unordered_mapstd::unordered_set 底层通常使用哈希表实现,即通过哈希函数将元素映射到数组的某个位置,并使用链表或其他解决冲突的方法来处理哈希冲突。这种实现方式能够提供较好的插入、删除和查找性能,但可能会占用较多的内存空间,并且无法保持元素的有序性。

除了这些容器之外,STL还提供了各种算法(例如排序算法、查找算法等)的实现,这些算法通常是基于泛型编程的思想,可以适用于各种不同类型的容器。这些算法的底层实现方式通常是由C++标准库提供的,并且在不同的标准库实现中可能会有所不同。

4.2.6利用迭代器删除元素会发生什么?⭐⭐⭐⭐

在STL中,使用迭代器删除元素通常会导致迭代器失效,这可能会引发未定义的行为或错误。具体来说,如果你在遍历容器的同时删除元素,那么删除操作可能会使得原来指向被删除元素的迭代器失效,导致迭代器指向的内存位置变得不可预测,从而产生错误。这是因为删除操作可能会改变容器的内部结构,使得原来的迭代器不再有效。

为了避免这种情况,通常建议在使用迭代器遍历容器时,不要在遍历过程中对容器进行修改操作,包括插入、删除等。如果需要删除容器中的元素,可以先记录需要删除元素的位置,然后在遍历完成后再进行删除操作,或者使用迭代器的 erase 成员函数来安全地删除元素。

以下是一种安全删除元素的方法示例:

std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int>::iterator it = vec.begin();

while (it != vec.end()) {
    if (*it % 2 == 0) {
        it = vec.erase(it); // 删除偶数元素,并更新迭代器
    } else {
        ++it; // 指向下一个元素
    }
}

在这个示例中,我们先遍历容器,找到需要删除的偶数元素,然后调用 erase 函数安全地删除元素,并更新迭代器指向下一个位置。这样就可以安全地删除元素而不会导致迭代器失效。

4.2.7 map是如何实现的,查找效率是多少⭐⭐⭐⭐⭐

std::map 是 C++ 标准库提供的关联容器,底层通常使用平衡二叉搜索树(例如红黑树)实现。平衡二叉搜索树是一种自平衡的二叉树结构,保持了有序性,使得元素在插入和删除时能够保持树的平衡,从而保证了查找、插入和删除操作的时间复杂度为 O(log n)。

平衡二叉搜索树具有以下特点:

  • 每个节点都有一个关键字,并且左子树中所有节点的关键字都小于根节点的关键字,右子树中所有节点的关键字都大于根节点的关键字。
  • 每个节点都维护一个平衡因子,用来保持树的平衡,使得左右子树的高度差不超过1。
  • 插入和删除操作会根据元素的关键字进行比较,并按照一定的规则调整树的结构,使得树保持平衡。

由于平衡二叉搜索树保持了有序性,因此可以在 O(log n) 的时间复杂度内完成查找、插入和删除操作。这使得 std::map 能够在大多数情况下提供较高的查找效率。但需要注意的是,在极端情况下,例如树高度不平衡时,操作的时间复杂度可能会接近 O(n),因此在使用 std::map 时需要考虑到这一点,并尽量保持树的平衡。

除了平衡二叉搜索树之外,std::map 的实现也可以使用其他数据结构,例如哈希表等,这取决于具体的标准库实现。在某些情况下,哈希表等数据结构可能会提供更高的查找效率,但也可能会占用更多的内存空间。

4.2.8几种模板插入的时间复杂度 ⭐⭐⭐⭐⭐

在STL中,有多种模板插入的时间复杂度,它们的效率各不相同。以下是常见的几种模板插入操作及其时间复杂度:

  1. 尾部插入(push_back): 将元素插入到容器的尾部,时间复杂度为 O(1)。对于动态数组(例如 std::vector),由于在尾部插入不需要移动其他元素,因此时间复杂度为 O(1)。对于双端队列(例如 std::deque),也可以在尾部以常数时间内插入元素。

  2. 头部插入(push_front): 将元素插入到容器的头部,时间复杂度为 O(n)。对于链表(例如 std::list),在头部插入需要移动其他元素,因此时间复杂度为 O(1)。但对于动态数组(例如 std::vector)和双端队列(例如 std::deque),在头部插入需要移动其他元素,因此时间复杂度为 O(n)。

  3. 任意位置插入(insert): 将元素插入到容器的任意位置,时间复杂度为 O(n)。对于链表(例如 std::list),在任意位置插入元素需要移动其他元素,因此时间复杂度为 O(1)。但对于动态数组(例如 std::vector)和双端队列(例如 std::deque),在任意位置插入元素需要移动其他元素,因此时间复杂度为 O(n)。

  4. 有序插入(insert): 将元素插入到有序容器的合适位置,时间复杂度为 O(log n)。对于红黑树等平衡二叉搜索树实现的容器(例如 std::mapstd::set),在有序容器中插入元素会保持容器的有序性,并且通过平衡树的自调整过程,使得插入的时间复杂度为 O(log n)。

  5. 批量插入(insert): 将多个元素一次性插入到容器中,时间复杂度取决于元素数量和插入位置。对于动态数组(例如 std::vector)和双端队列(例如 std::deque),批量插入的时间复杂度为 O(n),因为需要逐个插入元素。对于链表(例如 std::list),批量插入的时间复杂度也为 O(n)。

总的来说,插入操作的时间复杂度取决于容器的底层实现和插入位置。在选择容器和插入操作时,需要根据实际需求和性能要求进行权衡。

第五章 Linux操作系统常见面试题

5.1 Linux内核相关

5.1.1 Linux内核的组成⭐⭐

Linux内核是操作系统的核心部分,负责管理计算机硬件资源,提供各种系统服务和功能。它主要由以下几个组成部分构成:

  1. 进程管理: Linux内核负责管理进程(Process),包括创建、撤销、调度、同步和通信等。它提供了进程调度、进程间通信和进程管理等功能,保证了多个进程之间的正确协作。

  2. 内存管理: Linux内核负责管理计算机的物理内存和虚拟内存,包括内存分配、回收、页式管理、虚拟内存管理和内存保护等功能。它通过页表、页面置换和内存映射等机制,实现了虚拟内存的管理和地址空间的隔离。

  3. 文件系统: Linux内核提供了文件系统抽象层,负责管理文件和目录,包括文件的创建、打开、关闭、读写、删除等操作。它支持各种文件系统类型,如ext4、FAT、NTFS等,并提供了文件系统缓存、缓冲区管理和文件系统挂载等功能。

  4. 设备驱动: Linux内核包含了大量的设备驱动程序,用于管理和控制计算机的各种硬件设备,包括硬盘、网络接口、显示器、键盘、鼠标等。它提供了统一的设备模型和设备抽象层,使得应用程序可以通过统一的接口访问硬件设备。

  5. 系统调用: Linux内核提供了系统调用接口,允许用户空间程序请求内核执行特权操作,如创建进程、文件操作、网络通信等。它定义了一组标准的系统调用接口,供应用程序使用。

  6. 网络协议栈: Linux内核包含了完整的网络协议栈,支持各种网络协议和网络通信功能,如TCP/IP协议栈、UDP协议栈、IP路由、数据包过滤和网络设备驱动等。它提供了网络通信的基础设施,使得计算机可以通过网络进行通信和数据交换。

以上是Linux内核的主要组成部分,它们共同构成了一个功能强大、稳定可靠的操作系统内核。除了这些组成部分外,Linux内核还包含了许多其他的功能和模块,如定时器、中断处理、安全机制、电源管理、调试和性能监控等。

5.1.2用户空间与内核通信方式有哪些?⭐⭐⭐⭐⭐

用户空间与内核之间的通信是操作系统中至关重要的一部分,它允许用户程序与操作系统内核进行交互,请求服务或传递数据。以下是常见的用户空间与内核通信方式:

  1. 系统调用(System Calls): 系统调用是用户空间程序与内核之间最常用的通信方式之一。通过系统调用,用户程序可以请求内核执行特权操作,如创建进程、文件操作、网络通信等。常见的系统调用有fork()open()read()write()等。

  2. 文件操作(File Operations): 用户程序可以通过文件操作接口与内核进行通信。例如,用户程序可以通过open()read()write()close()等系统调用打开、读取、写入和关闭文件,内核负责管理文件系统和文件缓存。

  3. 设备文件(Device Files): 在Unix-like系统中,设备通常被视为文件,用户程序可以通过设备文件与硬件设备进行通信。例如,用户程序可以打开设备文件/dev/tty来访问终端设备,或者打开设备文件/dev/sda来访问硬盘设备。

  4. 进程间通信(Inter-process Communication,IPC): 用户空间程序之间可以通过IPC机制与内核进行通信。常见的IPC机制包括管道(pipe)、命名管道(named pipe,也称为FIFO)、信号(signal)、消息队列(message queue)、共享内存(shared memory)和套接字(socket)等。

  5. ioctl调用: ioctl() 是一种通用的设备控制接口,用户程序可以通过ioctl()调用向设备驱动程序发送控制命令,实现与内核的通信。通常情况下,ioctl()接口会由用户程序和设备驱动程序共同使用,以完成设备的配置、状态查询和控制等操作。

  6. 内存映射(Memory Mapping): 用户程序可以通过内存映射技术将内核中的某个文件或设备映射到自己的地址空间,然后直接访问映射的内存区域,从而实现与内核的通信。这种通信方式通常用于高性能和大数据量的数据传输。

以上是常见的用户空间与内核通信方式,它们在不同的场景下有不同的应用和特点,用户可以根据实际需求选择合适的通信方式。

5.1.3系统调用read()/write(),内核具体做了哪些事情⭐⭐

当用户程序调用系统调用 read()write() 时,内核会执行一系列操作以完成数据的读取和写入。以下是 read()write() 系统调用的主要步骤:

  1. 参数检查: 内核首先会检查用户程序传递给 read()write() 系统调用的参数是否合法,包括文件描述符、缓冲区指针和数据长度等参数。如果参数不合法,内核会返回错误码并提前结束系统调用。

  2. 文件描述符解析: 内核根据用户程序传递的文件描述符找到对应的文件对象,这个文件对象可能是打开的文件、设备文件或者管道等。内核需要知道要读取或写入的目标文件是什么,以便后续进行操作。

  3. 权限检查: 内核会检查用户程序是否具有对目标文件的合适权限,包括读取权限和写入权限。如果用户程序没有足够的权限,内核会返回相应的错误码。

  4. 缓冲区管理: 对于 write() 调用,内核会将用户程序提供的数据从用户空间缓冲区复制到内核空间缓冲区;而对于 read() 调用,内核会将数据从内核空间缓冲区复制到用户空间缓冲区。这个过程涉及到内核空间和用户空间之间的数据拷贝操作,需要考虑数据的大小、地址对齐等因素。

  5. 数据传输: 内核会根据文件对象的类型和属性,将数据从内核空间的缓冲区传输到实际的设备、文件或者进程中。这个过程可能涉及到硬件操作、文件系统操作或者进程间通信等。

  6. 文件偏移更新: 对于 read()write() 调用,内核会根据文件对象的当前偏移量和读写操作的字节数更新文件偏移量,以便下一次读写操作。

  7. 错误处理: 如果在读写过程中出现了错误,如设备故障、磁盘空间不足等,内核会返回相应的错误码给用户程序。用户程序可以根据错误码进行错误处理或者重试操作。

  8. 返回结果: 当读写操作完成后,内核会返回实际读写的字节数给用户程序,用户程序可以根据返回的字节数判断读写操作是否成功,并根据需要继续进行其他操作。

总的来说,read()write() 系统调用主要涉及到文件描述符解析、权限检查、数据传输和错误处理等步骤,内核会根据用户程序的请求和目标文件的属性执行相应的操作。

5.1.4系统调用的作用⭐⭐⭐⭐⭐

系统调用是用户程序与操作系统内核之间进行通信的一种机制,它的作用非常重要,主要包括以下几个方面:

  1. 访问特权功能: 操作系统内核掌握着计算机系统的底层资源和特权功能,如管理硬件设备、调度进程、分配内存等。用户程序通常无法直接访问这些功能,必须通过系统调用来请求内核执行特权操作。

  2. 提供服务: 操作系统内核为用户程序提供了各种服务和功能,如文件操作、网络通信、进程管理等。用户程序可以通过系统调用请求内核提供这些服务,以完成各种任务和操作。

  3. 提供抽象接口: 系统调用为用户程序提供了一组抽象的接口,屏蔽了底层硬件和操作系统内核的细节,使得用户程序可以更方便、更安全地访问操作系统的功能。

  4. 实现用户空间和内核空间的通信: 系统调用是用户程序与操作系统内核之间进行通信的桥梁,用户程序通过系统调用向内核发出请求,并接收内核的响应。通过系统调用,用户程序可以请求内核执行特定的操作,并传递数据给内核,实现用户空间和内核空间的数据交换。

  5. 提供安全保护: 操作系统内核通过系统调用来执行特权操作,系统调用提供了安全保护机制,限制了用户程序对计算机系统的访问权限。内核会对系统调用的参数进行检查和验证,确保用户程序只能访问其具有权限的资源和功能。

总的来说,系统调用是操作系统提供给用户程序访问特权功能和服务的主要接口,它起着连接用户空间和内核空间的桥梁作用,使得用户程序可以安全、可靠地利用操作系统的功能完成各种任务和操作。

5.1.5内核态,用户态的区别⭐⭐⭐⭐⭐

内核态(也称为系统态、特权态)和用户态是操作系统中两种不同的运行模式,它们之间的主要区别在于所能访问的资源和执行的权限不同。以下是内核态和用户态的主要区别:

  1. 权限级别:

    • 内核态:处于内核态的程序具有最高的权限,可以访问计算机系统的所有资源和功能,执行特权指令,如访问硬件设备、修改内存映射、修改处理器状态等。内核态运行的程序可以执行特权指令,例如设置中断向量、修改页面表、访问IO端口等。
    • 用户态:处于用户态的程序只能访问受限资源,无法直接访问计算机系统的底层资源和特权功能,不能执行特权指令。用户态程序的执行受到操作系统的限制,只能使用操作系统提供的接口和服务。
  2. 执行环境:

    • 内核态:内核态运行在操作系统内核的上下文中,具有最高的优先级和访问权限,可以直接控制计算机硬件和资源。
    • 用户态:用户态运行在用户程序的上下文中,受到操作系统的保护和限制,无法直接控制计算机硬件,必须通过操作系统提供的接口和服务来访问资源。
  3. 特权指令:

    • 内核态:内核态程序可以执行特权指令,如设置中断向量、修改页面表、访问IO端口等。
    • 用户态:用户态程序无法执行特权指令,只能执行普通指令,不能直接操作硬件设备和系统资源。
  4. 处理器状态:

    • 内核态:处理器处于特权级别最高的状态,可以执行所有指令和访问所有内存区域。
    • 用户态:处理器处于受限的状态,只能执行普通指令和访问用户空间的内存区域,无法直接访问内核空间的内存区域。

总的来说,内核态和用户态之间的区别主要在于所能访问的资源和执行的权限不同。内核态具有最高的权限和访问所有资源的能力,而用户态受到操作系统的保护和限制,只能访问受限资源和执行受限指令。

5.1.6 bootloader内核 根文件的关系⭐⭐⭐⭐

bootloader、内核(kernel)和根文件系统(root file system)是构成操作系统启动过程和运行环境的重要组成部分,它们之间的关系如下:

  1. Bootloader(引导加载程序):

    • Bootloader是在计算机开机时首先执行的程序,它负责引导操作系统的加载和启动过程。
    • Bootloader的主要任务是初始化硬件、加载操作系统内核到内存中,并将控制权转交给内核。
    • 常见的Bootloader有GRUB、LILO、UEFI等。
  2. 内核(Kernel):

    • 内核是操作系统的核心部分,负责管理计算机的硬件资源和提供各种系统服务和功能。
    • 内核包括进程管理、内存管理、文件系统、设备驱动等模块,它们共同构成了操作系统的功能框架。
    • 内核通常被编译成一个可执行文件(例如Linux内核的vmlinuz文件),并在启动时由Bootloader加载到内存中运行。
  3. 根文件系统(Root File System):

    • 根文件系统是操作系统的根目录,包含了操作系统运行所需的基本文件和目录结构。
    • 根文件系统通常包括了各种系统工具、配置文件、库文件和用户程序等,用于支持系统的正常运行和用户的日常使用。
    • 根文件系统可以存储在硬盘、固态硬盘、网络存储设备或者内存中,具体的存储介质取决于操作系统的安装方式和配置。

关于它们之间的关系:

  • Bootloader负责在计算机开机时加载内核到内存中,并将控制权转交给内核,然后内核开始初始化系统并加载根文件系统。
  • 内核在启动后会挂载根文件系统到指定的挂载点(通常是/),使得系统可以访问根文件系统中的文件和目录。
  • 根文件系统中包含了操作系统运行所需的基本文件和目录结构,包括系统工具、配置文件、库文件和用户程序等,以支持系统的正常运行和用户的日常使用。

这三者共同构成了操作系统启动过程和运行环境的基础,它们之间密切配合,共同完成了操作系统的加载、初始化和运行过程。

5.1.7 Bootloader多数有两个阶段的启动过程:⭐⭐⭐

是的,大多数Bootloader在启动过程中通常包含两个阶段:

  1. 第一阶段(Stage 1 Bootloader):

    • 第一阶段的Bootloader是在计算机开机时最先执行的程序,通常被存储在引导设备的引导扇区(通常是硬盘的主引导记录或分区引导记录)。
    • 第一阶段的Bootloader的主要任务是进行硬件初始化,并加载第二阶段的Bootloader到内存中执行。由于引导扇区的空间有限,因此第一阶段的Bootloader通常很小,只包含必要的代码和功能。
  2. 第二阶段(Stage 2 Bootloader):

    • 第二阶段的Bootloader是在第一阶段执行完毕后加载到内存中执行的程序,它通常被称为操作系统的真正启动加载程序。
    • 第二阶段的Bootloader负责进一步初始化系统硬件、加载操作系统内核到内存中,并启动操作系统的运行。
    • 第二阶段的Bootloader通常比第一阶段的Bootloader更复杂,包含了更多的功能和代码,如文件系统支持、多引导支持、内核参数设置等。

在Linux系统中,常见的引导加载程序(Bootloader)是GRUB(GRand Unified Bootloader)和LILO(LInux LOader),它们都采用了两个阶段的启动过程。在这个过程中,第一阶段的Bootloader主要负责引导加载第二阶段的Bootloader,而第二阶段的Bootloader则负责加载操作系统内核和根文件系统,完成系统的启动。

5.1.8 linux的内核是由bootloader装载到内存中的?⭐⭐⭐

是的,Linux的内核是由Bootloader加载到内存中的。

在启动过程中,当计算机开机时,BIOS(基本输入/输出系统)或UEFI(统一扩展固件接口)会首先执行,然后根据设定的启动顺序从引导设备(通常是硬盘)的引导扇区加载Bootloader到内存中。Bootloader的主要任务是初始化系统硬件,并加载操作系统内核到内存中执行。

一旦Bootloader加载完成,并将内核加载到内存中,控制权就转移到了内核。内核接着会继续系统的初始化过程,包括进一步初始化硬件、设置系统参数、加载驱动程序和初始化用户空间等操作,最终完成系统的启动过程。

因此,Linux的内核确实是由Bootloader加载到内存中的,Bootloader起到了引导加载操作系统的作用,而内核则是操作系统的核心部分,负责管理计算机系统的各种硬件资源和提供系统服务和功能。

5.1.9为什么需要BootLoader⭐⭐⭐⭐

Bootloader是操作系统启动过程中的重要组成部分,其存在有以下几个主要原因:

  1. 引导操作系统: Bootloader的主要任务是引导加载操作系统内核到内存中执行。在计算机开机时,BIOS或UEFI会将控制权转移到引导设备的引导扇区,然后由Bootloader加载内核到内存中,并开始执行操作系统的初始化过程。

  2. 硬件初始化: Bootloader负责对计算机硬件进行初始化,包括处理器、内存、外设等,以便在操作系统内核加载之前为其提供正确的运行环境。

  3. 系统选择和配置: Bootloader提供了选择不同操作系统或操作系统配置的功能。例如,可以通过Bootloader选择启动不同的操作系统或内核版本,或者配置内核启动参数以满足特定需求。

  4. 加载内核和根文件系统: Bootloader不仅加载操作系统内核到内存中,还负责加载根文件系统和其他必要的文件到内存中,以便操作系统能够正常启动和运行。

  5. 错误处理和修复: Bootloader还提供了一些错误处理和修复功能,如检测并修复启动引导记录的损坏、恢复系统到之前的状态等。

综上所述,Bootloader在操作系统启动过程中扮演着至关重要的角色,它负责引导加载操作系统内核和必要的文件到内存中,并进行硬件初始化和系统配置,从而确保系统能够正常启动和运行。

5.1.10 Linux内核同步方式总结⭐⭐⭐⭐

Linux内核采用多种同步机制来确保多个进程或线程之间的正确协作和共享资源的安全访问。以下是Linux内核中常见的同步方式的总结:

  1. 原子操作(Atomic Operations):

    • 原子操作是不可中断的操作,可以保证在多核处理器上的并发执行时不会被中断,从而避免了竞态条件的发生。
    • 在Linux内核中,原子操作通常由特殊的原子操作函数(如atomic_t类型和相关的宏)实现,用于对整数类型的变量进行原子操作,如加减操作、位操作等。
  2. 自旋锁(Spin Locks):

    • 自旋锁是一种基于忙等待的同步机制,在尝试获取锁时会一直自旋等待,直到获取到锁为止。
    • 在Linux内核中,自旋锁通常由spinlock_t类型表示,通过spin_lock()spin_unlock()等函数来获取和释放锁。
  3. 互斥锁(Mutex Locks):

    • 互斥锁是一种阻塞型的同步机制,当尝试获取锁时如果锁已被占用,则会阻塞当前线程,直到获取到锁为止。
    • 在Linux内核中,互斥锁通常由mutex类型表示,通过mutex_lock()mutex_unlock()等函数来获取和释放锁。
  4. 信号量(Semaphores):

    • 信号量是一种计数器,用于控制对共享资源的访问,可以用于实现进程间的同步和互斥。
    • 在Linux内核中,信号量通常由semaphore类型表示,通过down()up()等函数来获取和释放信号量。
  5. 读写锁(Read-Write Locks):

    • 读写锁是一种特殊的锁,允许多个读操作并发进行,但只允许一个写操作进行,用于提高读操作的并发性能。
    • 在Linux内核中,读写锁通常由rwlock_t类型表示,通过read_lock()read_unlock()write_lock()write_unlock()等函数来获取和释放锁。
  6. 屏障(Barriers):

    • 屏障是一种同步机制,用于确保多个线程在某一点上达到同步,可以分为读写屏障和内存屏障。
    • 在Linux内核中,屏障通常由barrier()等函数表示,用于确保在某一点上的同步操作。

以上是Linux内核中常见的同步方式,它们在不同的场景和需求下有着不同的应用和性能特点,可以根据具体情况选择合适的同步机制来确保程序的正确性和性能。

5.1.11为什么自旋锁不能睡眠 而在拥有信号量时就可以?⭐⭐⭐⭐

自旋锁和信号量是两种不同的同步机制,它们在实现方式和使用场景上有所不同,因此在设计上存在一些差异,其中包括自旋锁不能睡眠而信号量可以睡眠的原因:

  1. 自旋锁的特点:

    • 自旋锁是一种基于忙等待的同步机制,尝试获取锁时会一直自旋等待,直到获取到锁为止。在自旋等待的过程中,线程会一直占用CPU资源,不会主动放弃CPU,因此自旋锁不能睡眠。
    • 自旋锁适用于锁被持有时间较短、线程竞争激烈的情况,因为自旋等待的代价相对较小,不需要进行线程上下文切换。
  2. 信号量的特点:

    • 信号量是一种阻塞型的同步机制,当尝试获取信号量时如果信号量的计数值不足,则会将当前线程置为睡眠状态,直到信号量的计数值满足条件才会被唤醒。
    • 信号量适用于锁被持有时间较长、线程竞争不激烈的情况,因为在睡眠等待的过程中,线程会主动释放CPU资源,避免了不必要的资源浪费。

因此,自旋锁适用于对锁的持有时间较短、线程竞争激烈的情况,而信号量适用于对锁的持有时间较长、线程竞争不激烈的情况。在设计上,自旋锁不能睡眠是因为它的主要目的是为了避免线程上下文切换和提高锁的性能,而信号量可以睡眠是因为它更适合于需要长时间等待的情况,并且能够更好地利用系统资源。

5.1.12 linux下检查内存状态的命令⭐⭐⭐

在Linux系统下,可以使用以下命令来检查内存状态:

  1. free命令:

    • free命令用于显示系统内存使用情况,包括总内存、已用内存、空闲内存、缓存和交换空间等信息。
    • 常用选项:
      • -h:以人类可读的方式显示结果,单位为K、M、G等。
    free -h
    
  2. top命令:

    • top命令是一个动态监视系统进程和系统负载的工具,可以在实时显示中查看系统的内存使用情况。
    • 进入top命令后,按下Shift + M可以按内存使用量排序。
    top
    
  3. htop命令:

    • htop是top的增强版,功能更加强大,可以交互式地查看系统的进程和资源使用情况。
    • 类似于top,按下F6可以按内存使用量排序。
    htop
    
  4. vmstat命令:

    • vmstat命令用于显示系统的虚拟内存统计信息,包括内存、磁盘、CPU等。
    • 常用选项:
      • -s:显示内存使用的详细信息。
    vmstat -s
    

这些命令可以帮助您了解系统的内存使用情况,根据实际需求选择合适的命令来查看内存状态。

5.2 其他操作系统常见面试题

5.2.1大小端的区别以及各自的优点,哪种时候用⭐⭐⭐⭐⭐

大小端(Endian)是指在多字节数据存储时,字节的排列顺序。主要有两种类型:大端字节序和小端字节序。

  1. 大端字节序(Big-Endian):

    • 在大端字节序中,数据的高位字节存储在低地址处,低位字节存储在高地址处。即数据的首字节存储在起始地址。
    • 例如,十六进制数0x12345678在大端字节序中存储为12 34 56 78
  2. 小端字节序(Little-Endian):

    • 在小端字节序中,数据的低位字节存储在低地址处,高位字节存储在高地址处。即数据的末尾字节存储在起始地址。
    • 例如,十六进制数0x12345678在小端字节序中存储为78 56 34 12

各自的优点和应用场景如下:

  • 大端字节序的优点:

    • 直观性强:数值的高位字节存储在内存的低地址处,与数值的高位对应,更符合人类的习惯和直观。
    • 易于阅读和调试:在内存中以十六进制或二进制形式显示时,大端字节序更容易理解和调试。
  • 小端字节序的优点:

    • 内存地址连续性好:数据的末尾字节存储在起始地址,有利于按字节进行操作和处理,例如字符串拼接、内存复制等。
    • 与硬件结构匹配:许多处理器架构(如x86、ARM)采用小端字节序,因此在这些架构上使用小端字节序可以获得更好的性能和兼容性。

选择使用哪种字节序取决于具体的应用需求和平台架构:

  • 对于网络通信和数据存储,通常会明确指定使用的字节序,以确保不同平台之间的数据交换和兼容性。
  • 对于与硬件相关的底层编程和系统级编程,应根据目标平台的字节序选择适当的处理方式,以提高性能和兼容性。
  • 对于应用层编程,可以根据具体情况选择更符合需求和习惯的字节序,例如在x86架构下通常选择小端字节序。

5.2.2 一个程序从开始运行到结束的完整过程(四个过程)⭐⭐⭐⭐⭐

一个程序从开始运行到结束通常可以分为以下四个主要过程:

  1. 加载(Loading):

    • 加载是指操作系统将可执行程序从磁盘加载到内存中的过程。在加载过程中,操作系统会分配内存空间,并将程序的代码段、数据段和堆栈等部分加载到内存中的适当位置。
    • 加载完成后,程序的执行映像已经存在于内存中,操作系统可以开始执行程序。
  2. 链接(Linking):

    • 链接是指将程序中的各个模块(如函数、变量)的引用关系解析并连接起来的过程。这通常包括两个方面:静态链接和动态链接。
    • 静态链接(Static Linking):在编译时将程序中的各个模块直接链接到可执行文件中,生成一个完整的可执行文件。这样的可执行文件可以独立运行,不依赖于外部库文件。
    • 动态链接(Dynamic Linking):在运行时才将程序中需要的外部库文件链接到程序中,生成一个动态链接库(DLL)或共享对象文件(SO),程序在运行时通过动态链接器加载和使用这些库文件。
  3. 执行(Execution):

    • 执行是指操作系统将加载到内存中的程序代码转换成可执行指令,并按照指令序列开始执行程序的过程。在执行过程中,操作系统会分配CPU时间片给程序,并进行上下文切换来实现多任务处理。
    • 程序的执行过程包括了对数据的操作、控制流程的执行以及系统调用等操作。
  4. 终止(Termination):

    • 终止是指程序执行完成或异常退出时的结束过程。当程序执行完成时,操作系统会释放程序占用的内存资源,并将执行结果返回给用户或其他程序;当程序发生异常时,操作系统会捕获异常并进行相应的处理,可能包括打印错误信息、生成核心转储文件等。
    • 在终止过程中,操作系统会回收程序所占用的内存资源,并将程序的执行映像从内存中移除,以便其他程序可以使用这部分内存。

这四个过程构成了程序从开始运行到结束的完整生命周期,每个过程都是程序执行的重要阶段,对于程序的正确性和性能都有重要影响。

5.2.3什么是堆,栈,内存泄漏和内存溢出?⭐⭐⭐⭐

在计算机科学中,堆(Heap)和栈(Stack)是两种用于存储数据的内存区域,而内存泄漏(Memory Leak)和内存溢出(Memory Overflow)则是常见的内存管理问题。

  1. 堆(Heap):

    • 堆是一种动态分配内存的内存区域,用于存储程序运行时动态分配的数据,如对象、数组等。堆的分配和释放由程序员控制,通常通过malloc()calloc()realloc()等函数进行分配,通过free()函数进行释放。
    • 堆中的内存空间是由操作系统动态管理的,程序可以在堆中动态分配和释放内存,但需要注意内存的分配和释放需要手动管理,否则可能会导致内存泄漏或内存溢出。
  2. 栈(Stack):

    • 栈是一种后进先出(LIFO)的内存区域,用于存储程序的局部变量、函数调用信息等。栈的大小是固定的,由操作系统分配,通常在程序运行时就确定了大小。
    • 栈中的内存空间是由编译器自动管理的,程序员无法直接控制栈的分配和释放,栈的分配和释放是在函数调用和返回过程中自动完成的。
  3. 内存泄漏(Memory Leak):

    • 内存泄漏是指程序在动态分配内存后,未能及时释放已分配的内存,导致系统中的可用内存逐渐减少,最终耗尽系统的内存资源。内存泄漏通常发生在程序中频繁动态分配内存的情况下,如果没有正确释放已分配的内存,则可能导致内存泄漏问题。
  4. 内存溢出(Memory Overflow):

    • 内存溢出是指程序试图访问超出其分配的内存空间范围的区域,导致程序运行出错或崩溃的问题。内存溢出通常发生在栈溢出和堆溢出两种情况下:
      • 栈溢出:当程序中的递归调用层数过多或局部变量占用的栈空间过大时,可能会导致栈溢出。
      • 堆溢出:当程序中动态分配的内存空间超过了系统的可用内存大小,可能会导致堆溢出。

内存泄漏和内存溢出都是常见的内存管理问题,对程序的运行稳定性和性能都会产生不利影响。因此,在开发程序时需要注意合理管理内存资源,避免出现内存泄漏和内存溢出的问题。

5.2.4堆和栈的区别⭐⭐⭐⭐⭐

堆(Heap)和栈(Stack)是计算机内存中两种不同的分配方式和管理方式,它们在数据存储、生命周期、分配和释放等方面有着不同的特点和用途。

  1. 数据存储位置:

    • 堆: 堆是动态分配的内存空间,存储在计算机的堆区中。堆中的数据是通过动态分配和释放内存实现的,程序员可以手动控制内存的分配和释放。
    • 栈: 栈是静态分配的内存空间,存储在计算机的栈区中。栈中的数据是通过函数调用和返回过程实现的,由编译器自动管理栈的分配和释放。
  2. 生命周期:

    • 堆: 堆中的数据的生命周期由程序员手动控制,需要显式地分配内存和释放内存。堆中的数据可以在程序的任意时刻进行动态分配和释放。
    • 栈: 栈中的数据的生命周期由程序的函数调用和返回过程决定,局部变量在函数调用时被创建并在函数返回时被销毁。栈中的数据的生命周期是局部的,只能在其所在函数的作用域内访问。
  3. 分配和释放:

    • 堆: 堆中的内存分配和释放是动态的,通常使用malloc()calloc()realloc()等函数进行内存分配,使用free()函数进行内存释放。
    • 栈: 栈中的内存分配和释放是自动的,由编译器自动管理,无需程序员手动干预。在函数调用时分配局部变量的内存,在函数返回时自动释放内存。
  4. 大小和速度:

    • 堆: 堆的大小通常比较大,并且动态分配和释放内存需要时间开销,因此堆的访问速度相对较慢。
    • 栈: 栈的大小通常比较小,并且栈中的数据的分配和释放是自动的,无需额外的时间开销,因此栈的访问速度相对较快。

总的来说,堆和栈在数据存储、生命周期、分配和释放等方面有着明显的区别,程序员在设计和编写程序时需要根据实际需求和情况选择合适的数据存储方式。

5.2.5死锁的原因、条件 创建一个死锁,以及如何预防⭐⭐⭐⭐⭐

死锁是多线程或多进程并发编程中常见的问题,它发生在两个或多个进程(线程)之间,彼此持有对方需要的资源,但又无法释放自己拥有的资源,导致彼此都无法继续执行的情况。死锁的发生通常满足以下四个必要条件,也称为死锁的充分条件:

  1. 互斥条件(Mutual Exclusion): 进程对资源的访问是排他性的,即某个资源同时只能被一个进程占用。
  2. 请求与保持条件(Hold and Wait): 进程已经持有了至少一个资源,并且在请求新的资源时,由于该资源已被其他进程占用而被阻塞。
  3. 不剥夺条件(No Preemption): 资源只能由占有它的进程释放,不能被强行剥夺。
  4. 循环等待条件(Circular Wait): 存在一种进程资源的循环等待链,每个进程都在等待下一个进程所持有的资源。

为了创建一个死锁,需要满足以上四个条件。例如,两个线程分别持有资源A和资源B,并且互相等待对方所持有的资源。当资源A被线程1占用时,线程1尝试获取资源B,但资源B已经被线程2占用;同时,线程2也在等待资源A,造成了循环等待的情况,导致死锁的发生。

为了预防死锁,可以采取以下措施:

  1. 破坏死锁的四个条件:

    • 破坏互斥条件: 资源共享、可重入锁等。
    • 破坏请求与保持条件: 一次性申请所有需要的资源。
    • 破坏不剥夺条件: 允许系统强制抢占资源。
    • 破坏循环等待条件: 统一资源分配顺序、使用资源层级性等。
  2. 使用资源分配图(Resource Allocation Graph): 使用资源分配图来检测潜在的死锁,并在发现死锁时采取相应的措施,如进行资源抢占或进程回滚等。

  3. 避免长时间持有资源: 尽量避免长时间占有资源,及时释放不再需要的资源。

  4. 使用超时机制: 在获取资源时设置超时机制,避免长时间等待。

  5. 使用死锁检测和恢复算法: 定期检测死锁的发生,并采取恢复措施,如资源回收、进程终止等。

通过以上预防措施,可以有效地避免和减少死锁的发生,提高系统的稳定性和可靠性。

5.2.6硬链接与软链接的区别;⭐⭐⭐⭐⭐

硬链接(Hard Link)和软链接(Symbolic Link,也称为符号链接)是操作系统中用于创建文件链接的两种方式,它们有着以下几点区别:

  1. 文件类型:

    • 硬链接: 硬链接是指向文件数据块的物理链接,它与原始文件有相同的inode号,只是文件名不同。在文件系统中,硬链接和原始文件是完全平等的关系。
    • 软链接: 软链接是一个特殊的文件,它包含了指向目标文件的路径信息。软链接类似于Windows系统中的快捷方式,它本身并不包含文件数据,只是指向目标文件的指针。
  2. 跨文件系统支持:

    • 硬链接: 硬链接只能在同一文件系统中创建,因为硬链接是基于inode号来实现的。
    • 软链接: 软链接可以跨越文件系统创建,因为它包含的是文件路径信息,而不是inode号。
  3. 文件删除影响:

    • 硬链接: 删除任何一个硬链接并不会影响其他硬链接或原始文件,只有当所有硬链接都被删除时,才会释放文件的存储空间。
    • 软链接: 删除软链接不会影响目标文件,但如果目标文件被删除或移动,软链接将失效。
  4. 文件大小:

    • 硬链接: 所有硬链接和原始文件共享相同的数据块,它们的文件大小相同。
    • 软链接: 软链接本身很小,只需要存储目标文件的路径信息,因此它的文件大小通常比目标文件小。
  5. 用途:

    • 硬链接: 通常用于创建文件的备份、共享文件、保护重要文件等。
    • 软链接: 通常用于创建符号化路径、跨文件系统引用、指向动态库的指针等。

综上所述,硬链接和软链接在实现方式、跨文件系统支持、影响文件删除等方面有着明显的区别,应根据实际需求选择合适的链接类型。

5.2.7虚拟内存,虚拟地址与物理地址的转换⭐⭐⭐⭐

虚拟内存是计算机操作系统中的一种技术,它允许程序访问比实际物理内存更大的地址空间,并且将物理内存和磁盘存储结合起来,从而实现了更高效的内存管理和更大的程序运行空间。在虚拟内存系统中,虚拟地址和物理地址之间的转换是由操作系统的内存管理单元(MMU,Memory Management Unit)负责完成的,具体过程如下:

  1. 虚拟地址空间: 每个进程都有自己的虚拟地址空间,虚拟地址是进程所见到的地址,通常从0开始逐渐增加。虚拟地址空间的大小取决于操作系统的位数,比如32位系统的虚拟地址空间为2^32字节(4GB)。

  2. 页表: 操作系统维护着一个页表,用于记录虚拟地址和物理地址之间的映射关系。页表将虚拟地址空间划分成大小相等的页面(Page),通常为4KB或者更大。每个页面都对应着物理内存中的一个页面帧(Page Frame)。

  3. 地址转换: 当程序访问一个虚拟地址时,MMU会将虚拟地址通过页表转换为对应的物理地址,然后访问物理内存中的数据。

  4. 缺页异常: 如果虚拟地址对应的页面不在物理内存中,即页面失效(Page Fault),此时会触发缺页异常。操作系统会根据页表中的信息从磁盘中加载缺失的页面到物理内存中,并更新页表中的映射关系。

  5. 页面替换: 如果物理内存中的页面不足以容纳所有进程的页面,操作系统需要进行页面替换(Page Replacement),选择一个页面将其从物理内存中换出到磁盘,然后将所需的页面从磁盘加载到物理内存中。

总的来说,虚拟内存系统通过将虚拟地址空间映射到物理地址空间,实现了程序对比实际物理内存更大的访问范围,同时也提高了内存的利用率和系统的性能。地址转换的过程由操作系统的内存管理单元负责,包括页表的维护、缺页异常的处理和页面替换等操作。

5.2.8计算机中,32bit与64bit有什么区别⭐⭐⭐

32位(32-bit)和64位(64-bit)是指计算机处理器的数据总线宽度和寄存器的位数,它们代表了计算机系统的架构。这两种架构之间有以下几点区别:

  1. 地址空间大小:

    • 32位系统: 32位系统的寻址空间为 2 32 2^{32} 232(4GB),即可以寻址的内存地址数量为约42亿个。
    • 64位系统: 64位系统的寻址空间为 2 64 2^{64} 264(16EB),即可以寻址的内存地址数量为约18亿亿个。
  2. 寻址能力:

    • 32位系统: 32位系统每次能够处理32位的数据,因此能够处理的最大整数为 2 32 − 1 2^{32}-1 2321,约为42亿。
    • 64位系统: 64位系统每次能够处理64位的数据,因此能够处理的最大整数为 2 64 − 1 2^{64}-1 2641,约为18亿亿。
  3. 内存支持:

    • 32位系统: 32位系统最多只能寻址4GB的内存空间,因此无法充分利用大于4GB的内存。
    • 64位系统: 64位系统能够支持远远超过4GB的内存空间,可以充分利用现代计算机提供的大容量内存。
  4. 性能提升:

    • 64位系统: 64位系统在处理大数据集和运行大型应用程序时通常能够提供更好的性能和效率,因为它能够一次处理更多的数据。
  5. 兼容性:

    • 32位系统: 32位系统无法运行64位的应用程序,但可以运行32位和16位的应用程序。
    • 64位系统: 64位系统可以运行32位和64位的应用程序,具有更好的兼容性。

总的来说,64位系统相比于32位系统具有更大的地址空间、更高的寻址能力、更好的内存支持和更好的性能,能够处理更大的数据量和运行更大型的应用程序。因此,现代计算机系统普遍采用64位架构。

5.2.9中断和异常的区别⭐⭐⭐⭐⭐

中断(Interrupt)和异常(Exception)是计算机系统中两种不同的事件,它们在发生时会中断当前正在执行的程序,并且需要由操作系统进行处理。它们之间的主要区别在于产生原因和处理方式:

  1. 产生原因:

    • 中断: 中断是由外部设备或者其他处理器发出的信号,用于通知处理器需要进行处理。中断可以是硬件中断(如IO设备的就绪信号)或者软件中断(如系统调用)。
    • 异常: 异常是由于当前执行指令引发了不正常的情况,例如除零、越界访问、非法指令等。异常是由处理器内部产生的,通常是由程序错误或者系统故障引起的。
  2. 处理方式:

    • 中断: 中断的处理是由处理器接收到中断信号后,立即中断当前执行的程序,并转移到中断处理程序(Interrupt Service Routine,ISR)进行处理。处理完成后,控制权会返回到原程序继续执行。
    • 异常: 异常的处理是在当前指令执行异常后,处理器会根据异常类型跳转到相应的异常处理程序(Exception Handler)进行处理。异常处理程序可以采取不同的处理策略,例如终止程序、恢复程序状态、报告错误等。
  3. 响应时间:

    • 中断: 中断的响应时间通常较快,因为处理器可以立即响应外部设备或者其他处理器发出的中断信号。
    • 异常: 异常的响应时间较长,因为异常通常是由于程序错误或者系统故障引起的,需要一定时间来识别和处理。

总的来说,中断和异常都是计算机系统中用于处理特定事件的机制,它们有着不同的产生原因和处理方式,但都是由操作系统负责管理和处理的。

5.2.10中断怎么发生,中断处理大概流程⭐⭐⭐⭐

中断是计算机系统中一种重要的事件处理机制,它可以由外部设备或者软件发出,用于通知处理器需要进行特定的处理。中断的发生和处理过程大致如下:

  1. 中断发生: 中断可以由外部设备(硬件中断)或者软件(软件中断)发出。外部设备发送中断请求信号,通知处理器需要进行处理,例如IO设备就绪、定时器到期等。软件中断是由程序或者操作系统发出的中断请求,通常用于系统调用或者异常处理。

  2. 中断响应: 当处理器接收到中断请求信号时,会立即中断当前正在执行的程序,并转移到中断处理程序(Interrupt Service Routine,ISR)进行处理。中断的响应时间通常很短,处理器会尽快中断当前任务,以便处理中断请求。

  3. 保存现场: 在转移执行到ISR之前,处理器会保存当前执行程序的上下文(Context),包括程序计数器(PC)、寄存器值、标志位等重要状态信息。这样做是为了在中断处理完成后能够恢复原来的执行状态。

  4. 执行中断处理程序: 处理器转移到ISR后,开始执行中断处理程序。ISR是事先预先编写好的特定功能的程序,用于处理特定类型的中断请求。ISR会根据中断类型进行相应的处理,例如读取IO设备的数据、更新系统状态、调度其他任务等。

  5. 中断处理完成: 中断处理程序执行完成后,处理器会根据需要恢复之前保存的执行上下文,并将控制权返回到原来的执行程序,继续执行中断之前的任务。

总的来说,中断的发生和处理是计算机系统中一种重要的事件处理机制,它可以及时响应外部设备或者软件的请求,并通过中断处理程序进行相应的处理,保证系统的正常运行和高效工作。

5.2.11 Linux 操作系统挂起、休眠、关机相关命令⭐⭐

在Linux系统中,挂起(Suspend)、休眠(Hibernate)和关机(Shutdown)是常见的操作,可以通过以下命令进行操作:

  1. 挂起:

    • 挂起操作通常用于让计算机暂时进入低功耗状态,以节省电力。
    • 命令:sudo systemctl suspend
  2. 休眠:

    • 休眠操作将计算机的当前状态保存到磁盘中,然后关闭电源,待唤醒时可以恢复到之前的状态。
    • 命令:sudo systemctl hibernate
  3. 关机:

    • 关机操作用于完全关闭计算机的电源。
    • 命令:sudo shutdown now 或者 sudo poweroff

在执行以上命令时,通常需要具有足够的权限(通常是root权限)才能执行。另外,某些Linux发行版可能有自己特定的挂起、休眠、关机命令,可以根据实际情况进行选择使用。

5.2.12数据库为什么要建立索引,以及索引的缺点⭐⭐

数据库中建立索引是为了提高数据的检索速度和查询效率。索引类似于书籍的目录,可以快速定位到数据所在的位置,从而加快了数据的检索过程。具体来说,建立索引的好处包括:

  1. 加速数据检索: 索引可以将数据按照某种顺序进行排序,并创建一个索引结构,使得数据库系统能够更快速地定位到数据所在的位置,从而加速数据的检索速度。

  2. 提高查询效率: 对于经常需要进行查询操作的字段,通过建立索引可以大大减少查询所需的时间,提高查询效率,特别是对于大型数据集和复杂查询语句。

  3. 加速排序和连接操作: 索引不仅可以加速单表查询,还可以加速排序和连接操作,提高数据库系统的整体性能。

尽管索引可以提高数据检索和查询效率,但也存在一些缺点和注意事项:

  1. 占用额外存储空间: 索引需要额外的存储空间来存储索引结构,特别是对于大型数据集和多个索引字段的情况,可能会占用大量的存储空间。

  2. 影响写操作性能: 对于有索引的表进行插入、更新和删除操作时,除了要更新数据本身外,还需要更新索引结构,这可能会影响写操作的性能。

  3. 维护成本高: 索引需要定期维护和更新,特别是在数据量增大或者数据分布发生变化时,需要重新构建索引以保证索引的有效性。

  4. 过多的索引会降低性能: 过多的索引会增加数据库系统的负担,导致查询优化器难以选择合适的索引,从而降低整体性能。

综上所述,虽然建立索引可以提高数据库的检索速度和查询效率,但也需要权衡索引的缺点和成本,合理选择建立索引的字段和类型,避免不必要的索引,以提高数据库系统的整体性能。

第六章 单片机常见面试题

6.1 CPU 内存 虚拟内存 磁盘/硬盘 的关系⭐⭐⭐

CPU(Central Processing Unit,中央处理器)、内存(Memory)、虚拟内存和磁盘/硬盘(Disk/Hard Drive)在计算机系统中扮演着不同的角色,它们之间的关系如下:

  1. CPU: CPU是计算机系统的核心部件,负责执行计算机程序中的指令,控制和协调各种计算机硬件设备的工作。

  2. 内存: 内存是用于存储计算机程序和数据的临时存储器,也称为随机存取存储器(Random Access Memory,RAM)。CPU需要从内存中读取指令和数据,并将计算结果写回内存。内存的速度比磁盘快得多,但容量有限,且断电后数据会丢失。

  3. 虚拟内存: 虚拟内存是一种扩展内存的技术,通过将部分存储器空间作为磁盘上的虚拟存储器使用,来扩展内存的容量。当内存不足时,操作系统会将部分暂时不需要的数据和程序存储到磁盘上,从而释放内存空间给其他程序使用。虚拟内存的存在使得计算机系统能够运行更大的程序和处理更多的数据。

  4. 磁盘/硬盘: 磁盘/硬盘是用于永久存储数据的设备,也称为持久性存储器。磁盘的容量通常比内存大得多,但访问速度比内存慢得多。磁盘上存储的数据是持久的,即使断电后数据也不会丢失。

关系:

  • CPU和内存之间通过总线进行数据和指令的传输,CPU通过读取内存中的数据和程序来执行计算任务。
  • 内存和虚拟内存之间通过操作系统的虚拟内存管理机制进行数据交换,使得计算机系统能够运行更大的程序。
  • 磁盘/硬盘用于长期存储数据,提供了永久性存储的功能,同时也用作虚拟内存的备用存储空间。

综上所述,CPU、内存、虚拟内存和磁盘/硬盘在计算机系统中扮演着不同的角色,通过各自的功能和交互,共同组成了完整的计算机系统,实现了数据的存储、处理和持久化。

6.2 CPU内部结构⭐⭐⭐⭐

CPU(Central Processing Unit,中央处理器)是计算机系统的核心部件,负责执行计算机程序中的指令,控制和协调各种计算机硬件设备的工作。CPU的内部结构通常包括以下几个主要组成部分:

  1. 控制单元(Control Unit,CU): 控制单元负责控制整个CPU的操作,包括指令的解码和执行、数据的传输和处理、以及各个功能模块之间的协调和同步。控制单元根据程序计数器(Program Counter,PC)中存储的地址,从内存中读取指令,并按照指令序列执行相应的操作。

  2. 算术逻辑单元(Arithmetic Logic Unit,ALU): 算术逻辑单元负责执行CPU的算术运算和逻辑运算,包括加法、减法、乘法、除法等算术运算,以及与、或、非等逻辑运算。ALU接收来自寄存器的数据,并根据指令中的操作码执行相应的运算操作。

  3. 寄存器(Registers): 寄存器是CPU内部用于存储临时数据和控制信息的存储器单元,包括程序计数器(PC)、指令寄存器(Instruction Register,IR)、累加器(Accumulator)、数据寄存器等。寄存器的存在可以提高数据的访问速度和操作效率,减少了对内存的访问次数。

  4. 时钟(Clock): 时钟是CPU内部的一个计时器,用于控制CPU的工作节奏和时序。CPU根据时钟信号进行同步操作,按照时钟周期(Clock Cycle)的节奏执行指令和操作。时钟周期的频率决定了CPU的工作速度,通常以赫兹(Hz)为单位表示。

  5. 缓存(Cache): 缓存是CPU内部用于临时存储数据的高速缓存存储器,通常分为多级缓存(L1 Cache、L2 Cache、L3 Cache等)。缓存的存在可以提高CPU对数据的访问速度,减少了对内存的访问延迟,从而提高了系统的整体性能。

以上是CPU内部结构的基本组成部分,不同的CPU型号和架构可能会有所差异,但总体上都包括了控制单元、算术逻辑单元、寄存器、时钟和缓存等核心组件,以及与外部设备进行数据交换的接口和控制逻辑。

6.3 ARM结构处理器简析 ⭐⭐

ARM(Advanced RISC Machine,高级精简指令集计算机)是一种基于精简指令集(RISC)架构的处理器设计,广泛应用于移动设备、嵌入式系统和消费类电子产品等领域。以下是对ARM结构处理器的简析:

  1. RISC架构: ARM处理器采用了精简指令集计算机(RISC)的设计理念,即指令集简单且执行速度快,指令长度固定,执行周期短。这使得ARM处理器在相同频率下能够实现更高的性能和更低的功耗。

  2. 架构版本: ARM处理器有多个架构版本,包括ARMv6、ARMv7和ARMv8等。每个版本都有不同的特性和功能,适用于不同的应用场景和需求。例如,ARMv8架构支持64位指令集,提供了更高的性能和更大的内存寻址空间。

  3. 指令集: ARM处理器的指令集包括数据处理指令、分支指令、加载/存储指令等。ARM指令集的设计简洁高效,能够充分利用硬件资源,提高程序执行效率。

  4. 处理器核心: ARM处理器的核心包括单核、多核和异构核等,能够满足不同的性能需求和功耗限制。例如,Cortex-A系列核心用于高性能计算和多任务处理,而Cortex-M系列核心则用于低功耗、实时性要求高的嵌入式系统。

  5. 架构特性: ARM架构具有诸多特性,如支持内存保护和虚拟内存、支持多种中断和异常处理机制、提供丰富的外设接口和扩展功能等,为系统设计提供了灵活性和可靠性。

  6. 生态系统: ARM生态系统庞大且成熟,包括了各种软件工具、开发环境、操作系统和第三方库等,为开发者提供了丰富的资源和支持,使得基于ARM处理器的系统开发变得更加便捷和高效。

综上所述,ARM结构处理器以其简洁高效的设计理念、灵活多样的架构特性和丰富成熟的生态系统,在移动设备、嵌入式系统和消费类电子产品等领域拥有广泛的应用和市场份额。

6.4波特率是什么,为什么双方波特率要相同,高低波特率有什么区别;⭐⭐⭐⭐

波特率(Baud Rate)是指串行通信中单位时间内传输的比特数,通常以每秒传输的比特数(bits per second,bps)来表示。波特率描述了数据传输的速度,是串行通信中非常重要的参数之一。

双方通信的波特率需要相同,是因为波特率决定了数据传输的速度,如果发送方和接收方的波特率不一致,会导致数据传输时接收方无法正确解析发送方发送的数据,从而导致通信失败或者数据错误。因此,在进行串行通信时,双方必须事先协商好波特率,并确保波特率的设置一致。

在高低波特率方面,一般来说,高波特率可以实现更快的数据传输速度,适用于需要快速传输大量数据的场景,但同时也需要更高的硬件要求和更好的信号传输质量。而低波特率则可以减少通信时的电磁干扰和误码率,适用于对数据传输速度要求不高或者信号传输质量要求较高的场景。

总的来说,选择高低波特率应根据具体的通信需求和实际情况来决定,在保证通信质量的前提下尽可能选择更高的波特率以提高数据传输效率。

6.5arm和dsp有什么区别⭐⭐

ARM(Advanced RISC Machine)和DSP(Digital Signal Processor)是两种不同类型的处理器,它们在设计和应用方面有一些区别:

  1. 架构设计:

    • ARM处理器采用了精简指令集计算机(RISC)的设计理念,指令集简单且执行速度快,适用于通用计算和控制任务。
    • DSP处理器专门设计用于数字信号处理(DSP)应用,具有专门优化的指令集和硬件结构,能够高效地执行信号处理算法和运算。
  2. 应用领域:

    • ARM处理器广泛应用于移动设备、嵌入式系统、消费类电子产品等领域,适用于通用计算和控制任务,如智能手机、平板电脑、物联网设备等。
    • DSP处理器主要用于音频处理、视频处理、图像处理等数字信号处理领域,适用于需要高性能信号处理和算法计算的应用,如音频编解码、语音识别、图像处理等。
  3. 处理能力:

    • ARM处理器在通用计算和控制方面具有较强的处理能力和灵活性,适用于各种不同的应用场景和需求。
    • DSP处理器在数字信号处理方面具有专业的优势和高效的算法执行能力,能够快速处理复杂的信号处理任务。
  4. 开发和生态支持:

    • ARM处理器拥有庞大的生态系统和丰富的开发工具链、开发环境和第三方支持,开发者可以轻松地进行软件开发和系统集成。
    • DSP处理器在数字信号处理领域也有一定的生态系统和开发支持,但相对于ARM处理器来说,其生态系统规模较小。

综上所述,ARM和DSP是两种不同类型的处理器,它们在架构设计、应用领域、处理能力和生态支持等方面有所不同,开发者可以根据具体的应用需求和场景选择合适的处理器类型。

6.6 ROM RAM的概念浅析⭐⭐⭐

ROM(Read-Only Memory,只读存储器)和RAM(Random Access Memory,随机存取存储器)是计算机系统中两种不同类型的存储器,它们在功能和特性上有所不同:

  1. ROM(只读存储器):

    • ROM是一种只读存储器,用于存储固定的程序和数据,通常包括了计算机系统的基本启动程序(如BIOS)、固件(如固件固化的操作系统)等。
    • ROM的内容在制造时被写入,并且一般情况下无法被修改或擦除,因此称为只读存储器。这使得ROM具有较好的稳定性和可靠性,适用于存储不经常更改的固定数据。
  2. RAM(随机存取存储器):

    • RAM是一种随机存取存储器,用于临时存储程序和数据,是计算机系统中的主要工作内存。
    • RAM的内容可以被随时读取和写入,并且在计算机通电时内容会被清空,称为易失性存储器。这使得RAM适用于临时存储程序运行时需要的数据和中间结果。
  3. 特点对比:

    • ROM具有只读、稳定性高的特点,适合存储固定的程序和数据,但无法修改和擦除。
    • RAM具有读写速度快、可修改性高的特点,适合存储临时性的程序和数据,但断电后内容会丢失。

总的来说,ROM和RAM是计算机系统中两种不同类型的存储器,各自具有不同的功能和特点,通常在计算机系统中同时存在,分别用于存储固定的程序和数据以及临时性的程序和数据,共同构成了完整的计算机存储系统。

6.7 IO口工作方式:上拉输入 下拉输入 推挽输出 开漏输出⭐⭐⭐⭐

IO口工作方式指的是IO口(Input/Output Port)在不同工作模式下的表现和功能。常见的IO口工作方式包括上拉输入、下拉输入、推挽输出和开漏输出,它们各自的特点和应用如下:

  1. 上拉输入(Pull-Up Input):

    • 在上拉输入模式下,IO口通过一个上拉电阻连接到电源(通常是VCC),当外部设备不连接时,IO口的电平被上拉至高电平状态(逻辑1)。
    • 当外部设备连接到IO口并且输出低电平(逻辑0)时,外部设备可以将IO口拉低至低电平状态(逻辑0)。
  2. 下拉输入(Pull-Down Input):

    • 在下拉输入模式下,IO口通过一个下拉电阻连接到地(GND),当外部设备不连接时,IO口的电平被下拉至低电平状态(逻辑0)。
    • 当外部设备连接到IO口并且输出高电平(逻辑1)时,外部设备可以将IO口拉高至高电平状态(逻辑1)。
  3. 推挽输出(Push-Pull Output):

    • 在推挽输出模式下,IO口可以输出高电平或低电平,并且具有驱动能力,在输出高电平时输出端口驱动至高电平,输出低电平时输出端口驱动至低电平。
    • 推挽输出常用于驱动外部设备,如LED、电机、继电器等。
  4. 开漏输出(Open-Drain Output):

    • 在开漏输出模式下,IO口只能输出低电平,输出端口驱动至低电平,而在输出高电平时不主动输出高电平,而是高阻态,输出端口由外部上拉电阻拉高至高电平状态。
    • 开漏输出常用于多个设备共享一个信号线的场景,通过外部上拉电阻确保信号线在空闲状态时保持高电平。

综上所述,IO口的工作方式包括上拉输入、下拉输入、推挽输出和开漏输出,每种工作方式具有不同的特点和适用场景,在实际应用中需要根据具体的需求选择合适的工作方式。

6.8扇区 块 页 簇的概念⭐⭐⭐⭐

在计算机存储领域,扇区、块、页和簇是常见的存储单位,它们具有不同的概念和应用场景:

  1. 扇区(Sector):

    • 扇区是存储介质上的最小单位,通常是磁盘或固态硬盘中的最小可寻址单元。每个扇区通常包含512字节或者4KB的数据,用于存储文件系统的数据或者其他信息。
  2. 块(Block):

    • 块是逻辑存储中的一个概念,通常是操作系统或文件系统管理存储空间的最小单位。块的大小可以根据需要调整,通常是多个扇区的大小,以适应不同的应用需求。
  3. 页(Page):

    • 页是固态硬盘(SSD)中的一个概念,用于描述存储芯片中的最小可编程单位。每个页通常包含数千至数百万个字节的数据,用于存储和读取数据。
  4. 簇(Cluster):

    • 簇是文件系统中的一个概念,用于组织存储空间和管理文件。簇的大小可以根据文件系统的格式和存储介质的特性设置,通常由多个扇区或块组成。

这些存储单位在不同的存储设备和文件系统中具有不同的意义和用途,但它们都是用来管理和组织存储空间的基本单位。通过合理地划分和管理这些存储单位,可以提高存储效率、提升数据读写速度,并且延长存储介质的使用寿命。

6.9简述处理器在读内存的过程中,CPU核、cache、MMU如何协同工作?画出CPU核、cache、MMU、内存之间的关系示意图加以说明⭐⭐

处理器在读取内存数据时,CPU核(Central Processing Unit)、高速缓存(Cache)、内存管理单元(Memory Management Unit,MMU)之间协同工作,以提高内存访问效率。下面是它们之间的协同工作过程:

  1. CPU核(Central Processing Unit):

    • CPU核负责执行指令和控制计算机的运行,它发出读取内存数据的指令并接收处理器的结果。
  2. 高速缓存(Cache):

    • 高速缓存是一种位于CPU核和内存之间的临时存储器,用于存储频繁访问的数据和指令,以加速CPU的访问速度。
    • 当CPU核发出读取内存数据的指令时,高速缓存首先检查是否有需要的数据已经存储在缓存中。如果命中(Cache Hit),则直接从缓存中读取数据,无需访问内存。
    • 如果未命中(Cache Miss),则需要从内存中读取数据,并将数据存储到缓存中供下次访问使用。
  3. 内存管理单元(Memory Management Unit,MMU):

    • 内存管理单元负责将逻辑地址(由CPU核发出)转换为物理地址(用于访问内存),并执行地址映射、内存保护和虚拟内存等功能。
    • 当CPU核发出读取内存数据的指令时,MMU首先将逻辑地址转换为物理地址,然后将物理地址发送到内存控制器进行实际的内存访问。
  4. 内存(Memory):

    • 内存是存储数据和程序的主要设备,用于存储CPU执行的程序和数据。当CPU核需要读取内存数据时,内存控制器负责实际的数据读取操作,并将数据返回给CPU核或存储到高速缓存中。

下图展示了CPU核、Cache、MMU和内存之间的关系示意图:

          +------------------------+
          |         CPU核          |
          +------------------------+
                    |
          +------------------------+
          |         高速缓存         |
          +------------------------+
                    |
          +------------------------+
          |        内存管理单元        |
          +------------------------+
                    |
          +------------------------+
          |          内存          |
          +------------------------+

在CPU核发出读取内存数据的指令时,高速缓存首先检查是否有需要的数据已经存储在缓存中。如果有,则直接从缓存中读取数据;如果没有,则MMU将逻辑地址转换为物理地址,并发送给内存控制器进行实际的内存访问。读取的数据将返回给CPU核或存储到高速缓存中,以供后续的访问使用。

6.10请说明总线接口USRT、I2C、USB的异同点(串/并、速度、全/半双工、总线拓扑等)⭐⭐⭐⭐⭐

这里有关USART、I2C和USB总线接口的异同点:

  1. USART(Universal Synchronous/Asynchronous Receiver/Transmitter):

    • 串行通信接口: USART是一种串行通信接口,支持同步和异步通信模式。
    • 全双工: USART支持全双工通信,即可以同时进行数据的发送和接收。
    • 速度: USART的通信速度可以调整,支持多种波特率。
    • 拓扑: USART通常采用点对点连接方式,每个设备通过一对引脚(TX和RX)与另一个设备进行通信。
  2. I2C(Inter-Integrated Circuit):

    • 串行通信接口: I2C是一种串行通信接口,支持多主机和多从机的通信。
    • 半双工: I2C是半双工通信,即数据传输方向在通信过程中可以切换,但不能同时进行发送和接收。
    • 速度: I2C通信速度较低,通常在100kHz、400kHz或1MHz等速度下进行通信。
    • 拓扑: I2C采用总线拓扑结构,多个设备连接到同一条总线上,通过地址进行通信。
  3. USB(Universal Serial Bus):

    • 串行通信接口: USB是一种串行通信接口,支持同步和异步通信模式。
    • 全双工: USB支持全双工通信,即可以同时进行数据的发送和接收。
    • 速度: USB通信速度较高,支持多种速度等级,包括低速(1.5Mbps)、全速(12Mbps)、高速(480Mbps)和超速(5Gbps)等。
    • 拓扑: USB通常采用树状拓扑结构,主机(Host)连接到USB总线上,然后连接到多个外设(设备)。

综上所述,USART、I2C和USB是三种不同的串行通信接口,它们在通信模式、通信速度、全/半双工、总线拓扑等方面有所不同,适用于不同的应用场景和需求。选择合适的通信接口应根据具体的应用需求和设计要求来决定。

6.11什么是异步串口和同步串口⭐⭐⭐⭐⭐

异步串口和同步串口是计算机通信中的两种不同类型的串行通信接口。
异步串口(Asynchronous Serial Port):

  • 在异步通信中,数据是以帧的形式传输的,每一帧包含了一定的起始位、数据位、可选的奇偶校验位和停止位。
  • 每个字符之间的传输是独立的,不需要时钟信号同步。
  • 适用于不需要严格时间同步的应用,如某些类型的 Modem 和串行打印机。
  • 异步串口较为灵活,但效率较低,因为每个字符之间的空闲时间需要填充填充位(NULL bit)。
    同步串口(Synchronous Serial Port):
  • 同步通信依赖于时钟信号来同步数据的传输,通常在数据传输开始前需要建立时钟同步。
  • 数据通常以比特流的形式连续传输,不需要起始位和停止位,但需要在数据传输前进行同步。
  • 适用于对数据传输速度和实时性要求较高的应用,如高速数据采集和某些类型的网络通信。
  • 同步串口的数据传输效率较高,因为它不需要额外的位来建立帧。
    在现代计算机系统中,随着USB和网络技术的普及,传统的异步和同步串口已经较少直接使用。但是,在某些特定的工业控制和嵌入式系统中,它们仍然有其应用场景。

6.12 I2C时序图⭐⭐⭐⭐⭐

I2C(Inter-Integrated Circuit)总线是一种串行通信总线,用于连接低速外围设备到处理器和微控制器。I2C总线由两条线组成:一根是串行数据线(SDA),另一根是串行时钟线(SCL)。
I2C通信是基于信号线的电平状态,而不是基于传统的起始位和停止位的异步通信。在I2C通信中,数据的传输是由时序控制的,包括以下几个步骤:

  1. 开始条件(Start Condition):
    • 主机将SDA从高电平拉低到低电平,而SCL保持高电平。
  2. 地址发送(Address Send):
    • 主机发送一个7位的设备地址,包括一个读/写位(R/W位)。
  3. 确认位(ACK Bit):
    • 接收器在地址发送后,如果识别出自己的地址,就会在下一个时钟周期内将SDA拉低,表示ACK(应答)信号,如果不是目标设备,则保持SDA高电平。
  4. 数据发送(Data Send):
    • 主机可以发送数据,直到发送完毕。每次发送一个字节后,接收器会发送ACK信号。
  5. 停止条件(Stop Condition):
    • 数据传输完成后,主机将SDA从低电平拉高到高电平,而SCL保持高电平,形成停止条件。
      下面是一个简化的I2C时序图的例子:
  SCL   SDA
  _____|    |_____
     |          |
    -----------START----------
     |          |
   [ADDRESS]   |
     |          |
    ----------|ACK-------------
     |          |
     [DATA]    |
     |          |
    ----------|ACK-------------
     |          |
     [DATA]    |
     |          |
    ----------|ACK-------------
     |          |
       ...      |
    ----------|STOP-------------

在这个时序图中,“ADDRESS”、“DATA"和"STOP"表示数据传输的各个阶段,”|“表示时钟线SCL的高电平或低电平状态,”----“表示低电平,”-----“表示高电平,”[“和”]"分别表示数据被发送和接收。每个数据字节后面跟着一个ACK信号,表示数据被正确接收。在STOP条件之后,总线释放,可以由另一台设备开始新的传输。
请注意,这只是一个简化的示例,实际的I2C时序要复杂得多,包括对时序的精确要求和各种情况的处理。在设计和编程时,需要参考I2C的标准规格来确保正确的时序和协议遵守。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

cunshan_1

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值