Socket与系统调用深度分析(基于5.0.1/32,其实系统调用并不是int80,而是VDSO,另一种快速的系统调用方式

Socket与系统调用深度分析

em ,理论是INT80->entry_INT8_32,但实际调试最后才发现并不是的,所以我的过程中有很多的疑问,到最后我才发现。因此本博文实验过程可能有些奇怪,但如果你想知道5.0.1的系统调用方式到底是什么,请你继续观看

系统linux-5.0.1 32位
为加快大家查看源码的调用关系 提供 https://elixir.bootlin.com/linux/v5.0.1/source/net/ipv4/tcp_ipv4.c#L202
以下调试都是基于下图的理解进行的,针对图中1,2两个点,博文主要解决四个问题

1 int 0x80中断向量是如何与中断向量表绑定的?
2 socketAPI是如何进入内核调用socket 接口的?
3 socket接口是如何与传输层协议绑定的?
4 sokcet接口是如何调用具体协议的接口的?

Linux 引导过程综述

BIOS->Bootloader->内核初始化:体系结构相关部分-><内核初始化:体系结构无关部分>

内核初始化:体系结构相关部分

1 内核映像结构
2 初始化与保护模式 
3 自解压内核 
<4 startup_32(head_32.c)>

startup_32(head_32.c)

1 初始化参数(设置段的值,清楚BSS,初始化栈)
2 开启分页机制
3 初始化 Eflags
4 检查处理器类型
5 载入 GDT、IDT
<6 i386_start_kernel>

i386_start_kernel 执行与体系结构无关部分的内核初始化

1 检查中断向量表(IDT)是否已经启动,em,IDT要被初始化第一次
<2 调用start_kernel执行与体系结构无关部分的内核初始化>

start_kernel

使用"arch/alpha/kernel/entry.S"中的入口点设置系统自陷入口(trap_init())IDT初始化第二次

以上过程只是简单分析从linux启动到终端向量表的初始化的过程,下面我们来看IDT第二次初始化的细节。

中断向量int0x80是如何与中断处理例程绑定?

中断向量表的初始化分有三次:
(1)setup_once: early_idt_handler_array //./arch/x86/kernel/head_32.S line 377
(2)对0~19号的一些和0x80号系统保留中断向量的初始化,在trap_init中完成
(3)对其它中断向量的初始化,在init_IRQ中完成

第二次初始化中断向量表,绑定0x80和SYS_INT80_32

先给出答案:startup_32_smp-->i386_start_kernel -->start_kernel --> trap_init --> idt_setup_traps-->idt_setup_from_table
 gdb bt 调试结果如下:
 #0  <idt_setup_from_table> (t=0xc1d99b10 <def_idts+16>, size=<optimized out>, sys=true,
     idt=<optimized out>) at arch/x86/kernel/idt.c:225
 #1  0xc1d291d9 in <idt_setup_traps> () at arch/x86/kernel/idt.c:267
 #2  0xc1d29155 in <trap_init ()> at arch/x86/kernel/traps.c:934
 #3  0xc1d23a5b in <start_kernel> () at init/main.c:595
 #4  0xc1d2327c in <i386_start_kernel> () at arch/x86/kernel/head32.c:56
 #5  0xc10001ec in <startup_32_smp> () at arch/x86/kernel/head_32.S:363
 #6  0x00000000 in ?? ()
 

下面我们来具体分析一下。

idt_setup_traps

void __init idt_setup_traps(void)
{
    idt_setup_from_table(idt_table, def_idts, ARRAY_SIZE(def_idts), true);
}

先不管idt_setup_from_table的作用,先来看看结构体数组def_idts的最后一行SYSG(IA32_SYSCALL_VECTOR, entry_INT80_32),SYSG是一个宏,代表系统中断门,作用就是将中断向量IA32_SYSCALL_VECTOR和中断处理例程entry_INT80_32绑定,相信你现在已经明白idt_setup_from_table函数的作用了,就是在填一个table,包括中断向量号及其处理程序。

#define IA32_SYSCALL_VECTOR		0x80 // ./arch/x86/include/asm/irq_vectors.h line 45

def_idts

static const __initconst struct idt_data def_idts[] = {
    ...
    SYSG(IA32_SYSCALL_VECTOR,   entry_INT80_32)  /*int 0x80*/
};

