【Linux学习笔记】进程篇(九):进程的创建与退出——fork、写时拷贝与退出码

前言

前面我们已经学习了进程的基本概念,知道了进程不仅仅是一段正在运行的程序,还需要操作系统为它维护 PCB、虚拟地址空间、页表等信息。

那么,接下来就有两个很自然的问题:

  1. 一个进程是怎么创建出另一个进程的?
  2. 一个进程执行完毕之后,操作系统又是怎么知道它的执行结果的?

在 Linux 中,这两个过程分别涉及进程创建和进程退出。

本篇我们就从 fork() 开始,看看一个进程是如何创建子进程的,再通过写时拷贝理解父子进程之间的内存关系,最后看看进程退出时,返回值又是如何传递给父进程的。


一、进程创建

1.1 fork 函数初识

在 Linux 中,如果我们想创建一个新的进程,就需要使用 fork() 函数。

先来看一下它的函数原型:

cpp 复制代码
#include <unistd.h>

pid_t fork(void);

fork() 是一个系统调用,它的作用就是:以当前进程为基础,创建一个新的进程。

原来的进程叫作父进程,新创建出来的进程叫作子进程。

比如,现在有一个进程正在执行:

cpp 复制代码
int main()
{
    printf("hello Linux\n");
    return 0;
}

当它执行到 fork() 时,操作系统就会创建一个子进程。

这里要注意,子进程不是从 main() 函数的开头重新执行,而是从 fork() 调用之后的位置开始执行。

也就是说,父子进程都会继续执行 fork() 后面的代码。

那么,父子进程既然都执行同一份代码,程序怎么知道当前到底是父进程还是子进程呢?

这就涉及 fork() 最有意思的地方:它会返回两次。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

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

    if (id == 0)
    {
        printf("我是子进程,pid: %d\n", getpid());
    }
    else if (id > 0)
    {
        printf("我是父进程,pid: %d\n", getpid());
    }
    else
    {
        printf("fork failed\n");
    }

    return 0;
}

运行结果:

text 复制代码
我是父进程,pid: 1234
我是子进程,pid: 1235

这里的 PID 只是示意,实际运行时会有所不同。

我们发现,明明只有一个 fork() 调用,却出现了两次返回结果。

这是因为:

  • 父进程调用 fork(),返回的是新创建的子进程 PID。
  • 子进程调用 fork() 对应的执行流,返回的是 0。
  • 如果创建失败,则返回 -1,并且不会产生子进程。

为什么要设计成两个不同的返回值?

其实很好理解。

父进程需要知道自己创建出来的子进程是谁,所以返回子进程的 PID。

子进程只需要知道自己已经成功创建出来了,因此返回 0 就可以了。

这样,父子进程就可以通过 fork() 的返回值,分别执行不同的代码。

1.2 fork 创建子进程的底层过程

那么,fork() 到底做了什么?

我们之前说过,一个进程不仅仅是代码,还包含 PCB、地址空间、页表以及各种资源。

所以创建子进程,并不是简单地复制一份代码就结束了。

大致可以理解为以下几个步骤:

  1. 操作系统为子进程创建新的进程控制块(PCB,也就是 Linux 内核中的 task_struct)。
  2. 为子进程建立相应的虚拟地址空间和内存管理信息。
  3. 建立父子进程之间的资源关系,并处理需要继承的文件描述符等信息。
  4. 建立子进程的内存映射关系。
  5. 将子进程加入进程调度体系,等待 CPU 调度执行。
    6.

这里有一个非常重要的问题:

父进程已经有自己的代码、数据和各种变量了,子进程创建出来之后,难道还要把这些内容全部重新复制一份吗?

如果每次创建子进程,都要完整复制父进程的所有内存,不仅浪费时间,也会占用大量物理内存。

Linux 为了解决这个问题,就使用了写时拷贝技术。


1.3 写时拷贝(Copy On Write)

写时拷贝,英文叫作 Copy On Write,简称 COW。

它的核心思想是:

先共享,等到真正需要修改数据的时候,再进行拷贝。

我们先从一个简单的例子理解。

假设父进程中有一个变量:

cpp 复制代码
int a = 10;

当父进程调用 fork() 创建子进程时,父子进程都需要拥有这个变量。

如果直接复制,那么内存中就会有两份:

text 复制代码
父进程:a = 10
子进程:a = 10

