Linux线程的创建

news2024/12/23 17:17:39

用户态创建线程:

线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的。pthread_create不是一个系统调用,是Glibc库的一个函数

在nptl/pthread_creat.c里面找到了这个函数:

int __pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr, void *(*start_routine) (void
{
......
}
versioned_symbol (libpthread, __pthread_create_2_1, pthread_create, GLIBC_2_1);

下面依次来看这个函数做了些啥:

首先处理的是线程的属性参数。例如前面写程序的时候,我们设置的线程栈大小。如果没有传入 线程属性,就取默认值:

const struct pthread_attr *iattr = (struct pthread_attr *) attr;
struct pthread_attr default_attr;
if (iattr == NULL)
{
    ......
     iattr = &default_attr;
}

接下来,就像在内核里一样,每一个进程或者线程都有一个task_struct结构,在用户态也有一个用于维护线程的结构,就是这个pthread结构(也就是pthread相当于内核态里面的task_struct)

struct pthread *pd = NULL;

 凡是涉及函数的调用,都要使用到栈每个线程也有自己的栈。那接下来就是创建线程栈了:

int err = ALLOCATE_STACK (iattr, &pd);

这里的ALLOCATE_STACK是一个宏,我们找到它的定义之后,发现它其实就是一个函数,他的主要代码如下:

# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &stackaddr)
static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
                 ALLOCATE_STACK_PARMS)
{
     struct pthread *pd;
     size_t size;
     size_t pagesize_m1 = __getpagesize () - 1;
......
     size = attr->stacksize;
......
     /* Allocate some anonymous memory. If possible use the cache. */
     size_t guardsize;
     void *mem;
     const int prot = (PROT_READ | PROT_WRITE
        | ((GL(dl_stack_flags) & PF_X) ? PROT_EXEC : 0));
     /* Adjust the stack size for alignment. */
     size &= ~__static_tls_align_m1;
     /* Make sure the size of the stack is enough for the guard and
     eventually the thread descriptor. */
     guardsize = (attr->guardsize + pagesize_m1) & ~pagesize_m1;
     size += guardsize;
     pd = get_cached_stack (&size, &mem);
     if (pd == NULL)
 {
         /* If a guard page is required, avoid committing memory by first
         allocate with PROT_NONE and then reserve with required permission
         excluding the guard page. */
            mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE,
                MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
         /* Place the thread descriptor at the end of the stack. */
    #if TLS_TCB_AT_TP
         pd = (struct pthread *) ((char *) mem + size) - 1;
    #elif TLS_DTV_AT_TP
         pd = (struct pthread *) ((((uintptr_t) mem + size - __static_tls_size) & ~__static_tls_align_m1) - T
    #endif
         /* Now mprotect the required region excluding the guard area. */
         char *guard = guard_position (mem, size, guardsize, pd, pagesize_m1);
         setup_stack_prot (mem, size, guard, guardsize, prot);
         pd->stackblock = mem;
         pd->stackblock_size = size;
         pd->guardsize = guardsize;
         pd->specific[0] = pd->specific_1stblock;
         /* And add to the list of stacks in use. */
         stack_list_add (&pd->list, &stack_used);
     }
 
     *pdp = pd;
     void *stacktop;
    # if TLS_TCB_AT_TP
     /* The stack begins before the TCB and the static TLS block. */
     stacktop = ((char *) (pd + 1) - __static_tls_size);
    # elif TLS_DTV_AT_TP
         stacktop = (char *) (pd - 1);
    # endif
         *stack = stacktop;
...... 
}

allocate_stack主要做了以下这些事情:

1、如果你在线程属性里面设置过栈的大小,需要你把设置的值拿出来;

2、为了防止栈的访问越界,在栈的末尾会有一块空间guardsize,一旦访问到这里就错误了;

3、其实线程栈是在进程的堆里面创建的。如果一个进程不断地创建和删除线程,我们不可能不断 地去申请和清除线程栈使用的内存块,这样就需要有一个缓存。get_cached_stack就是根据计算出来的size大小,看一看已经有的缓存中,有没有已经能够满足条件的;

        如果缓存里面没有,就需要调用__mmap创建一块新的,系统调用那一节我们讲过,如果要在         堆里面malloc一块内存,比较大的话,用__mmap;

4、线程栈也是自顶向下生长的,还记得每个线程要有一个pthread结构,这个结构也是放在栈的 空间里面的。在栈底的位置,其实是地址最高位;

5、计算出guard内存的位置,调用setup_stack_prot设置这块内存的是受保护的;

6、接下来,开始填充pthread这个结构里面的成员变量stackblock、stackblock_size、 guardsize、specific。这里的specific是用于存放Thread Specific Data的,也即属于线程的全局变量;

7、将这个线程栈放到stack_used链表中,其实管理线程栈总共有两个链表,一个是 stack_used,也就是这个栈正被使用;另一个是stack_cache,就是上面说的,一旦线程结束,先缓存起来,不释放,等有其他的线程创建的时候,给其他的线程用

搞定了用户态栈的问题,其实用户态的事情基本搞定了一半,总结一下,在用户态中调用了glibc里面的pthread_create函数,在这里面做了一下几件事:

1、设置线程的属性,如果没有就用默认值

2、维护一个pthread结构,这个结构就相当于内核态里面的task_struct

3、因为凡是涉及函数的调用都要用到栈,每个线程都有一个自己的栈,所以要给线程分配一个栈,在这里面设置了防止越界的空间、栈大小、栈缓存(一个线程结束后可以供给其他线程用)

4、把pthread放入栈

5、填充pthread的成员变量

5、把这个线程栈放入stack_used链表中表示这个线程栈正在被使用。

内核态创建任务

接着pthread_create看,其实有了用户态的栈,接着需要解决的就是用户态的程序从哪里开始运行的问题。

pd->start_routine = start_routine;
pd->arg = arg;
pd->schedpolicy = self->schedpolicy;
pd->schedparam = self->schedparam;
/* Pass the descriptor to the caller. */
*newthread = (pthread_t) pd;
atomic_increment (&__nptl_nthreads);
retval = create_thread (pd, iattr, &stopped_start, STACK_VARIABLES_ARGS, &thread_ran);

start_routine就是咱们给线程的函数,start_routine,start_routine的参数arg,以及调度策略都要赋值给pthread。接下来__nptl_nthreads加一,说明有多了一个线程。真正创建线程的是调用create_thread函数,这个函数定义如下:

static int
create_thread (struct pthread *pd, const struct pthread_attr *attr,
bool *stopped_start, STACK_VARIABLES_PARMS, bool *thread_ran)
{
 const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THR
 ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &pd->tid, tp, &pd->tid);
 /* It's started now, so if we fail below, we'll have to cancel it
and let it clean itself up. */
 *thread_ran = true;
}