idt_setup_from_table

idt_setup_from_table(gate_desc *idt, const struct idt_data *t, int size, bool sys)
{
	gate_desc desc;
	for (; size > 0; t++, size--) {
		idt_init_desc(&desc, t);//初始化描述符对应的门:门类型有任务门、中断门、陷阱门和调用门
		write_idt_entry(idt, t->vector, &desc);//将对应的门和中断向量写入中断描述符表(IDT)
		if (sys)
			set_bit(t->vector, system_vectors);//系统设置了一个位图system_vectors,来表示每个中断向量表的使用情况,可以看到,这里是将size个向量表项对应的位图设置为1,表示已经被占用了。
	}
}
//1 中断向量表的每个表项叫做一个门描述符(gate descriptor),“门”的含义是当中断发生时必须先通过这些门,然后才能进入相应的处理程序。

关于门的描述参考:

https://www.cnblogs.com/qintangtao/p/3325985.html

https://blog.csdn.net/cwcmcw/article/details/21640363

如果你想看write_idt_entry,如下,就是一个简单的内存拷贝。

static inline void native_write_ldt_entry(struct desc_struct *ldt, int entry, const void *desc)
{
	memcpy(&ldt[entry], desc, 8);
}

到此我们已经理清楚了中断向量int80绑定了entry_INT80_32,下面我们看第二个问题。

系统调用如何关联系统调用号,系统调用号如何绑定socket接口。

直接查看:linux-5.0.1/arch/x86/entry/syscalls/syscall_32.tbl,以下列出socket相关的系统调用接口,以及对应的系统调用号和内核的socket接口。

102 i386    socketcall      sys_socketcall          __ia32_compat_sys_socketcall

359 i386    socket          sys_socket          __ia32_sys_socket
360 i386    socketpair      sys_socketpair          __ia32_sys_socketpair
361 i386    bind            sys_bind            __ia32_sys_bind
362 i386    connect         sys_connect         __ia32_sys_connect
363 i386    listen          sys_listen          __ia32_sys_listen
364 i386    accept4         sys_accept4         __ia32_sys_accept4
365 i386    getsockopt      sys_getsockopt          __ia32_compat_sys_getsockopt
366 i386    setsockopt      sys_setsockopt          __ia32_compat_sys_setsockopt
367 i386    getsockname     sys_getsockname         __ia32_sys_getsockname
368 i386    getpeername     sys_getpeername         __ia32_sys_getpeername
369 i386    sendto          sys_sendto          __ia32_sys_sendto
370 i386    sendmsg         sys_sendmsg         __ia32_compat_sys_sendmsg
371 i386    recvfrom        sys_recvfrom            __ia32_compat_sys_recvfrom
372 i386    recvmsg         sys_recvmsg         __ia32_compat_sys_recvmsg

两种调用方式,一种是以系统调用号102 socketcall 进入sys_socketcall,然后分支进入sys_socket,sys_bind 等,另一种是直接通过自身的系统调用号比如359(socket)找到sys_socket。

我们可以通过调试来查看到底系统是使用哪一种?

用户态调试

先说明一下:由于我的内核无法完成升级,搞了两次暂时没有成功,所以以内核 4.0.5版本的linux系统在用户态调试下调试,如果以后升级成功了再来验证5.0.1.

源代码

#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
    return 0;
}

静态编译

gcc  -g -o static  test.c  -static  -m32
gdb ./static

//反汇编 main 和 socket
disassemble main

disassemble 0x806e790

用户态系统调用过程:main->socked->mov $0x66 $eax->call *%gs:0x10
库函数socket将eax寄存器设置为socketcall系统调用的调用号0x66,然后调用%gs:0x10所指向的函数。在gdb中,无法查看非DS段的数据内容,所以无法查看%gs:0x10所保存的实际数值,但它对应一个函数地址,而这个地址就是内核为我们映射的系统调用入口代码,这个函数地址里面应该包含了int 0x80。

mov $0x66 $eax  这个语句相当重要,0x66恰好是十进制的102,保存到了eax寄存器里面了,对用socketcall的系统调用编号,然后陷入内核执行系统调用。

