IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 系统运维 -> 2021年8月上中旬好文收藏(1) -> 正文阅读

[系统运维]2021年8月上中旬好文收藏(1)

2021年8月25日22:23:16

目录

Linux Kernel TCP/IP Stack|Linux网络硬核系列

浅谈 Protobuf 编码

Linux 内核调试利器 | kprobe 的使用

Linux内存管理:SPARSEMEM模型:

一文说清OpenCL框架

HTTPS 中的 TLS 握手过程可以同时进行三次握手」对不对

Linux 信号处理原理与实现


Linux Kernel TCP/IP Stack|Linux网络硬核系列

https://mp.weixin.qq.com/s/63HBz8DGPjLeNd43kaOunw

?

?

?

?

?

?

?

?

?

?

?

?

?

?

?

?

浅谈 Protobuf 编码

https://mp.weixin.qq.com/s/enDUynhZ1Pnzg_4xEjR21A

作者:SG4YK,腾讯 PCG 后台开发工程师

近日简单学习了 Protobuf 中的编码实现,总结并整理成文。本文结构总体与 Protobuf 官方文档相似,不少内容也来自官方文档,并在官方文档的基础上添加作者理解的内容,如有出入请以官方文档为准。作者水平有限,难免有疏漏之处,欢迎指正并分享您的意见。

0x00 Before you start

简单来说,Protobuf 的编码是基于变种的 Base128。在学习 Protobuf 编码或是 Base128 之前,先来了解下 Base64 编码。

0x01 Base 64

当我们在计算机之间传输数据时,数据本质上是一串字节流。TCP 协议可以保证被发送的字节流正确地达到目的地(至少在出错时有一定的纠错机制),所以本文不讨论因网络因素造成的数据损坏。但数据到达目标机器之后,由于不同机器采用的字符集不同等原因,我们并不能保证目标机器能够正确地“理解”字节流。

Base 64 最初被设计是用于在邮件中嵌入文件(作为 MIME 的一部分)。它可以将任何形式的字节流编码为“安全”的字节流。何为“安全“的字节?先来看看 Base 64 是如何工作的。

假设这里有四个字节,代表你要传输的二进制数据。

?首先将这个字节流按每 6 个 bit 为一组进行分组,剩下少于 6 bits 的低位补 0。

?然后在每一组 6 bits 的高位补两个 0。

?

对照 Base 64 table,字节流可以用?ognC0w?来表示。另外,Base64 编码是按照 6 bits 为一组进行编码,每 3 个字节的原始数据要用 4 个字节来储存,编码后的长度要为 4 的整数倍,不足 4 字节的部分要使用 pad 补齐,所以最终的编码结果为ognC0w==

任意的字节流均可以使用 Base 64 进行编码,编码之后所有字节均可以用数字、字母和 + / = 号进行表示,这些都是可以被正常显示的 ascii 字符,即“安全”的字节。绝大部分的计算机和操作系统都对 ascii 有着良好的支持,保证了编码之后的字节流能被正确地复制、传播、解析。

注:下文关于字节顺序内容均基于机器采用小端模式的前提进行讨论。

0x02 Base 128?

Base 64 存在的问题就是,编码后的每一个字节的最高两位总是 0,在不考虑 pad 的情况下,有效 bit 只占 bit 总数的 75%,造成大量的空间浪费。是否可以进一步提高信息密度呢?

意识到这一点,你就很自然能想象出 Base 128 的大致实现思路了,将字节流按 7 bits 进行分组,然后低位补 0。

但问题来了,Base 64 实际上用了 64+1 个 ascii 字符,按照这个思路 Base 128 需要使用 128+1 个 ascii 个字符,但是 ascii 字符一共只有 128 个。另外,即使不考虑 pad,ascii 中包含了一些不可以正常打印的控制字符,编码之后的字符还可能包含会被不同操作系统转换的换行符号(10 和 13)。因此,Base 64 至今依然没有被 Base 128 替代。

Base 64 的规则因为上述限制不能完美地扩展到 Base 128,所以现有基于 Base 64 扩展而来的编码方式大部分都属于变种。如 LEB128(Little-Endian Base 128), Base 85 (Ascii 85),以及本文的主角:Base 128 Varints。

注:下文关于字节顺序内容均基于机器采用小端模式的前提进行讨论。

0x03 Base 128 Varints

Base 128 Varints 是 Google 开发的序列化库 Protocol Buffers 所用的编码方式。以下为 Protobuf 官方文档中对于 Varints 的解释:

Varints are a method of serializing integers using one or more bytes. Smaller numbers take a smaller number of bytes.

使用一个或多个字节对整数进行序列化。小的数字占用更少的字节。简单来说,就是尽量只储存整数的有效位,高位的 0 尽可能抛弃。

有两个需要注意的细节:

  • Base 128 Varints 只能对一部分数据结构进行编码,不适用于所有字节流(当然你可以把任意字节流转换为 string,但不是所有语言都支持这个 trick)。否则无法识别哪部分是无效的 bits。

  • Base 128 Varints 编码后的字节可以不存在于 Ascii 表中,因为和 Base 64 使用场景不同,不用考虑是否能正常打印。

下面以例子进行说明 Base 128 Varints 的编码实现。

对于编码后的每个字节,低 7 位用于储存数据,最高位用来标识当前字节是否是当前整数的最后一个字节,称为最高有效位(most significant bit, msb)。msb 为 1 时,代表着后面还有数据;msb 为 0 时代表着当前字节是当前整数的最后一个字节。

举个例子,下面是编码后的整数1。1 只需要用一个字节就能表示完全,所以 msb 为 0。

ASN.1编解码:asn1c的版本分析-诺基亚

Linux 内核调试利器 | kprobe 的使用

https://mp.weixin.qq.com/s/ttRxAdhjHF24I0mQvekfJA

软件调试?是软件开发中一个必不可少的过程,通过软件调试可以排查系统中存在的 BUG。我们在开发应用层程序时,可以使用?GDB?对程序进行调试。但由于 GDB 只能调试应用层程序,并不能用于调试内核代码。

那么,如何调试内核代码呢?与调试应用层程序的 GDB 类似,调试内核代码也有个名叫?KGDB?的工具,但是使用起来比较繁琐。所以,本文将会介绍一个使用起来比较简单的内核调试工具:kprobe

本篇文章主要介绍?kprobe?的使用,下篇文章将会介绍?kprobe?的实现原理。

回忆一下我们在开发应用程序时是怎样调试代码的?最原始的方法就是,在代码中使用?printf?这类打印函数把结果输出到屏幕或者日志中。当然在内核中有类似的打印函数:printk,但使用?printk?函数调试内核代码的话,必须要重新编译 Linux 内核代码,代价非常高。

所以,内核开发者们开发出一种不需要重新编译内核代码的调试工具:kprobe

kprobe?可以让用户在内核几乎所有的地址空间或函数(某些函数是被能被探测的)中插入探测点,用户可以在这些探测点上通过定义自定义函数来调试内核代码。

