0%

【经典问题】技术面试题汇总

这篇文章是用来总结 C/C++ 开发校招面试的笔记汇总,内容主要会涉及操作系统,计算机网络,计算机组成原理,编程语言,Linux,Git,多线程,数据结构与算法等等。这篇文章会持续更新,用作复习阶段的知识点梳理,也用作面试前的快速回顾。

操作系统

用户态和内核态

内核态和用户态的区别?

最简单的运行程序的方式是“直接执行”,即直接在 CPU 上执行任意程序。直接执行的问题是:

  1. 如何限制代码行为?比如禁止:设置特殊寄存器的值、访问存储器的任意位置、I/O 请求、申请更多系统资源等
  2. 在运行这个程序的时候,如何切换到另一个程序?进程调度应该是 OS 才有的权限

因此引入用户态和内核态和两种模式。用户态无法执行受限操作,如 I/O 请求,执行这些操作会引发异常。核心态只能由操作系统运行,可以执行特权操作。用户程序通过系统调用 system call 执行这些特权操作。OS 执行前会判断进程是否有权限执行相应的指令。

区分用户态和核心态的执行机制称为“受限直接执行”(Limited Direct Execution)。

什么时候会陷入内核态?

系统调用(trap)、中断(interrupt)和异常(exception)。

系统调用是用户进程主动发起的操作。发起系统调用,陷入内核,由操作系统执行系统调用,然后再返回到进程。

中断和异常是被动的,无法预测发生时机。中断包括 I/O 中断、外部信号中断、各种定时器引起的时钟中断等。异常包括程序运算引起的各种错误如除 0、缓冲区溢出、缺页等。

在系统的处理上,中断和异常类似,都是通过中断向量表来找到相应的处理程序进行处理。区别在于,中断来自处理器外部,不是由任何一条专门的指令造成,而异常是执行当前指令的结果。

C 访问空指针会不会陷入内核态?

会。

访问指针相当于访问一个虚拟地址,硬件会将虚拟地址映射到真实的物理内存。如果映射失败,硬件会抛出一个段错误异常(page fault exception),此时会从用户态转为内核态进行处理。

OS 会在中断描述符表中,找到处理 page fault exception 的中断向量,执行相应的 handler。一般情况下,OS 会抛出一个 SIGSEGV 信号给进程,中止进程,打印出 debug 信息。

陷阱、中断、异常、信号

陷阱、中断、异常、信号的概念

陷阱

陷阱是有意造成的“异常”,是执行一条指令的结果。陷阱是同步的。

陷阱的主要作用是实现系统调用。比如,进程可以执行 syscall n 指令向内核请求服务。当进程执行这条指令后,会中断当前的控制流,陷入到内核态,执行相应的系统调用。内核的处理程序在执行结束后,会将结果返回给进程,同时退回到用户态。进程此时继续执行下一条指令

每个系统调用有一个唯一的整数号,对应于内核中一个跳转表的偏移量。这个跳转表中的每个条目表示一个系统调用的代码位置。执行系统调用时,通过这个整数号作为跳转表的偏移量,就可以执行相应的系统调用。

中断

中断由处理器外部硬件产生,不是执行某条指令的结果,也无法预测发生时机。由于中断独立于当前执行的程序,因此中断是异步事件。

中断包括 I/O 设备发出的 I/O 中断、各种定时器引起的时钟中断、调试程序中设置的断点等引起的调试中断等。

每个中断都有一个中断号。操作系统使用中断描述符表(Interrupt Descriptor Table,IDT)来保存每个中断的中断处理程序的地址。当发生中断时,操作系统会根据中断号,在中断描述表中查找并执行相应的中断处理程序。当处理程序返回后,进程继续执行下一条指令,就好像没有发生过中断一样。

异常

异常是一种错误情况,是执行当前指令的结果,可能被错误处理程序修正,也可能直接终止应用程序。异常是同步的。

这里的“异常”特指因为执行当前指令而产生的错误情况,比如除法异常、缺页异常等。有些书上也将这类“异常”称为“故障”

当发生异常时,操作系统会将控制转移给相应的异常处理程序。如果处理程序能够修正这个错误情况,就将返回到引起异常的指令重新执行。否则,终止该应用程序。

异常处理程序的地址也保存在中断描述符表(IDT)中。

信号

信号是一种更高层的软件形式的异常,同样会中断进程的控制流,可以由进程进行处理。一个信号代表了一个消息。信号的作用是用来通知进程发生了某种系统事件。

上文的陷阱、中断和异常都是低层异常机制,由内核的异常处理程序进行处理,正常情况下对用户进程是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。

陷阱、中断、异常、信号的处理流程

陷阱/系统调用的处理流程:

  • 库函数:首先在用户空间,库函数会装配系统调用号和参数到指定寄存器中,然后执行特殊指令(svc / int 80)陷入内核
  • 特殊指令:上面的特殊指令处理陷入内核外,可能还会有一些附带的工作,如保存返回地址,切换系统状态,从用户栈切换到内核栈等
  • 内核保存用户上下文:进入内核后,内核会保存可能会修改的寄存器到内核栈上,这是为了能够恢复系统调用执行前的用户执行状态必须做的
  • 内核找到调用入口:接着,内核会检查系统调用的参数是否合法,并根据系统调用号和系统调用表找到指定系统调用的入口,装配好调用参数后跳转到具体系统调用
  • 返回:系统调用函数完成指定功能后,会将结果保存到指定寄存器中,然后恢复用户上下文,

中断的处理流程(异常也是类似的):

中断产生 :与系统调用不同的是,中断是异步发生的,CPU 并不知道何时会发生中断,这是由外设在就绪时主动告诉 CPU 的,CPU 会在每执行完一条指令后去检查中断信号。

栈的切换 :产生中断后,系统会切换当前程序使用的栈(在 Linux 内核中,可将中断内核栈分开)

保存线程 :和系统调用类似,为了后面的恢复,这里需要保存当前 CPU 上下文

中断服务程序 :根据不同的中断,系统会在中断向量表中,找到中断服务程序的入口,并执行中断服务程序

恢复现场 :执行完中断服务程序后,将之前保存的寄存器出栈,恢复到寄存器上,复原运行环境

信号的处理流程:

信号的发送:使用 kill 程序可以在 shell 中发送信号;使用 kill 系统调用可以在程序中发送信号

信号的接收 :每个进程有一个待处理信号的集合。待处理信号表示发送给该进程但是还未被处理(接收)的信号,任何时刻同一类型的待处理信号最多只有一个,后续发送的同类型信号将会被丢弃(隐式阻塞)。

信号的处理

信号的处理时机:

  • 当内核把进程从内核态切换到用户态时。例如,从系统调用返回,或是完成一次上下文切换
  • 内核通过控制转移来强制进程接收/处理信号。如果进程的未被阻塞的待处理信号集合不为空,则内核会选择集合中的某个信号(通常是最小的),并将控制传递到信号处理程序;否则,内核正常地将控制传递到进程的下一条指令

