• ARM中断处理过程


    转自:http://www.wowotech.net/irq_handler.html

    一、前言

    本文主要以ARM体系结构下的中断处理为例,讲述整个中断处理过程中的硬件行为和软件动作。具体整个处理过程分成三个步骤来描述:

    1、第二章描述了中断处理的准备过程

    2、第三章描述了当发生中的时候,ARM硬件的行为

    3、第四章描述了ARM的中断进入过程

    4、第五章描述了ARM的中断退出过程

    二、中断处理的准备过程

    ARM处理器有多种processor mode,例如user mode(用户空间的AP所处于的模式)、supervisor mode(即SVC mode,大部分的内核态代码都处于这种mode)、IRQ mode(发生中断后,处理器会切入到该mode)等。

    对于linux kernel,其中断处理处理过程中,ARM 处理器大部分都是处于SVC mode。

    但是,实际上产生中断的时候,ARM处理器实际上是先进入IRQ mode,因此在进入真正的IRQ异常处理之前会有一小段IRQ mode的操作,之后会进入SVC mode进行真正的IRQ异常处理。由于IRQ mode只是一个过度,因此IRQ mode的栈很小,只有12个字节,具体如下:

    sprdroid9.0_trunk/kernel4.4/arch/arm/kernel/setup.c
    
    132/*
    133 * Cached cpu_architecture() result for use by assembler code.
    134 * C code should use the cpu_architecture() function instead of accessing this
    135 * variable directly.
    136 */
    137int __cpu_architecture __read_mostly = CPU_ARCH_UNKNOWN;
    138
    139struct stack {
    140	u32 irq[3];
    141	u32 abt[3];
    142	u32 und[3];
    143	u32 fiq[3];
    144} ____cacheline_aligned;
    

    除了irq mode,linux kernel在处理abt mode(当发生data abort exception或者prefetch abort exception的时候进入的模式)和und mode(处理器遇到一个未定义的指令的时候进入的异常模式)的时候也是采用了相同的策略。

    也就是经过一个简短的abt或者und mode之后,stack切换到svc mode的栈上,这个栈就是发生异常那个时间点current thread的内核栈

    anyway,在irq mode和svc mode之间总是需要一个stack保存数据,这就是中断模式的stack,系统初始化的时候,cpu_init函数中会进行中断模式stack的设定:

    /*
    518 * cpu_init - initialise one CPU.
    519 *
    520 * cpu_init sets up the per-CPU stacks.
    521 */
    522void notrace cpu_init(void)
    523{
    524#ifndef CONFIG_CPU_V7M
    525    unsigned int cpu = smp_processor_id();------获取CPU ID
    526    struct stack *stk = &stacks[cpu];---------获取该CPU对于的irq abt和und的stack指针
    527
    528    if (cpu >= NR_CPUS) {
    529        pr_crit("CPU%u: bad primary CPU number
    ", cpu);
    530        BUG();
    531    }
    532
    533    /*
    534     * This only works on resume and secondary cores. For booting on the
    535     * boot cpu, smp_prepare_boot_cpu is called after percpu area setup.
    536     */
    537    set_my_cpu_offset(per_cpu_offset(cpu));
    538
    539    cpu_proc_init();
    540
    541    /*
    542     * Define the placement constraint for the inline asm directive below.
    543     * In Thumb-2, msr with an immediate value is not allowed.
    544     */
    545#ifdef CONFIG_THUMB2_KERNEL
    546#define PLC    "r"------Thumb-2下,msr指令不允许使用立即数,只能使用寄存器。
    547#else
    548#define PLC    "I"
    549#endif
    550
    551    /*
    552     * setup stacks for re-entrant exception handlers
    553     */
    554    __asm__ (
    555    "msr    cpsr_c, %1
    	"------让CPU进入IRQ mode 
    556    "add    r14, %0, %2
    	"------r14寄存器保存stk->irq 
    557    "mov    sp, r14
    	"--------设定IRQ mode的stack为stk->irq 
    558    "msr    cpsr_c, %3
    	"
    559    "add    r14, %0, %4
    	"
    560    "mov    sp, r14
    	"--------设定abt mode的stack为stk->abt 
    561    "msr    cpsr_c, %5
    	"
    562    "add    r14, %0, %6
    	"
    563    "mov    sp, r14
    	"--------设定und mode的stack为stk->und 
    564    "msr    cpsr_c, %7
    	"
    565    "add    r14, %0, %8
    	"
    566    "mov    sp, r14
    	"--------设定fiq mode的stack为stk->fiq 
    567    "msr    cpsr_c, %9"--------回到SVC mode
    568        :--------------------上面是code,下面的output部分是空的 
    569        : "r" (stk),----------------------对应上面代码中的%0 
    570          PLC (PSR_F_BIT | PSR_I_BIT | IRQ_MODE),------对应上面代码中的%1
    571          "I" (offsetof(struct stack, irq[0])),------------对应上面代码中的%2 
    572          PLC (PSR_F_BIT | PSR_I_BIT | ABT_MODE),------以此类推,下面不赘述 
    573          "I" (offsetof(struct stack, abt[0])),
    574          PLC (PSR_F_BIT | PSR_I_BIT | UND_MODE),
    575          "I" (offsetof(struct stack, und[0])),
    576          PLC (PSR_F_BIT | PSR_I_BIT | FIQ_MODE),
    577          "I" (offsetof(struct stack, fiq[0])),
    578          PLC (PSR_F_BIT | PSR_I_BIT | SVC_MODE)
    579        : "r14");--------上面是input操作数列表,r14是要clobbered register列表 
    580#endif
    581}

    嵌入式汇编的语法格式是:

    asm(code

    : output operand list

    : input operand list

    : clobber list);

    大家对着上面的code就可以分开各段内容了。在input operand list中,有两种限制符(constraint),"r"或者"I","I"表示立即数(Immediate operands),"r"表示用通用寄存器传递参数。clobber list中有一个r14,表示在汇编代码中修改了r14的值,这些信息是编译器需要的内容。

    对于SMP,bootstrap CPU会在系统初始化的时候执行cpu_init函数,进行本CPU的irq、abt和und三种模式的内核栈的设定,具体调用序列是:start_kernel--->setup_arch--->setup_processor--->cpu_init。

    对于系统中其他的CPU,bootstrap CPU会在系统初始化的最后,对每一个online的CPU进行初始化,具体的调用序列是:start_kernel--->rest_init--->kernel_init--->kernel_init_freeable--->kernel_init_freeable--->smp_init--->cpu_up--->_cpu_up--->__cpu_up。__cpu_up函数是和CPU architecture相关的。

    对于ARM,其调用序列是__cpu_up--->boot_secondary--->smp_ops.smp_boot_secondary(SOC相关代码)--->secondary_startup--->__secondary_switched--->secondary_start_kernel--->cpu_init。

    除了初始化,系统电源管理也需要irq、abt和und stack的设定。如果我们设定的电源管理状态在进入sleep的时候,CPU会丢失irq、abt和und stack point寄存器的值,那么在CPU resume的过程中,要调用cpu_init来重新设定这些值。

    2、SVC模式的stack准备

    我们经常说进程的用户空间和内核空间,对于一个应用程序而言,可以运行在用户空间,也可以通过系统调用进入内核空间。在用户空间,使用的是用户栈,也就是我们软件工程师编写用户空间程序的时候,保存局部变量的stack。陷入内核后,当然不能用用户栈了,这时候就需要使用到内核栈。所谓内核栈其实就是处于SVC mode时候使用的栈。

    在linux最开始启动的时候,系统只有一个进程(更准确的说是kernel thread),就是PID等于0的那个进程,叫做swapper进程(或者叫做idle进程)。该进程的内核栈是静态定义的,如下:  

    /sprdroid9.0_trunk/kernel4.4/init/init_task.c
    
    21/*
    22 * Initial thread structure. Alignment of this is handled by a special
    23 * linker map entry.
    24 */
    25union thread_union init_thread_union __init_task_data = {
    26#ifndef CONFIG_THREAD_INFO_IN_TASK
    27    INIT_THREAD_INFO(init_task)
    28#endif
    29};
    
    2633union thread_union {
    2634#ifndef CONFIG_THREAD_INFO_IN_TASK
    2635    struct thread_info thread_info;
    2636#endif
    2637    unsigned long stack[THREAD_SIZE/sizeof(long)];
    2638};

    对于ARM平台,THREAD_SIZE是8192个byte,因此占据两个page frame。

    随着初始化的进行,Linux kernel会创建若干的内核线程,而在进入用户空间后,user space的进程也会创建进程或者线程。

    Linux kernel在创建进程(包括用户进程和内核线程)的时候都会分配一个(或者两个,和配置相关)page frame,具体代码如下:

    static struct task_struct *dup_task_struct(struct task_struct *orig) 
    { 
        ...... 
    
        ti = alloc_thread_info_node(tsk, node); 
        if (!ti) 
            goto free_tsk; 
    
        ...... 
    }

    底部是struct thread_info数据结构,顶部(高地址)就是该进程的内核栈。当进程切换的时候,整个硬件和软件的上下文都会进行切换,这里就包括了svc mode的sp寄存器的值被切换到调度算法选定的新的进程的内核栈上来。

    3、异常向量表的准备

    对于ARM处理器而言,当发生异常的时候,处理器会暂停当前指令的执行,保存现场,转而去执行对应的异常向量处的指令,当处理完该异常的时候,
    恢复现场,回到原来的那点去继续执行程序。系统所有的异常向量(共计8个)组成了异常向量表。向量表(vector table)的代码如下:
    /sprdroid9.0_trunk/kernel4.4/arch/arm/kernel/entry-armv.S
    
    208    .section .vectors, "ax", %progbits
    1209__vectors_start:
    1210    W(b)    vector_rst
    1211    W(b)    vector_und
    1212    W(ldr)    pc, __vectors_start + 0x1000
    1213    W(b)    vector_pabt
    1214    W(b)    vector_dabt
    1215    W(b)    vector_addrexcptn
    1216    W(b)    vector_irq---------------------------IRQ Vector
    1217    W(b)    vector_fiq
    1218

    对于本文而言,我们重点关注vector_irq这个exception vector。异常向量表可能被安放在两个位置上:

    (1)异常向量表位于0x0的地址。这种设置叫做Normal vectors或者Low vectors。

    (2)异常向量表位于0xffff0000的地址。这种设置叫做high vectors

    具体是low vectors还是high vectors是由ARM的一个叫做的SCTLR寄存器的第13个bit (vector bit)控制的。对于启用MMU的ARM Linux而言,系统使用了high vectors。为什么不用low vector呢?对于linux而言,0~3G的空间是用户空间,如果使用low vector,那么异常向量表在0地址,那么则是用户空间的位置,因此linux选用high vector。当然,使用Low vector也可以,这样Low vector所在的空间则属于kernel space了(也就是说,3G~4G的空间加上Low vector所占的空间属于kernel space),不过这时候要注意一点,因为所有的进程共享kernel space,而用户空间的程序经常会发生空指针访问,这时候,内存保护机制应该可以捕获这种错误(大部分的MMU都可以做到,例如:禁止userspace访问kernel space的地址空间),防止vector table被访问到。对于内核中由于程序错误导致的空指针访问,内存保护机制也需要控制vector table被修改,因此vector table所在的空间被设置成read only的。在使用了MMU之后,具体异常向量表放在那个物理地址已经不重要了,重要的是把它映射到0xffff0000的虚拟地址就OK了,具体代码如下:

    /sprdroid9.0_trunk/kernel4.4/arch/arm/mm/mmu.c
    static void __init devicemaps_init(const struct machine_desc *mdesc) 
    { 
        …… 
        vectors = early_alloc(PAGE_SIZE * 2); -----分配两个page的物理页帧
    
        early_trap_init(vectors); -------copy向量表以及相关help function到该区域
    
        …… 
        map.pfn = __phys_to_pfn(virt_to_phys(vectors)); 
        map.virtual = 0xffff0000; 
        map.length = PAGE_SIZE; 
    #ifdef CONFIG_KUSER_HELPERS 
        map.type = MT_HIGH_VECTORS; 
    #else 
        map.type = MT_LOW_VECTORS; 
    #endif 
        create_mapping(&map); ----------映射0xffff0000的那个page frame
    
        if (!vectors_high()) {---如果SCTLR.V的值设定为low vectors,那么还要映射0地址开始的memory 
            map.virtual = 0; 
            map.length = PAGE_SIZE * 2; 
            map.type = MT_LOW_VECTORS; 
            create_mapping(&map); 
        }
    
    
        map.pfn += 1; 
        map.virtual = 0xffff0000 + PAGE_SIZE; 
        map.length = PAGE_SIZE; 
        map.type = MT_LOW_VECTORS; 
        create_mapping(&map); ----------映射high vecotr开始的第二个page frame
    
    …… 
    }

    为什么要分配两个page frame呢?这里vectors table和kuser helper函数(内核空间提供的函数,但是用户空间使用)占用了一个page frame,另外异常处理的stub函数占用了另外一个page frame。为什么会有stub函数呢?稍后会讲到。

    在early_trap_init函数中会初始化异常向量表,具体代码如下:

    void __init early_trap_init(void *vectors_base) 
    { 
        unsigned long vectors = (unsigned long)vectors_base; 
        extern char __stubs_start[], __stubs_end[]; 
        extern char __vectors_start[], __vectors_end[]; 
        unsigned i;
    
        vectors_page = vectors_base;
    
        将整个vector table那个page frame填充成未定义的指令。起始vector table加上kuser helper函数并不能完全的充满这个page,有些缝隙。如果不这么处理,当极端情况下(程序错误或者HW的issue),CPU可能从这些缝隙中取指执行,从而导致不可知的后果。如果将这些缝隙填充未定义指令,那么CPU可以捕获这种异常。 
        for (i = 0; i < PAGE_SIZE / sizeof(u32); i++) 
            ((u32 *)vectors_base)[i] = 0xe7fddef1;
    
      拷贝vector table,拷贝stub function 
        memcpy((void *)vectors, __vectors_start, __vectors_end - __vectors_start); 
        memcpy((void *)vectors + 0x1000, __stubs_start, __stubs_end - __stubs_start);
    
        kuser_init(vectors_base); ----copy kuser helper function
    
        flush_icache_range(vectors, vectors + PAGE_SIZE * 2); 
        modify_domain(DOMAIN_USER, DOMAIN_CLIENT); 
    
    }

    一旦涉及代码的拷贝,我们就需要关心其编译连接时地址(link-time address)和运行时地址(run-time address)。在kernel完成链接后,__vectors_start有了其link-time address,如果link-time address和run-time address一致,那么这段代码运行时毫无压力。但是,目前对于vector table而言,其被copy到其他的地址上(对于High vector,这是地址就是0xffff00000),也就是说,link-time address和run-time address不一样了,如果仍然想要这些代码可以正确运行,那么需要这些代码是位置无关的代码。对于vector table而言,必须要位置无关。B这个branch instruction本身就是位置无关的,它可以跳转到一个当前位置的offset。不过并非所有的vector都是使用了branch instruction,对于软中断,其vector地址上指令是“W(ldr)    pc, __vectors_start + 0x1000 ”,这条指令被编译器编译成ldr     pc, [pc, #4080],这种情况下,该指令也是位置无关的,但是有个限制,offset必须在4K的范围内,这也是为何存在stub section的原因了。

    4、中断控制器的初始化

    三、ARM HW对中断事件的处理

    当一切准备好之后,一旦打开处理器的全局中断就可以处理来自外设的各种中断事件了。

    当外设(SOC内部或者外部都可以)检测到了中断事件,就会通过interrupt requestion line上的电平或者边沿(上升沿或者下降沿或者both)通知到该外设连接到的那个中断控制器,而中断控制器就会在多个处理器中选择一个,并把该中断通过IRQ(或者FIQ,本文不讨论FIQ的情况)分发给该processor。ARM处理器感知到了中断事件后,会进行下面一系列的动作:

    1、修改CPSR(Current Program Status Register)寄存器中的M[4:0]。M[4:0]表示了ARM处理器当前处于的模式( processor modes)。ARM定义的mode包括:

    处理器模式 缩写 对应的M[4:0]编码 Privilege level
    User usr 10000 PL0
    FIQ fiq 10001 PL1
    IRQ irq 10010 PL1
    Supervisor svc 10011 PL1
    Monitor mon 10110 PL1
    Abort abt 10111 PL1
    Hyp hyp 11010 PL2
    Undefined und 11011 PL1
    System sys 11111 PL1

    一旦设定了CPSR.M,ARM处理器就会将processor mode切换到IRQ mode。

    2、保存发生中断那一点的CPSR值(step 1之前的状态)和PC值

    ARM处理器支持9种processor mode,每种mode看到的ARM core register(R0~R15,共计16个)都是不同的。每种mode都是从一个包括所有的Banked ARM core register中选取。全部Banked ARM core register包括:

    Usr System Hyp Supervisor abort undefined Monitor IRQ FIQ
    R0_usr                
    R1_usr                
    R2_usr                
    R3_usr                
    R4_usr                
    R5_usr                
    R6_usr                
    R7_usr                
    R8_usr               R8_fiq
    R9_usr               R9_fiq
    R10_usr               R10_fiq
    R11_usr               R11_fiq
    R12_usr               R12_fiq
    SP_usr   SP_hyp SP_svc SP_abt SP_und SP_mon SP_irq SP_fiq
    LR_usr     LR_svc LR_abt LR_und LR_mon LR_irq LR_fiq
    PC                
    CPSR                
        SPSR_hyp SPSR_svc SPSR_abt SPSR_und SPSR_mon SPSR_irq SPSR_fiq
        ELR_hyp            

    在IRQ mode下,CPU看到的R0~R12寄存器、PC以及CPSR是和usr mode(userspace)或者svc mode(kernel space)是一样的。不同的是IRQ mode下,有自己的R13(SP,stack pointer)、R14(LR,link register)和SPSR(Saved Program Status Register)。

    CPSR是共用的,虽然中断可能发生在usr mode(用户空间),也可能是svc mode(内核空间),不过这些信息都是体现在CPSR寄存器中。硬件会将发生中断那一刻的CPSR保存在SPSR寄存器中(由于不同的mode下有不同的SPSR寄存器,因此更准确的说应该是SPSR-irq,也就是IRQ mode中的SPSR寄存器)。

    PC也是共用的,由于后续PC会被修改为irq exception vector,因此有必要保存PC值。当然,与其说保存PC值,不如说是保存返回执行的地址。对于IRQ而言,我们期望返回地址是发生中断那一点执行指令的下一条指令。具体的返回地址保存在lr寄存器中(注意:这个lr寄存器是IRQ mode的lr寄存器,可以表示为lr_irq):

    (1)对于thumb state,lr_irq = PC

    (2)对于ARM state,lr_irq = PC - 4

    为何要减去4?我的理解是这样的(不一定对)。由于ARM采用流水线结构,当CPU正在执行某一条指令的时候,其实取指的动作早就执行了,这时候PC值=正在执行的指令地址 + 8,如下所示:

    ----> 发生中断的指令

                   发生中断的指令+4

    -PC-->发生中断的指令+8

                   发生中断的指令+12

    一旦发生了中断,当前正在执行的指令当然要执行完毕,但是已经完成取指、译码的指令则终止执行。当发生中断的指令执行完毕之后,原来指向(发生中断的指令+8)的PC会继续增加4,因此发生中断后,ARM core的硬件着手处理该中断的时候,硬件现场如下图所示:

    ----> 发生中断的指令

                   发生中断的指令+4 <-------中断返回的指令是这条指令

                  发生中断的指令+8

    -PC-->发生中断的指令+12

    这时候的PC值其实是比发生中断时候的指令超前12。减去4之后,lr_irq中保存了(发生中断的指令+8)的地址。为什么HW不帮忙直接减去8呢?这样,后续软件不就不用再减去4了。这里我们不能孤立的看待问题,实际上ARM的异常处理的硬件逻辑不仅仅处理IRQ的exception,还要处理各种exception,很遗憾,不同的exception期望的返回地址不统一,因此,硬件只是帮忙减去4,剩下的交给软件去调整。

    3、mask IRQ exception。也就是设定CPSR.I = 1

    4、设定PC值为IRQ exception vector。基本上,ARM处理器的硬件就只能帮你帮到这里了,一旦设定PC值,ARM处理器就会跳转到IRQ的exception vector地址了,后续的动作都是软件行为了。

    四、如何进入ARM中断处理

    1、IRQ mode中的处理

    IRQ mode的处理都在vector_irq中,vector_stub是一个宏,定义如下:

    .macro    vector_stub, name, mode, correction=0 
        .align    5
    
    vector_
    ame: 
        .if correction 
        sub    lr, lr, #correction-------------(1) 
        .endif
    
        @ 
        @ Save r0, lr_ (parent PC) and spsr_ 
        @ (parent CPSR) 
        @ 
        stmia    sp, {r0, lr}        @ save r0, lr--------(2) 
        mrs    lr, spsr 
        str    lr, [sp, #8]        @ save spsr
    
        @ 
        @ Prepare for SVC32 mode.  IRQs remain disabled. 
        @ 
        mrs    r0, cpsr-----------------------(3) 
        eor    r0, r0, #(mode ^ SVC_MODE | PSR_ISETSTATE) 
        msr    spsr_cxsf, r0
    
        @ 
        @ the branch table must immediately follow this code 
        @ 
        and    lr, lr, #0x0f---lr保存了发生IRQ时候的CPSR,通过and操作,可以获取CPSR.M[3:0]的值
    
                                这时候,如果中断发生在用户空间,lr=0,如果是内核空间,lr=3 
    THUMB( adr    r0, 1f            )----根据当前PC值,获取lable 1的地址 
    THUMB( ldr    lr, [r0, lr, lsl #2]  )-lr根据当前mode,要么是__irq_usr的地址 ,要么是__irq_svc的地址 
        mov    r0, sp------将irq mode的stack point通过r0传递给即将跳转的函数 
    ARM(    ldr    lr, [pc, lr, lsl #2]    )---根据mode,给lr赋值,__irq_usr或者__irq_svc 
        movs    pc, lr            @ branch to handler in SVC mode-----(4) 
    ENDPROC(vector_
    ame)
    
        .align    2 
        @ handler addresses follow this label 
    1: 
        .endm

    (1)我们期望在栈上保存发生中断时候的硬件现场(HW context),这里就包括ARM的core register。上一章我们已经了解到,当发生IRQ中断的时候,lr中保存了发生中断的PC+4,如果减去4的话,得到的就是发生中断那一点的PC值。

    (2)当前是IRQ mode,SP_irq在初始化的时候已经设定(12个字节)。在irq mode的stack上,依次保存了发生中断那一点的r0值、PC值以及CPSR值(具体操作是通过spsr进行的,其实硬件已经帮我们保存了CPSR到SPSR中了)。为何要保存r0值?因为随后的代码要使用r0寄存器,因此我们要把r0放到栈上,只有这样才能完完全全恢复硬件现场。

    (3)可怜的IRQ mode稍纵即逝,这段代码就是准备将ARM推送到SVC mode。如何准备?其实就是修改SPSR的值,SPSR不是CPSR,不会引起processor mode的切换(毕竟这一步只是准备而已)。

    (4)很多异常处理的代码返回的时候都是使用了stack相关的操作,这里没有。“movs    pc, lr ”指令除了字面上意思(把lr的值付给pc),还有一个隐含的操作(movs中‘s’的含义):把SPSR copy到CPSR,从而实现了模式的切换。

    2、当发生中断的时候,代码运行在用户空间

    Interrupt dispatcher的代码如下:

    vector_stub    irq, IRQ_MODE, 4 -----减去4,确保返回发生中断之后的那条指令

    .long    __irq_usr            @  0  (USR_26 / USR_32)   <---------------------> base address + 0 
    .long    __irq_invalid            @  1  (FIQ_26 / FIQ_32) 
    .long    __irq_invalid            @  2  (IRQ_26 / IRQ_32) 
    .long    __irq_svc            @  3  (SVC_26 / SVC_32)<---------------------> base address + 12 
    .long    __irq_invalid            @  4 
    .long    __irq_invalid            @  5 
    .long    __irq_invalid            @  6 
    .long    __irq_invalid            @  7 
    .long    __irq_invalid            @  8 
    .long    __irq_invalid            @  9 
    .long    __irq_invalid            @  a 
    .long    __irq_invalid            @  b 
    .long    __irq_invalid            @  c 
    .long    __irq_invalid            @  d 
    .long    __irq_invalid            @  e 
    .long    __irq_invalid            @  f

    这其实就是一个lookup table,根据CPSR.M[3:0]的值进行跳转(参考上一节的代码:and    lr, lr, #0x0f)。因此,该lookup table共设定了16个入口,当然只有两项有效,分别对应user mode和svc mode的跳转地址。其他入口的__irq_invalid也是非常关键的,这保证了在其模式下发生了中断,系统可以捕获到这样的错误,为debug提供有用的信息。

  • 相关阅读:
    题解 CF171G 【Mysterious numbers
    题解 P1157 【组合的输出】
    题解 P3955 【图书管理员】
    题解 P2036 【Perket】
    题解 CF837A 【Text Volume】
    题解 CF791A 【Bear and Big Brother】
    题解 CF747A 【Display Size】
    题解 P1332 【血色先锋队】
    题解 P2660 【zzc 种田】
    题解 P4470 【[BJWC2018]售票】
  • 原文地址:https://www.cnblogs.com/haimeng2010/p/10611102.html
Copyright © 2020-2023  润新知