用户可以对一个探测点进行执行前和执行后调试,在介绍?kprobe?的使用方式前,我们先来了解一下?struct kprobe?结构,其定义如下:

struct kprobe {
    ...
    kprobe_opcode_t        *addr;
    const char             *symbol_name;
    unsigned int           offset;

    kprobe_pre_handler_t   pre_handler;
    kprobe_post_handler_t  post_handler;
    kprobe_fault_handler_t fault_handler;
    ...
};

一个?struct kprobe?结构表示一个探测点,下面介绍一下其各个字段的作用:

  • addr:要探测的指令所在的内存地址(由于需要知道指令的内存地址,所以比较少使用)。

  • symbol_name:要探测的内核函数,symbol_name?与?addr?只能选择一个进行探测。

  • offset:探测点在内核函数内的偏移量,用于探测内核函数内部的指令,如果该值为0表示函数的入口。

  • pre_handler:在探测点处的指令执行前,被调用的调试函数。

  • post_handler:在探测点处的指令执行后,被调用的调试函数。

  • fault_handler:在执行?pre_handlerpost_handler?或单步执行被探测指令时出现内存异常,则会调用这个回调函数。

一个?kprobe?探测点的执行过程如下图所示:

从上面的介绍可知,kprobe?一般用于调试内核函数。

kprobe 使用

接下来,我们介绍一下怎么使用?kprobe?来调试内核函数。

使用?kprobe?来进行内核调试的方式有两种:

  • 第一种是通过编写内核模块,向内核注册探测点。探测函数可根据需要自行定制,使用灵活方便;

  • 第二种方式是使用?kprobes on ftrace,这种方式是?kprobe?和?ftrace?结合使用,即可以通过?kprobe?来优化?ftrace?来跟踪函数的调用。

由于第一种方式灵活而且功能更为强大,所以本文主要介绍第一种使用方式。

要编写一个?kprobe?内核模块,可以按照以下步骤完成:

  • 第一步:根据需要来编写探测函数,如?pre_handler?和?post_handler?回调函数。

  • 第二步:定义?struct kprobe?结构并且填充其各个字段,如要探测的内核函数名和各个探测回调函数。

  • 第三步:通过调用?register_kprobe?函数注册一个探测点。

  • 第四步:编写 Makefile 文件。

  • 第五步:编译并安装内核模块。

接下来就按照上面的步骤来完成一个?kprobe?的内核模块。

1. 定义回调函数

第一步就是编写追踪的回调函数,一般来说只需要编写?pre_handlerpost_handler?和?fault_handler?这三个回调函数,当然也可以只编写你想追踪的其中某一个回调函数。下面我们将会完成这三个追踪回调函数的编写:

pre_handler 回调函数

我们首先编写要追踪的内核函数被调用前的回调函数?pre_handler,代码如下:

static int pre_handler(struct kprobe *p, struct pt_regs *regs)
{
    printk(KERN_INFO "pre_handler: p->addr = 0x%p, ip = %lx, flags = 0x%lx\n",
           p->addr, regs->ip, regs->flags);
    return 0;
}

上面的函数只是简单的打印了要追踪的内核函数的内存地址、ip?寄存器和?flags?寄存器的值,在函数的定义中可以发现有个类型为?pt_regs?结构的参数 ,其主要保存了 CPU 各个寄存器的值,不同 CPU 架构的定义不一样,例如 x86 CPU 架构的定义如下:

struct?pt_regs?{
????long?ebx;????????//?ebx寄存器
????long?ecx;????????//?ecx寄存器
????long?edx;????????//?edx寄存器
????long?esi;????????//?esi寄存器
????long?edi;????????//?edi寄存器
????long?ebp;????????//?ebp寄存器
????long?eax;????????//?eax寄存器
????int??xds;????????//?ds寄存器
????int??xes;????????//?es寄存器
????int??xfs;????????//?fs寄存器
????long?orig_eax;???//?...
????long?eip;????????//?eip寄存器
????int??xcs;????????//?cs寄存器
????long?eflags;?????//?eflags寄存器
????long?esp;????????//?esp寄存器
????int??xss;????????//?ss寄存器
};

所以我们可以通过这个结构来获取 CPU 各个寄存器的值。

post_handler 回调函数

接着我们来编写要追踪的内核函数被调用后的回调函数?post_handler,其代码如下:

static?void?
post_handler(struct?kprobe?*p,?struct?pt_regs?*regs,?unsigned?long?flags)
{
????printk(KERN_INFO?"post_handler:?p->addr?=?0x%p,?flags?=?0x%lx\n",
???????????p->addr,?regs->flags);
}

post_handler?回调函数也只是简单的打印了要追踪的内核函数的内存地址和?flags?寄存器的值。

fault_handler 回调函数

最后我们来编写当发生内存异常时的回调函数?fault_handler,其代码如下:

static?int?fault_handler(struct?kprobe?*p,?struct?pt_regs?*regs,?int?trapnr)
{
????printk(KERN_INFO?"fault_handler:?p->addr?=?0x%p,?trap?#%dn",
???????????p->addr,?trapnr);
????return?0;
}

fault_handler?回调函数打印了要追踪的内核函数的内存地址和发生异常时的异常编号。

2. 定义 kprobe 结构

接下来我们定义一个?struct kprobe?结构并且填充其各个字段值,代码如下:

static?struct?kprobe?kp?=?{
????.symbol_name???=?"do_fork",??????//?要追踪的内核函数为?do_fork
????.pre_handler???=?pre_handler;????//?pre_handler?回调函数
????.post_handler??=?post_handler;???//?post_handler?回调函数
????.fault_handler?=?fault_handler;??//?fault_handler?回调函数
};

由于我们要追踪?do_fork?内核函数,所以在?kprobe?结构的?symbol_name?设置为?do_fork?字符串,然后设置各个回调函数即可。

3. 注册追踪点

最后通过调用?register_kprobe?函数来注册追踪点,代码如下:

static?int?__init?kprobe_init(void)
{
????int?ret;

????ret?=?register_kprobe(&kp);?//?调用?register_kprobe?注册追踪点
????if?(ret?<?0)?{
????????printk(KERN_INFO?"register_kprobe?failed,?returned?%d\n",?ret);
????????return?ret;
????}
????printk(KERN_INFO?"planted?kprobe?at?%p\n",?kp.addr);
????return?0;
}

static?void?__exit?kprobe_exit(void)
{
????unregister_kprobe(&kp);?//?调用?unregister_kprobe?注销追踪点
????printk(KERN_INFO?"kprobe?at?%p?unregistered\n",?kp.addr);
}

module_init(kprobe_init)?//?注册模块初始化函数
module_exit(kprobe_exit)?//?注册模块退出函数
MODULE_LICENSE("GPL");

4. 编写 Makefile 文件

Makefile 文件用于编译内核模块时使用,一般来说编译内核模块的 Makefile 格式相对固定,如下:

obj-m?:=?kprobe_example.o?#?编译后的二进制文件名
?
CROSS_COMPILE=''
KDIR?:=?/lib/modules/$(shell?uname?-r)/build
all:
????????make?-C?$(KDIR)?M=$(PWD)?modules?
clean:
????????rm?-f?*.ko?*.o?*.mod.o?*.mod.c?.*.cmd?*.symvers??modul*