但问题是,子进程刚创建出来时,可能根本不会修改这个变量。

如果它一直只是读取数据,那么提前复制一份就没有必要。

所以 Linux 的做法是:

刚创建子进程时,父子进程暂时共享同一份物理内存。

这样,父子进程虽然各自有独立的虚拟地址空间和页表,但在没有发生写入操作时,可以共同使用同一份物理内存。

不过,这里还有一个问题:

如果父进程把 a 改成了 20,那么子进程看到的也会变成 20 吗?

当然不行。

父子进程是两个独立的执行实体,它们应该拥有各自独立的数据,不能因为一方修改变量,就影响另一方。

因此,操作系统还需要做一件事:防止父子进程直接修改这份共享数据。

1.3.1 写时拷贝是如何实现的?

当 fork() 创建子进程后,操作系统会对父子进程共享的相关内存页进行权限设置。

对于需要写时拷贝保护的页面,父子进程对应的页表项都会被设置为只读。

注意,这里设置的是页表中的访问权限,并不是把原本可以修改的变量永久变成了只读变量。

它只是暂时不允许父子进程直接修改共享的物理页面。

接下来,我们看看具体会发生什么。

假设:

cpp 复制代码
int a = 10;

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

    if (id == 0)
    {
        a = 20;
    }
    else if (id > 0)
    {
        a = 30;
    }

    return 0;
}

父子进程刚创建出来时,内存关系大致如下:

text 复制代码
父进程虚拟地址                  子进程虚拟地址
      |                              |
      v                              v
  父进程页表                    子进程页表
      |                              |
      +--------------+---------------+
                     |
                     v
                物理内存
                  a = 10
                (只读保护)

现在,子进程执行:

cpp 复制代码
a = 20;

它想修改 a,但是页表告诉 CPU:这块内存目前只允许读取,不允许写入。

于是 CPU 就会产生一个异常,通知操作系统处理。

这里产生的异常并不意味着程序一定出错了。

因为操作系统知道,这个页面原本是可以写入的,只不过为了实现写时拷贝,暂时设置成了只读。

所以是在错误的驱使下,有了写时拷贝的行为

内核会结合进程的内存区域信息(vm_area_struct)和页表权限,判断这是不是一次合法的写时拷贝。

如果确认属于 COW,操作系统就会执行下面的操作:

  1. 申请一块新的物理内存。
  2. 将原来共享页面中的数据复制到新页面。
  3. 修改子进程的页表,让它指向新的物理页面。
  4. 将新页面的访问权限恢复为可读可写。
  5. 子进程重新执行刚才的写操作。

最终:

text 复制代码
父进程页表                    子进程页表
     |                            |
     v                            v
原来的物理页面                新的物理页面
    a = 10                       a = 20

这样,父进程和子进程就拥有了各自独立的数据。

如果父进程之后也尝试修改 a,同样会根据当前的共享情况触发相应的写时拷贝处理。

这就是写时拷贝的基本实现过程。

1.3.2 为什么操作系统能够分辨出谁是数据段?

我们刚才提到,父子进程共享的内存页面被设置成只读之后,如果发生写入,CPU 就会产生异常。

但操作系统怎么知道这次异常是写时拷贝引起的,而不是真正的非法内存访问呢?

这就要说到 vm_area_struct。

我们之前学习虚拟地址空间时,知道进程的虚拟地址空间中有不同的区域,比如代码段、数据段、堆区、栈区等。

Linux 内核通过 vm_area_struct 结构体来描述这些虚拟内存区域。

其中保存着该区域的起始地址、结束地址以及访问权限等信息。

比如:

text 复制代码
某块虚拟内存区域:

起始地址:0x1000
结束地址:0x2000
权限:可读、可写

如果这块区域原本就允许写入,只是因为写时拷贝暂时被设置成只读,那么操作系统就有机会识别出这是一次合法的写入请求。

而如果访问本身就不合法,比如访问了不属于当前进程的地址,那么就不能简单地当成写时拷贝处理。

所以,操作系统不是只看页表中的只读标记,还需要结合虚拟内存区域的信息进行判断。

1.3.3 查询页表时可能出现的几种情况

我们可以把页表理解成一张地址转换表。

程序访问虚拟地址时,CPU 和 MMU 会根据页表完成地址转换,同时检查访问权限。