用户进程对信号的处理过程有三种:

  1. 执行默认操作,Linux 对每种信号都规定了

    默认行为,是下面的一种

    • 进程终止
    • 进程终止并转储内存(core dump)
    • 进程停止(挂起)直到被 SIGCONT 信号重启
    • 进程忽略该信号
  2. 忽略信号。当不希望接收到的信号对进程的执行产生影响,而让进程继续执行时,可以忽略该信号,即不对信号进程作任何处理,SIGSTOPSIGKILL 无法忽略,也无法修改其默认行为

  3. 处理信号。定义信号处理程序,当信号发生时,执行相应的处理程序,使用 sigaction 可修改行为

常见的陷阱、中断、异常、信号有哪些?

常见的陷阱(系统调用):fork,exec,open,read,write 等

中断:中断包括 I/O 设备发出的 I/O 中断、各种定时器引起的时钟中断、调试程序中设置的断点等引起的调试中断等。

异常:除法(除零)异常、缺页异常等

信号:

image-20240726085124985

image-20240726085151116

Ctrl + CCtrl + Zkillkill -9 的原理

按下 Ctrl+C 发送 SIGINT (2)信号

按下 Ctrl+Z 发送 SIGTSTP (19)信号

kill 默认发送 SIGTERM (15)信号,可以在退出之前让程序执行清理操作、保存其状态或执行任何其他操作

kill -9 发送 SIGKILL (9)信号,程序会立即终止,没有机会保存其状态或释放资源。谨慎使用

进程与线程

进程与线程的区别

进程是操作系统分配资源调度的基本单位,进程会拥有内存空间的代码、数据,打开的文件,CPU 等资源

线程是操作系统进行调度的基本单位,线程除了 运行状态 是私有的,内存、打开文件等都是线程之间共享的

进程 线程
资源 进程是一个拥有资源执行任务的单元体。 线程是一个执行任务的单元体,不拥有资源,线程之间共享地址空间
切换开销 开销很大 开销很小
通信 IPC 共享内存
健壮性 健壮,多个进程之间不会互相干扰 不健壮,一个线程出错会终止整个进程

线程,进程的上下文切换

进程的上下文切换:在内核中,每个进程都有一个程序控制块 PCB来表示,其被保存在内存中,进程上下文切换的过程,就是将寄存器状态保存到 PCB 中,并找到新的 PCB,将其之前保存的内容加载到寄存器上,此外,还需要切换根页表目录 ,以及刷新 TLB 缓存 。总之,进程的切换涉及:

  1. 更新页表/MMU;
  2. 刷新 TLB;
  3. 保存和加载新旧 PCB 中的寄存器状态;
    • 将关键寄存器的值(如程序计数器和标志寄存器)保存到 previous PCB
    • 将关键寄存器的值(如程序计数器和标志寄存器)从 next PCB 恢复

线程的上下文切换:由于线程之间共享地址空间,所以,线程的切换不涉及页表和TLB的更新,只需要切换线程的寄存器状态即可,即只包含上面的第二步。

涉及内核线程的上下文切换有什么不同

内核线程与其他进程不同的是,它并没有属于自己的地址空间所以在上下文切换时,内核线程会 “借用”上一个用户进程的地址空间 next->active_mm = oldmm,自己的 mm = NULL 标识自己为内核线程。此外,在借用地址空间后,并不需要刷新页表和 TLB,这样做的目的时为了加快上下文切换的速度。

寄存器和栈的切换和其他线程没什么不同。

上下文切换完成后,内核线程就在上一个用户进程的地址空间上半部(内核地址空间)运行。

具体实现上,如果下一个进程的 mm == NULL ,则

1
2
3
4
5
6
if (next->mm == NULL) {				/* 下一个是内核线程 */
oldmm = prev->active_mm;
next->active_mm = oldmm; /* 借用的上一个地址空间 */
atomic_inc(&oldmm->mm_count); /* 提前增加引用计数 */
enter_lazy_tlb(oldmm, next); /* 不刷新 tlb */
}

如果上一个进程是内核线程,则

1
2
3
4
if (!prev->mm) {					/* 上一个是内核线程 */
prev->active_mm = NULL;
rq->prev_mm = oldmm; /* 在rq->prev_mm上保存了上一次使用的mm struct */
}

这里,我们还在内核线程的上下文运行,并不能释放地址空间,而是需要在下一个进程中释放,这就引出了下一个重要的问题:

switch_to 函数为什么需要三个参数

image-20240808142127702

一次 switch_to 函数的调用涉及三个进程A 是被调度下去的进程,B 是在 A 之后调度上 CPU 的进程,X 是经过漫长的调度过程后,在 A 再次调度上 CPU 时的上一个进程!

上面说到,如果 X 是内核线程,这时就需要 AX 做一些善后工作,如果 switch_to 只使用两个参数,那么 A 上下文中 prev 上一个进程永远都是 B,这显然不合理,所以,switch_to 需要第三的参数,用作 A 再次被调度时,告诉它上一个运行的进程是谁:

1
2
3
4
#define switch_to(prev, next, last)                    	\
do { \
((last) = __switch_to((prev), (next))); \
} while (0)

进程与线程的通信方式

方式 传输的信息量 使用场景 关键词
信号 少量 任何 硬件来源 / 软件来源 / 信号队列 / 中断返回 / 系统调用返回
管道 大量 亲缘进程间 单向流动 / 内核缓冲区 / 循环队列 / 没有格式的字节流 / 操作系统负责同步
命名管道 大量 任何 磁盘文件 / 访问权限 / 无数据块 / 内核缓冲区 / 操作系统负责同步
信号量 N 任何 互斥同步 / 原子性 / P 减 V 增
共享内存 大量 多个进程 内存映射 / 简单快速 / 操作系统不保证同步
消息队列 比信号多,但有限制 任何 有格式 / 按消息类型过滤 / 操作系统负责同步
套接字 大量 不同主机的进程 读缓存区 / 写缓冲区 / 操作系统负责同步

信号机制

信号就是操作系统预先定义的一些事件,进程可以对这些事件进行一些自定义的响应

内核与进程的通信:信号机制常用于内核与进程的通信,例如当进程访问非法内存时,收到 SIGSEGV 信号,想当于通知进程出现了段错误,默认情况下,进程终止并转储。

进程之间的通信:进程可以通过 kill 系统调用向其他进程发送信号,函数原型为: int kill(pid_t pid, int sig)