5. 编译并安装内核模块

最后,我们编译并且安装这个内核模块,命令如下:

$?make
$?sudo?insmod?kprobe_example.ko

安装完成后,随便敲入一个命令(如?ls),然后通过调用?dmesg?命令查看内核模块输出的结果,如下所示:

...
planted?kprobe?at?ffffffff81076400
pre_handler:?p->addr?=?0xffffffff81076400,?ip?=?ffffffff81076401,?flags?=?0x246
post_handler:?p->addr?=?0xffffffff81076400,?flags?=?0x246
pre_handler:?p->addr?=?0xffffffff81076400,?ip?=?ffffffff81076401,?flags?=?0x246
post_handler:?p->addr?=?0xffffffff81076400,?flags?=?0x246
pre_handler:?p->addr?=?0xffffffff81076400,?ip?=?ffffffff81076401,?flags?=?0x246
post_handler:?p->addr?=?0xffffffff81076400,?flags?=?0x246

可以看出,我们的调试模块已经正常工作,并且输出我们需要的信息。

总结

本文主要介绍了?kprobe?的使用方式,kprobe?的功能非常强大,可以帮助我们发现内核的一些 BUG。当然,本文也只是非常简单的介绍其使用,但有了这些基础就可以完成很多复杂的调试。

本文主要为了接下来的?kprobe?原理与实现打好基础,一下篇文章将会介绍?kprobe?的原理和实现,有兴趣的同学多多关注。

Linux内存管理:SPARSEMEM模型:

https://zhuanlan.zhihu.com/p/220068494

背景介绍:

内存对于OS来说就像我们生活中的水和电,这么重要的资源管理起来是很花心思的。我们知道Linux中的物理内存被按页框划分,每个页框都会对应一个struct page结构体存放元数据,也就是说每块页框大小的内存都要花费sizeof(struct page)个字节进行管理。

所以系统会有大量的struct page,在linux的历史上出现过三种内存模型去管理它们。依次是平坦内存模型(flat memory model)、不连续内存模型 (discontiguous memory model)和稀疏内存模型(sparse memory model)。新的内存模型的一次次被提出,无非因为是老的内存模型已不适应计算机硬件的新技术(例如:NUMA技术、内存热插拔等)。

内存模型的设计则主要是权衡以下两点(空间与时间):
1. 尽量少的消耗内存去管理众多的struct page
2. pfn_to_page和page_to_pfn的转换效率。

Tips: 老惯例,文章基于ARM64、Linux5.0的内核展开叙述。

三种内存模型:

  • FLATMEM (flat memory model)

FLATMEM内存模型是Linux最早使用的内存模型,那时计算机的内存通常不大。Linux会使用一个struct page mem_map[x]的数组根据PFN去依次存放所有的strcut page,且mem_map也位于内核空间的线性映射区,所以根据PFN(页帧号)即可轻松的找到目标页帧的strcut page

  • DISCONTIGMEM (discontiguous memory model)

对于物理地址空间不存在空洞(holes)的计算机来说,FLATMEM无疑是最优解。可物理地址中若是存在空洞的话,FLATMEM就显得格外的浪费内存,因为FLATMEM会在mem_map数组中为所有的物理地址都创建一个struct page,即使大块的物理地址是空洞,即不存在物理内存。可是为这些空洞这些struct page完全是没有必要的。为了解决空洞的问题,Linux社区提出了DISCONTIGMEM模型。

DISCONTIGMEM是个稍纵即逝的内存模型,在SPARSEMEM出现后即被完全替代,且当前的Linux kernel默认都是使用SPARSEMEM,所以介绍DISCONTIGMEM的意义不大,感兴趣可以看这篇文章:https://lwn.net/Articles/789304/

  • SPARSEMEM (sparse memory model)

稀疏内存模型是当前内核默认的选择,从2005年被提出后沿用至今,但中间经过几次优化,包括:CONFIG_SPARSEMEM_VMEMMAP和CONFIG_SPARSEMEM_EXTREME的引入,这两个配置通常是被打开的,下面的原理介绍也会基于它们开启的情况。

首次引入SPARSEMEM时的commit。https://lwn.net/Articles/134804/?原文中阐明了它的三个优点:

1. 可以解决内存空洞导致的内存浪费。
2. 支持内存的热插拔(memory hotplug)。
3. 支持nodes间的overlap。(我也不太清楚这是个啥...)

SPARSEMEM原理:

  • section的概念:

SPARSEMEM内存模型引入了section的概念,可以简单将它理解为struct page的集合(数组)。内核使用struct mem_section去描述section,定义如下:

struct mem_section {
        unsigned long section_mem_map;
        /* See declaration of similar field in struct zone */
        unsigned long *pageblock_flags;
};

其中的section_mem_map成员存放的是struct page数组的地址,每个section可容纳PFN_SECTION_SHIFT个struct page,arm64地址位宽为48bit时定义了每个section可囊括的地址范围是1GB。

  • 全局变量**mem_section

内核中用了一个二级指针struct mem_section **mem_section去管理section,我们可以简单理解为一个动态的二维数组。所谓二维即内核又将SECTIONS_PER_ROOT个section划分为一个ROOT,ROOT的个数不是固定的,根据系统实际的物理地址大小来分配。

  • 物理页帧号PFN

SPARSEMEM将PFN差分成了三个level,每个level分别对应:ROOT编号、ROOT内的section偏移、section内的page偏移。(可以类比多级页表来理解)

  • vmemmap区域

vmemmap区域是一块起始地址是VMEMMAP_START,范围是2TB的虚拟地址区域,位于kernel space。以section为单位来存放strcut page结构的虚拟地址空间,然后线性映射到物理内存。(关于虚拟地址空间,参考文章:Linux内存管理:虚拟地址空间

  • 内存热插拔

SPARSEMEM中section是最小管理单元。内存热插拔也是以section为单位,下图中画的热插拔单位是一个section(ARM64是1GB),通常会大于等于一个section。

通过下图来可以很好的串联上面几个概念:

  • PFN和struct page的转换:

SPARSEMEM中__pfn_to_page和__page_to_pfn的实现如下:

#define __pfn_to_page(pfn)      (vmemmap + (pfn))
#define __page_to_pfn(page)     (unsigned long)((page) - vmemmap)      
#define vmemmap        ((struct page *)VMEMMAP_START - (memstart_addr >> PAGE_SHIFT))

其中vmemmap指针指向VMEMMAP_START偏移memstart_addr的地址处,memstart_addr则是根据物理起始地址PHYS_OFFSET算出来的偏移,上图画出了三者之间的关系。

总结:

  • 高效

通过上面__pfn_to_page的实现可以看出其仅用一步计算即可找到对应的struct page,十分高效,毫不逊色于flat memory model,很符合文章开始我们提到的高效的目标。

  • 省内存

那另一个目标节省内存是如何做到的呢?答案就是按需分配,内存空洞或者被拔掉的内存条,我们不给它分配存放struct page的物理内存(只有虚拟内存)。
内存的多级页表也是利用了这个思想,通过将页表分成多级,未分配物理页帧的则不去为它建立PTE,上面将PFN拆成root、section是不是很像二级页表呢。

原创文章,转载和引用请注明出处。

作者:Yann Xu

一文说清OpenCL框架

https://mp.weixin.qq.com/s/XE-A4jnNJdjH_RyQb0ttzQ

  • OpenCL(Open Computing Language,开放计算语言):从软件视角看,它是用于异构平台编程的框架;从规范视角看,它是异构并行计算的行业标准,由Khronos Group来维护;

  • 异构平台包括了CPU、GPU、FPGA、DSP,以及最近几年流行的各类AI加速器等;

  • OpenCL包含两部分:

????1)用于编写运行在OpenCL device上的kernels的语言(基于C99);