这里面有很长的clone_flags。

然后就是ARCH_CLONE,其实调用的是__clone:

# define ARCH_CLONE __clone
/* The userland implementation is:
 int clone (int (*fn)(void *arg), void *child_stack, int flags, void *arg),
 the kernel entry is:
 int clone (long flags, void *child_stack).
 The parameters are passed in register and on the stack from userland:
 rdi: fn
 rsi: child_stack
 rdx: flags
 rcx: arg
r8d: TID field in parent
 r9d: thread pointer
%esp+8: TID field in child
 The kernel expects:
 rax: system call number
 rdi: flags
 rsi: child_stack
 rdx: TID field in parent
 r10: TID field in child
 r8: thread pointer */
 .text
ENTRY (__clone)
 movq $-EINVAL,%rax
......
 /* Insert the argument onto the new stack. */
 subq $16,%rsi
 movq %rcx,8(%rsi)
 /* Save the function pointer. It will be popped off in the
 child in the ebx frobbing below. */
 movq %rdi,0(%rsi)
 /* Do the system call. */
 movq %rdx, %rdi
 movq %r8, %rdx
 movq %r9, %r8
 mov 8(%rsp), %R10_LP
 movl $SYS_ify(clone),%eax
......
 syscall
......
PSEUDO_END (__clone)