信号机制的原理:

  1. 信号的发送
    • 使用 kill 系统调用后,陷入内核,内核会根据 pid 找到 task_struct
    • 将信号放入目标进程的信号队列 pending
  2. 信号的接收
    • 像上面说的,每个进程都有一个队列,用来存放待决的信号
    • 系统调用或中断返回前,内核会检查每个进程的 pending 列表
    • 如果有待决的信号,则跳转到信号处理函数执行
  3. 信号的处理
    • 忽略信号:当进程不希望被某信号打扰,可以忽略该信号,但 SIGSTOPSIGKILL 无法忽略
    • 处理信号:进程可以自定义信号处理函数,如使用 signalsigaction 函数
    • 默认处理:进程未定义信号处理函数时,使用默认的处理方式,一般是杀死或挂起进程

匿名管道

匿名管道用于有亲缘关系的进程之间进行通信,通过 pipe(int pipefd[2]) 系统调用创建读写文件描述符,随后使用 forkclone 创建子进程,共享文件描述符,这样两个进程就能分别在其中一个文件描述符上读写了。

实现 :匿名管道是借助 VFS 实现的,如下图,其申请了一个物理页创建了 inodefile 对象;两个 file 对象的 f_op 就是对物理页的读取/写入操作,对于该物理页内核使用环形缓冲区的方式进行管理。

同步:内核使用互斥锁,保证父子进程对管道的互斥访问

image-20240801164404729

命名管道

一句话概述,命名管道除了会在物理磁盘上真正创建索引节点外,和匿名管道的其他方面类似

通过 mknode() 系统调用或者 mkfifo() 函数建立命名管道,该系统调用会在物理磁盘上真正创建一个索引节点,但是磁盘上并没有真正的文件数据块,也是通过内核缓冲区来实现数据传输。

一旦建立 FIFO文件,任何有访问权的进程都可以通过文件名将其打开和进行读写,而不局限于父子进程。

当不再被任何进程使用时,命名管道内核缓冲区被释放,但磁盘节点仍然存在。

信号量

信号量本质上是一个整数,代表某种资源的数量,对它的 P,V 操作都是原子的。

  • V(S):如果有其他进程因等待 S 而被阻塞,就让它恢复运行,否则 S 加 1
  • P(S):如果 S 为 0,则阻塞进程,否则 S 减 1

信号量在底层的实现是通过硬件提供的原子指令,如 Test And SetCompare And Swap 等。

共享内存

无论是 System V 、Posix、还是 mmap() 都提供了共享内存的机制,但从底层原理上讲,都是内核开辟一片内存区域,然后多个用户进程可以将这片区域映射到它们自己的地址空间中进行读写。用户空间要负责读写该区域的互斥和同步。

image-20240802092625556

消息队列

消息队列使用内核维护的链表来实现,每个消息是由边界的,每个 MQ 通过消息队列描述符来区分(qid)。

image-20240802092935206

套接字 socket

不同的计算机的进程之间通过 socket 通信,也可用于同一台计算机的不同进程

操作系统提供创建 socket、发送、接收的系统调用,为每个 socket 设置发送缓冲区、接收缓冲区。

各种 IPC 适用什么场景

方式 类型 适用场景
信号 任意进程间的通知机制 进程间的简单通知和控制,如内核终止、暂停进程
管道 亲缘进程间的单向字节流 适合简单的父子进程通信
命名管道 任意进程间的双向字节流 需要长期存在且进程之间进行双向通信的场景
信号量 任意进程间的计数器 用于多个进程之间的同步和互斥访问共享资源
共享内存 任意进程间的共享内存区域 需要高效频繁数据交换的场景
消息队列 任意进程间的消息格式通信 需要传递结构化数据和消息的场景
套接字 支持在网络和本地进程间通信 不同主机的进程进行网络通信

要求进程崩溃重启后可以继续读写,该如何设计 IPC 方式

这种情况下,可以使用下面两种 IPC 方式:

  1. 消息队列:消息队列中的消息在进程崩溃后会继续存在,直到被接收。
  2. mmap 共享文件映射 :当进程崩溃后,如果文件还在磁盘上且没有被删除,其他进程可以重新映射相同的文件,继续读取或写入数据。

其他的方式无法实现继续读写的要求,比如:

  • 信号:进程崩溃或终止后,其相关的信号状态也随之丧失(task_struct 都没了,pending也没了)
  • 管道:无论是匿名还是命名管道,均是通过创建内存来实现的,在进程崩溃后,相关内存会被系统回收
  • 共享内存:和命名管道类似,虽然其对象本身(FIFO 文件 )并未被回收,但相关内存会被系统回收
  • mmap共享匿名映射:共享匿名映射的内存也会被回收
  • socket:对于 TCP 来说,连接会断开;对于 UDP 来说,数据会丢失。

并发与竞态

什么是线程安全,举个不安全的例子

线程安全就是指,在多线程并发的场景下,多个线程对于共享数据的访问不能造成竞态或数据不一致。换句话说,对于共享数据的并发读写,应该和多个线程串行读写的结果一致。

例子:两个线程同时对一个共享整数做 ++ 操作,各加 50 次,由于竞态的存在,结果可能会导致共享整数被加次数小于 100 次

原因:这是因为 ++ 操作并不是原子操作而导致的,在底层 ++ 操作可能被分解为 读、该、写回 等多个指令,两个线程的这些指令交叉执行就会导致竞态,所以需要合适的同步方法对临界区进行保护

线程同步的方法

  • 互斥锁 pthrad_mutex:互斥访问,会阻塞等待锁的线程,释放锁时唤醒线程
  • 条件变量 pthread_cond:可以使用一个等待队列、一个变量 以及保护变量的互斥锁实现
  • 读写锁 pthread_rwlock:使用两个等待队列,一个用于等待读锁的线程,另一个用于等待写锁的线程
  • 自选锁 pthread_spin:自旋等待
  • 信号量 sem :会阻塞等待锁的线程,释放锁时唤醒线程

何时使用多进程、又何时使用多线程

多进程的优势:

  • 各个进程有各自的地址空间,带来了更好的隔离性
  • 同样的,各个进程之间的运行互不干扰,带来了更好的稳定性

当对隔离性和稳定性要求有要求时,应该使用多进程,比如 数据库服务器,可以保证一个连接的错误不影响其他连接。

多线程的优势:

  • 各个线程共享地址空间,带来了高效的数据共享,避免数据多次拷贝
  • 由于上下文切换时无需切换地址空间,所以多线程创建和销毁开销更小

这种特性表示着多线程更适合高并发IO密集型场景,例如需要处理大量的并发请求和高吞吐量的 Web 服务器。

多线程下的 error 会有问题吗

在 Linux 下,errno 是一个全局变量,用于保存系统调用或库函数出错时的错误代码。

  • 每个线程都拥有独立的 errno 副本
  • 系统调用和库函数,设置 errno 时也是设置了每个线程的副本

这样的设计,保证了 errno 在多线程的环境下仍然是安全的

Linux 内核

uboot