????2)OpenCL API,至于Runtime的实现交由各个厂家,比如Intel发布的opencl_runtime_16.1.2_x64_rh_6.4.0.37.tgz

?以人工智能场景为例来理解一下,假如在某个AI芯片上跑人脸识别应用,CPU擅长控制,AI processor擅长计算,软件的flow就可以进行拆分,用CPU来负责控制视频流输入输出前后处理,AI processor来完成深度学习模型运算完成识别,这就是一个典型的异构处理场景,如果该AI芯片的SDK支持OpenCL,那么上层的软件就可以基于OpenCL进行开发了。

话不多说,看看OpenCL的架构吧。

OpenCL架构,可以从平台模型、内存模型、执行模型、编程模型四个角度来展开。

  • 平台模型由一个Host连接一个或多个OpenCL Devices组成;

  • OpenCL Device,可以划分成一个或多个计算单元Compute Unit(CU)

  • CU可以进一步划分成一个或多个处理单元Processing Unit(PE),最终的计算由PE来完成;

  • OpenCL应用程序分成两部分:host代码和device kernel代码,其中Host运行host代码,并将kernel代码以命令的方式提交到OpenCL devices,由OpenCL device来运行kernel代码;

HTTPS 中的 TLS 握手过程可以同时进行三次握手」对不对

https://mp.weixin.qq.com/s/KA66YEj5erMWj-cRgY7fAQ

面试官跟他说?HTTPS 中的 TLS 握手过程可以同时进行三次握手,然后读者之前看我的文章是说「先进行 TCP 三次握手,再进行 TLS 四次握手」,他跟面试官说了这个,面试官说他不对,他就感到很困惑。

我们先不管面试官说的那句「HTTPS 中的 TLS 握手过程可以同时进行三次握手」对不对。

但是面试官说「HTTPS 建立连接的过程,先进行 TCP 三次握手,再进行 TLS 四次握手」是错的,这很明显面试官的水平有问题,这种公司不去也罢!

如果是我面试遇到这样的面试官,我直接当场给他抓 HTTPS 建立过程的网络包,然后给他看,啪啪啪啪啪的打他脸。

比如,下面这个 TLSv1.2 的 基于 RSA 算法的四次握手过程:

难道不是先三次握手,再进行 TLS 四次握手吗?面试官你脸疼吗?

不过 TLS 握手过程的次数还得看版本。

TLSv1.2 握手过程基本都是需要四次,也就是需要经过 2-RTT 才能完成握手,然后才能发送请求,而 TLSv1.3 只需要 1-RTT 就能完成 TLS 握手,如下图。

一般情况下,不管 TLS 握手次数如何,都得先经过 TCP 三次握手后才能进行,因为 HTTPS 都是基于 TCP 传输协议实现的,得先建立完可靠的 TCP 连接才能做 TLS 握手的事情。

那面试官说的这句「HTTPS 中的 TLS 握手过程可以同时进行三次握手」对不对呢?

这个场景是可能发生的,但是需要在特定的条件下才可能发生,如果没有说任何前提条件,说这句话就是在耍流氓。

那到底什么条件下,这个场景才能发生呢?需要下面这两个条件同时满足才可以:

  • 客户端和服务端都开启了 TCP Fast Open 功能,且 TLS 版本是 1.3;

  • 客户端和服务端已经完成过一次通信。

那具体怎么做到的呢?我们先了解些 TCP Fast Open 功能和 TLSv1.3 的特性。

TCP Fast Open

我们先来了解下什么是 TCP Fast Open?

常规的情况下,如果要使用 TCP 传输协议进行通信,则客户端和服务端通信之前,先要经过 TCP 三次握手后,建立完可靠的 TCP 连接后,客户端才能将数据发送给服务端。

其中,TCP 的第一次和第二次握手是不能够携带数据的,而 TCP 的第三次握手是可以携带数据的,因为这时候客户端的 TCP 连接状态已经是 ESTABLISHED,表明客户端这一方已经完成了 TCP 连接建立。

就算客户端携带数据的第三次握手在网络中丢失了,客户端在一定时间内没有收到服务端对该数据的应答报文,就会触发超时重传机制,然后客户端重传该携带数据的第三次握手的报文,直到重传次数达到系统的阈值,客户端就会销毁该 TCP 连接。

说完常规的 TCP 连接后,我们再来看看 TCP Fast Open。

TCP Fast Open 是为了绕过 TCP 三次握手发送数据,在 Linux 3.7 内核版本之后,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。

要使用 TCP Fast Open 功能,客户端和服务端都要同时支持才会生效。

不过,开启了 TCP Fast Open 功能,想要绕过 TCP 三次握手发送数据,得建立第二次以后的通信过程。

在客户端首次建立连接时的过程,如下图:

具体介绍:

  • 客户端发送 SYN 报文,该报文包含 Fast Open 选项,且该选项的 Cookie 为空,这表明客户端请求 Fast Open Cookie;

  • 支持 TCP Fast Open 的服务器生成 Cookie,并将其置于 SYN-ACK 报文中的 Fast Open 选项以发回客户端;

  • 客户端收到 SYN-ACK 后,本地缓存 Fast Open 选项中的 Cookie。

所以,第一次客户端和服务端通信的时候,还是需要正常的三次握手流程。随后,客户端就有了 Cookie 这个东西,它可以用来向服务器 TCP 证明先前与客户端 IP 地址的三向握手已成功完成。

对于客户端与服务端的后续通信,客户端可以在第一次握手的时候携带应用数据,从而达到绕过三次握手发送数据的效果,整个过程如下图:

我详细介绍下这个过程:

  • 客户端发送 SYN 报文,该报文可以携带「应用数据」以及此前记录的 Cookie;

  • 支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN 和「数据」进行确认,服务器随后将「应用数据」递送给对应的应用程序;如果 Cookie 无效,服务器将丢弃 SYN 报文中包含的「应用数据」,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号;

  • 如果服务器接受了 SYN 报文中的「应用数据」,服务器可在握手完成之前发送「响应数据」,这就减少了握手带来的 1 个 RTT 的时间消耗

  • 客户端将发送 ACK 确认服务器发回的 SYN 以及「应用数据」,但如果客户端在初始的 SYN 报文中发送的「应用数据」没有被确认,则客户端将重新发送「应用数据」;

  • 此后的 TCP 连接的数据传输过程和非 TCP Fast Open 的正常情况一致。