大致有以下几种情况:

  1. 页表项存在,权限也符合要求: 正常完成地址转换,访问物理内存。
  2. 页表项不存在: 可能需要申请物理内存并建立映射,也可能是非法访问。
  3. 页表项存在,但权限不符合要求: 触发异常,交给操作系统进一步判断。

对于写时拷贝来说,重点就是第三种情况。

父子进程尝试写入共享的只读页面时,CPU 会触发异常,操作系统确认属于 COW 后,再完成物理页面的复制和页表修改。

1.3.4 为什么要使用写时拷贝?

理解了实现过程,再回头看它的好处就很简单了。

第一,提高进程创建效率。

如果每次 fork() 都需要完整复制父进程的物理内存,那么内存越大,创建子进程所需要的时间就越长。

而写时拷贝只在真正发生写入时才复制页面,避免了不必要的操作。

第二,节省物理内存。

如果父子进程一直没有修改某些共享页面,那么它们就可以一直使用同一份物理内存。

只有需要修改时,才为对应的页面分配新的物理内存。

因此,写时拷贝并不是让父子进程永远使用同一份数据,而是让它们在不需要修改时共享,在需要修改时分离。

1.3.5 写时拷贝之后,父进程的权限会恢复吗?

这里再补充一个容易混淆的地方。

当子进程发生写时拷贝后,它会获得一块新的物理页面,并且对应的页表权限可以恢复为可读可写。

但是,父进程原来的页面是否恢复可写,不能简单地说一定要等所有子进程都完成拷贝。

操作系统会根据物理页面的共享引用情况和其他映射关系进行判断。

当原来的页面不再需要受到共享写保护时,内核才会恢复相应的写权限。

所以,我们记住核心即可:

写时拷贝的目的,是保证父子进程修改数据时互不影响,而不是简单地给某个进程恢复权限。


1.4 fork 的常见用法

理解了 fork(),我们再看看实际开发中为什么需要创建子进程。

常见的使用方式主要有两种。

第一种:父进程复制自己,让父子进程执行不同的任务。

例如,一个服务器进程需要同时处理多个客户端请求。

如果只有一个进程,那么它可能需要不断等待和处理请求。

而通过 fork(),父进程可以创建子进程,让子进程负责处理客户端请求,自己继续等待新的请求。

这样就可以把不同任务交给不同进程执行。

第二种:创建子进程后,让子进程执行一个全新的程序。

这就涉及我们之前接触过的 exec 函数族。

例如:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

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

    if (id == 0)
    {
        execlp("ls", "ls", "-l", NULL);
    }
    else if (id > 0)
    {
        printf("我是父进程\n");
    }

    return 0;
}

这里的过程是:

  1. 父进程调用 fork() 创建子进程。
  2. 子进程执行 execlp()。
  3. execlp() 加载新的程序,替换子进程原来的用户空间程序映像。
  4. 子进程开始执行 ls 程序。

注意:exec 并不是创建一个新进程,而是替换当前进程所执行的程序。

所以,子进程的 PID 不会因为调用 exec 而改变。

这也是为什么 Linux 中经常使用:

text 复制代码
fork() + exec()

来实现新程序的启动。

1.5 fork 调用失败的原因

fork() 也不是每次都能成功。

如果系统无法为新进程准备好所需资源,就可能创建失败。

常见原因有:

  1. 系统中的进程数量已经达到限制。
  2. 当前用户能够创建的进程数量超过了限制。
  3. 系统内存或内核资源不足,无法完成进程创建所需的分配。

所以,在代码中调用 fork() 时,一定要检查返回值:

cpp 复制代码
pid_t id = fork();

if (id < 0)
{
    printf("fork failed\n");
}
else if (id == 0)
{
    printf("child process\n");
}
else
{
    printf("parent process\n");
}

二、进程退出

上面我们讲了进程是怎么创建出来的。

那么,子进程创建出来之后,总有执行完毕的一天。

当一个进程执行结束时,操作系统需要知道它是否正常完成任务,以及最终的执行结果是什么。

这就涉及进程退出。

2.1 进程退出场景

2.1.1 进程退出的三种场景

一个进程退出,大致可以分为以下三种情况。

第一种:代码运行完毕,结果正确。

例如:

cpp 复制代码
int main()
{
    printf("hello Linux\n");
    return 0;
}

程序正常执行完毕,返回 0。