uboot 的启动的大致流程

  • 架构级别初始化
    1. 修改 CPSR 寄存器:设为 SVC 特权模式,禁止 IRQ、FIQ;
    2. 修改 cp15 协处理器:设置中断向量表基地址,禁止 TLB、禁止 data cache (避免使用陈旧缓存条目)、关闭 mmu
    3. 准备 C 语言运行环境:分配了一片内存空间,从高到底分别为global data 区(保存在 r9),堆区栈区(保存在 sp)
    4. 调用一系列初始函数,这些函数主要完成:
      • gd 中保存全局变量,如 uboot 大小,设备树地址,环境变量等
      • 初始化串口终端 等设备,并开始打印启动信息
      • 初始化 DRAM,并在 DRAM 中为 页表ubootmalloc全局数据(gd)板卡数据(bd)预留空间,并将这些预留的DRAM 地址保存到当前 gd
      • 重定位,拷贝设备树gd 到新的地址,重定位 uboot,重定位中断向量表,最后在 DRAM 中重新执行架构初始化 (1,2,3 步)
  • 板级初始化
    1. 仍调用一系列初始化函数,主要完成
      • 使能 D cache、I cache
      • flash 设备的初始化、输入输出设备的初始化、中断有关的初始化、网卡相关初始化
      • main_loop,开始命令行操作。
  • 启动内核
    • 首先根据启动方式,从存放镜像的介质中加载到 DDR。
    • 检查、校验是哪类镜像
    • 传递参数给内核 (使用设备树传参 )r0 存放第一个参数 0、r1 存放第二个参数 机器 ID, r2 存放第三个参数 设备树首地址
    • 运行内核

计算机网络

两台主机间的 TCP 通信过程

这里以建立连接时的 SYN 报文为例,看一下不同局域网内的主机通信时具体过程,以及会涉及哪些协议

  1. 【主机】【应用层】通过 DNS 协议将域名解析为 IP 地址;
  2. 【主机】【传输层】封装 TCP 报文,建立连接使用 SYN 标志位,并选择一个序列号 seq;
  3. 【主机】【网络层】封装 IP 数据包,源 IP 地址为本机 IP,目的 IP 地址为 域名 IP;
  4. 【主机】【网络层】查询路由表,根据 目的IP 寻找 下一跳IP
  5. 【主机】【数据链路层】封装以太网帧,源 MAC 为本机 MAC,目的 MAC 通过下一跳IP 和 ARP 表获得;
  6. 【主机】【物理层】发送以太网帧,网卡会将以太网帧以比特流的形式发送出去;
  7. 【局域网】该帧会被局域网内的其他设备看到,根据 目的MAC 获取或丢弃该帧;
  8. 【网关/路由器】【网络层】根据 目的MAC 该帧最终会被局域网网关捕获,网关重复 4~7 步,发送带有新的 源MAC 和 新的目的 MAC 的以太网帧;
  9. 【互联网】中间经过若干个下一跳主机,最终 IP 数据包发送到域名所在的局域网的网关,每一跳的过程中,两个 MAC地址 是不断变化的,两个 IP地址 从未改变。
  10. 【网关/路由器】【网络层】目的局域网的网关,通过路由表发现,目的主机和自己在同一局域网;

Gateway 为 0.0.0.0 时,表示目的机器和当前主机位于同一个局域网内,它们互相连接,任何数据包都不需要路由,可以直接通过 MAC 地址发送到目的机器上。

  1. 【网关/路由器】【数据链路层】根据 ARP 表,查找目的 IP 的 MAC 地址,构造以太网帧;
  2. 【局域网】该帧会被局域网内的其他设备看到,根据 目的MAC 获取或丢弃该帧;
  3. 【目的主机】【数据链路层】根据 目的MAC,上述以太网帧会被目的组机接收;
  4. 【目的主机】【网络层】去掉 以太网头部,目的主机获取到了 IP 数据报;
  5. 【目的主机】【传输层】去掉 IP 头部,目的主机获取到了 TCP 报文;
  6. 【目的主机】【传输层】目的主机识别到 SYN 报文,会类似 2~15 步那样,发送 ACK 报文…

输入网址到出现网页的过程

这个问题和上面的比较类似,但是,会涉及到浏览器以及 http协议的相关知识,这里仅补充这些。

  1. 【主进程】用户输入 URL,回车;
  2. 【主进程】此时主线程会创建一个 网络连接线程
  3. 【网络连接线程】网络连接线程会像上面描述的那样,进行 DNS 解析,TCP 三次握手,SSL 四次握手,从而建立了 http 连接。
  4. 【服务器】服务器收到第一条 http 报文后(read),会解析请求,并返回请求内容(write);
  5. 【浏览器】浏览器收到请求内容后,将内容交给渲染进程;
  6. 【浏览器】渲染进程,会解析返回内容,并绘制页面;
  7. 【浏览器】如果遇到 JS/CSS/图片等静态资源,会重复 2~6 步骤;

image-20240801084207363

TCP

TCP 三次握手的过程

  1. 同步报文】客户端发送 SYN 报文,选择一个序列号 seq = M

  2. 同步确认报文】服务器发送 SYN ACK 报文,选择一个序列号 seq = N,确认号 ack = M + 1

  3. 确认报文】客户端发送 ACK 报文,序列号 seq = M + 1,确认后 ack = N + 1

    image-20240801112249259

TCP 四次挥手的过程

  1. 连接释放报文】客户端发送 FIN 报文,seq = y
  2. 确认报文】服务器端发送 ACK 报文,seq = z,ack = y + 1,并携带未发完的数据
  3. 连接释放报文】服务器端发送 FIN ACK 报文, seq = p, ack = y + 1
  4. 确认报文】客户端发送 ACK 报文 , seq = y + 1, ack = p + 1

image-20240801113032935

TCP 如何进行流量控制

什么是流量控制?在传输过程中,如果发送方发送的速度过快,接收方来不及处理,就会造成大量丢包,所以,使用滑动窗口机制,对发送方的发送速率进行限制

TCP 使用滑动窗口进行流量控制,在发送方和接收方各有一个缓冲区

发送方的缓冲区包括:

  • 已确认指针:指向接收方已确认的最后一个字节
  • 已发送指针:指向已发送数据的最后一个字节
  • 应用写入指针:指向应用程序写入缓冲区的最后一个字节

接收方的缓冲区包括:

  • 未确认指针:指向第一个未确认字节
  • 已接收指针:指向最新接收的最后一个字节
  • 应用读取指针:指向应用程序读取到的最后一个字节

image-20240801103914121

那么,接收方此时会在 TCP 头部 window 字段表明自己当前可容纳的数据大小:

请求窗口大小 = 接收方缓冲区大小 - 已接收指针 - 1

发送方读取到该窗口大小后,会根据这个窗口来控制发送数据的大小,以保证接收方可以处理

