• C库文件缓存对proc读取的影响


    一、问题
    有时候,一个用户态的监控任务可能要定期的获得系统的某个状态,例如CPU利用率,进程切换次数、甚至maps的布局等信息,这些信息一般都是通过内核的proc文件系统来获得。由于是周期性的获得这些信息,为了避免临时抱佛脚,可以在模块初始化的时候打开这个文件,然后下次就不同通过open系统调用,进而减少内核对struct file结构的申请和释放。但是在实际应用的过程中却发现效果不好,每次读取的文件内容都是相同的。这就有有理由怀疑是C库中对文件读取进行了缓冲。
    二、缓冲思想
    其实快速设备和慢速设备的通讯,缓冲思想的根本有两个:一个是提前读,一个是延迟写。所谓提前读就是要打提前亮,假设你要读1个字节,我一下充分利用带宽(这里的带宽就是一次系统调用),一次读入很多,从而你再次要求读入的时候这个内容已经存在了,从而不再需要进入新的系统调用。所谓的延迟写,就是向文件中写入内容的时候进行缓冲,并不马上把这个请求转达给内核,而是先记录在自己的一个账面上,当这个写操作累积到一定程度的时候,在进行一次性的写入。这样一方面减少了零碎而繁琐的写入操作,另一方面,这个写入可能之后还会被再次修改。例如我们调试一个程序的时候,某个地方可能会被改来改去、改来改去……。
    三、缓冲大小
    对于posix的说明,这个缓冲大小由stdio.h中的BUFSIZ宏确定,这个值好像编译时为8192字节,也就是两个4KB页面大小。但事实上C库对这个值可能进行微调,微调的依据就是待打开文件所在文件系统中一个block的大小。C库中对这个空间的判断为glibc-2.7libiofiledoalloc.c
    _IO_file_doallocate (fp)
    size = _IO_BUFSIZ;
      if (fp->_fileno >= 0 && __builtin_expect (_IO_SYSSTAT (fp, &st), 0) >= 0)通过stat系统调用获得将要打开的文件的stat信息。
        {
          if (S_ISCHR (st.st_mode))
        {
          /* Possibly a tty.  */
          if (
    #ifdef DEV_TTY_P
              DEV_TTY_P (&st) ||
    #endif
              isatty (fp->_fileno))
            fp->_flags |= _IO_LINE_BUF;
        }
    #if _IO_HAVE_ST_BLKSIZE
          if (st.st_blksize > 0)
        size = st.st_blksize;如果st中包含了文件所在存储设备上的块大小,则初始化这个大小为设备块大小
    #endif
        }
    简单测试一下(源代码非常简单,就是fopen + fread,这里就不贴了,只是里面的fread的大小是1)
    [tsecer@Harry fopen]$ strace ./a.out fopen.c 
    execve("./a.out", ["./a.out", "fopen.c"], [/* 24 vars */]) = 0
    ……
    read(3, "#include <stdio.h> int main(int "..., 4096) = 222
    ……
    [root@Harry fopen]# strace ./a.out /proc/cmdline 
    ……
    read(3, "ro root=/dev/mapper/vg_harry-lv_"..., 1024) = 120
    然后使用stat工具看一下这两个文件的设备块大小
    [tsecer@Harry fopen]$ stat /proc/cmdline 
      File: `/proc/cmdline'
      Size: 0             Blocks: 0          IO Block: 1024   regular empty file
    Device: 3h/3d    Inode: 4026531979  Links: 1
    Access: (0444/-r--r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
    Access: 2011-12-17 14:28:25.737845323 +0800
    Modify: 2011-12-17 14:28:25.737845323 +0800
    Change: 2011-12-17 14:28:25.737845323 +0800
    [tsecer@Harry fopen]$ stat fopen.c 
      File: `fopen.c'
      Size: 222           Blocks: 8          IO Block: 4096   regular file
    Device: fd00h/64768d    Inode: 413814      Links: 1
    Access: (0664/-rw-rw-r--)  Uid: (  500/  tsecer)   Gid: (  500/  tsecer)
    Access: 2011-12-17 14:22:28.079521553 +0800
    Modify: 2011-12-17 14:22:25.375289098 +0800
    Change: 2011-12-17 14:22:25.387015876 +0800
    也就是说,即使你每次只读取一个字节,那么C库的fread函数还是尽量按照一个block的大小来读取的。读取之后把这个文件的内容放在C库中通过malloc申请的一个内存区中,之后的操作将尽量尝试在这个缓冲区中完成。这个思想也是内核对于设备驱动的一个思路,就是尽可能的将数据尽可能的保存在快速设备中,这个快速设备对于内核来说就是高速缓存cache,而对于用户态的C库来说就是用户态malloc地址。
    这个想法是有它的积极意义的。因为对于文件操作,回想一下,我们的确是大部分时间都是每次读取少量的数据,然后处理,然后在读取,经过这个流程来迭代。如果C库不进行缓存,那么每次读取都要执行一次系统调用,那么这个开销还是比较大的。例如,我们最为常见的就是文本操作,当浏览一个文件的时候,一般是每次下翻一行。还有,linux下大部分常用文件都比较小,一般是4K一下,例如大量存在的脚本文件及配置文件等。所以如果能够一次性把这些内存读取出来也是一个好事。
    但是如果凑巧打开的是proc文件系统,其中的文件名虽然不变,但是每次读取的内容很可能会不同(这里指打开一次,之后每次都是在这个打开的FILE结构上执行fread操作)。加上proc中文件一般都很小,所以整个文件可能会在第一次读取的时候会被全部读入内存,从而之后的所有操作都是在用户态执行的,内核的更新无法反映到用户态。
    当然可以每次都fopen+fclose来一个推倒重来,但是这样明显是因噎废食,属于防卫过当行为,所以应该避免这种过激行为。
    四、缓冲结构
    glibc-2.7libiolibio.h
    struct _IO_FILE {
      /* The following pointers correspond to the C++ streambuf protocol. */
      /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
      char* _IO_read_ptr;    /* Current read pointer */当前读取位置指针
      char* _IO_read_end;    /* End of get area. */当前可以读入的指针,也就是预读数据的结尾位置,为提前读入数据结尾
      char* _IO_read_base;    /* Start of putback+get area. */读入位置的起始地址
      char* _IO_write_base;    /* Start of put area. */写入地址开始
      char* _IO_write_ptr;    /* Current put pointer. */当前写入地址
      char* _IO_write_end;    /* End of put area. */缓冲区中还有多少空间可用于延迟写。
      char* _IO_buf_base;    /* Start of reserve area. */用户态缓冲区起始地址
      char* _IO_buf_end;    /* End of reserve area. */用户态缓冲区结束地址
    该行之上的是我们真正关系的,之下的对于分析来说没有用到。
    。这里说明的是:其中的这些地址都是地址指针,并且都是在IO_buf_base和IO_base_end之间,而后两者则是用户态缓冲区的起始和结束地址,它们在缓冲区创建之后就不再变化,作为缓冲区定界作用。其它的指针都应该在这个范围之内,并且也是地址指针,也就是说它们都可以不经转换来作为memcpy或者内存赋值地址(相对于相对偏移量)。
    可以看到的是,其中为读出和写入定义了两个不同的指针,但是在内核开来,是不区分读指针和写指针的,内核的文件描述符中只有一个当前操作偏移量,所以如果进行读/写切换的时候,这些指针的转换就是一个需要考虑的问题。
    另一方面,由于预读和迟写的存在,操作系统看到的文件内部偏移量和应用程序看到的偏移量可能不一致。例如,我执行fread 1个字节之后,事实上C库向操作系统预读了4096字节,此时操作系统记录的文件内部偏移位置也是4096,但是用户认为他/她只读了1个字节。这些一致性需要在某些时机得到保证和维护。
    这个用户缓冲区的申请就是在第三段中描述的filedoalloc.c:ALLOC_BUF (p, size, EOF)函数中完成的,从而申请了一个用户态缓冲区。
    五、read/write/seek
    这些是文件系统的基本操作,很多操作都是在这三个基本操作的基础上变换出来的。
    1、从读模式切换到写模式。
    在这种情况下,其实用户没有对文件内容做任何修改,所以可以就地切换,也就是将当前的read_ptr转换为write_base,然后在这个基础上进行缓冲写入。但是此时还有一个问题需要注意,就是之前的读入模式很可能是进行了预读,此时操作系统中的文件偏移量是冒进了,所以要再把这个指针偏转到真正的当前写入地址,这是一个一致性保持的点。glibc-2.7libiofileops.c
    static
    _IO_size_t
    new_do_write (fp, data, to_do)
    ……
      else if (fp->_IO_read_end != fp->_IO_write_base)这里的read_end就是预读的结束位置,而write_base为即将开始写入的位置。如果两者不相等,那么此时说明预读了过多的内容,此时需要将内核的上下文指针归位到write_base,从而完成用户态和内核态指针的一次同步
        {
          _IO_off64_t new_pos
        = _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
          if (new_pos == _IO_pos_BAD)
        return 0;
          fp->_offset = new_pos;
        }
    ……
      _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);这里将所有的读取参数都赋值为同一个值,所以将不会再有预读内容,如果下次需要再次读入数据,需要重新启动read系统调用。
    2、从写模式切换到读模式
    此时要将所有未写入的数据写入操作系统,但是由于fwrite是滞后操作,所以在写入执行之后write_ptr可以直接作为read_base来使用。这里的思想就是一个尽量直接就地转换的思想。
    在_IO_switch_to_get_mode (fp)函数中
    ……
      fp->_IO_read_ptr = fp->_IO_write_ptr;这里将写入的位置赋值给当前读入位置,从而可以从该位置继续读出。但是可以看到,read_base并没有变化,所以,如果下次希望从之前修改过的地方读取数据的话,依然可以从C库中直接获得

      fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end = fp->_IO_read_ptr;写地址和当前读出地址一致。

      fp->_flags &= ~_IO_CURRENTLY_PUTTING;清空写入标志,进入读出模式。

    3、fseek
    在_IO_new_file_seekoff函数中,其中进行了当移动之后是否需要进行预取的判断。
       /* If destination is within current buffer, optimize: */
      if (fp->_offset != _IO_pos_BAD && fp->_IO_read_base != NULL第一次rewind的时候,这里的第一个条件是不满足的,但是之后就会满足。
          && !_IO_in_backup (fp))
        {
          _IO_off64_t start_offset = (fp->_offset
                      - (fp->_IO_read_end - fp->_IO_buf_base));这里eoffset是指文件在内核文件内的偏移量,或者说是C库向内核实质上已经读入的位置,可能包括了预读取得数据。预读取数据的长度可以从read_end-buf_base计算得到。所以这个start_offset就是指当前缓冲区中有效数据在文件中的起始位置偏移量
          if (offset >= start_offset && offset < fp->_offset)如果说新设置的文件指针在这个用户缓冲区之中,则只调整各个指针的位置而不进行真正的读取动作
        {
          _IO_setg (fp, fp->_IO_buf_base,
                fp->_IO_buf_base + (offset - start_offset),
                fp->_IO_read_end);这里更新了读取指针的位置,并没有进行真正的文件读取操作,也就是说数据没有根据文件内容更新
          _IO_setp (fp, fp->_IO_buf_base, fp->_IO_buf_base);

          _IO_mask_flags (fp, 0, _IO_EOF_SEEN);
          goto resync;如果文件比较小,那么这里将会直接退出,从而停止更新。
        }
        }
    4、fread操作
      while (want > 0)
        {
          have = fp->_IO_read_end - fp->_IO_read_ptr;这里是判断当前缓冲区中剩余数据数量,不包括已经被读出的数据,因为fread是消耗性读取,它将会偏移文件指针。
          if (want <= have)如果说缓冲中剩余数据大于此次读取,则可以直接从缓冲区中拷贝给用户。这里的判断非常重要,因为这个read_end和read_ptr可以通过fseek改变,并且这里决定是否真正启动read系统调用,所以决定用户fread/fsanf数据是否更新的问题
        {
          memcpy (s, fp->_IO_read_ptr, want);
          fp->_IO_read_ptr += want;
          want = 0;
        }
          else
        {
          if (have > 0)
            {
    ……
     /* If we now want less than a buffer, underflow and repeat
             the copy.  Otherwise, _IO_SYSREAD directly to
             the user buffer. */
          if (fp->_IO_buf_base
              && want < (size_t) (fp->_IO_buf_end - fp->_IO_buf_base))最后一次读入数据存放入缓冲区中,其它的直接通过read系统调用读取而不缓存
            {
              if (__underflow (fp) == EOF)这里将会进行最后一次的读取和缓存工作
            break;

              continue;
            }
          _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);相反的,如果缓冲区中的数据不足于此次读取,则一定会将指针归一,从而要求底层重新从文件中读入
          _IO_setp (fp, fp->_IO_buf_base, fp->_IO_buf_base);
    5、fscanf
    glibc-2.7libiolibio.h
    #define _IO_getc_unlocked(_fp)
           (_IO_BE ((_fp)->_IO_read_ptr >= (_fp)->_IO_read_end, 0)
        ? __uflow (_fp) : *(unsigned char *) (_fp)->_IO_read_ptr++)这里直接使用了C库的内部数据结构和接口,所以它的行为特征是和fread相似的
    #define _IO_peekc_unlocked(_fp)
           (_IO_BE ((_fp)->_IO_read_ptr >= (_fp)->_IO_read_end, 0)
          && __underflow (_fp) == EOF ? EOF
        : *(unsigned char *) (_fp)->_IO_read_ptr)
    #define _IO_putc_unlocked(_ch, _fp)
       (_IO_BE ((_fp)->_IO_write_ptr >= (_fp)->_IO_write_end, 0)
        ? __overflow (_fp, (unsigned char) (_ch))
        : (unsigned char) (*(_fp)->_IO_write_ptr++ = (_ch)))
    6、fflush
    INTDEF2(_IO_new_file_overflow, _IO_file_overflow)

    int
    _IO_new_file_sync (fp)
         _IO_FILE *fp;
    {
      _IO_ssize_t delta;
      int retval = 0;

      /*    char* ptr = cur_ptr(); */
      if (fp->_IO_write_ptr > fp->_IO_write_base)这里是大家熟知的一些功能,就是将之前所有缓冲的写操作一次性提交给操作系统
        if (_IO_do_flush(fp)) return EOF;
      delta = fp->_IO_read_ptr - fp->_IO_read_end;
      if (delta != 0)
        {
    #ifdef TODO
          if (_IO_in_backup (fp))
        delta -= eGptr () - Gbase ();
    #endif
          _IO_off64_t new_pos = _IO_SYSSEEK (fp, delta, 1);用户态文件位置和内核态位置一致化。
          if (new_pos != (_IO_off64_t) EOF)
        fp->_IO_read_end = fp->_IO_read_ptr;这里是更新的根本,这里强制将read_end和read_ptr归一,效果就是相当于C库没有任何预读数据,如果下次需要读取数据的话,必须启动read系统调用
    #ifdef ESPIPE
          else if (errno == ESPIPE)
        ; /* Ignore error from unseekable devices. */
    #endif
          else
        retval = EOF;
        }
      if (retval != EOF)
        fp->_offset = _IO_pos_BAD;
      /* FIXME: Cleanup - can this be shared? */
      /*    setg(base(), ptr, ptr); */
      return retval;
    }
    六、一些杂项
    C库中使用了一些函数指针,其中最为常用的文件跳转表内容
    const struct _IO_jump_t _IO_file_jumps =
    {
      JUMP_INIT_DUMMY,
      JUMP_INIT(finish, INTUSE(_IO_file_finish)),
      JUMP_INIT(overflow, INTUSE(_IO_file_overflow)),
      JUMP_INIT(underflow, INTUSE(_IO_file_underflow)),
      JUMP_INIT(uflow, INTUSE(_IO_default_uflow)),
      JUMP_INIT(pbackfail, INTUSE(_IO_default_pbackfail)),
      JUMP_INIT(xsputn, INTUSE(_IO_file_xsputn)),
      JUMP_INIT(xsgetn, INTUSE(_IO_file_xsgetn)),
      JUMP_INIT(seekoff, _IO_new_file_seekoff),
      JUMP_INIT(seekpos, _IO_default_seekpos),
      JUMP_INIT(setbuf, _IO_new_file_setbuf),
      JUMP_INIT(sync, _IO_new_file_sync),
      JUMP_INIT(doallocate, INTUSE(_IO_file_doallocate)),
      JUMP_INIT(read, INTUSE(_IO_file_read)),
      JUMP_INIT(write, _IO_new_file_write),
      JUMP_INIT(seek, INTUSE(_IO_file_seek)),
      JUMP_INIT(close, INTUSE(_IO_file_close)),
      JUMP_INIT(stat, INTUSE(_IO_file_stat)),
      JUMP_INIT(showmanyc, _IO_default_showmanyc),
      JUMP_INIT(imbue, _IO_default_imbue)
    };
    其中的JUMPX(其中的N为数字)即表示调用的其中的哪个接口,而数字则表示该函数需要的参数个数(除了fp本身)。例如
    #define _IO_SYSREAD(FP, DATA, LEN) JUMP2 (__read, FP, DATA, LEN)
    # define JUMP2(FUNC, THIS, X1, X2) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1, X2)
    overflow发生在当向缓冲区中写入数据时缓冲区满的情况,(也就是write_ptr==write_end),如果写入值为EOF,则强制缓冲区数据写入操作系统,对应地,underflow表示从缓冲区中读取数据时出现的已经没有缓冲数据的情况,此时需要重新从操作系统中读入数据。
    sget 表示Stream Get 
    sgetn Stream Get N bytes
    xsgetn  eXtended Stream Get N bytes
    七、如果说没有fclose时写入缓冲如何处理
    因为这里已经废话很多了,所以放一个调用链就好了,这个应该是一个很详细的资料了
    (gdb) bt
    #0  0x080661f0 in write ()
    #1  0x0804ab41 in _IO_new_file_write (f=0x80d5f70, data=0xb7fff002, n=2)
        at fileops.c:1266
    #2  0x08048fdf in new_do_write (fp=0x80d5f70, 
        data=0xb7fff002 "ncde  <stdio.h> int main(int argc ,char * argv[]) { char buf[2]; FILE* file = fopen(argv[1],"r+b"); if(NULL == file) perror("Fopen error\n"); fwrite(buf,sizeof(buf),1,file); fread(buf,sizeof(bu"..., to_do=2) at fileops.c:520
    #3  0x08048eff in _IO_new_do_write (fp=0x80d5f70, 
        data=0xb7fff002 "ncde  <stdio.h> int main(int argc ,char * argv[]) { char buf[2]; FILE* file = fopen(argv[1],"r+b"); if(NULL == file) perror("Fopen error\n"); fwrite(buf,sizeof(buf),1,file); fread(buf,sizeof(bu"..., to_do=2) at fileops.c:493
    #4  0x08049d98 in _IO_new_file_overflow (f=0x80d5f70, ch=-1) at fileops.c:871
    #5  0x0804cb8a in _IO_flush_all_lockp (do_lock=0) at genops.c:850
    #6  0x0804d12b in _IO_cleanup () at genops.c:1011
    #7  0x08058c2a in exit ()
    #8  0x08052eca in __libc_start_main ()
    #9  0x08048181 in _start ()

    八、现场还原
    最终就是简单演示依稀缓冲导致的不更新问题。
    [root@Harry libiodbg]# cat Makefile 
    default:
        gcc -static -g  MyTest.c -o freadwrite.exe
    my:
        gcc -static -g MyTest.c mylibio.o -o freadwrite.exe
    [root@Harry libiodbg]# cat MyTest.c
    #include  <stdio.h>
    int main(int argc ,char * argv[])
    {
        char buf[1000];
        unsigned long lint[4];
        FILE* file = fopen(argv[1],"r+b");
        if(NULL == file)
            perror("Fopen error ");
        while(1)
    {
    int i ;
    fscanf(file,"%lu.%02lu %lu.%02lu",lint,lint+1,lint+2,lint+3);这个格式化字符串从内核中拷贝出来,但是故意少了最后的换行符。如果添加了最后的换行符,则这个内容会及时更新
    for( i =0 ; i < 4 ; i++)
    printf("%lu",lint[i]);
    printf(" ");    
    rewind(file);
        sleep(2);
    }    
        
    }

    [root@Harry libiodbg]# ./freadwrite.exe /proc/uptime 
    1596769415046030
    1596789415046222
    1596789415046222
    1596789415046222
    ^C
    [root@Harry libiodbg]# 

    现在分析一下这个流程的原因:
    1、有换行符
    当fscanf通过inchar来扫描一个格式化字符串的时候,在扫描到最后一个 的时候,会完成匹配。但是C库有一个不知道是不是标准的实现,就是如果格式化的最后是一个空白,那么它将会消耗掉待匹配字符串中所有的空白,由于这里的最后是一个换行符,所以它将会持续的从众读取空白。但是此时文件已经读完,所以此时返回EOF。
    glibc-2.7stdio-commonvfscanf.c
      /* The last thing we saw int the format string was a white space.
         Consume the last white spaces.  */
      if (skip_space)
        {
          do
        c = inchar ();
          while (ISSPACE (c));
          ungetc (c, s);
        }
      isspace()
                  checks  for  white-space  characters.   In  the  "C" and "POSIX"
                  locales, these are: space,  form-feed  ('f'),  newline  (' '),
                  carriage  return (' '), horizontal tab (' '), and vertical tab
                  ('v').

    即便如此,此时系统的read_ptr和read_end已经被清零到buf_begin(在inchar-->>__uflow--->>>_IO_new_file_underflow: fp->_IO_read_end = fp->_IO_buf_base;函数中完成),所以在rewind执行_IO_new_file_seekoff函数时
      _IO_off64_t start_offset = (fp->_offset
                      - (fp->_IO_read_end - fp->_IO_buf_base));此时fp->_offset为20,即文件的当前偏移量,read_end和buf_base相等,均为零。即当前缓冲数据为从文件内 20 开始,共零个缓冲。而新的偏移offset为零,所以不满足下面的判断,进而造成从文件中重新读取数据
          if (offset >= start_offset && offset < fp->_offset)
        {
          _IO_setg (fp, fp->_IO_buf_base,
                fp->_IO_buf_base + (offset - start_offset),
                fp->_IO_read_end);
          _IO_setp (fp, fp->_IO_buf_base, fp->_IO_buf_base);

          _IO_mask_flags (fp, 0, _IO_EOF_SEEN);
          goto resync;
        }
    调用连
    (gdb) bt
    #0  _IO_new_file_underflow (fp=0x80d8f70) at fileops.c:594
    #1  0x0804c257 in _IO_default_uflow (fp=0x80d8f70) at genops.c:441
    #2  0x0804c137 in __uflow (fp=0x80d8f70) at genops.c:395
    #3  0x0804eed7 in _IO_vfscanf_internal (s=0x80d8f70, 
        format=0x80b351d "%lu.%02lu %lu.%02lu ", 
        argptr=0xbffff218 "03623772776436237727770362377277<362377277", errp=0x0) at vfscanf.c:577
    #4  0x0806ced7 in __isoc99_fscanf ()
    #5  0x080482df in main (argc=2, argv=0xbffff6e4) at MyTest.c:12

    2、没有换行符
    此时文件将不会下溢出。所以在执行上面的判断时,read_end还是在缓冲区的结尾,所以新的偏移量0是在缓冲区的,从上面的goto resync跳过了文件读取操作,也就是文件内容没有更新。
    (gdb) bt
    #0  _IO_new_file_seekoff (fp=0x80d8f70, offset=0, dir=0, mode=3)
        at fileops.c:1041
    #1  0x0804df1b in _IO_seekoff_unlocked (fp=0x80d8f70, offset=0, dir=0, mode=3)
        at ioseekoff.c:71
    #2  0x080700ed in rewind ()
    #3  0x08048335 in main (argc=2, argv=0xbffff6e4) at MyTest.c:16

  • 相关阅读:
    Zlib编译
    最新Webstrom, Idea 2019.2.3 的激活
    图像理解与深度学习开篇
    C# 反射(Reflection)
    SpringMVC中使用forward和redirect进行转发和重定向以及重定向时如何传参详解
    Navicat for Oracle中如何使用外键
    【数据库】主键,外键,主表,从表,关联表,父表,子表
    onclick事件没有反应的五种可能情况
    button小手设置 css的cursor
    Spring MVC F5刷新问题
  • 原文地址:https://www.cnblogs.com/tsecer/p/10485997.html
Copyright © 2020-2023  润新知