如果在进程的主线程里面调用其他系统调用,当前用户态的栈是指向整个进程的栈,栈顶指针也 是指向进程的栈,指令指针也是指向进程的主线程的代码。此时此刻执行到这里,调用clone的时候,用户态的栈、栈顶指针、指令指针和其他系统调用一样,都是指向主线程的。

但是对于线程来说,这些都要变。当clone这个系统调用成功的时候,除了内核里面有这个线程对应的task_struct,当系统调用返回到用户态的时候,用户态的栈应该是线程的栈,栈顶指针应该指向线程的栈,指令指针应该指向线程将要执行的那个函数。

所以这些都需要我们自己做,将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核 返回,从栈里弹出来的时候,就从这个函数开始,带着这些参数执行下去。

接下来我们就要进入内核了。内核里面对于clone系统调用的定义是这样的:

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
     int __user *, parent_tidptr,
     int __user *, child_tidptr,
     unsigned long, tls)
{
    return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}

在clone中系统调用里面执行了_do_fork,这里的_do_fork跟进程创建中的逻辑类似,主要有以下的区别:

第一个是上面复杂的标志位设定:

对于copy_files,原来是调用dup_fd复制一个files_struct的,现在因为CLONE_FILES标识位变成将原来的files_struct引用计数加一:

static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
    struct files_struct *oldf, *newf;
    oldf = current->files;
    if (clone_flags & CLONE_FILES) {
        atomic_inc(&oldf->count);
        goto out;
    }
    newf = dup_fd(oldf, &error);
    tsk->files = newf;
out:
    return error;
}

对于copy_fs,原来是调用copy_fs_struct复制一个fs_struct,现在因为CLONE_FS标识位变成将 原来的fs_struct的用户数加一:

static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
{
    struct fs_struct *fs = current->fs;
    if (clone_flags & CLONE_FS) {
        fs->users++;
        return 0;
    }
    tsk->fs = copy_fs_struct(fs);
    return 0;
}

 对于copy_sighand,原来是创建一个新的sighand_struct,现在因为CLONE_SIGHAND标识位变成将原来的sighand_struct引用计数加一:

static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
{
    struct sighand_struct *sig;
    if (clone_flags & CLONE_SIGHAND) {
        atomic_inc(&current->sighand->count);
    return 0;
    }
    sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
    atomic_set(&sig->count, 1);
    memcpy(sig->action, current->sighand->action, sizeof(sig->action));
    return 0;
}

 对于copy_signal,原来是创建一个新的signal_struct,现在因为CLONE_THREAD直接返回了。

static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
{
    struct signal_struct *sig;
    if (clone_flags & CLONE_THREAD)
        return 0;
    sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
    tsk->signal = sig;
     init_sigpending(&sig->shared_pending);
......
}

 对于copy_mm,原来是调用dup_mm复制一个mm_struct,现在因为CLONE_VM标识位而直接指向了原来的mm_struct

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
    struct mm_struct *mm, *oldmm;
    oldmm = current->mm;
    if (clone_flags & CLONE_VM) {
        mmget(oldmm);
        mm = oldmm;
        goto good_mm;
    }
    mm = dup_mm(tsk);
good_mm:
    tsk->mm = mm;
    tsk->active_mm = mm;
    return 0;
}

第二个就是对于亲缘关系的影响

毕竟我们要识别多个线程是不是属于一个进程。