TCP 如何进行拥塞控制

什么是拥塞控制?拥塞控制旨在是发送端的发送速度适应网络环境,解决网络拥塞问题。

TCP 也是使用滑动窗口机制来实现拥塞控制的,发送端会维护一个 cwnd 拥塞窗口,并使用 慢启动、拥塞避免、超时重传、快速重传/快速恢复 等机制实现拥塞控制。

  1. 慢启动:指数增加,探测网络容量

    • 连接建立时,拥塞窗口 cwnd = 1 代表可以发送一个 MSS 大小的数据
    • 每收到一个 ACK 报文,cwnd++ 加一
    • 每经过一个 RTT 时间,cwnd *= 2 翻倍

    cwnd >= ssthresh 大于等于 慢启动阈值 时,进入拥塞避免

  2. 拥塞避免:加法增加,探测网络容量

    • 每收到一个 ACK 包,cwnd = cwnd + 1/cwnd
    • 每经过一个 RTT,cwnd = cwnd + 1(加法增大)
  3. 超时重传:计时器超时!说明网络拥塞

    当等待队列中出现 计时器超时 时,说明网络拥塞,重新慢启动

    • ssthresh = cwnd / 2 ,慢启动阈值设为拥塞窗口的一半
    • cwnd = 1,重新开始慢启动
  4. 快重传/快恢复:收到多个重复 ACK 时,说明轻微丢包,轻微拥塞

    接收端收到乱序包时,会发送 重复 ACK 通知发送端。当发送端收到 3 个 重复 ACK 时,就立刻开始重传,而不必继续等待到计时器超时。

    • ssthresh = cwnd / 2 ,慢启动阈值设为拥塞窗口的一半
    • cwnd = ssthresh,重新开始拥塞避免过程

​ 为什么快速重传不需要像超时重传那样,将 cwnd 重置为 1 重新开始慢启动呢?因为它认为如果网络出现拥塞的话,是不会收到好几个重复的 ACK 的,所以现在网络可能没有出现拥塞

image-20240801114559664

UDP

如何使用 UDP 实现可靠传输

如果想要实现可靠传输,那么就需要在应用层设计合理的协议,至少要满足序号、确认、重传、流量控制、拥塞控制

  1. 序号

    • 序号机制可以保证数据包的先后顺序,供接收方来进行数据重排;
    • 序号机制是实现确认和重传的基础;有了序号才能匹配确认报文,进而对超时未确认报文的重传;
  2. 确认

    • 接收方需要对收到的报文进行确认

可以像 TCP 那样确认号代表之前报文全部收到,也可以牺牲一些带宽进行选择性确认。

  1. 重传

    • 发送方发送完一个报文后,将其放入待确认队列,并开启计时器,如果计时器超时,则重传该报文
  2. 流量控制

    • 可以像 TCP 那样,使用滑动窗口机制,发送方允许发送的数据受到发送方的发送窗口大小接收方的接收窗口大小 ,以及拥塞窗口大小 确定。
  3. 拥塞控制

    • 也可以像 TCP 那样,使用慢开始、拥塞避免、快重传和快恢复等处理方式进行拥塞控制

TCP v.s.UDP

TCP 和 DUP 的区别

首先最重要的就是二者的 3 个特征点:

TCP 提供面向连接可靠的双向字节流传输

UDP 提供无连接、不可靠数据报传输

TCP UDP
连接性 面向连接 无连接
可靠性 可靠 不可靠
传输方式 面向字节流 面向数据报(保留报文的边界)
传输速度
双工性 全双工 一对一、一对多、多对一、多对多
流量控制 / 拥塞控制
应用场景 对效率要求相对低,但是对准确性要求高的场景;或是要求有连接的场景。如文件传输、发送邮件等 对效率要求相对高,对准确性要求相对低的场景。如即时通信、直播等
应用层协议 SMTP(电子邮件)、TELNET(远程登录控制)、HTTP、FTP DNS、TFTP(文件传输)、DHCP(动态主机配置)…

UDP 为什么比 TCP 快

  1. 头部大小:UDP 报头(8 字节)比 TCP 报头(最小 20 字节)更小,所以传输速度更快
  2. 连接:TCP 面向连接需要进行 三次握手、四次挥手 等动作,而 UDP 则不需要
  3. 可靠性保证:TCP 为了保证可靠性,需要确认机制、超时重传、乱序重排、拥塞,流量控制等机制,大大增加了网络传输延时。

网络编程

socket 中各函数,对应客户端和服务器哪些行为

  1. 【服务器】使用 socket 函数创建一个 socket

  2. 【服务器】使用 bind 为 socket 设置 IP 地址和 端口

  3. 【服务器】使用 listen 将 socket 设为 LISTEN 状态,内核开始监听 IP & 端口,这个过程,内核会完成三次握手的过程(SYN Queue),建立 TCP 连接,将连接放入 Accept Queue,供 accept 获取

  4. 【服务器】使用 accept 完成连接的建立,如果 Accept Queue 为空,该函数会阻塞,否则取出一个连接,并为其创建一个新的 socket,并返回。

  5. 【服务器】一般情况下,主线程会创建新的线程,并使用 accept 返回的新 socket 进行读写

  6. 【客户端】使用 socket 函数创建一个 socket

  7. 【客户端】使用 connect 函数完成 三次握手过程,一般情况下,该函数会阻塞,直到连接创建成功

  8. 【客户端】使用 read / write 等函数进行读写

  9. 【客户端】使用 close 函数关闭 socket,引用计数减一 或 内核完成四次挥手

socket 通信时,进程崩溃会发生什么

对于 TCP 连接:

【本地崩溃】:本地进程崩溃,操作系统会回收进程 socket 资源,通常会发送连接重置报文到对端

【对端崩溃】:

  • 如果收到 RTS 连接重置报文,那可以进行相应处理,比如关闭 socket;
  • 如果没收到,那么会在报文达到最大重传次数时,关闭连接

对于 UDP 连接:

【本地崩溃】:本地进程崩溃,操作系统会回收进程 socket 资源

【对端崩溃】:UDP 是无连接的,因此对端进程崩溃不会直接影响到本地进程,只会发现数据未到达

socket 读写如何区分缓冲区空/满还是连接已断开

  1. read 读取时
  • 连接断开

​ 返回 0 表示正常连接关闭;

​ 返回 -1errno 设为 ECONNRESET,表示异常关闭;

  • 缓冲区空

    返回 -1errno 设为 EAGAIN,表示缓冲区空;

  1. write 写入时

    • 连接断开

      返回 -1errno 设为 EPIPE,表示连接关闭;

    • 缓冲区空

      返回 -1errno 设为 EAGAIN,表示缓冲区满;

返回 -1errno 设为 EAGAIN,表示缓冲区空;