通常我们用 0 表示程序执行成功。

第二种:代码运行完毕,但结果不正确。

例如:

cpp 复制代码
int main()
{
    // 执行某些操作
    return 1;
}

程序虽然正常执行到了结束位置,但是由于某些操作没有达到预期,所以返回了非 0 值。

这时,程序本身并没有崩溃,只是通过退出码告诉外部:这次执行存在问题。

第三种:程序异常终止。

例如,程序发生非法内存访问,或者收到了某些终止信号。

这种情况下,程序可能还没有执行到 main() 的结束位置,就已经被操作系统终止了。

例如,在终端运行程序时按下 Ctrl+C,通常就会向前台进程发送 SIGINT 信号。

因此,我们可以这样理解:

  • 正常退出:程序按照正常流程执行结束。
  • 错误退出:程序执行结束,但通过退出码表示执行失败。
  • 异常退出:程序因为信号或其他致命错误提前终止。
2.1.2 退出码有哪些?

退出码就是进程结束时留下的一个状态信息。

一般情况下:

  • 0:表示执行成功。
  • 非 0:表示执行过程中存在问题。

Linux 中还存在一些比较常见的退出码:

退出码 含义
0 执行成功
1 通用错误
2 命令或参数使用错误
126 找到了命令,但无法执行
127 没有找到命令
128+n 通常表示被编号为 n 的信号终止
130 常见于 Ctrl+C 终止
143 常见于 SIGTERM 终止

这些只是常见约定,不是所有程序都必须严格按照这个表使用退出码。

我们可以先记住一个最重要的结论:

退出码为 0,通常表示成功;非 0,通常表示失败或异常情况。


2.2 进程常见退出方法与核心机制

在 C/C++ 程序中,我们经常会看到三种退出方式:

  • return
  • exit()
  • _exit()

它们都能让进程结束,但底层执行过程并不完全相同。

2.2.1 return 机制与返回值

我们先来看最熟悉的 return。

cpp 复制代码
int Add(int x, int y)
{
    return x + y;
}

int main()
{
    int ret = Add(10, 20);

    return 0;
}

这里 Add() 执行完毕后,通过 return 将计算结果返回给调用它的函数。

那么,返回值是怎么传递的呢?

从底层来看,函数调用时会按照调用约定,通过寄存器等方式传递参数和返回值。

例如在常见的 x86-64 System V ABI 中,整数类型的返回值通常通过 RAX 寄存器传递。

可以简单理解为:

text 复制代码
main() 调用 Add()
       |
       v
Add() 执行计算
       |
       v
将结果放入返回值寄存器
       |
       v
返回 main()
       |
       v
main() 接收返回值

那么,main() 中的 return 0 又有什么不同呢?

普通函数的 return 是把结果交还给调用者。

而 main() 是程序的入口函数,当 main() 返回时,意味着整个程序已经执行完毕。

在正常的 C/C++ 运行环境中,main() 返回的状态会交给运行时库处理,最终完成进程退出。

所以:

cpp 复制代码
return 0;

表示程序正常结束,并将 0 作为退出状态。

这里要注意:

不是所有函数的 return 都会导致进程退出,只有 main 函数正常返回时,才会结束整个程序。

2.2.2 exit() 与 _exit() 的底层区别

除了 return,我们还可以主动调用 exit() 或 _exit() 来结束进程。

先看一下:

cpp 复制代码
#include <stdlib.h>

int main()
{
    printf("hello Linux\n");

    exit(0);

    return 0;
}

这里调用 exit(0) 后,进程就会结束,后面的 return 0 不会再执行。

那么,exit() 和 _exit() 又有什么区别?

我们先看一下它们的关系:

text 复制代码
        用户程序
           |
           v
        exit(0)
           |
           v
     执行注册的清理函数
           |
           v
      刷新标准 I/O 缓冲区
           |
           v
        _exit(0)
           |
           v
       进程终止

而如果直接调用 _exit():

text 复制代码
        用户程序
           |
           v
        _exit(0)
           |
           v
       进程终止

它不会替我们刷新 C 标准 I/O 库的缓冲区。

所以,两者最大的区别就是:

  • exit():先进行标准 I/O 清理、刷新缓冲区等操作,再结束进程。
  • _exit():直接请求内核终止进程,不执行上述用户态标准 I/O 清理操作。