p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
    p->exit_signal = -1;
    p->group_leader = current->group_leader;
    p->tgid = current->tgid;
} else {
    if (clone_flags & CLONE_PARENT)
        p->exit_signal = current->group_leader->exit_signal;
else
        p->exit_signal = (clone_flags & CSIGNAL);
    p->group_leader = p;
    p->tgid = p->pid;
}
    /* CLONE_PARENT re-uses the old parent */
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
    p->real_parent = current->real_parent;
    p->parent_exec_id = current->parent_exec_id;
} else {
    p->real_parent = current;
    p->parent_exec_id = current->self_exec_id;
}

如果是新进程,那这个进程的group_leader就是他自己,tgid是它自己的pid,这就完全重打 锣鼓另开张了,自己是线程组的头。如果是新线程,group_leader是当前进程的, group_leader,tgid是当前进程的tgid,也就是当前进程的pid,这个时候还是拜原来进程为 老大。

如果是新进程,新进程的real_parent是当前的进程,在进程树里面又见一辈人;如果是新线 程,线程的real_parent是当前的进程的real_parent,其实是平辈的。

 第三,对于信号的处理

如何保证发给进程的信号虽然可以被一个线程处理,但是影响范围应该是整个进程的。例如,kill一个进程,则所有线程都要被干掉。如果一个信号是发给一个线程的 pthread_kill,则应该只有线程能够收到。

在copy_process的主流程里面,无论是创建进程还是线程,都会初始化struct sigpending pending,也就是每个task_struct,都会有这样一个成员变量。这就是一个信号列表。如果这个 task_struct是一个线程,这里面的信号就是发给这个线程的;如果这个task_struct是一个进程,这里面的信号是发给主线程的。

init_sigpending(&p->pending);

另外,上面copy_signal的时候,我们可以看到,在创建进程的过程中,会初始化signal_struct里面的struct sigpending shared_pending。但是,在创建线程的过程中,连signal_struct都共享了。也就是说,整个进程里的所有线程共享一个shared_pending,这也是一个信号列表,是发给整个进程的,哪个线程处理都一样。 

init_sigpending(&sig->shared_pending);

 至此,clone在内核的调用完毕,要返回系统调用,回到用户态。

用户态执行线程

根据__clone的第一个参数,回到用户态也不是直接运行我们指定的那个函数,而是一个通用的 start_thread,这是所有线程在用户态的统一入口。

#define START_THREAD_DEFN \
 static int __attribute__ ((noreturn)) start_thread (void *arg)
START_THREAD_DEFN
{
     struct pthread *pd = START_THREAD_SELF;
 /* Run the code the user provided. */
     THREAD_SETMEM (pd, result, pd->start_routine (pd->arg));
 /* Call destructors for the thread_local TLS variables. */
 /* Run the destructor for the thread-local data. */
    __nptl_deallocate_tsd ();
 if (__glibc_unlikely (atomic_decrement_and_test (&__nptl_nthreads)))
 /* This was the last thread. */
     exit (0);
     __free_tcb (pd);
     __exit_thread ();
}

在start_thread入口函数中,才真正的调用用户提供的函数,在用户的函数执行完毕之后,会释 放这个线程相关的数据。例如,线程本地数据thread_local variables,线程数目也减一。如果 这是最后一个线程了,就直接退出进程,另外__free_tcb用于释放pthread。

void
internal_function
__free_tcb (struct pthread *pd)
{
 ......
 __deallocate_stack (pd);
}
void
internal_function
__deallocate_stack (struct pthread *pd)
{
 /* Remove the thread from the list of threads with user defined
 stacks. */
 stack_list_del (&pd->list);
 /* Not much to do. Just free the mmap()ed memory. Note that we do
 not reset the 'used' flag in the 'tid' field. This is done by
 the kernel. If no thread has been created yet this field is
 still zero. */
 if (__glibc_likely (! pd->user_stack))
 (void) queue_stack (pd);
}

__free_tcb会调用__deallocate_stack来释放整个线程栈,这个线程栈要从当前使用线程栈的列表stack_used中拿下来,放到缓存的线程栈列表stack_cache中。 