epoll 比 select 高效的原因

  1. select 是通过遍历检查 文件描述符数组,来查看每个 fd 状态的,这种主动查询的方式效率低下;而epoll 是通过向驱动程序中的等待队列注册回调函数实现让每个 fd 自动回调通知的。具体来说,
  2. 其次,每次调用 select 都需要将文件描述符数组从用户层拷贝到内核,而 epoll 在使用 epoll_ctl 将文件描述符添加到内核后,每次调用 epoll_wait 无需再次拷贝

使用 LT 水平触发,监听多个写事件,如何保证写就绪后 epoll_wait 下一次不返回

从 epoll_wait 的实现上来看,将就绪 fd 返回用户空间时,epoll 会将就绪链表中的 epitem 拷贝到一个临时链表,并将 LT 触发的 epitem 再放回原就绪链表中,这样就实现了多次返回。

而在将每个 fd 返回用户空间之前,epoll 在次调用驱动的 poll 函数,检查就绪状态(这里调用 poll 函数传入的 poll_table 为 NULL,不会加入等待队列),只有就绪状态仍符合的 fd 才会被返回用户空间。

通过上面的分析,这个问题就容易解答了,只需要在写就绪后,循环写入缓冲区,直到缓冲区满即可。这样下次调用 epoll_wait 时,会再次调用驱动 poll 函数,发现就绪状态不满足了,就不会返回。

如果用户程序没有那么多数据要写,无法填满缓冲区,那么就需要将 fd 从 epoll 监听的红黑树中移除,待下次有数据要写入时再放入 epoll。

epoll 为什么使用红黑树做数据结构

在使用 epoll_ctl 函数时,会涉及 fd 的 查找、插入、删除操作,而红黑树是一颗较平衡的二叉查找树,其插入、查找、删除操作在所有数据结构中最为优秀,平均时间复杂度均为 O(logn) ,所以为了实现更快的插入、查找、删除速度,epoll 使用了红黑树作为数据结构。

描述使用 epoll 实现服务端调用 api 的流程

  1. 使用 socketbindlisten 创建、绑定并监听一个套接字 sfd

  2. epoll_create 创建 epoll 对象;

  3. epoll_ctl 将刚刚创建的套接字加入到 epoll 对象的监控列表中;

  4. 进入事件循环:

    1. 使用 epoll_wait 查看套接字状态,对于返回的 n 个事件,循环检查下面中情况;

      • events[i].data.fd == sfd 代表 1~N 个新连接的建立,需要循环 accept 这些连接,并将新创建的 fd 添加到 epoll 的监控列表;

      • events[i].events & EPOLLERR 代表出现错误,需要 close 相应的 fd,这会自动从 epoll 移除;

      • 最后一种代表有数据可读/可写,这时需要使用 read / write 处理数据;

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
sfd = socket();
bind(sfd, addr, addrlen);
listen(sfd);

ep = epoll_create();

epoll_event.data.fd = sfd;
epoll_event.events = EPOLLIN | EPOLLET;
epoll_ctl(ep, EPOLL_CTL_ADD, sfd, epoll_event);

/* Event Loop */
while(1) {
n = epoll_wait(ep, events, MAXEVENTS, timeout);

for(i = 0; i < n; i++) {
fd = events.data.fd;

if (events[i].events & EPOLLERR) {
close(fd);
} else if (fd == sfd) {
while ((infd = accept(sfd, &in_addr, &in_len)) > 0) {
event.data.fd = infd;
event.events = EPOLLIN | EPOLLET;
s = epoll_ctl(ep, EPOLL_CTL_ADD, infd, &event);
}
} else {
while(read(fd, buf, sizeof buf)) {
handle_data();
}
}
}
}

sendfile 系统调用解决的问题

sendfile 是 零拷贝 技术的一种具体实现。

以 web 服务器为例,使用传统的 IO 方式,需要使用 readwrite 系统调用:

  1. 将数据从磁盘拷贝到 内核缓冲区,可以使用 DMA 方式;
  2. 将数据从 内核缓冲区 拷贝到 用户缓冲区,这需要 CPU 处理;
  3. 将数据从 用户缓冲区 拷贝到 socket 缓冲区,这也需要 CPU 处理;
  4. 最后使用 使用 DMA 将数据从网卡发送出去;

这种使用传统 IO 的方式,涉及 2 次数据拷贝,以及 4 次上下文切换,影响系统效率。而且,由于用户层无需对数据进行修改,所以向用户层拷贝数据是无意义的。

sendfile ** 系统调用的提出就是为了解决数据的无效拷贝**问题,对于上面的场景使用 sendfile 系统调用:

  1. 将数据从磁盘拷贝到 内核缓冲区,可以使用 DMA 方式;
  2. 将数据从 内核缓冲区 之间拷贝到 socket 缓冲区,这需要 CPU 处理;
  3. 使用 DMA 将数据从网卡发送出去;

可见,使用 sendfile 系统调用仅涉及 1 次数据拷贝,1 次上下文切换,效率很高。

更进一步,如果网卡支持 SG-DMA,那么数据可以之间从 内核缓冲区 通过 SG-DMA 技术发送到网卡,全程无需 CPU 进行数据拷贝!

编程基础

程序运行

gcc 编译过程

  • 预处理:包括头文件展开条件编译宏替换删除注释 等工作
  • 编译阶段:包括词法分析、语法分析、语义分析等,完成c语言到汇编语言的转换
  • 汇编阶段:将汇编代码转化为机器码,生成目标文件
  • 链接阶段:包括符号解析和重定位等,将多个目标文件合并成一个可执行文件或库文件

ELF 文件格式

  1. ELF 头部:记录了 ELF 文件的元信息,如 ELF 文件类型(可执行、目标、共享库)、入口地址等
  2. 程序头表:记录了 ELF 文件的段信息,段信息包括段类型与大小段在 ELF 文件中的偏移段被加载到的内存地址等,这些信息告定义了文件在内存中的布局
  3. 节头表:记录了 ELF 文件的节信息,包括各个节的名称、类型、偏移、大小
  4. 节:记录了 ELF 文件的代码和数据,如 .text.data.bss.rodata 等节;还记录了符号和重定位信息,如 .symtab 符号表节、.rel.text 重定位表节

静态链接的过程

  1. 收集输入文件:包含目标文件静态库文件,这些文件由.text.data.symtab.rel.text 等节构成
  2. 符号解析:根据各个目标文件的符号表,生成全局符号表(符号名称、类型、位置、作用域等),这个过程会检查未定义和重复定义错误
  3. 重定位:目标文件的重定位表记录了每个外部定义的符号,链接器会扫描每个文件的重定位表中的条目,从全局符号表中确认符号的最终地址,并修正
  4. 最后,生成程序头表设置入口地址生成可执行文件