由于知识水平有限,在用户态我也无法跟踪下去找到int 0x80指令的执行,当然我有想到反汇编static文件,然后查看out.txtx文件的结果,是否有int 0x80指令的调用

objdump -d static > out.txt

确实有,在如下几个文件里面都找到了int 0x80的踪影:

__libc_setup_tls>:
 _exit
_tunables_init
_dl_sysinfo_int80
_restore_rt
 _restore  
__brk

看以上几个函数名,我猜是_dl_sysinfo_int80 调用INT80指令进入内核态的,但我又没有证据,所以用户态下只能暂时告一段落。

用户态看不到,我们去内核态下看吧,注意注意,我的内核态切回5.0.1了。

内核态调试,基于linux-5.0.1

1 在lab3 目录下启动menu终端:

qemu -kernel ../../linux-5.0.1/arch/x86/boot/bzImage -initrd ../rootfs.img -append  nokaslr -s

2 在另一个终端输入

gdb
file vmlinux
break __sys_socket
target remote:1234
c

3 在menu系统中输入replyhi,另一个终端自然进入了内核的断点。

如图

为什么我输入了两次replyhi呢?因为我第一次的断点打在sys_socket处,并没有停下,所以5.0.1系统中的socket系统调用并不是按照第二种方式,而是按照sys_socketcall分发的方式,这儿的细节我已经在上一篇博客最后写过了。
堆栈情况如下:

(gdb) bt
#0  <__sys_socket >(family=2, type=1, protocol=0) at net/socket.c:1327
#1  0xc1757b98 in __do_sys_socketcall (args=<optimized out>,
    call=<optimized out>) at net/socket.c:2555
#2  <__se_sys_socketcall> (call=1, args=-1077721504) at net/socket.c:2527
#3  0xc1002095 in <do_syscall_32_irqs_on> (regs=<optimized out>)
    at arch/x86/entry/common.c:334
#4  <do_fast_syscall_32> (regs=0xc7191fb4) at arch/x86/entry/common.c:397
#5  0xc199141b in <entry_SYSENTER_32> () at arch/x86/entry/entry_32.S:887
#6  0x00000001 in ?? ()
#7  0xbfc34660 in ?? ()

现在我们来分析一下:enter_sysenter_32是如何通过系统调用号来找到__sys_socketcall的。你有发现点什么么?为什么进入内核的不是entry_INT80_32 ? int 0x80不是对应它吗?为什么是enter_SYSENTER_32,先不管我们就按照enter_SYSENTER_32分析下去。

enter_SYSENTER_32中断处理例程到底发生了什么

其中涉及:entry_SYSENTER_32 -> do_fast_syscall_32 -> do_syscall_32_irqs_on -> __do_sys_socketcall-> sys_socket,下面我们一个一个看

entry_SYSENTER_32

ENTRY(entry_SYSENTER_32) 截取重要部分
    movl    TSS_entry2task_stack(%esp), %esp //保存当进程内核栈
.Lsysenter_past_esp:  //保存当前的一些重要寄存器到结构体 pt_regs中
    pushl   $__USER_DS      /* pt_regs->ss */
    pushl   %ebp            /* pt_regs->sp (stashed in bp) */
    pushfl              /* pt_regs->flags (except IF = 0) */
    orl $X86_EFLAGS_IF, (%esp)  /* Fix IF */
    pushl   $__USER_CS      /* pt_regs->cs */
    pushl   $0          /* pt_regs->ip = 0 (placeholder) */
    pushl   %eax     //压栈保存eax!!!还记得我们在用户态下保存的系统调用号吗?5.0.1下应该是102
    SAVE_ALL pt_regs_ax=$-ENOSYS    /* 保存其他寄存器保在 pt_regs 结构中 */
    movl    %esp, %eax
    call    do_fast_syscall_32

在内核启动时,其中会有一个软中断的陷入门,当接收到一个系统调用的时候, 相应的文件就会被调用,然后通过 push 和 SAVE_ALL 将当前用户态的寄存器,保存在 pt_regs 结构中,而结构体pt_regs的内容将在do_syscall_32_irqs_on里面取值。

调试验证,如下图,此时的eax正好是102

do_fast_syscall_32