总结

创建进程的话,调用的系统调用是fork,在copy_process函数里面,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct都复制一遍,从此父进程 和子进程各用各的数据结构。而创建线程的话,调用的是系统调用clone,在copy_process函数 里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。 

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/77511.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

直播预告 | SOFAChannel#31《RPC 框架设计的考和量》

SOFARPC 是蚂蚁集团开源的一款基于 Java 实现的 RPC 服务框架,为应用之间提供远程服务调用能力,具有高可伸缩性,高容错性,目前蚂蚁集团所有的业务的相互间的 RPC 调用都是采用 SOFARPC。SOFARPC 为用户提供了负载均衡,…

web课程设计网页制作、基于HTML+CSS大学校园班级网页设计

🎉精彩专栏推荐👇🏻👇🏻👇🏻 ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 💂 作者主页: 【主页——🚀获取更多优质源码】 🎓 web前端期末大作业…

代码随想录刷题Day60 | 84. 柱状图中最大的矩形

代码随想录刷题Day60 | 84. 柱状图中最大的矩形 84. 柱状图中最大的矩形 题目: 定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。 求在该柱状图中,能够勾勒出来的矩形的最大面积。 示例 1: …

C++调用OpenCV实现图像阈值处理

1 前言 在计算机视觉技术中,阈值处理是一种非常重要的操作,它是很多高级算法的底层处理逻辑之一。比如在使用OpenCV检测图形时,通常要先对灰度图像进行阈值(二值化)处理,这样就得到了图像的大致轮廓&#…

English Learning - L1 站在高处建立灵魂 2022.12.5

English Learning - L1 站在高处建立灵魂 2022.12.51.1 到底什么是语法1.2 为什么要学习语法口语分广义和狭义讲母语的人为啥不学语法?作为一名二语习得者口语中可不可以没有有语法?1.3 英语(听说读写)的核心金字塔理论关于词汇量…

免费内网穿透工具测评对比,谁更好用 2

文章目录1. 前言2. 对比内容2.1 http协议功能及操作对比2.1.1 网云穿的http设置2.1.2 Cpolar的http设置2.2 使用感受对比3. 结语1. 前言 上篇文章,笔者对比了网云穿和Cpolar的直观内容,包括网站界面、客户端界面和内网穿透设置界面。总的来说&#xff0…

保姆级教程:手把手教你使用深度学习处理文本

大家好,今天给大家分享使用深度学习处理文本,更多技术干货,后面会陆续分享出来,感兴趣可以持续关注。 文章目录NLP技术历程准备数据标准化词元化Tokenization(文本拆分)技术提升建立索引表使用TextVectoriz…

开源开放 | 开源知识图谱抽取工具DeepKE发布更新

知识图谱是一种用图模型来描述知识和建模世界万物之间关联关系的大规模语义网络,是大数据时代知识表示的重要方式之一。近年来,知识图谱在辅助语义搜索、支持智能问答、增强推荐计算、提升语言语义理解和大数据分析能力等越来越多的技术领域得到重视&…

极客时间课程笔记:业务安全

业务安全 业务安全体系:对比基础安全,业务安全有哪些不同?业务安全和基础安全在本质上就有很大的不同:在基础安全中,黑客将技术作为核心竞争力;在业务安全中,黑产将资源作为核心竞争力。谁能够…

ADI Blackfin DSP处理器-BF533的开发详解23:SDRAM内存的设计和自检(含源代码)

硬件准备** ADSP-EDU-BF533:BF533开发板 AD-HP530ICE:ADI DSP仿真器 软件准备 Visual DSP软件 硬件链接 功能介绍 ADSP-EDU-BF53x 板卡上采用的 SDRAM 型号为 MT48LC16M16A2,容量为 32Mbyte,采用 16Bit 模式连接ADSP-BF53x。通过配置 EB…