动态链接的过程

  1. 输入文件:包含目标文件动态库文件

  2. ld 生成动态链接信息:生成动态符号表动态重定位表过程链接表(PLT)全局偏移表(GOT) 等节

  3. 加载到内存:加载器将 .text.data 数据代码加载到内存,并根据 PT_INTERP 段加载动态链接器

  4. 立即绑定:

    • 符号解析:解析可执行文件共享库中的动态符号表,检查未定义和重复定义错误

    • 重定位:根据动态重定位表,对程序中的符号引用进行修正

  5. 延迟绑定(符号解析和绑定的负担分散到程序的运行过程中,减少了启动时的延迟):

    • 初始:PLT 初始指向 GOT,GOT 初始指向 PLT0

    • 初次调用:借助 PLT,GOP,跳转到 PLT0,使用动态链接器解析符号地址,更新 GOT 表

    • 后续调用:后续调用直接使用 GOT 中的地址

在实际的动态链接过程中,立即绑定和延迟绑定可以结合使用。部分关键符号可能在加载时立即解析,而其他符号采用延迟绑定。

gdb 用过哪些指令

  • run 指令运行程序
  • breakpoint 打断点
  • countne 让程序继续运行,直到下一个断点
  • print 打印一些变量值
  • backtrace 查看堆栈信息
  • info thread 查看线程
  • thread 1 切换线程

CPU 占用率过高,如何查原因

  1. 使用 top 命令可以查看系统各个进程的 CPU 使用情况;

  2. 找到 CPU 占有率高的进程可以使用 strace -p <pid> 查看进程使用的系统调用;

  3. 还可以使用 perf -p <pid> record -g -F <frequent> 生成性能分析报告;使用 perf report -g 查看报告,该报告记录了热点函数所占的 CPU 时间,帮助我们找到性能瓶颈。

  4. 找到热点函数后,

    • 可以检查该函数使用的算法是否合理,是否还有优化空间;

    • 检查数据结构设计是否合理,例如,涉及频繁的查找时使用哈希表,涉及频繁插入、删除时使用红黑树等;

    • 函数是否频繁进行系统调用(如文件读写、网络操作),可以考虑使用零拷贝mmap 等优化;

    • 如果函数涉及多线程,需要检查锁竞争线程同步问题,可以使用 valgrind --tool=helgrind <bin> (配合 gdb 断点,可以检测单个函数)来检查潜在的数据竞争,或者使用更细粒度的锁;

内存泄漏如何检查或避免

什么是内存泄漏?进程动态申请的内存空间,在使用完毕后未释放

如何避免:

  1. 编程时应保持警觉,确保 malloc/free 配对使用;
  2. 在 C++ 中,可以使用智能指针(如 std::unique_ptrstd::shared_ptr )来自动管理内存;
  3. 在 C++ 中,对象的生命周期由操作系统来管理,故可以使用 RAII 机制管理动态申请的内存资源;

如何检测:

  1. valgrind 是一个内存管理分析工具,可以检测内存泄露、未初始化的内存访问、数组越界等问题。使用命令 valgrind --leak-check=yes ./bin 检测内存泄漏;
  2. address_sanitizer 是一个用于检测内存错误的编译器插件,大部分编译器集成了它,编译时使用 -fsanitize=address 选项启用;

C/C++

static 关键字

  • static 修饰函数或全局变量时,可以将函数和全局变量的作用域限制在当前文件,带来了更好的封装,避免命名冲突
  • static 修饰局部变量时,会影响局部变量的生命周期(编译时分配,程序结束时释放),并且仅初始化一次

volatile 关键字

  • 禁止编译器优化,不让变量被缓存在寄存器中,而是每次都直接从内存读取或者写入,这样保证了每次都能获取最新的值

场景

  1. 硬件寄存器的值,这些外部寄存器的值可能随时被设备更改,因此需要声明为 volatile
  2. 中断程序修改的全局变量,避免编译器优化在主程序中未修改,但是可能在中断处理程序中修改的全局变量
  3. 多线程共享变量,使得一个线程对共享变量的修改能及时被其他线程看到

inline 关键字

用于提示编译器将函数展开,而不是进行函数调用,这可以减少函数调用的开销,提升性能,尤其频繁调用的函数;而对于逻辑复杂的函数内联往往会增大代码体积,影响性能。

extern 关键字

  • extern 关键字用于声明外部变量或函数,表明它们的定义在其他文件

  • 在 C++ 中,extern "C" 用于与 C 语言兼容的编译和链接,特别在与 C 语言混合编程时使用。

通信协议

UART 总线协议

  • 概述 :uart 是一种硬件 通信协议,使用 uart 协议可以实现异步、串行、全双工 的数据通信(异步–即无需时钟信号)
  • 接口/物理层 :使用 TX 接口串行发送数据,RX 接口串行接收数据,此外还有一根接地线
    • img
  • 通信过程/协议层
    • 速度:通信双方要在传输开始前约定好波特率 ,常见的有 9600,19200,115200 等

    • 发送方以 UART 数据包的形式发送数据

    • img

    • 空闲 :不传输数据时,传输数据线上总是保存高电平

    • 起始位:将电平拉低 ,并保持一个周期

    • 数据位5-8 位数据位,低有效位在前,高有效位在后(多字节数据也是如此,类似小端模式),低电平表示 0,高电平表示 1

    • 奇偶校验位 :用于检查数据是否出错

    • 停止位1-2 位,保持高电平以示终止

    • img

IIC (I2C) 协议

  • 概述 :i2c 是一个主从架构串行通信 总线,可以同时连接多个主机和从机,用于连接嵌入式系统周边的低速设备
  • 接口/物理层 :使用两条线路,SDA 传输数据信息,SCL 传输同步时钟
    • 传输速率 :标准模式 100 K,快速模式 400 K,高速模式 3.4 M

img

  • 通信过程/协议层
    • 数据有效性 :靠时钟来确定 i2c 协议的数据有效性,SCL 高电平时,SDA 要保持稳定,在 SCL 低电平时,SDA 才能改变
    • 主机写从机过程
      • img

      • 开始信号 :在 SCL 高电平时,SDA 从高电平变为低电平

      • 地址命令 :传输 7位 从机地址和 1位 写命令后,等待从机应答

      • 应答信号 :从机在下个时钟会应答主机,低电平表示应答,高电平表示非应答

      • 传输数据 :紧接着,主机会向从机传输数据,高有效位先传输(类似大端)每个字节发送后,等待从机的应答

      • 结束信号 :在 SCL 高电平时,SDA 从低电平变为高电平

    • 主机先****写后读
      • img

      • 开始信号 :同上

      • 地址命令 :同上

      • 应答信号 :同上

      • 传输寄存器地址:告诉从机后面要被写入的寄存器地址

      • 重新开始信号 :同开始信号类似,在次开始可以用于读写命令转换

      • 地址命令 :同上

      • 应答信号 :同上

      • 传输数据 :同上

      • 结束信号 :同上

