• 十二.UART串口通讯


    一个嵌入式设备,串口基本上就是最常用到的外设了,通过串口可以将开发板和电脑连接,也有很多外设是通过串口来进行数据交互的。今天就来搞一下I.MX6UL的串口通讯,实现和电脑通讯的效果。

    UART接口

    I.MX6UL的串口外设叫做UART(Universal Asynchronous Receiver/Trasmitter),即异步串行收发器。UART作为串口的一种,其工作原理也是将数据位一帧一帧的进行传输,数据的发送和接收共用一条线缆,所以UART接口与外设相连的时候最少需要3根线:TXD、RXD和GND。下面的图是UART的通讯格式

    起始位(StartBit)是一个逻辑0

    空闲位:在起始位前面的状态为逻辑1,表示没有数据,空闲中。

    数据位:实际要传输的数据,一般是按照字节阐述,一个字节8位,低位在前先传输,高位在后面传输。

    停止位(StopBit)逻辑1,位数可以是1、1.5或2个bit。

    UART电平标准

    UART一般接口电平有TTL和RS232电平,开发板上的TXD和RXD对应的就是TTL电平,使用低电平表示0,高电平表示1;而DB9接口就是对应的RS232接口,用-3~-15V表示逻辑1,+3~+15V表示逻辑0,采用差分线连接。在使用RS232时候一定要注意接口电平,不要烧毁外设。

    由于现在电脑上基本都不带COM口,而在写单片机什么的需要串口,这就需要一个USB转TTL电平的芯片。最常用的就是CH340。比如Arduino(nano版)的的背后就有个CH340C。用过这个芯片和USB连接就可以实现串口功能(很多USB转232的设备就是用的这个芯片)。

    I.MX6UL的UART接口

    I.MX6UL提供了8组UART接口,结构体如下:

     具备如下特点:

    • 兼容TIA/EIA-232-F标准,最高速率5.0M/s
    • 支持串行IR接口,兼容IrDA,最高速率115200bps
    • 支持9位或多点的RS-485模式
    • 232可以选择7或8位的字符格式,485模式9bit格式
    • 停止位1或2个bit
    • 可编程的奇偶校验
    • 最高到115200bit/s自动波特率检测
    • 等等等等,太多了

    IMX.6UL的UART的功能有非常多,我们这里只用做最基础的串口通讯功能,具体的实际作用参考手册Chapter 55给了非常详细的介绍。

    主要寄存器

    UART相关寄存器也比较多,因为Soc一共有8组UART,这里截取;一组的寄存器映射

     但是要注意的是,虽然8组UART里各个寄存器功能序列是一样的,但是这个内存映射不是从UART1开始的,而是从UART7开始的。

    下面看看几个我们要用到的寄存器

    UARTx_URXD

    接收数据寄存器UART Receiver Register,寄存器结构如下:

     寄存器全部为只读,我们主要用到就是最后的低8位,用来存储接收的数据。另外,bit[10]=1时可以在RS485模式下,数据结构为9bit时保存第九个bit的数据

    UARTx_UTXD

    发送数据寄存器UART Transmitter Register,用来存放待发送的数据。

     寄存器低8位有效,在7bit数据结构下,bit[7]可以忽略,如果想要将数据写入该寄存器,需要确认TRDY(UARTx_UCR1[13])必须为高电平,即当前没有数据被发送。

    UARTx_UCR1

    控制寄存器1(UART Control Register 1)UART提供了4组控制寄存器用来对其进行功能设置,首先是UCR1,先看寄存器结构

    ADEN(bit[15])Automatic Baud Rate Detection Interrupt Enable,自动波特率侦测中断使能 ,允许ADET标志位(UARTx_USR2 bit[15])触发中断

    ADBR(bit[14])Automatic Detection of Baud Rate,自动检测波特率使能,大概意思就是当该位值为1且ADET被清除时,接收器通过接收一个字符A或者a,对比其ASCII码为0x41或0x61,去确认合适的比特率。

    TRDYEN(bit[13])Transmitter Ready Interrupt Enable,数据发送准备中断使能

    IDEN(bit[12])Idle Condition Detected Interrupt Enable,一个什么中断使能,这个暂时没搞懂,暂时应该也用不上

    中间几个中断使能就不说了,最后一个就是UART的使能UARTEN(bit[0]),整个寄存器我们暂时应该也就是能用到这一个bit。(自动获取波特率只能到115200,先关闭不用)

    UARTx_UCR2
    控制寄存器2,寄存器结构如下

     手册上有很详细的解释,这里只说一下需要用到的几个

    IRTS(bit[14])Ignore RTS Pin,1时忽略RTS引脚,我们在使用TTL电平串口信号时只用到RXD和TXD,RTS和CTS一般是不使用的,设置为1即可。

    PREN(bit[8])Parity Enable,校验使能,1时使能校验功能

    PROE(bit[7])Parity Odd/Even,校验方式:1为奇校验,0为偶校验

    STPB(bit[6])停止位,0时停止位1bit,1时2bit

    WS(bit[5])Word Size,数据位长度,0时7bit,1时8bit(该长度不包含起始、结束及校验位)

    TXEN(bit[2])Transmitter Enable,发送数据使能,1时使能

    RXEN(bit[1])Receiver Enable,接收数据使能

    SRET(bit[0])Software Reset,软件复位,写0时对FIFO,USR1,USR2,UBIR,UBMR,UBRC,URXD,UTXD和UTS[6:3]进行复位,但复位前保留4个时钟周期用来进行其他的操作。复位后该位自动置1。

    UARTx_UCR3

    控制寄存器3

     这个我们只用到了一个RXDMUXSEL,因为手册上说了这个应给被置1

    其他的位我们暂时也都用不到。

    UARTx_UFCR

    缓存控制寄存器UART FIFO Control Register,这里我们主要用来设置分频器

    RFDIV(bit[9:7])里定义了从CCM过来的时钟的分频

     注意这个分频不是按照数值+1的模式进行分频的,看具体的值,这个分频器决定的UART的参考时钟

    UARTx_USR2

    状态寄存器1我们也用不到,这里要用到状态寄存器2

    ADET(bit[15])Automatic Baud Rate Detect Complete,波特率检测完毕,当1时接收到合适的A或者a字符,需要写1清除状态

    TXFE(bit[14])Transmit Buffer FIFO Empty,发送缓存状态,1时表示缓存区为空

    TXDC(bit[3])Transmitter Complete,发送完成标志位,1时表示发送数据完成,发送寄存器或发送缓存写入数据,该位自动清零

    RDR(bit[0])Receive Data Ready,数据接收标志位,为1时表示至少还有1个数据要接收

    UARTx_UBIR和UARTx_UBMR

    用来凑波特率的两个寄存器,参考手册第55.5章节介绍了波特率的计算方法

     RefFreq就是经过分频后的参考时钟,比如我们时钟为80MHz,分频为1分频,想要用115200的波特率,就要自己凑了,正点原子给出的数据是UBMR=3124,UBIR=71,那么

    其实NGP给了个函数,可以根据我们需要的波特率计算出对应的参数。

    UART使用

    使用UART的流程和其他的外设差不多也是先初始化、再使用

    时钟源设置

    有一点要注意:修改时钟树对应的时钟源,UART和其他的外设用到的不是一个时钟源,我们前面的用到的都是IPG_CLK,UART用到的的是UART_CLK_ROOT

    我们需要通过CSCDR1选择6分频的pll3(480MHz),也就是80MHz,后面分频器为1分频。根据手册可以查出,UART_CLK_SEL为bit[6],值应为1,分频器UART_CLK_PODR对应bit[5:0],对应2^6+1分频,1分频值为0。

    所以要修改我们的clk初始化函数clk_init

    /*--------------------------UART_CLK设置--------------------------*/
        /*UART_CLK_ROOT主频设置为80MHz*/
        CCM->CSCDR1 &= ~(1<<6);                //CSCDR1[UART_CLK_SEL](bit[6])=0,时钟源80MHz
        CCM->CSCDR1 &= ~(7<<0);                //CSCDR1[UART_CLK_PODF](bit[5:0])设置为0,对应1分频
    /*-------------------------UART_CLK设置完毕------------------------*/

    这步一定要记得!否则波特率就乱了!我在调试的时候就是忘了这一步!

    UART初始化

    UART的初始化包括IO的复用设置、UART参数设置、波特率设置。主要就是设置UCR1、UCR2、UCR3、UFCR、UBIR、UBMR几个寄存器。在设置寄存器值时,应该按照下面的顺序

    • 关闭串口功能(UARTEN=0)
    • 复位UART(SRET=0),复位时等待SRET为1,即复位完毕
    • 设置相关寄存器的值
    • 使能UART

    配置寄存器的过程如下:

        /*配置UART1*/
        UART1->UCR1 = 0;
        // UART1->UCR1 &= ~(1<<14);
    
        /*配置UCR2*/
        UART1->UCR2 = 0;                                 //清除UCR0
        UART1->UCR2 |= (1<<1) |(1<<2) |(1<<5)|(1<<14);   //从左起:RXEN=1 TXEN=1 WS=1 IRTS=1
                                                         //接收、发送使能、数据长度为8bit 忽略RTS引脚
        /*配置UCR3*/
        UART1->UCR3 |= (1<<2);                           //RXDMUXSEL=1
    
        //波特率设置115200
        UART1->UFCR &= ~(7<<7);                          //RFDIV进行清零
        UART1->UFCR = 5<<7;                              //设置1分频,uart_clk=80MHz
    
        UART1->UBIR = 71;
        UART1->UBMR = 3124;

    其实还是比较简单的。

    其他的几个关闭、使能等函数放在最后。

    数据接收、发送

    数据的发送、接收就是对URXD、UTXD的低8位进行操作

    /**
     * @brief           通过UART1发送1个字符
     * 
     * @param c         待发送的字符
     */
    void putc(unsigned char c)
    {
        while(((UART1->USR2 >>3) & 0x01) == 0);     //等待前一个发送流程完毕
        UART1->UTXD = (c & 0xFF);
    }
    
    /*通过UART1接收一个字符*/
    unsigned char getc(void)
    {
        while(((UART1->USR2)&0x01) == 0);          //等待前一个接收流程完毕
        return UART1->URXD;
    }
    
    /**
     * @brief           发送字符串
     * 
     * @param str       待发送的字符串 
     */
    void puts(unsigned *str)
    {
        char *p = str;
        while(*p){
            putc(*p++);
        }
    }

    这样就完成了所有的功能定义。

    文件结构:

    UART功能的文件结构和其他外设一样

     两个文件如下:

    /**
     * @file bsp_uart.c
     * @author your name (you@domain.com)
     * @brief uart功能定义
     * @version 0.1
     * @date 2022-01-17
     * 
     * @copyright Copyright (c) 2022
     * 
     */
    #include "bsp_uart.h"
    
    //初始化uart1,波特率固定为115200
    void uart_init(void)
    {
        uart_io_init();             //IO初始化
        uart_disable(UART1);        //关闭串口
        uart_softreset(UART1);      //复位UART1
    
        /*配置UART1*/
        UART1->UCR1 = 0;
        // UART1->UCR1 &= ~(1<<14);
    
        /*配置UCR2*/
        UART1->UCR2 = 0;                                 //清除UCR0
        UART1->UCR2 |= (1<<1) |(1<<2) |(1<<5)|(1<<14);   //从左起:RXEN=1 TXEN=1 WS=1 IRTS=1
                                                         //接收、发送使能、数据长度为8bit 忽略RTS引脚
        /*配置UCR3*/
        UART1->UCR3 |= (1<<2);                           //RXDMUXSEL=1
    
        //波特率设置115200
        UART1->UFCR &= ~(7<<7);                          //RFDIV进行清零
        UART1->UFCR = 5<<7;                              //设置1分频,uart_clk=80MHz
    
        UART1->UBIR = 71;
        UART1->UBMR = 3124;
    
        uart_enable(UART1);         //使能UART1
    }
    
    /**
     * @brief IO初始化为UART
     * 
     */
    void uart_io_init(void)
    {   
        IOMUXC_SetPinMux(IOMUXC_UART1_TX_DATA_UART1_TX,0);//复用为UART1_TX 
        IOMUXC_SetPinConfig(IOMUXC_UART1_TX_DATA_UART1_TX,0x10b0);
    
        IOMUXC_SetPinMux(IOMUXC_UART1_RX_DATA_UART1_RX,0);
        IOMUXC_SetPinConfig(IOMUXC_UART1_RX_DATA_UART1_RX,0x10b0);
    }
    
    
    /**
     * @brief           关闭UART串口
     * 
     * @param base      UART结构体
     */
    void uart_disable(UART_Type *base)
    {
        base->UCR1 &= (1<<0);
    }
    
    
    /**
     * @brief           使能UART串口
     * 
     * @param base      UART结构体
     */
    void uart_enable(UART_Type *base)
    {
        base->UCR1 |= (1<<0);
    }
    
    
    /**
     * @brief           UART软复位
     * 
     * @param base      UART结构体
     */
    void uart_softreset(UART_Type *base)
    {
        base->UCR2 &= ~(1<<0);                     //SRET=0
        while((base->UCR2 & 0x01) == 0 );          //复位完毕,SRET=1
    }
    
    
    /**
     * @brief           通过UART1发送1个字符
     * 
     * @param c         待发送的字符
     */
    void putc(unsigned char c)
    {
        while(((UART1->USR2 >>3) & 0x01) == 0);     //等待前一个发送流程完毕
        UART1->UTXD = (c & 0xFF);
    }
    
    /*通过UART1接收一个字符*/
    unsigned char getc(void)
    {
        while(((UART1->USR2)&0x01) == 0);          //等待前一个接收流程完毕
        return UART1->URXD;
    }
    
    /**
     * @brief           发送字符串
     * 
     * @param str       待发送的字符串 
     */
    void puts(unsigned *str)
    {
        char *p = str;
        while(*p){
            putc(*p++);
        }
    }
    bsp_uart.c

    头文件

    /**
     * @file bsp_uart.h
     * @author your name (you@domain.com)
     * @brief uart头文件
     * @version 0.1
     * @date 2022-01-17
     * 
     * @copyright Copyright (c) 2022
     * 
     */
    #ifndef __BSP_UART_H
    #define __BSP_UART_H
    
    #include "imx6ul.h"
    
    
    void uart_io_init(void);
    void uart_disable(UART_Type *base);
    void uart_enable(UART_Type *base);
    void uart_softreset(UART_Type *base);
    
    void putc(unsigned char c);
    unsigned char getc(void);
    void puts(unsigned *str);
    #endif
    bsp_uart.h

    在main函数里导入头文件以后,调用函数

    int_init();
    imx6u_clkinit();
    clk_enable();
    uart_init();
    
    while(1)
    {   
        puts("input a char");
        a=getc();
        putc(a);
        puts("\r\n");
        puts("your input is:");
        putc(a);
        puts("\r\n");
    }

    就可以使用串口实现数据交互了。

    PC上运行SecureCRT,使用串口连接,Soc从PC串口接收一个字符,然后返回给PC,就是这么个效果。

    波特率计算

    前面我们已经实现了数据的通讯,但是波特率是固定在115200,并且波特率的计算也是我们凑出来了,可以如果我们需要9600的比特率,还要在凑半天。NXP给我们的SDK包里提供了一个函数,可以直接设置对应的寄存器,这个函数可以直接调用

    /**
     * @brief                   设置比特率(官方代码)
     * 
     * @param base              UART结构特
     * @param baudrate          要设置的比特率
     * @param srcclock_hz       
     */
    void uart_setbaudrate(UART_Type *base, unsigned int baudrate, unsigned int srcclock_hz)
    {
        uint32_t numerator = 0u;        //分子
        uint32_t denominator = 0U;        //分母
        uint32_t divisor = 0U;
        uint32_t refFreqDiv = 0U;
        uint32_t divider = 1U;
        uint64_t baudDiff = 0U;
        uint64_t tempNumerator = 0U;
        uint32_t tempDenominator = 0u;
    
        /* get the approximately maximum divisor */
        numerator = srcclock_hz;
        denominator = baudrate << 4;
        divisor = 1;
    
        while (denominator != 0)
        {
            divisor = denominator;
            denominator = numerator % denominator;
            numerator = divisor;
        }
    
        numerator = srcclock_hz / divisor;
        denominator = (baudrate << 4) / divisor;
    
        /* numerator ranges from 1 ~ 7 * 64k */
        /* denominator ranges from 1 ~ 64k */
        if ((numerator > (UART_UBIR_INC_MASK * 7)) || (denominator > UART_UBIR_INC_MASK))
        {
            uint32_t m = (numerator - 1) / (UART_UBIR_INC_MASK * 7) + 1;
            uint32_t n = (denominator - 1) / UART_UBIR_INC_MASK + 1;
            uint32_t max = m > n ? m : n;
            numerator /= max;
            denominator /= max;
            if (0 == numerator)
            {
                numerator = 1;
            }
            if (0 == denominator)
            {
                denominator = 1;
            }
        }
        divider = (numerator - 1) / UART_UBIR_INC_MASK + 1;
    
        switch (divider)
        {
            case 1:
                refFreqDiv = 0x05;
                break;
            case 2:
                refFreqDiv = 0x04;
                break;
            case 3:
                refFreqDiv = 0x03;
                break;
            case 4:
                refFreqDiv = 0x02;
                break;
            case 5:
                refFreqDiv = 0x01;
                break;
            case 6:
                refFreqDiv = 0x00;
                break;
            case 7:
                refFreqDiv = 0x06;
                break;
            default:
                refFreqDiv = 0x05;
                break;
        }
        /* Compare the difference between baudRate_Bps and calculated baud rate.
         * Baud Rate = Ref Freq / (16 * (UBMR + 1)/(UBIR+1)).
         * baudDiff = (srcClock_Hz/divider)/( 16 * ((numerator / divider)/ denominator).
         */
        tempNumerator = srcclock_hz;
        tempDenominator = (numerator << 4);
        divisor = 1;
        /* get the approximately maximum divisor */
        while (tempDenominator != 0)
        {
            divisor = tempDenominator;
            tempDenominator = tempNumerator % tempDenominator;
            tempNumerator = divisor;
        }
        tempNumerator = srcclock_hz / divisor;
        tempDenominator = (numerator << 4) / divisor;
        baudDiff = (tempNumerator * denominator) / tempDenominator;
        baudDiff = (baudDiff >= baudrate) ? (baudDiff - baudrate) : (baudrate - baudDiff);
    
        if (baudDiff < (baudrate / 100) * 3)
        {
            base->UFCR &= ~UART_UFCR_RFDIV_MASK;
            base->UFCR |= UART_UFCR_RFDIV(refFreqDiv);
            base->UBIR = UART_UBIR_INC(denominator - 1); //要先写UBIR寄存器,然后在写UBMR寄存器,3592页 
            base->UBMR = UART_UBMR_MOD(numerator / divider - 1);
        }
    }

    make的事项

    在导入上面自动设置波特率的函数以后,在make的时候会报错

    错误提示是变量未定义,原因是我们调用uart_setbaudrate这个函数时候需要进行除法运算,而ARM没有除法运算的硬件结构,进行除法运算需要借助软件编译器,软浮点的实现是在一个叫做libgcc.a的库中。这个库需要我们在编译的时候指定。因为我直接用到树莓派自带的交叉编译器,库的地址可以在/lib路径下搜一下:

     教程用的交叉编译器版本是4.9.4,我用的是8.3

    暂时还没出现什么问题, 记录下libgcc.a的路径,添加在makefile中

     1 CC                := $(CROSS_COMPILE)gcc
     2 LD                 := $(CROSS_COMPILE)ld
     3 OBJCOPY            := $(CROSS_COMPILE)objcopy 
     4 OBJDUMP            := $(CROSS_COMPILE)objdump 
     5 
     6 LIBPATH            := -lgcc -L /lib/gcc/arm-linux-gnueabihf/8   #制定依赖库路径
     7 
     8 $(TARGET).bin : $(OBJS)
     9 
    10     $(LD) -Timx6ul.lds -o $(TARGET).elf $^ $(LIBPATH)         #将所有依赖文件链接,生成.elf文件
    11     $(OBJCOPY) -O binary -S $(TARGET).elf $@                #将elf转换为依赖的目标集合
    12     $(OBJDUMP) -D -m arm $(TARGET).elf > $(TARGET).dis        #将elf文件反汇编

    要对原先的通用Makefile进行修改:

    • 添加第6行,通过一个变量指定依赖的路径
    • 修改第4行,在链接的时候引用变量

    修改完了make一下,看到会报一个错!

     

    原因是我们定义的putc和puts两个函数和libgcc.a库里的原生的函数重名了。要解决这个问题还是修改Makefile文件

    1 # 静态模式 <Targets...>:<tatgets-pattern>:<prereq-patterns...>下面两天为自写
    2 $(SOBJS) :    obj/%.o    :    %.s  #将所有的.s文件编译成.o文件放在obj文件夹内
    3     $(CC) -Wall -nostdlib -fno-builtin -c -O2 $(INCLUDE) -o $@ $<
    4 
    5 $(COBJS) : obj/%.o : %.c 
    6     $(CC) -Wall -nostdlib -fno-builtin -c -O2  $(INCLUDE) -o $@ $<

    在第3、6行加上参数-fno-builtin,意思是不调用C语言的内建函数。这样调用的函数就是我们自己定义的函数了。

    make以后还是有个错误

     通过提示大概意思就是要定义一个异常处理的函数raise,对应的idiv0我觉得意思是当除数为0时候的异常处理。我们定义一个空函数就可以了。

    void raise(int sig_nr) 
    {
    
    }

    在头文件里声明,搞定!

    printf格式化函数的移植

    我们前面的串口驱动,只能发送一般的字符,如果需要输出数字的时候还要将数字转换为字符,很不方便。一般很常用的方法就是把printf函数映射到串口上,那样就可以直接使用printf函数来完成格式化输出了。

    库移植

    将教程提供的stdio文件夹复制到项目根目录下,修改Makefile文件

     从文件名称就可以看出来,目录下include文件夹里的是头文件,lib里是源代码,将该路径添加到Makefile里。进行make。

    函数调用

    导入这个库以后就可以直接使用格式化输入和输出了

        int a,b;
        while(1)
        { 
            printf("请输入两个值,用空格隔开");
            scanf("%d %d",&a,&b);
            printf("\r\n 数据%d+%d=%d\r\n",a,b,a+b);
        }

    上面的代码是在main函数中的,前面初始化串口、时钟什么的我没有截取,主要就是看一下怎么使用两个函数。但是要注意一点:被移植的printf不支持浮点类运算!!!

    这里跟教程有些区别:

    前面说过,正点原子提供的教程上使用的交叉编译器什4.9.4,而我用到时8.3,我对照在X86架构下使用4.9.4在make的时候会报错:

     错误信息thumb conditional instruction should be in IT block -- `addcs r5,r5,#65536',这个指令集错误我没有找到出处,解决办法是在编译C文件时候加上一个参数:Wa,-mimplicit-it=thumb(百度上直接给的方案,没有找到具体的解决流程和原因)

    修改后的Makefile

    # 静态模式 <Targets...>:<tatgets-pattern>:<prereq-patterns...>下面两天为自写
    $(SOBJS) :    obj/%.o    :    %.s  #将所有的.s文件编译成.o文件放在obj文件夹内
        $(CC) -Wall -nostdlib -fno-builtin -c -O2 $(INCLUDE) -o $@ $<
    
    $(COBJS) : obj/%.o : %.c 
        $(CC) -Wall -Wa,-mimplicit-it=thumb -nostdlib -fno-builtin -c -O2  $(INCLUDE) -o $@ $<

    烧录sd卡就行了。

    调试时候的BUG

    在最后调试的时候出现了一个大BUG,这里记录一下吧,免得以后忘了!

    开始怎么也没搞清,现象就是添加玩stdio库以后make能成功,但是用imxdownload下载一直报错

    先后试过教程提供的源码,更换了交叉编译器,重新编译了下载软件一直都不行,按理说报dd错误是磁盘写入失败,烧录前面的所有程序都可以,在后来发现只要写入到文件size没有超过10000Bytes都正常,就没想过是卡的问题。直到发现烧录完以前的程序发现上电后初始化非常慢,想到卡可能出问题了,用fdisk格式化失败,Ubuntu下使用好几个磁盘工具格式化都报错,换了个读卡器也不行。没办法找了个win10的PC,格式化了一下,还是不行,用Imager烧录了个树莓派的镜像,没问题,回来重新烧录一遍,竟然好了!估计是最近经常用读卡器是不是有什么问题了。

  • 相关阅读:
    设计模式中的多态——策略模式详解
    Spring IOC容器启动流程源码解析(一)——容器概念详解及源码初探
    并发包下常见的同步工具类详解(CountDownLatch,CyclicBarrier,Semaphore)
    HNOI2020游记
    旧年之末,新年伊始
    退役V次后做题记录
    PKUWC2020 游记
    CSP2019退役记
    CTS/APIO2019游记
    HNOI2019游记
  • 原文地址:https://www.cnblogs.com/yinsedeyinse/p/15811080.html
Copyright © 2020-2023  润新知