所以,如果客户端和服务端同时支持 TCP Fast Open 功能,那么在完成首次通信过程后,后续客户端与服务端 的通信则可以绕过三次握手发送数据,这就减少了握手带来的 1 个 RTT 的时间消耗。

TLSv1.3

说完 TCP Fast Open,再来看看 TLSv1.3。

在最开始的时候,我也提到 TLSv1.3 握手过程只需 1-RTT 的时间,它到整个握手过程,如下图:

TCP 连接的第三次握手是可以携带数据的,如果客户端在第三次握手发送了 TLSv1.3 第一次握手数据,是不是就表示「HTTPS 中的 TLS 握手过程可以同时进行三次握手」?。

不是的,因为服务端只有在收到客户端的 TCP 的第三次握手后,才能和客户端进行后续 TLSv1.3 握手。

TLSv1.3 还有个更厉害到地方在于会话恢复机制,在重连 TLvS1.3 只需要 0-RTT,用“pre_shared_key”和“early_data”扩展,在 TCP 连接后立即就建立安全连接发送加密消息,过程如下图:

TCP Fast Open + TLSv1.3

在前面我们知道,客户端和服务端同时支持 TCP Fast Open 功能的情况下,在第二次以后到通信过程中,客户端可以绕过三次握手直接发送数据,而且服务端也不需要等收到第三次握手后才发送数据。

如果 HTTPS 的 TLS 版本是 1.3,那么 TLS 过程只需要 1-RTT。

因此如果「TCP Fast Open + TLSv1.3」情况下,在第二次以后的通信过程中,TLS 和 TCP 的握手过程是可以同时进行的。

如果基于 TCP Fast Open 场景下的 TLSv1.3 0-RTT 会话恢复过程,不仅 TLS 和 TCP 的握手过程是可以同时进行的,而且 HTTP 请求也可以在这期间内一同完成。

总结

最后做个总结。

「HTTPS 是先进行 TCP 三次握手,再进行 TLSv1.2 四次握手」,这句话一点问题都没有,怀疑这句话是错的人,才有问题。

「HTTPS 中的 TLS 握手过程可以同时进行三次握手」,这个场景是可能存在到,但是在没有说任何前提条件,而说这句话就等于耍流氓。需要下面这两个条件同时满足才可以:

  • 客户端和服务端都开启了 TCP Fast Open 功能,且 TLS 版本是 1.3;

  • 客户端和服务端已经完成过一次通信;

怎么样,那位“面试官”学废了吗?

Linux 信号处理原理与实现

https://mp.weixin.qq.com/s/8qQq_HXgT3MULbAbgBXWkg

信号本质上是在软件层次上对中断机制的一种模拟,其主要有以下几种来源:

  • 程序错误:除零,非法内存访问等。

  • 外部信号:终端?Ctrl-C?产生?SGINT?信号,定时器到期产生SIGALRM等。

  • 显式请求:kill函数允许进程发送任何信号给其他进程或进程组。

目前 Linux 支持64种信号。信号分为非实时信号(不可靠信号)和实时信号(可靠信号)两种类型,对应于 Linux 的信号值为 1-31 和 34-64。

信号是异步的,一个进程不必通过任何操作来等待信号的到达。事实上,进程也不知道信号到底什么时候到达。一般来说,我们只需要在进程中设置信号相应的处理函数,当有信号到达的时候,由系统异步触发相应的处理函数即可。如下代码:

#include?<signal.h>
#include?<unistd.h>
#include?<stdio.h>

void?sigcb(int?signo)?{
????switch?(signo)?{
????case?SIGHUP:
????????printf("Get?a?signal?--?SIGHUP\n");
????????break;
????case?SIGINT:
????????printf("Get?a?signal?--?SIGINT\n");
????????break;
????case?SIGQUIT:
????????printf("Get?a?signal?--?SIGQUIT\n");
????????break;
????}
????return;
}

int?main()?{
????signal(SIGHUP,?sigcb);
????signal(SIGINT,?sigcb);
????signal(SIGQUIT,?sigcb);
????for?(;;)?{
????????sleep(1);
????}
}

运行程序后,当我们按下?Ctrl+C?后,屏幕上将会打印?Get a signal -- SIGINT。当然我们可以使用?kill -s SIGINT pid?命令来发送一个信号给进程,屏幕同样打印出?Get a signal -- SIGINT?的信息。

信号实现原理

接下来我们分析一下Linux对信号处理机制的实现原理。

信号处理相关的数据结构

在进程管理结构?task_struct?中有几个与信号处理相关的字段,如下:

struct?task_struct?{
????...
????int?sigpending;
????...
????struct?signal_struct?*sig;
????sigset_t?blocked;
????struct?sigpending?pending;
????...
}

成员?sigpending?表示进程是否有信号需要处理(1表示有,0表示没有)。成员?blocked?表示被屏蔽的信息,每个位代表一个被屏蔽的信号。成员?sig?表示信号相应的处理方法,其类型是?struct signal_struct,定义如下:

#define??_NSIG??64

struct?signal_struct?{
?atomic_t??count;
?struct?k_sigaction?action[_NSIG];
?spinlock_t??siglock;
};

typedef?void?(*__sighandler_t)(int);

struct?sigaction?{
?__sighandler_t?sa_handler;
?unsigned?long?sa_flags;
?void?(*sa_restorer)(void);
?sigset_t?sa_mask;
};

struct?k_sigaction?{
?struct?sigaction?sa;
};

可以看出,struct signal_struct?是个比较复杂的结构,其?action?成员是个?struct k_sigaction?结构的数组,数组中的每个成员代表着相应信号的处理信息,而?struct k_sigaction?结构其实是?struct sigaction?的简单封装。

我们再来看看?struct sigaction?这个结构,其中?sa_handler?成员是类型为?__sighandler_t?的函数指针,代表着信号处理的方法。

最后我们来看看?struct task_struct?结构的?pending?成员,其类型为?struct sigpending,存储着进程接收到的信号队列,struct sigpending?的定义如下:

struct?sigqueue?{
?struct?sigqueue?*next;
?siginfo_t?info;
};

struct?sigpending?{
?struct?sigqueue?*head,?**tail;
?sigset_t?signal;
};

当进程接收到一个信号时,就需要把接收到的信号添加?pending?这个队列中。

发送信号

可以通过?kill()?系统调用发送一个信号给指定的进程,其原型如下:

int?kill(pid_t?pid,?int?sig);

参数?pid?指定要接收信号进程的ID,而参数?sig?是要发送的信号。kill()?系统调用最终会进入内核态,并且调用内核函数?sys_kill(),代码如下:

asmlinkage?long
sys_kill(int?pid,?int?sig)
{
?struct?siginfo?info;

?info.si_signo?=?sig;
?info.si_errno?=?0;
?info.si_code?=?SI_USER;
?info.si_pid?=?current->pid;
?info.si_uid?=?current->uid;

?return?kill_something_info(sig,?&info,?pid);
}