【STM32】详解嵌入式中FLASH闪存的特性和代码示例

一、存储器 我们正常编译生成的二进制文件,需要下载烧录到单片机里面去,这个文件保存在单片机的ROM(read only memory)中,所有可以完成这种特性的存储介质都可以称为ROM。 分类 ROM一般分为四大类 ①PROM:可编程只读存储器&#…

毫米波雷达系列 | 基于前后向空间平滑的MUSIC算法详解

毫米波雷达系列 | 基于前后向空间平滑的MUSIC算法详解 文章目录毫米波雷达系列 | 基于前后向空间平滑的MUSIC算法详解DOA阵列模型MUSIC算法空间平滑算法整体流程仿真代码忙了一阵子的中期和专利,基本上告一段落,简单的写一个比较常见的解相干MUSIC角度估…

阿里高工珍藏版“亿级高并发系统设计手册(2023版)”面面俱到,太全了!

高并发 俗话说:罗马不是一天建成的,系统的设计当然也是如此。 从原来谁都不看好的淘宝到现在的电商巨头,展现的不仅仅是一家互联网巨头的兴起,也是国内互联网行业迎来井喷式发展的历程,网络信号从 2G 发展到现在的 5…

ATtiny13与Proteus仿真-UART信号模拟仿真

UART信号模拟仿真 ATtiny13没有UART模块,因此在调试程序时,使用软件模拟UART信号很有必要。本文将介绍如何如何控制2个GPIO来模拟UART TX和RX信号,并在Proteus仿真。 1、UART信号介绍 UART的信号一般由如下三部分组成: 开始信号数据信号停止信号UART 信号保持高电平。 作…

软件测试概念基础——小记

文章目录1. 什么是软件测试2. 软件测试和软件开发的区别3. 什么是需求4. 需求是软件测试的依据5. 什么是BUG6. 什么是测试用例7. 开发模型瀑布模型螺旋模型增量模型 迭代模型敏捷模型scrum8. 测试模型V模型W模型9. 软件测试的生命周期(软件测试的流程)10…

Web大学生个人网页作业成品——学生个人爱好展示网站设计与实现(HTML+CSS+JS)

🎉精彩专栏推荐👇🏻👇🏻👇🏻 ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 💂 作者主页: 【主页——🚀获取更多优质源码】 🎓 web前端期末大作业…

FlinkCDC部署

文章目录Flink安装job部署1、测试代码2、打包插件3、打包4、测试Flink安装 1、解压 wget -b https://archive.apache.org/dist/flink/flink-1.13.6/flink-1.13.6-bin-scala_2.12.tgz tar -zxf flink-1.13.6-bin-scala_2.12.tgz mv flink-1.13.6 /opt/module/flink2、环境变量…

快手某HR吐槽:职位要求写得很清楚,照着写简历不行吗?有的工作经历不相关,有的工作好几年还写学生会奖学金,这种一秒扔垃圾桶!...

求职时,你的简历是什么样的?能否帮你顺利通过初筛?一位快手的面试官吐槽很多求职者的简历“一塌糊涂”:职位要求已经写得很明白了,就把里面罗列的技术和跟业务相关的项目经验贴上来就好了,有人偏写航空公司…

Vue 不重新打包,动态加载全局配置的实现过程

背景 项目前端采用了 Vue.js ,跟传统前端 html 技术不同之处在于,每次打包后都重新生成新的 js 文件,而且不可读,必须全量替换。但最近碰到一个漏洞扫描的问题,系统通过单点登录方式访问时,是不能有登录首…

【MySQL基础】数据库操作语言DML相关操作有那些?

目录 一、什么是DML 二、数据插入insert 三、数据的修改update 四、数据的删除delete 五、delete和truncate有什么不同? 六、DML操作知识构图 七、DML操作练习 💟 创作不易,不妨点赞💚评论❤️收藏💙一下 一、什…