我们通过一个例子来看看。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    printf("hello");

    exit(0);
}

运行结果:

text 复制代码
hello

再看:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("hello");

    _exit(0);
}

运行结果:

text 复制代码
没有输出 hello

为什么会这样?

这里就涉及我们之前学习过的缓冲区。

2.2.3 深入理解:我们谈论的缓冲区在哪里?

之前我们学习文件 I/O 时,会接触到输入输出缓冲区。

这里先提前理解一个重要的知识点:

C 标准 I/O 库的缓冲区,不等于操作系统内核中的缓冲区。

例如:

cpp 复制代码
printf("hello");

当我们调用 printf() 时,数据不一定会立即显示在屏幕上。

对于终端这种通常采用行缓冲的标准输出,输出内容可能暂时保存在 C 标准 I/O 库维护的用户态缓冲区中。

因为我们没有输出换行符,所以数据可能还没有真正通过底层输出操作交给内核。

这时候:

cpp 复制代码
exit(0);

会先刷新标准 I/O 缓冲区,再结束进程。

所以我们能够看到 hello。

但是:

cpp 复制代码
_exit(0);

不会执行 C 标准 I/O 库的缓冲区刷新操作。

进程直接终止,尚未刷新的用户态数据就可能丢失。

这也说明了一个问题:

如果缓冲区中的数据属于 C 标准 I/O 库,那么内核直接终止进程时,并不会自动替我们调用 printf() 对应的刷新逻辑。

这里要区分两个概念:

  • 用户态缓冲区:由 C 标准 I/O 库维护,例如 printf() 使用的 FILE 流缓冲区。
  • 内核缓冲区:操作系统内核在处理 I/O 时使用的缓冲机制。

它们不是同一个东西。

另外,exit() 除了刷新标准 I/O 缓冲区,还会执行通过 atexit() 注册的清理函数等操作,最后再完成进程终止。


2.3 退出码的回收与访问

上面我们已经知道,进程退出时可以带着一个退出码。

那么,进程退出之后,这个退出码会交给谁?

答案就是:父进程。

2.3.1 子进程退出码的保存

我们之前学习过,父进程可以通过 fork() 创建子进程。

那么,子进程执行完毕之后,父进程怎么知道它的执行结果呢?

操作系统会在子进程退出时保存它的退出状态,供父进程后续获取。

这里还涉及一个重要概念:僵尸进程(Zombie)。

当一个进程退出时,并不意味着它的所有进程信息都会立即从系统中消失。

为了让父进程能够获取子进程的退出状态,内核会保留必要的退出信息。

此时,子进程就可能进入僵尸状态。

简单理解:

僵尸进程就是已经退出,但是还没有被父进程回收退出状态的进程。

它已经不再执行任务,也不再占用正常运行进程所需的用户态内存,但内核仍然保留着必要的进程信息。

等父进程调用 wait() 或 waitpid() 获取子进程的退出状态后,内核就可以完成对子进程的回收。

所以,进程退出和进程被彻底回收,并不是完全相同的概念。

2.3.2 退出码的访问

那么,我们平时在 Linux 终端中运行程序之后,怎么查看它的退出码呢?

例如:

cpp 复制代码
#include <stdio.h>

int main()
{
    printf("hello Linux\n");

    return 10;
}

运行:

bash 复制代码
./proc

然后输入:

bash 复制代码
echo $?

就可以看到:

text 复制代码
10

这里的 $? 是 Shell 中的一个特殊变量,表示上一条前台命令的退出状态。

为什么 Bash 能拿到这个值?

因为我们在终端中执行:

bash 复制代码
./proc

实际上是 Bash 创建并启动了一个子进程来执行这个程序。

程序执行完毕之后,Bash 会通过等待机制获取它的退出状态,并将对应的状态码保存到 $? 中。

所以整个过程可以理解为:

text 复制代码
          Bash(父进程)
                |
                | fork()
                v
          子进程 proc
                |
                | 执行程序
                v
          return 10
                |
                v
          进程退出
                |
                v
       Bash 获取退出状态
                |
                v
          保存到 $?
                |
                v
          echo $?
                |
                v
               10

这就解释了为什么我们可以通过 echo $? 查看程序的执行结果。

2.3.3 退出码可以转化为错误信息

除了直接查看退出码,我们还可以通过错误码获取对应的错误描述。