sys_kill()?的代码比较简单,首先初始化?info?变量的成员,接着调用?kill_something_info()?函数来处理发送信号的操作。kill_something_info()?函数的代码如下:

static?int?kill_something_info(int?sig,?struct?siginfo?*info,?int?pid)
{
?if?(!pid)?{
??return?kill_pg_info(sig,?info,?current->pgrp);
?}?else?if?(pid?==?-1)?{
??int?retval?=?0,?count?=?0;
??struct?task_struct?*?p;

??read_lock(&tasklist_lock);
??for_each_task(p)?{
???if?(p->pid?>?1?&&?p?!=?current)?{
????int?err?=?send_sig_info(sig,?info,?p);
????++count;
????if?(err?!=?-EPERM)
?????retval?=?err;
???}
??}
??read_unlock(&tasklist_lock);
??return?count???retval?:?-ESRCH;
?}?else?if?(pid?<?0)?{
??return?kill_pg_info(sig,?info,?-pid);
?}?else?{
??return?kill_proc_info(sig,?info,?pid);
?}
}

kill_something_info()?函数根据传入pid?的不同来进行不同的操作,有如下4种可能:

  • pid?等于0时,表示信号将送往所有与调用?kill()?的那个进程属同一个使用组的进程。

  • pid?大于零时,pid?是信号要送往的进程ID。

  • pid?等于-1时,信号将送往调用进程有权给其发送信号的所有进程,除了进程1(init)。

  • pid?小于-1时,信号将送往以-pid为组标识的进程。

我们这里只分析?pid?大于0的情况,从上面的代码可以知道,当?pid?大于0时,会调用?kill_proc_info()?函数来处理信号发送操作,其代码如下:

inline?int
kill_proc_info(int?sig,?struct?siginfo?*info,?pid_t?pid)
{
?int?error;
?struct?task_struct?*p;

?read_lock(&tasklist_lock);
?p?=?find_task_by_pid(pid);
?error?=?-ESRCH;
?if?(p)
??error?=?send_sig_info(sig,?info,?p);
?read_unlock(&tasklist_lock);
?return?error;
}

kill_proc_info()?首先通过调用?find_task_by_pid()?函数来获得?pid?对应的进程管理结构,然后通过?send_sig_info()?函数来发送信号给此进程,send_sig_info()?函数代码如下:

int
send_sig_info(int?sig,?struct?siginfo?*info,?struct?task_struct?*t)
{
????unsigned?long?flags;
????int?ret;

????ret?=?-EINVAL;
????if?(sig?<?0?||?sig?>?_NSIG)
????????goto?out_nolock;

????ret?=?-EPERM;
????if?(bad_signal(sig,?info,?t))
????????goto?out_nolock;

????ret?=?0;
????if?(!sig?||?!t->sig)
????????goto?out_nolock;

????spin_lock_irqsave(&t->sigmask_lock,?flags);
????handle_stop_signal(sig,?t);

????if?(ignored_signal(sig,?t))
????????goto?out;

????if?(sig?<?SIGRTMIN?&&?sigismember(&t->pending.signal,?sig))
????????goto?out;

????ret?=?deliver_signal(sig,?info,?t);
out:
????spin_unlock_irqrestore(&t->sigmask_lock,?flags);
????if?((t->state?&?TASK_INTERRUPTIBLE)?&&?signal_pending(t))
????????wake_up_process(t);

out_nolock:
????return?ret;
}

send_sig_info()?首先调用?bad_signal()?函数来检查是否有权发送信号给进程,然后调用?ignored_signal()?函数来检查信号是否被忽略,接着调用?deliver_signal()?函数开始发送信号,最后如果进程是睡眠状态就唤醒进程。我们接着来分析?deliver_signal()?函数:

static?int?deliver_signal(int?sig,?struct?siginfo?*info,?struct?task_struct?*t)
{
?int?retval?=?send_signal(sig,?info,?&t->pending);

?if?(!retval?&&?!sigismember(&t->blocked,?sig))
??signal_wake_up(t);

?return?retval;
}

deliver_signal()?首先调用?send_signal()?函数进行信号的发送,然后调用?signal_wake_up()?函数唤醒进程。我们来分析一下最重要的函数?send_signal()

static?int?send_signal(int?sig,?struct?siginfo?*info,?struct?sigpending?*signals)
{
????struct?sigqueue?*?q?=?NULL;

????if?(atomic_read(&nr_queued_signals)?<?max_queued_signals)?{
????????q?=?kmem_cache_alloc(sigqueue_cachep,?GFP_ATOMIC);
????}

????if?(q)?{
????????atomic_inc(&nr_queued_signals);
????????q->next?=?NULL;
????????*signals->tail?=?q;
????????signals->tail?=?&q->next;
????????switch?((unsigned?long)?info)?{
????????????case?0:
????????????????q->info.si_signo?=?sig;
????????????????q->info.si_errno?=?0;
????????????????q->info.si_code?=?SI_USER;
????????????????q->info.si_pid?=?current->pid;
????????????????q->info.si_uid?=?current->uid;
????????????????break;
????????????case?1:
????????????????q->info.si_signo?=?sig;
????????????????q->info.si_errno?=?0;
????????????????q->info.si_code?=?SI_KERNEL;
????????????????q->info.si_pid?=?0;
????????????????q->info.si_uid?=?0;
????????????????break;
????????????default:
????????????????copy_siginfo(&q->info,?info);
????????????????break;
????????}
????}?else?if?(sig?>=?SIGRTMIN?&&?info?&&?(unsigned?long)info?!=?1
???????????&&?info->si_code?!=?SI_USER)?{
????????return?-EAGAIN;
????}

????sigaddset(&signals->signal,?sig);
????return?0;
}

send_signal()?函数虽然比较长,但逻辑还是比较简单的。在?信号处理相关的数据结构?一节我们介绍过进程管理结构?task_struct?有个?pending?的成员变量,其用于保存接收到的信号队列。send_signal()?函数的第三个参数就是进程管理结构的?pending?成员变量。

send_signal()?首先调用?kmem_cache_alloc()?函数来申请一个类型为?struct sigqueue?的队列节点,然后把节点添加到?pending?队列中,接着根据参数?info?的值来进行不同的操作,最后通过?sigaddset()?函数来设置信号对应的标志位,表示进程接收到该信号。

signal_wake_up()?函数会把进程的?sigpending?成员变量设置为1,表示有信号需要处理,如果进程是睡眠可中断状态还会唤醒进程。

至此,发送信号的流程已经完成,我们可以通过下面的调用链来更加直观的理解此过程:

kill()   
  |                                         User Space
=========================================================
  |                                         Kernel Space
sys_kill()
  └→  kill_something_info()
     └→ kill_proc_info()
        └→  find_task_by_pid()
        └→ send_sig_info()
           └→ bad_signal()
           └→ handle_stop_signal()
           └→ ignored_signal()
           └→ deliver_signal()
              └→ send_signal()
              |  └→  kmem_cache_alloc()
              |  └→ sigaddset()
              └→ signal_wake_up()