__visible long do_fast_syscall_32(struct pt_regs *regs)
{
    /*
     * Called using the internal vDSO SYSENTER/SYSCALL32 calling
     * convention.  Adjust regs so it looks like we entered using int80.
     */

    unsigned long landing_pad = (unsigned long)current->mm->context.vdso +
        vdso_image_32.sym_int80_landing_pad;
    regs->ip = landing_pad;
    enter_from_user_mode();//进入用户态
    local_irq_enable();//
    if (
#ifdef CONFIG_X86_64
        __get_user(*(u32 *)&regs->bp,
                (u32 __user __force *)(unsigned long)(u32)regs->sp)
#else
        get_user(*(u32 *)&regs->bp,
             (u32 __user __force *)(unsigned long)(u32)regs->sp)
#endif
        ) {

        /* User code screwed up. */
        local_irq_disable();
        regs->ax = -EFAULT;
        prepare_exit_to_usermode(regs);//推出用户态
        return 0;

Called using the internal vDSO SYSENTER/SYSCALL32 calling convention. Adjust regs so it looks like we entered using int80.

这句话特别有意思,使其看起来像我们使用了INT80进入内核!!!!!!!!!事实证明它确实不是通过:entry_INT80_32->do_fast_INT80_32->do_syscall_32_irqs_on->__do_sys_socketcall-> sys_socket,有一种挂羊头卖狗肉的感觉,感觉被骗了

实际上linux为了减少系统调用的开销,采取了一种比通过0x80->entry_INT80_32更快的方式,仿照entry_INT80_32,即通过vDSO SYSENTER/SYSCALL32,具体请参考: http://blog.chinaunix.net/uid-27717694-id-4233173.html ,但是二者确实很类似: 可以通过网站 https://elixir.bootlin.com/linux/v5.0.1/source/net/ipv4/tcp_ipv4.c#L202 搜索entry_INT80_32的实现,和entry_SYSENTER_32是差不多的。

do_syscall_32_irqs_on

static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
{
    struct thread_info *ti = current_thread_info();
    unsigned int nr = (unsigned int)regs->orig_ax;  //取系统调用号102
    nr = array_index_nospec(nr, IA32_NR_syscalls);//检查nr是否越界
    regs->ax = ia32_sys_call_table[nr](regs);//取对调用号对应的函数地址和参数
    }

regs的定义

struct pt_regs {
//重点是:orig_ax
/*
 * C ABI says these regs are callee-preserved. They aren't saved on kernel entry
 * unless syscall needs a complete, fully filled "struct pt_regs".
 */
    unsigned long r15;
    unsigned long r14;
    unsigned long r13;
    unsigned long r12;
    unsigned long bp;
    unsigned long bx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
    unsigned long r11;
    unsigned long r10;
    unsigned long r9;
    unsigned long r8;
    unsigned long ax;
    unsigned long cx;
    unsigned long dx;
    unsigned long si;
    unsigned long di;
/*
 * On syscall entry, this is syscall#. On CPU exception, this is error code.
 * On hw interrupt, it's IRQ number:
 */
    unsigned long orig_ax;
/* Return frame for iretq */
    unsigned long ip;
    unsigned long cs;
    unsigned long flags;
    unsigned long sp;
    unsigned long ss;
/* top of stack page */
};

调试验证,hh了,在编译内核的时候应该禁止编译优化的

虽然我看不到,但是从我们之前的程序来看,regs->orig_ax就是102,从而调用了sys_socketcall.

sys_socketcall

switch (call) {
   case SYS_SOCKET:
       err = __sys_socket(a0, a1, a[2]);
       break;
   case SYS_BIND:
       err = __sys_bind(a0, (struct sockaddr __user *)a1, a[2]);
       break;
   case SYS_CONNECT:
       err = __sys_connect(a0, (struct sockaddr __user *)a1, a[2]);
       break;
   case SYS_LISTEN:
       err = __sys_listen(a0, a1);
       break;
   case SYS_ACCEPT:
       err = __sys_accept4(a0, (struct sockaddr __user *)a1,
                   (int __user *)a[2], 0);
       break;
   case SYS_GETSOCKNAME:
       err =
           __sys_getsockname(a0, (struct sockaddr __user *)a1,
                     (int __user *)a[2]);
       break;
   case SYS_GETPEERNAME:
...

通过socket的总接口sys_socketcall进入socket

__sys_socket

int __sys_socket(int family, int type, int protocol)
{
    int retval;
    struct socket *sock;
    int flags;

    flags = type & ~SOCK_TYPE_MASK;
    if (flags & ~(SOCK_CLOEXEC | SOCK_NONBLOCK))
        return -EINVAL;
    type &= SOCK_TYPE_MASK;
    if (SOCK_NONBLOCK != O_NONBLOCK && (flags & SOCK_NONBLOCK))
        flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK;

    retval = sock_create(family, type, protocol, &sock);
    if (retval < 0)
        return retval;
    return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}

SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
    return __sys_socket(family, type, protocol);
}

到此我们的前面两个问题已经解决了。

socket接口是如何与传输层协议绑定的?

由于socket接口对应传输层的协议,包括: TCP,UDP,PING RAW等,那么一个socket接口是如何与这些协议绑定的呢?即linux内核是如何初始化的传输层协议的。

lab3目录下输入调式

qemu -kernel ../../linux-5.0.1/arch/x86/boot/bzImage -initrd ../rootfs.img -append  nokaslr -s -S

另一个终端

gdb
file vmlinux
break inet_init
target remote:1234
c
bt  //查看断点的堆栈情况

#0  inet_init () at net/ipv4/af_inet.c:1900
#1  0xc1000a2d in do_one_initcall (fn=0xc1d6cea6 <inet_init>)
    at init/main.c:887
#2  0xc1d23da2 in do_initcall_level (level=<optimized out>) at init/main.c:955
#3  do_initcalls () at init/main.c:963
#4  do_basic_setup () at init/main.c:981
#5  kernel_init_freeable () at init/main.c:1136
#6  0xc198af98 in kernel_init (unused=<optimized out>) at init/main.c:1054
#7  0xc1991386 in ret_from_fork () at arch/x86/entry/entry_32.S:722

如上TCP/IP协议的初始化是从Linux内核初始化过程中加载TCP/IP协议栈的,从start_kernel、kernel_init、do_initcalls、inet_init,中间过程过

inet_init

static int __init inet_init(void)
{
	struct inet_protosw *q;
	struct list_head *r;
	int rc = -EINVAL;
	sock_skb_cb_check_size(sizeof(struct inet_skb_parm));
	rc = proto_register(&tcp_prot, 1);  //注册tcp
	if (rc)
		goto out;
	rc = proto_register(&udp_prot, 1);//注册udp
	if (rc)
		goto out_unregister_tcp_proto;
	rc = proto_register(&raw_prot, 1); //注册raw
	if (rc)
		goto out_unregister_udp_proto;
	rc = proto_register(&ping_prot, 1);//注册ping
	if (rc)
		goto out_unregister_raw_proto;
	/*
	 *	Tell SOCKET that we are alive...
	 */
	(void)sock_register(&inet_family_ops);
	}

我们可能到注册不同的协议是通过结构体 struct inet_protosw来实现的,我们来看一下结构体的内容,大部分主要内容都是函数指针对应函数指针,即通过结构体包含协议类型协议的处理函数对应,将socket接口和不同的协议关联起来了,所以我们写应用程序socket时必须要指明协议族类型比如 tcp对应AF_INET。

struct proto tcp_prot = {
	.name			= "TCP",              //重要  类型
	.owner			= THIS_MODULE,
	.close			= tcp_close,          //函数close->tcp_close函数
	.pre_connect		= tcp_v4_pre_connect,
	.connect		= tcp_v4_connect,    //函数connect->tcp_v4_connect函数
	.disconnect		= tcp_disconnect,
	.accept			= inet_csk_accept,   //accept
	.ioctl			= tcp_ioctl,
	.init			= tcp_v4_init_sock,
	.destroy		= tcp_v4_destroy_sock,
	.shutdown		= tcp_shutdown,
	.setsockopt		= tcp_setsockopt,
	.getsockopt		= tcp_getsockopt,
	.keepalive		= tcp_set_keepalive,
	.recvmsg		= tcp_recvmsg,     //recvmsg
	.sendmsg		= tcp_sendmsg,     //sendmsg
	.sendpage		= tcp_sendpage,
	.backlog_rcv		= tcp_v4_do_rcv,
	.release_cb		= tcp_release_cb,
	.hash			= inet_hash,
	.unhash			= inet_unhash,
	.get_port		= inet_csk_get_port,
	.enter_memory_pressure	= tcp_enter_memory_pressure,
	.leave_memory_pressure	= tcp_leave_memory_pressure,
	.stream_memory_free	= tcp_stream_memory_free,
	.sockets_allocated	= &tcp_sockets_allocated,
	.orphan_count		= &tcp_orphan_count,
	.memory_allocated	= &tcp_memory_allocated,
	.memory_pressure	= &tcp_memory_pressure,
	.sysctl_mem		= sysctl_tcp_mem,
	.sysctl_wmem_offset	= offsetof(struct net, ipv4.sysctl_tcp_wmem),
	.sysctl_rmem_offset	= offsetof(struct net, ipv4.sysctl_tcp_rmem),
	.max_header		= MAX_TCP_HEADER,
	.obj_size		= sizeof(struct tcp_sock),
	.slab_flags		= SLAB_TYPESAFE_BY_RCU,
	.twsk_prot		= &tcp_timewait_sock_ops,
	.rsk_prot		= &tcp_request_sock_ops,
	.h.hashinfo		= &tcp_hashinfo,
	.no_autobind		= true,
#ifdef CONFIG_COMPAT
	.compat_setsockopt	= compat_tcp_setsockopt,
	.compat_getsockopt	= compat_tcp_getsockopt,
#endif
	.diag_destroy		= tcp_abort,
};

好了,到此传输层协议的初始化过程就结束了。

sokcet接口是如何调用具体协议的接口的?

相信看了上面的传输层协议初始化,这儿你就已经清楚了,是通过应用层接口的参数指定了传输层的协议,再通过结构体的绑定,从而调用相应协议的函数接口。

下面只是调试验证一下,由于我们写的协议是AF_INET,所以我的断点就直接打在tcp的tcp_v4_connect函数,看一看是否和我们想象的一样,接着前面我们分析到 __sys_connect,看看它是否是从 __sys_connect ->tcp_v4_connect。

(gdb) bt
#0  <tcp_v4_connect> (sk=0xc71b06a0, uaddr=0xc7895ec4, addr_len=16)
    at net/ipv4/tcp_ipv4.c:203
#1  0xc18151a1 in __inet_stream_connect (sock=0xc77a04e0,
    uaddr=<optimized out>, addr_len=<optimized out>, flags=2, is_sendmsg=0)
    at net/ipv4/af_inet.c:655
#2  0xc18152c6 in inet_stream_connect (sock=0xc77a04e0, uaddr=0xc7895ec4,
    addr_len=16, flags=2) at net/ipv4/af_inet.c:719