例如,C 语言提供了:

cpp 复制代码
#include <string.h>

char* strerror(int errnum);

它的作用就是:将错误码转换成对应的错误描述字符串。

例如:

cpp 复制代码
#include <stdio.h>
#include <string.h>

int main()
{
    for (int i = 0; i < 10; i++)
    {
        printf("%d -> %s\n", i, strerror(i));
    }

    return 0;
}

运行之后,就可以看到不同错误码对应的描述信息。

这里需要区分:

  • 退出码:表示程序最终的执行状态。
  • errno:通常用于保存某些库函数或系统调用失败时对应的错误编号。
  • strerror():负责将错误编号转换成便于阅读的错误描述。

例如:

cpp 复制代码
#include <stdio.h>
#include <errno.h>
#include <string.h>

int main()
{
    FILE* fp = fopen("test.txt", "r");

    if (fp == NULL)
    {
        printf("打开文件失败:%s\n", strerror(errno));
        return 1;
    }

    fclose(fp);
    return 0;
}

这里如果文件打开失败,fopen() 会返回 NULL,并设置相应的 errno。

我们就可以通过 strerror(errno) 将错误编号转换成具体的错误信息。

这样,程序不仅能够通过退出码告诉外部自己是否执行成功,还可以通过错误信息帮助我们定位问题。


三、本篇总结

到这里,我们就把 Linux 中进程的创建与退出串起来了。

创建进程时,我们使用 fork(),让操作系统创建一个新的子进程。

为了避免直接复制大量内存造成浪费,Linux 使用写时拷贝技术,让父子进程先共享物理页面,等到真正需要修改数据时再进行复制。

而进程退出时,可以通过 return、exit() 或 _exit() 等方式结束执行,并留下相应的退出状态。

最后,父进程通过等待机制获取子进程的退出状态,完成对退出信息的回收。

我们可以用一张图把整个过程串起来:

text 复制代码
             父进程
                |
                v
             fork()
                |
         创建子进程
                |
                v
       父子进程共享内存页面
                |
          是否修改数据?
             /      \
            /        \
          否          是
          |           |
          |       写时拷贝
          |           |
          |       分离物理页面
          |           |
          +-----+-----+
                |
                v
           进程继续执行
                |
                v
             进程退出
                |
                v
          保存退出状态
                |
                v
          父进程 wait()
                |
                v
          获取退出状态
                |
                v
           回收子进程

这篇内容中有几个概念需要重点记住:

  1. fork() 创建子进程,父进程返回子进程 PID,子进程返回 0。
  2. 写时拷贝的核心是先共享、后分离,真正修改数据时才复制物理页面。
  3. exec() 不创建新进程,而是替换当前进程的程序映像。
  4. return、exit() 和 _exit() 都可以参与进程退出,但处理用户态缓冲区的方式不同。
  5. 子进程退出后,父进程通过 wait() 或 waitpid() 获取退出状态并完成回收。

至此,进程从创建到退出的基本过程就介绍完了。下一篇我们继续学习进程等待,看看父进程究竟是如何回收子进程的,以及僵尸进程和孤儿进程又是怎么产生的。

相关推荐
楚山之石39 分钟前
【MRM】CAPLEX 对比剂灌注 MRI 开源术语库:ISMRM OSIPI 共识报告
图像处理·人工智能·学习·mri
李松桃40 分钟前
基于IP的服务配置
linux·nginx-核心模块配置
FungLeo42 分钟前
成为全栈·基础补充·Linux 服务器入门
linux·运维·服务器·ssh·部署·成为全栈
时间的拾荒人1 小时前
CMake 实战指南:从基础语法到静态库/动态库制作与链接
开发语言·c++·面试
智者知已应修善业1 小时前
【51单片机实现从下到上,从上到下,从中间到两边,两边到中间】2024-10-23
c语言·经验分享·笔记·单片机·嵌入式硬件·51单片机
道尔柯南1 小时前
C++ STL 入门详解:从认识标准模板库到掌握学习方法
java·c++·学习方法
爱和冰阔落1 小时前
【Linux网络】TCP 服务器怎么写?从 listen、accept 到 read/write,手写完整 Echo Server
linux·运维·服务器·网络
欢欢李4 小时前
【LTE L3 笔记 06】LTE下行参考信号
笔记·无线通信·物理层·lte·空口协议