SPI 协议

  • Spi 是一种主从架构全双工串行同步 通信协议,适用于对通信速度要求较高的场合,原生不支持多主机,可魔改

  • 物理层

    • SCK :时钟信号线,由主机产生,用于实现数据同步
    • MISO :主机输入从机输出线,顾名思义,主机通过这条线输入数据,从机输出数据
    • MOSI :主机输出从机输入线,同上
    • CS :片选信号线,用来让主机选择从机通信,每多一个从机,需要增加一条片选信号线
    • 传输速度 :传输速度不固定,由时钟分频系数配置确定,几百k ~几十M Hz
    • img
  • 协议层

    • 工作模式 :根据时钟极性CPOL)、时钟相位CPHA)可以分为四种工作模式,此外也可配置MSBLSB

      • CPOL CPHA 工作模式
        0 0 空闲时时钟引脚(SCK)为电平,第个边沿进行数据采样
        0 1 空闲时时钟引脚(SCK)为电平,第个边沿进行数据采样
        1 0 空闲时时钟引脚(SCK)为电平,第个边沿进行数据采样
        1 1 空闲时时钟引脚(SCK)为电平,第个边沿进行数据采样
    • 通信时序

      • 开始 :主机拉低对应从机的片选线,并产生时钟信号
      • 传输 :根据工作模式的选择,确定数据有效性,如配置 CPOL=0 CPHA=0 时,MOSI MISO 引脚在上升沿 进行数据采样,下降沿进行数据准备
      • 结束 :主机拉高从机的片选线 ,结束传输
  • 用途 :EEPROM,FLASH,实时时钟,ADC 转换器

CAN 总线

介绍一下 CAN 总线协议

概述 :can 是一种支持多节点的,半双工,串行,****异步通信协议

物理层 :物理层根据通信速率 ,分为开环总线闭环总线

  • 高速CAN

img

  • 低速 CAN

img

  • CAN_HighCAN_Low 两个引脚形成了差分信号 进行传输

协议层

  • 速度 :CAN 总线通信节点间使用约定好的波特率进行通讯
  • 数据位分段
    • SS段,同步段;通信节点会检测信号的跳变是否发送在同步段,以此来抗干扰和防止误差
    • PTS段,传播时间段 ;用来补偿物理时延
    • PBS1段,相位缓冲段1;重新同步时,通过延长 该段减小/消除误差
    • PBS2段,相位缓冲段2;重新同步时,通过缩短 该段减小/消除误差

img

  • 总线上的各个通讯节点只要约定好 1 个 Tq 的时间长度以及每一个数据位占据多少个 Tq,就可以确定 CAN 通讯的波特率
  • 同步过程 : CAN 的数据同步分为硬同步和重新同步
    • 硬同步 :硬同步只是在帧起始信号 时起作用,通过扩大PBS1 或者缩小PBS2 来使得 信号跳变落入SS
    • 重新同步 :重新同步则利用普通数据位的高至低电平的跳变沿来同步,方法也会类似的
  • CAN报文
    • 数据帧 :向其他节点传输数据
    • 遥控帧 :向其他节点请求数据
    • 错误帧 :向其他节点通知校验错误,请求重发数据
    • 过载帧 :向其他节点通知过载,表示未做好接收数据的准备
    • 帧间隔 :用于将数据帧遥控帧 和前面的帧分离开
  • 数据帧****结构:
    • img

    • SOF帧起始:逻辑 0,通知其他节点有数据传输

    • 仲裁段

      • 报文ID: ID 决定着数据帧发送的优先级,也决定着其它节点是否会接收这个数据帧
      • RTR位:用来区分数据帧遥控帧
    • 控制段

      • DLC:数据段长度
    • 数据段 :需要传输的数据

    • CRC :循环冗余校验码

    • ACK :接收数据节点响应

    • EOF :报文结束段

  • 遥控帧结构

img

  • 错误帧结构

img

  • 过载帧结构

img

  • 隔帧结构

img

安全

SSL/TLS 握手过程

经典的 RSA 握手:
  1. client hello:客户端发送 客户端随机数TLS 版本加密套件列表
  2. server hello:回复客户端的 client hello,包含 服务端随机数数字证书选择的加密套件
  3. 客户端:
    • 身份验证:验证服务器的数字证书
    • 预主密钥:客户端提取数字证书中的公钥,并使用该公钥加密随机生成的预主密钥,发送给服务器
    • 完成:根据 客户端随机数、服务器随机数以及预主密钥,生成已完成消息,发送给服务器
  4. 服务器:
    • 预主密钥:服务器端使用私钥解密预主密钥
    • 完成:根据 客户端随机数、服务器随机数以及预主密钥,生成已完成消息,发送给客户端
TLS 1.2 版本 DH 握手
  1. client hello:客户端发送 客户端随机数TLS 版本加密套件列表
  2. server hello:回复客户端的 client hello,包含 服务端随机数SSL 证书选择的加密套件,以及服务器签名(使用服务器私钥对客户端随机数服务端随机数服务端参数进行签名)与服务端参数(DH算法所需)
  3. 客户端:
    • 身份验证:验证服务器的数字证书,并验证签名
    • 发送参数:将客户端参数(DH算法所需)发送给服务器,
    • 预主密钥:根据客户端参数预服务器端参数,使用 DH 算法生成预主密钥
    • 完成:根据 客户端随机数、服务器随机数以及预主密钥,生成已完成消息,发送给服务器
  4. 服务器:
    • 预主密钥:接收客户端参数,并客户端参数预服务器端参数,使用 DH 算法生成预主密钥
    • 完成:根据 客户端随机数、服务器随机数以及预主密钥,生成已完成消息,发送给客户端
TLS 1.3 版本的握手
  1. client hello:客户端发送 客户端随机数TLS 版本加密套件列表。客户端问候消息还包括将用于计算预主密钥的参数

    大体上来说,假设客户端知道服务器的首选密钥交换方法(由于简化的密码套件列表,它有可能知道)。这减少了握手的总长度——这是 TLS 1.3 握手与 TLS 1.0、1.1 和 1.2 握手之间的重要区别之一。

  2. server hello:回复客户端的 client hello,包含 服务端随机数SSL 证书选择的加密套件服务端参数

  3. 客户端:

    • 身份验证:验证服务器的数字证书,并验证签名
    • 预主密钥:根据客户端参数预服务器端参数,使用 DH 算法生成预主密钥
    • 完成:根据 客户端随机数、服务器随机数以及预主密钥,生成已完成消息,发送给服务器
  4. 服务器:

    • 预主密钥:接收客户端参数,并客户端参数预服务器端参数,使用 DH 算法生成预主密钥
    • 完成:根据 客户端随机数、服务器随机数以及预主密钥,生成已完成消息,发送给客户端