内核触发信号处理函数

上面介绍了怎么发生一个信号给指定的进程,但是什么时候会触发信号相应的处理函数呢?为了尽快让信号得到处理,Linux把信号处理过程放置在进程从内核态返回到用户态前,也就是在?ret_from_sys_call?处:

//?arch/i386/kernel/entry.S

ENTRY(ret_from_sys_call)
?...
ret_with_reschedule:
?...
?cmpl?$0,?sigpending(%ebx)??//?检查进程的sigpending成员是否等于1
?jne?signal_return??????????//?如果是就跳转到?signal_return?处执行
restore_all:
?RESTORE_ALL

?ALIGN
signal_return:
?sti?????????????????????????????//?开启硬件中断
?testl?$(VM_MASK),EFLAGS(%esp)
?movl?%esp,%eax
?jne?v86_signal_return
?xorl?%edx,%edx
?call?SYMBOL_NAME(do_signal)????//?调用do_signal()函数进行处理
?jmp?restore_all

由于这是一段汇编代码,有点不太直观(大概知道意思就可以了),所以我在代码中进行了注释。主要的逻辑就是首先检查进程的?sigpending?成员是否等于1,如果是调用?do_signal()?函数进行处理,由于?do_signal()?函数代码比较长,所以我们分段来说明,如下:

int?do_signal(struct?pt_regs?*regs,?sigset_t?*oldset)
{
?siginfo_t?info;
?struct?k_sigaction?*ka;

?if?((regs->xcs?&?3)?!=?3)
??return?1;

?if?(!oldset)
??oldset?=?&current->blocked;

?for?(;;)?{
??unsigned?long?signr;

??spin_lock_irq(&current->sigmask_lock);
??signr?=?dequeue_signal(&current->blocked,?&info);
??spin_unlock_irq(&current->sigmask_lock);

??if?(!signr)
???break;

上面这段代码的主要逻辑是通过?dequeue_signal()?函数获取到进程接收队列中的一个信号,如果没有信号,那么就跳出循环。我们接着来分析:

??ka?=?&current->sig->action[signr-1];
??if?(ka->sa.sa_handler?==?SIG_IGN)?{
???if?(signr?!=?SIGCHLD)
????continue;
???/*?Check?for?SIGCHLD:?it's?special.??*/
???while?(sys_wait4(-1,?NULL,?WNOHANG,?NULL)?>?0)
????/*?nothing?*/;
???continue;
??}

上面这段代码首先获取到信号对应的处理方法,如果对此信号的处理是忽略的话,那么就直接跳过。

??if?(ka->sa.sa_handler?==?SIG_DFL)?{
???int?exit_code?=?signr;

???/*?Init?gets?no?signals?it?doesn't?want.??*/
???if?(current->pid?==?1)
????continue;

???switch?(signr)?{
???case?SIGCONT:?case?SIGCHLD:?case?SIGWINCH:
????continue;

???case?SIGTSTP:?case?SIGTTIN:?case?SIGTTOU:
????if?(is_orphaned_pgrp(current->pgrp))
?????continue;
????/*?FALLTHRU?*/

???case?SIGSTOP:
????current->state?=?TASK_STOPPED;
????current->exit_code?=?signr;
????if?(!(current->p_pptr->sig->action[SIGCHLD-1].sa.sa_flags?&?SA_NOCLDSTOP))
?????notify_parent(current,?SIGCHLD);
????schedule();
????continue;

???case?SIGQUIT:?case?SIGILL:?case?SIGTRAP:
???case?SIGABRT:?case?SIGFPE:?case?SIGSEGV:
???case?SIGBUS:?case?SIGSYS:?case?SIGXCPU:?case?SIGXFSZ:
????if?(do_coredump(signr,?regs))
?????exit_code?|=?0x80;
????/*?FALLTHRU?*/

???default:
????sigaddset(&current->pending.signal,?signr);
????recalc_sigpending(current);
????current->flags?|=?PF_SIGNALED;
????do_exit(exit_code);
????/*?NOTREACHED?*/
???}
??}
??...
??handle_signal(signr,?ka,?&info,?oldset,?regs);
??return?1;
?}
?...
?return?0;
}

上面的代码表示,如果指定为默认的处理方法,那么就使用系统的默认处理方法去处理信号,比如?SIGSEGV?信号的默认处理方法就是使用?do_coredump()?函数来生成一个?core dump?文件,并且通过调用?do_exit()?函数退出进程。

如果指定了自定义的处理方法,那么就通过?handle_signal()?函数去进行处理,handle_signal()?函数代码如下:

static?void
handle_signal(unsigned?long?sig,?struct?k_sigaction?*ka,
???????siginfo_t?*info,?sigset_t?*oldset,?struct?pt_regs?*?regs)
{
?...
?if?(ka->sa.sa_flags?&?SA_SIGINFO)
??setup_rt_frame(sig,?ka,?info,?oldset,?regs);
?else
??setup_frame(sig,?ka,?oldset,?regs);

?if?(ka->sa.sa_flags?&?SA_ONESHOT)
??ka->sa.sa_handler?=?SIG_DFL;

?if?(!(ka->sa.sa_flags?&?SA_NODEFER))?{
??spin_lock_irq(&current->sigmask_lock);
??sigorsets(&current->blocked,&current->blocked,&ka->sa.sa_mask);
??sigaddset(&current->blocked,sig);
??recalc_sigpending(current);
??spin_unlock_irq(&current->sigmask_lock);
?}
}

由于信号处理程序是由用户提供的,所以信号处理程序的代码是在用户态的。而从系统调用返回到用户态前还是属于内核态,CPU是禁止内核态执行用户态代码的,那么怎么办?

答案先返回到用户态执行信号处理程序,执行完信号处理程序后再返回到内核态,再在内核态完成收尾工作。听起来有点绕,事实也的确是这样。下面通过一副图片来直观的展示这个过程(图片来源网络):

为了达到这个目的,Linux经历了一个十分崎岖的过程。我们知道,从内核态返回到用户态时,CPU要从内核栈中找到返回到用户态的地址(就是调用系统调用的下一条代码指令地址),Linux为了先让信号处理程序执行,所以就需要把这个返回地址修改为信号处理程序的入口,这样当从系统调用返回到用户态时,就可以执行信号处理程序了。

所以,handle_signal()?调用了?setup_frame()?函数来构建这个过程的运行环境(其实就是修改内核栈和用户栈相应的数据来完成)。我们先来看看内核栈的内存布局图:

图中的?eip?就是内核态返回到用户态后开始执行的第一条指令地址,所以把?eip?改成信号处理程序的地址就可以在内核态返回到用户态的时候自动执行信号处理程序了。我们看看?setup_frame()?函数其中有一行代码就是修改?eip?的值,如下:

static?void?setup_frame(int?sig,?struct?k_sigaction?*ka,
???sigset_t?*set,?struct?pt_regs?*?regs)
{
????...
????regs->eip?=?(unsigned?long)?ka->sa.sa_handler;?//?regs是内核栈中保存的寄存器集合
????...
}

现在可以在内核态返回到用户态时自动执行信号处理程序了,但是当信号处理程序执行完怎么返回到内核态呢?Linux的做法就是在用户态栈空间构建一个?Frame(帧)(我也不知道为什么要这样叫),构建这个帧的目的就是为了执行完信号处理程序后返回到内核态,并恢复原来内核栈的内容。返回到内核态的方式是调用一个名为?sigreturn()?系统调用,然后再?sigreturn()?中恢复原来内核栈的内容。

怎样能在执行完信号处理程序后调用?sigreturn()?系统调用呢?其实跟前面修改内核栈?eip?的值一样,这里修改的是用户栈?eip?的值,修改后跳转到一个执行下面代码的地方(用户栈的某一处):

popl %eax 
movl $__NR_sigreturn,%eax 
int $0x80

从上面的汇编代码可以知道,这里就是调用了?sigreturn()?系统调用。修改用户栈的代码在?setup_frame()?中,代码如下:

static?void?setup_frame(int?sig,?struct?k_sigaction?*ka,
???sigset_t?*set,?struct?pt_regs?*?regs)
{
?...
??err?|=?__put_user(frame->retcode,?&frame->pretcode);
??/*?This?is?popl?%eax?;?movl?$,%eax?;?int?$0x80?*/
??err?|=?__put_user(0xb858,?(short?*)(frame->retcode+0));
??err?|=?__put_user(__NR_sigreturn,?(int?*)(frame->retcode+2));
??err?|=?__put_user(0x80cd,?(short?*)(frame->retcode+6));
?...
}

这几行代码比较难懂,其实就是修改信号程序程序返回后要执行代码的地址。修改后如下图:

这样执行完信号处理程序后就会调用?sigreturn(),而?sigreturn()?要做的工作就是恢复原来内核栈的内容了,我们来看看?sigreturn()?的代码:

asmlinkage?int?sys_sigreturn(unsigned?long?__unused)
{
?struct?pt_regs?*regs?=?(struct?pt_regs?*)?&__unused;
?struct?sigframe?*frame?=?(struct?sigframe?*)(regs->esp?-?8);
?sigset_t?set;
?int?eax;

?if?(verify_area(VERIFY_READ,?frame,?sizeof(*frame)))
??goto?badframe;
?if?(__get_user(set.sig[0],?&frame->sc.oldmask)
?????||?(_NSIG_WORDS?>?1
??&&?__copy_from_user(&set.sig[1],?&frame->extramask,
????????sizeof(frame->extramask))))
??goto?badframe;

?sigdelsetmask(&set,?~_BLOCKABLE);
?spin_lock_irq(&current->sigmask_lock);
?current->blocked?=?set;
?recalc_sigpending(current);
?spin_unlock_irq(&current->sigmask_lock);

?if?(restore_sigcontext(regs,?&frame->sc,?&eax))
??goto?badframe;
?return?eax;

badframe:
?force_sig(SIGSEGV,?current);
?return?0;
}

其中最重要的是调用?restore_sigcontext()?恢复原来内核栈的内容,要恢复原来内核栈的内容首先是要指定原来内核栈的内容,所以先要保存原来内核栈的内容。保存原来内核栈的内容也是在?setup_frame()?函数中,setup_frame()?函数把原来内核栈的内容保存到用户栈中(也就是上面所说的??中)。restore_sigcontext()?函数就是从用户栈中读取原来内核栈的数据,然后恢复之。保存内核栈内容主要由?setup_sigcontext()?函数完成,有兴趣可以查阅代码,这里就不做详细说明了。

这样,当从?sigreturn()?系统调用返回时,就可以按原来的路径返回到用户程序的下一个执行点(比如调用系统调用的下一行代码)。

设置信号处理程序

最后我们来分析一下怎么设置一个信号处理程序。

用户可以通过?signal()?系统调用设置一个信号处理程序,我们来看看?signal()?系统调用的代码:

asmlinkage?unsigned?long
sys_signal(int?sig,?__sighandler_t?handler)
{
?struct?k_sigaction?new_sa,?old_sa;
?int?ret;

?new_sa.sa.sa_handler?=?handler;
?new_sa.sa.sa_flags?=?SA_ONESHOT?|?SA_NOMASK;

?ret?=?do_sigaction(sig,?&new_sa,?&old_sa);

?return?ret???ret?:?(unsigned?long)old_sa.sa.sa_handler;
}

代码比较简单,就是先设置一个新的?struct k_sigaction?结构,把其?sa.sa_handler?字段设置为用户自定义的处理程序。然后通过?do_sigaction()?函数进行设置,代码如下:

int
do_sigaction(int?sig,?const?struct?k_sigaction?*act,?struct?k_sigaction?*oact)
{
????struct?k_sigaction?*k;

????if?(sig?<?1?||?sig?>?_NSIG?||
????????(act?&&?(sig?==?SIGKILL?||?sig?==?SIGSTOP)))
????????return?-EINVAL;

????k?=?&current->sig->action[sig-1];

????spin_lock(&current->sig->siglock);

????if?(oact)
????????*oact?=?*k;

????if?(act)?{
????????*k?=?*act;
????????sigdelsetmask(&k->sa.sa_mask,?sigmask(SIGKILL)?|?sigmask(SIGSTOP));
????????
????????if?(k->sa.sa_handler?==?SIG_IGN
????????????||?(k->sa.sa_handler?==?SIG_DFL
????????????&&?(sig?==?SIGCONT?||
????????????????sig?==?SIGCHLD?||
????????????????sig?==?SIGWINCH)))?{
????????????spin_lock_irq(&current->sigmask_lock);
????????????if?(rm_sig_from_queue(sig,?current))
????????????????recalc_sigpending(current);
????????????spin_unlock_irq(&current->sigmask_lock);
????????}
????}

????spin_unlock(&current->sig->siglock);
????return?0;
}

这个函数也不难,我们上面介绍过,进程管理结构中有个?sig?的字段,它是一个?struct k_sigaction?结构的数组,每个元素保存着对应信号的处理程序,所以?do_sigaction()?函数就是修改这个信号处理程序。代码?k = &current->sig->action[sig-1]?就是获取对应信号的处理程序,然后把其设置为新的信号处理程序即可。

  系统运维 最新文章
配置小型公司网络WLAN基本业务(AC通过三层
如何在交付运维过程中建立风险底线意识,提
快速传输大文件,怎么通过网络传大文件给对
从游戏服务端角度分析移动同步(状态同步)
MySQL使用MyCat实现分库分表
如何用DWDM射频光纤技术实现200公里外的站点
国内顺畅下载k8s.gcr.io的镜像
自动化测试appium
ctfshow ssrf
Linux操作系统学习之实用指令(Centos7/8均
上一篇文章      下一篇文章      查看所有文章
加:2021-08-26 12:31:17  更:2021-08-26 12:32:44 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/15 12:22:17-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码