#3  0xc1756f44 in <__sys_connect> (fd=<optimized out>,
    uservaddr=<optimized out>, addrlen=16) at net/socket.c:1663
#4  0xc1757b78 in __do_sys_socketcall (args=<optimized out>,
    call=<optimized out>) at net/socket.c:2561
#5  __se_sys_socketcall (call=3, args=-1076065920) at net/socket.c:2527
#6  0xc1002095 in do_syscall_32_irqs_on (regs=<optimized out>)
    at arch/x86/entry/common.c:334
#7  do_fast_syscall_32 (regs=0xc7895fb4) at arch/x86/entry/common.c:397
#8  0xc199141b in entry_SYSENTER_32 () at arch/x86/entry/entry_32.S:887
#9  0x00000003 in ?? ()
#10 0x00000000 in ?? ()

从堆栈情况来看,完全一致,关于tcp_v4_connect分析参考 https://blog.csdn.net/wangpengqi/article/details/9472699

到此终于分析结束了,虽然中间分析有很多小波澜,尤其系统调用方式不是INT80,这个过程让我很矛盾,但总归结束了。

致谢以下博客及文中提到的博客
https://blog.csdn.net/sunnybeike/article/details/6958473
https://blog.csdn.net/yin262/article/details/53928178
https://blog.csdn.net/qyanqing/article/details/8039343
https://cloud.tencent.com/developer/article/1492374

原文地址:https://www.cnblogs.com/Alexkk/p/12049969.html